From dc0d79635bcc826b94b817b6b7a6f317449122f9 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Fri, 16 Oct 2026 19:12:49 +0200 Subject: [PATCH 001/120] [ld2410][ld2412] Move CONF_QUERY_PARAMS to the shared constants (#20878) --- esphome/components/const/__init__.py | 1 + esphome/components/ld2410/button/__init__.py | 3 +-- esphome/components/ld2412/button/__init__.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 3a531978aaa..5d900bbd5b4 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -54,6 +54,7 @@ CONF_ON_STATE_CHANGE = "on_state_change" CONF_ON_STOP = "on_stop" CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" +CONF_QUERY_PARAMS = "query_params" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_RED_CHANNEL = "red_channel" diff --git a/esphome/components/ld2410/button/__init__.py b/esphome/components/ld2410/button/__init__.py index 3826455986d..21618babe79 100644 --- a/esphome/components/ld2410/button/__init__.py +++ b/esphome/components/ld2410/button/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import button +from esphome.components.const import CONF_QUERY_PARAMS import esphome.config_validation as cv from esphome.const import ( CONF_FACTORY_RESET, @@ -20,8 +21,6 @@ FactoryResetButton = ld2410_ns.class_("FactoryResetButton", button.Button) QueryButton = ld2410_ns.class_("QueryButton", button.Button) RestartButton = ld2410_ns.class_("RestartButton", button.Button) -CONF_QUERY_PARAMS = "query_params" - CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), cv.GenerateID(CONF_LD2410_ID): cv.use_id(LD2410Component), diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button/__init__.py index 9f5c868310b..c1737a494de 100644 --- a/esphome/components/ld2412/button/__init__.py +++ b/esphome/components/ld2412/button/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import button +from esphome.components.const import CONF_QUERY_PARAMS import esphome.config_validation as cv from esphome.const import ( CONF_FACTORY_RESET, @@ -23,8 +24,6 @@ RestartButton = LD2412_ns.class_("RestartButton", button.Button) StartDynamicBackgroundCorrectionButton = LD2412_ns.class_( "StartDynamicBackgroundCorrectionButton", button.Button ) - -CONF_QUERY_PARAMS = "query_params" CONF_START_DYNAMIC_BACKGROUND_CORRECTION = "start_dynamic_background_correction" CONFIG_SCHEMA = { From 1368ce43423c209f7c48b0942e059cdab156dee4 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Fri, 16 Oct 2026 19:31:58 +0200 Subject: [PATCH 002/120] [dfrobot_sen0609] Add restart, factory reset and query buttons (#20877) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../dfrobot_sen0609/button/__init__.py | 57 +++++++++++++ .../button/dfrobot_sen0609_button.h | 30 +++++++ .../dfrobot_sen0609/dfrobot_sen0609.cpp | 85 +++++++++++++++++-- .../dfrobot_sen0609/dfrobot_sen0609.h | 24 ++++++ .../components/dfrobot_sen0609/benchmark.yaml | 1 + tests/components/dfrobot_sen0609/common.yaml | 10 +++ .../dfrobot_sen0609_sequencer.cpp | 56 ++++++++++++ 7 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 esphome/components/dfrobot_sen0609/button/__init__.py create mode 100644 esphome/components/dfrobot_sen0609/button/dfrobot_sen0609_button.h diff --git a/esphome/components/dfrobot_sen0609/button/__init__.py b/esphome/components/dfrobot_sen0609/button/__init__.py new file mode 100644 index 00000000000..1db608e62c5 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/button/__init__.py @@ -0,0 +1,57 @@ +import esphome.codegen as cg +from esphome.components import button +from esphome.components.const import CONF_QUERY_PARAMS +import esphome.config_validation as cv +from esphome.const import ( + CONF_FACTORY_RESET, + CONF_ID, + CONF_RESTART, + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_DATABASE, + ICON_RESTART, + ICON_RESTART_ALERT, +) +from esphome.types import ConfigType + +from .. import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component, dfrobot_sen0609_ns + +DFRobotSEN0609Button = dfrobot_sen0609_ns.class_("DFRobotSEN0609Button", button.Button) +ButtonAction = dfrobot_sen0609_ns.enum("ButtonAction") + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_FACTORY_RESET): button.button_schema( + DFRobotSEN0609Button, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART_ALERT, + ), + cv.Optional(CONF_QUERY_PARAMS): button.button_schema( + DFRobotSEN0609Button, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_DATABASE, + ), + cv.Optional(CONF_RESTART): button.button_schema( + DFRobotSEN0609Button, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_RESTART, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons( + CONF_FACTORY_RESET, + hub.set_factory_reset_button, + ButtonAction.BUTTON_FACTORY_RESET, + ) + await buttons( + CONF_QUERY_PARAMS, hub.set_query_button, ButtonAction.BUTTON_QUERY_PARAMS + ) + await buttons(CONF_RESTART, hub.set_restart_button, ButtonAction.BUTTON_RESTART) diff --git a/esphome/components/dfrobot_sen0609/button/dfrobot_sen0609_button.h b/esphome/components/dfrobot_sen0609/button/dfrobot_sen0609_button.h new file mode 100644 index 00000000000..c7c03aa2b87 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/button/dfrobot_sen0609_button.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../dfrobot_sen0609.h" + +namespace esphome::dfrobot_sen0609 { + +class DFRobotSEN0609Button final : public button::Button, public Parented { + public: + explicit DFRobotSEN0609Button(ButtonAction action) : action_(action) {} + + protected: + void press_action() override { + switch (this->action_) { + case BUTTON_RESTART: + this->parent_->restart(); + break; + case BUTTON_FACTORY_RESET: + this->parent_->factory_reset(); + break; + case BUTTON_QUERY_PARAMS: + this->parent_->query_params(); + break; + } + } + + ButtonAction action_; +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp index 26538998a2f..5f42d05a827 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -21,6 +21,9 @@ static constexpr uint32_t COMMAND_GAP_MS = 100; 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 FACTORY_RESET_TIMEOUT_MS = 3000; +// resetSystem has no answer; this is how long the module takes to boot again +static constexpr uint32_t RESTART_MS = 1500; 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; @@ -113,6 +116,10 @@ static const LogString *operation_to_str(Operation operation) { return LOG_STR("stop"); case Operation::OPERATION_START: return LOG_STR("start"); + case Operation::OPERATION_RESTART: + return LOG_STR("restart"); + case Operation::OPERATION_FACTORY_RESET: + return LOG_STR("factory reset"); case Operation::OPERATION_WRITE: return LOG_STR("write"); case Operation::OPERATION_SAVE: @@ -161,6 +168,11 @@ void DFRobotSEN0609Component::dump_config() { #ifdef USE_SELECT LOG_SELECT(" ", "OutPinLevel", this->out_pin_level_select_); #endif +#ifdef USE_BUTTON + LOG_BUTTON(" ", "FactoryReset", this->factory_reset_button_); + LOG_BUTTON(" ", "Query", this->query_button_); + LOG_BUTTON(" ", "Restart", this->restart_button_); +#endif } void DFRobotSEN0609Component::setup() { @@ -436,6 +448,21 @@ void DFRobotSEN0609Component::request_write_(Param param) { this->hold_(COALESCE_MS); } +void DFRobotSEN0609Component::restart() { + this->restart_pending_ = true; + this->hold_(COMMAND_GAP_MS); +} + +void DFRobotSEN0609Component::factory_reset() { + this->factory_reset_pending_ = true; + this->hold_(COMMAND_GAP_MS); +} + +void DFRobotSEN0609Component::query_params() { + this->pending_reads_ |= this->wanted_reads_(); + this->hold_(COMMAND_GAP_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 @@ -511,20 +538,42 @@ 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 || this->operation_ == Operation::OPERATION_SAVE; - if (now - this->sent_ms_ < (slow ? LONG_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)) + uint32_t timeout = DEFAULT_TIMEOUT_MS; + switch (this->operation_) { + case Operation::OPERATION_RESTART: + timeout = RESTART_MS; + break; + case Operation::OPERATION_FACTORY_RESET: + timeout = FACTORY_RESET_TIMEOUT_MS; + break; + case Operation::OPERATION_STOP: + case Operation::OPERATION_SAVE: + timeout = LONG_TIMEOUT_MS; + break; + default: + break; + } + if (now - this->sent_ms_ < timeout) return; - ESP_LOGD(TAG, "No answer to '%s'", this->command_); - this->fail_operation_(); + if (this->operation_ == Operation::OPERATION_RESTART) { + this->finish_operation_(); + } else { + 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->save_pending_ || this->pending_writes_ != 0 || this->pending_reads_ != 0; + const bool work_pending = + this->factory_reset_pending_ || this->save_pending_ || this->pending_writes_ != 0 || this->pending_reads_ != 0; Operation operation; Param param = PARAM_COUNT; - if (!work_pending) { + if (this->restart_pending_) { + // The reboot drops a write the module took but did not save; writes not sent yet are kept for after it + operation = this->stopped_ && this->save_pending_ ? Operation::OPERATION_SAVE : Operation::OPERATION_RESTART; + } else 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 @@ -539,6 +588,8 @@ void DFRobotSEN0609Component::run_sequencer_() { operation = Operation::OPERATION_START; } else if (!this->stopped_) { operation = Operation::OPERATION_STOP; + } else if (this->factory_reset_pending_) { + operation = Operation::OPERATION_FACTORY_RESET; } else if (this->pending_writes_ != 0) { operation = Operation::OPERATION_WRITE; param = static_cast(__builtin_ctz(this->pending_writes_)); @@ -611,6 +662,12 @@ 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_RESTART: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("resetSystem"), len); + return true; + case Operation::OPERATION_FACTORY_RESET: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("resetCfg"), len); + return true; case Operation::OPERATION_SAVE: ESPHOME_strncpy_P(buf, ESPHOME_PSTR("saveConfig"), len); return true; @@ -785,6 +842,16 @@ void DFRobotSEN0609Component::finish_operation_() { this->status_clear_warning(); this->sequence_failed_ = false; break; + case Operation::OPERATION_RESTART: + // The module starts sensing on its own after a reboot + this->restart_pending_ = false; + this->stopped_ = false; + this->queue_configuration_(); + break; + case Operation::OPERATION_FACTORY_RESET: + this->factory_reset_pending_ = false; + this->queue_configuration_(); + break; case Operation::OPERATION_WRITE: this->deferred_writes_ &= ~param_bit(this->param_); if (PERSISTENT_PARAMS & param_bit(this->param_)) @@ -846,6 +913,12 @@ 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; switch (operation) { + case Operation::OPERATION_RESTART: + this->restart_pending_ = false; + break; + case Operation::OPERATION_FACTORY_RESET: + this->factory_reset_pending_ = false; + break; case Operation::OPERATION_WRITE: #ifdef USE_SWITCH if (is_led(this->param_)) { diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h index 719aa6f5fe0..ca70fa14943 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h @@ -21,6 +21,9 @@ #ifdef USE_SELECT #include "esphome/components/select/select.h" #endif +#ifdef USE_BUTTON +#include "esphome/components/button/button.h" +#endif #include "esphome/components/uart/uart.h" #include @@ -58,6 +61,12 @@ enum SwitchSetting : uint8_t { SWITCH_COUNT, }; +enum ButtonAction : uint8_t { + BUTTON_RESTART, + BUTTON_FACTORY_RESET, + BUTTON_QUERY_PARAMS, +}; + /// Module parameters; a write or read of each is one command. Pending work runs in this order. enum Param : uint8_t { PARAM_RUN_APP, @@ -81,6 +90,8 @@ enum class Operation : uint8_t { OPERATION_NONE, OPERATION_STOP, OPERATION_START, + OPERATION_RESTART, + OPERATION_FACTORY_RESET, OPERATION_WRITE, OPERATION_SAVE, OPERATION_READ, @@ -102,6 +113,11 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice #ifdef USE_SELECT SUB_SELECT(out_pin_level) #endif +#ifdef USE_BUTTON + SUB_BUTTON(factory_reset) + SUB_BUTTON(query) + SUB_BUTTON(restart) +#endif public: explicit DFRobotSEN0609Component(RunMode mode); @@ -121,6 +137,12 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice #ifdef USE_SELECT void control_out_pin_level(size_t index); #endif + /// Reboot the module, then apply the configuration and read the settings again. + void restart(); + /// Restore the module's factory settings, then apply the configuration and read the settings again. + void factory_reset(); + /// Read all settings shown by entities from the module. + void query_params(); protected: void readline_(uint8_t c); @@ -207,6 +229,8 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice bool stopped_{false}; bool save_pending_{false}; bool deferred_save_{false}; + bool restart_pending_{false}; + bool factory_reset_pending_{false}; bool sequence_failed_{false}; }; diff --git a/tests/components/dfrobot_sen0609/benchmark.yaml b/tests/components/dfrobot_sen0609/benchmark.yaml index 2a35b04794d..3c20e117389 100644 --- a/tests/components/dfrobot_sen0609/benchmark.yaml +++ b/tests/components/dfrobot_sen0609/benchmark.yaml @@ -6,3 +6,4 @@ text_sensor: number: switch: select: +button: diff --git a/tests/components/dfrobot_sen0609/common.yaml b/tests/components/dfrobot_sen0609/common.yaml index aca15b906fc..bb7ad5d622f 100644 --- a/tests/components/dfrobot_sen0609/common.yaml +++ b/tests/components/dfrobot_sen0609/common.yaml @@ -49,3 +49,13 @@ select: dfrobot_sen0609_id: sen0609_presence out_pin_level: name: Out Pin Level + +button: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + factory_reset: + name: Factory Reset + query_params: + name: Query Params + restart: + name: Restart diff --git a/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp index aa39c4f6a4d..528c788490a 100644 --- a/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp +++ b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp @@ -340,6 +340,18 @@ TEST_F(SEN0609SequencerTest, FailedWriteShowsModuleValueAgain) { EXPECT_TRUE(this->hub_.status_has_warning()); } +TEST_F(SEN0609SequencerTest, QueryKeepsGapAfterAnswer) { + this->boot_(); + this->hub_.query_params(); + this->run_(); + EXPECT_EQ(this->uart_.tx, ""); + this->expect_and_answer_("sensorStop"); + 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_("sensorStart"); +} + TEST_F(SEN0609SequencerTest, AlreadyStoppedCountsAsStopped) { this->hub_.setup(); ASSERT_EQ(this->next_command_(), "sensorStop"); @@ -480,6 +492,50 @@ TEST_F(SEN0609SequencerTest, TriggerRangeOutsideRangeIsRefused) { this->expect_and_answer_("setTrigRange 8.00"); } +TEST_F(SEN0609SequencerTest, RestartReappliesConfiguration) { + this->boot_(); + this->hub_.restart(); + ASSERT_EQ(this->next_command_(), "resetSystem"); + // resetSystem has no answer; the hub waits for the reboot + EXPECT_EQ(this->next_command_(1000), ""); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); +} + +TEST_F(SEN0609SequencerTest, RestartDuringSequenceSavesWrittenChange) { + 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_HOLD_SENSITIVITY, 3.0f); + this->hub_.restart(); + this->answer_("setRange 1.00 12.00"); + this->expect_and_answer_("saveConfig"); + ASSERT_EQ(this->next_command_(), "resetSystem"); + EXPECT_EQ(this->next_command_(1000), ""); + // The change not sent before the reboot is written after it + 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_("setSensitivity 3 5"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("getRange", "Response 1.0 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 3 5\r\n"); + EXPECT_FLOAT_EQ(this->min_range_.state, 1.0f); + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 3.0f); +} + +TEST_F(SEN0609SequencerTest, FactoryResetRereadsSettings) { + this->boot_(); + this->hub_.factory_reset(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("resetCfg"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 25.0\r\n"); + EXPECT_FLOAT_EQ(this->max_range_.state, 25.0f); +} + class SEN0609SettingsTest : public SEN0609Test { protected: void SetUp() override { From 5ab92ed36319eb74b14955b74abcaf392c751893 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:15:17 +0200 Subject: [PATCH 003/120] [ld2450] Handle footer bytes in zone query responses (#20852) Co-authored-by: J. Nick Koston --- esphome/components/ld2450/ld2450.cpp | 35 ++++ tests/components/ld2450/common.h | 1 + tests/components/ld2450/ld2450_zone_ack.cpp | 180 ++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 tests/components/ld2450/ld2450_zone_ack.cpp diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index d2a0e04d122..cdf542ee8b8 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -10,6 +10,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include #include #include #include @@ -131,6 +132,9 @@ static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0x04, 0x03, 0x0 // Data Header & Footer static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xAA, 0xFF, 0x03, 0x00}; static constexpr uint8_t DATA_FRAME_FOOTER[2] = {0x55, 0xCC}; +// Successful zone query ACK: header (4) + length (2) + command/status (2) + result (2) + zone type (2) + +// 3 zones * 8 + footer (4). Zone coordinates may contain the command footer bytes. +static constexpr uint8_t ZONE_ACK_FRAME_SIZE = 40; // MAC address the module uses when Bluetooth is disabled static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01}; @@ -173,6 +177,20 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0; } +// Drop the frame at the start of the buffer, keeping bytes from the next frame header (or a partial +// header at the end). Returns the new buffer length. +static inline uint8_t drop_to_next_header(uint8_t *buffer, uint8_t len) { + for (uint8_t start = 1; start < len; start++) { + const size_t n = std::min(HEADER_FOOTER_SIZE, len - start); + if (std::memcmp(&buffer[start], CMD_FRAME_HEADER, n) == 0 || + std::memcmp(&buffer[start], DATA_FRAME_HEADER, n) == 0) { + std::memmove(buffer, &buffer[start], len - start); + return len - start; + } + } + return 0; +} + void LD2450Component::setup() { #ifdef USE_NUMBER if (this->presence_timeout_number_ != nullptr) { @@ -756,6 +774,9 @@ bool LD2450Component::handle_ack_data_() { break; case CMD_QUERY_ZONE: + if (this->buffer_pos_ < ZONE_ACK_FRAME_SIZE) { + return false; // command footer bytes inside the coordinates; keep reading + } ESP_LOGV(TAG, "Query zone conf"); this->zone_type_ = this->buffer_data_[10]; this->publish_zone_type(); @@ -824,6 +845,20 @@ void LD2450Component::readline_(int readch) { } else { ESP_LOGV(TAG, "Ack Data incomplete"); } + } else if (this->buffer_pos_ == ZONE_ACK_FRAME_SIZE && this->buffer_data_[COMMAND] == CMD_QUERY_ZONE && + ld2450::validate_header_footer(CMD_FRAME_HEADER, this->buffer_data_)) { + ESP_LOGW(TAG, "Truncated zone ACK"); + // Replay the retained bytes so frames already complete inside them are dispatched. + // At most ZONE_ACK_FRAME_SIZE - 1 bytes are replayed from position 0, so neither this + // branch nor the overflow path can be re-entered during the replay. + uint8_t pending[ZONE_ACK_FRAME_SIZE]; + const uint8_t pending_len = ld2450::drop_to_next_header(this->buffer_data_, this->buffer_pos_); + std::memcpy(pending, this->buffer_data_, pending_len); + this->buffer_pos_ = 0; + this->buffer_data_[0] = 0; + for (uint8_t i = 0; i < pending_len; i++) { + this->readline_(pending[i]); // NOLINT(misc-no-recursion) + } } } diff --git a/tests/components/ld2450/common.h b/tests/components/ld2450/common.h index de912ddcbca..95b0a65de8e 100644 --- a/tests/components/ld2450/common.h +++ b/tests/components/ld2450/common.h @@ -29,6 +29,7 @@ class TestableLD2450 : public LD2450Component { using LD2450Component::buffer_data_; using LD2450Component::buffer_pos_; using LD2450Component::readline_; + using LD2450Component::zone_config_; void feed(const std::vector &data) { for (uint8_t byte : data) { diff --git a/tests/components/ld2450/ld2450_zone_ack.cpp b/tests/components/ld2450/ld2450_zone_ack.cpp new file mode 100644 index 00000000000..5f6674709d9 --- /dev/null +++ b/tests/components/ld2450/ld2450_zone_ack.cpp @@ -0,0 +1,180 @@ +#include +#include "common.h" + +namespace esphome::ld2450::testing { + +namespace { +// Successful zone query ACK (40 bytes), zone type 0x01, with the given coordinate bytes; missing ones are zero. +std::vector make_zone_ack(std::vector coords) { + coords.resize(24, 0); + std::vector frame = {0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00}; + frame.insert(frame.end(), coords.begin(), coords.end()); + frame.insert(frame.end(), {0x04, 0x03, 0x02, 0x01}); + return frame; +} + +// Zone ACK with zone 0 at (100, 200) to (1500, 3000) and the other zones cleared. +std::vector make_complete_zone_ack() { + return make_zone_ack({0x64, 0x00, 0xC8, 0x00, 0xDC, 0x05, 0xB8, 0x0B}); +} + +// The first 16 bytes of a zone ACK whose zone 0 starts at (772, 258): its last four bytes look like a footer. +std::vector make_truncated_zone_ack() { + return {0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01}; +} + +// Successful disable-configuration ACK: a four-byte body. +std::vector make_disable_conf_ack() { + auto frame = make_ack_frame(); + frame[6] = 0xFE; + return frame; +} + +void expect_zone(const TestableLD2450 &ld2450, size_t index, int x1, int y1, int x2, int y2) { + EXPECT_EQ(ld2450.zone_config_[index].x1, x1) << "zone " << index; + EXPECT_EQ(ld2450.zone_config_[index].y1, y1) << "zone " << index; + EXPECT_EQ(ld2450.zone_config_[index].x2, x2) << "zone " << index; + EXPECT_EQ(ld2450.zone_config_[index].y2, y2) << "zone " << index; +} +} // namespace + +class LD2450ZoneAckTest : public ::testing::Test { + protected: + void SetUp() override { this->ld2450_.set_uart_parent(&this->mock_uart_); } + void set_all_zones_(int16_t x1, int16_t y1, int16_t x2, int16_t y2) { + for (auto &zone : this->ld2450_.zone_config_) { + zone = {x1, y1, x2, y2}; + } + } + void expect_all_zones_(int x1, int y1, int x2, int y2) { + for (size_t i = 0; i < MAX_ZONES; i++) { + expect_zone(this->ld2450_, i, x1, y1, x2, y2); + } + } + + MockUARTComponent mock_uart_; + TestableLD2450 ld2450_; +}; + +TEST_F(LD2450ZoneAckTest, CmdFooterBytesInZonePayloadDoNotTruncateAck) { + // x1=772 and y1=258 are the command footer bytes, followed by x2=1500 and y2=3000. + static constexpr uint8_t COORDINATES[] = {0x04, 0x03, 0x02, 0x01, 0xDC, 0x05, 0xB8, 0x0B}; + for (uint8_t zone = 0; zone < MAX_ZONES; zone++) { + std::vector coords(24, 0); + std::memcpy(coords.data() + zone * 8, COORDINATES, sizeof(COORDINATES)); + const auto frame = make_zone_ack(coords); + std::memset(this->ld2450_.buffer_data_, 0xEE, MAX_LINE_LENGTH); + + const size_t split = 12 + zone * 8 + 4; + this->ld2450_.feed(std::vector(frame.begin(), frame.begin() + split)); + EXPECT_EQ(this->ld2450_.buffer_pos_, split) << "zone " << static_cast(zone); + this->ld2450_.feed(std::vector(frame.begin() + split, frame.end())); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + for (uint8_t i = 0; i < MAX_ZONES; i++) { + if (i == zone) { + expect_zone(this->ld2450_, i, 772, 258, 1500, 3000); + } else { + expect_zone(this->ld2450_, i, 0, 0, 0, 0); + } + } + } +} + +TEST_F(LD2450ZoneAckTest, CompleteZoneAckWithoutEmbeddedFooter) { + this->set_all_zones_(100, 200, 300, 400); + this->ld2450_.feed(make_zone_ack({})); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + this->expect_all_zones_(0, 0, 0, 0); +} + +TEST_F(LD2450ZoneAckTest, FailedZoneQueryAckDoesNotWaitForCoordinates) { + this->set_all_zones_(100, 200, 300, 400); + auto frame = make_ack_frame(); + frame[6] = 0xC1; + frame[8] = 0x01; + this->ld2450_.feed(frame); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + this->expect_all_zones_(100, 200, 300, 400); +} + +TEST_F(LD2450ZoneAckTest, ValidZoneAckWithEmbeddedFooterAndHeaderIsNotResynced) { + const auto frame = make_zone_ack({0x04, 0x03, 0x02, 0x01, 0xFD, 0xFC, 0xFB, 0xFA, // 772, 258, -771, -1285 + 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00, // 30, 449, 0, 1 + 0x64, 0x00, 0xC8, 0x00, 0xDC, 0x05, 0xB8, 0x0B}); // 100, 200, 1500, 3000 + // The first 36 bytes equal a truncated ACK followed by a complete one; only the footer at offset 36 differs. + auto stream = make_truncated_zone_ack(); + const auto complete = make_complete_zone_ack(); + stream.insert(stream.end(), complete.begin(), complete.end()); + ASSERT_TRUE(std::equal(frame.begin(), frame.begin() + 36, stream.begin())); + + this->ld2450_.feed(frame); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 772, 258, -771, -1285); + expect_zone(this->ld2450_, 1, 30, 449, 0, 1); + expect_zone(this->ld2450_, 2, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, TruncatedZoneAckThenCompleteZoneAckRecovers) { + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_complete_zone_ack()); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, TruncatedZoneAckThenPeriodicFrameIsParsed) { + this->ld2450_.zone_config_[0] = {100, 200, 300, 400}; + int frames = 0; + this->ld2450_.add_on_data_callback([&frames]() { frames++; }); + + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_periodic_frame()); + + // The truncated ACK is not decoded; the periodic frame is recovered through the data header. + EXPECT_EQ(frames, 1); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 300, 400); +} + +TEST_F(LD2450ZoneAckTest, TruncatedZoneAckThenShortAckThenCompleteZoneAckRecovers) { + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_disable_conf_ack()); + this->ld2450_.feed(make_complete_zone_ack()); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, ShortAckInRetainedSuffixIsDispatchedOnResync) { + const auto complete = make_complete_zone_ack(); + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_disable_conf_ack()); + // Ten bytes of the next zone ACK bring the buffer to 40 bytes without a footer. + this->ld2450_.feed(std::vector(complete.begin(), complete.begin() + 10)); + // The short ACK was replayed and consumed; only the new zone ACK prefix remains. + EXPECT_EQ(this->ld2450_.buffer_pos_, 10); + EXPECT_EQ(this->ld2450_.buffer_data_[6], 0xC1); + this->ld2450_.feed(std::vector(complete.begin() + 10, complete.end())); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, MultipleCompleteFramesInRetainedSuffixAreDrained) { + // Truncated zone ACK without any footer, followed by two complete short ACKs (11 + 14 + 14 = 39). + const auto truncated = make_truncated_zone_ack(); + std::vector stream(truncated.begin(), truncated.begin() + 11); + const auto disable_ack = make_disable_conf_ack(); + stream.insert(stream.end(), disable_ack.begin(), disable_ack.end()); + const auto enable_ack = make_ack_frame(); + stream.insert(stream.end(), enable_ack.begin(), enable_ack.end()); + this->ld2450_.feed(stream); + EXPECT_EQ(this->ld2450_.buffer_pos_, 39); // the C1 header still claims the buffer + // The 40th byte triggers the resync; both buffered ACKs are drained, leaving only this byte. + const auto complete = make_complete_zone_ack(); + this->ld2450_.feed({complete[0]}); + EXPECT_EQ(this->ld2450_.buffer_pos_, 1); + EXPECT_EQ(this->ld2450_.buffer_data_[0], 0xFD); + this->ld2450_.feed(std::vector(complete.begin() + 1, complete.end())); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +} // namespace esphome::ld2450::testing From cd6a34401ba52255774dfa8e4eb25422f083f4a1 Mon Sep 17 00:00:00 2001 From: sebo-b <51707418+sebo-b@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:22:46 +0200 Subject: [PATCH 004/120] [web_server] Do not match internal entities by URL unless include_internal is set (#20883) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/web_server/web_server.cpp | 5 +++++ esphome/components/web_server/web_server.h | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index b0da010561e..04cd0ff43d2 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -203,6 +203,10 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const { EntityMatchResult result{false, this->method.empty()}; + // Internal entities are unreachable by URL unless include_internal is set, as with the native API and MQTT. + if (!this->include_internal && entity->is_internal()) + return result; + #ifdef USE_DEVICES Device *entity_device = entity->get_device(); bool url_has_device = !this->device_name.empty(); @@ -2560,6 +2564,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { // Parse URL for component routing // Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action) UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST); + match.include_internal = this->include_internal_; // Route to appropriate handler based on domain // NOLINTNEXTLINE(readability-simplify-boolean-expr) diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index d3bd1ae0da6..f8ef12f9bff 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -66,7 +66,8 @@ struct UrlMatch { #ifdef USE_DEVICES StringRef device_name; ///< Device name within URL, empty for main device #endif - bool valid{false}; ///< Whether this match is valid + bool valid{false}; ///< Whether this match is valid + bool include_internal{false}; ///< Whether internal entities may match, the web server's include_internal // Helper methods for string comparisons bool domain_equals(const char *str) const { return this->domain == str; } @@ -78,7 +79,7 @@ struct UrlMatch { bool method_equals(const __FlashStringHelper *str) const { return this->method == str; } #endif - /// Match entity by name + /// Match entity by name; internal entities match only if include_internal is set /// Returns EntityMatchResult with match status and whether action segment is empty EntityMatchResult match_entity(EntityBase *entity) const; }; From ab0c32c2aadea7c7a091ffd0eedfe04b368a2f80 Mon Sep 17 00:00:00 2001 From: TheLexus Date: Fri, 16 Oct 2026 20:50:13 +0200 Subject: [PATCH 005/120] [haier_water_heater] Initial water-heater implementation tested with Haier VH3W (#20765) 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 + .../components/haier_water_heater/__init__.py | 4 + .../haier_water_heater/hon_parser.h | 262 +++++++++++ .../haier_water_heater/hon_water_heater.cpp | 424 ++++++++++++++++++ .../haier_water_heater/hon_water_heater.h | 96 ++++ .../haier_water_heater/water_heater.py | 110 +++++ .../haier_water_heater/__init__.py | 0 .../haier_water_heater/test_water_heater.py | 115 +++++ .../components/haier_water_heater/__init__.py | 12 + .../components/haier_water_heater/common.yaml | 23 + .../haier_water_heater/test.esp32-idf.yaml | 3 + .../haier_water_heater/test.esp8266-ard.yaml | 3 + .../haier_water_heater/test.rp2040-ard.yaml | 3 + .../haier_water_heater/test_hon_protocol.cpp | 219 +++++++++ .../water_heater/test_hon_controller.cpp | 292 ++++++++++++ 15 files changed, 1567 insertions(+) create mode 100644 esphome/components/haier_water_heater/__init__.py create mode 100644 esphome/components/haier_water_heater/hon_parser.h create mode 100644 esphome/components/haier_water_heater/hon_water_heater.cpp create mode 100644 esphome/components/haier_water_heater/hon_water_heater.h create mode 100644 esphome/components/haier_water_heater/water_heater.py create mode 100644 tests/component_tests/haier_water_heater/__init__.py create mode 100644 tests/component_tests/haier_water_heater/test_water_heater.py create mode 100644 tests/components/haier_water_heater/__init__.py create mode 100644 tests/components/haier_water_heater/common.yaml create mode 100644 tests/components/haier_water_heater/test.esp32-idf.yaml create mode 100644 tests/components/haier_water_heater/test.esp8266-ard.yaml create mode 100644 tests/components/haier_water_heater/test.rp2040-ard.yaml create mode 100644 tests/components/haier_water_heater/test_hon_protocol.cpp create mode 100644 tests/components/haier_water_heater/water_heater/test_hon_controller.cpp diff --git a/CODEOWNERS b/CODEOWNERS index f2f24f1106f..38d2da4d870 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -252,6 +252,7 @@ esphome/components/haier/text_sensor/* @paveldn esphome/components/haier_tundra/* @fauxpark esphome/components/haier_tundra/button/* @fauxpark esphome/components/haier_tundra/switch/* @fauxpark +esphome/components/haier_water_heater/* @TheLexus esphome/components/havells_solar/* @sourabhjaiswal esphome/components/hbridge/fan/* @WeekendWarrior esphome/components/hbridge/light/* @DotNetDann diff --git a/esphome/components/haier_water_heater/__init__.py b/esphome/components/haier_water_heater/__init__.py new file mode 100644 index 00000000000..7ecd409dd80 --- /dev/null +++ b/esphome/components/haier_water_heater/__init__.py @@ -0,0 +1,4 @@ +"""Haier water heater component.""" + +DOMAIN = "haier_water_heater" +CODEOWNERS = ["@TheLexus"] diff --git a/esphome/components/haier_water_heater/hon_parser.h b/esphome/components/haier_water_heater/hon_parser.h new file mode 100644 index 00000000000..1f7e22abc76 --- /dev/null +++ b/esphome/components/haier_water_heater/hon_parser.h @@ -0,0 +1,262 @@ +#pragma once + +#include +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::haier_water_heater::water_heater_protocol { + +// A logical frame, after the FF FF separator and the FF 55 escapes are removed: +// [length][flags][5 reserved bytes][type][payload...][checksum][crc high][crc low] +// The length counts every byte before the checksum; the CRC is only present when its flag is set. +static constexpr uint8_t FRAME_FLAGS_OFFSET = 1; +static constexpr uint8_t FRAME_TYPE_OFFSET = 7; +static constexpr uint8_t FRAME_PAYLOAD_OFFSET = 8; +static constexpr uint8_t FRAME_FLAG_CRC = 0x40; +static constexpr uint8_t FRAME_FLAG_ENCRYPTED = 0x80; +static constexpr uint8_t MIN_FRAME_LENGTH = 8; +static constexpr uint8_t MAX_FRAME_LENGTH = 0xF1; +// The longest frame that is decoded is the 61-byte status report. Longer frames are still checked +// in full, but only their head is kept. +static constexpr size_t MAX_STORED_FRAME = 64; +static constexpr size_t MAX_PAYLOAD = 4; +static constexpr size_t MAX_WIRE_FRAME = 28; // 2 separators, up to 12 escaped bytes, an escaped checksum + +enum class FrameType : uint8_t { + FRAME_TYPE_CONTROL = 0x01, + FRAME_TYPE_STATUS = 0x02, + FRAME_TYPE_INVALID = 0x03, // the appliance rejected the last request + FRAME_TYPE_CONFIRM = 0x05, + FRAME_TYPE_REPORT = 0x06, // an unsolicited status, which expects a CONFIRM back + FRAME_TYPE_GET_VERSION = 0x61, + FRAME_TYPE_VERSION = 0x62, + FRAME_TYPE_GET_DEVICE_ID = 0x70, + FRAME_TYPE_DEVICE_ID = 0x71, + FRAME_TYPE_GET_NETWORK_STATUS = 0xF0, + FRAME_TYPE_NETWORK_STATUS = 0xF7, +}; + +// Bounded receive parser. The checksum and CRC are kept running while the bytes arrive, so a frame +// longer than the stored head is validated without being stored. +class FrameParser { + public: + void reset() { + this->size_ = 0; + this->separators_ = 0; + this->escape_ = false; + this->complete_ = false; + this->checksum_ = 0; + this->crc_ = 0; + } + bool complete() const { return this->complete_; } + /// The stored head of the frame, at most MAX_STORED_FRAME bytes; valid until the next feed or reset. + const uint8_t *data() const { return this->buffer_.data(); } + /// Logical bytes received, including the checksum and CRC; may exceed MAX_STORED_FRAME. + size_t size() const { return this->size_; } + size_t stored_size() const { return std::min(this->size_, MAX_STORED_FRAME); } + + /// Returns true once a valid frame is complete. A complete but invalid frame leaves complete() set. + bool feed(uint8_t value) { + if (this->complete_) { + this->reset(); + } + if (this->separators_ < 2) { + this->separators_ = value == 0xFF ? this->separators_ + 1 : 0; + return false; + } + if (this->escape_) { + this->escape_ = false; + if (value == 0xFF) { + // A new unescaped separator pair: start over without losing synchronisation. + this->size_ = 0; + this->checksum_ = 0; + this->crc_ = 0; + return false; + } + if (value != 0x55) { + this->reset(); + return false; + } + value = 0xFF; + } else if (value == 0xFF) { + this->escape_ = true; + return false; + } + if (this->size_ == 0 && (value < MIN_FRAME_LENGTH || value > MAX_FRAME_LENGTH)) { + this->reset(); + return false; + } + const uint8_t index = this->size_++; + if (index < MAX_STORED_FRAME) { + this->buffer_[index] = value; + } + const uint8_t length = this->buffer_[0]; + if (index < length) { + this->checksum_ += value; + if (value == 0xFF) { + this->checksum_ += 0x55; // The escape byte counts towards the checksum. + } + this->crc_ = crc16(&value, 1, this->crc_, 0xA001, false, false); + } else if (index == length) { + this->received_checksum_ = value; + } else if (index == length + 1) { + this->received_crc_ = static_cast(value) << 8; + } else { + this->received_crc_ |= value; + } + if (this->size_ < 2) { + return false; + } + const bool crc = (this->buffer_[FRAME_FLAGS_OFFSET] & FRAME_FLAG_CRC) != 0; + if (this->size_ != length + 1 + (crc ? 2 : 0)) { + return false; + } + this->complete_ = true; + if ((this->buffer_[FRAME_FLAGS_OFFSET] & ~FRAME_FLAG_CRC) != 0 || this->checksum_ != this->received_checksum_) { + return false; + } + return !crc || this->crc_ == this->received_crc_; + } + + protected: + std::array buffer_{}; + uint16_t crc_{0}; + uint16_t received_crc_{0}; + uint8_t size_{0}; + uint8_t separators_{0}; + uint8_t checksum_{0}; + uint8_t received_checksum_{0}; + bool escape_{false}; + bool complete_{false}; +}; + +/// Writes a clear-text frame with up to MAX_PAYLOAD bytes of payload into wire, which must hold +/// MAX_WIRE_FRAME bytes. Returns the number of bytes written, or 0 when the payload is too long. +inline size_t encode_frame(FrameType type, const uint8_t *payload, size_t size, uint8_t *wire) { + if (size > MAX_PAYLOAD) { + return 0; + } + uint8_t core[FRAME_PAYLOAD_OFFSET + MAX_PAYLOAD] = {static_cast(FRAME_PAYLOAD_OFFSET + size)}; + core[FRAME_TYPE_OFFSET] = static_cast(type); + for (size_t i = 0; i < size; i++) { + core[FRAME_PAYLOAD_OFFSET + i] = payload[i]; + } + size_t count = 0; + wire[count++] = 0xFF; + wire[count++] = 0xFF; + uint8_t checksum = 0; + for (size_t i = 0; i < FRAME_PAYLOAD_OFFSET + size; i++) { + wire[count++] = core[i]; + checksum += core[i]; + if (core[i] == 0xFF) { + wire[count++] = 0x55; + checksum += 0x55; + } + } + wire[count++] = checksum; + if (checksum == 0xFF) { + wire[count++] = 0x55; + } + return count; +} + +enum class HonMode : uint8_t { HON_MODE_UNKNOWN, HON_MODE_NORMAL, HON_MODE_ECO, HON_MODE_BPS }; + +// The appliance reports and takes target temperatures with this offset. +static constexpr uint8_t TARGET_TEMPERATURE_OFFSET = 30; +static constexpr uint8_t MAX_REPORTED_TEMPERATURE = 100; + +struct Status { + uint8_t current_temperature; + uint8_t target_temperature; + uint8_t secondary_temperature; + uint8_t estimated_heating_time; + uint8_t available_water; // 0 to 3 segments + bool powered; + bool heating; + HonMode mode; +}; + +// The mode is spread over three flag bytes; exactly one of the three indications is set in a known mode. +inline HonMode decode_mode(const uint8_t *payload) { + const bool normal = (payload[38] & 0x10) && (payload[39] & 0x10); + const bool eco = (payload[41] & 0x04) != 0; + const bool bps = (payload[40] & 0x04) != 0; + if (unsigned(normal) + unsigned(eco) + unsigned(bps) != 1) { + return HonMode::HON_MODE_UNKNOWN; + } + if (normal) { + return HonMode::HON_MODE_NORMAL; + } + return eco ? HonMode::HON_MODE_ECO : HonMode::HON_MODE_BPS; +} + +static constexpr uint8_t STATUS_FRAME_LENGTH = 60; + +/// Decodes a status report or a polled status; false for any other frame or an implausible one. +/// Offsets are into the payload, which starts with 6D 01, as captured from a VH3W. The secondary +/// temperature's physical location and the heating time's unit (assumed minutes) are unconfirmed. +/// Available water is 0 to 3 segments of usable hot water, not the tank fill level. +inline bool decode_status(const uint8_t *frame, size_t size, Status &status) { + if (size <= STATUS_FRAME_LENGTH || frame[0] != STATUS_FRAME_LENGTH) { + return false; + } + const auto type = static_cast(frame[FRAME_TYPE_OFFSET]); + if (type != FrameType::FRAME_TYPE_STATUS && type != FrameType::FRAME_TYPE_REPORT) { + return false; + } + const uint8_t *payload = frame + FRAME_PAYLOAD_OFFSET; + if (payload[0] != 0x6D || payload[1] != 0x01) { + return false; + } + status.current_temperature = payload[2]; + status.target_temperature = payload[3] + TARGET_TEMPERATURE_OFFSET; + status.estimated_heating_time = payload[7]; + status.secondary_temperature = payload[15]; + status.powered = (payload[39] & 0x01) != 0; + status.heating = (payload[41] & 0x01) != 0; + status.mode = decode_mode(payload); + status.available_water = payload[45]; + return payload[2] <= MAX_REPORTED_TEMPERATURE && payload[3] <= MAX_REPORTED_TEMPERATURE - TARGET_TEMPERATURE_OFFSET; +} + +// A control write: one parameter of the 0x5D group and its value. +enum class Parameter : uint8_t { + PARAMETER_POWER = 0x01, + PARAMETER_TARGET_TEMPERATURE = 0x02, // value is the temperature minus TARGET_TEMPERATURE_OFFSET + PARAMETER_ECO = 0x05, // 1 selects ECO, 0 selects Normal + PARAMETER_BPS = 0x17, +}; +static constexpr uint8_t CONTROL_GROUP = 0x5D; + +struct Command { + Parameter parameter; + uint8_t value; +}; + +/// Fills the four-byte control payload for a command. +inline void encode_control(const Command &command, uint8_t *payload) { + payload[0] = CONTROL_GROUP; + payload[1] = static_cast(command.parameter); + payload[2] = 0x00; + payload[3] = command.value; +} + +/// True when a status shows that the command took effect. +inline bool command_confirmed(const Command &command, const Status &status) { + switch (command.parameter) { + case Parameter::PARAMETER_POWER: + return status.powered == (command.value != 0); + case Parameter::PARAMETER_TARGET_TEMPERATURE: + return status.target_temperature == command.value + TARGET_TEMPERATURE_OFFSET; + case Parameter::PARAMETER_ECO: + return status.mode == (command.value != 0 ? HonMode::HON_MODE_ECO : HonMode::HON_MODE_NORMAL); + case Parameter::PARAMETER_BPS: + return status.mode == HonMode::HON_MODE_BPS; + } + return false; +} + +} // namespace esphome::haier_water_heater::water_heater_protocol diff --git a/esphome/components/haier_water_heater/hon_water_heater.cpp b/esphome/components/haier_water_heater/hon_water_heater.cpp new file mode 100644 index 00000000000..3f190de5b83 --- /dev/null +++ b/esphome/components/haier_water_heater/hon_water_heater.cpp @@ -0,0 +1,424 @@ +#include "hon_water_heater.h" + +#include + +#include "esphome/core/application.h" +#ifdef USE_NETWORK +#include "esphome/components/network/util.h" +#endif +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::haier_water_heater { + +ESPHOME_LOG_TAG(TAG, "haier_water_heater"); +using namespace water_heater_protocol; + +// Indexed by HonMode, with Unknown as the fallback for out-of-range values. +PROGMEM_STRING_TABLE(HonModeLabels, "Unknown", "Normal", "ECO", "BPS"); + +void HonWaterHeater::setup() { + this->status_set_warning(LOG_STR("Waiting for a valid appliance status frame")); + if (this->communication_sensor_ != nullptr) + this->communication_sensor_->publish_state(false); + this->update_network_status_(); + this->set_interval(NETWORK_STATUS_INTERVAL_ID, NETWORK_STATUS_INTERVAL_MS, + [this]() { this->update_network_status_(); }); + this->set_interval(STATUS_POLL_INTERVAL_ID, POLL_INTERVAL_MS, [this]() { this->poll_status_(); }); +} + +void HonWaterHeater::update_network_status_() { +#ifdef USE_NETWORK + const bool ready = network::is_connected(); +#else + const bool ready = false; +#endif + const uint8_t status = ready ? NETWORK_READY : NETWORK_NOT_READY; + if (status != this->network_status_) { + this->network_status_ = status; + this->network_pending_ = true; + ESP_LOGI(TAG, "Local network status changed: %02X", status); + } +} + +void HonWaterHeater::send_frame_(FrameType type, const uint8_t *payload, size_t size) { + uint8_t wire[MAX_WIRE_FRAME]; + const size_t length = encode_frame(type, payload, size, wire); + if (length == 0) + return; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex[format_hex_pretty_size(sizeof(wire))]; + ESP_LOGV(TAG, "TX type=%02X: %s", static_cast(type), format_hex_pretty_to(hex, wire, length)); +#endif + this->write_array(wire, length); +} + +void HonWaterHeater::send_request_(uint32_t now, Awaiting kind, FrameType type, const uint8_t *payload, size_t size, + FrameType expected) { + this->send_frame_(type, payload, size); + this->awaiting_ = kind; + this->expected_type_ = expected; + this->request_sent_ = now; +} + +void HonWaterHeater::poll_status_() { + const uint32_t now = App.get_loop_component_start_time(); + this->check_timeouts_(now); + if (this->awaiting_ != Awaiting::AWAITING_NONE || this->command_count_ != 0 || this->pending_off_) + return; + if (this->network_pending_ || now - this->network_sent_ >= NETWORK_REFRESH_MS) + return; // the network status goes out first, from loop() + // The version and device id are read once per cycle, the status on every other pass. + static constexpr uint8_t VERSION_REQUEST[] = {0x00, 0x07}; + static constexpr uint8_t STATUS_REQUEST[] = {0x4D, 0x01}; + if (this->poll_stage_ == 0) { + this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_GET_VERSION, VERSION_REQUEST, + sizeof(VERSION_REQUEST), FrameType::FRAME_TYPE_VERSION); + } else if (this->poll_stage_ == 1) { + this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_GET_DEVICE_ID, nullptr, 0, + FrameType::FRAME_TYPE_DEVICE_ID); + } else { + this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_CONTROL, STATUS_REQUEST, + sizeof(STATUS_REQUEST), FrameType::FRAME_TYPE_STATUS); + } + this->poll_stage_ = (this->poll_stage_ + 1) % POLL_CYCLE; +} + +void HonWaterHeater::check_timeouts_(uint32_t now) { + if (this->have_status_ && now - this->last_status_ >= this->communication_timeout_) { + this->have_status_ = false; + this->status_set_warning(LOG_STR("Heater communication timed out")); + ESP_LOGW(TAG, "Heater communication lost: no valid status for %u ms", + static_cast(this->communication_timeout_)); + if (this->communication_sensor_ != nullptr) + this->communication_sensor_->publish_state(false); + } + const uint32_t waited = now - this->request_sent_; + switch (this->awaiting_) { + case Awaiting::AWAITING_NONE: + break; + case Awaiting::AWAITING_POLL: + if (waited >= RESPONSE_TIMEOUT_MS) { + ESP_LOGW(TAG, "No matching response type=%02X in %u ms", static_cast(this->expected_type_), + static_cast(RESPONSE_TIMEOUT_MS)); + this->awaiting_ = Awaiting::AWAITING_NONE; + } + break; + case Awaiting::AWAITING_CONTROL: + if (waited >= RESPONSE_TIMEOUT_MS) { + ESP_LOGW(TAG, "Control response timed out; discarding remaining commands"); + this->awaiting_ = Awaiting::AWAITING_NONE; + this->command_count_ = 0; + } + break; + case Awaiting::AWAITING_NETWORK: + if (waited >= NETWORK_ACK_TIMEOUT_MS) { + ESP_LOGW(TAG, "Network status: no CONFIRM within %u ms", static_cast(NETWORK_ACK_TIMEOUT_MS)); + this->awaiting_ = Awaiting::AWAITING_NONE; + } + break; + } +} + +void HonWaterHeater::send_pending_(uint32_t now) { + if (this->awaiting_ != Awaiting::AWAITING_NONE) + return; + if (this->pending_off_) { + this->commands_[0] = {Parameter::PARAMETER_POWER, 0}; + this->command_index_ = 0; + this->command_count_ = 1; + this->pending_off_ = false; + } + if (this->command_count_ == 0 && (this->network_pending_ || now - this->network_sent_ >= NETWORK_REFRESH_MS)) { + // Signal remains a placeholder; status represents local IP readiness, not cloud access. + const uint8_t payload[] = {0x00, this->network_status_, 0x00, 100}; + this->send_request_(now, Awaiting::AWAITING_NETWORK, FrameType::FRAME_TYPE_NETWORK_STATUS, payload, sizeof(payload), + FrameType::FRAME_TYPE_CONFIRM); + this->network_sent_ = now; + this->network_pending_ = false; + return; + } + if (this->command_count_ != 0) + this->send_next_control_(now); +} + +void HonWaterHeater::send_next_control_(uint32_t now) { + uint8_t payload[4]; + encode_control(this->commands_[this->command_index_], payload); + this->send_request_(now, Awaiting::AWAITING_CONTROL, FrameType::FRAME_TYPE_CONTROL, payload, sizeof(payload), + FrameType::FRAME_TYPE_STATUS); +} + +void HonWaterHeater::read_frames_(uint32_t now) { + if (now - this->last_byte_ > FRAME_GAP_MS) + this->parser_.reset(); + uint8_t budget = MAX_BYTES_PER_LOOP; + while (budget-- > 0 && this->available() > 0) { + uint8_t value; + if (!this->read_byte(&value)) + break; + this->last_byte_ = now; + if (this->parser_.feed(value)) { + this->process_frame_(); + } else if (this->parser_.complete()) { + if ((this->parser_.data()[FRAME_FLAGS_OFFSET] & FRAME_FLAG_ENCRYPTED) != 0) { + ESP_LOGW(TAG, "RX: XOR-encrypted frames are not supported and will be dropped"); + } else { + ESP_LOGW(TAG, "RX: unsupported flags or invalid checksum/CRC"); + } + } + } +} + +void HonWaterHeater::loop() { + const uint32_t now = App.get_loop_component_start_time(); + this->check_timeouts_(now); + this->send_pending_(now); + this->read_frames_(now); +} + +void HonWaterHeater::handle_frame_type_(FrameType type) { + if (this->awaiting_ == Awaiting::AWAITING_POLL && type == this->expected_type_) { + ESP_LOGV(TAG, "Response matched type=%02X", static_cast(type)); + this->awaiting_ = Awaiting::AWAITING_NONE; + } + switch (type) { + case FrameType::FRAME_TYPE_GET_NETWORK_STATUS: + this->network_pending_ = true; + break; + case FrameType::FRAME_TYPE_CONFIRM: + if (this->awaiting_ == Awaiting::AWAITING_NETWORK) { + ESP_LOGD(TAG, "Network status acknowledged"); + this->awaiting_ = Awaiting::AWAITING_NONE; + } + break; + case FrameType::FRAME_TYPE_INVALID: + ESP_LOGW(TAG, "Appliance rejected request"); + // Controls queued behind a rejected poll were never sent and keep their turn. + if (this->awaiting_ == Awaiting::AWAITING_CONTROL) + this->command_count_ = 0; + if (this->awaiting_ != Awaiting::AWAITING_POLL) + this->awaiting_ = Awaiting::AWAITING_NONE; + break; + case FrameType::FRAME_TYPE_REPORT: + this->send_frame_(FrameType::FRAME_TYPE_CONFIRM); + break; + default: + break; + } +} + +void HonWaterHeater::confirm_control_(const Status &status) { + const Command &command = this->commands_[this->command_index_]; + if (!command_confirmed(command, status)) + return; + ESP_LOGD(TAG, "Control confirmed: parameter=%02X value=%02X", static_cast(command.parameter), command.value); + this->awaiting_ = Awaiting::AWAITING_NONE; + if (++this->command_index_ == this->command_count_) + this->command_count_ = 0; +} + +void HonWaterHeater::process_frame_() { + const uint8_t *frame = this->parser_.data(); + const auto type = static_cast(frame[FRAME_TYPE_OFFSET]); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex[format_hex_pretty_size(MAX_STORED_FRAME)]; + ESP_LOGV(TAG, "RX decoded type=%02X flags=%02X: %s", static_cast(type), frame[FRAME_FLAGS_OFFSET], + format_hex_pretty_to(hex, frame, this->parser_.stored_size())); +#endif + this->handle_frame_type_(type); + + Status status{}; + if (!decode_status(frame, this->parser_.size(), status)) + return; + const bool recovered = !this->have_status_; + if (status.mode == HonMode::HON_MODE_UNKNOWN && (recovered || this->reported_mode_ != HonMode::HON_MODE_UNKNOWN)) { + const uint8_t *payload = frame + FRAME_PAYLOAD_OFFSET; + ESP_LOGW(TAG, + "Unknown heater mode: payload[38..41]=%02X %02X %02X %02X; using last recognized active mode, or Electric " + "if none is known", + payload[38], payload[39], payload[40], payload[41]); + } + this->have_status_ = true; + this->last_status_ = App.get_loop_component_start_time(); + this->reported_mode_ = status.mode; + if (this->awaiting_ == Awaiting::AWAITING_CONTROL && type == FrameType::FRAME_TYPE_STATUS) + this->confirm_control_(status); + this->status_clear_warning(); + this->publish_status_(status); + // Published last so an automation on the sensor sees fresh heater state and can send a control. + if (recovered) { + ESP_LOGI(TAG, "Heater communication established"); + if (this->communication_sensor_ != nullptr) + this->communication_sensor_->publish_state(true); + } +} + +void HonWaterHeater::publish_status_(const Status &status) { + this->current_temperature_ = status.current_temperature; + this->target_temperature_ = status.target_temperature; + this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, status.powered); + switch (status.mode) { + case HonMode::HON_MODE_NORMAL: + this->last_active_mode_ = water_heater::WATER_HEATER_MODE_ELECTRIC; + break; + case HonMode::HON_MODE_ECO: + this->last_active_mode_ = water_heater::WATER_HEATER_MODE_ECO; + break; + case HonMode::HON_MODE_BPS: + this->last_active_mode_ = water_heater::WATER_HEATER_MODE_PERFORMANCE; + break; + case HonMode::HON_MODE_UNKNOWN: + break; + } + // The base entity has no Unknown mode; the text sensor preserves the decoded result. + this->mode_ = status.powered ? this->last_active_mode_ : water_heater::WATER_HEATER_MODE_OFF; + if (this->primary_sensor_ != nullptr) + this->primary_sensor_->publish_state(status.current_temperature); + if (this->secondary_sensor_ != nullptr) { + this->secondary_sensor_->publish_state( + status.secondary_temperature <= MAX_REPORTED_TEMPERATURE ? status.secondary_temperature : NAN); + } + if (this->target_sensor_ != nullptr) + this->target_sensor_->publish_state(status.target_temperature); + if (this->heating_time_sensor_ != nullptr) + this->heating_time_sensor_->publish_state(status.estimated_heating_time); + if (this->water_sensor_ != nullptr) { + // Three segments of available hot water, published as 0, 33, 66 or 100 percent. + const uint8_t percent = status.available_water * 100 / 3; + this->water_sensor_->publish_state(status.available_water <= 3 ? percent : NAN); + } + if (this->power_sensor_ != nullptr) + this->power_sensor_->publish_state(status.powered); + if (this->heating_sensor_ != nullptr) + this->heating_sensor_->publish_state(status.heating); + if (this->mode_sensor_ != nullptr) { + char label[HonModeLabels::MAX_LENGTH + 1]; + ESPHOME_strncpy_P(label, + reinterpret_cast(HonModeLabels::get_progmem_str( + static_cast(status.mode), static_cast(HonMode::HON_MODE_UNKNOWN))), + sizeof(label)); + this->mode_sensor_->publish_state(label); + } + this->publish_state(); +} + +void HonWaterHeater::dump_config() { + LOG_WATER_HEATER("", "Haier hOn", this); + ESP_LOGCONFIG(TAG, + " Polling interval: %u ms\n" + " Communication timeout: %u ms", + static_cast(POLL_INTERVAL_MS), static_cast(this->communication_timeout_)); + LOG_SENSOR(" ", "Primary temperature", this->primary_sensor_); + LOG_SENSOR(" ", "Secondary temperature", this->secondary_sensor_); + LOG_SENSOR(" ", "Target temperature", this->target_sensor_); + LOG_SENSOR(" ", "Estimated heating time", this->heating_time_sensor_); + LOG_SENSOR(" ", "Available water", this->water_sensor_); + LOG_BINARY_SENSOR(" ", "Communication", this->communication_sensor_); + LOG_BINARY_SENSOR(" ", "Power", this->power_sensor_); + LOG_BINARY_SENSOR(" ", "Heating", this->heating_sensor_); + LOG_TEXT_SENSOR(" ", "Mode", this->mode_sensor_); +} + +water_heater::WaterHeaterCallInternal HonWaterHeater::make_call() { + return water_heater::WaterHeaterCallInternal(this); +} + +// The appliance mode a water heater mode maps to; Unknown for modes the appliance does not have. +static HonMode hon_mode(water_heater::WaterHeaterMode mode) { + switch (mode) { + case water_heater::WATER_HEATER_MODE_ELECTRIC: + return HonMode::HON_MODE_NORMAL; + case water_heater::WATER_HEATER_MODE_ECO: + return HonMode::HON_MODE_ECO; + case water_heater::WATER_HEATER_MODE_PERFORMANCE: + return HonMode::HON_MODE_BPS; + default: + return HonMode::HON_MODE_UNKNOWN; + } +} + +void HonWaterHeater::control(const water_heater::WaterHeaterCall &call) { + using namespace water_heater; + const auto mode = call.get_mode(); + const auto on = call.get_on(); + const bool off = (on.has_value() && !*on) || (mode.has_value() && *mode == WATER_HEATER_MODE_OFF); + if (off) { + // Keep only the command in flight until its reply or timeout; unsent writes are dropped. + if (this->awaiting_ == Awaiting::AWAITING_CONTROL) { + this->commands_[0] = this->commands_[this->command_index_]; + this->command_index_ = 0; + this->command_count_ = 1; + } else { + this->command_count_ = 0; + } + this->pending_off_ = true; + return; + } + if (!this->have_status_ || App.get_loop_component_start_time() - this->last_status_ > CONTROL_STATUS_MAX_AGE_MS) { + ESP_LOGW(TAG, "Control rejected: no recent appliance status"); + return; + } + if (this->command_count_ != 0 || this->pending_off_) { + ESP_LOGW(TAG, "Control busy; wait for the previous request to finish"); + return; + } + const float temperature = call.get_target_temperature(); + if (!std::isnan(temperature)) { + if (!std::isfinite(temperature) || temperature < MIN_TARGET_TEMPERATURE || temperature > MAX_TARGET_TEMPERATURE || + std::round(temperature) != temperature) { + ESP_LOGW(TAG, "Target must be a whole temperature from %u to %u C", MIN_TARGET_TEMPERATURE, + MAX_TARGET_TEMPERATURE); + return; + } + const bool normal = + mode.has_value() ? *mode == WATER_HEATER_MODE_ELECTRIC : this->reported_mode_ == HonMode::HON_MODE_NORMAL; + if (!normal) { + ESP_LOGW(TAG, "Target can only be set in Normal mode"); + return; + } + } + this->command_index_ = 0; + auto append = [this](Parameter parameter, uint8_t value) { + this->commands_[this->command_count_++] = {parameter, value}; + }; + // A write is only queued when it changes something, so a repeated state costs no round trip. + if (!this->is_on() && ((on.has_value() && *on) || mode.has_value())) + append(Parameter::PARAMETER_POWER, 1); + if (mode.has_value() && hon_mode(*mode) != this->reported_mode_) { + // Captures show the appliance moves between ECO and BPS through Normal, never directly. + if (*mode != WATER_HEATER_MODE_ELECTRIC && this->reported_mode_ != HonMode::HON_MODE_NORMAL) + append(Parameter::PARAMETER_ECO, 0); + switch (*mode) { + case WATER_HEATER_MODE_ELECTRIC: + append(Parameter::PARAMETER_ECO, 0); + break; + case WATER_HEATER_MODE_ECO: + append(Parameter::PARAMETER_ECO, 1); + break; + case WATER_HEATER_MODE_PERFORMANCE: + append(Parameter::PARAMETER_BPS, 1); + break; + default: + break; + } + } + if (!std::isnan(temperature)) + append(Parameter::PARAMETER_TARGET_TEMPERATURE, static_cast(temperature - TARGET_TEMPERATURE_OFFSET)); +} + +water_heater::WaterHeaterTraits HonWaterHeater::traits() { + using namespace water_heater; + WaterHeaterTraits traits; + traits.set_supports_current_temperature(true); + traits.add_feature_flags(WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE | WATER_HEATER_SUPPORTS_OPERATION_MODE | + WATER_HEATER_SUPPORTS_ON_OFF); + traits.set_supported_modes( + {WATER_HEATER_MODE_OFF, WATER_HEATER_MODE_ELECTRIC, WATER_HEATER_MODE_ECO, WATER_HEATER_MODE_PERFORMANCE}); + traits.set_min_temperature(MIN_TARGET_TEMPERATURE); + traits.set_max_temperature(MAX_TARGET_TEMPERATURE); + traits.set_target_temperature_step(1); + return traits; +} + +} // namespace esphome::haier_water_heater diff --git a/esphome/components/haier_water_heater/hon_water_heater.h b/esphome/components/haier_water_heater/hon_water_heater.h new file mode 100644 index 00000000000..5f8ede184e7 --- /dev/null +++ b/esphome/components/haier_water_heater/hon_water_heater.h @@ -0,0 +1,96 @@ +#pragma once + +#include "hon_parser.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/water_heater/water_heater.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/component.h" + +namespace esphome::haier_water_heater { + +class HonWaterHeater : public Component, public water_heater::WaterHeater, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + water_heater::WaterHeaterCallInternal make_call() override; + + void set_communication_timeout(uint32_t value) { this->communication_timeout_ = value; } + void set_primary_temperature_sensor(sensor::Sensor *value) { this->primary_sensor_ = value; } + void set_secondary_temperature_sensor(sensor::Sensor *value) { this->secondary_sensor_ = value; } + void set_target_temperature_sensor(sensor::Sensor *value) { this->target_sensor_ = value; } + void set_estimated_heating_time_sensor(sensor::Sensor *value) { this->heating_time_sensor_ = value; } + void set_available_water_sensor(sensor::Sensor *value) { this->water_sensor_ = value; } + void set_communication_sensor(binary_sensor::BinarySensor *value) { this->communication_sensor_ = value; } + void set_power_sensor(binary_sensor::BinarySensor *value) { this->power_sensor_ = value; } + void set_heating_sensor(binary_sensor::BinarySensor *value) { this->heating_sensor_ = value; } + void set_mode_sensor(text_sensor::TextSensor *value) { this->mode_sensor_ = value; } + + protected: + static constexpr uint32_t NETWORK_STATUS_INTERVAL_ID = 0; + static constexpr uint32_t STATUS_POLL_INTERVAL_ID = 1; + static constexpr uint32_t NETWORK_STATUS_INTERVAL_MS = 500; + static constexpr uint32_t POLL_INTERVAL_MS = 5000; + static constexpr uint32_t RESPONSE_TIMEOUT_MS = 5000; // a polled or controlled reply + static constexpr uint32_t NETWORK_ACK_TIMEOUT_MS = 2000; // the CONFIRM of a network status + static constexpr uint32_t NETWORK_REFRESH_MS = 30000; // resend the network status this often + static constexpr uint32_t CONTROL_STATUS_MAX_AGE_MS = 15000; // a control needs a status this fresh + static constexpr uint32_t FRAME_GAP_MS = 300; // silence that ends a partial frame + static constexpr uint8_t POLL_CYCLE = 12; // version and id are re-read once per cycle + static constexpr uint8_t MAX_BYTES_PER_LOOP = 255; + static constexpr uint8_t MIN_TARGET_TEMPERATURE = 35; + static constexpr uint8_t MAX_TARGET_TEMPERATURE = 75; + static constexpr uint8_t NETWORK_READY = 0x07; + static constexpr uint8_t NETWORK_NOT_READY = 0x05; + + // The link carries one request at a time; this is the one whose answer is outstanding. + enum class Awaiting : uint8_t { AWAITING_NONE, AWAITING_POLL, AWAITING_NETWORK, AWAITING_CONTROL }; + + void control(const water_heater::WaterHeaterCall &call) override; + water_heater::WaterHeaterTraits traits() override; + void check_timeouts_(uint32_t now); + void send_pending_(uint32_t now); + void read_frames_(uint32_t now); + void poll_status_(); + void update_network_status_(); + void send_frame_(water_heater_protocol::FrameType type, const uint8_t *payload = nullptr, size_t size = 0); + void send_request_(uint32_t now, Awaiting kind, water_heater_protocol::FrameType type, const uint8_t *payload, + size_t size, water_heater_protocol::FrameType expected); + void send_next_control_(uint32_t now); + void process_frame_(); + void handle_frame_type_(water_heater_protocol::FrameType type); + void confirm_control_(const water_heater_protocol::Status &status); + void publish_status_(const water_heater_protocol::Status &status); + + water_heater_protocol::FrameParser parser_; + sensor::Sensor *primary_sensor_{nullptr}; + sensor::Sensor *secondary_sensor_{nullptr}; + sensor::Sensor *target_sensor_{nullptr}; + sensor::Sensor *heating_time_sensor_{nullptr}; + sensor::Sensor *water_sensor_{nullptr}; + binary_sensor::BinarySensor *communication_sensor_{nullptr}; + binary_sensor::BinarySensor *power_sensor_{nullptr}; + binary_sensor::BinarySensor *heating_sensor_{nullptr}; + text_sensor::TextSensor *mode_sensor_{nullptr}; + uint32_t request_sent_{0}; + uint32_t network_sent_{0}; + uint32_t last_status_{0}; + uint32_t last_byte_{0}; + uint32_t communication_timeout_{20000}; + water_heater::WaterHeaterMode last_active_mode_{water_heater::WATER_HEATER_MODE_ELECTRIC}; + std::array commands_{}; // power, to Normal, mode, temperature at most + water_heater_protocol::HonMode reported_mode_{water_heater_protocol::HonMode::HON_MODE_UNKNOWN}; + water_heater_protocol::FrameType expected_type_{water_heater_protocol::FrameType::FRAME_TYPE_STATUS}; + Awaiting awaiting_{Awaiting::AWAITING_NONE}; + uint8_t network_status_{NETWORK_NOT_READY}; + uint8_t command_count_{0}; + uint8_t command_index_{0}; + uint8_t poll_stage_{0}; + bool network_pending_{true}; + bool have_status_{false}; + bool pending_off_{false}; +}; + +} // namespace esphome::haier_water_heater diff --git a/esphome/components/haier_water_heater/water_heater.py b/esphome/components/haier_water_heater/water_heater.py new file mode 100644 index 00000000000..33a54a815b0 --- /dev/null +++ b/esphome/components/haier_water_heater/water_heater.py @@ -0,0 +1,110 @@ +"""Local controller for Haier hOn water heaters.""" + +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor, text_sensor, uart, water_heater +import esphome.config_validation as cv +from esphome.const import ( + CONF_MODE, + CONF_POWER, + CONF_TARGET_TEMPERATURE, + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_HEAT, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_MINUTE, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +CONF_AVAILABLE_WATER = "available_water" +CONF_COMMUNICATION = "communication" +CONF_COMMUNICATION_TIMEOUT = "communication_timeout" +CONF_ESTIMATED_HEATING_TIME = "estimated_heating_time" +CONF_HEATING = "heating" +CONF_PRIMARY_TEMPERATURE = "primary_temperature" +CONF_SECONDARY_TEMPERATURE = "secondary_temperature" + +CODEOWNERS = ["@TheLexus"] +DEPENDENCIES = ["uart"] +AUTO_LOAD = ["sensor", "binary_sensor", "text_sensor"] + +haier_water_heater_ns = cg.esphome_ns.namespace("haier_water_heater") +HonWaterHeater = haier_water_heater_ns.class_( + "HonWaterHeater", water_heater.WaterHeater, cg.Component, uart.UARTDevice +) + +_TEMPERATURE_SENSOR = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + water_heater.water_heater_schema(HonWaterHeater) + .extend( + { + cv.Optional( + CONF_COMMUNICATION_TIMEOUT, default="20s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_PRIMARY_TEMPERATURE): _TEMPERATURE_SENSOR, + cv.Optional(CONF_SECONDARY_TEMPERATURE): _TEMPERATURE_SENSOR, + cv.Optional(CONF_TARGET_TEMPERATURE): _TEMPERATURE_SENSOR, + cv.Optional(CONF_ESTIMATED_HEATING_TIME): sensor.sensor_schema( + unit_of_measurement=UNIT_MINUTE, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_AVAILABLE_WATER): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COMMUNICATION): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_POWER): binary_sensor.binary_sensor_schema(), + cv.Optional(CONF_HEATING): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_HEAT + ), + cv.Optional(CONF_MODE): text_sensor.text_sensor_schema(), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "haier_water_heater", + baud_rate=9600, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config: ConfigType) -> None: + var = await water_heater.new_water_heater(config) + await cg.register_component(var, config) + cg.add(var.set_communication_timeout(config[CONF_COMMUNICATION_TIMEOUT])) + await uart.register_uart_device(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_PRIMARY_TEMPERATURE, var.set_primary_temperature_sensor) + await sensors(CONF_SECONDARY_TEMPERATURE, var.set_secondary_temperature_sensor) + await sensors(CONF_TARGET_TEMPERATURE, var.set_target_temperature_sensor) + await sensors(CONF_ESTIMATED_HEATING_TIME, var.set_estimated_heating_time_sensor) + await sensors(CONF_AVAILABLE_WATER, var.set_available_water_sensor) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_COMMUNICATION, var.set_communication_sensor) + await binary_sensors(CONF_POWER, var.set_power_sensor) + await binary_sensors(CONF_HEATING, var.set_heating_sensor) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_MODE, var.set_mode_sensor) diff --git a/tests/component_tests/haier_water_heater/__init__.py b/tests/component_tests/haier_water_heater/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/haier_water_heater/test_water_heater.py b/tests/component_tests/haier_water_heater/test_water_heater.py new file mode 100644 index 00000000000..518b5d26867 --- /dev/null +++ b/tests/component_tests/haier_water_heater/test_water_heater.py @@ -0,0 +1,115 @@ +"""Tests for the Haier water heater configuration.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import uart +from esphome.components.haier_water_heater.water_heater import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, +) +from esphome.config import Config +from esphome.const import PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + + +def test_visible_by_default(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({"name": "Heater capture"}) + assert config.get("internal", False) is False + assert "protocol" not in config + assert config["communication_timeout"].total_milliseconds == 20000 + + +@pytest.mark.parametrize("protocol", ["HON", "SMARTAIR2", "unknown"]) +def test_rejects_protocol_option( + set_core_config: SetCoreConfigCallable, protocol: str +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"name": "Heater capture", "protocol": protocol}) + + +def test_rejects_climate_control_options( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA( + {"name": "Heater capture", "control_method": "SET_GROUP_PARAMETERS"} + ) + + +@pytest.mark.parametrize("missing", [None, "rx_pin", "tx_pin"]) +@pytest.mark.parametrize( + ("setting", "value"), + [ + (None, None), + ("baud_rate", 115200), + ("data_bits", 7), + ("parity", "EVEN"), + ("stop_bits", 2), + ], +) +def test_uart_requirements( + set_core_config: SetCoreConfigCallable, + missing: str | None, + setting: str | None, + value: int | str | None, +) -> None: + bus_id = ID("heater_uart", is_declaration=True, type=uart.IDFUARTComponent) + bus = { + "id": bus_id, + "baud_rate": 9600, + "data_bits": 8, + "parity": "NONE", + "stop_bits": 1, + "rx_pin": {"number": 16}, + "tx_pin": {"number": 17}, + } + if missing is not None: + del bus[missing] + if setting is not None: + bus[setting] = value + full = Config() + full["uart"] = [bus] + full.declare_ids.append((bus_id, ["uart", 0, "id"])) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full) + config = {"uart_id": ID("heater_uart", type=uart.IDFUARTComponent)} + if missing is None and setting is None: + FINAL_VALIDATE_SCHEMA(config) + else: + with pytest.raises(cv.Invalid): + FINAL_VALIDATE_SCHEMA(config) + + +def test_read_only_entities(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA( + { + "name": "Water Heater", + "internal": False, + "target_temperature": {"name": "Target"}, + "estimated_heating_time": {"name": "Estimated Heating Time"}, + "available_water": {"name": "Water Segments"}, + "communication": {"name": "Communication"}, + "power": {"name": "Power"}, + "heating": {"name": "Heating"}, + "mode": {"name": "Mode"}, + } + ) + assert config["target_temperature"]["unit_of_measurement"] == "°C" + assert config["estimated_heating_time"]["unit_of_measurement"] == "min" + assert config["estimated_heating_time"]["device_class"] == "duration" + assert config["communication"]["device_class"] == "connectivity" + assert config["heating"]["device_class"] == "heat" + assert config["internal"] is False + + +def test_custom_communication_timeout(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({"name": "Heater", "communication_timeout": "7500ms"}) + assert config["communication_timeout"].total_milliseconds == 7500 + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"name": "Heater", "communication_timeout": "-1s"}) diff --git a/tests/components/haier_water_heater/__init__.py b/tests/components/haier_water_heater/__init__.py new file mode 100644 index 00000000000..7a32bd70c15 --- /dev/null +++ b/tests/components/haier_water_heater/__init__.py @@ -0,0 +1,12 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Include the real entity classes used by the controller fixtures. + manifest.auto_load = [ + "uart", + "water_heater", + "sensor", + "binary_sensor", + "text_sensor", + ] diff --git a/tests/components/haier_water_heater/common.yaml b/tests/components/haier_water_heater/common.yaml new file mode 100644 index 00000000000..657fdf25334 --- /dev/null +++ b/tests/components/haier_water_heater/common.yaml @@ -0,0 +1,23 @@ +water_heater: + - platform: haier_water_heater + id: haier_water_heater_device + name: Haier Water Heater + communication_timeout: 25s + primary_temperature: + name: Primary temperature zone + secondary_temperature: + name: Secondary temperature zone + estimated_heating_time: + name: Haier Estimated Heating Time + communication: + name: Haier Communication + target_temperature: + name: Haier Target Temperature + available_water: + name: Haier Available Water + power: + name: Haier Power + heating: + name: Haier Heating + mode: + name: Haier Mode diff --git a/tests/components/haier_water_heater/test.esp32-idf.yaml b/tests/components/haier_water_heater/test.esp32-idf.yaml new file mode 100644 index 00000000000..dd692146863 --- /dev/null +++ b/tests/components/haier_water_heater/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + haier_water_heater: !include common.yaml diff --git a/tests/components/haier_water_heater/test.esp8266-ard.yaml b/tests/components/haier_water_heater/test.esp8266-ard.yaml new file mode 100644 index 00000000000..1c3b570d552 --- /dev/null +++ b/tests/components/haier_water_heater/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + haier_water_heater: !include common.yaml diff --git a/tests/components/haier_water_heater/test.rp2040-ard.yaml b/tests/components/haier_water_heater/test.rp2040-ard.yaml new file mode 100644 index 00000000000..b7119efb4ab --- /dev/null +++ b/tests/components/haier_water_heater/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + haier_water_heater: !include common.yaml diff --git a/tests/components/haier_water_heater/test_hon_protocol.cpp b/tests/components/haier_water_heater/test_hon_protocol.cpp new file mode 100644 index 00000000000..f9feadf8a79 --- /dev/null +++ b/tests/components/haier_water_heater/test_hon_protocol.cpp @@ -0,0 +1,219 @@ +#include "esphome/components/haier_water_heater/hon_parser.h" +#include +#include +#include +#include +using namespace esphome::haier_water_heater::water_heater_protocol; +namespace esphome::haier_water_heater::testing { +std::vector bytes(const char *hex) { + std::istringstream stream(hex); + unsigned byte; + std::vector result; + while (stream >> std::hex >> byte) + result.push_back(byte); + return result; +} +unsigned feed(FrameParser &parser, const std::vector &wire) { + unsigned count = 0; + for (auto byte : wire) + count += parser.feed(byte); + return count; +} +unsigned feed_head(FrameParser &parser, const std::vector &wire, size_t count) { + unsigned frames = 0; + for (size_t i = 0; i < count; i++) + frames += parser.feed(wire[i]); + return frames; +} +TEST(HonProtocolTest, EncodesRequestsAndEscapesBytes) { + FrameParser parser; + uint8_t wire[MAX_WIRE_FRAME]; + const uint8_t version[] = {0x00, 0x07}; + const uint8_t query[] = {0x4D, 0x01}; + auto check_tx = [&](FrameType type, const uint8_t *payload, size_t size, const char *expected) { + const auto count = encode_frame(type, payload, size, wire); + EXPECT_EQ(std::vector(wire, wire + count), bytes(expected)); + ASSERT_EQ(feed(parser, std::vector(wire, wire + count)), 1u); + EXPECT_EQ(parser.data()[FRAME_FLAGS_OFFSET], 0); + EXPECT_EQ(parser.data()[FRAME_TYPE_OFFSET], static_cast(type)); + }; + check_tx(FrameType::FRAME_TYPE_GET_VERSION, version, 2, "FF FF 0A 00 00 00 00 00 00 61 00 07 72"); + check_tx(FrameType::FRAME_TYPE_GET_DEVICE_ID, nullptr, 0, "FF FF 08 00 00 00 00 00 00 70 78"); + check_tx(FrameType::FRAME_TYPE_CONTROL, query, 2, "FF FF 0A 00 00 00 00 00 00 01 4D 01 59"); + check_tx(FrameType::FRAME_TYPE_CONFIRM, nullptr, 0, "FF FF 08 00 00 00 00 00 00 05 0D"); + auto check_control = [&](Command command, const char *expected) { + uint8_t payload[4]; + encode_control(command, payload); + check_tx(FrameType::FRAME_TYPE_CONTROL, payload, sizeof(payload), expected); + }; + check_control({Parameter::PARAMETER_POWER, 1}, "FF FF 0C 00 00 00 00 00 00 01 5D 01 00 01 6C"); + check_control({Parameter::PARAMETER_ECO, 1}, "FF FF 0C 00 00 00 00 00 00 01 5D 05 00 01 70"); + check_control({Parameter::PARAMETER_ECO, 0}, "FF FF 0C 00 00 00 00 00 00 01 5D 05 00 00 6F"); + check_control({Parameter::PARAMETER_BPS, 1}, "FF FF 0C 00 00 00 00 00 00 01 5D 17 00 01 82"); + check_control({Parameter::PARAMETER_TARGET_TEMPERATURE, 6}, "FF FF 0C 00 00 00 00 00 00 01 5D 02 00 06 72"); + const uint8_t network[] = {0, 7, 0, 100}; + check_tx(FrameType::FRAME_TYPE_NETWORK_STATUS, network, 4, "FF FF 0C 00 00 00 00 00 00 F7 00 07 00 64 6E"); + const uint8_t escaped[] = {0xFF, 0x55}; + const size_t escaped_size = encode_frame(FrameType::FRAME_TYPE_CONTROL, escaped, 2, wire); + EXPECT_EQ(feed(parser, std::vector(wire, wire + escaped_size)), 1u); + EXPECT_EQ(encode_frame(FrameType::FRAME_TYPE_CONTROL, escaped, 5, wire), 0u); +} + +struct StatusCase { + const char *name; + const char *wire; + uint8_t current_temperature; + uint8_t target_temperature; + uint8_t estimated_heating_time; + bool powered; + bool heating; + HonMode mode; + uint8_t available_water; +}; + +// Captured from a VH3W on 2026-10-14; the name gives the packet of the session. +static const StatusCase STATUS_CASES[] = { + {"packet 4", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 00 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 10 00 00 00 00 00 02 00 00 00 00 00 00 2E", + 53, 35, 0, false, false, HonMode::HON_MODE_NORMAL, 2}, + {"packet 33", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 00 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 11 00 00 00 00 00 02 00 00 00 00 00 00 2F", + 53, 35, 0, true, false, HonMode::HON_MODE_NORMAL, 2}, + {"packet 36", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 00 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 00 01 00 04 00 00 00 02 00 00 00 00 00 00 13", + 53, 35, 0, true, false, HonMode::HON_MODE_ECO, 2}, + {"packet 39", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 32 00 00 00 1D 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 00 01 04 01 00 00 00 02 00 00 00 00 00 00 6D", + 53, 80, 29, true, true, HonMode::HON_MODE_BPS, 2}, + {"packet 50", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 01 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 00 01 00 04 00 00 00 02 00 00 00 00 00 00 14", + 53, 35, 0, true, false, HonMode::HON_MODE_ECO, 2}, + {"packet 52", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 32 00 01 00 1D 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 00 01 04 01 00 00 00 02 00 00 00 00 00 00 6E", + 53, 80, 29, true, true, HonMode::HON_MODE_BPS, 2}, + {"packet 55", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 01 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 11 00 00 00 00 00 02 00 00 00 00 00 00 30", + 53, 35, 0, true, false, HonMode::HON_MODE_NORMAL, 2}, + {"packet 58", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 01 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 10 00 00 00 00 00 02 00 00 00 00 00 00 2F", + 53, 35, 0, false, false, HonMode::HON_MODE_NORMAL, 2}, + {"target 35 C", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 1E 05 00 00 00 06 00 00 00 32 19 00 00 1E 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 10 00 00 00 00 00 00 00 00 00 00 00 00 04", + 30, 35, 6, false, false, HonMode::HON_MODE_NORMAL, 0}, + {"target 40 C", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 1E 0A 00 01 00 0B 00 00 00 32 19 00 00 1E 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 10 11 00 01 00 00 00 00 00 00 00 00 00 00 20", + 30, 40, 11, true, true, HonMode::HON_MODE_NORMAL, 0}, + {"target 75 C", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 1F 2D 00 01 00 2F 00 00 00 32 19 00 00 1F 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 10 11 80 01 00 00 00 00 00 00 00 00 00 00 E9", + 31, 75, 47, true, true, HonMode::HON_MODE_NORMAL, 0}, + {"target back to 35 C", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 20 05 00 01 00 04 00 00 00 32 19 00 00 20 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 10 11 00 01 00 00 00 00 00 00 00 00 00 00 18", + 32, 35, 4, true, true, HonMode::HON_MODE_NORMAL, 0}, +}; + +TEST(HonProtocolTest, DecodesCapturedStatusFrames) { + FrameParser parser; + for (const auto &expected : STATUS_CASES) { + SCOPED_TRACE(expected.name); + Status status{}; + ASSERT_EQ(feed(parser, bytes(expected.wire)), 1u); + ASSERT_TRUE(decode_status(parser.data(), parser.size(), status)); + EXPECT_EQ(status.current_temperature, expected.current_temperature); + EXPECT_EQ(status.target_temperature, expected.target_temperature); + EXPECT_EQ(status.estimated_heating_time, expected.estimated_heating_time); + EXPECT_EQ(status.powered, expected.powered); + EXPECT_EQ(status.heating, expected.heating); + EXPECT_EQ(status.mode, expected.mode); + EXPECT_EQ(status.available_water, expected.available_water); + } +} + +TEST(HonProtocolTest, ConfirmsCommandsAgainstStatus) { + Status status{}; + status.powered = true; + status.target_temperature = 40; + status.mode = HonMode::HON_MODE_ECO; + EXPECT_TRUE(command_confirmed({Parameter::PARAMETER_POWER, 1}, status)); + EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_POWER, 0}, status)); + EXPECT_TRUE(command_confirmed({Parameter::PARAMETER_TARGET_TEMPERATURE, 10}, status)); + EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_TARGET_TEMPERATURE, 11}, status)); + EXPECT_TRUE(command_confirmed({Parameter::PARAMETER_ECO, 1}, status)); + EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_ECO, 0}, status)); + EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_BPS, 1}, status)); +} + +TEST(HonProtocolTest, KeepsTemperatureZonesIndependent) { + Status status{}; + uint8_t independent[61] = {}; + independent[0] = STATUS_FRAME_LENGTH; + independent[FRAME_TYPE_OFFSET] = static_cast(FrameType::FRAME_TYPE_STATUS); + independent[8] = 0x6D; + independent[9] = 1; + independent[10] = 42; + independent[11] = 5; + independent[23] = 43; + ASSERT_TRUE(decode_status(independent, sizeof(independent), status)); + EXPECT_EQ(status.current_temperature, 42); + EXPECT_EQ(status.secondary_temperature, 43); +} + +TEST(HonProtocolTest, RejectsCorruptionAndResynchronizes) { + FrameParser parser; + Status status{}; + auto valid = bytes("FF FF 3C 00 00 00 00 00 00 06 6D 01 35 32 00 00 00 1D 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 00 01 04 01 00 00 00 02 00 00 00 00 00 00 6D"); + auto damaged = valid; + damaged.back() ^= 1; + EXPECT_EQ(feed(parser, damaged), 0u); + EXPECT_EQ(feed(parser, valid), 1u); + parser.reset(); + EXPECT_EQ(feed_head(parser, valid, 20), 0u); + parser.reset(); // Partial frame timeout. + EXPECT_EQ(feed(parser, valid), 1u); + parser.reset(); + EXPECT_EQ(feed_head(parser, valid, 20), 0u); + EXPECT_EQ(feed(parser, valid), 1u); // New separator resynchronizes. + EXPECT_EQ(feed(parser, bytes("FF FF 07 7F FF 55 DA")), 0u); + EXPECT_EQ(feed(parser, valid), 1u); + auto crc = bytes("FF FF 0D 40 00 00 00 00 00 01 4D 01 FF 55 BB FF 55 FF 55 D1 3C"); + EXPECT_EQ(feed(parser, crc), 1u); + EXPECT_FALSE(decode_status(parser.data(), parser.size(), status)); + crc.back() ^= 1; + EXPECT_EQ(feed(parser, crc), 0u); + EXPECT_EQ(feed(parser, bytes("FF FF 08 00 00 00 00 00 00 05 0D")), 1u); + EXPECT_FALSE(decode_status(parser.data(), 0, status)); +} + +TEST(HonProtocolTest, ValidatesFramesLongerThanTheStoredHead) { + FrameParser parser; + std::vector frame(MAX_FRAME_LENGTH + 1, 0); + frame[0] = MAX_FRAME_LENGTH; + frame[FRAME_TYPE_OFFSET] = static_cast(FrameType::FRAME_TYPE_VERSION); + frame[MAX_FRAME_LENGTH - 1] = 0x7E; // a byte past the stored head still counts + uint8_t checksum = 0; + for (size_t i = 0; i < MAX_FRAME_LENGTH; i++) + checksum += frame[i]; + frame[MAX_FRAME_LENGTH] = checksum; + std::vector wire = {0xFF, 0xFF}; + wire.insert(wire.end(), frame.begin(), frame.end()); + EXPECT_EQ(feed(parser, wire), 1u); + EXPECT_EQ(parser.size(), MAX_FRAME_LENGTH + 1u); + EXPECT_EQ(parser.stored_size(), MAX_STORED_FRAME); + EXPECT_EQ(parser.data()[FRAME_TYPE_OFFSET], static_cast(FrameType::FRAME_TYPE_VERSION)); + wire.back() ^= 1; + EXPECT_EQ(feed(parser, wire), 0u); + EXPECT_TRUE(parser.complete()); +} +} // namespace esphome::haier_water_heater::testing diff --git a/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp new file mode 100644 index 00000000000..41b40687d48 --- /dev/null +++ b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp @@ -0,0 +1,292 @@ +#include +#include +#include +#include +#include + +#include "esphome/components/haier_water_heater/hon_water_heater.h" +#include "esphome/core/application.h" + +namespace esphome::haier_water_heater::testing { + +class RecordingUART : public uart::UARTComponent { + public: + std::deque rx; + std::vector> sent; + void write_array(const uint8_t *data, size_t size) override { this->sent.emplace_back(data, data + size); } + bool read_array(uint8_t *data, size_t size) override { + if (this->rx.size() < size) + return false; + for (size_t i = 0; i < size; i++) { + data[i] = this->rx.front(); + this->rx.pop_front(); + } + return true; + } + bool peek_byte(uint8_t *data) override { + if (this->rx.empty()) + return false; + *data = this->rx.front(); + return true; + } + size_t available() override { return this->rx.size(); } + 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 +}; + +class TestableHeater : public HonWaterHeater { + public: + using HonWaterHeater::poll_status_; + void set_network_pending(bool value) { this->network_pending_ = value; } + bool waiting_poll() const { return this->awaiting_ == Awaiting::AWAITING_POLL; } + bool pending_off() const { return this->pending_off_; } + uint8_t command_count() const { return this->command_count_; } + void cancel_test_intervals() { + this->cancel_interval(NETWORK_STATUS_INTERVAL_ID); + this->cancel_interval(STATUS_POLL_INTERVAL_ID); + } +}; + +class HonControllerTest : public ::testing::Test { + protected: + void SetUp() override { + // The test runner skips generated setup(), which normally constructs App and its scheduler. + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + this->heater_.set_uart_parent(&this->uart_); + this->heater_.set_network_pending(false); + this->set_clock_(100); + } + void TearDown() override { this->heater_.cancel_test_intervals(); } + void set_clock_(uint32_t ms) { LoopBlockingGuard clock{nullptr, nullptr, ms}; } + void status_(bool powered, bool unknown = false, bool eco = false, bool bps = false) { + std::array core{}; + core[0] = 60; + core[7] = 2; + core[8] = 0x6D; + core[9] = 1; + core[10] = 42; + core[11] = 5; + core[23] = 43; + core[46] = (unknown || eco || bps) ? 0 : 16; + core[47] = ((unknown || eco || bps) ? 0 : 16) | (powered ? 1 : 0); + core[48] = bps ? 4 : 0; + core[49] = eco ? 4 : 0; + for (size_t i = 0; i < 60; i++) { + core[60] += core[i]; + if (core[i] == 0xFF) + core[60] += 0x55; + } + this->uart_.rx.insert(this->uart_.rx.end(), {0xFF, 0xFF}); + for (uint8_t byte : core) { + this->uart_.rx.push_back(byte); + if (byte == 0xFF) + this->uart_.rx.push_back(0x55); + } + this->heater_.loop(); + } + void request_bps_() { + auto call = this->heater_.make_call(); + call.set_mode(water_heater::WATER_HEATER_MODE_PERFORMANCE); + call.perform(); + this->heater_.loop(); + } + void reject_() { + uint8_t wire[water_heater_protocol::MAX_WIRE_FRAME]; + const size_t count = + water_heater_protocol::encode_frame(water_heater_protocol::FrameType::FRAME_TYPE_INVALID, nullptr, 0, wire); + this->uart_.rx.insert(this->uart_.rx.end(), wire, wire + count); + this->heater_.loop(); + } + void request_off_() { + auto call = this->heater_.make_call(); + call.set_on(false); + call.perform(); + } + void expect_off_() { + ASSERT_FALSE(this->uart_.sent.empty()); + const std::vector expected = {0xFF, 0xFF, 0x0C, 0, 0, 0, 0, 0, 0, 1, 0x5D, 1, 0, 0, 0x6B}; + EXPECT_EQ(this->uart_.sent.back(), expected); + } + RecordingUART uart_; + TestableHeater heater_; +}; + +TEST_F(HonControllerTest, OffIgnoresTemperatureWithoutStatus) { + auto call = this->heater_.make_call(); + call.set_mode(water_heater::WATER_HEATER_MODE_OFF); + call.set_target_temperature(40); + call.perform(); + this->heater_.loop(); + this->expect_off_(); +} + +TEST_F(HonControllerTest, OffSurvivesTimeoutAndCancelsQueuedMode) { + this->status_(false); + this->request_bps_(); + ASSERT_EQ(this->heater_.command_count(), 2); + auto call = this->heater_.make_call(); + call.set_on(false); + call.set_target_temperature(999); + call.perform(); + EXPECT_TRUE(this->heater_.pending_off()); + EXPECT_EQ(this->heater_.command_count(), 1); + this->set_clock_(5101); + this->heater_.loop(); + this->expect_off_(); + EXPECT_FALSE(this->heater_.pending_off()); + EXPECT_EQ(this->uart_.sent.size(), 2); +} + +TEST_F(HonControllerTest, ActiveModeIsNotRequestedAgain) { + this->status_(true, false, false, true); + this->request_bps_(); + EXPECT_EQ(this->heater_.command_count(), 0); + EXPECT_TRUE(this->uart_.sent.empty()); + auto call = this->heater_.make_call(); + call.set_mode(water_heater::WATER_HEATER_MODE_ECO); + call.perform(); + EXPECT_EQ(this->heater_.command_count(), 2); // through Normal, then ECO +} + +TEST_F(HonControllerTest, PowerOnIsNotRequestedAgain) { + this->status_(true); + auto call = this->heater_.make_call(); + call.set_on(true); + call.set_target_temperature(40); + call.perform(); + EXPECT_EQ(this->heater_.command_count(), 1); // the temperature only +} + +TEST_F(HonControllerTest, OffFollowsConfirmedInFlightCommand) { + this->status_(false); + this->request_bps_(); + this->request_off_(); + this->set_clock_(200); + this->status_(true); + this->heater_.loop(); + this->expect_off_(); + EXPECT_EQ(this->uart_.sent.size(), 2); +} + +TEST_F(HonControllerTest, CommunicationTimeoutAndRecovery) { + binary_sensor::BinarySensor health; + this->heater_.set_communication_sensor(&health); + this->heater_.setup(); + ASSERT_TRUE(health.has_state()); + EXPECT_FALSE(health.state); + EXPECT_TRUE(this->heater_.status_has_warning()); + this->status_(false); + EXPECT_TRUE(health.state); + EXPECT_FALSE(this->heater_.status_has_warning()); + this->set_clock_(20099); + this->heater_.loop(); + EXPECT_TRUE(health.state); + this->set_clock_(20100); + this->heater_.loop(); + EXPECT_FALSE(health.state); + EXPECT_TRUE(this->heater_.status_has_warning()); + this->heater_.loop(); + EXPECT_FALSE(health.state); + this->status_(false); + EXPECT_TRUE(health.state); + EXPECT_FALSE(this->heater_.status_has_warning()); +} + +TEST_F(HonControllerTest, ControlFromCommunicationRecoveryIsAccepted) { + binary_sensor::BinarySensor health; + this->heater_.set_communication_sensor(&health); + health.add_on_state_callback([this](bool state) { + if (!state) + return; + EXPECT_TRUE(this->heater_.is_on()); + auto call = this->heater_.make_call(); + call.set_target_temperature(40); + call.perform(); + }); + this->status_(true); + EXPECT_EQ(this->heater_.command_count(), 1); +} + +TEST_F(HonControllerTest, PollTimeoutReleasesNetworkUpdateThenPollingResumes) { + this->heater_.poll_status_(); + ASSERT_TRUE(this->heater_.waiting_poll()); + this->heater_.set_network_pending(true); + this->set_clock_(5099); + this->heater_.loop(); + ASSERT_EQ(this->uart_.sent.size(), 1); + this->set_clock_(5100); + this->heater_.poll_status_(); + this->heater_.loop(); + EXPECT_FALSE(this->heater_.waiting_poll()); + ASSERT_EQ(this->uart_.sent.size(), 2); + EXPECT_EQ(this->uart_.sent.back()[9], 0xF7); + this->set_clock_(7201); + this->heater_.loop(); + this->set_clock_(10100); + this->heater_.poll_status_(); + EXPECT_TRUE(this->heater_.waiting_poll()); + EXPECT_EQ(this->uart_.sent.back()[9], 0x70); +} + +TEST_F(HonControllerTest, OffFollowsPollTimeout) { + this->heater_.poll_status_(); + this->request_off_(); + this->set_clock_(5100); + this->heater_.loop(); + this->expect_off_(); +} + +TEST_F(HonControllerTest, RejectionDropsOnlyTheControlInFlight) { + this->status_(false); + this->heater_.poll_status_(); + ASSERT_TRUE(this->heater_.waiting_poll()); + this->request_bps_(); + ASSERT_EQ(this->heater_.command_count(), 2); + this->reject_(); + EXPECT_TRUE(this->heater_.waiting_poll()); + EXPECT_EQ(this->heater_.command_count(), 2); + this->set_clock_(5100); + this->heater_.loop(); // the poll times out and the first queued control goes out + ASSERT_EQ(this->uart_.sent.size(), 2); + EXPECT_EQ(this->uart_.sent.back()[10], 0x5D); + this->reject_(); + EXPECT_EQ(this->heater_.command_count(), 0); + EXPECT_FALSE(this->heater_.waiting_poll()); +} + +TEST_F(HonControllerTest, UnknownModeRetainsActiveModeAcrossOff) { + this->status_(true, true); + EXPECT_TRUE(this->heater_.is_on()); + EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ELECTRIC); + this->status_(true, false, true); + EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ECO); + this->status_(false, true); + EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_OFF); + this->status_(true, true); + EXPECT_TRUE(this->heater_.is_on()); + EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ECO); + this->status_(true); + EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ELECTRIC); +} + +TEST_F(HonControllerTest, CustomCommunicationTimeout) { + binary_sensor::BinarySensor health; + this->heater_.set_communication_sensor(&health); + this->heater_.set_communication_timeout(7500); + this->heater_.setup(); + this->status_(false); + this->set_clock_(7599); + this->heater_.loop(); + EXPECT_TRUE(health.state); + this->set_clock_(7600); + this->heater_.loop(); + EXPECT_FALSE(health.state); + this->status_(false); + EXPECT_TRUE(health.state); +} + +} // namespace esphome::haier_water_heater::testing From 38db8c1554b06084f45de08b7d890ca1e433ce32 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:00:55 +0200 Subject: [PATCH 006/120] [ci] Fail a hung integration test instead of waiting for the job limit (#20902) --- .github/workflows/ci.yml | 5 ++++- requirements_test.txt | 1 + script/integration_test | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d97352f65f4..cfb304ef1b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -395,6 +395,8 @@ jobs: - common - determine-jobs if: needs.determine-jobs.outputs.integration-tests == 'true' + # A hung test is failed by pytest's --timeout below; this bounds the rest + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -471,7 +473,8 @@ jobs: exit 1 fi echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" - pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal \ + # A hung test fails after 30 min: the 15 min shared build lock wait plus a cold compile + pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal --timeout=1800 \ --junitxml=junit-integration.xml "${test_files[@]}" - name: Upload test artifacts # Tests that compare rendered output write the image they actually got here, so a diff --git a/requirements_test.txt b/requirements_test.txt index b36562727f8..68c4bbaf426 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,6 +9,7 @@ yamlrocks==0.6.1 # used by script/sync_dependency_versions.py pytest==9.1.1 pytest-cov==7.1.0 pytest-mock==3.16.0 +pytest-timeout==2.4.0 pytest-asyncio==1.4.0 pytest-xdist==3.8.0 asyncmock==0.4.2 diff --git a/script/integration_test b/script/integration_test index d637cdd2985..5339d655765 100755 --- a/script/integration_test +++ b/script/integration_test @@ -7,4 +7,4 @@ cd "${script_dir}/.." set -x -pytest -vvs --no-cov --tb=native -n 0 tests/integration/ +pytest -vvs --no-cov --tb=native -n 0 --timeout=1800 tests/integration/ From 94400124134bafe440e701de4817f3eb9dfc5592 Mon Sep 17 00:00:00 2001 From: Nicolas Liaudat Date: Fri, 16 Oct 2026 21:14:06 +0200 Subject: [PATCH 007/120] [mics_5524] Add MiCS-5524 gas sensor platform (1/2) (#19721) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/mics_5524/__init__.py | 19 ++ esphome/components/mics_5524/coefficients.py | 32 +++ esphome/components/mics_5524/mics_5524.cpp | 206 ++++++++++++++++++ esphome/components/mics_5524/mics_5524.h | 104 +++++++++ esphome/components/mics_5524/mics_math.h | 59 +++++ esphome/components/mics_5524/sensor.py | 153 +++++++++++++ esphome/core/defines.h | 1 + tests/component_tests/mics_5524/__init__.py | 0 .../mics_5524/config/mics_5524.yaml | 37 ++++ .../mics_5524/test_mics_5524.py | 205 +++++++++++++++++ tests/components/mics_5524/common.h | 23 ++ tests/components/mics_5524/common.yaml | 22 ++ .../mics_5524/sensor/mics_math_test.cpp | 93 ++++++++ .../components/mics_5524/test.esp32-idf.yaml | 5 + .../mics_5524/test.esp8266-ard.yaml | 5 + .../components/mics_5524/test.rp2040-ard.yaml | 5 + .../mics_5524/validate.esp32-idf.yaml | 23 ++ 18 files changed, 993 insertions(+) create mode 100644 esphome/components/mics_5524/__init__.py create mode 100644 esphome/components/mics_5524/coefficients.py create mode 100644 esphome/components/mics_5524/mics_5524.cpp create mode 100644 esphome/components/mics_5524/mics_5524.h create mode 100644 esphome/components/mics_5524/mics_math.h create mode 100644 esphome/components/mics_5524/sensor.py create mode 100644 tests/component_tests/mics_5524/__init__.py create mode 100644 tests/component_tests/mics_5524/config/mics_5524.yaml create mode 100644 tests/component_tests/mics_5524/test_mics_5524.py create mode 100644 tests/components/mics_5524/common.h create mode 100644 tests/components/mics_5524/common.yaml create mode 100644 tests/components/mics_5524/sensor/mics_math_test.cpp create mode 100644 tests/components/mics_5524/test.esp32-idf.yaml create mode 100644 tests/components/mics_5524/test.esp8266-ard.yaml create mode 100644 tests/components/mics_5524/test.rp2040-ard.yaml create mode 100644 tests/components/mics_5524/validate.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 38d2da4d870..55f58dbe696 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -391,6 +391,7 @@ esphome/components/micro_wake_word/* @jesserockz @kahrendt esphome/components/micronova/* @edenhaus @jorre05 esphome/components/microphone/* @jesserockz @kahrendt esphome/components/mics_4514/* @jesserockz +esphome/components/mics_5524/* @nliaudat esphome/components/midea/* @dudanov esphome/components/midea_ir/* @dudanov esphome/components/midea_portasplit/* @Fexiven diff --git a/esphome/components/mics_5524/__init__.py b/esphome/components/mics_5524/__init__.py new file mode 100644 index 00000000000..1b1c4111470 --- /dev/null +++ b/esphome/components/mics_5524/__init__.py @@ -0,0 +1,19 @@ +"""MiCS-5524 gas sensor platform: the DFRobot_MICS vendor model on a voltage sampler.""" + +import esphome.codegen as cg +from esphome.components import sensor + +CODEOWNERS = ["@nliaudat"] +DOMAIN = "mics_5524" + +mics_5524_ns = cg.esphome_ns.namespace("mics_5524") + +MiCS5524Component = mics_5524_ns.class_("MiCS5524Component", cg.PollingComponent) +MiCS5524GasSensor = mics_5524_ns.class_("MiCS5524GasSensor", sensor.Sensor) + +CONF_VOLTAGE_MULTIPLIER = "voltage_multiplier" +CONF_VCC = "vcc" +CONF_AIR_REFERENCE = "air_reference" +CONF_SAMPLE_COUNT = "sample_count" +CONF_SAMPLE_INTERVAL = "sample_interval" +CONF_MAX_PPM = "max_ppm" diff --git a/esphome/components/mics_5524/coefficients.py b/esphome/components/mics_5524/coefficients.py new file mode 100644 index 00000000000..cb1b0551f75 --- /dev/null +++ b/esphome/components/mics_5524/coefficients.py @@ -0,0 +1,32 @@ +"""Gas table of the DFRobot_MICS vendor model; the formula lives in mics_math.h.""" + +from dataclasses import dataclass + +from esphome.const import ( + CONF_AMMONIA, + CONF_CARBON_MONOXIDE, + CONF_ETHANOL, + CONF_HYDROGEN, + CONF_METHANE, +) + + +@dataclass(frozen=True) +class VendorCurve: + """Constants of the vendor model for one gas.""" + + label: str # vendor name of the gas + threshold: float # ratio at or below which the gas counts as detected + gain: float # ppm per unit ratio + min_ppm: float # the vendor reports 0 ppm below this + max_ppm: float # upper end of the measuring range + + +# DFRobot_MICS getGasData() thresholds and gains with the vendor measuring ranges. +GASES: dict[str, VendorCurve] = { + CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), + CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), + CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), + CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), +} diff --git a/esphome/components/mics_5524/mics_5524.cpp b/esphome/components/mics_5524/mics_5524.cpp new file mode 100644 index 00000000000..2023661b369 --- /dev/null +++ b/esphome/components/mics_5524/mics_5524.cpp @@ -0,0 +1,206 @@ +#include "mics_5524.h" + +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::mics_5524 { + +ESPHOME_LOG_TAG(TAG, "mics_5524"); + +void MiCS5524GasSensor::publish_ratio(float ratio) { + const float ppm = micsmath::ppm_from_vendor_curve(ratio, this->curve_); + ESP_LOGD(TAG, "'%s': ratio=%.4f -> %.1f ppm", this->get_name().c_str(), ratio, ppm); + this->publish_state(ppm); +} + +void MiCS5524Component::setup() { + if (this->has_reference_()) { + ESP_LOGI(TAG, "using the air reference from the configuration (%.4f V)", this->reference_); + } + + if (this->warmup_time_ > 0) { + this->stop_poller(); + this->set_timeout(this->warmup_time_, [this]() { this->start_poller(); }); + ESP_LOGI(TAG, "warm-up of %" PRIu32 " s - no readings published before that", this->warmup_time_ / 1000); + } + + if (this->calibration_samples_ > 0) { + // Nothing is stored across reboots; the warm-up window starts here, so the delay covers it. + const uint32_t delay = std::max(this->calibration_delay_, this->warmup_time_); + this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); + ESP_LOGI(TAG, "air reference calibration in %" PRIu32 " s - the sensor must be in clean air", delay / 1000); + } else if (!this->has_reference_()) { + ESP_LOGW(TAG, "no air reference available, the PPM values stay unknown\n" + " set 'air_reference:' or enable 'calibration:'"); + } + + // The loop only samples while a clean-air calibration runs; `update()` publishes. + this->disable_loop(); +} + +void MiCS5524Component::dump_config() { + ESP_LOGCONFIG(TAG, + "MiCS-5524:\n" + " VCC: %.2f V, AO multiplier: x%.3f, AO samples per reading: %u\n" + " Warm-up: %" PRIu32 " s, calibration: %s, sample spacing: %" PRIu32 " ms", + this->vcc_, this->voltage_multiplier_, static_cast(this->samples_), this->warmup_time_ / 1000, + this->calibration_samples_ > 0 ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"), + this->sample_interval_); + LOG_UPDATE_INTERVAL(this); + if (this->has_reference_()) { + ESP_LOGCONFIG(TAG, " Air reference (vcc - V_AO): %.4f V", this->reference_); + } else { + ESP_LOGCONFIG(TAG, " Air reference: not available yet"); + } +#ifdef MICS_5524_GAS_COUNT + for (auto *gas : this->gases_) { + LOG_SENSOR(" ", "Gas", gas); + ESP_LOGCONFIG(TAG, " Range: %.1f - %.1f ppm", gas->get_min_ppm(), gas->get_max_ppm()); + } +#endif +} + +float MiCS5524Component::sample_voltage_() { + // Taken back to back; waiting between them would block the main loop. + float sum = 0.0f; + for (uint8_t i = 0; i < this->samples_; i++) + sum += this->source_->sample(); + return (sum / static_cast(this->samples_)) * this->voltage_multiplier_; +} + +void MiCS5524Component::publish_ratio_(float ratio) { +#ifdef MICS_5524_GAS_COUNT + for (auto *gas : this->gases_) { + gas->publish_ratio(ratio); + } +#endif +} + +float MiCS5524Component::read_air_value_() { + const float voltage = this->sample_voltage_(); + const float x = micsmath::air_value_from_voltage(voltage, this->vcc_); + if (std::isfinite(x)) + return x; + // The status flag makes this log once per fault, not once per update. + if (!this->status_has_warning()) { + ESP_LOGW(TAG, "analog output reads %.4f V with vcc %.2f V\n check the AO wiring, 'vcc:' and 'voltage_multiplier:'", + voltage, this->vcc_); + } + this->status_set_warning(LOG_STR("analog output out of range")); + return NAN; +} + +void MiCS5524Component::update() { + // Every gas starts unknown and the reference is never cleared, so there is nothing to republish. + if (!this->has_reference_()) + return; + + const float air_value = this->read_air_value_(); + if (std::isnan(air_value)) { + this->publish_ratio_(NAN); + return; + } + this->status_clear_warning(); + + const float ratio = micsmath::ratio_from_air_value(air_value, this->reference_); + ESP_LOGD(TAG, "V_AO=%.4f V, x=%.4f, ratio=%.4f", this->vcc_ - air_value, air_value, ratio); + this->publish_ratio_(ratio); +} + +void MiCS5524Component::set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval) { + this->calibration_delay_ = delay; + this->calibration_samples_ = samples; + this->sample_interval_ = sample_interval; +} + +void MiCS5524Component::loop() { + if (!this->calibrating_) { + // Only a calibration samples here; the deferred start is a timer. + this->disable_loop(); + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->calibration_last_sample_ < this->sample_interval_) + return; + + this->calibration_last_sample_ = now; + this->process_calibration_(); +} + +void MiCS5524Component::begin_calibration_() { + this->calibrating_ = true; + this->calibration_last_sample_ = App.get_loop_component_start_time(); + this->calibration_count_ = 0; + this->calibration_attempts_ = 0; + this->calibration_sum_ = 0.0f; + this->calibration_min_ = INFINITY; + this->calibration_max_ = -INFINITY; + this->enable_loop(); + + ESP_LOGI(TAG, "calibrating the air reference, %" PRIu32 " samples - keep the sensor in clean air", + this->calibration_samples_); +} + +void MiCS5524Component::process_calibration_() { + this->calibration_attempts_++; + + const float reference = this->read_air_value_(); + if (!std::isnan(reference)) { + this->calibration_min_ = std::min(this->calibration_min_, reference); + this->calibration_max_ = std::max(this->calibration_max_, reference); + this->calibration_sum_ += reference; + this->calibration_count_++; + } + + if (this->calibration_attempts_ >= this->calibration_samples_) + this->finish_calibration_(); +} + +void MiCS5524Component::finish_calibration_() { + this->calibrating_ = false; + this->disable_loop(); + + const uint32_t attempts = this->calibration_attempts_; + const uint32_t valid = this->calibration_count_; + // A majority of the attempts has to have produced a sample, two at least. + const uint32_t required = std::max((attempts + 1) / 2, 2); + + if (valid < required) { + ESP_LOGW(TAG, "calibration failed, %" PRIu32 " of %" PRIu32 " samples were valid", valid, attempts); + this->schedule_calibration_retry_(); + return; + } + + const float reference = this->calibration_sum_ / static_cast(valid); + + // A sensor still settling, or not in clean air, varies too much to be the reference. + if (!micsmath::calibration_is_stable(this->calibration_min_, this->calibration_max_, reference)) { + ESP_LOGW( + TAG, "clean-air samples spread %.4f V around %.4f V (%.4f - %.4f V)\n the sensor was not settled in clean air", + this->calibration_max_ - this->calibration_min_, reference, this->calibration_min_, this->calibration_max_); + this->schedule_calibration_retry_(); + return; + } + + this->reference_ = reference; + this->status_clear_warning(); + + ESP_LOGI(TAG, + "air reference = %.4f (%" PRIu32 "/%" PRIu32 " samples valid)\n" + " hard-code 'air_reference: %.4f' to skip the calibration at boot", + this->reference_, valid, attempts, this->reference_); +} + +void MiCS5524Component::schedule_calibration_retry_() { + this->status_set_warning(); + // A zero delay would log the same failure about five times per second. + const uint32_t delay = std::max(this->calibration_delay_, CALIBRATION_RETRY_MIN_MS); + this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); + ESP_LOGW(TAG, "retrying the calibration in %" PRIu32 " s", delay / 1000); +} + +} // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_5524.h b/esphome/components/mics_5524/mics_5524.h new file mode 100644 index 00000000000..de7263e5d78 --- /dev/null +++ b/esphome/components/mics_5524/mics_5524.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/voltage_sampler/voltage_sampler.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "mics_math.h" + +namespace esphome::mics_5524 { + +/// One gas of a MiCS-5524: converts the measured ratio with the vendor curve of that gas. +class MiCS5524GasSensor : public sensor::Sensor { + public: + /// The vendor constants from coefficients.py. + MiCS5524GasSensor(float threshold, float gain, float min_ppm, float max_ppm) + : curve_{threshold, gain, min_ppm, max_ppm} {} + + /// Publish the concentration for `ratio`; NAN publishes `unknown`. + void publish_ratio(float ratio); + + float get_min_ppm() const { return this->curve_.min_ppm; } + float get_max_ppm() const { return this->curve_.max_ppm; } + + protected: + micsmath::VendorCurve curve_; +}; + +/// The MiCS-5524 breakout: one analog output, converted for every configured gas against +/// the clean-air reference x_air from `air_reference:` or a calibration at boot. +class MiCS5524Component : public PollingComponent { + public: + /// `source` must outlive this component, which code generation guarantees. + explicit MiCS5524Component(voltage_sampler::VoltageSampler *source) : source_(source) {} + + void setup() override; + void loop() override; + void update() override; + void dump_config() override; + +#ifdef MICS_5524_GAS_COUNT + void register_gas_sensor(MiCS5524GasSensor *gas) { this->gases_.push_back(gas); } +#endif + + void set_vcc(float vcc) { this->vcc_ = vcc; } + void set_voltage_multiplier(float multiplier) { this->voltage_multiplier_ = multiplier; } + void set_samples(uint8_t samples) { this->samples_ = samples; } + void set_warmup_time(uint32_t warmup) { this->warmup_time_ = warmup; } + /// Calibrate at boot: start after `delay` ms, average `samples` readings `sample_interval` ms apart. + void set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval); + void set_air_reference(float air_reference) { this->reference_ = air_reference; } + + protected: + /// Averaged, scaled output voltage (V); the conversions run back to back. + float sample_voltage_(); + /// x = vcc - V_AO, or NAN (and a status warning) when the voltage is unusable. + float read_air_value_(); + void publish_ratio_(float ratio); + + void begin_calibration_(); + void process_calibration_(); + void finish_calibration_(); + void schedule_calibration_retry_(); + bool has_reference_() const { return this->reference_ > 0.0f; } + + voltage_sampler::VoltageSampler *source_{nullptr}; + float vcc_{5.0f}; + float voltage_multiplier_{1.0f}; + uint32_t warmup_time_{0}; + + uint32_t calibration_delay_{0}; + /// 0 when calibration is disabled. + uint32_t calibration_samples_{0}; + /// Spacing of the clean-air calibration samples (ms); the ~16 ms loop tick is the floor. + uint32_t sample_interval_{20}; + + static constexpr uint32_t CALIBRATION_START_ID = 0; + /// Floor of the delay between two calibration attempts (ms). + static constexpr uint32_t CALIBRATION_RETRY_MIN_MS = 10000; + + uint32_t calibration_last_sample_{0}; + uint32_t calibration_count_{0}; + uint32_t calibration_attempts_{0}; + float calibration_sum_{0.0f}; + /// Smallest and largest clean-air sample of the run, see calibration_is_stable(). + float calibration_min_{0.0f}; + float calibration_max_{0.0f}; + + /// Clean-air reference x_air in volts, 0 while there is none. + float reference_{0.0f}; + + // Sized by code generation; the host unit tests run none, so the list compiles out there. +#ifdef MICS_5524_GAS_COUNT + StaticVector gases_; +#endif + + uint8_t samples_{4}; + bool calibrating_{false}; +}; + +} // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_math.h b/esphome/components/mics_5524/mics_math.h new file mode 100644 index 00000000000..e66def345db --- /dev/null +++ b/esphome/components/mics_5524/mics_math.h @@ -0,0 +1,59 @@ +#pragma once +// Pure math of the MiCS-5524 vendor model; the per-gas constants live in coefficients.py. +// x = VCC - V_ao, ratio = x / x_air (sampled in clean air), ppm = (threshold - ratio) / gain. +// No ESPHome dependencies, so the host tests cover it. + +#include + +namespace esphome::mics_5524::micsmath { + +/// Per-gas constants of the vendor model. +struct VendorCurve { + float threshold; ///< ratio at or below which the gas counts as detected + float gain; ///< ppm per unit ratio + float min_ppm; ///< the vendor reports 0 ppm below this + float max_ppm; ///< upper end of the measuring range +}; + +/// x = VCC - V_ao: 0 at VCC (a saturated sensor, never clean air). NaN at or below 0 V (a +/// disconnected pin) and above VCC, which only a wrong vcc or voltage_multiplier produces. +inline float air_value_from_voltage(float voltage, float vcc) { + if (!std::isfinite(voltage) || !std::isfinite(vcc) || voltage <= 0.0f || voltage > vcc) + return NAN; + return vcc - voltage; +} + +/// Largest relative spread (max - min) / mean a clean-air calibration accepts. +inline constexpr float MAX_CALIBRATION_SPREAD = 0.25f; + +/// True when the clean-air samples spread at most MAX_CALIBRATION_SPREAD of their mean. +inline bool calibration_is_stable(float min_value, float max_value, float mean) { + if (!std::isfinite(min_value) || !std::isfinite(max_value) || !std::isfinite(mean) || mean <= 0.0f) + return false; + return max_value - min_value <= MAX_CALIBRATION_SPREAD * mean; +} + +/// ratio = x / x_air (NaN when either value is unusable). +inline float ratio_from_air_value(float x, float x_air) { + if (!std::isfinite(x) || x < 0.0f || !std::isfinite(x_air) || x_air <= 0.0f) + return NAN; + return x / x_air; +} + +/// ratio -> ppm as DFRobot_MICS getGasData(): 0 above the threshold or below the vendor +/// range, the top of the range at ratio 0 (a saturated sensor), NaN stays NaN. +inline float ppm_from_vendor_curve(float ratio, const VendorCurve &curve) { + if (!std::isfinite(ratio)) + return NAN; + if (ratio > curve.threshold) + return 0.0f; + + const float ppm = (curve.threshold - ratio) / curve.gain; + if (ppm < curve.min_ppm) + return 0.0f; + if (ppm > curve.max_ppm) + return curve.max_ppm; + return ppm; +} + +} // namespace esphome::mics_5524::micsmath diff --git a/esphome/components/mics_5524/sensor.py b/esphome/components/mics_5524/sensor.py new file mode 100644 index 00000000000..f2b05c8da2e --- /dev/null +++ b/esphome/components/mics_5524/sensor.py @@ -0,0 +1,153 @@ +"""MiCS-5524 sensor platform: one entry per module, one sub-sensor per configured gas.""" + +import logging + +import esphome.codegen as cg +from esphome.components import sensor, voltage_sampler +import esphome.config_validation as cv +from esphome.const import ( + CONF_CALIBRATION, + CONF_DELAY, + CONF_ID, + CONF_VOLTAGE, + CONF_WARMUP_TIME, + STATE_CLASS_MEASUREMENT, + UNIT_PARTS_PER_MILLION, +) +from esphome.types import ConfigType + +from . import ( + CONF_AIR_REFERENCE, + CONF_MAX_PPM, + CONF_SAMPLE_COUNT, + CONF_SAMPLE_INTERVAL, + CONF_VCC, + CONF_VOLTAGE_MULTIPLIER, + MiCS5524Component, + MiCS5524GasSensor, +) +from .coefficients import GASES + +_LOGGER = logging.getLogger(__name__) + +AUTO_LOAD = ["sensor", "voltage_sampler"] + +# The main loop runs every ~16 ms, so a shorter spacing cannot sample more often. +MIN_SAMPLE_INTERVAL_MS = 16 + +# Conversions per reading run back to back inside one loop pass. +MAX_SAMPLE_COUNT = 16 + +_request_gas_slot = cg.slot_counter("MICS_5524_GAS_COUNT") + + +def _gas_schema(gas_key: str) -> cv.Schema: + """Schema of one gas sub-sensor.""" + curve = GASES[gas_key] + return sensor.sensor_schema( + MiCS5524GasSensor, + unit_of_measurement=UNIT_PARTS_PER_MILLION, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:molecule", + ).extend( + { + # A ceiling above the vendor range could never be reached. + cv.Optional(CONF_MAX_PPM, default=curve.max_ppm): cv.float_range( + min=0, min_included=False, max=curve.max_ppm + ) + } + ) + + +CALIBRATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds, + cv.Optional(CONF_SAMPLE_COUNT, default=10): cv.int_range( + min=2, max=MAX_SAMPLE_COUNT + ), + cv.Optional(CONF_SAMPLE_INTERVAL, default="20ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=cv.TimePeriod(milliseconds=MIN_SAMPLE_INTERVAL_MS)), + ), + } +) + + +def _validate_config(config: ConfigType) -> ConfigType: + """Reject an air reference at or above the supply; warn when there is none.""" + if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: + # The reference is vcc - V_AO in clean air, so a larger one reads 0 ppm forever. + if air_reference >= config[CONF_VCC]: + raise cv.Invalid( + f"air_reference ({air_reference:g} V) must be below vcc " + f"({config[CONF_VCC]:g} V): it is how far the analog output sits " + "below the supply in clean air" + ) + elif CONF_CALIBRATION not in config: + _LOGGER.warning( + "no air reference configured - set 'air_reference:' or 'calibration:', " + "the PPM values stay unknown until then" + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MiCS5524Component), + cv.Required(CONF_VOLTAGE): cv.use_id(voltage_sampler.VoltageSampler), + cv.Optional( + CONF_VOLTAGE_MULTIPLIER, default=1.0 + ): cv.positive_not_null_float, + cv.Optional(CONF_VCC, default=5.0): cv.positive_not_null_float, + cv.Optional(CONF_AIR_REFERENCE): cv.positive_not_null_float, + cv.Optional(CONF_SAMPLE_COUNT, default=4): cv.int_range( + min=1, max=MAX_SAMPLE_COUNT + ), + cv.Optional( + CONF_WARMUP_TIME, default="3min" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CALIBRATION): CALIBRATION_SCHEMA, + **{cv.Optional(gas_key): _gas_schema(gas_key) for gas_key in GASES}, + } + ).extend(cv.polling_component_schema("30s")), + cv.has_at_least_one_key(*GASES), + cv.has_at_most_one_key(CONF_AIR_REFERENCE, CONF_CALIBRATION), + _validate_config, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], await cg.get_variable(config[CONF_VOLTAGE])) + await cg.register_component(var, config) + + cg.add(var.set_vcc(config[CONF_VCC])) + cg.add(var.set_voltage_multiplier(config[CONF_VOLTAGE_MULTIPLIER])) + cg.add(var.set_samples(config[CONF_SAMPLE_COUNT])) + cg.add(var.set_warmup_time(config[CONF_WARMUP_TIME])) + + if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: + cg.add(var.set_air_reference(air_reference)) + elif (calibration := config.get(CONF_CALIBRATION)) is not None: + cg.add( + var.set_calibration( + calibration[CONF_DELAY], + calibration[CONF_SAMPLE_COUNT], + calibration[CONF_SAMPLE_INTERVAL], + ) + ) + + sensors = sensor.sub_sensors(config) + for gas_key, curve in GASES.items(): + if (gas_config := config.get(gas_key)) is None: + continue + _request_gas_slot(str(var)) + await sensors( + gas_key, + var.register_gas_sensor, + curve.threshold, + curve.gain, + curve.min_ppm, + gas_config[CONF_MAX_PPM], + ) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0655c4a399c..cd0f0d39103 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -165,6 +165,7 @@ #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 +#define MICS_5524_GAS_COUNT 5 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 #define NFC_TAG_LISTENER_COUNT 1 diff --git a/tests/component_tests/mics_5524/__init__.py b/tests/component_tests/mics_5524/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mics_5524/config/mics_5524.yaml b/tests/component_tests/mics_5524/config/mics_5524.yaml new file mode 100644 index 00000000000..7538fb973e1 --- /dev/null +++ b/tests/component_tests/mics_5524/config/mics_5524.yaml @@ -0,0 +1,37 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: adc + id: mics_5524_adc + name: MiCS-5524 AO + pin: GPIO34 + + # Calibrated at boot; every gas below it is published from the same ratio. + - platform: mics_5524 + id: mics_5524_boot + voltage: mics_5524_adc + voltage_multiplier: 1.5 + sample_count: 3 + calibration: + delay: 5s + sample_count: 3 + carbon_monoxide: + id: mics_5524_co + name: MiCS-5524 CO + max_ppm: 500.0 + methane: + id: mics_5524_ch4 + name: MiCS-5524 CH4 + + # The reference is hard-coded, so nothing is calibrated at boot. + - platform: mics_5524 + id: mics_5524_fixed + voltage: mics_5524_adc + air_reference: 3.6 + hydrogen: + id: mics_5524_h2 + name: MiCS-5524 H2 diff --git a/tests/component_tests/mics_5524/test_mics_5524.py b/tests/component_tests/mics_5524/test_mics_5524.py new file mode 100644 index 00000000000..53dc2f49708 --- /dev/null +++ b/tests/component_tests/mics_5524/test_mics_5524.py @@ -0,0 +1,205 @@ +"""Tests for the MiCS-5524 gas sensor code generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.mics_5524.coefficients import GASES, VendorCurve +from esphome.components.mics_5524.sensor import CONFIG_SCHEMA +from esphome.const import ( + CONF_AMMONIA, + CONF_CARBON_MONOXIDE, + CONF_ETHANOL, + CONF_HYDROGEN, + CONF_METHANE, +) + + +def test_voltage_source_is_a_constructor_argument( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Every reading needs the sampler, so it is passed at construction.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert ( + "new(mics_5524_boot) mics_5524::MiCS5524Component(mics_5524_adc);" in main_cpp + ) + assert ( + "new(mics_5524_fixed) mics_5524::MiCS5524Component(mics_5524_adc);" in main_cpp + ) + assert "set_source(" not in main_cpp + + +def test_every_gas_becomes_a_sub_sensor_of_its_hub( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The curve is passed to the constructor and the sub-sensor to its own hub.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + # Threshold, gain, minimum and maximum ppm of the gas. + assert ( + "new(mics_5524_co) mics_5524::MiCS5524GasSensor(0.425f, " + "0.000405f, 1.0f, 500.0f);" in main_cpp + ) + assert "mics_5524_boot->register_gas_sensor(mics_5524_co);" in main_cpp + # A gas without `max_ppm:` keeps the measuring range of the vendor. + assert ( + "new(mics_5524_ch4) mics_5524::MiCS5524GasSensor(0.786f, " + "2.3e-05f, 1000.0f, 25000.0f);" in main_cpp + ) + assert "mics_5524_boot->register_gas_sensor(mics_5524_ch4);" in main_cpp + # The gas belongs to the sensor it is configured under, not to the first one. + assert "mics_5524_fixed->register_gas_sensor(mics_5524_h2);" in main_cpp + assert "mics_5524_boot->register_gas_sensor(mics_5524_h2);" not in main_cpp + + +def test_shared_options_reach_the_hub( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The analog front end is configured once per physical sensor.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert "mics_5524_boot->set_vcc(5.0f);" in main_cpp + assert "mics_5524_boot->set_voltage_multiplier(1.5f);" in main_cpp + assert "mics_5524_boot->set_samples(3);" in main_cpp + assert "mics_5524_boot->set_warmup_time(180000);" in main_cpp + assert "mics_5524_boot->set_calibration(5000, 3, 20);" in main_cpp + assert "mics_5524_fixed->set_air_reference(3.6f);" in main_cpp + # `air_reference:` and `calibration:` exclude each other, so the sensor with the + # hard-coded reference is never calibrated at boot. + assert "mics_5524_fixed->set_calibration(" not in main_cpp + + +def test_air_reference_and_calibration_exclude_each_other() -> None: + """Two references for one sensor would silently pick one, so it is an error.""" + with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, + "air_reference": 3.6, + "calibration": {"delay": "5s"}, + } + ) + + +@pytest.mark.parametrize("air_reference", [5.0, 6.0]) +def test_air_reference_at_or_above_vcc_is_rejected(air_reference: float) -> None: + """A reference at or above the supply would report every gas as 0 ppm forever.""" + with pytest.raises(cv.Invalid, match="must be below vcc"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "vcc": 5.0, + "hydrogen": {"name": "MiCS-5524 H2 (reference above vcc)"}, + "air_reference": air_reference, + } + ) + + +def test_air_reference_below_vcc_is_accepted() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "vcc": 5.0, + "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, + "air_reference": 3.6, + } + ) + + assert config["air_reference"] == 3.6 + + +def test_at_least_one_gas_is_required() -> None: + """A sensor without a gas has nothing to publish.""" + with pytest.raises(cv.Invalid, match="Must contain at least one of"): + CONFIG_SCHEMA({"id": "mics_5524", "voltage": "mics_5524_adc", "vcc": 5.0}) + + +def test_max_ppm_above_the_vendor_range_is_rejected() -> None: + """A ceiling the conversion can never reach is a configuration error.""" + with pytest.raises(cv.Invalid, match="value must be at most 1000.0"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "carbon_monoxide": { + "name": "MiCS-5524 CO (rejected)", + "max_ppm": 2000.0, + }, + "air_reference": 3.6, + } + ) + + +def test_max_ppm_at_the_vendor_range_is_accepted() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "carbon_monoxide": {"name": "MiCS-5524 CO (accepted)", "max_ppm": 1000.0}, + "air_reference": 3.6, + } + ) + + assert config["carbon_monoxide"]["max_ppm"] == 1000.0 + + +def test_max_ppm_defaults_to_the_vendor_range_of_the_gas() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "carbon_monoxide": {"name": "MiCS-5524 CO (default)"}, + "air_reference": 3.6, + } + ) + + assert config["carbon_monoxide"]["max_ppm"] == 1000.0 + + +def test_calibration_cannot_sample_faster_than_the_loop() -> None: + """The main loop runs every ~16 ms, so a shorter spacing samples nothing extra.""" + with pytest.raises(cv.Invalid, match="value must be at least 16ms"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "hydrogen": {"name": "MiCS-5524 H2 (tick)"}, + "calibration": {"sample_interval": "10ms"}, + } + ) + + +def test_voltage_multiplier_defaults_to_one() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "hydrogen": {"name": "MiCS-5524 H2 (default)"}, + "air_reference": 3.6, + } + ) + + assert config["voltage_multiplier"] == 1.0 + + +def test_vendor_table_is_the_datasheet_one() -> None: + """The whole table is pinned, not only the gases a config file happens to use.""" + expected = { + CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), + CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), + CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), + CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), + } + + assert expected == GASES diff --git a/tests/components/mics_5524/common.h b/tests/components/mics_5524/common.h new file mode 100644 index 00000000000..7f46ba9bea3 --- /dev/null +++ b/tests/components/mics_5524/common.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include + +#include "esphome/components/mics_5524/mics_math.h" + +namespace esphome::mics_5524::testing { + +using micsmath::air_value_from_voltage; +using micsmath::calibration_is_stable; +using micsmath::ppm_from_vendor_curve; +using micsmath::ratio_from_air_value; + +// Independent copy of the DFRobot table; the Python tests pin coefficients.py itself. +constexpr micsmath::VendorCurve CO{0.425f, 0.000405f, 1.0f, 1000.0f}; +constexpr micsmath::VendorCurve H2{0.279f, 0.00026f, 1.0f, 1000.0f}; +constexpr micsmath::VendorCurve NH3{0.8f, 0.0015f, 10.0f, 500.0f}; +constexpr micsmath::VendorCurve C2H5OH{0.306f, 0.00057f, 10.0f, 500.0f}; +constexpr micsmath::VendorCurve CH4{0.786f, 0.000023f, 1000.0f, 25000.0f}; + +} // namespace esphome::mics_5524::testing diff --git a/tests/components/mics_5524/common.yaml b/tests/components/mics_5524/common.yaml new file mode 100644 index 00000000000..eb5f69b14ee --- /dev/null +++ b/tests/components/mics_5524/common.yaml @@ -0,0 +1,22 @@ +sensor: + - platform: adc + id: mics_5524_adc + name: MiCS-5524 AO + pin: ${mics_pin} + + - platform: mics_5524 + id: mics_5524_module + # 10k/20k divider: the 5 V breakout's 3.33 V stays under the ADC limit. + voltage: mics_5524_adc + voltage_multiplier: 1.5 + sample_count: 3 + warmup_time: 1s + calibration: + delay: 5s + sample_count: 3 + sample_interval: 20ms + carbon_monoxide: + name: MiCS-5524 CO + max_ppm: 500.0 + hydrogen: + name: MiCS-5524 H2 diff --git a/tests/components/mics_5524/sensor/mics_math_test.cpp b/tests/components/mics_5524/sensor/mics_math_test.cpp new file mode 100644 index 00000000000..946b4fa2e2e --- /dev/null +++ b/tests/components/mics_5524/sensor/mics_math_test.cpp @@ -0,0 +1,93 @@ +#include "../common.h" + +namespace esphome::mics_5524::testing { + +TEST(MicsMath, SaturatedSensorReportsTheTopOfTheRange) { + // ratio 0 is V_AO at VCC. The vendor helpers return the top of the range there, + // so a saturated sensor must not be converted into a clean-air reading. + EXPECT_FLOAT_EQ(CO.max_ppm, ppm_from_vendor_curve(0.0f, CO)); + EXPECT_FLOAT_EQ(H2.max_ppm, ppm_from_vendor_curve(0.0f, H2)); + EXPECT_FLOAT_EQ(NH3.max_ppm, ppm_from_vendor_curve(0.0f, NH3)); + EXPECT_FLOAT_EQ(C2H5OH.max_ppm, ppm_from_vendor_curve(0.0f, C2H5OH)); + EXPECT_FLOAT_EQ(CH4.max_ppm, ppm_from_vendor_curve(0.0f, CH4)); +} + +TEST(MicsMath, GasFreeAirReadsZero) { + // At or above the threshold the vendor reports no gas, and so does a value below + // its own measuring range. + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(CO.threshold, CO)); + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(CO.threshold + 0.01f, CO)); + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(1.0f, CO)); + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(0.4249f, CO)); // 0.25 ppm, below the range +} + +TEST(MicsMath, MidRangeReadingsFollowTheVendorFormula) { + // (0.425 - 0.2) / 0.000405 = 555.56 ppm + EXPECT_NEAR(555.556f, ppm_from_vendor_curve(0.2f, CO), 0.01f); + // (0.8 - 0.7) / 0.0015 = 66.67 ppm + EXPECT_NEAR(66.667f, ppm_from_vendor_curve(0.7f, NH3), 0.01f); + // (0.279 - 0.1) / 0.00026 = 688.46 ppm + EXPECT_NEAR(688.462f, ppm_from_vendor_curve(0.1f, H2), 0.01f); +} + +TEST(MicsMath, UnusableInputReadsUnknown) { + EXPECT_TRUE(std::isnan(ppm_from_vendor_curve(NAN, CO))); + EXPECT_TRUE(std::isnan(ppm_from_vendor_curve(INFINITY, CO))); + EXPECT_TRUE(std::isnan(air_value_from_voltage(NAN, 5.0f))); + EXPECT_TRUE(std::isnan(air_value_from_voltage(4.0f, NAN))); + EXPECT_TRUE(std::isnan(ratio_from_air_value(1.0f, NAN))); + EXPECT_TRUE(std::isnan(ratio_from_air_value(NAN, 1.0f))); + // A missing clean-air reference (x_air = 0) must not be read as a real ratio. + EXPECT_TRUE(std::isnan(ratio_from_air_value(1.0f, 0.0f))); +} + +TEST(MicsMath, AirValueIsTheVoltageBelowVcc) { + EXPECT_FLOAT_EQ(1.0f, air_value_from_voltage(4.0f, 5.0f)); + // At VCC the vendor value is 0, the saturated end of the range ... + EXPECT_FLOAT_EQ(0.0f, air_value_from_voltage(5.0f, 5.0f)); + // ... but a voltage above VCC cannot come from the sensor, so it is unusable rather + // than saturated: a wrong `vcc:` is not reported as a real reading. + EXPECT_TRUE(std::isnan(air_value_from_voltage(5.2f, 5.0f))); + // A pin at or below 0 V is disconnected, not a reading of VCC. + EXPECT_TRUE(std::isnan(air_value_from_voltage(0.0f, 5.0f))); + EXPECT_TRUE(std::isnan(air_value_from_voltage(-0.1f, 5.0f))); + // The two ends of the vendor range, and the unusable case, one by one. + EXPECT_FLOAT_EQ(1.0f, ratio_from_air_value(air_value_from_voltage(4.0f, 5.0f), 1.0f)); + EXPECT_FLOAT_EQ(0.0f, ratio_from_air_value(air_value_from_voltage(5.0f, 5.0f), 1.0f)); + EXPECT_TRUE(std::isnan(ratio_from_air_value(air_value_from_voltage(6.0f, 5.0f), 1.0f))); +} + +TEST(MicsMath, CalibrationSamplesMustAgree) { + // Clean air at 3.6 V with a few millivolts of ADC noise: a settled reference. + EXPECT_TRUE(calibration_is_stable(3.555f, 3.645f, 3.6f)); + // A sensor that is still settling or not in clean air: (4.9 - 0.1) / 2.5 = 1.92. + EXPECT_FALSE(calibration_is_stable(0.1f, 4.9f, 2.5f)); + // The limit is inclusive: 0.25 of the mean passes, a little more does not. + EXPECT_TRUE(calibration_is_stable(3.0f, 4.0f, 4.0f)); + EXPECT_FALSE(calibration_is_stable(2.9f, 4.0f, 4.0f)); + // Unusable values are never a settled reference. + EXPECT_FALSE(calibration_is_stable(NAN, 3.6f, 3.6f)); + EXPECT_FALSE(calibration_is_stable(3.6f, NAN, 3.6f)); + EXPECT_FALSE(calibration_is_stable(3.6f, 3.6f, NAN)); + EXPECT_FALSE(calibration_is_stable(3.6f, 3.6f, 0.0f)); +} + +TEST(MicsMath, ConfiguredCeilingCapsTheReading) { + // `max_ppm:` narrows the vendor range: a reading above the ceiling is reported as + // the ceiling, never as a value the sensor is not configured for. + const micsmath::VendorCurve narrow{0.425f, 0.000405f, 1.0f, 500.0f}; + EXPECT_FLOAT_EQ(500.0f, ppm_from_vendor_curve(0.2f, narrow)); // 555.56 ppm + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(0.5f, narrow)); // above the threshold +} + +TEST(MicsMath, SaturatedSensorEndToEnd) { + // V_AO at VCC -> x = 0 -> ratio 0 -> top of the range, the case that used to read + // 0 ppm and made a saturated sensor look like clean air. + const float x = air_value_from_voltage(5.0f, 5.0f); + const float ratio = ratio_from_air_value(x, 1.0f); + + EXPECT_FLOAT_EQ(0.0f, ratio); + EXPECT_FLOAT_EQ(CO.max_ppm, ppm_from_vendor_curve(ratio, CO)); +} + +} // namespace esphome::mics_5524::testing diff --git a/tests/components/mics_5524/test.esp32-idf.yaml b/tests/components/mics_5524/test.esp32-idf.yaml new file mode 100644 index 00000000000..1028eb373bc --- /dev/null +++ b/tests/components/mics_5524/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + mics_pin: GPIO34 + +packages: + mics_5524: !include common.yaml diff --git a/tests/components/mics_5524/test.esp8266-ard.yaml b/tests/components/mics_5524/test.esp8266-ard.yaml new file mode 100644 index 00000000000..6383f1e139a --- /dev/null +++ b/tests/components/mics_5524/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + mics_pin: A0 + +packages: + mics_5524: !include common.yaml diff --git a/tests/components/mics_5524/test.rp2040-ard.yaml b/tests/components/mics_5524/test.rp2040-ard.yaml new file mode 100644 index 00000000000..a5aff154eba --- /dev/null +++ b/tests/components/mics_5524/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + mics_pin: GPIO26 + +packages: + mics_5524: !include common.yaml diff --git a/tests/components/mics_5524/validate.esp32-idf.yaml b/tests/components/mics_5524/validate.esp32-idf.yaml new file mode 100644 index 00000000000..9ec6154be60 --- /dev/null +++ b/tests/components/mics_5524/validate.esp32-idf.yaml @@ -0,0 +1,23 @@ +# Config-only test: the external voltage sampler path and the PPM range options. +sensor: + - platform: adc + id: mics_5524_adc + name: MiCS-5524 AO + pin: GPIO35 + + - platform: mics_5524 + id: mics_5524_module + voltage: mics_5524_adc + voltage_multiplier: 1.5 + air_reference: 3.6 + vcc: 5.0 + sample_count: 2 + warmup_time: 30s + carbon_monoxide: + name: MiCS-5524 CO + max_ppm: 1000.0 + hydrogen: + name: MiCS-5524 H2 + ammonia: + name: MiCS-5524 NH3 + max_ppm: 300.0 From 04aa59563dc9cdae081f501777c08fff45225d72 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:45:03 +1100 Subject: [PATCH 008/120] [display] Drop the color order from draw_pixels_at() and ColorUtil (#20884) --- esphome/components/display/display.cpp | 61 ++++-- esphome/components/display/display.h | 50 ++++- .../components/display/display_color_utils.h | 178 ++++++++++-------- esphome/components/epaper_spi/epaper_spi.cpp | 13 +- esphome/components/epaper_spi/epaper_spi.h | 9 +- esphome/components/hub75/hub75.cpp | 17 +- esphome/components/hub75/hub75_component.h | 5 +- .../components/ili9xxx/ili9xxx_display.cpp | 18 +- esphome/components/ili9xxx/ili9xxx_display.h | 5 +- esphome/components/image/image.cpp | 4 +- esphome/components/it8951/it8951.cpp | 9 +- esphome/components/it8951/it8951.h | 11 +- esphome/components/lvgl/lvgl_esphome.cpp | 3 +- esphome/components/mipi_dsi/mipi_dsi.cpp | 11 +- esphome/components/mipi_dsi/mipi_dsi.h | 6 +- esphome/components/mipi_rgb/mipi_rgb.cpp | 9 +- esphome/components/mipi_rgb/mipi_rgb.h | 7 +- esphome/components/mipi_spi/mipi_spi.h | 120 ++++++------ esphome/components/pixoo/pixoo.cpp | 28 ++- esphome/components/pixoo/pixoo.h | 5 +- esphome/components/qspi_dbi/qspi_dbi.cpp | 22 +-- esphome/components/qspi_dbi/qspi_dbi.h | 8 +- .../components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 12 +- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h | 5 +- esphome/components/sdl/sdl_esphome.cpp | 8 +- esphome/components/sdl/sdl_esphome.h | 5 +- .../snapshot/display/snapshot_display.cpp | 10 +- .../snapshot/display/snapshot_display.h | 6 +- esphome/components/st7701s/st7701s.cpp | 12 +- esphome/components/st7701s/st7701s.h | 7 +- esphome/components/st7735/st7735.cpp | 4 +- esphome/components/st7789v/st7789v.cpp | 3 +- .../display/bench_draw_pixels_at.cpp | 2 +- .../components/image/bench_image_draw.cpp | 6 +- .../display/test_draw_pixels_at.cpp | 159 +++++++++++++++- .../epaper_spi/display/test_draw_pixels.cpp | 86 ++++++--- tests/components/image/test_image_draw.cpp | 6 +- 37 files changed, 598 insertions(+), 332 deletions(-) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index ba8f85463f4..a5dd398e81b 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -57,17 +57,17 @@ void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_ // -Os stops inlining it once there are two call sites, which costs a call and // spills per pixel. Without PSRAM there is one call site and it changes // nothing, and draw_pixel_at() stays a virtual call either way. -template -static void __attribute__((flatten)) -draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - bool big_endian, int x_offset, int y_offset, int x_pad) { +// HIGH_BYTE_FIRST (big endian) only matters for 565; as a template argument its test leaves the pixel loop. +template +static void __attribute__((flatten)) draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, + const uint8_t *ptr, int x_offset, int y_offset, int x_pad) { const size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels // BITNESS is a constant here, so to_color()'s three per pixel divisions // become a multiply and shift and its bitness switch folds. const auto draw_source_pixel = [&](int x, int y, size_t source_idx) ESPHOME_ALWAYS_INLINE { display->draw_pixel_at( x + x_start, y + y_start, - ColorUtil::to_color(ColorUtil::read_packed(ptr, source_idx, big_endian), order, BITNESS)); + ColorUtil::to_color(ColorUtil::read_packed(ptr, source_idx, HIGH_BYTE_FIRST), BITNESS)); }; // The source and frame buffers are row-major, so walking rows keeps the read // and the write sequential. When the display swaps the axes before writing, @@ -94,20 +94,59 @@ draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, con } } +// Remove before 2027.5.0. Draws a block in the deprecated BGR or GRB order, pixel by pixel. Not inlined, so +// the RGB loops above compile as tightly as before. +static void __attribute__((noinline)) +draw_ordered_pixels(Display *display, 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) { + const size_t line_stride = x_offset + w + x_pad; + for (int y = 0; y != h; y++) { + size_t source_idx = (y_offset + y) * line_stride + x_offset; + for (int x = 0; x != w; x++, source_idx++) { + uint32_t code; + if (bitness == COLOR_BITNESS_565) { + code = ColorUtil::read_packed(ptr, source_idx, big_endian); + } else if (bitness == COLOR_BITNESS_888) { + code = ColorUtil::read_packed(ptr, source_idx, big_endian); + } else { + code = ColorUtil::read_packed(ptr, source_idx, big_endian); + } + display->draw_pixel_at(x + x_start, y + y_start, apply_color_order(ColorUtil::to_color(code, bitness), order)); + } + } +} + +void Display::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) { + // Through the deprecated form, so a driver that still overrides only that one keeps its fast path. + // Remove before 2027.5.0, when the per pixel drawing below moves here. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->draw_pixels_at(x_start, y_start, w, h, ptr, COLOR_ORDER_RGB, bitness, big_endian, x_offset, y_offset, x_pad); +#pragma GCC diagnostic pop +} + +// Must not call draw_pixels_at() without a color order or draw_pixels_internal(): those call this, and a legacy +// override's fallback ends up here. void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + if (order != COLOR_ORDER_RGB) { + draw_ordered_pixels(this, x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } switch (bitness) { case COLOR_BITNESS_565: - draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, - x_pad); + if (big_endian) { + draw_packed_pixels(this, x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); + } else { + draw_packed_pixels(this, x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); + } break; case COLOR_BITNESS_888: - draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, - x_pad); + draw_packed_pixels(this, x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); break; default: - draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, - x_pad); + draw_packed_pixels(this, x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); break; } } diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index b43a3221518..a6ac319939c 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -346,8 +346,6 @@ class Display : public PollingComponent { virtual void draw_pixel_at(int x, int y, Color color) = 0; /** Given an array of pixels encoded in the nominated format, draw these into the display's buffer. - * The naive implementation here will work in all cases, but can be overridden by sub-classes - * in order to optimise the procedure. * The parameters describe a rectangular block of pixels, potentially within a larger buffer. * * \param x_start The starting destination x position @@ -355,27 +353,49 @@ class Display : public PollingComponent { * \param w the width of the pixel block * \param h the height of the pixel block * \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 bitness Defines the number of bits and their format for each pixel. Red is always in the + * most significant bits. * \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. + * in memory, 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. * * 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. + * The block must lie on the screen. A driver that can copy blocks itself overrides draw_pixels_internal(). */ + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, bool big_endian, + int x_offset, int y_offset, int x_pad) { + this->draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); + } + + /// Convenience overload for base case where the pixels are packed into the buffer with no gaps (e.g. suits LVGL.) + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian) { + this->draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, 0, 0, 0); + } + + // Remove before 2027.5.0 + /// The block form above with a color order. The base version draws per pixel and honors the order; + /// a driver that still overrides this one keeps receiving every block, since draw_pixels_internal() calls it. + /// Such a driver needs `using display::Display::draw_pixels_at;` so the forms above stay visible through it, + /// and its fallback must call Display::draw_pixels_at() with the order: Display::draw_pixels_internal() would + /// call the override again. + ESPDEPRECATED( + "Pixels are always RGB: call draw_pixels_at() without the ColorOrder, and override draw_pixels_internal() " + "to copy blocks. Removed in 2027.5.0", + "2026.11.0") 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); - /// Convenience overload for base case where the pixels are packed into the buffer with no gaps (e.g. suits LVGL.) + // Remove before 2027.5.0 + ESPDEPRECATED("Pixels are always RGB, drop the ColorOrder argument. Removed in 2027.5.0", "2026.11.0") 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) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" this->draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, 0, 0, 0); +#pragma GCC diagnostic pop } /// Draw a straight line from the point [x1,y1] to [x2,y2] with the given color. @@ -796,6 +816,16 @@ class Display : public PollingComponent { void show_test_card() { this->show_test_card_ = true; } protected: + /** Draws a block for draw_pixels_at(); the parameters are the same. The base version draws it per pixel + * through draw_pixel_at(), so it works on any display. + * + * A driver can override it to copy blocks that are already laid out the way its own draw_pixel_at() + * writes, and must send any other format, byte order, a software rotation or an active clipping + * rectangle through Display::draw_pixels_internal(), which draws per pixel. Do not also override the + * deprecated draw_pixels_at() with a color order: Display::draw_pixels_internal() calls it. + */ + virtual void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad); bool clamp_x_(int x, int w, int &min_x, int &max_x); bool clamp_y_(int y, int h, int &min_y, int &max_y); void vprintf_(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format, diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index bd658986519..1b4f47c1250 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -9,6 +9,38 @@ enum ColorOrder : uint8_t { COLOR_ORDER_RGB = 0, COLOR_ORDER_BGR = 1, COLOR_ORDE enum ColorBitness : uint8_t { COLOR_BITNESS_888 = 0, COLOR_BITNESS_565 = 1, COLOR_BITNESS_332 = 2 }; inline static uint8_t esp_scale(uint8_t i, uint8_t scale, uint8_t max_value = 255) { return (max_value * i / scale); } +/// esp_scale(value, (1 << bits) - 1) for a field of 2, 3, 5, 6 or 8 bits, as a multiply and shift that gives the +/// same result for every field value. With bits a constant this avoids a division. +constexpr uint8_t expand_field(uint8_t value, uint8_t bits) { + switch (bits) { + case 2: + return (value * 170) >> 1; + case 3: + return (value * 583) >> 4; + case 5: + return (value * 1053) >> 7; + case 6: + return (value * 4145) >> 10; + case 8: + return value; + default: + return esp_scale(value, (1 << bits) - 1); + } +} + +// Remove before 2027.5.0, with the forms that take a color order. Maps a color read in RGB order to the +// given order, by moving its channels. +inline Color apply_color_order(Color color, ColorOrder order) { + switch (order) { + case COLOR_ORDER_BGR: + return Color(color.b, color.g, color.r); + case COLOR_ORDER_GRB: + return Color(color.g, color.r, color.b); + default: + return color; + } +} + class ColorUtil { public: /// Reads one packed pixel of the given bitness from a source buffer into the @@ -23,7 +55,7 @@ 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 + // Always the LVGL RGB888 layout, b,g,r in memory; byte order applies to 565 only const size_t idx = index * 3; return ptr[idx + 0] + (ptr[idx + 1] << 8) + (ptr[idx + 2] << 16); } else { @@ -31,9 +63,9 @@ class ColorUtil { } } - static Color to_color(uint32_t colorcode, ColorOrder color_order, - ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, bool right_bit_aligned = true) { - uint8_t first_color, second_color, third_color; + /// Unpacks a color of the given bitness, red in the most significant bits. + static Color to_color(uint32_t colorcode, ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, + bool right_bit_aligned = true) { uint8_t first_bits = 0; uint8_t second_bits = 0; uint8_t third_bits = 0; @@ -56,74 +88,66 @@ class ColorUtil { break; } - first_color = right_bit_aligned ? esp_scale(((colorcode >> (second_bits + third_bits)) & ((1 << first_bits) - 1)), - ((1 << first_bits) - 1)) - : esp_scale(((colorcode >> 16) & 0xFF), (1 << first_bits) - 1); + const uint8_t red = + right_bit_aligned + ? expand_field((colorcode >> (second_bits + third_bits)) & ((1 << first_bits) - 1), first_bits) + : esp_scale(((colorcode >> 16) & 0xFF), (1 << first_bits) - 1); - second_color = right_bit_aligned - ? esp_scale(((colorcode >> third_bits) & ((1 << second_bits) - 1)), ((1 << second_bits) - 1)) - : esp_scale(((colorcode >> 8) & 0xFF), ((1 << second_bits) - 1)); + const uint8_t green = right_bit_aligned + ? expand_field((colorcode >> third_bits) & ((1 << second_bits) - 1), second_bits) + : esp_scale(((colorcode >> 8) & 0xFF), ((1 << second_bits) - 1)); - third_color = (right_bit_aligned ? esp_scale(((colorcode >> 0) & ((1 << third_bits) - 1)), ((1 << third_bits) - 1)) - : esp_scale(((colorcode >> 0) & 0xFF), (1 << third_bits) - 1)); + const uint8_t blue = right_bit_aligned ? expand_field(colorcode & ((1 << third_bits) - 1), third_bits) + : esp_scale(((colorcode >> 0) & 0xFF), (1 << third_bits) - 1); - Color color_return; - - switch (color_order) { - case COLOR_ORDER_RGB: - color_return.r = first_color; - color_return.g = second_color; - color_return.b = third_color; - break; - case COLOR_ORDER_BGR: - color_return.b = first_color; - color_return.g = second_color; - color_return.r = third_color; - break; - case COLOR_ORDER_GRB: - color_return.g = first_color; - color_return.r = second_color; - color_return.b = third_color; - break; - } - return color_return; + return Color(red, green, blue); + } + // Remove before 2027.5.0 + ESPDEPRECATED("Packed colors are always RGB, drop the ColorOrder argument. Removed in 2027.5.0", "2026.11.0") + static Color to_color(uint32_t colorcode, ColorOrder color_order, + ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, bool right_bit_aligned = true) { + return apply_color_order(to_color(colorcode, color_bitness, right_bit_aligned), color_order); } static inline Color rgb332_to_color(uint8_t rgb332_color) { - return to_color((uint32_t) rgb332_color, COLOR_ORDER_RGB, COLOR_BITNESS_332); + return to_color((uint32_t) rgb332_color, COLOR_BITNESS_332); } - static uint8_t color_to_332(Color color, ColorOrder color_order = ColorOrder::COLOR_ORDER_RGB) { - uint16_t red_color, green_color, blue_color; - - red_color = esp_scale8(color.red, ((1 << 3) - 1)); - green_color = esp_scale8(color.green, ((1 << 3) - 1)); - blue_color = esp_scale8(color.blue, (1 << 2) - 1); - - switch (color_order) { - case COLOR_ORDER_RGB: - return red_color << 5 | green_color << 2 | blue_color; - case COLOR_ORDER_BGR: - return blue_color << 6 | green_color << 3 | red_color; - case COLOR_ORDER_GRB: - return green_color << 5 | red_color << 2 | blue_color; - } - return 0; + static uint8_t color_to_332(Color color) { + const uint16_t red_color = esp_scale8(color.red, ((1 << 3) - 1)); + const uint16_t green_color = esp_scale8(color.green, ((1 << 3) - 1)); + const uint16_t blue_color = esp_scale8(color.blue, (1 << 2) - 1); + return red_color << 5 | green_color << 2 | blue_color; } - static uint16_t color_to_565(Color color, ColorOrder color_order = ColorOrder::COLOR_ORDER_RGB) { - uint16_t red_color, green_color, blue_color; - - red_color = esp_scale8(color.red, ((1 << 5) - 1)); - green_color = esp_scale8(color.green, ((1 << 6) - 1)); - blue_color = esp_scale8(color.blue, (1 << 5) - 1); - + // Remove before 2027.5.0 + ESPDEPRECATED("Packed colors are always RGB, drop the ColorOrder argument. Removed in 2027.5.0", "2026.11.0") + static uint8_t color_to_332(Color color, ColorOrder color_order) { + if (color_order == COLOR_ORDER_RGB) + return color_to_332(color); + // The other orders move the 2 bit field, so they cannot reuse the RGB packing + const uint16_t red_color = esp_scale8(color.red, ((1 << 3) - 1)); + const uint16_t green_color = esp_scale8(color.green, ((1 << 3) - 1)); + if (color_order == COLOR_ORDER_BGR) + return esp_scale8(color.blue, (1 << 2) - 1) << 6 | green_color << 3 | red_color; + return green_color << 5 | red_color << 2 | esp_scale8(color.blue, (1 << 2) - 1); + } + static uint16_t color_to_565(Color color) { + const uint16_t red_color = esp_scale8(color.red, ((1 << 5) - 1)); + const uint16_t green_color = esp_scale8(color.green, ((1 << 6) - 1)); + const uint16_t blue_color = esp_scale8(color.blue, (1 << 5) - 1); + return red_color << 11 | green_color << 5 | blue_color; + } + // Remove before 2027.5.0 + ESPDEPRECATED("Packed colors are always RGB, drop the ColorOrder argument. Removed in 2027.5.0", "2026.11.0") + static uint16_t color_to_565(Color color, ColorOrder color_order) { switch (color_order) { - case COLOR_ORDER_RGB: - return red_color << 11 | green_color << 5 | blue_color; case COLOR_ORDER_BGR: - return blue_color << 11 | green_color << 5 | red_color; + return color_to_565(Color(color.b, color.g, color.r)); case COLOR_ORDER_GRB: - return green_color << 10 | red_color << 5 | blue_color; + // Green moves to the 5 bit field at the top, so it cannot reuse the RGB packing + return esp_scale8(color.green, (1 << 6) - 1) << 10 | esp_scale8(color.red, (1 << 5) - 1) << 5 | + esp_scale8(color.blue, (1 << 5) - 1); + default: + return color_to_565(color); } - return 0; } static uint32_t color_to_grayscale4(Color color) { uint32_t gs4 = esp_scale8(color.white, 15); @@ -196,23 +220,13 @@ constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 */ class Rgb565ToMono { public: + Rgb565ToMono() { this->fill_tables_(REC601_LUMA_R, REC601_LUMA_G, REC601_LUMA_B); } + // Remove before 2027.5.0 + ESPDEPRECATED("Packed colors are always RGB, drop the ColorOrder argument. Removed in 2027.5.0", "2026.11.0") explicit Rgb565ToMono(ColorOrder order) { - // Rec.601 weight of the 5-bit, 6-bit and 5-bit fields, by the channel each one holds in this order - uint16_t high = REC601_LUMA_R, middle = REC601_LUMA_G, low = REC601_LUMA_B; - if (order == COLOR_ORDER_BGR) { - high = REC601_LUMA_B; - low = REC601_LUMA_R; - } else if (order == COLOR_ORDER_GRB) { - high = REC601_LUMA_G; - middle = REC601_LUMA_R; - } - // Each field value scaled to 0..255, then weighted - for (unsigned i = 0; i != 32; i++) { - this->high_[i] = high * esp_scale(i, 31); - this->low_[i] = low * esp_scale(i, 31); - } - for (unsigned i = 0; i != 64; i++) - this->middle_[i] = middle * esp_scale(i, 63); + // The weight of the channel each 5, 6 and 5 bit field holds in this order + const Color weights = apply_color_order(Color(REC601_LUMA_R, REC601_LUMA_G, REC601_LUMA_B), order); + this->fill_tables_(weights.r, weights.g, weights.b); } uint8_t operator()(uint16_t value) const { @@ -221,6 +235,16 @@ class Rgb565ToMono { } protected: + // Each field value scaled to 0..255, then weighted + void fill_tables_(uint16_t high, uint16_t middle, uint16_t low) { + for (unsigned i = 0; i != 32; i++) { + this->high_[i] = high * esp_scale(i, 31); + this->low_[i] = low * esp_scale(i, 31); + } + for (unsigned i = 0; i != 64; i++) + this->middle_[i] = middle * esp_scale(i, 63); + } + // Weighted contribution of each value of each field std::array high_{}; std::array middle_{}; diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index d8ead388183..a0c90a0b686 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -399,7 +399,7 @@ void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_widt // Each source column maps to one buffer coordinate and each source row to the other, each moving by 1 per pixel, so // the visible part of a block and its dirty area are worked out once for the whole block. void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, - ColorOrder order, bool big_endian, int x_offset, int y_offset, int x_pad) { + bool big_endian, int x_offset, int y_offset, int x_pad) { const bool swap = this->effective_transform_ & SWAP_XY; const bool mirror_x = this->effective_transform_ & MIRROR_X; const bool mirror_y = this->effective_transform_ & MIRROR_Y; @@ -432,7 +432,7 @@ void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_star const int x_high = x_low + (swap ? rows : columns); const int y_high = y_low + (swap ? columns : rows); - const Rgb565ToMono to_mono(order); + const Rgb565ToMono to_mono; const size_t line_stride = x_offset + w + x_pad; const size_t row_width = this->row_width_; const int step = column_mirrored ? -1 : 1; @@ -464,16 +464,17 @@ void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_star // Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the // usual case): the same result as draw_pixel_at() for each pixel. Anything else goes through draw_pixel_at(). -void HOT EPaperBase::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void HOT EPaperBase::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { size_t contiguous_length; uint8_t *contiguous = this->buffer_.get_span(0, contiguous_length); if (!this->buffer_is_mono_() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 || contiguous_length != this->buffer_length_) { - Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } - this->draw_rgb565_block_(contiguous, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, x_pad); + this->draw_rgb565_block_(contiguous, x_start, y_start, w, h, ptr, big_endian, x_offset, y_offset, x_pad); } void EPaperBase::dump_config() { diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 37f9a1449a2..dccff3cbef1 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -111,14 +111,15 @@ class EPaperBase : public Display, int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } bool pixel_axes_swapped() const override { return this->effective_transform_ & SWAP_XY; } void draw_pixel_at(int x, int y, Color color) override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; // Make the next update a full one. Applied when that update starts, so one in progress is not affected. void request_full_update() { this->full_update_requested_ = true; } // True from the start of an update until the display has refreshed and gone back to sleep. bool is_updating() const { return this->state_ != EPaperState::IDLE; } protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_height_internal() override { return this->height_; }; int get_width_internal() override { return this->width_; }; bool is_using_partial_update_() const { return this->full_update_every_ > 1; } @@ -135,8 +136,8 @@ class EPaperBase : public Display, bool init_buffer_(size_t buffer_length); void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); - void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - bool big_endian, int x_offset, int y_offset, int x_pad); + void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, bool big_endian, + int x_offset, int y_offset, int x_pad); // 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; } diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index 5f2149747e1..4edc1c548f6 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -130,8 +130,9 @@ void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) { this->feed_wdt_per_pixel_(); } -void HOT HUB75Display::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) { +void HOT HUB75Display::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (!driver_) [[unlikely]] return; if (!this->enabled_) [[unlikely]] @@ -153,15 +154,13 @@ void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, co format = Hub75PixelFormat::RGB888_32; bytes_per_pixel = 4; - // Map ESPHome ColorOrder to Hub75ColorOrder - // ESPHome ColorOrder is typically BGR for little-endian 32-bit - color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::RGB : Hub75ColorOrder::BGR; + color_order = Hub75ColorOrder::RGB; #elif LV_COLOR_DEPTH == 24 // 24-bit: 3 bytes per pixel, tightly packed format = Hub75PixelFormat::RGB888; bytes_per_pixel = 3; - // 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; + // 888 is b,g,r in memory, which hub75 calls BGR + color_order = Hub75ColorOrder::BGR; #else ESP_LOGE(TAG, "Unsupported LV_COLOR_DEPTH: %d", LV_COLOR_DEPTH); return; @@ -170,8 +169,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; - // 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; + // 888 is b,g,r in memory, which hub75 calls BGR + color_order = Hub75ColorOrder::BGR; #endif } else { ESP_LOGE(TAG, "Unsupported bitness: %d", static_cast(bitness)); diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index 122e8960fb0..ae31cadf83a 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -30,8 +30,6 @@ class HUB75Display final : public display::Display { display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } void fill(Color color) override; void draw_pixel_at(int x, int y, Color color) override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; // Brightness control (runtime mutable) void set_brightness(uint8_t brightness); @@ -40,6 +38,9 @@ class HUB75Display final : public display::Display { display::DisplayRotation get_rotation() const override; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + // Display internal methods int get_width_internal() override { return this->driver_ != nullptr ? this->driver_->get_width() : 0; } int get_height_internal() override { return this->driver_ != nullptr ? this->driver_->get_height() : 0; } diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index d2e1bc127e5..379c45f6737 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -162,7 +162,7 @@ void ILI9XXXDisplay::fill(Color color) { } return; default: - new_color = display::ColorUtil::color_to_332(color, display::ColorOrder::COLOR_ORDER_RGB); + new_color = display::ColorUtil::color_to_332(color); break; } memset(this->buffer_, (uint8_t) new_color, this->get_buffer_length_()); @@ -183,7 +183,7 @@ void HOT ILI9XXXDisplay::draw_absolute_pixel_internal(int x, int y, Color color) break; case BITS_16: pos = pos * 2; - new_color = display::ColorUtil::color_to_565(color, display::ColorOrder::COLOR_ORDER_RGB); + new_color = display::ColorUtil::color_to_565(color); if (this->buffer_[pos] != (uint8_t) (new_color >> 8)) { this->buffer_[pos] = (uint8_t) (new_color >> 8); updated = true; @@ -192,7 +192,7 @@ void HOT ILI9XXXDisplay::draw_absolute_pixel_internal(int x, int y, Color color) new_color = new_color & 0xFF; break; default: - new_color = display::ColorUtil::color_to_332(color, display::ColorOrder::COLOR_ORDER_RGB); + new_color = display::ColorUtil::color_to_332(color); break; } @@ -315,19 +315,17 @@ void ILI9XXXDisplay::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) { +void ILI9XXXDisplay::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (w <= 0 || h <= 0) return; // 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 || - 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); + this->is_clipping() || (this->prossing_update_ && this->buffer_color_mode_ != BITS_16)) { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } // x_ and y_offset are offsets into the source buffer, unrelated to our own offsets into the display. diff --git a/esphome/components/ili9xxx/ili9xxx_display.h b/esphome/components/ili9xxx/ili9xxx_display.h index 77cee44ae68..4eabd02f3d5 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.h +++ b/esphome/components/ili9xxx/ili9xxx_display.h @@ -92,10 +92,11 @@ class ILI9XXXDisplay : public display::DisplayBuffer, void on_powerdown() override { this->command(ILI9XXX_SLPIN); } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } - 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; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + inline bool check_buffer_() { if (this->buffer_ == nullptr) { if (!this->is_failed()) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index 60d81ad62f1..a38e84caaf9 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -59,8 +59,8 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color 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); + rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_888, this->big_endian_, + x_start, y_start, this->width_ - x_end); return; } #endif diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 3612b795b98..6b0a4b56094 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -959,8 +959,9 @@ void HOT IT8951Display::write_pixel_native_(uint16_t x, uint16_t y, const Color } } -void HOT IT8951Display::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) { +void HOT IT8951Display::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { // A writer (e.g. LVGL) may push pixels before the framebuffer is ready or // after an allocation failure; ignore those rather than dereferencing null. if (this->buffer_ == nullptr) @@ -968,7 +969,7 @@ void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, c // A clipping rectangle would need a per-pixel test; that's rare for the bulk // blit callers (LVGL, images), so fall back to the base per-pixel path then. if (this->get_clipping().is_set()) { - Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } @@ -1000,7 +1001,7 @@ void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, c if (nx < 0 || ny < 0 || nx >= this->width_ || ny >= this->height_) continue; this->write_pixel_native_(static_cast(nx), static_cast(ny), - ColorUtil::to_color(color_value, order, bitness)); + ColorUtil::to_color(color_value, bitness)); } } diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index e99e905ebf5..2b5f8d299fc 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -197,16 +197,17 @@ class IT8951Display : public Display, void fill(Color color) override; void clear() override { this->fill(Color::WHITE); } void draw_pixel_at(int x, int y, Color color) override; - // Bulk pixel blit (used by LVGL and image rendering). Overridden to write - // straight into the framebuffer, avoiding the base class's per-pixel - // draw_pixel_at overhead (watchdog feed, clipping test, dirty-box clamps). - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; } int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; } bool pixel_axes_swapped() const override { return this->effective_transform_ & TRANSFORM_SWAP_XY; } protected: + // Bulk pixel blit (used by LVGL and image rendering). Overridden to write + // straight into the framebuffer, avoiding the base class's per-pixel + // draw_pixel_at overhead (watchdog feed, clipping test, dirty-box clamps). + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_height_internal() override { return this->height_; } int get_width_internal() override { return this->width_; } diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 9bc83800051..e968e329dfb 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -439,8 +439,7 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) { ptr = dst; } for (auto *display : this->displays_) { - display->draw_pixels_at(x1, y1, width, height, (const uint8_t *) ptr, display::COLOR_ORDER_RGB, LV_BITNESS, - this->big_endian_); + display->draw_pixels_at(x1, y1, width, height, (const uint8_t *) ptr, LV_BITNESS, this->big_endian_); } } diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 31bf69b178c..005087b719b 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -213,15 +213,14 @@ void MipiDsi::flush_dirty_() { this->y_high_ = 0; } -void MipiDsi::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) { +void MipiDsi::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (w <= 0 || h <= 0) return; - if (bitness != this->color_depth_ || order != display::COLOR_ORDER_RGB || - (bitness == display::COLOR_BITNESS_565 && big_endian != this->big_endian_) || + if (bitness != this->color_depth_ || (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); + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); this->mark_dirty_(x_start, y_start, w, h); this->flush_dirty_(); return; diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index dd1b0dc5f90..9e7c562b54d 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -72,9 +72,6 @@ class MipiDsi final : public display::Display { void setup() override; - 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; - void draw_pixel_at(int x, int y, Color color) override; void fill(Color color) override; int get_width() override; @@ -83,6 +80,9 @@ class MipiDsi final : public display::Display { void dump_config() override; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, 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); bool check_buffer_(); diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 2bc300e5b82..a38404030ad 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -218,13 +218,14 @@ void MipiRgb::flush_dirty_() { this->y_high_ = 0; } -void MipiRgb::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) { +void MipiRgb::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + 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 (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || big_endian != this->big_endian_ || + if (bitness != display::COLOR_BITNESS_565 || 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); + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); this->mark_dirty_(x_start, y_start, w, h); this->flush_dirty_(); } else if (this->buffer_ != nullptr) { diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 393e052b26b..f6aec76976b 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -36,8 +36,6 @@ class MipiRgb : public display::Display { #endif void update() override; void fill(Color color) override; - 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; display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } @@ -69,8 +67,11 @@ class MipiRgb : public display::Display { void dump_config() override; void draw_pixel_at(int x, int y, Color color) override; - // this will be horribly slow. protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + + // this will be horribly slow. 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_(); diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index cbf025e8c31..f8fecf2e4ea 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -199,22 +199,6 @@ class MipiSpi : public display::Display, this->init_sequence_len_ = 0; } - // Drawing operations - - 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->is_failed()) - return; - if (w <= 0 || h <= 0) - return; - 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, - x_pad); - } - void dump_config() override { internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(), this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, @@ -223,6 +207,22 @@ class MipiSpi : public display::Display, } protected: + // Drawing operations + + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (this->is_failed()) + return; + if (w <= 0 || h <= 0) + return; + if (!this->is_native_block_(bitness, big_endian)) { + this->draw_pixels_slow(x_start, y_start, w, h, ptr, 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, + x_pad); + } + /* METHODS */ // If hardware rotation is in use, the actual display width/height changes with rotation int get_width_internal() override { @@ -421,15 +421,15 @@ 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 && + bool is_native_block_(display::ColorBitness bitness, bool big_endian) const { + return get_pixel_mode(bitness) == BUFFERPIXEL && big_endian == IS_BIG_ENDIAN && (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, + virtual void draw_pixels_slow(int x_start, int y_start, int w, int h, const uint8_t *ptr, 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"); + esph_log_e(TAG, "Unsupported color depth, bit order or rotation"); } /** @@ -625,42 +625,6 @@ 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(); } @@ -716,11 +680,45 @@ class MipiSpiBuffer } protected: - 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); + // 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_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (!this->updating_) { + MipiSpiBuffer::MipiSpi::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, + x_pad); + return; + } + if (this->is_failed() || w <= 0 || h <= 0) + return; + if (!this->is_native_block_(bitness, big_endian)) { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, 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); + } + + void draw_pixels_slow(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); } // Convert a color to the buffer pixel format. static BUFFERTYPE convert_color(const Color &color) { diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index 7e9b0bd89bd..f6ccdef27a5 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -1,4 +1,5 @@ #include "pixoo.h" +#include "esphome/components/display/display_color_utils.h" #include "esphome/core/log.h" @@ -144,17 +145,16 @@ void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { this->set_pixel_(static_cast(y) * side + x, color); } -void Pixoo::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) { - // Fast path for the common LVGL/image blit: RGB565, RGB order, no rotation, no active clipping. +// flatten keeps the RGB565 decode inlined in the pixel loop, as in Display's own per pixel loop. +void __attribute__((flatten)) +Pixoo::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) { + // Fast path for the common LVGL/image blit: RGB565, no rotation, no active clipping. // Anything else defers to the base implementation, which decodes per pixel and routes through // draw_pixel_at() so rotation, clipping and other color formats stay correct. - // NOTE: the stride/index math and 565->888 expansion below mirror Display::draw_pixels_at (the - // source of truth) -- keep them in sync if the base ever changes its source layout or decoding. - if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || - 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); + if (bitness != display::COLOR_BITNESS_565 || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || + this->is_clipping()) { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } const int side = static_cast(this->model_); @@ -168,14 +168,10 @@ void Pixoo::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t const int dst_x = x_start + x; if (dst_x < 0 || dst_x >= side) continue; - const size_t byte_idx = source_idx * 2; - const uint16_t rgb565 = - big_endian ? (ptr[byte_idx] << 8) | ptr[byte_idx + 1] : ptr[byte_idx] | (ptr[byte_idx + 1] << 8); - const uint8_t r5 = (rgb565 >> 11) & 0x1F; - const uint8_t g6 = (rgb565 >> 5) & 0x3F; - const uint8_t b5 = rgb565 & 0x1F; this->set_pixel_(static_cast(dst_y) * side + dst_x, - Color((r5 << 3) | (r5 >> 2), (g6 << 2) | (g6 >> 4), (b5 << 3) | (b5 >> 2))); + display::ColorUtil::to_color( + display::ColorUtil::read_packed(ptr, source_idx, big_endian), + display::COLOR_BITNESS_565)); } } } diff --git a/esphome/components/pixoo/pixoo.h b/esphome/components/pixoo/pixoo.h index 4913ef85db9..e8c8efbae73 100644 --- a/esphome/components/pixoo/pixoo.h +++ b/esphome/components/pixoo/pixoo.h @@ -38,10 +38,11 @@ class Pixoo : public display::Display, void fill(Color color) override; void draw_pixel_at(int x, int y, Color color) override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_width_internal() override { return static_cast(this->model_); } int get_height_internal() override { return static_cast(this->model_); } diff --git a/esphome/components/qspi_dbi/qspi_dbi.cpp b/esphome/components/qspi_dbi/qspi_dbi.cpp index ce0df1beb50..8387652d31e 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.cpp +++ b/esphome/components/qspi_dbi/qspi_dbi.cpp @@ -72,7 +72,7 @@ void QspiDbi::draw_absolute_pixel_internal(int x, int y, Color color) { uint32_t pos = (y * this->width_) + x; bool updated = false; pos = pos * 2; - uint16_t new_color = display::ColorUtil::color_to_565(color, display::ColorOrder::COLOR_ORDER_RGB); + uint16_t new_color = display::ColorUtil::color_to_565(color); if (this->buffer_[pos] != static_cast(new_color >> 8)) { this->buffer_[pos] = static_cast(new_color >> 8); updated = true; @@ -137,20 +137,20 @@ void QspiDbi::set_addr_window_(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y this->write_command_(CASET, buf, sizeof buf); } -void QspiDbi::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) { +void QspiDbi::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (!this->setup_complete_ || this->is_failed()) return; 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) || + if (bitness != display::COLOR_BITNESS_565 || 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); + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, 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); + this->copy_to_buffer_(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } else if (this->draw_from_origin_) { auto stride = x_offset + w + x_pad; @@ -169,11 +169,11 @@ 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) { +void QspiDbi::copy_to_buffer_(int x_start, int y_start, int w, int h, const uint8_t *ptr, 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); + if (!big_endian) { + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, 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); diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index 1c2565625b2..2d7c4572ca9 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -112,12 +112,12 @@ class QspiDbi final : public display::DisplayBuffer, } void write_sequence_(const std::vector &vec); void draw_absolute_pixel_internal(int x, int y, Color color) override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, 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); + void copy_to_buffer_(int x_start, int y_start, int w, int h, const uint8_t *ptr, 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.) diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index 9f92a850f55..1e248780414 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -47,14 +47,14 @@ void RpiDpiRgb::loop() { esp_lcd_rgb_panel_restart(this->handle_); } -void RpiDpiRgb::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) { +void RpiDpiRgb::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (w <= 0 || h <= 0) return; - 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); + if (bitness != display::COLOR_BITNESS_565 || !big_endian || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || + this->is_clipping()) { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, 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); diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index 5f1f3be0191..3c4d7f14e9b 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -24,8 +24,6 @@ class RpiDpiRgb final : public display::Display { void setup() override; void loop() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } - 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; void draw_pixel_at(int x, int y, Color color) override; display::ColorOrder get_color_mode() { return this->color_mode_; } @@ -62,6 +60,9 @@ class RpiDpiRgb final : public display::Display { void dump_config() override; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, 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); int get_width_internal() override { return this->width_; } diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index 5dd25ec2bb8..e98cf3da482 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -148,13 +148,13 @@ void Sdl::redraw_(SDL_Rect &rect) { SDL_RenderPresent(this->renderer_); } -void Sdl::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) { +void Sdl::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (this->texture_ == nullptr) return; SDL_Rect rect{x_start, y_start, w, h}; if (this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || bitness != display::COLOR_BITNESS_565 || big_endian) { - Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); } else { auto stride = x_offset + w + x_pad; const auto *data = ptr + (stride * y_offset + x_offset) * 2; @@ -181,7 +181,7 @@ void Sdl::draw_pixel_at(int x, int y, Color color) { } SDL_Rect rect{x, y, 1, 1}; - auto data = (display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB)); + auto data = (display::ColorUtil::color_to_565(color)); SDL_UpdateTexture(this->texture_, &rect, &data, 2); if (x < this->x_low_) this->x_low_ = x; diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index c927004e1f6..e46a64acdc2 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -21,8 +21,6 @@ class Sdl final : public display::Display, public snapshot::Snapshot { void update() override; void loop() override; void setup() override; - 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; void draw_pixel_at(int x, int y, Color color) override; void process_key(uint32_t keycode, bool down); void set_dimensions(uint16_t width, uint16_t height) { @@ -53,6 +51,9 @@ class Sdl final : public display::Display, public snapshot::Snapshot { bool mouse_down{}; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } void redraw_(SDL_Rect &rect); diff --git a/esphome/components/snapshot/display/snapshot_display.cpp b/esphome/components/snapshot/display/snapshot_display.cpp index 6618d9ae74a..301e2bd47ba 100644 --- a/esphome/components/snapshot/display/snapshot_display.cpp +++ b/esphome/components/snapshot/display/snapshot_display.cpp @@ -34,12 +34,12 @@ void SnapshotDisplay::dump_config() { LOG_DISPLAY("", "Snapshot", this); } void SnapshotDisplay::draw_absolute_pixel_internal(int x, int y, Color color) { if (this->buffer_ == nullptr || x < 0 || x >= this->width_ || y < 0 || y >= this->height_) return; - this->pixels_()[y * this->width_ + x] = display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB); + this->pixels_()[y * this->width_ + x] = display::ColorUtil::color_to_565(color); } -void SnapshotDisplay::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) { +void SnapshotDisplay::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (this->buffer_ == nullptr) return; // Anything that is not already laid out the way the buffer is, or that would reach outside it, @@ -48,7 +48,7 @@ void SnapshotDisplay::draw_pixels_at(int x_start, int y_start, int w, int h, con bitness == display::COLOR_BITNESS_565 && !big_endian && x_start >= 0 && y_start >= 0 && x_start + w <= this->width_ && y_start + h <= this->height_; if (!copyable) { - DisplayBuffer::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + DisplayBuffer::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } const size_t stride = static_cast(x_offset) + w + x_pad; diff --git a/esphome/components/snapshot/display/snapshot_display.h b/esphome/components/snapshot/display/snapshot_display.h index 5317bc6058b..31e711c3bf5 100644 --- a/esphome/components/snapshot/display/snapshot_display.h +++ b/esphome/components/snapshot/display/snapshot_display.h @@ -23,10 +23,10 @@ class SnapshotDisplay final : public display::DisplayBuffer, public Snapshot { this->height_ = height; } - 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; - protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + void draw_absolute_pixel_internal(int x, int y, Color color) override; int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 756473dae71..6bffafe7389 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -50,14 +50,14 @@ void ST7701S::loop() { esp_lcd_rgb_panel_restart(this->handle_); } -void ST7701S::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) { +void ST7701S::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (w <= 0 || h <= 0) return; - 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); + if (bitness != display::COLOR_BITNESS_565 || !big_endian || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || + this->is_clipping()) { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, 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); diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index e34b980e73b..181a580ada7 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -34,8 +34,6 @@ class ST7701S final : public display::Display, void update() override { this->do_update_(); } void setup() override; void loop() override; - 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; display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } @@ -76,8 +74,11 @@ class ST7701S final : public display::Display, void dump_config() override; void draw_pixel_at(int x, int y, Color color) override; - // this will be horribly slow. protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + + // this will be horribly slow. 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); diff --git a/esphome/components/st7735/st7735.cpp b/esphome/components/st7735/st7735.cpp index 94b94d17a98..c8b10fd8318 100644 --- a/esphome/components/st7735/st7735.cpp +++ b/esphome/components/st7735/st7735.cpp @@ -449,8 +449,8 @@ void HOT ST7735::write_display_data_() { if (this->eightbitcolor_) { for (size_t line = 0; line < this->get_buffer_length(); line = line + this->get_width_internal()) { for (int index = 0; index < this->get_width_internal(); ++index) { - auto color332 = display::ColorUtil::to_color(this->buffer_[index + line], display::ColorOrder::COLOR_ORDER_RGB, - display::ColorBitness::COLOR_BITNESS_332, true); + auto color332 = + display::ColorUtil::to_color(this->buffer_[index + line], display::ColorBitness::COLOR_BITNESS_332, true); auto color = display::ColorUtil::color_to_565(color332); diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index 343d196b507..84b27251053 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -182,8 +182,7 @@ void ST7789V::write_display_data() { for (size_t line = 0; line < this->get_buffer_length_(); line += width) { for (size_t index = 0; index < width; ++index) { auto color = display::ColorUtil::color_to_565( - display::ColorUtil::to_color(this->buffer_[index + line], display::ColorOrder::COLOR_ORDER_RGB, - display::ColorBitness::COLOR_BITNESS_332, true)); + display::ColorUtil::to_color(this->buffer_[index + line], display::ColorBitness::COLOR_BITNESS_332, true)); temp_buffer[temp_index++] = (uint8_t) (color >> 8); temp_buffer[temp_index++] = (uint8_t) color; if (temp_index == TEMP_BUFFER_SIZE) { diff --git a/tests/benchmarks/components/display/bench_draw_pixels_at.cpp b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp index da96dd83b9a..9469011cf68 100644 --- a/tests/benchmarks/components/display/bench_draw_pixels_at.cpp +++ b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp @@ -47,7 +47,7 @@ static void draw_block(benchmark::State &state, ColorBitness bitness, bool big_e BenchDisplay display; display.set_rotation(rotation); for (auto _ : state) { - display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), bitness, big_endian, 0, 0, 0); } state.SetItemsProcessed(state.iterations() * kWidth * kHeight); } diff --git a/tests/benchmarks/components/image/bench_image_draw.cpp b/tests/benchmarks/components/image/bench_image_draw.cpp index 6bdd0acffbc..63e2d8bac6f 100644 --- a/tests/benchmarks/components/image/bench_image_draw.cpp +++ b/tests/benchmarks/components/image/bench_image_draw.cpp @@ -42,10 +42,10 @@ class BenchDisplay : public display::DisplayBuffer { class BulkBenchDisplay : public BenchDisplay { public: BulkBenchDisplay() : frame565_(std::make_unique(kStride * kStride)) {} - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { if (bitness != display::COLOR_BITNESS_565 || big_endian) { - BenchDisplay::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + BenchDisplay::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } const size_t stride = x_offset + w + x_pad; diff --git a/tests/components/display/test_draw_pixels_at.cpp b/tests/components/display/test_draw_pixels_at.cpp index 1278e2462df..3fbf76dd713 100644 --- a/tests/components/display/test_draw_pixels_at.cpp +++ b/tests/components/display/test_draw_pixels_at.cpp @@ -52,8 +52,8 @@ static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool b RecordingDisplay rows; RecordingDisplay columns; columns.set_axes_swapped(true); - rows.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); - columns.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + rows.draw_pixels_at(10, 20, W, H, source.data(), bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(10, 20, W, H, source.data(), bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); EXPECT_EQ(rows.count(), static_cast(W * H)); EXPECT_EQ(columns.count(), static_cast(W * H)); EXPECT_EQ(rows.sorted_pixels(), columns.sorted_pixels()); @@ -69,7 +69,7 @@ TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888) { expect_walks_match(COLOR_BITNE static void expect_golden(ColorBitness bitness, std::initializer_list bytes, bool big_endian, Color expected) { const std::vector source(bytes); RecordingDisplay display; - display.draw_pixels_at(3, 4, 1, 1, source.data(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + display.draw_pixels_at(3, 4, 1, 1, source.data(), bitness, big_endian, 0, 0, 0); ASSERT_EQ(display.count(), 1u); EXPECT_EQ(display.pixels()[0], std::make_tuple(3, 4, expected.raw_32)); } @@ -97,13 +97,160 @@ TEST(DrawPixelsAt, ColumnWalkChangesOrder) { RecordingDisplay rows; RecordingDisplay columns; columns.set_axes_swapped(true); - rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD); - columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, - X_PAD); + rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD); ASSERT_GT(rows.count(), 1u); ASSERT_GT(columns.count(), 1u); EXPECT_EQ(std::get<0>(rows.pixels()[1]), 1); // the row walk steps x first EXPECT_EQ(std::get<1>(columns.pixels()[1]), 1); // the column walk steps y first } +// The multiply and shift field expansion gives exactly what dividing with esp_scale() does. +TEST(ColorUtil, ExpandFieldMatchesEspScale) { + // 2, 3, 5, 6 and 8 have a multiply and shift; the others fall back to esp_scale() + for (const uint8_t bits : {1, 2, 3, 4, 5, 6, 7, 8}) { + const uint8_t max = (1 << bits) - 1; + for (uint32_t value = 0; value <= max; value++) + EXPECT_EQ(expand_field(value, bits), esp_scale(value, max)) << "bits " << int(bits) << " value " << value; + } +} + +// to_color() decodes every 565 and 332 value as the per channel esp_scale() arithmetic does. +TEST(ColorUtil, ToColorMatchesEspScale) { + for (uint32_t value = 0; value != 0x10000; value++) { + const Color expected(esp_scale((value >> 11) & 0x1F, 31), esp_scale((value >> 5) & 0x3F, 63), + esp_scale(value & 0x1F, 31)); + ASSERT_EQ(ColorUtil::to_color(value, COLOR_BITNESS_565).raw_32, expected.raw_32) << "565 value " << value; + } + for (uint32_t value = 0; value != 0x100; value++) { + const Color expected(esp_scale((value >> 5) & 0x07, 7), esp_scale((value >> 2) & 0x07, 7), + esp_scale(value & 0x03, 3)); + ASSERT_EQ(ColorUtil::to_color(value, COLOR_BITNESS_332).raw_32, expected.raw_32) << "332 value " << value; + } +} + +// Without right_bit_aligned each channel is read from its own byte; at 8 bits it comes back unchanged. +TEST(ColorUtil, ToColorByteAligned) { + EXPECT_EQ(ColorUtil::to_color(0x563412, COLOR_BITNESS_888, false).raw_32, Color(0x56, 0x34, 0x12).raw_32); +} + +// A driver that copies blocks itself overrides the protected draw_pixels_internal() hook. +class HookDisplay : public RecordingDisplay { + public: + std::vector> calls; + const uint8_t *ptr{}; + + protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { + this->calls.push_back({x_start, y_start, w, h, bitness, big_endian, x_offset, y_offset, x_pad}); + this->ptr = ptr; + } +}; + +// Both public forms reach the hook, and nothing is hidden when called through the driver's own type. +TEST(DrawPixelsAt, BothFormsReachTheHook) { + const auto source = make_source(2); + HookDisplay display; + display.draw_pixels_at(10, 20, W, H, source.data(), COLOR_BITNESS_565, true, X_OFFSET, Y_OFFSET, X_PAD); + display.draw_pixels_at(1, 2, 3, 4, source.data(), COLOR_BITNESS_888, false); + ASSERT_EQ(display.calls.size(), 2u); + EXPECT_EQ(display.calls[0], (std::vector{10, 20, W, H, COLOR_BITNESS_565, true, X_OFFSET, Y_OFFSET, X_PAD})); + EXPECT_EQ(display.calls[1], (std::vector{1, 2, 3, 4, COLOR_BITNESS_888, false, 0, 0, 0})); + EXPECT_EQ(display.ptr, source.data()); + EXPECT_EQ(display.count(), 0u); +} + +// Remove before 2027.5.0, with the draw_pixels_at() overloads that take a color order. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + +// An out of tree driver that still overrides only the form with a color order. +class LegacyDisplay : public RecordingDisplay { + public: + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + this->calls_.push_back({x_start, y_start, w, h, x_offset, y_offset, x_pad}); + this->order_ = order; + this->ptr_ = ptr; + this->bitness_ = bitness; + this->big_endian_ = big_endian; + } + + std::vector> calls_; + ColorOrder order_{COLOR_ORDER_GRB}; + const uint8_t *ptr_{}; + ColorBitness bitness_{COLOR_BITNESS_888}; + bool big_endian_{}; +}; + +// Callers use the form without a color order; a legacy override still receives every block, as RGB. +TEST(DrawPixelsAt, LegacyOverrideStillReceivesBlocks) { + const auto source = make_source(2); + LegacyDisplay legacy; + Display &display = legacy; + display.draw_pixels_at(10, 20, W, H, source.data(), COLOR_BITNESS_565, true, X_OFFSET, Y_OFFSET, X_PAD); + display.draw_pixels_at(1, 2, 3, 4, source.data(), COLOR_BITNESS_565, true); + ASSERT_EQ(legacy.calls_.size(), 2u); + EXPECT_EQ(legacy.calls_[0], (std::vector{10, 20, W, H, X_OFFSET, Y_OFFSET, X_PAD})); + EXPECT_EQ(legacy.calls_[1], (std::vector{1, 2, 3, 4, 0, 0, 0})); + EXPECT_EQ(legacy.order_, COLOR_ORDER_RGB); + EXPECT_EQ(legacy.ptr_, source.data()); + EXPECT_EQ(legacy.bitness_, COLOR_BITNESS_565); + EXPECT_TRUE(legacy.big_endian_); + EXPECT_EQ(legacy.count(), 0u); +} + +// The deprecated form still honors a color order other than RGB when it draws per pixel. +static void expect_ordered(ColorBitness bitness, std::initializer_list bytes, uint32_t code, ColorOrder order, + Color expected) { + const std::vector source(bytes); + RecordingDisplay display; + display.draw_pixels_at(3, 4, 1, 1, source.data(), order, bitness, true); + ASSERT_EQ(display.count(), 1u); + EXPECT_EQ(display.pixels()[0], std::make_tuple(3, 4, expected.raw_32)); + EXPECT_EQ(expected.raw_32, ColorUtil::to_color(code, order, bitness).raw_32); +} + +// 888 is b, g, r in memory: 0x563412 +TEST(DrawPixelsAt, DeprecatedOrderRgb) { + expect_ordered(COLOR_BITNESS_888, {0x12, 0x34, 0x56}, 0x563412, COLOR_ORDER_RGB, Color(0x56, 0x34, 0x12)); +} +TEST(DrawPixelsAt, DeprecatedOrderBgr) { + expect_ordered(COLOR_BITNESS_888, {0x12, 0x34, 0x56}, 0x563412, COLOR_ORDER_BGR, Color(0x12, 0x34, 0x56)); +} +TEST(DrawPixelsAt, DeprecatedOrderGrb) { + expect_ordered(COLOR_BITNESS_888, {0x12, 0x34, 0x56}, 0x563412, COLOR_ORDER_GRB, Color(0x34, 0x56, 0x12)); +} +// 565 big endian 0xE007 is r 230, g 0, b 57 in RGB order +TEST(DrawPixelsAt, DeprecatedOrderBgr565) { + expect_ordered(COLOR_BITNESS_565, {0xE0, 0x07}, 0xE007, COLOR_ORDER_BGR, Color(57, 0, 230)); +} +TEST(DrawPixelsAt, DeprecatedOrderGrb565) { + expect_ordered(COLOR_BITNESS_565, {0xE0, 0x07}, 0xE007, COLOR_ORDER_GRB, Color(0, 230, 57)); +} +// 332 0xE0 is r 255, g 0, b 0 in RGB order +TEST(DrawPixelsAt, DeprecatedOrderBgr332) { + expect_ordered(COLOR_BITNESS_332, {0xE0}, 0xE0, COLOR_ORDER_BGR, Color(0, 0, 255)); +} +TEST(DrawPixelsAt, DeprecatedOrderGrb332) { + expect_ordered(COLOR_BITNESS_332, {0xE0}, 0xE0, COLOR_ORDER_GRB, Color(0, 255, 0)); +} + +// The deprecated packing functions still honor every order; with RGB they match the new ones. +// 0xC0, 0x60, 0x30 is red 24, green 24, blue 6 in 565 and red 6, green 3, blue 0 in 332. +TEST(DrawPixelsAt, DeprecatedPackingHonorsTheOrder) { + const Color color(0xC0, 0x60, 0x30); + EXPECT_EQ(ColorUtil::color_to_565(color), 0xC306); + EXPECT_EQ(ColorUtil::color_to_565(color, COLOR_ORDER_RGB), 0xC306); + EXPECT_EQ(ColorUtil::color_to_565(color, COLOR_ORDER_BGR), 0x3318); + EXPECT_EQ(ColorUtil::color_to_565(color, COLOR_ORDER_GRB), 0x6306); + EXPECT_EQ(ColorUtil::color_to_332(color), 0xCC); + EXPECT_EQ(ColorUtil::color_to_332(color, COLOR_ORDER_RGB), 0xCC); + EXPECT_EQ(ColorUtil::color_to_332(color, COLOR_ORDER_BGR), 0x1E); + EXPECT_EQ(ColorUtil::color_to_332(color, COLOR_ORDER_GRB), 0x78); +} + +#pragma GCC diagnostic pop + } // namespace esphome::display::testing diff --git a/tests/components/epaper_spi/display/test_draw_pixels.cpp b/tests/components/epaper_spi/display/test_draw_pixels.cpp index a3b47ea8fc1..1933637efdb 100644 --- a/tests/components/epaper_spi/display/test_draw_pixels.cpp +++ b/tests/components/epaper_spi/display/test_draw_pixels.cpp @@ -42,11 +42,11 @@ template class TestableDisplay : public Driver { std::vector bounds() const { return {this->x_low_, this->y_low_, this->x_high_, this->y_high_}; } - void draw_one_by_one(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + void draw_one_by_one(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) { // The per-pixel path the fast one must match - display::Display::draw_pixels_at( // NOLINT(bugprone-parent-virtual-call) - x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + display::Display::draw_pixels_internal( // NOLINT(bugprone-parent-virtual-call) + x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); } }; @@ -100,10 +100,10 @@ TEST(EPaperDrawPixels, SameResultAsDrawingPixelByPixel) { } // a 3 pixel left offset, 2 rows skipped and 4 pixels of padding const auto data = random_bytes((3 + block.w + 4) * (2 + block.h), seed++); - fast.draw_pixels_at(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, - display::COLOR_BITNESS_565, big_endian, 3, 2, 4); - slow.draw_one_by_one(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, - display::COLOR_BITNESS_565, big_endian, 3, 2, 4); + fast.draw_pixels_at(block.x, block.y, block.w, block.h, data.data(), display::COLOR_BITNESS_565, big_endian, + 3, 2, 4); + slow.draw_one_by_one(block.x, block.y, block.w, block.h, data.data(), display::COLOR_BITNESS_565, big_endian, + 3, 2, 4); const auto where = ::testing::Message() << "rotation " << rotation << " mirroring " << int(mirroring) << " big endian " << big_endian << " block " << block.x << "," << block.y << " " << block.w << "x" << block.h; @@ -115,16 +115,28 @@ TEST(EPaperDrawPixels, SameResultAsDrawingPixelByPixel) { } } -/// The colour order reaches the RGB565 block writer. -TEST(EPaperDrawPixels, ColourOrderIsHonoured) { - for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { - TestableDisplay fast(21, 13); - TestableDisplay slow(21, 13); - const auto data = random_bytes(21 * 13, 99); - fast.draw_pixels_at(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); - slow.draw_one_by_one(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); - EXPECT_EQ(fast.buffer(), slow.buffer()) << "order " << int(order); - } +/// Every form of draw_pixels_at() can be called through the driver's own type and draws the same result. +TEST(EPaperDrawPixels, AllFormsAreVisibleThroughTheDriver) { + const auto data = rgb565(24 * 16, 11); + TestableDisplay slow(24, 16); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); + TestableDisplay full(24, 16); + TestableDisplay packed(24, 16); + full.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); + packed.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_BITNESS_565, false); + EXPECT_EQ(full.buffer(), slow.buffer()); + EXPECT_EQ(packed.buffer(), slow.buffer()); + // Remove before 2027.5.0, with the forms that take a color order +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + TestableDisplay old_full(24, 16); + TestableDisplay old_packed(24, 16); + old_full.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, + 0); + old_packed.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false); +#pragma GCC diagnostic pop + EXPECT_EQ(old_full.buffer(), slow.buffer()); + EXPECT_EQ(old_packed.buffer(), slow.buffer()); } /// With a clipping region set the block goes through draw_pixel_at(), which honours it. @@ -134,8 +146,8 @@ TEST(EPaperDrawPixels, ClippingFallsBackToDrawingPixelByPixel) { const auto data = rgb565(24 * 16, 7); for (auto *display : {&fast, &slow}) display->start_clipping(4, 4, 12, 10); - fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); - slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); EXPECT_EQ(fast.buffer(), slow.buffer()); EXPECT_EQ(fast.bounds(), (std::vector{4, 4, 12, 10})); } @@ -145,13 +157,28 @@ TEST(EPaperDrawPixels, OtherBufferLayoutsDrawPixelByPixel) { TestableDisplay fast(16, 4); TestableDisplay slow(16, 4); const auto data = rgb565(16 * 4, 11); - fast.draw_pixels_at(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); - slow.draw_one_by_one(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + fast.draw_pixels_at(0, 0, 16, 4, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 16, 4, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); EXPECT_EQ(fast.buffer(), slow.buffer()); } -/// The RGB565 lookup gives exactly what converting the colour and thresholding it does, for every value and order. -TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColourConversion) { +/// The RGB565 lookup gives exactly what converting the color and thresholding it does, for every value. +TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColorConversion) { + const Rgb565ToMono to_mono; + size_t mismatches = 0; + for (uint32_t value = 0; value != 0x10000; value++) { + const uint8_t expected = color_to_mono(ColorUtil::to_color(value, display::COLOR_BITNESS_565)); + if (to_mono(static_cast(value)) != expected) + mismatches++; + } + EXPECT_EQ(mismatches, 0u); +} + +// Remove before 2027.5.0 +/// The deprecated lookup that takes a color order still matches the deprecated conversion with that order. +TEST(EPaperDrawPixels, DeprecatedOrderedLookupMatchesTheColorConversion) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { const Rgb565ToMono to_mono(order); size_t mismatches = 0; @@ -162,6 +189,7 @@ TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColourConversion) { } EXPECT_EQ(mismatches, 0u) << "order " << int(order); } +#pragma GCC diagnostic pop } /// Other bit depths, and a buffer split into several blocks, go through draw_pixel_at() and give the same result. @@ -172,10 +200,8 @@ TEST(EPaperDrawPixels, OtherCasesDrawPixelByPixel) { fast.split_buffer(7); slow.split_buffer(7); const auto data = rgb565(24 * 16, 5); - fast.draw_pixels_at(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, - 0); - slow.draw_one_by_one(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, - 0); + fast.draw_pixels_at(-2, 3, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(-2, 3, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); EXPECT_EQ(fast.buffer(), slow.buffer()) << "split buffer"; EXPECT_EQ(fast.bounds(), slow.bounds()) << "split buffer"; } @@ -183,8 +209,8 @@ TEST(EPaperDrawPixels, OtherCasesDrawPixelByPixel) { TestableDisplay fast(24, 16); TestableDisplay slow(24, 16); const auto data = random_bytes(24 * 16, 9); - fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); - slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), bitness, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), bitness, false, 0, 0, 0); EXPECT_EQ(fast.buffer(), slow.buffer()) << "bitness " << int(bitness); EXPECT_EQ(fast.bounds(), slow.bounds()) << "bitness " << int(bitness); } diff --git a/tests/components/image/test_image_draw.cpp b/tests/components/image/test_image_draw.cpp index 80db8a4ad2a..516f07fea52 100644 --- a/tests/components/image/test_image_draw.cpp +++ b/tests/components/image/test_image_draw.cpp @@ -26,8 +26,8 @@ struct Blit { // 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 { + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, 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++; } @@ -154,7 +154,7 @@ static void expect_fallback_decodes(bool big_endian) { 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); + const Color expected = display::ColorUtil::to_color(rgb565, display::COLOR_BITNESS_565); EXPECT_EQ(pixel.raw, expected.raw_32) << "at " << pixel.x << "," << pixel.y; } } From 57dd09018efa1d5b390c8b6f0815bb8facb9d8a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:00:58 -1000 Subject: [PATCH 009/120] Bump yamlrocks from 0.6.1 to 0.7.0 (#20904) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 68c4bbaf426..f0dd218adf2 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,7 +3,7 @@ flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_ve ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin -yamlrocks==0.6.1 # used by script/sync_dependency_versions.py +yamlrocks==0.7.0 # used by script/sync_dependency_versions.py # Unit tests pytest==9.1.1 From c0b7c5e59023f9bc821dfd7d35b66c8f0ddc70ab Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:03:07 +0200 Subject: [PATCH 010/120] [dfrobot_sen0395] Forward trigger arguments in settings actions (#20885) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/dfrobot_sen0395/automation.h | 17 +- tests/components/dfrobot_sen0395/common.yaml | 15 ++ .../dfrobot_sen0395/settings_action.cpp | 145 ++++++++++++++++++ 3 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 tests/components/dfrobot_sen0395/settings_action.cpp diff --git a/esphome/components/dfrobot_sen0395/automation.h b/esphome/components/dfrobot_sen0395/automation.h index c3632085310..58dbb9afb09 100644 --- a/esphome/components/dfrobot_sen0395/automation.h +++ b/esphome/components/dfrobot_sen0395/automation.h @@ -26,17 +26,22 @@ class DfrobotSen0395SettingsAction final : public Action, public Parented TEMPLATABLE_VALUE(float, det_min4) TEMPLATABLE_VALUE(float, det_max4) - void play(const Ts &...x) { + void play(const Ts &...x) override { this->parent_->enqueue(make_unique(0)); if (this->factory_reset_.has_value() && this->factory_reset_.value(x...) == true) { this->parent_->enqueue(make_unique()); } if (this->det_min1_.has_value() && this->det_max1_.has_value()) { - if (this->det_min1_.value() >= 0 && this->det_max1_.value() >= 0) { - this->parent_->enqueue(make_unique( - this->det_min1_.value_or(-1), this->det_max1_.value_or(-1), this->det_min2_.value_or(-1), - this->det_max2_.value_or(-1), this->det_min3_.value_or(-1), this->det_max3_.value_or(-1), - this->det_min4_.value_or(-1), this->det_max4_.value_or(-1))); + // Each segment is read once, so the guard and the command see the same values. + const float min1 = this->det_min1_.value(x...); + if (min1 >= 0) { + const float max1 = this->det_max1_.value(x...); + if (max1 >= 0) { + this->parent_->enqueue(make_unique( + min1, max1, this->det_min2_.value_or(x..., -1), this->det_max2_.value_or(x..., -1), + this->det_min3_.value_or(x..., -1), this->det_max3_.value_or(x..., -1), + this->det_min4_.value_or(x..., -1), this->det_max4_.value_or(x..., -1))); + } } } if (this->delay_after_detect_.has_value() && this->delay_after_disappear_.has_value()) { diff --git a/tests/components/dfrobot_sen0395/common.yaml b/tests/components/dfrobot_sen0395/common.yaml index 7f980574ed9..48226193bc2 100644 --- a/tests/components/dfrobot_sen0395/common.yaml +++ b/tests/components/dfrobot_sen0395/common.yaml @@ -17,6 +17,21 @@ esphome: dfrobot_sen0395: - id: mmwave +number: + - platform: template + id: mmwave_max_distance + name: mmwave max distance + min_value: 1 + max_value: 9 + step: 0.5 + set_action: + - dfrobot_sen0395.settings: + id: mmwave + detection_segments: + - 0cm + - !lambda "return x;" + sensitivity: 5 + binary_sensor: - platform: dfrobot_sen0395 id: mmwave_detected diff --git a/tests/components/dfrobot_sen0395/settings_action.cpp b/tests/components/dfrobot_sen0395/settings_action.cpp new file mode 100644 index 00000000000..92a75b81677 --- /dev/null +++ b/tests/components/dfrobot_sen0395/settings_action.cpp @@ -0,0 +1,145 @@ +#include +#include +#include "esphome/components/dfrobot_sen0395/automation.h" + +namespace esphome::dfrobot_sen0395::testing { +namespace { +struct CallRecord { + std::array segment_calls{}; + std::array seen_float{}; + std::array seen_int{}; + int sensitivity_calls{0}; + float sensitivity_argument{0}; +}; + +// TemplatableFn stores only a function pointer, so the test lambdas cannot capture. They record into this +// function-local static instead, which SetUp() clears before every test. +CallRecord &call_record() { + static CallRecord record{}; + return record; +} + +float segment_value(size_t index, float x, int y = 0) { + CallRecord &record = call_record(); + record.segment_calls[index]++; + record.seen_float[index] = x; + record.seen_int[index] = y; + return x + y + static_cast(index); +} +} // namespace + +// The component is final and its command queue protected, so these tests check the segment +// reads and their arguments; the enqueued DetRangeCfgCommand itself is not inspected. +class SettingsActionTest : public ::testing::Test { + protected: + void SetUp() override { call_record() = CallRecord{}; } + + DfrobotSen0395Component radar_; +}; + +TEST_F(SettingsActionTest, EmptyTriggerPreservesSegmentCallableCounts) { + DfrobotSen0395SettingsAction<> action; + action.set_parent(&this->radar_); + action.set_det_min1([]() -> float { return segment_value(0, 0); }); + action.set_det_max1([]() -> float { return segment_value(1, 0); }); + action.set_det_min2([]() -> float { return segment_value(2, 0); }); + action.set_det_max2([]() -> float { return segment_value(3, 0); }); + action.set_det_min3([]() -> float { return segment_value(4, 0); }); + action.set_det_max3([]() -> float { return segment_value(5, 0); }); + action.set_det_min4([]() -> float { return segment_value(6, 0); }); + action.set_det_max4([]() -> float { return segment_value(7, 0); }); + action.play(); + for (size_t i = 0; i < call_record().segment_calls.size(); i++) { + EXPECT_EQ(call_record().segment_calls[i], 1) << i; + } +} + +TEST_F(SettingsActionTest, EmptyTriggerWithoutSegmentsIsValid) { + DfrobotSen0395SettingsAction<> action; + action.set_parent(&this->radar_); + action.play(); + for (auto calls : call_record().segment_calls) { + EXPECT_EQ(calls, 0); + } +} + +TEST_F(SettingsActionTest, EmptyTriggerNegativeMinKeepsShortCircuit) { + DfrobotSen0395SettingsAction<> action; + action.set_parent(&this->radar_); + action.set_det_min1([]() -> float { return segment_value(0, -1); }); + action.set_det_max1([]() -> float { return segment_value(1, 0); }); + action.play(); + EXPECT_EQ(call_record().segment_calls[0], 1); + EXPECT_EQ(call_record().segment_calls[1], 0); +} + +TEST_F(SettingsActionTest, FloatTriggerForwardsAllSegmentsAndPreservesCounts) { + DfrobotSen0395SettingsAction action; + action.set_parent(&this->radar_); + action.set_det_min1([](float x) -> float { return segment_value(0, x); }); + action.set_det_max1([](float x) -> float { return segment_value(1, x); }); + action.set_det_min2([](float x) -> float { return segment_value(2, x); }); + action.set_det_max2([](float x) -> float { return segment_value(3, x); }); + action.set_det_min3([](float x) -> float { return segment_value(4, x); }); + action.set_det_max3([](float x) -> float { return segment_value(5, x); }); + action.set_det_min4([](float x) -> float { return segment_value(6, x); }); + action.set_det_max4([](float x) -> float { return segment_value(7, x); }); + action.play(2.5f); + for (size_t i = 0; i < call_record().segment_calls.size(); i++) { + EXPECT_EQ(call_record().segment_calls[i], 1) << i; + EXPECT_FLOAT_EQ(call_record().seen_float[i], 2.5f) << i; + } +} + +TEST_F(SettingsActionTest, MultipleTriggerArgumentsReachConfiguredSegments) { + DfrobotSen0395SettingsAction action; + action.set_parent(&this->radar_); + action.set_det_min1([](float x, int y) -> float { return segment_value(0, x, y); }); + action.set_det_max1([](float x, int y) -> float { return segment_value(1, x, y); }); + action.play(1.5f, 2); + for (size_t i = 0; i < 2; i++) { + EXPECT_EQ(call_record().segment_calls[i], 1) << i; + EXPECT_FLOAT_EQ(call_record().seen_float[i], 1.5f) << i; + EXPECT_EQ(call_record().seen_int[i], 2) << i; + } + for (size_t i = 2; i < call_record().segment_calls.size(); i++) { + EXPECT_EQ(call_record().segment_calls[i], 0) << i; + } +} + +TEST_F(SettingsActionTest, FloatTriggerWithoutSegmentsStillEvaluatesSensitivity) { + DfrobotSen0395SettingsAction action; + action.set_parent(&this->radar_); + action.set_sensitivity([](float x) -> int8_t { + call_record().sensitivity_calls++; + call_record().sensitivity_argument = x; + return 5; + }); + action.play(3.25f); + EXPECT_EQ(call_record().sensitivity_calls, 1); + EXPECT_FLOAT_EQ(call_record().sensitivity_argument, 3.25f); + for (auto calls : call_record().segment_calls) { + EXPECT_EQ(calls, 0); + } +} + +TEST_F(SettingsActionTest, FloatTriggerNegativeMinKeepsShortCircuit) { + DfrobotSen0395SettingsAction action; + action.set_parent(&this->radar_); + action.set_det_min1([](float x) -> float { return segment_value(0, x); }); + action.set_det_max1([](float x) -> float { return segment_value(1, x); }); + action.play(-1.0f); + EXPECT_EQ(call_record().segment_calls[0], 1); + EXPECT_EQ(call_record().segment_calls[1], 0); + EXPECT_FLOAT_EQ(call_record().seen_float[0], -1.0f); +} + +TEST_F(SettingsActionTest, FloatTriggerUnpairedSegmentIsNotEvaluated) { + DfrobotSen0395SettingsAction action; + action.set_parent(&this->radar_); + action.set_det_min1([](float x) -> float { return segment_value(0, x); }); + action.play(4.0f); + EXPECT_EQ(call_record().segment_calls[0], 0); +} + +} // namespace esphome::dfrobot_sen0395::testing From 6fa7b87e6aa5433dd64a21f3999d93399ff1c0f0 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:04:45 +0200 Subject: [PATCH 011/120] [lp5562] Add the current per channel and logarithmic dimming (#20903) --- esphome/components/lp5562/__init__.py | 5 +++ esphome/components/lp5562/lp5562_output.cpp | 24 ++++++++++- esphome/components/lp5562/lp5562_output.h | 10 +++++ esphome/components/lp5562/output.py | 42 ++++++++++++++++++- tests/component_tests/lp5562/__init__.py | 0 tests/component_tests/lp5562/test_channels.py | 33 +++++++++++++++ tests/component_tests/lp5562/test_current.py | 23 ++++++++++ tests/components/lp5562/common.yaml | 4 ++ 8 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lp5562/__init__.py create mode 100644 tests/component_tests/lp5562/test_channels.py create mode 100644 tests/component_tests/lp5562/test_current.py diff --git a/esphome/components/lp5562/__init__.py b/esphome/components/lp5562/__init__.py index b8ace97fc9d..4a5b77f36bb 100644 --- a/esphome/components/lp5562/__init__.py +++ b/esphome/components/lp5562/__init__.py @@ -9,6 +9,8 @@ DEPENDENCIES = ["i2c"] DOMAIN = "lp5562" MULTI_CONF = True +CONF_LOGARITHMIC_DIMMING = "logarithmic_dimming" + lp5562_ns = cg.esphome_ns.namespace("lp5562") LP5562Output = lp5562_ns.class_("LP5562Output", cg.Component, i2c.I2CDevice) @@ -16,6 +18,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(LP5562Output), + cv.Optional(CONF_LOGARITHMIC_DIMMING, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA) @@ -27,3 +30,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + if config[CONF_LOGARITHMIC_DIMMING]: + cg.add(var.set_logarithmic_dimming(True)) diff --git a/esphome/components/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp index b55e8b297d2..da392ea5c04 100644 --- a/esphome/components/lp5562/lp5562_output.cpp +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -15,6 +15,7 @@ static constexpr uint8_t LP5562_REG_LED_MAP = 0x70; static constexpr uint8_t LP5562_RESET_VALUE = 0xff; static constexpr uint8_t LP5562_ENABLE_CHIP_EN = 0x40; +static constexpr uint8_t LP5562_ENABLE_LOG_EN = 0x80; static constexpr uint8_t LP5562_CONFIG_INT_CLK_EN = 0x01; static constexpr uint8_t LP5562_LED_MAP_ALL_PWM_REGISTERS = 0x00; // every channel driven by its PWM register @@ -26,13 +27,22 @@ static const uint8_t LP5562_REG_PWM[4] PROGMEM = { 0x0e, // white }; +// Current registers, same order +static const uint8_t LP5562_REG_CURRENT[4] PROGMEM = { + 0x05, // blue + 0x06, // green + 0x07, // red + 0x0f, // white +}; + void LP5562Output::setup() { // clear any state left over from before a warm reboot; the chip may not ACK while resetting, so ignore the result this->write_byte(LP5562_REG_RESET, LP5562_RESET_VALUE); delay(1); // power up the chip and enable the internal 32 kHz oscillator, see // https://github.com/m5stack/M5GFX/blob/b2422cdb4735159209c2097d425c0756505b9a81/src/M5GFX.cpp#L566 - if (!this->write_byte(LP5562_REG_ENABLE, LP5562_ENABLE_CHIP_EN)) { + const uint8_t enable = LP5562_ENABLE_CHIP_EN | (this->logarithmic_dimming_ ? LP5562_ENABLE_LOG_EN : 0); + if (!this->write_byte(LP5562_REG_ENABLE, enable)) { this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -42,11 +52,21 @@ void LP5562Output::setup() { 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(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + for (size_t channel = 0; channel < this->currents_.size(); channel++) { + if (!this->write_byte(progmem_read_byte(&LP5562_REG_CURRENT[channel]), this->currents_[channel])) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } } } void LP5562Output::dump_config() { - ESP_LOGCONFIG(TAG, "LP5562:"); + ESP_LOGCONFIG(TAG, + "LP5562:\n" + " Logarithmic dimming: %s", + YESNO(this->logarithmic_dimming_)); LOG_I2C_DEVICE(this); } diff --git a/esphome/components/lp5562/lp5562_output.h b/esphome/components/lp5562/lp5562_output.h index a67a4b0658b..51e17eeb6c0 100644 --- a/esphome/components/lp5562/lp5562_output.h +++ b/esphome/components/lp5562/lp5562_output.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/i2c/i2c.h" @@ -26,10 +28,18 @@ class LP5562Output final : public Component, public i2c::I2CDevice { void dump_config() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } + void set_logarithmic_dimming(bool logarithmic_dimming) { this->logarithmic_dimming_ = logarithmic_dimming; } + /// Current limit of a channel in steps of 0.1 mA, written to the chip in setup(). + void set_channel_current(uint8_t channel, uint8_t current) { this->currents_[channel] = current; } + protected: friend class LP5562Channel; void set_channel_value_(uint8_t channel, uint8_t value); + + // 17.5 mA, the chip's reset value + std::array currents_{175, 175, 175, 175}; + bool logarithmic_dimming_{false}; }; } // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/output.py b/esphome/components/lp5562/output.py index 4866eb7b9c9..a9d94ac09b9 100644 --- a/esphome/components/lp5562/output.py +++ b/esphome/components/lp5562/output.py @@ -1,7 +1,8 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.const import CONF_CHANNEL, CONF_CURRENT, CONF_ID +import esphome.final_validate as fv from esphome.types import ConfigType from . import LP5562Output, lp5562_ns @@ -10,6 +11,8 @@ DEPENDENCIES = ["lp5562"] LP5562Channel = lp5562_ns.class_("LP5562Channel", output.FloatOutput) CONF_LP5562_ID = "lp5562_id" +# Chip reset value of the current registers, 17.5 mA; matches currents_ in lp5562_output.h +CURRENT_RESET_TENTHS = 175 # Indices match the LP5562_REG_PWM lookup table in lp5562_output.cpp LP5562_CHANNELS = { @@ -19,17 +22,54 @@ LP5562_CHANNELS = { "white": 3, } + +def _tenths(amps: float) -> int: + """The register value of a current: steps of 0.1 mA.""" + return round(amps * 10000) + + +def _validate_current(amps: float) -> float: + if abs(amps * 10000 - _tenths(amps)) > 1e-6: + raise cv.Invalid("current must be a multiple of 0.1mA") + if not 0 <= _tenths(amps) <= 255: + raise cv.Invalid("current must be between 0mA and 25.5mA") + return amps + + CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.Required(CONF_ID): cv.declare_id(LP5562Channel), cv.GenerateID(CONF_LP5562_ID): cv.use_id(LP5562Output), cv.Required(CONF_CHANNEL): cv.enum(LP5562_CHANNELS, lower=True), + cv.Optional(CONF_CURRENT, default="17.5mA"): cv.All( + cv.current, _validate_current + ), } ) +def _final_validate(config: ConfigType) -> ConfigType: + # Two outputs on one channel would fight over its PWM, and the last current would win + used = fv.full_config.get().data.setdefault("lp5562_channels", set()) + hub, channel = str(config[CONF_LP5562_ID]), str(config[CONF_CHANNEL]) + if (hub, channel) in used: + raise cv.Invalid( + f"Channel '{channel}' of '{hub}' is already used by another output", + [CONF_CHANNEL], + ) + used.add((hub, channel)) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_LP5562_ID]) + parent = await cg.get_variable(config[CONF_LP5562_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) + # The hub already holds the chip's reset value, 17.5 mA + if (current := _tenths(config[CONF_CURRENT])) != CURRENT_RESET_TENTHS: + cg.add(parent.set_channel_current(config[CONF_CHANNEL], current)) await output.register_output(var, config) diff --git a/tests/component_tests/lp5562/__init__.py b/tests/component_tests/lp5562/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/lp5562/test_channels.py b/tests/component_tests/lp5562/test_channels.py new file mode 100644 index 00000000000..a64acf2b348 --- /dev/null +++ b/tests/component_tests/lp5562/test_channels.py @@ -0,0 +1,33 @@ +"""Tests for the lp5562 duplicate channel check.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.lp5562.output import CONF_LP5562_ID, _final_validate +from esphome.config import Config +from esphome.const import CONF_CHANNEL, PlatformFramework +from esphome.core import ID + +from ..types import SetCoreConfigCallable + +RED = "red" +BLUE = "blue" + + +def _output(hub: str, channel: str) -> dict: + return {CONF_LP5562_ID: ID(hub), CONF_CHANNEL: channel} + + +def test_each_channel_once_is_accepted(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=Config()) + _final_validate(_output("lp5562_1", RED)) + _final_validate(_output("lp5562_1", BLUE)) + # Same channel on a second chip is fine + _final_validate(_output("lp5562_2", RED)) + + +def test_same_channel_twice_is_rejected(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=Config()) + _final_validate(_output("lp5562_1", RED)) + with pytest.raises(cv.Invalid, match="Channel 'red' of 'lp5562_1' is already used"): + _final_validate(_output("lp5562_1", RED)) diff --git a/tests/component_tests/lp5562/test_current.py b/tests/component_tests/lp5562/test_current.py new file mode 100644 index 00000000000..91b57a2e6f3 --- /dev/null +++ b/tests/component_tests/lp5562/test_current.py @@ -0,0 +1,23 @@ +"""Tests for the lp5562 output current option.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.lp5562.output import _tenths, _validate_current + + +def test_current_is_stored_in_steps_of_0_1_ma() -> None: + # The register counts in 0.1 mA: 17.5 mA is 175, the full scale 25.5 mA is 255 + assert _tenths(_validate_current(cv.current("17.5mA"))) == 175 + assert _tenths(_validate_current(cv.current("25.5mA"))) == 255 + assert _tenths(_validate_current(cv.current("0mA"))) == 0 + + +def test_current_between_two_steps_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="multiple of 0.1mA"): + _validate_current(cv.current("10.05mA")) + + +def test_current_above_25_5_ma_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="between 0mA and 25.5mA"): + _validate_current(cv.current("25.6mA")) diff --git a/tests/components/lp5562/common.yaml b/tests/components/lp5562/common.yaml index 5bf3406938b..60cf15cf3a8 100644 --- a/tests/components/lp5562/common.yaml +++ b/tests/components/lp5562/common.yaml @@ -2,20 +2,24 @@ lp5562: - address: 0x30 id: lp5562_1 i2c_id: i2c_bus + logarithmic_dimming: true output: - platform: lp5562 id: lp5562_red channel: red lp5562_id: lp5562_1 + current: 10mA - platform: lp5562 id: lp5562_green channel: green lp5562_id: lp5562_1 + current: 25.5mA - platform: lp5562 id: lp5562_blue channel: blue lp5562_id: lp5562_1 + current: 0.5mA - platform: lp5562 id: lp5562_white channel: white From 10d3ced897c50595e1f63d77b0116a64d2383d15 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:11:56 +0200 Subject: [PATCH 012/120] [core] Parse unit values as one decimal so listed floats match, fixing at581x power consumption (#20889) Co-authored-by: J. Nick Koston --- esphome/components/at581x/__init__.py | 2 +- esphome/config_validation.py | 11 +- tests/component_tests/at581x/__init__.py | 0 .../component_tests/at581x/test_validation.py | 105 ++++++++++++++++++ tests/components/at581x/common.yaml | 4 + tests/unit_tests/test_config_validation.py | 12 ++ 6 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/at581x/__init__.py create mode 100644 tests/component_tests/at581x/test_validation.py diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 50c9080c2dd..fd1a8002921 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -93,7 +93,7 @@ def _megahertz(value: float) -> int: def _microamps(value: float) -> int: - return int(value * 1000000) + return round(value * 1000000) RADAR_SETTINGS_SCHEMA = cv.Schema( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index e7d2501e5df..32da6e41855 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -16,6 +16,7 @@ from ipaddress import ( ip_network, ) import logging +import math import os from pathlib import Path import re @@ -1416,12 +1417,16 @@ def float_with_unit(quantity, regex_suffix, optional_unit=False): if match is None: raise Invalid(f"Expected {quantity} with unit, got {value}") - mantissa = float(match.group(1)) if match.group(2) not in METRIC_SUFFIXES: raise Invalid(f"Invalid {quantity} suffix {match.group(2)}") - multiplier = METRIC_SUFFIXES[match.group(2)] - return mantissa * multiplier + # Parse the scaled decimal in one go: "91uA" is then the same float as the + # literal 91e-6, which a product of two floats is not always. + exponent = round(math.log10(METRIC_SUFFIXES[match.group(2)])) + try: + return float(f"{match.group(1)}e{exponent}") + except ValueError as err: + raise Invalid(f"Expected {quantity} with unit, got {value}") from err return validator diff --git a/tests/component_tests/at581x/__init__.py b/tests/component_tests/at581x/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/at581x/test_validation.py b/tests/component_tests/at581x/test_validation.py new file mode 100644 index 00000000000..7ef209a4b6f --- /dev/null +++ b/tests/component_tests/at581x/test_validation.py @@ -0,0 +1,105 @@ +"""Tests for at581x power_consumption validation.""" + +from collections.abc import Callable +from decimal import Decimal + +import pytest + +from esphome import config_validation as cv +from esphome.components.at581x import CONF_POWER_CONSUMPTION, RADAR_SETTINGS_SCHEMA +from esphome.const import CONF_FREQUENCY, CONF_ID +from esphome.core import Lambda +from esphome.types import ConfigType + +ALLOWED_UA = [48, 56, 63, 70, 77, 91, 105, 115, 40, 44, 47, 51, 54, 61, 68, 78] + + +def _settings(**extra: object) -> ConfigType: + return RADAR_SETTINGS_SCHEMA( + {CONF_ID: "waveradar", CONF_FREQUENCY: "5800MHz", **extra} + ) + + +def _power(value: object) -> object: + return _settings(**{CONF_POWER_CONSUMPTION: value})[CONF_POWER_CONSUMPTION] + + +UNIT_SPELLINGS = [ + lambda ua: f"{ua}uA", + lambda ua: f"{ua}µA", + lambda ua: f"{ua} uA", + lambda ua: f"{Decimal(ua) / 1000}mA", + lambda ua: f"{Decimal(ua) / 1000000}A", + lambda ua: f"{ua * 1000}nA", +] + + +@pytest.mark.parametrize("spell", UNIT_SPELLINGS) +@pytest.mark.parametrize("microamps", ALLOWED_UA) +def test_every_listed_value_in_every_unit( + spell: Callable[[int], str], microamps: int +) -> None: + result = _power(spell(microamps)) + assert result == microamps + assert type(result) is int + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("91uA", 91), + ("105uA", 105), + ("115uA", 115), + ("40uA", 40), + ("0.07mA", 70), + ("0.091mA", 91), + ("0.000091A", 91), + ("0.000000091kA", 91), + ("+91uA", 91), + ], +) +def test_previously_broken_and_equivalent_spellings(value: str, expected: int) -> None: + assert _power(value) == expected + + +def test_default_is_70() -> None: + assert _settings()[CONF_POWER_CONSUMPTION] == 70 + + +def test_lambda_is_passed_through() -> None: + assert isinstance(_power(Lambda("return 70;")), Lambda) + + +@pytest.mark.parametrize( + "value", + [ + # Near-miss values must not be rounded onto a supported setting. + "91.00000000001uA", + "90.99999999999uA", + "91.00000000009uA", + "91.0000000001uA", + "90.9999999999uA", + "91.000000001uA", + "91.0001uA", + "91.4uA", + "0.09100000000001mA", + ], +) +def test_fractional_microamps_are_rejected(value: str) -> None: + with pytest.raises(cv.Invalid): + _power(value) + + +def test_sub_ulp_digits_are_indistinguishable_parse_noise() -> None: + # 1e-15 is below half an ulp of 91.0, so the mantissa parses to exactly 91.0. + assert float("91.000000000000001") == 91.0 + assert _power("91.000000000000001uA") == 91 + + +@pytest.mark.parametrize( + "value", + ["92uA", "0uA", "-91uA", "-0.091mA", "9" * 400 + "uA", "infuA", "nanuA", "nan"], +) +def test_unsupported_and_nonfinite_are_rejected(value: str) -> None: + with pytest.raises(cv.Invalid): + _power(value) diff --git a/tests/components/at581x/common.yaml b/tests/components/at581x/common.yaml index dfb3a9a05eb..fbe5bef16ea 100644 --- a/tests/components/at581x/common.yaml +++ b/tests/components/at581x/common.yaml @@ -17,6 +17,10 @@ esphome: frequency: !lambda "return 5800;" poweron_selfcheck_time: !lambda "return 2000;" power_consumption: !lambda "return 70;" + - at581x.settings: + id: waveradar + frequency: 5800MHz + power_consumption: 91uA - at581x.reset: id: waveradar diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 0f68ccb34ab..66732dd04db 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2101,6 +2101,18 @@ def test_float_with_unit_with_suffix() -> None: assert cv.frequency("10kHz") == 10000.0 +def test_float_with_unit_matches_the_literal() -> None: + """The scaled decimal is parsed once, so it equals the same value written as a literal.""" + assert cv.current("91uA") == 91e-6 + assert cv.current("0.091mA") == 91e-6 + assert cv.frequency("5.8GHz") == 5.8e9 + + +def test_float_with_unit_empty_mantissa() -> None: + with pytest.raises(cv.Invalid): + cv.current("uA") + + def test_float_with_unit_no_match() -> None: with pytest.raises(Invalid, match="Expected frequency with unit"): cv.frequency("!!") From 1ddacb49310eb212df6227fcd731631f166b5b0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 13:24:34 -1000 Subject: [PATCH 013/120] [tests] Set i2c_id in the component tests that clash with tca9548a (#20963) --- tests/components/bq27220/common.yaml | 1 + tests/components/bq76972/common.yaml | 1 + tests/components/fusb302b/common.yaml | 1 + tests/components/max17048/common.yaml | 1 + tests/components/spd2010/common.yaml | 3 +++ 5 files changed, 7 insertions(+) diff --git a/tests/components/bq27220/common.yaml b/tests/components/bq27220/common.yaml index 99d1e1f7f85..fad1ef169cd 100644 --- a/tests/components/bq27220/common.yaml +++ b/tests/components/bq27220/common.yaml @@ -1,6 +1,7 @@ sensor: - platform: bq27220 id: bq27220_id + i2c_id: i2c_bus voltage: name: "Battery Voltage" current: diff --git a/tests/components/bq76972/common.yaml b/tests/components/bq76972/common.yaml index 42a9f633aff..4b2e62eb20b 100644 --- a/tests/components/bq76972/common.yaml +++ b/tests/components/bq76972/common.yaml @@ -1,5 +1,6 @@ bq76972: id: bq76972_id + i2c_id: i2c_bus address: 0x08 crc_enabled: true reg_disabled: true diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml index 94e5f2da2bc..e2c4ee87ca7 100644 --- a/tests/components/fusb302b/common.yaml +++ b/tests/components/fusb302b/common.yaml @@ -1,5 +1,6 @@ fusb302b: id: pd + i2c_id: i2c_bus interrupt_pin: ${interrupt_pin} request_voltage: 20 contract: diff --git a/tests/components/max17048/common.yaml b/tests/components/max17048/common.yaml index de0247ed4e2..628bf7ce5c2 100644 --- a/tests/components/max17048/common.yaml +++ b/tests/components/max17048/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: max17048 + i2c_id: i2c_bus battery_voltage: name: Battery Voltage battery_level: diff --git a/tests/components/spd2010/common.yaml b/tests/components/spd2010/common.yaml index 0692137638d..2bd89ebfccd 100644 --- a/tests/components/spd2010/common.yaml +++ b/tests/components/spd2010/common.yaml @@ -1,15 +1,18 @@ display: - platform: ssd1306_i2c id: spd2010_display + i2c_id: i2c_bus model: SSD1306_128X64 touchscreen: - platform: spd2010 + i2c_id: i2c_bus id: spd2010_touchscreen display: spd2010_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} - platform: spd2010 + i2c_id: i2c_bus id: spd2010_polling_touchscreen display: spd2010_display address: 0x54 From b3f5789dc6e6da005a63b406961303015a8a17cf Mon Sep 17 00:00:00 2001 From: Hasan Khalil Date: Fri, 16 Oct 2026 19:39:44 -0400 Subject: [PATCH 014/120] [mipi_rgb] direct-to-PSRAM fast rotated blitting (#20905) --- esphome/components/mipi_rgb/display.py | 2 +- esphome/components/mipi_rgb/mipi_rgb.cpp | 50 +++++++----- esphome/components/mipi_rgb/mipi_rgb.h | 21 +---- .../components/mipi_rgb/mipi_rgb_blitter.h | 76 +++++++++++++++++++ .../mipi_rgb/test_mipi_rgb_config.py | 10 +-- tests/components/mipi_rgb/__init__.py | 5 ++ .../mipi_rgb/test.esp32-s3-idf.yaml | 1 + tests/components/mipi_rgb/test_blitter.cpp | 49 ++++++++++++ 8 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 esphome/components/mipi_rgb/mipi_rgb_blitter.h create mode 100644 tests/components/mipi_rgb/__init__.py create mode 100644 tests/components/mipi_rgb/test_blitter.cpp diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 6e508dd7c44..e973d78ae4d 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -242,7 +242,7 @@ def _config_schema(config: ConfigType) -> ConfigType: config[CONF_ID], width, height, - model.rotation_as_transform(config), + has_hardware_rotation=True, byte_order=config[CONF_BYTE_ORDER], has_writer=requires_buffer(config) or config.get(CONF_AUTO_CLEAR_ENABLED) is True, diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index a38404030ad..beb39ce8473 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -175,6 +175,15 @@ void MipiRgb::common_setup_() { err = esp_lcd_panel_reset(this->handle_); if (err == ESP_OK) err = esp_lcd_panel_init(this->handle_); + if (err == ESP_OK) { + void *fb = nullptr; + err = esp_lcd_rgb_panel_get_frame_buffer(this->handle_, 1, &fb); + if (err == ESP_OK && fb != nullptr) { + this->fb_ = reinterpret_cast(fb); + } else if (err == ESP_OK) { + err = ESP_ERR_NO_MEM; + } + } if (err != ESP_OK) { ESP_LOGE(TAG, "lcd setup failed: %s", esp_err_to_name(err)); this->mark_failed(LOG_STR("lcd setup failed")); @@ -223,30 +232,31 @@ void MipiRgb::draw_pixels_internal(int x_start, int y_start, int w, int h, const int x_pad) { if (w <= 0 || h <= 0 || this->is_failed()) return; - if (bitness != display::COLOR_BITNESS_565 || big_endian != this->big_endian_ || - this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { + if (bitness != display::COLOR_BITNESS_565 || big_endian != this->big_endian_ || this->is_clipping()) { Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); 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); + return; + } + + const int log_w = this->get_width(); + const int log_h = this->get_height(); + + if (x_start < 0 || y_start < 0 || x_start + w > log_w || y_start + h > log_h) { + return; + } + + uint16_t *dst = this->buffer_ != nullptr ? this->buffer_ : this->fb_; + const size_t stride = static_cast(x_offset) + w + x_pad; + const auto *src_origin = + reinterpret_cast(ptr) + (static_cast(y_offset) * stride + x_offset); + + blit_rotated_565(dst, this->width_, this->height_, this->rotation_, src_origin, x_start, y_start, w, h, stride); + if (dst == this->buffer_) { + this->mark_dirty_(x_start, y_start, w, h); + if (!this->updating_) { + this->flush_dirty_(); } - 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); } } diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index f6aec76976b..199dfcfbfc7 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -5,6 +5,7 @@ #include "esphome/core/log.h" #include "esphome/components/display/display.h" #include "esphome/components/display/display_color_utils.h" +#include "mipi_rgb_blitter.h" #include #include #ifdef USE_SPI @@ -83,24 +84,7 @@ class MipiRgb : public display::Display { /// 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 rotate_(int &x, int &y) const { rotate_point(x, y, this->width_, this->height_, this->rotation_); } 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_(); @@ -134,6 +118,7 @@ class MipiRgb : public display::Display { uint16_t y_high_{0}; esp_lcd_panel_handle_t handle_{}; + uint16_t *fb_{nullptr}; }; #ifdef USE_SPI diff --git a/esphome/components/mipi_rgb/mipi_rgb_blitter.h b/esphome/components/mipi_rgb/mipi_rgb_blitter.h new file mode 100644 index 00000000000..1f64b40f5b6 --- /dev/null +++ b/esphome/components/mipi_rgb/mipi_rgb_blitter.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include +#include +#include +#include +#include + +namespace esphome::mipi_rgb { + +inline void rotate_point(int &x, int &y, int width, int height, display::DisplayRotation rotation) { + switch (rotation) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = width - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = width - x - 1; + y = height - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = height - y - 1; + break; + } +} + +inline void blit_rotated_565(uint16_t *fb, int fb_width, int fb_height, display::DisplayRotation rotation, + const uint16_t *src_origin, int x_start, int y_start, int w, int h, size_t stride) { + switch (rotation) { + case display::DISPLAY_ROTATION_0_DEGREES: { + for (int py = 0; py < h; py++) { + memcpy(&fb[(y_start + py) * fb_width + x_start], src_origin + static_cast(py) * stride, + w * sizeof(uint16_t)); + } + break; + } + case display::DISPLAY_ROTATION_90_DEGREES: { + const size_t x_dest_offset = fb_width - y_start - h; + for (int px = 0; px < w; px++) { + uint16_t *dest = &fb[(x_start + px) * fb_width + x_dest_offset]; + for (int py = 0; py < h; py++) { + *dest++ = src_origin[static_cast(h - 1 - py) * stride + px]; + } + } + break; + } + case display::DISPLAY_ROTATION_180_DEGREES: { + const size_t x_dest_offset = fb_width - x_start - w; + for (int py = 0; py < h; py++) { + uint16_t *dest = &fb[(fb_height - 1 - (y_start + py)) * fb_width + x_dest_offset]; + const uint16_t *src_row = src_origin + static_cast(py) * stride; + for (int px = 0; px < w; px++) { + *dest++ = src_row[w - 1 - px]; + } + } + break; + } + case display::DISPLAY_ROTATION_270_DEGREES: { + for (int px = 0; px < w; px++) { + uint16_t *dest = &fb[(fb_height - 1 - x_start - px) * fb_width + y_start]; + const uint16_t *s = src_origin + px; + for (int py = 0; py < h; py++) { + *dest++ = *s; + s += stride; + } + } + break; + } + } +} + +} // namespace esphome::mipi_rgb diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py index 58e7ba9d959..96da849e3c4 100644 --- a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -139,14 +139,14 @@ def test_st7701s_only_supports_mirror_x( CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) -def test_pure_rgb_model_has_no_hardware_transform( +def test_pure_rgb_model_has_hardware_rotation( set_core_config: SetCoreConfigCallable, ) -> None: """A model with no init sequence has no command interface, so no transform - can be applied in hardware and every rotation must be done in software. + can be applied in panel silicon (MADCTL). - Previously such models claimed mirror_x/mirror_y support, so a rotation of - 180 was handed to the (nonexistent) hardware and the display was not rotated. + The driver itself handles rotation directly in the framebuffer without + allocating software rotation buffers, so has_hardware_rotation is True. """ _set_s3(set_core_config) @@ -162,7 +162,7 @@ def test_pure_rgb_model_has_no_hardware_transform( assert not model.rotation_as_transform(config) metadata = get_display_metadata(config["id"]) assert metadata.rotation == rotation - assert not metadata.has_hardware_rotation + assert metadata.has_hardware_rotation with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"): CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) diff --git a/tests/components/mipi_rgb/__init__.py b/tests/components/mipi_rgb/__init__.py new file mode 100644 index 00000000000..584c6e74b42 --- /dev/null +++ b/tests/components/mipi_rgb/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["display"] diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index 12b45ee160e..8a3a6fd3237 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -9,3 +9,4 @@ ch422g: display: - platform: mipi_rgb model: WAVESHARE-5-1024X600 + rotation: 90 diff --git a/tests/components/mipi_rgb/test_blitter.cpp b/tests/components/mipi_rgb/test_blitter.cpp new file mode 100644 index 00000000000..48da6d12199 --- /dev/null +++ b/tests/components/mipi_rgb/test_blitter.cpp @@ -0,0 +1,49 @@ +#include +#include +#include "esphome/components/mipi_rgb/mipi_rgb_blitter.h" + +namespace esphome::mipi_rgb::testing { + +static void test_rotation(display::DisplayRotation rotation) { + constexpr int fb_width = 40; + constexpr int fb_height = 30; + constexpr int w = 7, h = 5, x_offset = 2, y_offset = 3, x_pad = 4; + constexpr size_t stride = x_offset + w + x_pad; + constexpr size_t lines = y_offset + h; + + std::vector source(stride * lines); + uint16_t val = 0x1000; + for (size_t y = 0; y < lines; y++) { + for (size_t x = 0; x < stride; x++) { + source[y * stride + x] = val++; + } + } + + int x_start = 10; + int y_start = 8; + + // Run blit_rotated_565 + std::vector fb_actual(fb_width * fb_height, 0); + const uint16_t *src_origin = source.data() + (y_offset * stride + x_offset); + blit_rotated_565(fb_actual.data(), fb_width, fb_height, rotation, src_origin, x_start, y_start, w, h, stride); + + // Compute reference per-pixel using rotate_point + std::vector fb_expected(fb_width * fb_height, 0); + for (int py = 0; py < h; py++) { + for (int px = 0; px < w; px++) { + int rx = x_start + px; + int ry = y_start + py; + rotate_point(rx, ry, fb_width, fb_height, rotation); + fb_expected[ry * fb_width + rx] = src_origin[py * stride + px]; + } + } + + EXPECT_EQ(fb_actual, fb_expected); +} + +TEST(MipiRgbBlitter, Rotation0) { test_rotation(display::DISPLAY_ROTATION_0_DEGREES); } +TEST(MipiRgbBlitter, Rotation90) { test_rotation(display::DISPLAY_ROTATION_90_DEGREES); } +TEST(MipiRgbBlitter, Rotation180) { test_rotation(display::DISPLAY_ROTATION_180_DEGREES); } +TEST(MipiRgbBlitter, Rotation270) { test_rotation(display::DISPLAY_ROTATION_270_DEGREES); } + +} // namespace esphome::mipi_rgb::testing From b3ef5cf2f3cefbe2da7b5b2d8eb36779292e915b Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Sat, 17 Oct 2026 01:58:12 +0200 Subject: [PATCH 015/120] [online_image] Report an error when a download can never complete (#20594) Co-authored-by: J. Nick Koston Co-authored-by: guillempages Co-authored-by: J. Nick Koston --- .../components/online_image/online_image.cpp | 90 ++++-- .../components/online_image/online_image.h | 2 + .../components/runtime_image/bmp_decoder.h | 9 +- .../components/runtime_image/image_decoder.h | 3 + .../components/runtime_image/qoi_decoder.h | 9 +- .../runtime_image/runtime_image.cpp | 2 + .../components/runtime_image/runtime_image.h | 3 + tests/components/online_image/__init__.py | 14 + .../online_image/test_download_end.cpp | 289 ++++++++++++++++++ .../online_image_stalled_download.yaml | 54 ++++ .../test_online_image_stalled_download.py | 121 ++++++++ 11 files changed, 566 insertions(+), 30 deletions(-) create mode 100644 tests/components/online_image/__init__.py create mode 100644 tests/components/online_image/test_download_end.cpp create mode 100644 tests/integration/fixtures/online_image_stalled_download.yaml create mode 100644 tests/integration/test_online_image_stalled_download.py diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 4497d5cad12..59c1db38a58 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -1,5 +1,6 @@ #include "online_image.h" #include "esphome/components/runtime_image/image_decoder.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -51,6 +52,13 @@ void OnlineImage::update() { return; } + // A failed allocation at boot, or while growing the buffer for a JPEG, leaves no download buffer + if (this->download_buffer_.size() == 0 && this->download_buffer_.resize(this->download_buffer_initial_size_) == 0) { + // resize() logged the failed allocation + this->download_error_callback_.call(); + return; + } + ESP_LOGD(TAG, "Updating image from %s", this->url_.c_str()); std::vector headers; @@ -133,12 +141,17 @@ void OnlineImage::update() { } // JPEG requires the complete image in the download buffer before decoding - if (format == runtime_image::JPEG && total_size > this->download_buffer_.size()) { - this->download_buffer_.resize(total_size); + if (format == runtime_image::JPEG && total_size > this->download_buffer_.size() && + this->download_buffer_.resize(total_size) == 0) { + // resize() logged the failed allocation; the next update() allocates the configured buffer again + this->end_connection_(); + this->download_error_callback_.call(); + return; } ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size); this->start_time_ = millis(); + this->last_data_time_ = this->start_time_; this->enable_loop(); } @@ -161,18 +174,7 @@ void OnlineImage::loop() { if (this->is_decode_finished() || (this->downloader_->content_length > 0 && this->downloader_->get_bytes_read() >= this->downloader_->content_length && this->download_buffer_.unread() == 0)) { - // Finalize decoding - this->end_decode(); - - ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(), - millis() - this->start_time_); - - // Save caching headers - this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME); - this->last_modified_ = this->downloader_->get_response_header(LAST_MODIFIED_HEADER_NAME); - - this->download_finished_callback_.call(false); - this->end_connection_(); + this->finish_download_(); return; } @@ -184,6 +186,7 @@ void OnlineImage::loop() { auto len = this->downloader_->read(this->download_buffer_.append(), available); if (len > 0) { + this->last_data_time_ = App.get_loop_component_start_time(); this->download_buffer_.write(len); // Feed data to decoder @@ -204,25 +207,76 @@ void OnlineImage::loop() { this->end_connection_(); this->download_error_callback_.call(); return; + } else if (this->downloader_->is_read_complete()) { + // The response has ended, so data left in the buffer can never be completed + size_t unread = this->download_buffer_.unread(); + if (unread == 0 && this->downloader_->get_bytes_read() > 0) { + // A decoder that counts pixels knows the image was cut off; PNG cannot tell, so it passes + if (this->is_decode_finished() || !this->decoder_knows_end()) { + this->finish_download_(); + return; + } + ESP_LOGE(TAG, "Download ended before the image was complete"); + } else if (unread == 0) { + ESP_LOGE(TAG, "Server sent an empty response"); + } else { + ESP_LOGE(TAG, "Download ended with %zu bytes the decoder could not use", unread); + } + this->end_connection_(); + this->download_error_callback_.call(); + return; + } else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) { + // ESP-IDF keeps returning 0 after the server closes the connection early + ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout()); + this->end_connection_(); + this->download_error_callback_.call(); + return; } } else { // Buffer is full, need to decode some data first auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread()); if (consumed > 0) { + this->last_data_time_ = App.get_loop_component_start_time(); this->download_buffer_.read(consumed); } else if (consumed < 0) { ESP_LOGE(TAG, "Decode error with full buffer: %d", consumed); this->end_connection_(); this->download_error_callback_.call(); return; - } else { - // Decoder can't process more data, might need complete image - // This is normal for JPEG which needs complete data - ESP_LOGV(TAG, "Decoder waiting for more data"); + } else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) { + // The decoder needs more data than the buffer can hold, so it can never make progress. The + // no-data timeout gives a decoder that only had to wait the chance to show otherwise. + ESP_LOGE(TAG, "Image does not fit in the %zu byte download buffer; increase buffer_size", + this->download_buffer_.size()); + this->end_connection_(); + this->download_error_callback_.call(); + return; } } } +void OnlineImage::finish_download_() { + // Must be read before end_decode() ends the decoding session + bool decoder_finished = this->is_decode_finished(); + this->end_decode(); + + ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(), + millis() - this->start_time_); + + // Only cache an image the decoder confirmed as complete; otherwise a cut-off image would be kept by 304 responses + if (decoder_finished) { + this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME); + this->last_modified_ = this->downloader_->get_response_header(LAST_MODIFIED_HEADER_NAME); + } else { + ESP_LOGW(TAG, "Decoder could not confirm the image is complete; not caching it"); + this->etag_.clear(); + this->last_modified_.clear(); + } + + this->download_finished_callback_.call(false); + this->end_connection_(); +} + void OnlineImage::end_connection_() { // Abort any in-progress decode; the decoder object is kept warm for the next decode. // Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release(). diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index 8b3485b8f74..7397c6c5c32 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -80,6 +80,7 @@ class OnlineImage final : public PollingComponent, protected: bool validate_url_(const std::string &url); void end_connection_(); + void finish_download_(); CallbackManager download_finished_callback_{}; CallbackManager download_error_callback_{}; @@ -107,6 +108,7 @@ class OnlineImage final : public PollingComponent, std::string last_modified_; uint32_t start_time_{0}; + uint32_t last_data_time_{0}; }; } // namespace esphome::online_image diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index 01acc41f91f..cea7f11c784 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -26,13 +26,10 @@ class BmpDecoder : public ImageDecoder { void reset() override; int HOT decode(uint8_t *buffer, size_t size) override; + /// The header gives the pixel count, so the end is known once it has been parsed + bool knows_end() const override { return this->bits_per_pixel_ != 0; } bool is_finished() const override { - if (this->bits_per_pixel_ == 0) { - // header not yet received, so dimensions not yet determined - return false; - } - // BMP is finished when we've decoded all pixel data - return this->paint_index_ >= static_cast(this->width_ * this->height_); + return this->knows_end() && this->paint_index_ >= static_cast(this->width_ * this->height_); } protected: diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 8e0399ac354..6472638261e 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -126,6 +126,9 @@ class ImageDecoder { return false; } + /// Whether is_finished() can tell a complete image from a cut-off one + virtual bool knows_end() const { return this->expected_size_ > 0; } + protected: /// The image's pixel buffer, for decoders that write their output straight into it uint8_t *image_buffer_() const; diff --git a/esphome/components/runtime_image/qoi_decoder.h b/esphome/components/runtime_image/qoi_decoder.h index c44afd83cf2..e66b10027d8 100644 --- a/esphome/components/runtime_image/qoi_decoder.h +++ b/esphome/components/runtime_image/qoi_decoder.h @@ -25,13 +25,10 @@ class QoiDecoder : public ImageDecoder { void reset() override; int HOT decode(uint8_t *buffer, size_t size) override; + /// The header gives the pixel count, so the end is known once it has been parsed + bool knows_end() const override { return this->bits_per_pixel_ != 0; } bool is_finished() const override { - if (this->bits_per_pixel_ == 0) { - // header not yet received, so dimensions not yet determined - return false; - } - // QOI is finished when we've decoded all pixel data - return this->paint_index_ >= static_cast(this->width_ * this->height_); + return this->knows_end() && this->paint_index_ >= static_cast(this->width_ * this->height_); } protected: diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 14e0d5665a6..1c6a2bc6002 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -262,6 +262,8 @@ bool RuntimeImage::end_decode() { bool RuntimeImage::is_decode_finished() const { return this->is_decoding() && this->decoder_->is_finished(); } +bool RuntimeImage::decoder_knows_end() const { return this->is_decoding() && this->decoder_->knows_end(); } + void RuntimeImage::release() { this->release_buffer_(); // End any active decode session; decoders free the format-specific working buffers diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 2a3ab40a5c1..4dc60213649 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -96,6 +96,9 @@ class RuntimeImage : public image::Image { */ bool is_decode_finished() const; + /// True while decoding and the decoder can tell a complete image from a cut-off one + bool decoder_knows_end() const; + /** * @brief Check if an image is currently loaded. */ diff --git a/tests/components/online_image/__init__.py b/tests/components/online_image/__init__.py new file mode 100644 index 00000000000..8635a2ba4ee --- /dev/null +++ b/tests/components/online_image/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.runtime_image import get_format +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The real to_code needs an image config. The unit tests decode BMP and PNG, and use + # JPEG for the download buffer resize, so compile in those three decoders. + async def to_code_testing(config: ConfigType) -> None: + get_format("BMP").actions() + get_format("JPEG").actions() + get_format("PNG").actions() + + manifest.to_code = to_code_testing diff --git a/tests/components/online_image/test_download_end.cpp b/tests/components/online_image/test_download_end.cpp new file mode 100644 index 00000000000..d844cf845e8 --- /dev/null +++ b/tests/components/online_image/test_download_end.cpp @@ -0,0 +1,289 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/http_request/http_request.h" +#include "esphome/components/online_image/online_image.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::online_image::testing { + +// Serves a fixed body, handing out at most `available` bytes of it. +class FakeContainer : public http_request::HttpContainer { + public: + FakeContainer(std::vector body, size_t available, bool chunked) + : body_(std::move(body)), available_(std::min(available, this->body_.size())) { + this->status_code = 200; + this->content_length = chunked ? 0 : this->body_.size(); + this->set_chunked(chunked); + } + + int read(uint8_t *buf, size_t max_len) override { + size_t len = std::min(max_len, this->available_ - this->bytes_read_); + std::memcpy(buf, this->body_.data() + this->bytes_read_, len); + this->bytes_read_ += len; + return static_cast(len); + } + void end() override {} + + // Like ESP-IDF: a chunked response is complete once its last chunk has been read + bool is_read_complete() const override { + if (this->is_chunked_) + return this->bytes_read_ >= this->body_.size(); + return HttpContainer::is_read_complete(); + } + + void set_content_length(size_t length) { this->content_length = length; } + void add_response_header(const std::string &name, const std::string &value) { + this->response_headers_.push_back({name, value}); + } + + protected: + std::vector body_; + size_t available_; +}; + +class FakeHttpRequest : public http_request::HttpRequestComponent { + public: + std::shared_ptr next; + std::vector sent_headers; + + bool sent_header(const std::string &name) const { + return std::any_of(this->sent_headers.begin(), this->sent_headers.end(), + [&name](const http_request::Header &header) { return header.name == name; }); + } + + protected: + std::shared_ptr perform(const char *url, const char *method, const std::string &body, + const std::vector &request_headers, + const std::vector &collect_headers, + uint32_t timeout) override { + this->sent_headers = request_headers; + return std::move(this->next); + } +}; + +// A 24-bit BMP, 2x2 pixels: 54 header bytes, then two rows of 6 pixel bytes and 2 padding bytes. +static constexpr size_t BMP_HEADER_SIZE = 54; +static constexpr size_t BMP_ROW_SIZE = 8; + +static std::vector make_bmp() { + std::vector bmp(BMP_HEADER_SIZE + 2 * BMP_ROW_SIZE, 0); + auto put32 = [&bmp](size_t offset, uint32_t value) { + for (size_t i = 0; i < 4; i++) + bmp[offset + i] = (value >> (8 * i)) & 0xFF; + }; + bmp[0] = 'B'; + bmp[1] = 'M'; + put32(2, bmp.size()); + put32(10, BMP_HEADER_SIZE); + put32(14, 40); // DIB header size + put32(18, 2); // width + put32(22, 2); // height + bmp[26] = 1; // color planes + bmp[28] = 24; // bits per pixel + return bmp; +} + +// An 8x8, 8-bit BMP whose 256-color table puts the pixels at offset 1078, past a 256 byte buffer. +static std::vector make_palette_bmp() { + constexpr size_t data_offset = 14 + 40 + 256 * 4; + std::vector bmp(data_offset + 8 * 8, 0); + auto put32 = [&bmp](size_t offset, uint32_t value) { + for (size_t i = 0; i < 4; i++) + bmp[offset + i] = (value >> (8 * i)) & 0xFF; + }; + bmp[0] = 'B'; + bmp[1] = 'M'; + put32(2, bmp.size()); + put32(10, data_offset); + put32(14, 40); // DIB header size + put32(18, 8); // width + put32(22, 8); // height + bmp[26] = 1; // color planes + bmp[28] = 8; // bits per pixel + put32(34, 64); // image size + put32(46, 256); // colors in the table + return bmp; +} + +// A 1x1 RGB PNG: 8 signature bytes, a 25 byte IHDR chunk, a 24 byte IDAT chunk and a 12 byte IEND chunk. +static constexpr size_t PNG_HEADER_SIZE = 8 + 25; +static constexpr std::array PNG_1X1{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, + 0x0C, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0xC9, + 0xFE, 0x92, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82}; + +static void set_loop_time(uint32_t now) { LoopBlockingGuard clock(nullptr, LOG_STR("test"), now); } + +class OnlineImageDownloadEnd : public ::testing::Test { + protected: + std::unique_ptr make_image_(runtime_image::ImageFormat format) { + auto img = std::make_unique("http://example.com/image", 0, 0, format, image::IMAGE_TYPE_RGB, + image::TRANSPARENCY_OPAQUE, nullptr, 256); + img->set_parent(&this->http_); + return img; + } + + void start_(OnlineImage &image, const std::shared_ptr &container) { + image.add_on_finished_callback([this](bool) { this->finished_++; }); + image.add_on_error_callback([this]() { this->errors_++; }); + this->http_.next = container; + image.update(); + } + + FakeHttpRequest http_; + int finished_{0}; + int errors_{0}; +}; + +// A chunked stream can end without the decoder reporting it is finished. PNG cannot tell a +// complete image from a cut-off one, so when the decoder used every byte the download succeeds, +// but the image is not cached: a 304 response must not keep it. +TEST_F(OnlineImageDownloadEnd, ChunkedPngCutOffFinishesWithoutCaching) { + std::vector png(PNG_1X1.begin(), PNG_1X1.begin() + PNG_HEADER_SIZE); // ends after IHDR + auto container = std::make_shared(png, png.size(), true); + container->add_response_header("etag", "\"abc\""); + container->add_response_header("last-modified", "Mon, 12 Oct 2026 10:00:00 GMT"); + auto image = this->make_image_(runtime_image::PNG); + this->start_(*image, container); + + for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++) + image->loop(); + + EXPECT_EQ(this->finished_, 1); + EXPECT_EQ(this->errors_, 0); + + this->http_.next = std::make_shared(png, 0, false); + image->update(); + EXPECT_FALSE(this->http_.sent_header("if-none-match")); + EXPECT_FALSE(this->http_.sent_header("if-modified-since")); +} + +// BMP knows its pixel count from the header, so a chunked BMP cut off at a row boundary is an error. +TEST_F(OnlineImageDownloadEnd, ChunkedBmpCutOffAtRowReportsError) { + std::vector bmp = make_bmp(); + bmp.resize(BMP_HEADER_SIZE + BMP_ROW_SIZE); // only the first row + auto container = std::make_shared(bmp, bmp.size(), true); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++) + image->loop(); + + EXPECT_EQ(this->errors_, 1); + EXPECT_EQ(this->finished_, 0); +} + +// An image the decoder confirmed as complete is cached. +TEST_F(OnlineImageDownloadEnd, CompleteImageIsCached) { + std::vector bmp = make_bmp(); + auto container = std::make_shared(bmp, bmp.size(), false); + container->add_response_header("etag", "\"abc\""); + container->add_response_header("last-modified", "Mon, 12 Oct 2026 10:00:00 GMT"); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++) + image->loop(); + ASSERT_EQ(this->finished_, 1); + + this->http_.next = std::make_shared(bmp, 0, false); + image->update(); + EXPECT_TRUE(this->http_.sent_header("if-none-match")); + EXPECT_TRUE(this->http_.sent_header("if-modified-since")); +} + +// ESP-IDF keeps returning 0 after the server closes the connection early. +TEST_F(OnlineImageDownloadEnd, NoDataForTimeoutReportsError) { + this->http_.set_timeout(100); + auto container = std::make_shared(make_bmp(), 20, false); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + const uint32_t now = millis(); + set_loop_time(now); + image->loop(); + ASSERT_EQ(container->get_bytes_read(), 20u); + set_loop_time(now + 99); + image->loop(); + EXPECT_EQ(this->errors_, 0); + + set_loop_time(now + 100); + image->loop(); + EXPECT_EQ(this->errors_, 1); + EXPECT_EQ(this->finished_, 0); +} + +// The time spent connecting and reading the headers must not count as time without data. +TEST_F(OnlineImageDownloadEnd, NoDataTimeoutStartsAfterRequest) { + this->http_.set_timeout(100); + auto container = std::make_shared(make_bmp(), 0, false); + auto image = this->make_image_(runtime_image::BMP); + // update() runs in a dispatch that started before the blocking request, here one second earlier + set_loop_time(millis() - 1000); + this->start_(*image, container); + + const uint32_t now = millis(); + set_loop_time(now); + image->loop(); + EXPECT_EQ(this->errors_, 0); + + set_loop_time(now + 100); + image->loop(); + EXPECT_EQ(this->errors_, 1); +} + +// A full buffer the decoder cannot use waits for the no-data timeout before it fails, so a decoder +// that only had to wait is not cut off early. +TEST_F(OnlineImageDownloadEnd, FullBufferFailsAfterTimeout) { + this->http_.set_timeout(100); + auto bmp = make_palette_bmp(); + auto container = std::make_shared(bmp, bmp.size(), false); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + const uint32_t now = millis(); + set_loop_time(now); + for (int i = 0; i < 8; i++) + image->loop(); + EXPECT_EQ(this->errors_, 0); + set_loop_time(now + 99); + image->loop(); + EXPECT_EQ(this->errors_, 0); + + set_loop_time(now + 100); + image->loop(); + EXPECT_EQ(this->errors_, 1); + EXPECT_EQ(this->finished_, 0); +} + +// A JPEG too big for the heap must fail, and the next download must get the configured buffer back. +TEST_F(OnlineImageDownloadEnd, JpegBufferAllocationFailureReportsError) { + auto image = this->make_image_(runtime_image::JPEG); + auto huge = std::make_shared(std::vector(16), 16, false); + huge->set_content_length(SIZE_MAX / 2); + this->start_(*image, huge); + EXPECT_EQ(this->errors_, 1); + + // A chunked response does not resize the buffer. The body is not a real JPEG, so the decoder + // rejects it, but only once it has been read into the buffer. + auto next = std::make_shared(std::vector(100, 0xAA), 100, true); + this->http_.next = next; + image->update(); + image->loop(); + EXPECT_EQ(next->get_bytes_read(), 100u); + EXPECT_EQ(this->errors_, 2); + EXPECT_EQ(this->finished_, 0); +} + +} // namespace esphome::online_image::testing diff --git a/tests/integration/fixtures/online_image_stalled_download.yaml b/tests/integration/fixtures/online_image_stalled_download.yaml new file mode 100644 index 00000000000..1be383ee9be --- /dev/null +++ b/tests/integration/fixtures/online_image_stalled_download.yaml @@ -0,0 +1,54 @@ +esphome: + name: online-image-stalled + +host: + +http_request: + timeout: 500ms + +display: + +image: + - platform: online_image + url: http://127.0.0.1:HTTP_PORT/truncated.bmp + id: truncated_img + format: BMP + type: RGB + on_download_finished: + logger.log: "download finished: truncated" + on_error: + logger.log: "download error: truncated" + - platform: online_image + url: http://127.0.0.1:HTTP_PORT/large_header.bmp + id: large_header_img + format: BMP + type: RGB + buffer_size: 256 + on_download_finished: + logger.log: "download finished: large_header" + on_error: + logger.log: "download error: large_header" + - platform: online_image + url: http://127.0.0.1:HTTP_PORT/empty.bmp + id: empty_img + format: BMP + type: RGB + on_download_finished: + logger.log: "download finished: empty" + on_error: + logger.log: "download error: empty" + +api: + actions: + - action: fetch_truncated + then: + - component.update: truncated_img + - action: fetch_large_header + then: + - component.update: large_header_img + - action: fetch_empty + then: + - component.update: empty_img + +logger: + level: DEBUG diff --git a/tests/integration/test_online_image_stalled_download.py b/tests/integration/test_online_image_stalled_download.py new file mode 100644 index 00000000000..35c549afe60 --- /dev/null +++ b/tests/integration/test_online_image_stalled_download.py @@ -0,0 +1,121 @@ +"""Downloads that can never complete must report an error instead of waiting forever.""" + +from __future__ import annotations + +import asyncio +import struct + +import pytest + +from .online_image_utils import BMP_IMAGE +from .types import APIClientConnectedFactory, RunCompiledFunction + +# 24-bit BMP cut off one byte into the sixth pixel: the decoder keeps that byte, +# waiting for the rest of the pixel, but the response has already ended. +TRUNCATED_BMP = BMP_IMAGE[: 54 + 5 * 3 + 1] + + +def _make_palette_bmp() -> bytes: + """Build an 8x8, 8-bit BMP whose 256-color table puts the pixels at offset 1078.""" + width = height = 8 + data_offset = 14 + 40 + 256 * 4 + file_size = data_offset + width * height + file_header = b"BM" + struct.pack(" None: + try: + async with asyncio.timeout(1.0): + request_line = await reader.readuntil(b"\r\n") + await reader.readuntil(b"\r\n\r\n") + path = request_line.split(b" ")[1].decode() + body = IMAGES[path] + writer.write( + b"\r\n".join( + [ + b"HTTP/1.1 200 OK", + b"Content-Length: %d" % len(body), + b"Content-Type: image/bmp", + b"Connection: close", + b"", + b"", + ] + ) + + body + ) + await writer.drain() + finally: + writer.close() + + +@pytest.mark.asyncio +async def test_online_image_stalled_download( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Esphome should report an error when a download can never complete.""" + loop = asyncio.get_running_loop() + expected_logs = { + "truncated_reason": "Download ended with 1 bytes the decoder could not use", + "truncated_error": "download error: truncated", + "large_header_reason": "Image does not fit in the 256 byte download buffer", + "large_header_error": "download error: large_header", + "empty_reason": "Server sent an empty response", + "empty_error": "download error: empty", + } + log_futures = {key: loop.create_future() for key in expected_logs} + finished_lines: list[str] = [] + + def check_output(line: str) -> None: + for key, text in expected_logs.items(): + if text in line and not log_futures[key].done(): + log_futures[key].set_result(True) + if "download finished" in line: + finished_lines.append(line) + + server = await asyncio.start_server(handle_http, "127.0.0.1", 0) + http_server_port = server.sockets[0].getsockname()[1] + config = yaml_config.replace("HTTP_PORT", str(http_server_port)) + + async with ( + server, + run_compiled(config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + actions = {s.name: s for s in services} + + await client.execute_service(actions["fetch_truncated"], {}) + async with asyncio.timeout(2.0): + await log_futures["truncated_reason"] + await log_futures["truncated_error"] + + await client.execute_service(actions["fetch_large_header"], {}) + async with asyncio.timeout(2.0): + await log_futures["large_header_reason"] + await log_futures["large_header_error"] + + await client.execute_service(actions["fetch_empty"], {}) + async with asyncio.timeout(2.0): + await log_futures["empty_reason"] + await log_futures["empty_error"] + + assert not finished_lines, f"Unexpected successful download: {finished_lines}" From 51ae3bb64245d8441492664c1554f3f157d16b90 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:31:06 +1100 Subject: [PATCH 016/120] [ci] Rename merge conflict label to has-conflicts (#20969) --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7507eb4a424..1b0d478bd86 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -27,7 +27,7 @@ jobs: days-before-close: 7 stale-label: stale exempt-label: not-stale - conflict-label: needs-rebase + conflict-label: has-conflicts ignored-users: esphbot,codecov-commenter stale-pr-message: > There hasn't been any activity on this pull request recently. This From d9969dd53376fda4d8658260367f91b3ba2c3cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:55:08 +0200 Subject: [PATCH 017/120] [teleinfo] Skip sensor values that are not numbers instead of publishing 0 (#20960) Co-authored-by: Claude --- .../teleinfo/sensor/teleinfo_sensor.cpp | 9 +++- tests/components/teleinfo/__init__.py | 11 +++++ tests/components/teleinfo/sensor/__init__.py | 10 ++++ .../teleinfo/teleinfo_sensor_test.cpp | 47 +++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 tests/components/teleinfo/__init__.py create mode 100644 tests/components/teleinfo/sensor/__init__.py create mode 100644 tests/components/teleinfo/teleinfo_sensor_test.cpp diff --git a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp index 443d4be8a10..6929f587d10 100644 --- a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp +++ b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp @@ -6,8 +6,13 @@ namespace esphome::teleinfo { ESPHOME_LOG_TAG(TAG, "teleinfo_sensor"); TeleInfoSensor::TeleInfoSensor(const char *tag) { this->tag = std::string(tag); } void TeleInfoSensor::publish_val(const std::string &val) { - auto newval = parse_number(val).value_or(0.0f); - publish_state(newval); + auto newval = parse_number(val); + if (!newval.has_value()) { + // Publishing 0 instead would look like a meter reset to total_increasing energy sensors + ESP_LOGW(TAG, "Value '%s' for tag %s is not a number", val.c_str(), this->tag.c_str()); + return; + } + this->publish_state(*newval); } void TeleInfoSensor::dump_config() { LOG_SENSOR(" ", "Teleinfo Sensor", this); } } // namespace esphome::teleinfo diff --git a/tests/components/teleinfo/__init__.py b/tests/components/teleinfo/__init__.py new file mode 100644 index 00000000000..5f1db440600 --- /dev/null +++ b/tests/components/teleinfo/__init__.py @@ -0,0 +1,11 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # teleinfo.h includes uart.h; the hub's own to_code needs a configured UART bus. + async def to_code_testing(config: ConfigType) -> None: + pass + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["uart"] diff --git a/tests/components/teleinfo/sensor/__init__.py b/tests/components/teleinfo/sensor/__init__.py new file mode 100644 index 00000000000..cfaaf8d96bd --- /dev/null +++ b/tests/components/teleinfo/sensor/__init__.py @@ -0,0 +1,10 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The platform's own to_code needs a configured hub; the sensor class is built directly. + async def to_code_testing(config: ConfigType) -> None: + pass + + manifest.to_code = to_code_testing diff --git a/tests/components/teleinfo/teleinfo_sensor_test.cpp b/tests/components/teleinfo/teleinfo_sensor_test.cpp new file mode 100644 index 00000000000..9b86fd61879 --- /dev/null +++ b/tests/components/teleinfo/teleinfo_sensor_test.cpp @@ -0,0 +1,47 @@ +#include + +#include + +#include "esphome/components/teleinfo/sensor/teleinfo_sensor.h" + +namespace esphome::teleinfo::testing { + +namespace { +class TeleInfoSensorTest : public ::testing::Test { + protected: + void SetUp() override { + this->sensor_.add_on_state_callback([this](float state) { + this->publishes_++; + this->last_ = state; + }); + } + + TeleInfoSensor sensor_{"EAST"}; + int publishes_{0}; + float last_{NAN}; +}; +} // namespace + +TEST_F(TeleInfoSensorTest, PublishesZeroPaddedNumber) { + this->sensor_.publish_val("000012345"); + EXPECT_EQ(this->publishes_, 1); + EXPECT_FLOAT_EQ(this->last_, 12345.0f); +} + +TEST_F(TeleInfoSensorTest, PublishesRealZero) { + this->sensor_.publish_val("000000000"); + EXPECT_EQ(this->publishes_, 1); + EXPECT_FLOAT_EQ(this->last_, 0.0f); +} + +// A value that is not a number must be dropped: publishing 0 would look like a meter reset +TEST_F(TeleInfoSensorTest, SkipsValueThatIsNotANumber) { + this->sensor_.publish_val("000012345"); + this->sensor_.publish_val("0000l2345"); + this->sensor_.publish_val("H081225223518"); + EXPECT_EQ(this->publishes_, 1); + EXPECT_FLOAT_EQ(this->last_, 12345.0f); + EXPECT_FLOAT_EQ(this->sensor_.get_state(), 12345.0f); +} + +} // namespace esphome::teleinfo::testing From bb0eabf241bd46d54f70df67c3d65a8baff2678b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 14:56:34 -1000 Subject: [PATCH 018/120] [core] Add options() to C++ enums (#20910) --- esphome/cpp_generator.py | 14 ++++++++++ tests/unit_tests/test_cpp_generator.py | 36 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index d0fc126957f..b17db965212 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1270,6 +1270,9 @@ class MockObj(Expression): return MockObj(op) +_ENUM_NON_ALNUM = re.compile(r"[^A-Za-z0-9]") + + class MockObjEnum(MockObj): def __init__(self, *args, **kwargs): self._enum = kwargs.pop("enum") @@ -1289,6 +1292,17 @@ class MockObjEnum(MockObj): def __repr__(self): return f"MockObj<{str(self.base)}>" + def options(self, *names: str, prefix: str = "") -> dict[str, "MockObj"]: + """Map option names to prefix + NAME enumerators (non-alphanumerics become "_"), for cv.enum.""" + enumerators = { + name: prefix + _ENUM_NON_ALNUM.sub("_", name).upper() for name in names + } + if len(set(enumerators.values())) != len(names): + raise ValueError(f"Option names must map to distinct enumerators: {names}") + return { + name: getattr(self, enumerator) for name, enumerator in enumerators.items() + } + class MockObjClass(MockObj): def __init__(self, *args, **kwargs): diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index ec88c583baa..e78035f1f6c 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -965,3 +965,39 @@ def test_extern_progmem_array_is_an_externally_linked_global() -> None: assert [str(st) for st in CORE.global_statements if "PROGMEM" in str(st)] == [ "const uint8_t esphome::api::API_TABLE[] PROGMEM = {1, 2};" ] + + +class TestEnumOptions: + def test_class_enum_with_prefix(self) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode", is_class=True) + options = mode.options("fast", "slow-ish", "2.5x", prefix="MODE_") + assert list(options) == ["fast", "slow-ish", "2.5x"] + assert {k: str(v) for k, v in options.items()} == { + "fast": "esphome::foo::Mode::MODE_FAST", + "slow-ish": "esphome::foo::Mode::MODE_SLOW_ISH", + "2.5x": "esphome::foo::Mode::MODE_2_5X", + } + + def test_matches_attribute_access(self) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode") + options = mode.options("LOW", "HIGH") + assert str(options["LOW"]) == str(mode.LOW) == "esphome::foo::LOW" + assert str(options["HIGH"]) == str(mode.HIGH) + + def test_result_works_with_cv_enum(self) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode", is_class=True) + options = mode.options("on", "off", prefix="MODE_") + validator = cv.enum(options, lower=True) + assert str(validator("ON").enum_value) == "esphome::foo::Mode::MODE_ON" + assert validator(cv.SCHEMA_EXTRACT) is options + + @pytest.mark.parametrize( + "names", + [("OFF", "OFF"), ("slow-ish", "slow_ish"), ("2.5x", "2_5X")], + ) + def test_names_must_map_to_distinct_enumerators( + self, names: tuple[str, ...] + ) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode") + with pytest.raises(ValueError, match="distinct enumerators"): + mode.options(*names) From 36ba2f6106c9a0648a26c585e699cf45dfabbbe8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 14:56:34 -1000 Subject: [PATCH 019/120] [components] Build enum option dicts with options() (#20911) --- .../components/absolute_humidity/sensor.py | 6 +- esphome/components/ac_dimmer/output.py | 8 +- esphome/components/adc/__init__.py | 6 +- esphome/components/ags10/sensor.py | 8 +- esphome/components/aht10/sensor.py | 5 +- .../components/airthings_wave_plus/sensor.py | 5 +- esphome/components/as5600/sensor/__init__.py | 5 +- esphome/components/as734x/__init__.py | 9 +- esphome/components/audio/__init__.py | 8 +- esphome/components/bedjet/climate/__init__.py | 12 +- esphome/components/bh1745/__init__.py | 6 +- esphome/components/bl0939/sensor.py | 7 +- esphome/components/bme280_base/__init__.py | 21 +--- esphome/components/bme680/sensor.py | 24 +--- esphome/components/bme680_bsec/__init__.py | 10 +- esphome/components/bme68x_bsec2/__init__.py | 22 +--- esphome/components/bmi270/motion.py | 9 +- esphome/components/bmp280_base/__init__.py | 21 +--- esphome/components/canbus/__init__.py | 47 +++---- esphome/components/cc1101/__init__.py | 115 +++++------------- esphome/components/climate/__init__.py | 93 +++++++------- esphome/components/cover/__init__.py | 8 +- esphome/components/cs5460a/sensor.py | 5 +- esphome/components/cse7761/sensor.py | 7 +- esphome/components/deep_sleep/__init__.py | 16 +-- esphome/components/dfplayer/__init__.py | 14 +-- esphome/components/dht/sensor.py | 21 ++-- esphome/components/es8311/audio_dac.py | 21 ++-- esphome/components/es8388/select/__init__.py | 16 +-- .../components/es9038q2m_katana/audio_dac.py | 9 +- esphome/components/esp32_ble/__init__.py | 21 ++-- esphome/components/esp32_camera/__init__.py | 64 +++++----- .../esp32_camera_web_server/__init__.py | 2 +- esphome/components/espectre/__init__.py | 11 +- esphome/components/ethernet/__init__.py | 48 ++++---- .../exponential_moving_average/sensor.py | 8 +- esphome/components/fan/__init__.py | 23 ++-- .../components/fingerprint_grow/__init__.py | 23 +--- esphome/components/friedrich/climate.py | 4 +- esphome/components/gp8403/__init__.py | 10 +- esphome/components/graph/__init__.py | 23 ++-- esphome/components/gree/climate.py | 12 +- esphome/components/growatt_solar/sensor.py | 5 +- esphome/components/haier/climate.py | 29 ++--- esphome/components/haier_tundra/climate.py | 5 +- esphome/components/hbridge/fan/__init__.py | 5 +- esphome/components/hdc302x/sensor.py | 9 +- esphome/components/heatpumpir/climate.py | 22 +--- esphome/components/hlw8012/sensor.py | 8 +- .../components/honeywellabp2_i2c/sensor.py | 5 +- esphome/components/htu21d/sensor.py | 8 +- esphome/components/hub75/display.py | 49 ++++---- .../components/i2s_audio/speaker/__init__.py | 25 ++-- esphome/components/icm20689/motion.py | 9 +- esphome/components/inkplate/display.py | 16 +-- esphome/components/integration/sensor.py | 8 +- esphome/components/jsn_sr04t/sensor.py | 5 +- esphome/components/lc709203f/sensor.py | 7 +- esphome/components/light/types.py | 27 ++-- esphome/components/lis2dw12/__init__.py | 39 +++--- esphome/components/lis3dh/motion.py | 22 ++-- esphome/components/lock/__init__.py | 19 +-- esphome/components/lsm6ds/motion.py | 9 +- esphome/components/max31856/sensor.py | 13 +- esphome/components/max31865/sensor.py | 5 +- esphome/components/max44009/sensor.py | 8 +- esphome/components/max6956/__init__.py | 5 +- esphome/components/max7219digit/display.py | 10 +- esphome/components/mcp23xxx_base/__init__.py | 9 +- esphome/components/mcp2515/canbus.py | 13 +- esphome/components/mcp4728/output/__init__.py | 11 +- esphome/components/mcp9600/sensor.py | 13 +- esphome/components/media_player/__init__.py | 7 +- esphome/components/midea/climate.py | 8 +- esphome/components/mitsubishi/climate.py | 34 +++--- esphome/components/modbus/helpers.py | 60 +++++---- .../modbus_controller/text_sensor/__init__.py | 7 +- .../motion/binary_sensor/__init__.py | 9 +- esphome/components/msa3xx/__init__.py | 7 +- esphome/components/number/__init__.py | 15 +-- esphome/components/pca9685/__init__.py | 5 +- esphome/components/pcm5122/switch/__init__.py | 7 +- esphome/components/pm2005/sensor.py | 5 +- esphome/components/pulse_counter/sensor.py | 8 +- esphome/components/pulse_meter/sensor.py | 5 +- esphome/components/pzem6l24/sensor.py | 10 +- esphome/components/qmi8658/motion.py | 9 +- esphome/components/qmp6988/sensor.py | 11 +- esphome/components/qr_code/__init__.py | 9 +- esphome/components/remote_base/__init__.py | 47 +++---- esphome/components/resistance/sensor.py | 5 +- esphome/components/rotary_encoder/sensor.py | 7 +- .../components/rp2040_pio_led_strip/light.py | 7 +- esphome/components/scd4x/sensor.py | 9 +- esphome/components/select/__init__.py | 9 +- esphome/components/sen5x/sensor.py | 6 +- .../sendspin/text_sensor/__init__.py | 9 +- esphome/components/serial_proxy/__init__.py | 9 +- esphome/components/sht4x/sensor.py | 23 ++-- esphome/components/sm2135/__init__.py | 48 ++++---- esphome/components/spa06_base/__init__.py | 37 +++--- .../speaker/media_player/__init__.py | 5 +- esphome/components/spi_device/__init__.py | 5 +- esphome/components/st7701s/display.py | 5 +- esphome/components/stcc4/sensor.py | 5 +- esphome/components/sun/sensor/__init__.py | 5 +- esphome/components/sx126x/__init__.py | 32 +++-- esphome/components/sx127x/__init__.py | 11 +- esphome/components/sy6970/__init__.py | 9 +- esphome/components/syslog/__init__.py | 5 +- esphome/components/tas58xx/audio_dac.py | 15 +-- esphome/components/tcs34725/sensor.py | 50 ++++---- .../template/alarm_control_panel/__init__.py | 20 +-- .../components/template/climate/__init__.py | 7 +- esphome/components/template/cover/__init__.py | 8 +- esphome/components/template/valve/__init__.py | 8 +- .../template/water_heater/__init__.py | 8 +- esphome/components/thermostat/climate.py | 24 ++-- esphome/components/tmc22xx/__init__.py | 13 +- esphome/components/tmp1075/sensor.py | 5 +- esphome/components/toshiba/climate.py | 17 +-- .../components/total_daily_energy/sensor.py | 8 +- esphome/components/tsl2561/sensor.py | 5 +- esphome/components/tuya/cover/__init__.py | 8 +- esphome/components/tuya/light/__init__.py | 6 +- esphome/components/uart/__init__.py | 24 ++-- esphome/components/uln2003/stepper.py | 8 +- esphome/components/usb_uart/__init__.py | 8 +- esphome/components/valve/__init__.py | 8 +- esphome/components/veml3235/sensor.py | 23 ++-- esphome/components/water_heater/__init__.py | 19 +-- esphome/components/whirlpool/climate.py | 6 +- esphome/components/wifi/__init__.py | 25 ++-- esphome/components/xgzp68xx/sensor.py | 21 ++-- esphome/components/zigbee/const.py | 9 +- esphome/components/zigbee/const_esp32.py | 62 +++++----- 136 files changed, 821 insertions(+), 1339 deletions(-) diff --git a/esphome/components/absolute_humidity/sensor.py b/esphome/components/absolute_humidity/sensor.py index 84a69dfa236..f11ba5af990 100644 --- a/esphome/components/absolute_humidity/sensor.py +++ b/esphome/components/absolute_humidity/sensor.py @@ -19,11 +19,7 @@ AbsoluteHumidityComponent = absolute_humidity_ns.class_( SaturationVaporPressureEquation = absolute_humidity_ns.enum( "SaturationVaporPressureEquation" ) -EQUATION = { - "BUCK": SaturationVaporPressureEquation.BUCK, - "TETENS": SaturationVaporPressureEquation.TETENS, - "WOBUS": SaturationVaporPressureEquation.WOBUS, -} +EQUATION = SaturationVaporPressureEquation.options("BUCK", "TETENS", "WOBUS") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index 498565b0ea8..7d1b66a6905 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -14,11 +14,9 @@ ac_dimmer_ns = cg.esphome_ns.namespace("ac_dimmer") AcDimmer = ac_dimmer_ns.class_("AcDimmer", output.FloatOutput, cg.Component) DimMethod = ac_dimmer_ns.enum("DimMethod") -DIM_METHODS = { - "LEADING_PULSE": DimMethod.DIM_METHOD_LEADING_PULSE, - "LEADING": DimMethod.DIM_METHOD_LEADING, - "TRAILING": DimMethod.DIM_METHOD_TRAILING, -} +DIM_METHODS = DimMethod.options( + "LEADING_PULSE", "LEADING", "TRAILING", prefix="DIM_METHOD_" +) ZC_INTERRUPT_TYPES = { "RISING": gpio_ns.INTERRUPT_RISING_EDGE, diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 6966a86f6b9..f90c01e833d 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -46,11 +46,7 @@ ATTENUATION_MODES = { sampling_mode = adc_ns.enum("SamplingMode", is_class=True) -SAMPLING_MODES = { - "avg": sampling_mode.AVG, - "min": sampling_mode.MIN, - "max": sampling_mode.MAX, -} +SAMPLING_MODES = sampling_mode.options("avg", "min", "max") adc_unit_t = cg.global_ns.enum("adc_unit_t", is_class=True) diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index d20d80b2fc7..8fd18671f81 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -84,11 +84,9 @@ automation.register_apply_action( ) AGS10SetZeroPointActionMode = ags10_ns.enum("AGS10SetZeroPointActionMode") -AGS10_SET_ZERO_POINT_ACTION_MODE = { - "FACTORY_DEFAULT": AGS10SetZeroPointActionMode.FACTORY_DEFAULT, - "CURRENT_VALUE": AGS10SetZeroPointActionMode.CURRENT_VALUE, - "CUSTOM_VALUE": AGS10SetZeroPointActionMode.CUSTOM_VALUE, -} +AGS10_SET_ZERO_POINT_ACTION_MODE = AGS10SetZeroPointActionMode.options( + "FACTORY_DEFAULT", "CURRENT_VALUE", "CUSTOM_VALUE" +) AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( { diff --git a/esphome/components/aht10/sensor.py b/esphome/components/aht10/sensor.py index add86e16a63..974e7d4df4c 100644 --- a/esphome/components/aht10/sensor.py +++ b/esphome/components/aht10/sensor.py @@ -20,10 +20,7 @@ aht10_ns = cg.esphome_ns.namespace("aht10") AHT10Component = aht10_ns.class_("AHT10Component", cg.PollingComponent, i2c.I2CDevice) AHT10Variant = aht10_ns.enum("AHT10Variant") -AHT10_VARIANTS = { - "AHT10": AHT10Variant.AHT10, - "AHT20": AHT10Variant.AHT20, -} +AHT10_VARIANTS = AHT10Variant.options("AHT10", "AHT20") CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/airthings_wave_plus/sensor.py b/esphome/components/airthings_wave_plus/sensor.py index 75c1eca594f..14e862d55b8 100644 --- a/esphome/components/airthings_wave_plus/sensor.py +++ b/esphome/components/airthings_wave_plus/sensor.py @@ -29,10 +29,7 @@ AirthingsWavePlus = airthings_wave_plus_ns.class_( CONF_DEVICE_TYPE = "device_type" WaveDeviceType = airthings_wave_plus_ns.enum("WaveDeviceType") -DEVICE_TYPES = { - "WAVE_PLUS": WaveDeviceType.WAVE_PLUS, - "WAVE_GEN2": WaveDeviceType.WAVE_GEN2, -} +DEVICE_TYPES = WaveDeviceType.options("WAVE_PLUS", "WAVE_GEN2") def validate_wave_gen2_config(config: ConfigType) -> ConfigType: diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index 4cf1e95f695..7a3010b8e35 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -30,10 +30,7 @@ CONF_END_POSITION = "end_position" CONF_OUT_OF_RANGE_MODE = "out_of_range_mode" OutOfRangeMode = as5600_ns.enum("OutRangeMode") -OUT_OF_RANGE_MODES = { - "MIN_MAX": OutOfRangeMode.OUT_RANGE_MODE_MIN_MAX, - "NAN": OutOfRangeMode.OUT_RANGE_MODE_NAN, -} +OUT_OF_RANGE_MODES = OutOfRangeMode.options("MIN_MAX", "NAN", prefix="OUT_RANGE_MODE_") CONF_AS5600_ID = "as5600_id" diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index bd43f78f01e..61eeaa5a35b 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__init__.py @@ -55,12 +55,9 @@ 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, -} +NORMALIZATIONS = Normalization.options( + "NONE", "ALL", "BANDS", "CLEAR", prefix="NORMALIZATION_" +) # Per-channel correction for the golden device, measured by the manufacturer. DEFAULT_CHANNEL_CORRECTION_43 = [ diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 36a9161eb45..24b3c1435d2 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -27,13 +27,7 @@ audio_ns = cg.esphome_ns.namespace("audio") AudioFile = audio_ns.struct("AudioFile") AudioFileType = audio_ns.enum("AudioFileType", is_class=True) -AUDIO_FILE_TYPE_ENUM = { - "NONE": AudioFileType.NONE, - "WAV": AudioFileType.WAV, - "MP3": AudioFileType.MP3, - "FLAC": AudioFileType.FLAC, - "OPUS": AudioFileType.OPUS, -} +AUDIO_FILE_TYPE_ENUM = AudioFileType.options("NONE", "WAV", "MP3", "FLAC", "OPUS") MEMORY_PSRAM = "psram" MEMORY_INTERNAL = "internal" diff --git a/esphome/components/bedjet/climate/__init__.py b/esphome/components/bedjet/climate/__init__.py index 36650d643cc..417bad44fc4 100644 --- a/esphome/components/bedjet/climate/__init__.py +++ b/esphome/components/bedjet/climate/__init__.py @@ -12,14 +12,10 @@ DEPENDENCIES = ["bedjet"] BedJetClimate = bedjet_ns.class_("BedJetClimate", climate.Climate, cg.PollingComponent) BedjetHeatMode = bedjet_ns.enum("BedjetHeatMode") BedjetTemperatureSource = bedjet_ns.enum("BedjetTemperatureSource") -BEDJET_HEAT_MODES = { - "heat": BedjetHeatMode.HEAT_MODE_HEAT, - "extended": BedjetHeatMode.HEAT_MODE_EXTENDED, -} -BEDJET_TEMPERATURE_SOURCES = { - "outlet": BedjetTemperatureSource.TEMPERATURE_SOURCE_OUTLET, - "ambient": BedjetTemperatureSource.TEMPERATURE_SOURCE_AMBIENT, -} +BEDJET_HEAT_MODES = BedjetHeatMode.options("heat", "extended", prefix="HEAT_MODE_") +BEDJET_TEMPERATURE_SOURCES = BedjetTemperatureSource.options( + "outlet", "ambient", prefix="TEMPERATURE_SOURCE_" +) CONFIG_SCHEMA = ( climate.climate_schema(BedJetClimate) diff --git a/esphome/components/bh1745/__init__.py b/esphome/components/bh1745/__init__.py index bd63fd4ef2d..a922340df71 100644 --- a/esphome/components/bh1745/__init__.py +++ b/esphome/components/bh1745/__init__.py @@ -25,11 +25,7 @@ BH1745Component = bh1745_ns.class_( ) AdcGain = bh1745_ns.enum("AdcGain", is_class=True) -ADC_GAINS = { - "1X": AdcGain.ADC_GAIN_1X, - "2X": AdcGain.ADC_GAIN_2X, - "16X": AdcGain.ADC_GAIN_16X, -} +ADC_GAINS = AdcGain.options("1X", "2X", "16X", prefix="ADC_GAIN_") MeasurementTime = bh1745_ns.enum("MeasurementTime", is_class=True) MEASUREMENT_TIMES = { diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 248002d185f..5e33759dea4 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -35,10 +35,9 @@ CONF_ENERGY_TOTAL = "energy_total" bl0939_ns = cg.esphome_ns.namespace("bl0939") BL0939 = bl0939_ns.class_("BL0939", cg.PollingComponent, uart.UARTDevice) BL0939Mode = bl0939_ns.enum("BL0939Mode", is_class=True) -MODE_OPTIONS = { - "direct": BL0939Mode.BL0939_MODE_DIRECT, - "current_transformer": BL0939Mode.BL0939_MODE_CURRENT_TRANSFORMER, -} +MODE_OPTIONS = BL0939Mode.options( + "direct", "current_transformer", prefix="BL0939_MODE_" +) # Must match the mode_ member initializer in bl0939.h; set_mode() is skipped # when the config already equals it. DEFAULT_MODE = "direct" diff --git a/esphome/components/bme280_base/__init__.py b/esphome/components/bme280_base/__init__.py index 8d54cbed2a0..c55e40f90d1 100644 --- a/esphome/components/bme280_base/__init__.py +++ b/esphome/components/bme280_base/__init__.py @@ -24,23 +24,14 @@ DOMAIN = "bme280_base" bme280_ns = cg.esphome_ns.namespace("bme280_base") BME280Oversampling = bme280_ns.enum("BME280Oversampling") -OVERSAMPLING_OPTIONS = { - "NONE": BME280Oversampling.BME280_OVERSAMPLING_NONE, - "1X": BME280Oversampling.BME280_OVERSAMPLING_1X, - "2X": BME280Oversampling.BME280_OVERSAMPLING_2X, - "4X": BME280Oversampling.BME280_OVERSAMPLING_4X, - "8X": BME280Oversampling.BME280_OVERSAMPLING_8X, - "16X": BME280Oversampling.BME280_OVERSAMPLING_16X, -} +OVERSAMPLING_OPTIONS = BME280Oversampling.options( + "NONE", "1X", "2X", "4X", "8X", "16X", prefix="BME280_OVERSAMPLING_" +) BME280IIRFilter = bme280_ns.enum("BME280IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": BME280IIRFilter.BME280_IIR_FILTER_OFF, - "2X": BME280IIRFilter.BME280_IIR_FILTER_2X, - "4X": BME280IIRFilter.BME280_IIR_FILTER_4X, - "8X": BME280IIRFilter.BME280_IIR_FILTER_8X, - "16X": BME280IIRFilter.BME280_IIR_FILTER_16X, -} +IIR_FILTER_OPTIONS = BME280IIRFilter.options( + "OFF", "2X", "4X", "8X", "16X", prefix="BME280_IIR_FILTER_" +) CONFIG_SCHEMA_BASE = cv.Schema( { diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index ffe5c74bbd4..73f48526be6 100644 --- a/esphome/components/bme680/sensor.py +++ b/esphome/components/bme680/sensor.py @@ -28,26 +28,14 @@ DEPENDENCIES = ["i2c"] bme680_ns = cg.esphome_ns.namespace("bme680") BME680Oversampling = bme680_ns.enum("BME680Oversampling") -OVERSAMPLING_OPTIONS = { - "NONE": BME680Oversampling.BME680_OVERSAMPLING_NONE, - "1X": BME680Oversampling.BME680_OVERSAMPLING_1X, - "2X": BME680Oversampling.BME680_OVERSAMPLING_2X, - "4X": BME680Oversampling.BME680_OVERSAMPLING_4X, - "8X": BME680Oversampling.BME680_OVERSAMPLING_8X, - "16X": BME680Oversampling.BME680_OVERSAMPLING_16X, -} +OVERSAMPLING_OPTIONS = BME680Oversampling.options( + "NONE", "1X", "2X", "4X", "8X", "16X", prefix="BME680_OVERSAMPLING_" +) BME680IIRFilter = bme680_ns.enum("BME680IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": BME680IIRFilter.BME680_IIR_FILTER_OFF, - "1X": BME680IIRFilter.BME680_IIR_FILTER_1X, - "3X": BME680IIRFilter.BME680_IIR_FILTER_3X, - "7X": BME680IIRFilter.BME680_IIR_FILTER_7X, - "15X": BME680IIRFilter.BME680_IIR_FILTER_15X, - "31X": BME680IIRFilter.BME680_IIR_FILTER_31X, - "63X": BME680IIRFilter.BME680_IIR_FILTER_63X, - "127X": BME680IIRFilter.BME680_IIR_FILTER_127X, -} +IIR_FILTER_OPTIONS = BME680IIRFilter.options( + "OFF", "1X", "3X", "7X", "15X", "31X", "63X", "127X", prefix="BME680_IIR_FILTER_" +) BME680Component = bme680_ns.class_( "BME680Component", cg.PollingComponent, i2c.I2CDevice diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 190994b2f8a..55fd1c975f5 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -19,10 +19,7 @@ CONF_SUPPLY_VOLTAGE = "supply_voltage" bme680_bsec_ns = cg.esphome_ns.namespace("bme680_bsec") IAQMode = bme680_bsec_ns.enum("IAQMode") -IAQ_MODE_OPTIONS = { - "STATIC": IAQMode.IAQ_MODE_STATIC, - "MOBILE": IAQMode.IAQ_MODE_MOBILE, -} +IAQ_MODE_OPTIONS = IAQMode.options("STATIC", "MOBILE", prefix="IAQ_MODE_") SupplyVoltage = bme680_bsec_ns.enum("SupplyVoltage") SUPPLY_VOLTAGE_OPTIONS = { @@ -31,10 +28,7 @@ SUPPLY_VOLTAGE_OPTIONS = { } SampleRate = bme680_bsec_ns.enum("SampleRate") -SAMPLE_RATE_OPTIONS = { - "LP": SampleRate.SAMPLE_RATE_LP, - "ULP": SampleRate.SAMPLE_RATE_ULP, -} +SAMPLE_RATE_OPTIONS = SampleRate.options("LP", "ULP", prefix="SAMPLE_RATE_") BME680BSECComponent = bme680_bsec_ns.class_( "BME680BSECComponent", cg.Component, i2c.I2CDevice diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 8208672b6a5..85bb250cd0a 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -36,28 +36,18 @@ BME68xBSEC2Component = bme68x_bsec2_ns.class_("BME68xBSEC2Component", cg.Compone MODEL_OPTIONS = ["bme680", "bme688"] AlgorithmOutput = bme68x_bsec2_ns.enum("AlgorithmOutput") -ALGORITHM_OUTPUT_OPTIONS = { - "classification": AlgorithmOutput.ALGORITHM_OUTPUT_CLASSIFICATION, - "regression": AlgorithmOutput.ALGORITHM_OUTPUT_REGRESSION, -} +ALGORITHM_OUTPUT_OPTIONS = AlgorithmOutput.options( + "classification", "regression", prefix="ALGORITHM_OUTPUT_" +) OperatingAge = bme68x_bsec2_ns.enum("OperatingAge") -OPERATING_AGE_OPTIONS = { - "4d": OperatingAge.OPERATING_AGE_4D, - "28d": OperatingAge.OPERATING_AGE_28D, -} +OPERATING_AGE_OPTIONS = OperatingAge.options("4d", "28d", prefix="OPERATING_AGE_") SampleRate = bme68x_bsec2_ns.enum("SampleRate") -SAMPLE_RATE_OPTIONS = { - "LP": SampleRate.SAMPLE_RATE_LP, - "ULP": SampleRate.SAMPLE_RATE_ULP, -} +SAMPLE_RATE_OPTIONS = SampleRate.options("LP", "ULP", prefix="SAMPLE_RATE_") Voltage = bme68x_bsec2_ns.enum("Voltage") -VOLTAGE_OPTIONS = { - "1.8V": Voltage.VOLTAGE_1_8V, - "3.3V": Voltage.VOLTAGE_3_3V, -} +VOLTAGE_OPTIONS = Voltage.options("1.8V", "3.3V", prefix="VOLTAGE_") ALGORITHM_OUTPUT_FILE_NAME = { "classification": "sel", diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py index 97127fd2dd5..bab24b45d65 100644 --- a/esphome/components/bmi270/motion.py +++ b/esphome/components/bmi270/motion.py @@ -20,12 +20,9 @@ CONF_AUX_DEVICE = "aux_device" # Enum proxies (must match the C++ enum values exactly) BMI270AuxDevice = bmi270_ns.enum("BMI270AuxDevice") BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange") -ACCEL_RANGE_OPTIONS = { - "2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G, - "4G": BMI270AccelRange.BMI270_ACCEL_RANGE_4G, - "8G": BMI270AccelRange.BMI270_ACCEL_RANGE_8G, - "16G": BMI270AccelRange.BMI270_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = BMI270AccelRange.options( + "2G", "4G", "8G", "16G", prefix="BMI270_ACCEL_RANGE_" +) BMI270GyroRange = bmi270_ns.enum("BMI270GyroRange") GYRO_RANGE_OPTIONS = { diff --git a/esphome/components/bmp280_base/__init__.py b/esphome/components/bmp280_base/__init__.py index 68d9145378b..3d84d6728f5 100644 --- a/esphome/components/bmp280_base/__init__.py +++ b/esphome/components/bmp280_base/__init__.py @@ -21,23 +21,14 @@ DOMAIN = "bmp280_base" bmp280_ns = cg.esphome_ns.namespace("bmp280_base") BMP280Oversampling = bmp280_ns.enum("BMP280Oversampling") -OVERSAMPLING_OPTIONS = { - "NONE": BMP280Oversampling.BMP280_OVERSAMPLING_NONE, - "1X": BMP280Oversampling.BMP280_OVERSAMPLING_1X, - "2X": BMP280Oversampling.BMP280_OVERSAMPLING_2X, - "4X": BMP280Oversampling.BMP280_OVERSAMPLING_4X, - "8X": BMP280Oversampling.BMP280_OVERSAMPLING_8X, - "16X": BMP280Oversampling.BMP280_OVERSAMPLING_16X, -} +OVERSAMPLING_OPTIONS = BMP280Oversampling.options( + "NONE", "1X", "2X", "4X", "8X", "16X", prefix="BMP280_OVERSAMPLING_" +) BMP280IIRFilter = bmp280_ns.enum("BMP280IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": BMP280IIRFilter.BMP280_IIR_FILTER_OFF, - "2X": BMP280IIRFilter.BMP280_IIR_FILTER_2X, - "4X": BMP280IIRFilter.BMP280_IIR_FILTER_4X, - "8X": BMP280IIRFilter.BMP280_IIR_FILTER_8X, - "16X": BMP280IIRFilter.BMP280_IIR_FILTER_16X, -} +IIR_FILTER_OPTIONS = BMP280IIRFilter.options( + "OFF", "2X", "4X", "8X", "16X", prefix="BMP280_IIR_FILTER_" +) CONFIG_SCHEMA_BASE = cv.Schema( { diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 56733870dc4..27220051a77 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -56,29 +56,30 @@ CanbusTrigger = canbus_ns.class_( ) CanSpeed = canbus_ns.enum("CAN_SPEED") -CAN_SPEEDS = { - "1KBPS": CanSpeed.CAN_1KBPS, - "5KBPS": CanSpeed.CAN_5KBPS, - "10KBPS": CanSpeed.CAN_10KBPS, - "12K5BPS": CanSpeed.CAN_12K5BPS, - "16KBPS": CanSpeed.CAN_16KBPS, - "20KBPS": CanSpeed.CAN_20KBPS, - "25KBPS": CanSpeed.CAN_25KBPS, - "31K25BPS": CanSpeed.CAN_31K25BPS, - "33KBPS": CanSpeed.CAN_33KBPS, - "40KBPS": CanSpeed.CAN_40KBPS, - "50KBPS": CanSpeed.CAN_50KBPS, - "80KBPS": CanSpeed.CAN_80KBPS, - "83K3BPS": CanSpeed.CAN_83K3BPS, - "95KBPS": CanSpeed.CAN_95KBPS, - "100KBPS": CanSpeed.CAN_100KBPS, - "125KBPS": CanSpeed.CAN_125KBPS, - "200KBPS": CanSpeed.CAN_200KBPS, - "250KBPS": CanSpeed.CAN_250KBPS, - "500KBPS": CanSpeed.CAN_500KBPS, - "800KBPS": CanSpeed.CAN_800KBPS, - "1000KBPS": CanSpeed.CAN_1000KBPS, -} +CAN_SPEEDS = CanSpeed.options( + "1KBPS", + "5KBPS", + "10KBPS", + "12K5BPS", + "16KBPS", + "20KBPS", + "25KBPS", + "31K25BPS", + "33KBPS", + "40KBPS", + "50KBPS", + "80KBPS", + "83K3BPS", + "95KBPS", + "100KBPS", + "125KBPS", + "200KBPS", + "250KBPS", + "500KBPS", + "800KBPS", + "1000KBPS", + prefix="CAN_", +) def get_rate(value: str) -> int: diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 8b9e91f833a..2660d5bbde7 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -67,41 +67,30 @@ CONF_GDO0_PIN = "gdo0_pin" # Enums SyncMode = ns.enum("SyncMode", True) -SYNC_MODE = { - "None": SyncMode.SYNC_MODE_NONE, - "15/16": SyncMode.SYNC_MODE_15_16, - "16/16": SyncMode.SYNC_MODE_16_16, - "30/32": SyncMode.SYNC_MODE_30_32, -} +SYNC_MODE = SyncMode.options("None", "15/16", "16/16", "30/32", prefix="SYNC_MODE_") Modulation = ns.enum("Modulation", True) -MODULATION = { - "2-FSK": Modulation.MODULATION_2_FSK, - "GFSK": Modulation.MODULATION_GFSK, - "ASK/OOK": Modulation.MODULATION_ASK_OOK, - "4-FSK": Modulation.MODULATION_4_FSK, - "MSK": Modulation.MODULATION_MSK, -} +MODULATION = Modulation.options( + "2-FSK", "GFSK", "ASK/OOK", "4-FSK", "MSK", prefix="MODULATION_" +) RxAttenuation = ns.enum("RxAttenuation", True) -RX_ATTENUATION = { - "0dB": RxAttenuation.RX_ATTENUATION_0DB, - "6dB": RxAttenuation.RX_ATTENUATION_6DB, - "12dB": RxAttenuation.RX_ATTENUATION_12DB, - "18dB": RxAttenuation.RX_ATTENUATION_18DB, -} +RX_ATTENUATION = RxAttenuation.options( + "0dB", "6dB", "12dB", "18dB", prefix="RX_ATTENUATION_" +) MagnTarget = ns.enum("MagnTarget", True) -MAGN_TARGET = { - "24dB": MagnTarget.MAGN_TARGET_24DB, - "27dB": MagnTarget.MAGN_TARGET_27DB, - "30dB": MagnTarget.MAGN_TARGET_30DB, - "33dB": MagnTarget.MAGN_TARGET_33DB, - "36dB": MagnTarget.MAGN_TARGET_36DB, - "38dB": MagnTarget.MAGN_TARGET_38DB, - "40dB": MagnTarget.MAGN_TARGET_40DB, - "42dB": MagnTarget.MAGN_TARGET_42DB, -} +MAGN_TARGET = MagnTarget.options( + "24dB", + "27dB", + "30dB", + "33dB", + "36dB", + "38dB", + "40dB", + "42dB", + prefix="MAGN_TARGET_", +) MaxLnaGain = ns.enum("MaxLnaGain", True) MAX_LNA_GAIN = { @@ -140,20 +129,14 @@ FILTER_LENGTH_FSK_MSK = { } FilterLengthAskOok = ns.enum("FilterLengthAskOok", True) -FILTER_LENGTH_ASK_OOK = { - "4dB": FilterLengthAskOok.FILTER_LENGTH_4DB, - "8dB": FilterLengthAskOok.FILTER_LENGTH_8DB, - "12dB": FilterLengthAskOok.FILTER_LENGTH_12DB, - "16dB": FilterLengthAskOok.FILTER_LENGTH_16DB, -} +FILTER_LENGTH_ASK_OOK = FilterLengthAskOok.options( + "4dB", "8dB", "12dB", "16dB", prefix="FILTER_LENGTH_" +) Freeze = ns.enum("Freeze", True) -FREEZE = { - "Default": Freeze.FREEZE_DEFAULT, - "On Sync": Freeze.FREEZE_ON_SYNC, - "Analog Only": Freeze.FREEZE_ANALOG_ONLY, - "Analog And Digital": Freeze.FREEZE_ANALOG_AND_DIGITAL, -} +FREEZE = Freeze.options( + "Default", "On Sync", "Analog Only", "Analog And Digital", prefix="FREEZE_" +) WaitTime = ns.enum("WaitTime", True) WAIT_TIME = { @@ -164,34 +147,16 @@ WAIT_TIME = { } HystLevel = ns.enum("HystLevel", True) -HYST_LEVEL = { - "None": HystLevel.HYST_LEVEL_NONE, - "Low": HystLevel.HYST_LEVEL_LOW, - "Medium": HystLevel.HYST_LEVEL_MEDIUM, - "High": HystLevel.HYST_LEVEL_HIGH, -} +HYST_LEVEL = HystLevel.options("None", "Low", "Medium", "High", prefix="HYST_LEVEL_") FocLimit = ns.enum("FocLimit", True) -FOC_LIMIT = { - "Disabled": FocLimit.FOC_LIMIT_DISABLED, - "BW/8": FocLimit.FOC_LIMIT_BW_8, - "BW/4": FocLimit.FOC_LIMIT_BW_4, - "BW/2": FocLimit.FOC_LIMIT_BW_2, -} +FOC_LIMIT = FocLimit.options("Disabled", "BW/8", "BW/4", "BW/2", prefix="FOC_LIMIT_") FocPreK = ns.enum("FocPreK", True) -FOC_PRE_K = { - "K": FocPreK.FOC_PRE_K_K, - "2K": FocPreK.FOC_PRE_K_2K, - "3K": FocPreK.FOC_PRE_K_3K, - "4K": FocPreK.FOC_PRE_K_4K, -} +FOC_PRE_K = FocPreK.options("K", "2K", "3K", "4K", prefix="FOC_PRE_K_") FocPostK = ns.enum("FocPostK", True) -FOC_POST_K = { - "Same": FocPostK.FOC_POST_K_SAME, - "K/2": FocPostK.FOC_POST_K_K_2, -} +FOC_POST_K = FocPostK.options("Same", "K/2", prefix="FOC_POST_K_") BsLimit = ns.enum("BsLimit", True) BS_LIMIT = { @@ -202,32 +167,16 @@ BS_LIMIT = { } BsPreKi = ns.enum("BsPreKi", True) -BS_PRE_KI = { - "KI": BsPreKi.BS_PRE_KI_KI, - "2KI": BsPreKi.BS_PRE_KI_2KI, - "3KI": BsPreKi.BS_PRE_KI_3KI, - "4KI": BsPreKi.BS_PRE_KI_4KI, -} +BS_PRE_KI = BsPreKi.options("KI", "2KI", "3KI", "4KI", prefix="BS_PRE_KI_") BsPreKp = ns.enum("BsPreKp", True) -BS_PRE_KP = { - "KP": BsPreKp.BS_PRE_KP_KP, - "2KP": BsPreKp.BS_PRE_KP_2KP, - "3KP": BsPreKp.BS_PRE_KP_3KP, - "4KP": BsPreKp.BS_PRE_KP_4KP, -} +BS_PRE_KP = BsPreKp.options("KP", "2KP", "3KP", "4KP", prefix="BS_PRE_KP_") BsPostKi = ns.enum("BsPostKi", True) -BS_POST_KI = { - "Same": BsPostKi.BS_POST_KI_SAME, - "KI/2": BsPostKi.BS_POST_KI_KI_2, -} +BS_POST_KI = BsPostKi.options("Same", "KI/2", prefix="BS_POST_KI_") BsPostKp = ns.enum("BsPostKp", True) -BS_POST_KP = { - "Same": BsPostKp.BS_POST_KP_SAME, - "KP": BsPostKp.BS_POST_KP_KP, -} +BS_POST_KP = BsPostKp.options("Same", "KP", prefix="BS_POST_KP_") # Optional settings to generate setter calls for CONFIG_MAP = { diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index a59c03ed1e5..1b335c27395 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -69,67 +69,68 @@ ClimateCall = climate_ns.class_("ClimateCall") ClimateTraits = climate_ns.class_("ClimateTraits") ClimateMode = climate_ns.enum("ClimateMode") -CLIMATE_MODES = { - "OFF": ClimateMode.CLIMATE_MODE_OFF, - "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, - "COOL": ClimateMode.CLIMATE_MODE_COOL, - "HEAT": ClimateMode.CLIMATE_MODE_HEAT, - "DRY": ClimateMode.CLIMATE_MODE_DRY, - "FAN_ONLY": ClimateMode.CLIMATE_MODE_FAN_ONLY, - "AUTO": ClimateMode.CLIMATE_MODE_AUTO, -} +CLIMATE_MODES = ClimateMode.options( + "OFF", + "HEAT_COOL", + "COOL", + "HEAT", + "DRY", + "FAN_ONLY", + "AUTO", + prefix="CLIMATE_MODE_", +) validate_climate_mode = cv.enum(CLIMATE_MODES, upper=True) ClimateFanMode = climate_ns.enum("ClimateFanMode") -CLIMATE_FAN_MODES = { - "ON": ClimateFanMode.CLIMATE_FAN_ON, - "OFF": ClimateFanMode.CLIMATE_FAN_OFF, - "AUTO": ClimateFanMode.CLIMATE_FAN_AUTO, - "LOW": ClimateFanMode.CLIMATE_FAN_LOW, - "MEDIUM": ClimateFanMode.CLIMATE_FAN_MEDIUM, - "HIGH": ClimateFanMode.CLIMATE_FAN_HIGH, - "MIDDLE": ClimateFanMode.CLIMATE_FAN_MIDDLE, - "FOCUS": ClimateFanMode.CLIMATE_FAN_FOCUS, - "DIFFUSE": ClimateFanMode.CLIMATE_FAN_DIFFUSE, - "QUIET": ClimateFanMode.CLIMATE_FAN_QUIET, -} +CLIMATE_FAN_MODES = ClimateFanMode.options( + "ON", + "OFF", + "AUTO", + "LOW", + "MEDIUM", + "HIGH", + "MIDDLE", + "FOCUS", + "DIFFUSE", + "QUIET", + prefix="CLIMATE_FAN_", +) validate_climate_fan_mode = cv.enum(CLIMATE_FAN_MODES, upper=True) ClimatePreset = climate_ns.enum("ClimatePreset") -CLIMATE_PRESETS = { - "NONE": ClimatePreset.CLIMATE_PRESET_NONE, - "ECO": ClimatePreset.CLIMATE_PRESET_ECO, - "AWAY": ClimatePreset.CLIMATE_PRESET_AWAY, - "BOOST": ClimatePreset.CLIMATE_PRESET_BOOST, - "COMFORT": ClimatePreset.CLIMATE_PRESET_COMFORT, - "HOME": ClimatePreset.CLIMATE_PRESET_HOME, - "SLEEP": ClimatePreset.CLIMATE_PRESET_SLEEP, - "ACTIVITY": ClimatePreset.CLIMATE_PRESET_ACTIVITY, -} +CLIMATE_PRESETS = ClimatePreset.options( + "NONE", + "ECO", + "AWAY", + "BOOST", + "COMFORT", + "HOME", + "SLEEP", + "ACTIVITY", + prefix="CLIMATE_PRESET_", +) validate_climate_preset = cv.enum(CLIMATE_PRESETS, upper=True) ClimateSwingMode = climate_ns.enum("ClimateSwingMode") -CLIMATE_SWING_MODES = { - "OFF": ClimateSwingMode.CLIMATE_SWING_OFF, - "BOTH": ClimateSwingMode.CLIMATE_SWING_BOTH, - "VERTICAL": ClimateSwingMode.CLIMATE_SWING_VERTICAL, - "HORIZONTAL": ClimateSwingMode.CLIMATE_SWING_HORIZONTAL, -} +CLIMATE_SWING_MODES = ClimateSwingMode.options( + "OFF", "BOTH", "VERTICAL", "HORIZONTAL", prefix="CLIMATE_SWING_" +) validate_climate_swing_mode = cv.enum(CLIMATE_SWING_MODES, upper=True) ClimateAction = climate_ns.enum("ClimateAction") -CLIMATE_ACTIONS = { - "OFF": ClimateAction.CLIMATE_ACTION_OFF, - "COOLING": ClimateAction.CLIMATE_ACTION_COOLING, - "HEATING": ClimateAction.CLIMATE_ACTION_HEATING, - "IDLE": ClimateAction.CLIMATE_ACTION_IDLE, - "DRYING": ClimateAction.CLIMATE_ACTION_DRYING, - "FAN": ClimateAction.CLIMATE_ACTION_FAN, - "DEFROSTING": ClimateAction.CLIMATE_ACTION_DEFROSTING, -} +CLIMATE_ACTIONS = ClimateAction.options( + "OFF", + "COOLING", + "HEATING", + "IDLE", + "DRYING", + "FAN", + "DEFROSTING", + prefix="CLIMATE_ACTION_", +) validate_climate_action = cv.enum(CLIMATE_ACTIONS, upper=True) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index c570f3d4ca2..7bdd9000094 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -80,11 +80,9 @@ COVER_STATES = { validate_cover_state = cv.enum(COVER_STATES, upper=True) CoverOperation = cover_ns.enum("CoverOperation") -COVER_OPERATIONS = { - "IDLE": CoverOperation.COVER_OPERATION_IDLE, - "OPENING": CoverOperation.COVER_OPERATION_OPENING, - "CLOSING": CoverOperation.COVER_OPERATION_CLOSING, -} +COVER_OPERATIONS = CoverOperation.options( + "IDLE", "OPENING", "CLOSING", prefix="COVER_OPERATION_" +) validate_cover_operation = cv.enum(COVER_OPERATIONS, upper=True) # Actions diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index d087c99bf1a..54e452efc88 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -24,10 +24,7 @@ DEPENDENCIES = ["spi"] cs5460a_ns = cg.esphome_ns.namespace("cs5460a") CS5460APGAGain = cs5460a_ns.enum("CS5460APGAGain") -PGA_GAIN_OPTIONS = { - "10X": CS5460APGAGain.CS5460A_PGA_GAIN_10X, - "50X": CS5460APGAGain.CS5460A_PGA_GAIN_50X, -} +PGA_GAIN_OPTIONS = CS5460APGAGain.options("10X", "50X", prefix="CS5460A_PGA_GAIN_") CS5460AComponent = cs5460a_ns.class_("CS5460AComponent", spi.SPIDevice, cg.Component) diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 6f695307de8..1618cb43962 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -39,10 +39,9 @@ CONF_POWER_FACTOR_1 = "power_factor_1" CONF_CURRENT_INPUT = "current_input" CSE7761CurrentInput = cse7761_ns.enum("CSE7761CurrentInput", is_class=True) -CURRENT_INPUTS = { - "shunt": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_SHUNT, - "current_transformer": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER, -} +CURRENT_INPUTS = CSE7761CurrentInput.options( + "shunt", "current_transformer", prefix="CSE7761_CURRENT_INPUT_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 5f340c60efe..d60a51c3cf7 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -223,19 +223,15 @@ DeepSleepComponent = deep_sleep_ns.class_("DeepSleepComponent", cg.Component) EnterDeepSleepAction = deep_sleep_ns.class_("EnterDeepSleepAction", automation.Action) WakeupPinMode = deep_sleep_ns.enum("WakeupPinMode") -WAKEUP_PIN_MODES = { - "IGNORE": WakeupPinMode.WAKEUP_PIN_MODE_IGNORE, - "KEEP_AWAKE": WakeupPinMode.WAKEUP_PIN_MODE_KEEP_AWAKE, - "INVERT_WAKEUP": WakeupPinMode.WAKEUP_PIN_MODE_INVERT_WAKEUP, -} +WAKEUP_PIN_MODES = WakeupPinMode.options( + "IGNORE", "KEEP_AWAKE", "INVERT_WAKEUP", prefix="WAKEUP_PIN_MODE_" +) esp_sleep_ext1_wakeup_mode_t = cg.global_ns.enum("esp_sleep_ext1_wakeup_mode_t") Ext1Wakeup = deep_sleep_ns.struct("Ext1Wakeup") -EXT1_WAKEUP_MODES = { - "ANY_LOW": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ANY_LOW, - "ALL_LOW": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ALL_LOW, - "ANY_HIGH": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ANY_HIGH, -} +EXT1_WAKEUP_MODES = esp_sleep_ext1_wakeup_mode_t.options( + "ANY_LOW", "ALL_LOW", "ANY_HIGH", prefix="ESP_EXT1_WAKEUP_" +) WakeupCauseToRunDuration = deep_sleep_ns.struct("WakeupCauseToRunDuration") WakeupCause = deep_sleep_ns.enum("WakeupCause") diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index d4ec9d6590a..69b745df9cf 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -21,19 +21,9 @@ CONF_EQ_PRESET = "eq_preset" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" EqPreset = dfplayer_ns.enum("EqPreset") -EQ_PRESET = { - "NORMAL": EqPreset.NORMAL, - "POP": EqPreset.POP, - "ROCK": EqPreset.ROCK, - "JAZZ": EqPreset.JAZZ, - "CLASSIC": EqPreset.CLASSIC, - "BASS": EqPreset.BASS, -} +EQ_PRESET = EqPreset.options("NORMAL", "POP", "ROCK", "JAZZ", "CLASSIC", "BASS") Device = dfplayer_ns.enum("Device") -DEVICE = { - "USB": Device.USB, - "TF_CARD": Device.TF_CARD, -} +DEVICE = Device.options("USB", "TF_CARD") CONFIG_SCHEMA = cv.All( cv.Schema( diff --git a/esphome/components/dht/sensor.py b/esphome/components/dht/sensor.py index c7ae70b8344..da8a5775b15 100644 --- a/esphome/components/dht/sensor.py +++ b/esphome/components/dht/sensor.py @@ -19,16 +19,17 @@ from esphome.types import ConfigType dht_ns = cg.esphome_ns.namespace("dht") DHTModel = dht_ns.enum("DHTModel") -DHT_MODELS = { - "AUTO_DETECT": DHTModel.DHT_MODEL_AUTO_DETECT, - "DHT11": DHTModel.DHT_MODEL_DHT11, - "DHT22": DHTModel.DHT_MODEL_DHT22, - "AM2120": DHTModel.DHT_MODEL_AM2120, - "AM2302": DHTModel.DHT_MODEL_AM2302, - "RHT03": DHTModel.DHT_MODEL_RHT03, - "SI7021": DHTModel.DHT_MODEL_SI7021, - "DHT22_TYPE2": DHTModel.DHT_MODEL_DHT22_TYPE2, -} +DHT_MODELS = DHTModel.options( + "AUTO_DETECT", + "DHT11", + "DHT22", + "AM2120", + "AM2302", + "RHT03", + "SI7021", + "DHT22_TYPE2", + prefix="DHT_MODEL_", +) DHT = dht_ns.class_("DHT", cg.PollingComponent) CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/es8311/audio_dac.py b/esphome/components/es8311/audio_dac.py index f9cfb822cca..036ee93bd3e 100644 --- a/esphome/components/es8311/audio_dac.py +++ b/esphome/components/es8311/audio_dac.py @@ -22,16 +22,17 @@ ES8311_BITS_PER_SAMPLE_ENUM = { } es8311_mic_gain = es8311_ns.enum("ES8311MicGain") -ES8311_MIC_GAIN_ENUM = { - "0DB": es8311_mic_gain.ES8311_MIC_GAIN_0DB, - "6DB": es8311_mic_gain.ES8311_MIC_GAIN_6DB, - "12DB": es8311_mic_gain.ES8311_MIC_GAIN_12DB, - "18DB": es8311_mic_gain.ES8311_MIC_GAIN_18DB, - "24DB": es8311_mic_gain.ES8311_MIC_GAIN_24DB, - "30DB": es8311_mic_gain.ES8311_MIC_GAIN_30DB, - "36DB": es8311_mic_gain.ES8311_MIC_GAIN_36DB, - "42DB": es8311_mic_gain.ES8311_MIC_GAIN_42DB, -} +ES8311_MIC_GAIN_ENUM = es8311_mic_gain.options( + "0DB", + "6DB", + "12DB", + "18DB", + "24DB", + "30DB", + "36DB", + "42DB", + prefix="ES8311_MIC_GAIN_", +) _validate_bits = cv.float_with_unit("bits", "bit") diff --git a/esphome/components/es8388/select/__init__.py b/esphome/components/es8388/select/__init__.py index f0e3f5b7c89..41866877384 100644 --- a/esphome/components/es8388/select/__init__.py +++ b/esphome/components/es8388/select/__init__.py @@ -14,18 +14,14 @@ CONF_DAC_OUTPUT = "dac_output" CONF_ADC_INPUT_MIC = "adc_input_mic" DacOutputLine = es8388_ns.enum("DacOutputLine") -DAC_OUTPUT_OPTIONS = { - "LINE1": DacOutputLine.DAC_OUTPUT_LINE1, - "LINE2": DacOutputLine.DAC_OUTPUT_LINE2, - "BOTH": DacOutputLine.DAC_OUTPUT_BOTH, -} +DAC_OUTPUT_OPTIONS = DacOutputLine.options( + "LINE1", "LINE2", "BOTH", prefix="DAC_OUTPUT_" +) DAC_OUTPUT_OPTION_NAMES = list(DAC_OUTPUT_OPTIONS) AdcInputMicLine = es8388_ns.enum("AdcInputMicLine") -ADC_INPUT_MIC_OPTIONS = { - "LINE1": AdcInputMicLine.ADC_INPUT_MIC_LINE1, - "LINE2": AdcInputMicLine.ADC_INPUT_MIC_LINE2, - "DIFFERENCE": AdcInputMicLine.ADC_INPUT_MIC_DIFFERENCE, -} +ADC_INPUT_MIC_OPTIONS = AdcInputMicLine.options( + "LINE1", "LINE2", "DIFFERENCE", prefix="ADC_INPUT_MIC_" +) ADC_INPUT_MIC_OPTION_NAMES = list(ADC_INPUT_MIC_OPTIONS) DacOutputSelect = es8388_ns.class_("DacOutputSelect", select.Select) diff --git a/esphome/components/es9038q2m_katana/audio_dac.py b/esphome/components/es9038q2m_katana/audio_dac.py index f5c14d234c6..0568e5e202a 100644 --- a/esphome/components/es9038q2m_katana/audio_dac.py +++ b/esphome/components/es9038q2m_katana/audio_dac.py @@ -42,12 +42,9 @@ FILTER_SHAPES = { } DeemphasisMode = es9038q2m_katana_ns.enum("DeemphasisMode") -DEEMPHASIS_MODES = { - "bypass": DeemphasisMode.DEEMPHASIS_BYPASS, - "32khz": DeemphasisMode.DEEMPHASIS_32KHZ, - "44_1khz": DeemphasisMode.DEEMPHASIS_44_1KHZ, - "48khz": DeemphasisMode.DEEMPHASIS_48KHZ, -} +DEEMPHASIS_MODES = DeemphasisMode.options( + "bypass", "32khz", "44_1khz", "48khz", prefix="DEEMPHASIS_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 48f2e3c9460..95a83edf76f 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -310,16 +310,17 @@ IO_CAPABILITY = { } AuthReqMode = esp32_ble_ns.enum("AuthReqMode") -AUTH_REQ_MODE = { - "no_bond": AuthReqMode.AUTH_REQ_NO_BOND, - "bond": AuthReqMode.AUTH_REQ_BOND, - "mitm": AuthReqMode.AUTH_REQ_MITM, - "bond_mitm": AuthReqMode.AUTH_REQ_BOND_MITM, - "sc_only": AuthReqMode.AUTH_REQ_SC_ONLY, - "sc_bond": AuthReqMode.AUTH_REQ_SC_BOND, - "sc_mitm": AuthReqMode.AUTH_REQ_SC_MITM, - "sc_mitm_bond": AuthReqMode.AUTH_REQ_SC_MITM_BOND, -} +AUTH_REQ_MODE = AuthReqMode.options( + "no_bond", + "bond", + "mitm", + "bond_mitm", + "sc_only", + "sc_bond", + "sc_mitm", + "sc_mitm_bond", + prefix="AUTH_REQ_", +) esp_power_level_t = cg.global_ns.enum("esp_power_level_t") diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 2d4e1ed7e99..0014d38a5c4 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -90,50 +90,42 @@ FRAME_SIZES = { "QSXGA": ESP32CameraFrameSize.ESP32_CAMERA_SIZE_2560X1920, } ESP32CameraPixelFormat = esp32_camera_ns.enum("ESP32CameraPixelFormat") -PIXEL_FORMATS = { - "RGB565": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB565, - "YUV422": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_YUV422, - "YUV420": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_YUV420, - "GRAYSCALE": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_GRAYSCALE, - "JPEG": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_JPEG, - "RGB888": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB888, - "RAW": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RAW, - "RGB444": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB444, - "RGB555": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB555, -} +PIXEL_FORMATS = ESP32CameraPixelFormat.options( + "RGB565", + "YUV422", + "YUV420", + "GRAYSCALE", + "JPEG", + "RGB888", + "RAW", + "RGB444", + "RGB555", + prefix="ESP32_PIXEL_FORMAT_", +) ESP32GainControlMode = esp32_camera_ns.enum("ESP32GainControlMode") ENUM_GAIN_CONTROL_MODE = { "MANUAL": ESP32GainControlMode.ESP32_GC_MODE_MANU, "AUTO": ESP32GainControlMode.ESP32_GC_MODE_AUTO, } ESP32AgcGainCeiling = esp32_camera_ns.enum("ESP32AgcGainCeiling") -ENUM_GAIN_CEILING = { - "2X": ESP32AgcGainCeiling.ESP32_GAINCEILING_2X, - "4X": ESP32AgcGainCeiling.ESP32_GAINCEILING_4X, - "8X": ESP32AgcGainCeiling.ESP32_GAINCEILING_8X, - "16X": ESP32AgcGainCeiling.ESP32_GAINCEILING_16X, - "32X": ESP32AgcGainCeiling.ESP32_GAINCEILING_32X, - "64X": ESP32AgcGainCeiling.ESP32_GAINCEILING_64X, - "128X": ESP32AgcGainCeiling.ESP32_GAINCEILING_128X, -} +ENUM_GAIN_CEILING = ESP32AgcGainCeiling.options( + "2X", "4X", "8X", "16X", "32X", "64X", "128X", prefix="ESP32_GAINCEILING_" +) ESP32WhiteBalanceMode = esp32_camera_ns.enum("ESP32WhiteBalanceMode") -ENUM_WB_MODE = { - "AUTO": ESP32WhiteBalanceMode.ESP32_WB_MODE_AUTO, - "SUNNY": ESP32WhiteBalanceMode.ESP32_WB_MODE_SUNNY, - "CLOUDY": ESP32WhiteBalanceMode.ESP32_WB_MODE_CLOUDY, - "OFFICE": ESP32WhiteBalanceMode.ESP32_WB_MODE_OFFICE, - "HOME": ESP32WhiteBalanceMode.ESP32_WB_MODE_HOME, -} +ENUM_WB_MODE = ESP32WhiteBalanceMode.options( + "AUTO", "SUNNY", "CLOUDY", "OFFICE", "HOME", prefix="ESP32_WB_MODE_" +) ESP32SpecialEffect = esp32_camera_ns.enum("ESP32SpecialEffect") -ENUM_SPECIAL_EFFECT = { - "NONE": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_NONE, - "NEGATIVE": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_NEGATIVE, - "GRAYSCALE": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_GRAYSCALE, - "RED_TINT": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_RED_TINT, - "GREEN_TINT": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_GREEN_TINT, - "BLUE_TINT": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_BLUE_TINT, - "SEPIA": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_SEPIA, -} +ENUM_SPECIAL_EFFECT = ESP32SpecialEffect.options( + "NONE", + "NEGATIVE", + "GRAYSCALE", + "RED_TINT", + "GREEN_TINT", + "BLUE_TINT", + "SEPIA", + prefix="ESP32_SPECIAL_EFFECT_", +) camera_fb_location_t = cg.global_ns.enum("camera_fb_location_t") ENUM_FB_LOCATION = { diff --git a/esphome/components/esp32_camera_web_server/__init__.py b/esphome/components/esp32_camera_web_server/__init__.py index c063eb88be5..60465cf8f91 100644 --- a/esphome/components/esp32_camera_web_server/__init__.py +++ b/esphome/components/esp32_camera_web_server/__init__.py @@ -14,7 +14,7 @@ esp32_camera_web_server_ns = cg.esphome_ns.namespace("esp32_camera_web_server") CameraWebServer = esp32_camera_web_server_ns.class_("CameraWebServer", cg.Component) Mode = esp32_camera_web_server_ns.enum("Mode") -MODES = {"STREAM": Mode.STREAM, "SNAPSHOT": Mode.SNAPSHOT} +MODES = Mode.options("STREAM", "SNAPSHOT") def _consume_camera_web_server_sockets(config: ConfigType) -> ConfigType: diff --git a/esphome/components/espectre/__init__.py b/esphome/components/espectre/__init__.py index 4f10db996c9..3d9154c5d62 100644 --- a/esphome/components/espectre/__init__.py +++ b/esphome/components/espectre/__init__.py @@ -38,15 +38,8 @@ CsiCapturePolicy = sdk_ns.enum("CsiCapturePolicy", is_class=True) TrafficGeneratorMode = sdk_ns.enum("TrafficGeneratorMode", is_class=True) WifiBandPolicy = sdk_ns.enum("WifiBandPolicy", is_class=True) -DETECTION_ALGORITHMS = { - "lightweight": DetectionAlgorithm.LIGHTWEIGHT, - "high_accuracy": DetectionAlgorithm.HIGH_ACCURACY, -} -CSI_CAPTURE_PROFILES = { - "auto": CsiCapturePolicy.AUTO, - "lltf": CsiCapturePolicy.LLTF, - "ht_vht": CsiCapturePolicy.HT_VHT, -} +DETECTION_ALGORITHMS = DetectionAlgorithm.options("lightweight", "high_accuracy") +CSI_CAPTURE_PROFILES = CsiCapturePolicy.options("auto", "lltf", "ht_vht") TRAFFIC_GENERATOR_MODES = { "ping": TrafficGeneratorMode.PING, "dns": TrafficGeneratorMode.DNS, diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 31e4952d8e0..3fa71632f02 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -117,27 +117,28 @@ CONF_INTERFACE = "interface" CONF_CLOCK_SPEED = "clock_speed" EthernetType = ethernet_ns.enum("EthernetType") -ETHERNET_TYPES = { - "LAN8720": EthernetType.ETHERNET_TYPE_LAN8720, - "RTL8201": EthernetType.ETHERNET_TYPE_RTL8201, - "DP83848": EthernetType.ETHERNET_TYPE_DP83848, - "IP101": EthernetType.ETHERNET_TYPE_IP101, - "JL1101": EthernetType.ETHERNET_TYPE_JL1101, - "KSZ8081": EthernetType.ETHERNET_TYPE_KSZ8081, - "KSZ8081RNA": EthernetType.ETHERNET_TYPE_KSZ8081RNA, - "W5100": EthernetType.ETHERNET_TYPE_W5100, - "W5500": EthernetType.ETHERNET_TYPE_W5500, - "OPENETH": EthernetType.ETHERNET_TYPE_OPENETH, - "DM9051": EthernetType.ETHERNET_TYPE_DM9051, - "LAN8670": EthernetType.ETHERNET_TYPE_LAN8670, - "ENC28J60": EthernetType.ETHERNET_TYPE_ENC28J60, - "W6100": EthernetType.ETHERNET_TYPE_W6100, - "W6300": EthernetType.ETHERNET_TYPE_W6300, - "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, - "YT8531": EthernetType.ETHERNET_TYPE_YT8531, - "CH390": EthernetType.ETHERNET_TYPE_CH390, - "KSZ8851SNL": EthernetType.ETHERNET_TYPE_KSZ8851SNL, -} +ETHERNET_TYPES = EthernetType.options( + "LAN8720", + "RTL8201", + "DP83848", + "IP101", + "JL1101", + "KSZ8081", + "KSZ8081RNA", + "W5100", + "W5500", + "OPENETH", + "DM9051", + "LAN8670", + "ENC28J60", + "W6100", + "W6300", + "GENERIC", + "YT8531", + "CH390", + "KSZ8851SNL", + prefix="ETHERNET_TYPE_", +) # PHY types that need compile-time defines for conditional compilation # Each RMII PHY type gets a define so unused PHY drivers are excluded by the linker @@ -207,10 +208,7 @@ SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) emac_rmii_clock_mode_t = cg.global_ns.enum("emac_rmii_clock_mode_t") -CLK_MODES = { - "CLK_EXT_IN": emac_rmii_clock_mode_t.EMAC_CLK_EXT_IN, - "CLK_OUT": emac_rmii_clock_mode_t.EMAC_CLK_OUT, -} +CLK_MODES = emac_rmii_clock_mode_t.options("CLK_EXT_IN", "CLK_OUT", prefix="EMAC_") spi_host_device_t = cg.global_ns.enum("spi_host_device_t") diff --git a/esphome/components/exponential_moving_average/sensor.py b/esphome/components/exponential_moving_average/sensor.py index 20c8135636d..d8f6a949969 100644 --- a/esphome/components/exponential_moving_average/sensor.py +++ b/esphome/components/exponential_moving_average/sensor.py @@ -23,11 +23,9 @@ ExponentialMovingAverageSensor = exponential_moving_average_ns.class_( ) TimeWeighting = exponential_moving_average_ns.enum("TimeWeighting") -TIME_WEIGHTINGS: dict[str, cg.MockObj] = { - "new": TimeWeighting.TIME_WEIGHTING_NEW, - "previous": TimeWeighting.TIME_WEIGHTING_PREVIOUS, - "linear": TimeWeighting.TIME_WEIGHTING_LINEAR, -} +TIME_WEIGHTINGS: dict[str, cg.MockObj] = TimeWeighting.options( + "new", "previous", "linear", prefix="TIME_WEIGHTING_" +) CONF_TIME_WEIGHTING: str = "time_weighting" diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index cb54e89c3ca..2f0003a4def 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -45,21 +45,18 @@ fan_ns = cg.esphome_ns.namespace("fan") Fan = fan_ns.class_("Fan", cg.EntityBase) FanDirection = fan_ns.enum("FanDirection", is_class=True) -FAN_DIRECTION_ENUM = { - "FORWARD": FanDirection.FORWARD, - "REVERSE": FanDirection.REVERSE, -} +FAN_DIRECTION_ENUM = FanDirection.options("FORWARD", "REVERSE") FanRestoreMode = fan_ns.enum("FanRestoreMode", is_class=True) -RESTORE_MODES = { - "NO_RESTORE": FanRestoreMode.NO_RESTORE, - "ALWAYS_OFF": FanRestoreMode.ALWAYS_OFF, - "ALWAYS_ON": FanRestoreMode.ALWAYS_ON, - "RESTORE_DEFAULT_OFF": FanRestoreMode.RESTORE_DEFAULT_OFF, - "RESTORE_DEFAULT_ON": FanRestoreMode.RESTORE_DEFAULT_ON, - "RESTORE_INVERTED_DEFAULT_OFF": FanRestoreMode.RESTORE_INVERTED_DEFAULT_OFF, - "RESTORE_INVERTED_DEFAULT_ON": FanRestoreMode.RESTORE_INVERTED_DEFAULT_ON, -} +RESTORE_MODES = FanRestoreMode.options( + "NO_RESTORE", + "ALWAYS_OFF", + "ALWAYS_ON", + "RESTORE_DEFAULT_OFF", + "RESTORE_DEFAULT_ON", + "RESTORE_INVERTED_DEFAULT_OFF", + "RESTORE_INVERTED_DEFAULT_ON", +) # Actions CycleSpeedAction = fan_ns.class_("CycleSpeedAction", automation.Action) diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index e496e9c635e..2ef654afc55 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -40,25 +40,14 @@ FingerprintGrowComponent = fingerprint_grow_ns.class_( AuraLEDState = fingerprint_grow_ns.enum("GrowAuraLEDState", True) -AURA_LED_STATES = { - "BREATHING": AuraLEDState.BREATHING, - "FLASHING": AuraLEDState.FLASHING, - "ALWAYS_ON": AuraLEDState.ALWAYS_ON, - "ALWAYS_OFF": AuraLEDState.ALWAYS_OFF, - "GRADUAL_ON": AuraLEDState.GRADUAL_ON, - "GRADUAL_OFF": AuraLEDState.GRADUAL_OFF, -} +AURA_LED_STATES = AuraLEDState.options( + "BREATHING", "FLASHING", "ALWAYS_ON", "ALWAYS_OFF", "GRADUAL_ON", "GRADUAL_OFF" +) validate_aura_led_states = cv.enum(AURA_LED_STATES, upper=True) AuraLEDColor = fingerprint_grow_ns.enum("GrowAuraLEDColor", True) -AURA_LED_COLORS = { - "RED": AuraLEDColor.RED, - "BLUE": AuraLEDColor.BLUE, - "PURPLE": AuraLEDColor.PURPLE, - "GREEN": AuraLEDColor.GREEN, - "YELLOW": AuraLEDColor.YELLOW, - "CYAN": AuraLEDColor.CYAN, - "WHITE": AuraLEDColor.WHITE, -} +AURA_LED_COLORS = AuraLEDColor.options( + "RED", "BLUE", "PURPLE", "GREEN", "YELLOW", "CYAN", "WHITE" +) validate_aura_led_colors = cv.enum(AURA_LED_COLORS, upper=True) diff --git a/esphome/components/friedrich/climate.py b/esphome/components/friedrich/climate.py index 52f1f35708d..b2c65843676 100644 --- a/esphome/components/friedrich/climate.py +++ b/esphome/components/friedrich/climate.py @@ -11,9 +11,7 @@ friedrich_ns = cg.esphome_ns.namespace("friedrich") FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR) Model = friedrich_ns.enum("Model", is_class=True) -MODELS = { - "MW12Y3H": Model.MODEL_MW12Y3H, -} +MODELS = Model.options("MW12Y3H", prefix="MODEL_") CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend( { diff --git a/esphome/components/gp8403/__init__.py b/esphome/components/gp8403/__init__.py index 583b1d15689..59bab37d255 100644 --- a/esphome/components/gp8403/__init__.py +++ b/esphome/components/gp8403/__init__.py @@ -17,15 +17,9 @@ GP8403Model = gp8403_ns.enum("GP8403Model") CONF_GP8403_ID = "gp8403_id" -MODELS = { - "GP8403": GP8403Model.GP8403, - "GP8413": GP8403Model.GP8413, -} +MODELS = GP8403Model.options("GP8403", "GP8413") -VOLTAGES = { - "5V": GP8403Voltage.GP8403_VOLTAGE_5V, - "10V": GP8403Voltage.GP8403_VOLTAGE_10V, -} +VOLTAGES = GP8403Voltage.options("5V", "10V", prefix="GP8403_VOLTAGE_") CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index 0aee5973347..ce9035655c3 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -43,26 +43,17 @@ GraphTrace = graph_ns.class_("GraphTrace") GraphLegend = graph_ns.class_("GraphLegend") LineType = graph_ns.enum("LineType") -LINE_TYPE = { - "SOLID": LineType.LINE_TYPE_SOLID, - "DOTTED": LineType.LINE_TYPE_DOTTED, - "DASHED": LineType.LINE_TYPE_DASHED, -} +LINE_TYPE = LineType.options("SOLID", "DOTTED", "DASHED", prefix="LINE_TYPE_") DirectionType = graph_ns.enum("DirectionType") -DIRECTION_TYPE = { - "AUTO": DirectionType.DIRECTION_TYPE_AUTO, - "HORIZONTAL": DirectionType.DIRECTION_TYPE_HORIZONTAL, - "VERTICAL": DirectionType.DIRECTION_TYPE_VERTICAL, -} +DIRECTION_TYPE = DirectionType.options( + "AUTO", "HORIZONTAL", "VERTICAL", prefix="DIRECTION_TYPE_" +) ValuePositionType = graph_ns.enum("ValuePositionType") -VALUE_POSITION_TYPE = { - "NONE": ValuePositionType.VALUE_POSITION_TYPE_NONE, - "AUTO": ValuePositionType.VALUE_POSITION_TYPE_AUTO, - "BESIDE": ValuePositionType.VALUE_POSITION_TYPE_BESIDE, - "BELOW": ValuePositionType.VALUE_POSITION_TYPE_BELOW, -} +VALUE_POSITION_TYPE = ValuePositionType.options( + "NONE", "AUTO", "BESIDE", "BELOW", prefix="VALUE_POSITION_TYPE_" +) GRAPH_TRACE_SCHEMA = cv.Schema( { diff --git a/esphome/components/gree/climate.py b/esphome/components/gree/climate.py index 356845a7a6f..ebdc5f27744 100644 --- a/esphome/components/gree/climate.py +++ b/esphome/components/gree/climate.py @@ -12,15 +12,9 @@ AUTO_LOAD = ["climate_ir"] GreeClimate = gree_ns.class_("GreeClimate", climate_ir.ClimateIR) Model = gree_ns.enum("Model") -MODELS = { - "generic": Model.GREE_GENERIC, - "yan": Model.GREE_YAN, - "yaa": Model.GREE_YAA, - "yac": Model.GREE_YAC, - "yac1fb9": Model.GREE_YAC1FB9, - "yx1ff": Model.GREE_YX1FF, - "yag": Model.GREE_YAG, -} +MODELS = Model.options( + "generic", "yan", "yaa", "yac", "yac1fb9", "yx1ff", "yag", prefix="GREE_" +) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend( { diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index af323e6b07d..0f49a30e6da 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -100,10 +100,7 @@ PV_SCHEMA = cv.Schema( ) GrowattProtocolVersion = growatt_solar_ns.enum("GrowattProtocolVersion") -PROTOCOL_VERSIONS = { - "RTU": GrowattProtocolVersion.RTU, - "RTU2": GrowattProtocolVersion.RTU2, -} +PROTOCOL_VERSIONS = GrowattProtocolVersion.options("RTU", "RTU2") CONFIG_SCHEMA = ( diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index db915e9c7f4..52171d1855f 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -67,23 +67,14 @@ Smartair2Climate = haier_ns.class_("Smartair2Climate", HaierClimateBase) CONF_HAIER_ID = "haier_id" AirflowVerticalDirection = hon_protocol_ns.enum("VerticalSwingMode", True) -AIRFLOW_VERTICAL_DIRECTION_OPTIONS = { - "HEALTH_UP": AirflowVerticalDirection.HEALTH_UP, - "MAX_UP": AirflowVerticalDirection.MAX_UP, - "UP": AirflowVerticalDirection.UP, - "CENTER": AirflowVerticalDirection.CENTER, - "DOWN": AirflowVerticalDirection.DOWN, - "HEALTH_DOWN": AirflowVerticalDirection.HEALTH_DOWN, -} +AIRFLOW_VERTICAL_DIRECTION_OPTIONS = AirflowVerticalDirection.options( + "HEALTH_UP", "MAX_UP", "UP", "CENTER", "DOWN", "HEALTH_DOWN" +) AirflowHorizontalDirection = hon_protocol_ns.enum("HorizontalSwingMode", True) -AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS = { - "MAX_LEFT": AirflowHorizontalDirection.MAX_LEFT, - "LEFT": AirflowHorizontalDirection.LEFT, - "CENTER": AirflowHorizontalDirection.CENTER, - "RIGHT": AirflowHorizontalDirection.RIGHT, - "MAX_RIGHT": AirflowHorizontalDirection.MAX_RIGHT, -} +AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS = AirflowHorizontalDirection.options( + "MAX_LEFT", "LEFT", "CENTER", "RIGHT", "MAX_RIGHT" +) SUPPORTED_SWING_MODES_OPTIONS = { "OFF": ClimateSwingMode.CLIMATE_SWING_OFF, @@ -114,11 +105,9 @@ SUPPORTED_CLIMATE_PRESETS_HON_OPTIONS = { } HonControlMethod = haier_ns.enum("HonControlMethod", True) -SUPPORTED_HON_CONTROL_METHODS = { - "MONITOR_ONLY": HonControlMethod.MONITOR_ONLY, - "SET_GROUP_PARAMETERS": HonControlMethod.SET_GROUP_PARAMETERS, - "SET_SINGLE_PARAMETER": HonControlMethod.SET_SINGLE_PARAMETER, -} +SUPPORTED_HON_CONTROL_METHODS = HonControlMethod.options( + "MONITOR_ONLY", "SET_GROUP_PARAMETERS", "SET_SINGLE_PARAMETER" +) def validate_visual(config): diff --git a/esphome/components/haier_tundra/climate.py b/esphome/components/haier_tundra/climate.py index c992d14bfed..3ef6ff48598 100644 --- a/esphome/components/haier_tundra/climate.py +++ b/esphome/components/haier_tundra/climate.py @@ -12,10 +12,7 @@ HaierTundra = haier_tundra_ns.class_("HaierTundra", climate_ir.ClimateIR) CONF_UNIT = "unit" Unit = haier_tundra_ns.enum("Unit") -UNITS = { - "a": Unit.HAIER_HEADER_UNIT_A, - "b": Unit.HAIER_HEADER_UNIT_B, -} +UNITS = Unit.options("a", "b", prefix="HAIER_HEADER_UNIT_") CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HaierTundra).extend( { diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index e862760e6ae..f6d935c300d 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -22,10 +22,7 @@ CODEOWNERS = ["@WeekendWarrior"] HBridgeFan = hbridge_ns.class_("HBridgeFan", cg.Component, fan.Fan) DecayMode = hbridge_ns.enum("DecayMode") -DECAY_MODE_OPTIONS = { - "SLOW": DecayMode.DECAY_MODE_SLOW, - "FAST": DecayMode.DECAY_MODE_FAST, -} +DECAY_MODE_OPTIONS = DecayMode.options("SLOW", "FAST", prefix="DECAY_MODE_") CONFIG_SCHEMA = ( fan.fan_schema(HBridgeFan) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 46e1a95cb12..604730f971a 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -28,12 +28,9 @@ HDC302XComponent = hdc302x_ns.class_( ) HDC302XPowerMode = hdc302x_ns.enum("HDC302XPowerMode") -POWER_MODE_OPTIONS = { - "HIGH_ACCURACY": HDC302XPowerMode.HIGH_ACCURACY, - "BALANCED": HDC302XPowerMode.BALANCED, - "LOW_POWER": HDC302XPowerMode.LOW_POWER, - "ULTRA_LOW_POWER": HDC302XPowerMode.ULTRA_LOW_POWER, -} +POWER_MODE_OPTIONS = HDC302XPowerMode.options( + "HIGH_ACCURACY", "BALANCED", "LOW_POWER", "ULTRA_LOW_POWER" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 42ef79d31d2..03148194fe6 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -78,25 +78,15 @@ PROTOCOLS = { CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = heatpumpir_ns.enum("HorizontalDirections") -HORIZONTAL_DIRECTIONS = { - "auto": HorizontalDirections.HORIZONTAL_DIRECTION_AUTO, - "middle": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE, - "left": HorizontalDirections.HORIZONTAL_DIRECTION_LEFT, - "mleft": HorizontalDirections.HORIZONTAL_DIRECTION_MLEFT, - "mright": HorizontalDirections.HORIZONTAL_DIRECTION_MRIGHT, - "right": HorizontalDirections.HORIZONTAL_DIRECTION_RIGHT, -} +HORIZONTAL_DIRECTIONS = HorizontalDirections.options( + "auto", "middle", "left", "mleft", "mright", "right", prefix="HORIZONTAL_DIRECTION_" +) CONF_VERTICAL_DEFAULT = "vertical_default" VerticalDirections = heatpumpir_ns.enum("VerticalDirections") -VERTICAL_DIRECTIONS = { - "auto": VerticalDirections.VERTICAL_DIRECTION_AUTO, - "up": VerticalDirections.VERTICAL_DIRECTION_UP, - "mup": VerticalDirections.VERTICAL_DIRECTION_MUP, - "middle": VerticalDirections.VERTICAL_DIRECTION_MIDDLE, - "mdown": VerticalDirections.VERTICAL_DIRECTION_MDOWN, - "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, -} +VERTICAL_DIRECTIONS = VerticalDirections.options( + "auto", "up", "mup", "middle", "mdown", "down", prefix="VERTICAL_DIRECTION_" +) def _default_visual(config: ConfigType) -> ConfigType: diff --git a/esphome/components/hlw8012/sensor.py b/esphome/components/hlw8012/sensor.py index 5099dcc94ca..007f7c53030 100644 --- a/esphome/components/hlw8012/sensor.py +++ b/esphome/components/hlw8012/sensor.py @@ -41,11 +41,9 @@ INITIAL_MODES = { CONF_VOLTAGE: HLW8012InitialMode.HLW8012_INITIAL_MODE_VOLTAGE, } -MODELS = { - "HLW8012": HLW8012SensorModels.HLW8012_SENSOR_MODEL_HLW8012, - "CSE7759": HLW8012SensorModels.HLW8012_SENSOR_MODEL_CSE7759, - "BL0937": HLW8012SensorModels.HLW8012_SENSOR_MODEL_BL0937, -} +MODELS = HLW8012SensorModels.options( + "HLW8012", "CSE7759", "BL0937", prefix="HLW8012_SENSOR_MODEL_" +) CONF_CF1_PIN = "cf1_pin" CONF_CF_PIN = "cf_pin" diff --git a/esphome/components/honeywellabp2_i2c/sensor.py b/esphome/components/honeywellabp2_i2c/sensor.py index d4c4e44a3b6..7f41fe21939 100644 --- a/esphome/components/honeywellabp2_i2c/sensor.py +++ b/esphome/components/honeywellabp2_i2c/sensor.py @@ -20,10 +20,7 @@ CONF_MIN_PRESSURE = "min_pressure" CONF_MAX_PRESSURE = "max_pressure" TRANSFER_FUNCTION = "transfer_function" ABP2TRANFERFUNCTION = honeywellabp2_ns.enum("ABP2TRANFERFUNCTION") -TRANS_FUNC_OPTIONS = { - "A": ABP2TRANFERFUNCTION.ABP2_TRANS_FUNC_A, - "B": ABP2TRANFERFUNCTION.ABP2_TRANS_FUNC_B, -} +TRANS_FUNC_OPTIONS = ABP2TRANFERFUNCTION.options("A", "B", prefix="ABP2_TRANS_FUNC_") HONEYWELLABP2Sensor = honeywellabp2_ns.class_( "HONEYWELLABP2Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index d78267964b0..bde4b244b4a 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -27,11 +27,9 @@ HTU21DComponent = htu21d_ns.class_( ) HTU21DSensorModels = htu21d_ns.enum("HTU21DSensorModels") -MODELS = { - "HTU21D": HTU21DSensorModels.HTU21D_SENSOR_MODEL_HTU21D, - "SI7021": HTU21DSensorModels.HTU21D_SENSOR_MODEL_SI7021, - "SHT21": HTU21DSensorModels.HTU21D_SENSOR_MODEL_SHT21, -} +MODELS = HTU21DSensorModels.options( + "HTU21D", "SI7021", "SHT21", prefix="HTU21D_SENSOR_MODEL_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 823928176a1..408126e1ad1 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -97,37 +97,32 @@ CONF_MIN_REFRESH_RATE = "min_refresh_rate" # Map to hub75 library enums (in global namespace) Hub75ShiftDriver = cg.global_ns.enum("Hub75ShiftDriver", is_class=True) -SHIFT_DRIVERS = { - "GENERIC": Hub75ShiftDriver.GENERIC, - "FM6126A": Hub75ShiftDriver.FM6126A, - "ICN2038S": Hub75ShiftDriver.ICN2038S, - "FM6124": Hub75ShiftDriver.FM6124, - "MBI5124": Hub75ShiftDriver.MBI5124, - "DP3246": Hub75ShiftDriver.DP3246, -} +SHIFT_DRIVERS = Hub75ShiftDriver.options( + "GENERIC", "FM6126A", "ICN2038S", "FM6124", "MBI5124", "DP3246" +) Hub75PanelLayout = cg.global_ns.enum("Hub75PanelLayout", is_class=True) -PANEL_LAYOUTS = { - "HORIZONTAL": Hub75PanelLayout.HORIZONTAL, - "TOP_LEFT_DOWN": Hub75PanelLayout.TOP_LEFT_DOWN, - "TOP_RIGHT_DOWN": Hub75PanelLayout.TOP_RIGHT_DOWN, - "BOTTOM_LEFT_UP": Hub75PanelLayout.BOTTOM_LEFT_UP, - "BOTTOM_RIGHT_UP": Hub75PanelLayout.BOTTOM_RIGHT_UP, - "TOP_LEFT_DOWN_ZIGZAG": Hub75PanelLayout.TOP_LEFT_DOWN_ZIGZAG, - "TOP_RIGHT_DOWN_ZIGZAG": Hub75PanelLayout.TOP_RIGHT_DOWN_ZIGZAG, - "BOTTOM_LEFT_UP_ZIGZAG": Hub75PanelLayout.BOTTOM_LEFT_UP_ZIGZAG, - "BOTTOM_RIGHT_UP_ZIGZAG": Hub75PanelLayout.BOTTOM_RIGHT_UP_ZIGZAG, -} +PANEL_LAYOUTS = Hub75PanelLayout.options( + "HORIZONTAL", + "TOP_LEFT_DOWN", + "TOP_RIGHT_DOWN", + "BOTTOM_LEFT_UP", + "BOTTOM_RIGHT_UP", + "TOP_LEFT_DOWN_ZIGZAG", + "TOP_RIGHT_DOWN_ZIGZAG", + "BOTTOM_LEFT_UP_ZIGZAG", + "BOTTOM_RIGHT_UP_ZIGZAG", +) Hub75ScanWiring = cg.global_ns.enum("Hub75ScanWiring", is_class=True) -SCAN_WIRINGS = { - "STANDARD_TWO_SCAN": Hub75ScanWiring.STANDARD_TWO_SCAN, - "SCAN_1_4_16PX_HIGH": Hub75ScanWiring.SCAN_1_4_16PX_HIGH, - "SCAN_1_8_32PX_HIGH": Hub75ScanWiring.SCAN_1_8_32PX_HIGH, - "SCAN_1_8_32PX_FULL": Hub75ScanWiring.SCAN_1_8_32PX_FULL, - "SCAN_1_8_40PX_HIGH": Hub75ScanWiring.SCAN_1_8_40PX_HIGH, - "SCAN_1_8_64PX_HIGH": Hub75ScanWiring.SCAN_1_8_64PX_HIGH, -} +SCAN_WIRINGS = Hub75ScanWiring.options( + "STANDARD_TWO_SCAN", + "SCAN_1_4_16PX_HIGH", + "SCAN_1_8_32PX_HIGH", + "SCAN_1_8_32PX_FULL", + "SCAN_1_8_40PX_HIGH", + "SCAN_1_8_64PX_HIGH", +) def _validate_scan_wiring(value: Any) -> str: diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index ddbe08922fe..3c7922421bf 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -68,18 +68,19 @@ INTERNAL_DAC_OPTIONS = { } i2s_comm_format_t = cg.global_ns.enum("i2s_comm_format_t") -I2C_COMM_FMT_OPTIONS = { - "stand_i2s": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_I2S, - "stand_msb": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_MSB, - "stand_pcm_short": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_PCM_SHORT, - "stand_pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_PCM_LONG, - "stand_max": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_MAX, - "i2s_msb": i2s_comm_format_t.I2S_COMM_FORMAT_I2S_MSB, - "i2s_lsb": i2s_comm_format_t.I2S_COMM_FORMAT_I2S_LSB, - "pcm": i2s_comm_format_t.I2S_COMM_FORMAT_PCM, - "pcm_short": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_SHORT, - "pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_LONG, -} +I2C_COMM_FMT_OPTIONS = i2s_comm_format_t.options( + "stand_i2s", + "stand_msb", + "stand_pcm_short", + "stand_pcm_long", + "stand_max", + "i2s_msb", + "i2s_lsb", + "pcm", + "pcm_short", + "pcm_long", + prefix="I2S_COMM_FORMAT_", +) INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] diff --git a/esphome/components/icm20689/motion.py b/esphome/components/icm20689/motion.py index 7673dcf4cf4..b2677596c4d 100644 --- a/esphome/components/icm20689/motion.py +++ b/esphome/components/icm20689/motion.py @@ -11,12 +11,9 @@ DEPENDENCIES = ["spi"] # Enum proxies (must match the C++ enum values exactly) ICM20689AccelRange = icm20689_ns.enum("ICM20689AccelRange") -ACCEL_RANGE_OPTIONS = { - "2G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_2G, - "4G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_4G, - "8G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_8G, - "16G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = ICM20689AccelRange.options( + "2G", "4G", "8G", "16G", prefix="ICM20689_ACCEL_RANGE_" +) ICM20689GyroRange = icm20689_ns.enum("ICM20689GyroRange") GYRO_RANGE_OPTIONS = { diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 350a0c16527..93eecbbe919 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -57,14 +57,14 @@ Inkplate = inkplate_ns.class_( InkplateModel = inkplate_ns.enum("InkplateModel") -MODELS = { - "inkplate_6": InkplateModel.INKPLATE_6, - "inkplate_10": InkplateModel.INKPLATE_10, - "inkplate_6_plus": InkplateModel.INKPLATE_6_PLUS, - "inkplate_6_v2": InkplateModel.INKPLATE_6_V2, - "inkplate_5": InkplateModel.INKPLATE_5, - "inkplate_5_v2": InkplateModel.INKPLATE_5_V2, -} +MODELS = InkplateModel.options( + "inkplate_6", + "inkplate_10", + "inkplate_6_plus", + "inkplate_6_v2", + "inkplate_5", + "inkplate_5_v2", +) CONF_CUSTOM_WAVEFORM = "custom_waveform" diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index a68cb575192..d8e2c82cf6f 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -28,11 +28,9 @@ INTEGRATION_TIMES = { "d": IntegrationSensorTime.INTEGRATION_SENSOR_TIME_DAY, } IntegrationMethod = integration_ns.enum("IntegrationMethod") -INTEGRATION_METHODS = { - "trapezoid": IntegrationMethod.INTEGRATION_METHOD_TRAPEZOID, - "left": IntegrationMethod.INTEGRATION_METHOD_LEFT, - "right": IntegrationMethod.INTEGRATION_METHOD_RIGHT, -} +INTEGRATION_METHODS = IntegrationMethod.options( + "trapezoid", "left", "right", prefix="INTEGRATION_METHOD_" +) CONF_TIME_UNIT = "time_unit" CONF_INTEGRATION_METHOD = "integration_method" diff --git a/esphome/components/jsn_sr04t/sensor.py b/esphome/components/jsn_sr04t/sensor.py index 0c4187b823f..695c8b97d8d 100644 --- a/esphome/components/jsn_sr04t/sensor.py +++ b/esphome/components/jsn_sr04t/sensor.py @@ -17,10 +17,7 @@ Jsnsr04tComponent = jsn_sr04t_ns.class_( "Jsnsr04tComponent", sensor.Sensor, cg.PollingComponent, uart.UARTDevice ) Model = jsn_sr04t_ns.enum("Model") -MODEL = { - "jsn_sr04t": Model.JSN_SR04T, - "aj_sr04m": Model.AJ_SR04M, -} +MODEL = Model.options("jsn_sr04t", "aj_sr04m") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 0281609edfa..67e5942a43b 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -25,10 +25,9 @@ DEPENDENCIES = ["i2c"] lc709203f_ns = cg.esphome_ns.namespace("lc709203f") LC709203FBatteryVoltage = lc709203f_ns.enum("LC709203FBatteryVoltage") -BATTERY_VOLTAGE_OPTIONS = { - "3.7": LC709203FBatteryVoltage.LC709203F_BATTERY_VOLTAGE_3_7, - "3.8": LC709203FBatteryVoltage.LC709203F_BATTERY_VOLTAGE_3_8, -} +BATTERY_VOLTAGE_OPTIONS = LC709203FBatteryVoltage.options( + "3.7", "3.8", prefix="LC709203F_BATTERY_VOLTAGE_" +) lc709203f = lc709203f_ns.class_("Lc709203f", cg.PollingComponent, i2c.I2CDevice) diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 7a95920a740..23d1d7e00ea 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -21,24 +21,21 @@ ChannelColors = light_ns.struct("ChannelColors") # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) -COLOR_MODES = { - "ON_OFF": ColorMode.ON_OFF, - "BRIGHTNESS": ColorMode.BRIGHTNESS, - "WHITE": ColorMode.WHITE, - "COLOR_TEMPERATURE": ColorMode.COLOR_TEMPERATURE, - "COLD_WARM_WHITE": ColorMode.COLD_WARM_WHITE, - "RGB": ColorMode.RGB, - "RGB_WHITE": ColorMode.RGB_WHITE, - "RGB_COLOR_TEMPERATURE": ColorMode.RGB_COLOR_TEMPERATURE, - "RGB_COLD_WARM_WHITE": ColorMode.RGB_COLD_WARM_WHITE, -} +COLOR_MODES = ColorMode.options( + "ON_OFF", + "BRIGHTNESS", + "WHITE", + "COLOR_TEMPERATURE", + "COLD_WARM_WHITE", + "RGB", + "RGB_WHITE", + "RGB_COLOR_TEMPERATURE", + "RGB_COLD_WARM_WHITE", +) # Limit modes LimitMode = light_ns.enum("LimitMode", is_class=True) -LIMIT_MODES = { - "CLAMP": LimitMode.CLAMP, - "DO_NOTHING": LimitMode.DO_NOTHING, -} +LIMIT_MODES = LimitMode.options("CLAMP", "DO_NOTHING") # Actions ToggleAction = light_ns.class_("ToggleAction", automation.Action) diff --git a/esphome/components/lis2dw12/__init__.py b/esphome/components/lis2dw12/__init__.py index fcd885e5db4..76a69433910 100644 --- a/esphome/components/lis2dw12/__init__.py +++ b/esphome/components/lis2dw12/__init__.py @@ -37,12 +37,7 @@ lis2dw12_ns = cg.esphome_ns.namespace("lis2dw12") LIS2DW12Component = lis2dw12_ns.class_("LIS2DW12Component", cg.PollingComponent) LIS2DW12Range = lis2dw12_ns.enum("Range", True) -RANGES = { - "2G": LIS2DW12Range.RANGE_2G, - "4G": LIS2DW12Range.RANGE_4G, - "8G": LIS2DW12Range.RANGE_8G, - "16G": LIS2DW12Range.RANGE_16G, -} +RANGES = LIS2DW12Range.options("2G", "4G", "8G", "16G", prefix="RANGE_") LIS2DW12PowerMode = lis2dw12_ns.enum("PowerMode", True) POWER_MODES = { @@ -54,25 +49,23 @@ POWER_MODES = { } LIS2DW12DataRate = lis2dw12_ns.enum("DataRate", True) -DATA_RATES = { - "1.6Hz": LIS2DW12DataRate.RATE_1_6HZ, - "12.5Hz": LIS2DW12DataRate.RATE_12_5HZ, - "25Hz": LIS2DW12DataRate.RATE_25HZ, - "50Hz": LIS2DW12DataRate.RATE_50HZ, - "100Hz": LIS2DW12DataRate.RATE_100HZ, - "200Hz": LIS2DW12DataRate.RATE_200HZ, - "400Hz": LIS2DW12DataRate.RATE_400HZ, - "800Hz": LIS2DW12DataRate.RATE_800HZ, - "1600Hz": LIS2DW12DataRate.RATE_1600HZ, -} +DATA_RATES = LIS2DW12DataRate.options( + "1.6Hz", + "12.5Hz", + "25Hz", + "50Hz", + "100Hz", + "200Hz", + "400Hz", + "800Hz", + "1600Hz", + prefix="RATE_", +) LIS2DW12FilterBandwidth = lis2dw12_ns.enum("FilterBandwidth", True) -FILTER_BANDWIDTHS = { - "odr_div_2": LIS2DW12FilterBandwidth.BW_ODR_DIV_2, - "odr_div_4": LIS2DW12FilterBandwidth.BW_ODR_DIV_4, - "odr_div_10": LIS2DW12FilterBandwidth.BW_ODR_DIV_10, - "odr_div_20": LIS2DW12FilterBandwidth.BW_ODR_DIV_20, -} +FILTER_BANDWIDTHS = LIS2DW12FilterBandwidth.options( + "odr_div_2", "odr_div_4", "odr_div_10", "odr_div_20", prefix="BW_" +) _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation(CONF_ON_TAP, "add_on_tap_callback"), diff --git a/esphome/components/lis3dh/motion.py b/esphome/components/lis3dh/motion.py index ea4b4d3b81a..317d4b48e90 100644 --- a/esphome/components/lis3dh/motion.py +++ b/esphome/components/lis3dh/motion.py @@ -21,12 +21,7 @@ CONF_HIGH_PASS_FILTER = "high_pass_filter" # Enum values must match the C++ enums. LIS3DHRange = lis3dh_ns.enum("LIS3DHRange") -RANGE_OPTIONS = { - "2G": LIS3DHRange.LIS3DH_RANGE_2G, - "4G": LIS3DHRange.LIS3DH_RANGE_4G, - "8G": LIS3DHRange.LIS3DH_RANGE_8G, - "16G": LIS3DHRange.LIS3DH_RANGE_16G, -} +RANGE_OPTIONS = LIS3DHRange.options("2G", "4G", "8G", "16G", prefix="LIS3DH_RANGE_") LIS3DHDataRate = lis3dh_ns.enum("LIS3DHDataRate") DATA_RATE_OPTIONS = { @@ -58,17 +53,14 @@ ODR_HZ = { DURATION_MAX_REG = 0x7F LIS3DHOperatingMode = lis3dh_ns.enum("LIS3DHOperatingMode") -OPERATING_MODE_OPTIONS = { - "LOW_POWER": LIS3DHOperatingMode.LIS3DH_MODE_LOW_POWER, - "NORMAL": LIS3DHOperatingMode.LIS3DH_MODE_NORMAL, - "HIGH_RESOLUTION": LIS3DHOperatingMode.LIS3DH_MODE_HIGH_RESOLUTION, -} +OPERATING_MODE_OPTIONS = LIS3DHOperatingMode.options( + "LOW_POWER", "NORMAL", "HIGH_RESOLUTION", prefix="LIS3DH_MODE_" +) LIS3DHInterruptPin = lis3dh_ns.enum("LIS3DHInterruptPin") -INTERRUPT_PIN_OPTIONS = { - "INT1": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT1, - "INT2": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT2, -} +INTERRUPT_PIN_OPTIONS = LIS3DHInterruptPin.options( + "INT1", "INT2", prefix="LIS3DH_INT_PIN_" +) # INT1_CFG high-event bit per axis. INT_CFG_HIGH_EVENT = {"x": 0x02, "y": 0x08, "z": 0x20} diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index cf723df9b93..fb9a79f81fe 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -36,15 +36,16 @@ LockStateForwarder = lock_ns.class_("LockStateForwarder") LockState = lock_ns.enum("LockState") -LOCK_STATES = { - "OPEN": LockState.LOCK_STATE_OPEN, - "LOCKED": LockState.LOCK_STATE_LOCKED, - "UNLOCKED": LockState.LOCK_STATE_UNLOCKED, - "JAMMED": LockState.LOCK_STATE_JAMMED, - "OPENING": LockState.LOCK_STATE_OPENING, - "LOCKING": LockState.LOCK_STATE_LOCKING, - "UNLOCKING": LockState.LOCK_STATE_UNLOCKING, -} +LOCK_STATES = LockState.options( + "OPEN", + "LOCKED", + "UNLOCKED", + "JAMMED", + "OPENING", + "LOCKING", + "UNLOCKING", + prefix="LOCK_STATE_", +) validate_lock_state = cv.enum(LOCK_STATES, upper=True) diff --git a/esphome/components/lsm6ds/motion.py b/esphome/components/lsm6ds/motion.py index 064cd312a92..0eaff38db3e 100644 --- a/esphome/components/lsm6ds/motion.py +++ b/esphome/components/lsm6ds/motion.py @@ -19,12 +19,9 @@ DOMAIN = "lsm6ds" # ── C++ namespace / class ──────────────────────────────────────────────────── # ── Enum proxies ───────────────────────────────────────────────────────────── LSM6DSAccelRange = lsm6ds_ns.enum("LSM6DSAccelRange") -ACCEL_RANGE_OPTIONS = { - "2G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_2G, - "4G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_4G, - "8G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_8G, - "16G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = LSM6DSAccelRange.options( + "2G", "4G", "8G", "16G", prefix="LSM6DS_ACCEL_RANGE_" +) LSM6DSAccelODR = lsm6ds_ns.enum("LSM6DSAccelODR") ACCEL_ODR_OPTIONS = { diff --git a/esphome/components/max31856/sensor.py b/esphome/components/max31856/sensor.py index 43a2e18db87..4a12c1bb482 100644 --- a/esphome/components/max31856/sensor.py +++ b/esphome/components/max31856/sensor.py @@ -21,16 +21,9 @@ FILTER = { 60: MAX31865ConfigFilter.FILTER_60HZ, } MAX31856ThermocoupleType = max31856_ns.enum("MAX31856ThermocoupleType") -THERMOCOUPLE_TYPE = { - "B": MAX31856ThermocoupleType.MAX31856_TCTYPE_B, - "E": MAX31856ThermocoupleType.MAX31856_TCTYPE_E, - "J": MAX31856ThermocoupleType.MAX31856_TCTYPE_J, - "K": MAX31856ThermocoupleType.MAX31856_TCTYPE_K, - "N": MAX31856ThermocoupleType.MAX31856_TCTYPE_N, - "R": MAX31856ThermocoupleType.MAX31856_TCTYPE_R, - "S": MAX31856ThermocoupleType.MAX31856_TCTYPE_S, - "T": MAX31856ThermocoupleType.MAX31856_TCTYPE_T, -} +THERMOCOUPLE_TYPE = MAX31856ThermocoupleType.options( + "B", "E", "J", "K", "N", "R", "S", "T", prefix="MAX31856_TCTYPE_" +) CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/max31865/sensor.py b/esphome/components/max31865/sensor.py index 167a0997e45..e249dc5855c 100644 --- a/esphome/components/max31865/sensor.py +++ b/esphome/components/max31865/sensor.py @@ -21,10 +21,7 @@ MAX31865Sensor = max31865_ns.class_( ) MAX31865ConfigFilter = max31865_ns.enum("MAX31865ConfigFilter") -FILTER = { - "50HZ": MAX31865ConfigFilter.FILTER_50HZ, - "60HZ": MAX31865ConfigFilter.FILTER_60HZ, -} +FILTER = MAX31865ConfigFilter.options("50HZ", "60HZ", prefix="FILTER_") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/max44009/sensor.py b/esphome/components/max44009/sensor.py index 88673c5d00a..09b480335ec 100644 --- a/esphome/components/max44009/sensor.py +++ b/esphome/components/max44009/sensor.py @@ -19,11 +19,9 @@ MAX44009Sensor = max44009_ns.class_( ) MAX44009Mode = max44009_ns.enum("MAX44009Mode") -MODE_OPTIONS = { - "auto": MAX44009Mode.MAX44009_MODE_AUTO, - "low_power": MAX44009Mode.MAX44009_MODE_LOW_POWER, - "continuous": MAX44009Mode.MAX44009_MODE_CONTINUOUS, -} +MODE_OPTIONS = MAX44009Mode.options( + "auto", "low_power", "continuous", prefix="MAX44009_MODE_" +) CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 77e82fdec71..b5d5041c968 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -30,10 +30,7 @@ MAX6956 = max6956_ns.class_("MAX6956", cg.Component, i2c.I2CDevice) MAX6956GPIOPin = max6956_ns.class_("MAX6956GPIOPin", cg.GPIOPin) MAX6956_CURRENTMODE = max6956_ns.enum("MAX6956CURRENTMODE") -CURRENT_MODES = { - "global": MAX6956_CURRENTMODE.GLOBAL, - "segment": MAX6956_CURRENTMODE.SEGMENT, -} +CURRENT_MODES = MAX6956_CURRENTMODE.options("global", "segment") CONFIG_SCHEMA = ( diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index 7974ef20202..2271bc09f48 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -28,16 +28,10 @@ CONF_CHIP_LINES_STYLE = "chip_lines_style" integration_ns = cg.esphome_ns.namespace("max7219digit") ChipLinesStyle = integration_ns.enum("ChipLinesStyle") -CHIP_LINES_STYLE = { - "ZIGZAG": ChipLinesStyle.ZIGZAG, - "SNAKE": ChipLinesStyle.SNAKE, -} +CHIP_LINES_STYLE = ChipLinesStyle.options("ZIGZAG", "SNAKE") ScrollMode = integration_ns.enum("ScrollMode") -SCROLL_MODES = { - "CONTINUOUS": ScrollMode.CONTINUOUS, - "STOP": ScrollMode.STOP, -} +SCROLL_MODES = ScrollMode.options("CONTINUOUS", "STOP") CHIP_MODES = { "0": 0, diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index f7449b0ab9f..942a975813a 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -27,12 +27,9 @@ MCP23XXXBase = mcp23xxx_base_ns.class_("MCP23XXXBase", cg.Component) MCP23XXXGPIOPin = mcp23xxx_base_ns.class_("MCP23XXXGPIOPin", cg.GPIOPin) MCP23XXXInterruptMode = mcp23xxx_base_ns.enum("MCP23XXXInterruptMode") -MCP23XXX_INTERRUPT_MODES = { - "NO_INTERRUPT": MCP23XXXInterruptMode.MCP23XXX_NO_INTERRUPT, - "CHANGE": MCP23XXXInterruptMode.MCP23XXX_CHANGE, - "RISING": MCP23XXXInterruptMode.MCP23XXX_RISING, - "FALLING": MCP23XXXInterruptMode.MCP23XXX_FALLING, -} +MCP23XXX_INTERRUPT_MODES = MCP23XXXInterruptMode.options( + "NO_INTERRUPT", "CHANGE", "RISING", "FALLING", prefix="MCP23XXX_" +) MCP23XXX_CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/mcp2515/canbus.py b/esphome/components/mcp2515/canbus.py index eab9be0f700..62b80b172c1 100644 --- a/esphome/components/mcp2515/canbus.py +++ b/esphome/components/mcp2515/canbus.py @@ -15,18 +15,9 @@ mcp2515 = mcp2515_ns.class_("MCP2515", CanbusComponent, spi.SPIDevice) CanClock = mcp2515_ns.enum("CAN_CLOCK") McpMode = mcp2515_ns.enum("CANCTRL_REQOP_MODE") -CAN_CLOCK = { - "8MHZ": CanClock.MCP_8MHZ, - "12MHZ": CanClock.MCP_12MHZ, - "16MHZ": CanClock.MCP_16MHZ, - "20MHZ": CanClock.MCP_20MHZ, -} +CAN_CLOCK = CanClock.options("8MHZ", "12MHZ", "16MHZ", "20MHZ", prefix="MCP_") -MCP_MODE = { - "NORMAL": McpMode.CANCTRL_REQOP_NORMAL, - "LOOPBACK": McpMode.CANCTRL_REQOP_LOOPBACK, - "LISTENONLY": McpMode.CANCTRL_REQOP_LISTENONLY, -} +MCP_MODE = McpMode.options("NORMAL", "LOOPBACK", "LISTENONLY", prefix="CANCTRL_REQOP_") CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( { diff --git a/esphome/components/mcp4728/output/__init__.py b/esphome/components/mcp4728/output/__init__.py index e8cb4c47d69..659a3731a5a 100644 --- a/esphome/components/mcp4728/output/__init__.py +++ b/esphome/components/mcp4728/output/__init__.py @@ -19,7 +19,7 @@ VREF_OPTIONS = { } MCP4728Gain = mcp4728_ns.enum("MCP4728Gain") -GAIN_OPTIONS = {"X1": MCP4728Gain.MCP4728_GAIN_X1, "X2": MCP4728Gain.MCP4728_GAIN_X2} +GAIN_OPTIONS = MCP4728Gain.options("X1", "X2", prefix="MCP4728_GAIN_") MCP4728PwrDown = mcp4728_ns.enum("MCP4728PwrDown") PWRDOWN_OPTIONS = { @@ -30,12 +30,9 @@ PWRDOWN_OPTIONS = { } MCP4728ChannelIdx = mcp4728_ns.enum("MCP4728ChannelIdx") -CHANNEL_OPTIONS = { - "A": MCP4728ChannelIdx.MCP4728_CHANNEL_A, - "B": MCP4728ChannelIdx.MCP4728_CHANNEL_B, - "C": MCP4728ChannelIdx.MCP4728_CHANNEL_C, - "D": MCP4728ChannelIdx.MCP4728_CHANNEL_D, -} +CHANNEL_OPTIONS = MCP4728ChannelIdx.options( + "A", "B", "C", "D", prefix="MCP4728_CHANNEL_" +) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { diff --git a/esphome/components/mcp9600/sensor.py b/esphome/components/mcp9600/sensor.py index 75f2e025c76..9a5afe64c28 100644 --- a/esphome/components/mcp9600/sensor.py +++ b/esphome/components/mcp9600/sensor.py @@ -22,16 +22,9 @@ MCP9600Component = mcp9600_ns.class_( ) MCP9600ThermocoupleType = mcp9600_ns.enum("MCP9600ThermocoupleType") -THERMOCOUPLE_TYPE = { - "K": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_K, - "J": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_J, - "T": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_T, - "N": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_N, - "S": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_S, - "E": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_E, - "B": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_B, - "R": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_R, -} +THERMOCOUPLE_TYPE = MCP9600ThermocoupleType.options( + "K", "J", "T", "N", "S", "E", "B", "R", prefix="MCP9600_THERMOCOUPLE_TYPE_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index d012773d8a6..88b000f5fcb 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -42,10 +42,9 @@ MediaPlayerSupportedFormat = media_player_ns.struct("MediaPlayerSupportedFormat" MediaPlayerFormatPurpose = media_player_ns.enum( "MediaPlayerFormatPurpose", is_class=True ) -MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = { - "default": MediaPlayerFormatPurpose.PURPOSE_DEFAULT, - "announcement": MediaPlayerFormatPurpose.PURPOSE_ANNOUNCEMENT, -} +MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = MediaPlayerFormatPurpose.options( + "default", "announcement", prefix="PURPOSE_" +) # Public API for external components. Do not remove. FORMAT_MAPPING = { diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 20cd194d2ed..cecbf9836ef 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -86,11 +86,9 @@ CUSTOM_PRESETS = { } # How midea_ac.display_toggle reaches the appliance; see do_display_toggle() for the AUTO rules -DISPLAY_CONTROLS = { - "UART": DisplayControl.DISPLAY_CONTROL_UART, - "AUTO": DisplayControl.DISPLAY_CONTROL_AUTO, - "IR": DisplayControl.DISPLAY_CONTROL_IR, -} +DISPLAY_CONTROLS = DisplayControl.options( + "UART", "AUTO", "IR", prefix="DISPLAY_CONTROL_" +) validate_modes = cv.enum(ALLOWED_CLIMATE_MODES, upper=True) validate_presets = cv.enum(ALLOWED_CLIMATE_PRESETS, upper=True) diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 1fbe1d67cf3..0085d062d77 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -24,25 +24,27 @@ CONF_FAHRENHEIT_COMPATIBILITY = "fahrenheit_compatibility" CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = mitsubishi_ns.enum("HorizontalDirections") -HORIZONTAL_DIRECTIONS = { - "left": HorizontalDirections.HORIZONTAL_DIRECTION_LEFT, - "middle-left": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE_LEFT, - "middle": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE, - "middle-right": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE_RIGHT, - "right": HorizontalDirections.HORIZONTAL_DIRECTION_RIGHT, - "split": HorizontalDirections.HORIZONTAL_DIRECTION_SPLIT, -} +HORIZONTAL_DIRECTIONS = HorizontalDirections.options( + "left", + "middle-left", + "middle", + "middle-right", + "right", + "split", + prefix="HORIZONTAL_DIRECTION_", +) CONF_VERTICAL_DEFAULT = "vertical_default" VerticalDirections = mitsubishi_ns.enum("VerticalDirections") -VERTICAL_DIRECTIONS = { - "auto": VerticalDirections.VERTICAL_DIRECTION_AUTO, - "up": VerticalDirections.VERTICAL_DIRECTION_UP, - "middle-up": VerticalDirections.VERTICAL_DIRECTION_MIDDLE_UP, - "middle": VerticalDirections.VERTICAL_DIRECTION_MIDDLE, - "middle-down": VerticalDirections.VERTICAL_DIRECTION_MIDDLE_DOWN, - "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, -} +VERTICAL_DIRECTIONS = VerticalDirections.options( + "auto", + "up", + "middle-up", + "middle", + "middle-down", + "down", + prefix="VERTICAL_DIRECTION_", +) CONF_VERTICAL_VANES = "vertical_vanes" diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py index ec95b820455..2d996dc376a 100644 --- a/esphome/components/modbus/helpers.py +++ b/esphome/components/modbus/helpers.py @@ -9,25 +9,21 @@ PduBuffer = modbus_helpers_ns.class_("PduBuffer") FunctionCode_ns = modbus_ns.namespace("FunctionCode") FunctionCode = FunctionCode_ns.enum("FunctionCode") -MODBUS_FUNCTION_CODE = { - "read_coils": FunctionCode.READ_COILS, - "read_discrete_inputs": FunctionCode.READ_DISCRETE_INPUTS, - "read_holding_registers": FunctionCode.READ_HOLDING_REGISTERS, - "read_input_registers": FunctionCode.READ_INPUT_REGISTERS, - "write_single_coil": FunctionCode.WRITE_SINGLE_COIL, - "write_single_register": FunctionCode.WRITE_SINGLE_REGISTER, - "write_multiple_coils": FunctionCode.WRITE_MULTIPLE_COILS, - "write_multiple_registers": FunctionCode.WRITE_MULTIPLE_REGISTERS, -} +MODBUS_FUNCTION_CODE = FunctionCode.options( + "read_coils", + "read_discrete_inputs", + "read_holding_registers", + "read_input_registers", + "write_single_coil", + "write_single_register", + "write_multiple_coils", + "write_multiple_registers", +) EntityType_ns = modbus_ns.namespace("EntityType") EntityType = EntityType_ns.enum("EntityType") -MODBUS_WRITE_REGISTER_TYPE = { - "custom": EntityType.CUSTOM, - "coil": EntityType.COIL, - "holding": EntityType.HOLDING, -} +MODBUS_WRITE_REGISTER_TYPE = EntityType.options("custom", "coil", "holding") MODBUS_REGISTER_TYPE = { **MODBUS_WRITE_REGISTER_TYPE, @@ -38,23 +34,23 @@ MODBUS_REGISTER_TYPE = { SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType") SensorValueType = SensorValueType_ns.enum("SensorValueType") -SENSOR_VALUE_TYPE = { - "RAW": SensorValueType.RAW, - "U_WORD": SensorValueType.U_WORD, - "U_WORD_S": SensorValueType.U_WORD_S, - "S_WORD": SensorValueType.S_WORD, - "S_WORD_S": SensorValueType.S_WORD_S, - "U_DWORD": SensorValueType.U_DWORD, - "U_DWORD_R": SensorValueType.U_DWORD_R, - "S_DWORD": SensorValueType.S_DWORD, - "S_DWORD_R": SensorValueType.S_DWORD_R, - "U_QWORD": SensorValueType.U_QWORD, - "U_QWORD_R": SensorValueType.U_QWORD_R, - "S_QWORD": SensorValueType.S_QWORD, - "S_QWORD_R": SensorValueType.S_QWORD_R, - "FP32": SensorValueType.FP32, - "FP32_R": SensorValueType.FP32_R, -} +SENSOR_VALUE_TYPE = SensorValueType.options( + "RAW", + "U_WORD", + "U_WORD_S", + "S_WORD", + "S_WORD_S", + "U_DWORD", + "U_DWORD_R", + "S_DWORD", + "S_DWORD_R", + "U_QWORD", + "U_QWORD_R", + "S_QWORD", + "S_QWORD_R", + "FP32", + "FP32_R", +) TYPE_REGISTER_MAP = { "RAW": 1, diff --git a/esphome/components/modbus_controller/text_sensor/__init__.py b/esphome/components/modbus_controller/text_sensor/__init__.py index 7ab77700ca5..00955464a3d 100644 --- a/esphome/components/modbus_controller/text_sensor/__init__.py +++ b/esphome/components/modbus_controller/text_sensor/__init__.py @@ -33,12 +33,7 @@ ModbusTextSensor = modbus_controller_ns.class_( RawEncoding_ns = modbus_controller_ns.namespace("RawEncoding") RawEncoding = RawEncoding_ns.enum("RawEncoding") -RAW_ENCODING = { - "NONE": RawEncoding.NONE, - "HEXBYTES": RawEncoding.HEXBYTES, - "COMMA": RawEncoding.COMMA, - "ANSI": RawEncoding.ANSI, -} +RAW_ENCODING = RawEncoding.options("NONE", "HEXBYTES", "COMMA", "ANSI") CONFIG_SCHEMA = cv.All( text_sensor.text_sensor_schema() diff --git a/esphome/components/motion/binary_sensor/__init__.py b/esphome/components/motion/binary_sensor/__init__.py index 0a3876c8ab2..175f859e02e 100644 --- a/esphome/components/motion/binary_sensor/__init__.py +++ b/esphome/components/motion/binary_sensor/__init__.py @@ -24,12 +24,9 @@ MotionBinarySensor = motion_ns.class_( MotionBinarySensorType = motion_ns.enum("MotionBinarySensorType") -SENSOR_TYPES = { - "face_up": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_UP, - "face_down": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_DOWN, - "free_fall": MotionBinarySensorType.MOTION_BINARY_SENSOR_FREE_FALL, - "moving": MotionBinarySensorType.MOTION_BINARY_SENSOR_MOVING, -} +SENSOR_TYPES = MotionBinarySensorType.options( + "face_up", "face_down", "free_fall", "moving", prefix="MOTION_BINARY_SENSOR_" +) # face_up / face_down configure their threshold as a maximum tilt angle in degrees; # the C++ side compares against the cosine of that angle. diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 75260a1b0af..3c491ffc898 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -48,12 +48,7 @@ MSA_MODELS = { } MSARange = msa3xx_ns.enum("Range", True) -MSA_RANGES = { - "2G": MSARange.RANGE_2G, - "4G": MSARange.RANGE_4G, - "8G": MSARange.RANGE_8G, - "16G": MSARange.RANGE_16G, -} +MSA_RANGES = MSARange.options("2G", "4G", "8G", "16G", prefix="RANGE_") MSAResolution = msa3xx_ns.enum("Resolution", True) RESOLUTIONS_MSA301 = { diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 5822c95c7ef..96352a8f1a6 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -177,20 +177,13 @@ NumberMode = number_ns.enum("NumberMode") # skips the setter when the config equals it. DEFAULT_MODE = "AUTO" -NUMBER_MODES = { - "AUTO": NumberMode.NUMBER_MODE_AUTO, - "BOX": NumberMode.NUMBER_MODE_BOX, - "SLIDER": NumberMode.NUMBER_MODE_SLIDER, -} +NUMBER_MODES = NumberMode.options("AUTO", "BOX", "SLIDER", prefix="NUMBER_MODE_") NumberOperation = number_ns.enum("NumberOperation") -NUMBER_OPERATION_OPTIONS = { - "INCREMENT": NumberOperation.NUMBER_OP_INCREMENT, - "DECREMENT": NumberOperation.NUMBER_OP_DECREMENT, - "TO_MIN": NumberOperation.NUMBER_OP_TO_MIN, - "TO_MAX": NumberOperation.NUMBER_OP_TO_MAX, -} +NUMBER_OPERATION_OPTIONS = NumberOperation.options( + "INCREMENT", "DECREMENT", "TO_MIN", "TO_MAX", prefix="NUMBER_OP_" +) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") validate_unit_of_measurement = cv.All( diff --git a/esphome/components/pca9685/__init__.py b/esphome/components/pca9685/__init__.py index 16103aee443..699643f044c 100644 --- a/esphome/components/pca9685/__init__.py +++ b/esphome/components/pca9685/__init__.py @@ -17,10 +17,7 @@ pca9685_ns = cg.esphome_ns.namespace("pca9685") PCA9685Output = pca9685_ns.class_("PCA9685Output", cg.Component, i2c.I2CDevice) phase_balancer = pca9685_ns.enum("PhaseBalancer", is_class=True) -PHASE_BALANCERS = { - "none": phase_balancer.NONE, - "linear": phase_balancer.LINEAR, -} +PHASE_BALANCERS = phase_balancer.options("none", "linear") def validate_frequency(config: ConfigType) -> ConfigType: diff --git a/esphome/components/pcm5122/switch/__init__.py b/esphome/components/pcm5122/switch/__init__.py index 829adeccb7a..638e51a0bc3 100644 --- a/esphome/components/pcm5122/switch/__init__.py +++ b/esphome/components/pcm5122/switch/__init__.py @@ -9,10 +9,9 @@ from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns PCM5122PowerSwitch = pcm5122_ns.class_("PCM5122PowerSwitch", switch.Switch) pcm5122_power_switch_mode = pcm5122_ns.enum("PCM5122PowerSwitchMode") -PCM5122_POWER_SWITCH_MODE_ENUM = { - "standby": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_STANDBY, - "powerdown": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_POWERDOWN, -} +PCM5122_POWER_SWITCH_MODE_ENUM = pcm5122_power_switch_mode.options( + "standby", "powerdown", prefix="PCM5122_POWER_SWITCH_MODE_" +) CONFIG_SCHEMA = switch.switch_schema( PCM5122PowerSwitch, diff --git a/esphome/components/pm2005/sensor.py b/esphome/components/pm2005/sensor.py index a0c133eba10..0e36992141b 100644 --- a/esphome/components/pm2005/sensor.py +++ b/esphome/components/pm2005/sensor.py @@ -27,10 +27,7 @@ PM2005Component = pm2005_ns.class_( ) SensorType = pm2005_ns.enum("SensorType") -SENSOR_TYPE = { - "PM2005": SensorType.PM2005, - "PM2105": SensorType.PM2105, -} +SENSOR_TYPE = SensorType.options("PM2005", "PM2105") CONFIG_SCHEMA = cv.All( diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 4cf88bafe9f..46aa87a8e11 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -28,11 +28,9 @@ CONF_USE_PCNT = "use_pcnt" pulse_counter_ns = cg.esphome_ns.namespace("pulse_counter") PulseCounterCountMode = pulse_counter_ns.enum("PulseCounterCountMode") -COUNT_MODES = { - "DISABLE": PulseCounterCountMode.PULSE_COUNTER_DISABLE, - "INCREMENT": PulseCounterCountMode.PULSE_COUNTER_INCREMENT, - "DECREMENT": PulseCounterCountMode.PULSE_COUNTER_DECREMENT, -} +COUNT_MODES = PulseCounterCountMode.options( + "DISABLE", "INCREMENT", "DECREMENT", prefix="PULSE_COUNTER_" +) COUNT_MODE_SCHEMA = cv.enum(COUNT_MODES, upper=True) diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index e523ebe7569..05708c65c13 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -32,10 +32,7 @@ PulseMeterSensor = pulse_meter_ns.class_( ) PulseMeterInternalFilterMode = PulseMeterSensor.enum("InternalFilterMode") -FILTER_MODES = { - "EDGE": PulseMeterInternalFilterMode.FILTER_EDGE, - "PULSE": PulseMeterInternalFilterMode.FILTER_PULSE, -} +FILTER_MODES = PulseMeterInternalFilterMode.options("EDGE", "PULSE", prefix="FILTER_") def validate_internal_filter(value: Any) -> TimePeriodMicroseconds: diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py index 5e883acd7e1..7788caca442 100644 --- a/esphome/components/pzem6l24/sensor.py +++ b/esphome/components/pzem6l24/sensor.py @@ -40,13 +40,9 @@ PZEM6L24 = pzem6l24_ns.class_( ) ResetPhase = pzem6l24_ns.enum("ResetPhase") -RESET_PHASE_OPTIONS = { - "all": ResetPhase.RESET_PHASE_ALL, - "a": ResetPhase.RESET_PHASE_A, - "b": ResetPhase.RESET_PHASE_B, - "c": ResetPhase.RESET_PHASE_C, - "combined": ResetPhase.RESET_PHASE_COMBINED, -} +RESET_PHASE_OPTIONS = ResetPhase.options( + "all", "a", "b", "c", "combined", prefix="RESET_PHASE_" +) # Combined config keys CONF_TOTAL_ACTIVE_POWER = "total_active_power" diff --git a/esphome/components/qmi8658/motion.py b/esphome/components/qmi8658/motion.py index 2fd12dc9216..2d5a83a1f1b 100644 --- a/esphome/components/qmi8658/motion.py +++ b/esphome/components/qmi8658/motion.py @@ -14,12 +14,9 @@ from . import QMI8658Component, qmi8658_ns # Enum proxies (must match the C++ enum values exactly) QMI8658AccelRange = qmi8658_ns.enum("QMI8658AccelRange") -ACCEL_RANGE_OPTIONS = { - "2G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_2G, - "4G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_4G, - "8G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_8G, - "16G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = QMI8658AccelRange.options( + "2G", "4G", "8G", "16G", prefix="QMI8658_ACCEL_RANGE_" +) QMI8658GyroRange = qmi8658_ns.enum("QMI8658GyroRange") GYRO_RANGE_OPTIONS = { diff --git a/esphome/components/qmp6988/sensor.py b/esphome/components/qmp6988/sensor.py index abc39176c14..955e7026062 100644 --- a/esphome/components/qmp6988/sensor.py +++ b/esphome/components/qmp6988/sensor.py @@ -35,14 +35,9 @@ OVERSAMPLING_OPTIONS = { } QMP6988IIRFilter = qmp6988_ns.enum("QMP6988IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": QMP6988IIRFilter.QMP6988_IIR_FILTER_OFF, - "2X": QMP6988IIRFilter.QMP6988_IIR_FILTER_2X, - "4X": QMP6988IIRFilter.QMP6988_IIR_FILTER_4X, - "8X": QMP6988IIRFilter.QMP6988_IIR_FILTER_8X, - "16X": QMP6988IIRFilter.QMP6988_IIR_FILTER_16X, - "32X": QMP6988IIRFilter.QMP6988_IIR_FILTER_32X, -} +IIR_FILTER_OPTIONS = QMP6988IIRFilter.options( + "OFF", "2X", "4X", "8X", "16X", "32X", prefix="QMP6988_IIR_FILTER_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py index 356d78469fe..460e097b437 100644 --- a/esphome/components/qr_code/__init__.py +++ b/esphome/components/qr_code/__init__.py @@ -16,12 +16,9 @@ qr_code_ns = cg.esphome_ns.namespace("qr_code") QRCode = qr_code_ns.class_("QrCode", cg.Component) qrcodegen_Ecc = cg.esphome_ns.enum("qrcodegen_Ecc") -ECC = { - "LOW": qrcodegen_Ecc.qrcodegen_Ecc_LOW, - "MEDIUM": qrcodegen_Ecc.qrcodegen_Ecc_MEDIUM, - "QUARTILE": qrcodegen_Ecc.qrcodegen_Ecc_QUARTILE, - "HIGH": qrcodegen_Ecc.qrcodegen_Ecc_HIGH, -} +ECC = qrcodegen_Ecc.options( + "LOW", "MEDIUM", "QUARTILE", "HIGH", prefix="qrcodegen_Ecc_" +) CONFIG_SCHEMA = cv.ensure_list( cv.Schema( diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 286d209e980..0e0a63e9d94 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -2565,15 +2565,16 @@ register_protocol_action( ) = declare_protocol("Hob2Hood") Hob2HoodCommand = remote_base_ns.enum("Hob2HoodCommand") -HOB2HOOD_COMMAND_OPTIONS = { - "light_off": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_OFF, - "light_on": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_ON, - "fan_off": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_OFF, - "fan_low": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_LOW, - "fan_medium": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MEDIUM, - "fan_high": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_HIGH, - "fan_max": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MAX, -} +HOB2HOOD_COMMAND_OPTIONS = Hob2HoodCommand.options( + "light_off", + "light_on", + "fan_off", + "fan_low", + "fan_medium", + "fan_high", + "fan_max", + prefix="HOB2HOOD_COMMAND_", +) HOB2HOOD_SCHEMA = cv.Schema( {cv.Required(CONF_COMMAND): cv.enum(HOB2HOOD_COMMAND_OPTIONS, lower=True)} @@ -2841,29 +2842,19 @@ register_protocol_action( ) = declare_protocol("IRA211") IRA211CommandEnum = ns.enum("IRA211Command", is_class=True) -IRA211_COMMANDS = { - "TEMP_UP": IRA211CommandEnum.IRA211_COMMAND_TEMP_UP, - "TEMP_DOWN": IRA211CommandEnum.IRA211_COMMAND_TEMP_DOWN, - "MODE": IRA211CommandEnum.IRA211_COMMAND_MODE, - "FAN": IRA211CommandEnum.IRA211_COMMAND_FAN, - "POWER": IRA211CommandEnum.IRA211_COMMAND_POWER, - "SYNC": IRA211CommandEnum.IRA211_COMMAND_SYNC, -} +IRA211_COMMANDS = IRA211CommandEnum.options( + "TEMP_UP", "TEMP_DOWN", "MODE", "FAN", "POWER", "SYNC", prefix="IRA211_COMMAND_" +) IRA211ModeEnum = ns.enum("IRA211Mode", is_class=True) -IRA211_MODES = { - "PROTECTION": IRA211ModeEnum.IRA211_MODE_PROTECTION, - "TIMER": IRA211ModeEnum.IRA211_MODE_TIMER, - "COMFORT": IRA211ModeEnum.IRA211_MODE_COMFORT, -} +IRA211_MODES = IRA211ModeEnum.options( + "PROTECTION", "TIMER", "COMFORT", prefix="IRA211_MODE_" +) IRA211FanEnum = ns.enum("IRA211Fan", is_class=True) -IRA211_FAN_SPEEDS = { - "AUTO": IRA211FanEnum.IRA211_FAN_AUTO, - "LOW": IRA211FanEnum.IRA211_FAN_LOW, - "MEDIUM": IRA211FanEnum.IRA211_FAN_MEDIUM, - "HIGH": IRA211FanEnum.IRA211_FAN_HIGH, -} +IRA211_FAN_SPEEDS = IRA211FanEnum.options( + "AUTO", "LOW", "MEDIUM", "HIGH", prefix="IRA211_FAN_" +) def _half_degree_step(value: float) -> float: diff --git a/esphome/components/resistance/sensor.py b/esphome/components/resistance/sensor.py index 1333375717e..cbf329713df 100644 --- a/esphome/components/resistance/sensor.py +++ b/esphome/components/resistance/sensor.py @@ -17,10 +17,7 @@ CONF_CONFIGURATION = "configuration" CONF_RESISTOR = "resistor" ResistanceConfiguration = resistance_ns.enum("ResistanceConfiguration") -CONFIGURATIONS = { - "DOWNSTREAM": ResistanceConfiguration.DOWNSTREAM, - "UPSTREAM": ResistanceConfiguration.UPSTREAM, -} +CONFIGURATIONS = ResistanceConfiguration.options("DOWNSTREAM", "UPSTREAM") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index ab5442e6764..31391d9c6ac 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -20,10 +20,9 @@ from esphome.types import ConfigType rotary_encoder_ns = cg.esphome_ns.namespace("rotary_encoder") RotaryEncoderRestoreMode = rotary_encoder_ns.enum("RotaryEncoderRestoreMode") -RESTORE_MODES = { - "RESTORE_DEFAULT_ZERO": RotaryEncoderRestoreMode.ROTARY_ENCODER_RESTORE_DEFAULT_ZERO, - "ALWAYS_ZERO": RotaryEncoderRestoreMode.ROTARY_ENCODER_ALWAYS_ZERO, -} +RESTORE_MODES = RotaryEncoderRestoreMode.options( + "RESTORE_DEFAULT_ZERO", "ALWAYS_ZERO", prefix="ROTARY_ENCODER_" +) RotaryEncoderResolution = rotary_encoder_ns.enum("RotaryEncoderResolution") RESOLUTIONS = { diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 5b7259f9e51..53a9d438292 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -143,12 +143,7 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( Chipset = rp2040_pio_led_strip_ns.enum("Chipset") -CHIPSETS = { - "WS2812": Chipset.CHIPSET_WS2812, - "WS2812B": Chipset.CHIPSET_WS2812B, - "SK6812": Chipset.CHIPSET_SK6812, - "SM16703": Chipset.CHIPSET_SM16703, -} +CHIPSETS = Chipset.options("WS2812", "WS2812B", "SK6812", "SM16703", prefix="CHIPSET_") @dataclass diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 71be6e43b16..e78ae2a6b22 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -37,12 +37,9 @@ SCD4XComponent = scd4x_ns.class_( "SCD4XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) MeasurementMode = scd4x_ns.enum("MEASUREMENT_MODE") -MEASUREMENT_MODE_OPTIONS = { - "periodic": MeasurementMode.PERIODIC, - "low_power_periodic": MeasurementMode.LOW_POWER_PERIODIC, - "single_shot": MeasurementMode.SINGLE_SHOT, - "single_shot_rht_only": MeasurementMode.SINGLE_SHOT_RHT_ONLY, -} +MEASUREMENT_MODE_OPTIONS = MeasurementMode.options( + "periodic", "low_power_periodic", "single_shot", "single_shot_rht_only" +) CONFIG_SCHEMA = ( diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 7b9f616460b..68fe39b6bcb 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -41,12 +41,9 @@ SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) # Enums SelectOperation = select_ns.enum("SelectOperation") -SELECT_OPERATION_OPTIONS = { - "NEXT": SelectOperation.SELECT_OP_NEXT, - "PREVIOUS": SelectOperation.SELECT_OP_PREVIOUS, - "FIRST": SelectOperation.SELECT_OP_FIRST, - "LAST": SelectOperation.SELECT_OP_LAST, -} +SELECT_OPERATION_OPTIONS = SelectOperation.options( + "NEXT", "PREVIOUS", "FIRST", "LAST", prefix="SELECT_OP_" +) _SELECT_SCHEMA = ( diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 7bfce0f26a5..d45bb2c7916 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -66,11 +66,7 @@ ACCELERATION_MODES = { "high": RhtAccelerationMode.HIGH_ACCELERATION, } -MODELS = { - "SEN50": Sen5xType.SEN50, - "SEN54": Sen5xType.SEN54, - "SEN55": Sen5xType.SEN55, -} +MODELS = Sen5xType.options("SEN50", "SEN54", "SEN55") def _gas_sensor( diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py index 24aa2d57346..bd492b0d0b6 100644 --- a/esphome/components/sendspin/text_sensor/__init__.py +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -29,12 +29,9 @@ SendspinPairingCodeTextSensor = sendspin_ns.class_( ) SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True) -SENDSPIN_TEXT_METADATA_TYPES = { - "title": SendspinTextMetadataTypes.TITLE, - "artist": SendspinTextMetadataTypes.ARTIST, - "album": SendspinTextMetadataTypes.ALBUM, - "album_artist": SendspinTextMetadataTypes.ALBUM_ARTIST, -} +SENDSPIN_TEXT_METADATA_TYPES = SendspinTextMetadataTypes.options( + "title", "artist", "album", "album_artist" +) def _request_roles(config: ConfigType) -> ConfigType: diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index d5baac3c9df..6c3327e556f 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -36,12 +36,9 @@ SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap") api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums") SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType") -SERIAL_PROXY_PORT_TYPES = { - "TTL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_TTL, - "RS232": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS232, - "RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485, - "USB_SERIAL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_USB_SERIAL, -} +SERIAL_PROXY_PORT_TYPES = SerialProxyPortType.options( + "TTL", "RS232", "RS485", "USB_SERIAL", prefix="SERIAL_PROXY_PORT_TYPE_" +) CONF_DTR_PIN = "dtr_pin" CONF_PORT_TYPE = "port_type" diff --git a/esphome/components/sht4x/sensor.py b/esphome/components/sht4x/sensor.py index 7fb52e37d4b..d491d94ba14 100644 --- a/esphome/components/sht4x/sensor.py +++ b/esphome/components/sht4x/sensor.py @@ -27,26 +27,21 @@ SHT4XComponent = sht4x_ns.class_( CONF_PRECISION = "precision" SHT4XPRECISION = sht4x_ns.enum("SHT4XPRECISION") -PRECISION_OPTIONS = { - "High": SHT4XPRECISION.SHT4X_PRECISION_HIGH, - "Med": SHT4XPRECISION.SHT4X_PRECISION_MED, - "Low": SHT4XPRECISION.SHT4X_PRECISION_LOW, -} +PRECISION_OPTIONS = SHT4XPRECISION.options( + "High", "Med", "Low", prefix="SHT4X_PRECISION_" +) CONF_HEATER_POWER = "heater_power" SHT4XHEATERPOWER = sht4x_ns.enum("SHT4XHEATERPOWER") -HEATER_POWER_OPTIONS = { - "High": SHT4XHEATERPOWER.SHT4X_HEATERPOWER_HIGH, - "Med": SHT4XHEATERPOWER.SHT4X_HEATERPOWER_MED, - "Low": SHT4XHEATERPOWER.SHT4X_HEATERPOWER_LOW, -} +HEATER_POWER_OPTIONS = SHT4XHEATERPOWER.options( + "High", "Med", "Low", prefix="SHT4X_HEATERPOWER_" +) CONF_HEATER_TIME = "heater_time" SHT4XHEATERTIME = sht4x_ns.enum("SHT4XHEATERTIME") -HEATER_TIME_OPTIONS = { - "Long": SHT4XHEATERTIME.SHT4X_HEATERTIME_LONG, - "Short": SHT4XHEATERTIME.SHT4X_HEATERTIME_SHORT, -} +HEATER_TIME_OPTIONS = SHT4XHEATERTIME.options( + "Long", "Short", prefix="SHT4X_HEATERTIME_" +) CONF_HEATER_MAX_DUTY = "heater_max_duty" diff --git a/esphome/components/sm2135/__init__.py b/esphome/components/sm2135/__init__.py index c42ce4c8b3f..7c5622c041b 100644 --- a/esphome/components/sm2135/__init__.py +++ b/esphome/components/sm2135/__init__.py @@ -17,29 +17,31 @@ CONF_SEPARATE_MODES = "separate_modes" SM2135Current = sm2135_ns.enum("SM2135Current") -DRIVE_STRENGTHS_CW = { - "10mA": SM2135Current.SM2135_CURRENT_10MA, - "15mA": SM2135Current.SM2135_CURRENT_15MA, - "20mA": SM2135Current.SM2135_CURRENT_20MA, - "25mA": SM2135Current.SM2135_CURRENT_25MA, - "30mA": SM2135Current.SM2135_CURRENT_30MA, - "35mA": SM2135Current.SM2135_CURRENT_35MA, - "40mA": SM2135Current.SM2135_CURRENT_40MA, - "45mA": SM2135Current.SM2135_CURRENT_45MA, - "50mA": SM2135Current.SM2135_CURRENT_50MA, - "55mA": SM2135Current.SM2135_CURRENT_55MA, - "60mA": SM2135Current.SM2135_CURRENT_60MA, -} -DRIVE_STRENGTHS_RGB = { - "10mA": SM2135Current.SM2135_CURRENT_10MA, - "15mA": SM2135Current.SM2135_CURRENT_15MA, - "20mA": SM2135Current.SM2135_CURRENT_20MA, - "25mA": SM2135Current.SM2135_CURRENT_25MA, - "30mA": SM2135Current.SM2135_CURRENT_30MA, - "35mA": SM2135Current.SM2135_CURRENT_35MA, - "40mA": SM2135Current.SM2135_CURRENT_40MA, - "45mA": SM2135Current.SM2135_CURRENT_45MA, -} +DRIVE_STRENGTHS_CW = SM2135Current.options( + "10mA", + "15mA", + "20mA", + "25mA", + "30mA", + "35mA", + "40mA", + "45mA", + "50mA", + "55mA", + "60mA", + prefix="SM2135_CURRENT_", +) +DRIVE_STRENGTHS_RGB = SM2135Current.options( + "10mA", + "15mA", + "20mA", + "25mA", + "30mA", + "35mA", + "40mA", + "45mA", + prefix="SM2135_CURRENT_", +) MULTI_CONF = True diff --git a/esphome/components/spa06_base/__init__.py b/esphome/components/spa06_base/__init__.py index c08d92f9d8b..d12f443b8ce 100644 --- a/esphome/components/spa06_base/__init__.py +++ b/esphome/components/spa06_base/__init__.py @@ -24,24 +24,25 @@ DOMAIN = "spa06_base" spa06_ns = cg.esphome_ns.namespace("spa06_base") SampleRate = spa06_ns.enum("SampleRate") -SAMPLE_RATE_OPTIONS = { - "1": SampleRate.SAMPLE_RATE_1, - "2": SampleRate.SAMPLE_RATE_2, - "4": SampleRate.SAMPLE_RATE_4, - "8": SampleRate.SAMPLE_RATE_8, - "16": SampleRate.SAMPLE_RATE_16, - "32": SampleRate.SAMPLE_RATE_32, - "64": SampleRate.SAMPLE_RATE_64, - "128": SampleRate.SAMPLE_RATE_128, - "25p16": SampleRate.SAMPLE_RATE_25P16, - "25p8": SampleRate.SAMPLE_RATE_25P8, - "25p4": SampleRate.SAMPLE_RATE_25P4, - "25p2": SampleRate.SAMPLE_RATE_25P2, - "25": SampleRate.SAMPLE_RATE_25, - "50": SampleRate.SAMPLE_RATE_50, - "100": SampleRate.SAMPLE_RATE_100, - "200": SampleRate.SAMPLE_RATE_200, -} +SAMPLE_RATE_OPTIONS = SampleRate.options( + "1", + "2", + "4", + "8", + "16", + "32", + "64", + "128", + "25p16", + "25p8", + "25p4", + "25p2", + "25", + "50", + "100", + "200", + prefix="SAMPLE_RATE_", +) Oversampling = spa06_ns.enum("Oversampling") OVERSAMPLING_OPTIONS = { diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 159af158086..b37a288d874 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -57,10 +57,7 @@ SpeakerMediaPlayer = speaker_ns.class_( AudioPipeline = speaker_ns.class_("AudioPipeline") AudioPipelineType = speaker_ns.enum("AudioPipelineType", is_class=True) -AUDIO_PIPELINE_TYPE_ENUM = { - "MEDIA": AudioPipelineType.MEDIA, - "ANNOUNCEMENT": AudioPipelineType.ANNOUNCEMENT, -} +AUDIO_PIPELINE_TYPE_ENUM = AudioPipelineType.options("MEDIA", "ANNOUNCEMENT") StopStreamAction = speaker_ns.class_( "StopStreamAction", automation.Action, cg.Parented.template(SpeakerMediaPlayer) diff --git a/esphome/components/spi_device/__init__.py b/esphome/components/spi_device/__init__.py index 3f64dfc3d16..d841987fdc3 100644 --- a/esphome/components/spi_device/__init__.py +++ b/esphome/components/spi_device/__init__.py @@ -14,10 +14,7 @@ spi_device_ns = cg.esphome_ns.namespace("spi_device") spi_device = spi_device_ns.class_("SPIDeviceComponent", cg.Component, spi.SPIDevice) BitOrder = spi.spi_ns.enum("SPIBitOrder") -ORDERS = { - "msb_first": BitOrder.BIT_ORDER_MSB_FIRST, - "lsb_first": BitOrder.BIT_ORDER_LSB_FIRST, -} +ORDERS = BitOrder.options("msb_first", "lsb_first", prefix="BIT_ORDER_") CONF_BIT_ORDER = "bit_order" CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 16d7ef8e862..2805837ee01 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -54,10 +54,7 @@ ST7701S = st7701s_ns.class_("ST7701S", display.Display, cg.Component, spi.SPIDev ColorOrder = display.display_ns.enum("ColorMode") ST7701S_DELAY_FLAG = 0xFF -COLOR_ORDERS = { - "RGB": ColorOrder.COLOR_ORDER_RGB, - "BGR": ColorOrder.COLOR_ORDER_BGR, -} +COLOR_ORDERS = ColorOrder.options("RGB", "BGR", prefix="COLOR_ORDER_") DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py index 212fe6bb489..4385164384a 100644 --- a/esphome/components/stcc4/sensor.py +++ b/esphome/components/stcc4/sensor.py @@ -36,10 +36,7 @@ STCC4Component = stcc4_ns.class_( MeasurementMode = stcc4_ns.enum("MeasurementMode", is_class=True) -MEASUREMENT_MODE_OPTIONS = { - "continuous": MeasurementMode.CONTINUOUS, - "single_shot": MeasurementMode.SINGLE_SHOT, -} +MEASUREMENT_MODE_OPTIONS = MeasurementMode.options("continuous", "single_shot") def validate_config(config: ConfigType) -> ConfigType: diff --git a/esphome/components/sun/sensor/__init__.py b/esphome/components/sun/sensor/__init__.py index d2e9fa750dc..ef4e51d5e5e 100644 --- a/esphome/components/sun/sensor/__init__.py +++ b/esphome/components/sun/sensor/__init__.py @@ -15,10 +15,7 @@ DEPENDENCIES = ["sun"] SunSensor = sun_ns.class_("SunSensor", sensor.Sensor, cg.PollingComponent) SensorType = sun_ns.enum("SensorType") -TYPES = { - "elevation": SensorType.SUN_SENSOR_ELEVATION, - "azimuth": SensorType.SUN_SENSOR_AZIMUTH, -} +TYPES = SensorType.options("elevation", "azimuth", prefix="SUN_SENSOR_") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index c8b920d6cb7..075aad534e7 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -89,29 +89,27 @@ BW = { "500_0kHz": SX126xBw.SX126X_BW_500000, } -CODING_RATE = { - "CR_4_5": SX126xLoraCr.LORA_CR_4_5, - "CR_4_6": SX126xLoraCr.LORA_CR_4_6, - "CR_4_7": SX126xLoraCr.LORA_CR_4_7, - "CR_4_8": SX126xLoraCr.LORA_CR_4_8, -} +CODING_RATE = SX126xLoraCr.options( + "CR_4_5", "CR_4_6", "CR_4_7", "CR_4_8", prefix="LORA_" +) MOD = { "LORA": SX126xPacketType.PACKET_TYPE_LORA, "FSK": SX126xPacketType.PACKET_TYPE_GFSK, } -TCXO_VOLTAGE = { - "1_6V": SX126xTcxoCtrl.TCXO_CTRL_1_6V, - "1_7V": SX126xTcxoCtrl.TCXO_CTRL_1_7V, - "1_8V": SX126xTcxoCtrl.TCXO_CTRL_1_8V, - "2_2V": SX126xTcxoCtrl.TCXO_CTRL_2_2V, - "2_4V": SX126xTcxoCtrl.TCXO_CTRL_2_4V, - "2_7V": SX126xTcxoCtrl.TCXO_CTRL_2_7V, - "3_0V": SX126xTcxoCtrl.TCXO_CTRL_3_0V, - "3_3V": SX126xTcxoCtrl.TCXO_CTRL_3_3V, - "NONE": SX126xTcxoCtrl.TCXO_CTRL_NONE, -} +TCXO_VOLTAGE = SX126xTcxoCtrl.options( + "1_6V", + "1_7V", + "1_8V", + "2_2V", + "2_4V", + "2_7V", + "3_0V", + "3_3V", + "NONE", + prefix="TCXO_CTRL_", +) RAMP = { "10us": SX126xRampTime.PA_RAMP_10, diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index ea7fe38ba3c..4b54c646338 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -82,16 +82,9 @@ CODING_RATE = { "CR_4_8": SX127xModemCfg1.CODING_RATE_4_8, } -MOD = { - "LORA": SX127xOpMode.MOD_LORA, - "FSK": SX127xOpMode.MOD_FSK, - "OOK": SX127xOpMode.MOD_OOK, -} +MOD = SX127xOpMode.options("LORA", "FSK", "OOK", prefix="MOD_") -PA_PIN = { - "RFO": SX127xPaConfig.PA_PIN_RFO, - "BOOST": SX127xPaConfig.PA_PIN_BOOST, -} +PA_PIN = SX127xPaConfig.options("RFO", "BOOST", prefix="PA_PIN_") RAMP = { "10us": SX127xPaRamp.PA_RAMP_10, diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index c1c770aa591..7ed2b4fe869 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -27,12 +27,9 @@ SY6970Listener = sy6970_ns.class_("SY6970Listener") I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout") # REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on -I2C_WATCHDOG_TIMEOUTS = { - "DISABLED": I2CWatchdogTimeout.I2C_WATCHDOG_DISABLED, - "40S": I2CWatchdogTimeout.I2C_WATCHDOG_40S, - "80S": I2CWatchdogTimeout.I2C_WATCHDOG_80S, - "160S": I2CWatchdogTimeout.I2C_WATCHDOG_160S, -} +I2C_WATCHDOG_TIMEOUTS = I2CWatchdogTimeout.options( + "DISABLED", "40S", "80S", "160S", prefix="I2C_WATCHDOG_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/syslog/__init__.py b/esphome/components/syslog/__init__.py index b48192ddd29..f1393e9fbd7 100644 --- a/esphome/components/syslog/__init__.py +++ b/esphome/components/syslog/__init__.py @@ -17,10 +17,7 @@ syslog_ns = cg.esphome_ns.namespace("syslog") Syslog = syslog_ns.class_("Syslog", Component, Parented.template(udp.UDPComponent)) SyslogFormat = syslog_ns.enum("SyslogFormat") -SYSLOG_FORMATS = { - "RFC3164": SyslogFormat.SYSLOG_FORMAT_RFC3164, - "RFC5424": SyslogFormat.SYSLOG_FORMAT_RFC5424, -} +SYSLOG_FORMATS = SyslogFormat.options("RFC3164", "RFC5424", prefix="SYSLOG_FORMAT_") CONF_STRIP = "strip" CONF_FACILITY = "facility" diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 7b1e3e239e8..94d37832eb4 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -32,19 +32,12 @@ tas58xx_ns = cg.esphome_ns.namespace("tas58xx") TAS58xx = tas58xx_ns.class_("TAS58xx", AudioDac, cg.PollingComponent, i2c.I2CDevice) DacMode = tas58xx_ns.enum("DacMode") -DAC_MODES = { - "btl": DacMode.DAC_MODE_BTL, - "pbtl": DacMode.DAC_MODE_PBTL, -} +DAC_MODES = DacMode.options("btl", "pbtl", prefix="DAC_MODE_") MixerMode = tas58xx_ns.enum("MixerMode") -MIXER_MODES = { - "stereo": MixerMode.MIXER_MODE_STEREO, - "stereo_inverse": MixerMode.MIXER_MODE_STEREO_INVERSE, - "mono": MixerMode.MIXER_MODE_MONO, - "left": MixerMode.MIXER_MODE_LEFT, - "right": MixerMode.MIXER_MODE_RIGHT, -} +MIXER_MODES = MixerMode.options( + "stereo", "stereo_inverse", "mono", "left", "right", prefix="MIXER_MODE_" +) @dataclass(frozen=True) diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index e19d76a64ae..de327f0eba7 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -32,35 +32,31 @@ TCS34725Component = tcs34725_ns.class_( ) TCS34725IntegrationTime = tcs34725_ns.enum("TCS34725IntegrationTime") -TCS34725_INTEGRATION_TIMES = { - "auto": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_AUTO, - "2.4ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_2_4MS, - "24ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_24MS, - "50ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_50MS, - "101ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_101MS, - "120ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_120MS, - "154ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_154MS, - "180ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_180MS, - "199ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_199MS, - "240ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_240MS, - "300ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_300MS, - "360ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_360MS, - "401ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_401MS, - "420ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_420MS, - "480ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_480MS, - "499ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_499MS, - "540ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_540MS, - "600ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_600MS, - "614ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_614MS, -} +TCS34725_INTEGRATION_TIMES = TCS34725IntegrationTime.options( + "auto", + "2.4ms", + "24ms", + "50ms", + "101ms", + "120ms", + "154ms", + "180ms", + "199ms", + "240ms", + "300ms", + "360ms", + "401ms", + "420ms", + "480ms", + "499ms", + "540ms", + "600ms", + "614ms", + prefix="TCS34725_INTEGRATION_TIME_", +) TCS34725Gain = tcs34725_ns.enum("TCS34725Gain") -TCS34725_GAINS = { - "1X": TCS34725Gain.TCS34725_GAIN_1X, - "4X": TCS34725Gain.TCS34725_GAIN_4X, - "16X": TCS34725Gain.TCS34725_GAIN_16X, - "60X": TCS34725Gain.TCS34725_GAIN_60X, -} +TCS34725_GAINS = TCS34725Gain.options("1X", "4X", "16X", "60X", prefix="TCS34725_GAIN_") color_channel_schema = sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, diff --git a/esphome/components/template/alarm_control_panel/__init__.py b/esphome/components/template/alarm_control_panel/__init__.py index a708c83d09f..ec59facb2cf 100644 --- a/esphome/components/template/alarm_control_panel/__init__.py +++ b/esphome/components/template/alarm_control_panel/__init__.py @@ -43,19 +43,19 @@ TemplateAlarmControlPanel = template_ns.class_( TemplateAlarmControlPanelRestoreMode = template_ns.enum( "TemplateAlarmControlPanelRestoreMode" ) -RESTORE_MODES = { - "ALWAYS_DISARMED": TemplateAlarmControlPanelRestoreMode.ALARM_CONTROL_PANEL_ALWAYS_DISARMED, - "RESTORE_DEFAULT_DISARMED": TemplateAlarmControlPanelRestoreMode.ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED, -} +RESTORE_MODES = TemplateAlarmControlPanelRestoreMode.options( + "ALWAYS_DISARMED", "RESTORE_DEFAULT_DISARMED", prefix="ALARM_CONTROL_PANEL_" +) AlarmSensorType = template_ns.enum("AlarmSensorType") -ALARM_SENSOR_TYPES = { - "DELAYED": AlarmSensorType.ALARM_SENSOR_TYPE_DELAYED, - "INSTANT": AlarmSensorType.ALARM_SENSOR_TYPE_INSTANT, - "DELAYED_FOLLOWER": AlarmSensorType.ALARM_SENSOR_TYPE_DELAYED_FOLLOWER, - "INSTANT_ALWAYS": AlarmSensorType.ALARM_SENSOR_TYPE_INSTANT_ALWAYS, -} +ALARM_SENSOR_TYPES = AlarmSensorType.options( + "DELAYED", + "INSTANT", + "DELAYED_FOLLOWER", + "INSTANT_ALWAYS", + prefix="ALARM_SENSOR_TYPE_", +) def validate_config(config): diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index 5f7627c5c38..30155de28aa 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -55,10 +55,9 @@ TemplateClimate = template_ns.class_("TemplateClimate", climate.Climate, cg.Comp TemplateClimateRestoreMode = template_ns.enum( "TemplateClimateRestoreMode", is_class=True ) -CLIMATE_RESTORE_MODES = { - "NO_RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE, - "RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE, -} +CLIMATE_RESTORE_MODES = TemplateClimateRestoreMode.options( + "NO_RESTORE", "RESTORE", prefix="TEMPLATE_CLIMATE_RESTORE_MODE_" +) # Per-field actions that forward a requested value on. The third item is the type of `x`. SET_ACTIONS = ( diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 39df5affcf2..e47fdd9a56d 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -26,11 +26,9 @@ from .. import template_ns TemplateCover = template_ns.class_("TemplateCover", cover.Cover, cg.Component) TemplateCoverRestoreMode = template_ns.enum("TemplateCoverRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TemplateCoverRestoreMode.COVER_NO_RESTORE, - "RESTORE": TemplateCoverRestoreMode.COVER_RESTORE, - "RESTORE_AND_CALL": TemplateCoverRestoreMode.COVER_RESTORE_AND_CALL, -} +RESTORE_MODES = TemplateCoverRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="COVER_" +) CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index a7fd6f7632c..a0b8e9c7c51 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -22,11 +22,9 @@ from .. import template_ns TemplateValve = template_ns.class_("TemplateValve", valve.Valve, cg.Component) TemplateValveRestoreMode = template_ns.enum("TemplateValveRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TemplateValveRestoreMode.VALVE_NO_RESTORE, - "RESTORE": TemplateValveRestoreMode.VALVE_RESTORE, - "RESTORE_AND_CALL": TemplateValveRestoreMode.VALVE_RESTORE_AND_CALL, -} +RESTORE_MODES = TemplateValveRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="VALVE_" +) CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index 7f8a82c916d..39e3c56da5f 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -32,11 +32,9 @@ TemplateWaterHeaterPublishAction = template_ns.class_( ) TemplateWaterHeaterRestoreMode = template_ns.enum("TemplateWaterHeaterRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TemplateWaterHeaterRestoreMode.WATER_HEATER_NO_RESTORE, - "RESTORE": TemplateWaterHeaterRestoreMode.WATER_HEATER_RESTORE, - "RESTORE_AND_CALL": TemplateWaterHeaterRestoreMode.WATER_HEATER_RESTORE_AND_CALL, -} +RESTORE_MODES = TemplateWaterHeaterRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="WATER_HEATER_" +) CONFIG_SCHEMA = ( water_heater.water_heater_schema(TemplateWaterHeater) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 6d8c24918d3..bebdd7d0660 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -96,22 +96,20 @@ ThermostatClimateTargetTempConfig = thermostat_ns.struct( ThermostatPresetEntry = thermostat_ns.struct("ThermostatPresetEntry") ThermostatCustomPresetEntry = thermostat_ns.struct("ThermostatCustomPresetEntry") OnBootRestoreFrom = thermostat_ns.enum("OnBootRestoreFrom") -ON_BOOT_RESTORE_FROM = { - "MEMORY": OnBootRestoreFrom.MEMORY, - "DEFAULT_PRESET": OnBootRestoreFrom.DEFAULT_PRESET, -} +ON_BOOT_RESTORE_FROM = OnBootRestoreFrom.options("MEMORY", "DEFAULT_PRESET") validate_on_boot_restore_from = cv.enum(ON_BOOT_RESTORE_FROM, upper=True) ClimateMode = climate_ns.enum("ClimateMode") -CLIMATE_MODES = { - "OFF": ClimateMode.CLIMATE_MODE_OFF, - "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, - "COOL": ClimateMode.CLIMATE_MODE_COOL, - "HEAT": ClimateMode.CLIMATE_MODE_HEAT, - "DRY": ClimateMode.CLIMATE_MODE_DRY, - "FAN_ONLY": ClimateMode.CLIMATE_MODE_FAN_ONLY, - "AUTO": ClimateMode.CLIMATE_MODE_AUTO, -} +CLIMATE_MODES = ClimateMode.options( + "OFF", + "HEAT_COOL", + "COOL", + "HEAT", + "DRY", + "FAN_ONLY", + "AUTO", + prefix="CLIMATE_MODE_", +) validate_climate_mode = cv.enum(CLIMATE_MODES, upper=True) ClimatePreset = climate_ns.enum("ClimatePreset") diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index 82ac29c7d5b..ce06cb82e3c 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -59,12 +59,13 @@ TMC22XXStepper = tmc22xx_ns.class_( StandstillMode = tmc22xx_ns.enum("StandstillMode") DriverEvent = tmc22xx_ns.enum("DriverEvent") -STANDSTILL_MODES = { - "normal": StandstillMode.STANDSTILL_MODE_NORMAL, - "freewheeling": StandstillMode.STANDSTILL_MODE_FREEWHEELING, - "coil_short_ls": StandstillMode.STANDSTILL_MODE_COIL_SHORT_LS, - "coil_short_hs": StandstillMode.STANDSTILL_MODE_COIL_SHORT_HS, -} +STANDSTILL_MODES = StandstillMode.options( + "normal", + "freewheeling", + "coil_short_ls", + "coil_short_hs", + prefix="STANDSTILL_MODE_", +) validate_microsteps = cv.one_of(1, 2, 4, 8, 16, 32, 64, 128, 256, int=True) validate_current = cv.All(cv.current, cv.positive_float) diff --git a/esphome/components/tmp1075/sensor.py b/esphome/components/tmp1075/sensor.py index ab510db0c53..09ac80aa4f9 100644 --- a/esphome/components/tmp1075/sensor.py +++ b/esphome/components/tmp1075/sensor.py @@ -32,10 +32,7 @@ POLARITY = { } EAlertFunction = tmp1075_ns.enum("EAlertFunction") -ALERT_FUNCTION = { - "COMPARATOR": EAlertFunction.ALERT_COMPARATOR, - "INTERRUPT": EAlertFunction.ALERT_INTERRUPT, -} +ALERT_FUNCTION = EAlertFunction.options("COMPARATOR", "INTERRUPT", prefix="ALERT_") CONF_ALERT = "alert" CONF_LIMIT_LOW = "limit_low" diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 69fbb210305..1a17045a410 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -11,14 +11,15 @@ toshiba_ns = cg.esphome_ns.namespace("toshiba") ToshibaClimate = toshiba_ns.class_("ToshibaClimate", climate_ir.ClimateIR) Model = toshiba_ns.enum("Model") -MODELS = { - "GENERIC": Model.MODEL_GENERIC, - "RAC-PT1411HWRU-C": Model.MODEL_RAC_PT1411HWRU_C, - "RAC-PT1411HWRU-F": Model.MODEL_RAC_PT1411HWRU_F, - "RAS-2819T": Model.MODEL_RAS_2819T, - "RAS-B10N3KV2": Model.MODEL_RAS_B10N3KV2, - "SEIYA": Model.MODEL_SEIYA, -} +MODELS = Model.options( + "GENERIC", + "RAC-PT1411HWRU-C", + "RAC-PT1411HWRU-F", + "RAS-2819T", + "RAS-B10N3KV2", + "SEIYA", + prefix="MODEL_", +) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).extend( { diff --git a/esphome/components/total_daily_energy/sensor.py b/esphome/components/total_daily_energy/sensor.py index 343fe917de9..af2f5ff561c 100644 --- a/esphome/components/total_daily_energy/sensor.py +++ b/esphome/components/total_daily_energy/sensor.py @@ -20,11 +20,9 @@ DEPENDENCIES = ["time"] CONF_POWER_ID = "power_id" total_daily_energy_ns = cg.esphome_ns.namespace("total_daily_energy") TotalDailyEnergyMethod = total_daily_energy_ns.enum("TotalDailyEnergyMethod") -TOTAL_DAILY_ENERGY_METHODS = { - "trapezoid": TotalDailyEnergyMethod.TOTAL_DAILY_ENERGY_METHOD_TRAPEZOID, - "left": TotalDailyEnergyMethod.TOTAL_DAILY_ENERGY_METHOD_LEFT, - "right": TotalDailyEnergyMethod.TOTAL_DAILY_ENERGY_METHOD_RIGHT, -} +TOTAL_DAILY_ENERGY_METHODS = TotalDailyEnergyMethod.options( + "trapezoid", "left", "right", prefix="TOTAL_DAILY_ENERGY_METHOD_" +) TotalDailyEnergy = total_daily_energy_ns.class_( "TotalDailyEnergy", sensor.Sensor, cg.Component ) diff --git a/esphome/components/tsl2561/sensor.py b/esphome/components/tsl2561/sensor.py index 79ee2587d33..3771851acc6 100644 --- a/esphome/components/tsl2561/sensor.py +++ b/esphome/components/tsl2561/sensor.py @@ -24,10 +24,7 @@ INTEGRATION_TIMES = { } TSL2561Gain = tsl2561_ns.enum("TSL2561Gain") -GAINS = { - "1X": TSL2561Gain.TSL2561_GAIN_1X, - "16X": TSL2561Gain.TSL2561_GAIN_16X, -} +GAINS = TSL2561Gain.options("1X", "16X", prefix="TSL2561_GAIN_") CONF_IS_CS_PACKAGE = "is_cs_package" diff --git a/esphome/components/tuya/cover/__init__.py b/esphome/components/tuya/cover/__init__.py index 8c610c02720..6e1daef884a 100644 --- a/esphome/components/tuya/cover/__init__.py +++ b/esphome/components/tuya/cover/__init__.py @@ -17,11 +17,9 @@ CONF_INVERT_POSITION_REPORT = "invert_position_report" TuyaCover = tuya_ns.class_("TuyaCover", cover.Cover, cg.Component) TuyaCoverRestoreMode = tuya_ns.enum("TuyaCoverRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TuyaCoverRestoreMode.COVER_NO_RESTORE, - "RESTORE": TuyaCoverRestoreMode.COVER_RESTORE, - "RESTORE_AND_CALL": TuyaCoverRestoreMode.COVER_RESTORE_AND_CALL, -} +RESTORE_MODES = TuyaCoverRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="COVER_" +) def validate_range(config): diff --git a/esphome/components/tuya/light/__init__.py b/esphome/components/tuya/light/__init__.py index bf2d3daf981..5ba55b6ccbb 100644 --- a/esphome/components/tuya/light/__init__.py +++ b/esphome/components/tuya/light/__init__.py @@ -30,11 +30,7 @@ CONF_COLOR_TYPE_LOWERCASE = "color_type_lowercase" TuyaColorType = tuya_ns.enum("TuyaColorType") -COLOR_TYPES = { - "RGB": TuyaColorType.RGB, - "HSV": TuyaColorType.HSV, - "RGBHSV": TuyaColorType.RGBHSV, -} +COLOR_TYPES = TuyaColorType.options("RGB", "HSV", "RGBHSV") TuyaLight = tuya_ns.class_("TuyaLight", light.LightOutput, cg.Component) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 1cf01f4b486..fc811e385f4 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -199,11 +199,9 @@ def _uart_declare_type(value): UARTParityOptions = uart_ns.enum("UARTParityOptions") -UART_PARITY_OPTIONS = { - "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, - "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, - "ODD": UARTParityOptions.UART_CONFIG_PARITY_ODD, -} +UART_PARITY_OPTIONS = UARTParityOptions.options( + "NONE", "EVEN", "ODD", prefix="UART_CONFIG_PARITY_" +) CONF_FLUSH_TIMEOUT = "flush_timeout" CONF_RX_FULL_THRESHOLD = "rx_full_threshold" @@ -211,13 +209,9 @@ CONF_RX_TIMEOUT = "rx_timeout" CONF_CLOCK_SOURCE = "clock_source" UARTClockSource = cg.global_ns.enum("uart_sclk_t") -UART_CLOCK_SOURCES = { - "DEFAULT": UARTClockSource.UART_SCLK_DEFAULT, - "APB": UARTClockSource.UART_SCLK_APB, - "XTAL": UARTClockSource.UART_SCLK_XTAL, - "RTC": UARTClockSource.UART_SCLK_RTC, - "REF_TICK": UARTClockSource.UART_SCLK_REF_TICK, -} +UART_CLOCK_SOURCES = UARTClockSource.options( + "DEFAULT", "APB", "XTAL", "RTC", "REF_TICK", prefix="UART_SCLK_" +) # Keep in sync with SOC_UART_SUPPORT_* in ESP-IDF's per-variant soc_caps.h. UART_CLOCK_SOURCES_BY_VARIANT = { @@ -237,11 +231,7 @@ UART_CLOCK_SOURCES_BY_VARIANT = { } UARTDirection = uart_ns.enum("UARTDirection") -UART_DIRECTIONS = { - "RX": UARTDirection.UART_DIRECTION_RX, - "TX": UARTDirection.UART_DIRECTION_TX, - "BOTH": UARTDirection.UART_DIRECTION_BOTH, -} +UART_DIRECTIONS = UARTDirection.options("RX", "TX", "BOTH", prefix="UART_DIRECTION_") # The reason for having CONF_BYTES at 150 by default: # diff --git a/esphome/components/uln2003/stepper.py b/esphome/components/uln2003/stepper.py index 03fd254d175..f09b2702c71 100644 --- a/esphome/components/uln2003/stepper.py +++ b/esphome/components/uln2003/stepper.py @@ -16,11 +16,9 @@ from esphome.types import ConfigType uln2003_ns = cg.esphome_ns.namespace("uln2003") ULN2003StepMode = uln2003_ns.enum("ULN2003StepMode") -STEP_MODES = { - "FULL_STEP": ULN2003StepMode.ULN2003_STEP_MODE_FULL_STEP, - "HALF_STEP": ULN2003StepMode.ULN2003_STEP_MODE_HALF_STEP, - "WAVE_DRIVE": ULN2003StepMode.ULN2003_STEP_MODE_WAVE_DRIVE, -} +STEP_MODES = ULN2003StepMode.options( + "FULL_STEP", "HALF_STEP", "WAVE_DRIVE", prefix="ULN2003_STEP_MODE_" +) ULN2003 = uln2003_ns.class_("ULN2003", stepper.Stepper, cg.Component) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 0875966071d..213ff54afb1 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -54,11 +54,9 @@ UART_PARITY_OPTIONS = { } UARTStopBitsOptions = usb_uart_ns.enum("UARTStopBitsOptions") -UART_STOP_BITS_OPTIONS = { - "1": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_1, - "1.5": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_1_5, - "2": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_2, -} +UART_STOP_BITS_OPTIONS = UARTStopBitsOptions.options( + "1", "1.5", "2", prefix="UART_CONFIG_STOP_BITS_" +) DEFAULT_BAUD_RATE = 9600 CONF_CLAIM_COMM_INTERFACE = "claim_comm_interface" diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 41e25d63b15..932d677889a 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -54,11 +54,9 @@ VALVE_STATES = { validate_valve_state = cv.enum(VALVE_STATES, upper=True) ValveOperation = valve_ns.enum("ValveOperation") -VALVE_OPERATIONS = { - "IDLE": ValveOperation.VALVE_OPERATION_IDLE, - "OPENING": ValveOperation.VALVE_OPERATION_OPENING, - "CLOSING": ValveOperation.VALVE_OPERATION_CLOSING, -} +VALVE_OPERATIONS = ValveOperation.options( + "IDLE", "OPENING", "CLOSING", prefix="VALVE_OPERATION_" +) validate_valve_operation = cv.enum(VALVE_OPERATIONS, upper=True) CONF_ON_CLOSED = "on_closed" diff --git a/esphome/components/veml3235/sensor.py b/esphome/components/veml3235/sensor.py index afff5988698..e48e654a7a7 100644 --- a/esphome/components/veml3235/sensor.py +++ b/esphome/components/veml3235/sensor.py @@ -24,24 +24,15 @@ VEML3235Sensor = veml3235_ns.class_( "VEML3235Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) VEML3235ComponentIntegrationTime = veml3235_ns.enum("VEML3235ComponentIntegrationTime") -VEML3235_INTEGRATION_TIMES = { - "50ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_50MS, - "100ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_100MS, - "200ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_200MS, - "400ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_400MS, - "800ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_800MS, -} +VEML3235_INTEGRATION_TIMES = VEML3235ComponentIntegrationTime.options( + "50ms", "100ms", "200ms", "400ms", "800ms", prefix="VEML3235_INTEGRATION_TIME_" +) VEML3235ComponentDigitalGain = veml3235_ns.enum("VEML3235ComponentDigitalGain") -DIGITAL_GAINS = { - "1X": VEML3235ComponentDigitalGain.VEML3235_DIGITAL_GAIN_1X, - "2X": VEML3235ComponentDigitalGain.VEML3235_DIGITAL_GAIN_2X, -} +DIGITAL_GAINS = VEML3235ComponentDigitalGain.options( + "1X", "2X", prefix="VEML3235_DIGITAL_GAIN_" +) VEML3235ComponentGain = veml3235_ns.enum("VEML3235ComponentGain") -GAINS = { - "1X": VEML3235ComponentGain.VEML3235_GAIN_1X, - "2X": VEML3235ComponentGain.VEML3235_GAIN_2X, - "4X": VEML3235ComponentGain.VEML3235_GAIN_4X, -} +GAINS = VEML3235ComponentGain.options("1X", "2X", "4X", prefix="VEML3235_GAIN_") def _validate_auto_gain_thresholds(config: ConfigType) -> ConfigType: diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index b5821e55af5..a3a7f047069 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -30,15 +30,16 @@ WaterHeaterTraits = water_heater_ns.class_("WaterHeaterTraits") CONF_TARGET_TEMPERATURE_STEP = "target_temperature_step" WaterHeaterMode = water_heater_ns.enum("WaterHeaterMode") -WATER_HEATER_MODES = { - "OFF": WaterHeaterMode.WATER_HEATER_MODE_OFF, - "ECO": WaterHeaterMode.WATER_HEATER_MODE_ECO, - "ELECTRIC": WaterHeaterMode.WATER_HEATER_MODE_ELECTRIC, - "PERFORMANCE": WaterHeaterMode.WATER_HEATER_MODE_PERFORMANCE, - "HIGH_DEMAND": WaterHeaterMode.WATER_HEATER_MODE_HIGH_DEMAND, - "HEAT_PUMP": WaterHeaterMode.WATER_HEATER_MODE_HEAT_PUMP, - "GAS": WaterHeaterMode.WATER_HEATER_MODE_GAS, -} +WATER_HEATER_MODES = WaterHeaterMode.options( + "OFF", + "ECO", + "ELECTRIC", + "PERFORMANCE", + "HIGH_DEMAND", + "HEAT_PUMP", + "GAS", + prefix="WATER_HEATER_MODE_", +) validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True) _WATER_HEATER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( diff --git a/esphome/components/whirlpool/climate.py b/esphome/components/whirlpool/climate.py index efc7e6ca570..4a3a750f503 100644 --- a/esphome/components/whirlpool/climate.py +++ b/esphome/components/whirlpool/climate.py @@ -11,11 +11,7 @@ whirlpool_ns = cg.esphome_ns.namespace("whirlpool") WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR) Model = whirlpool_ns.enum("Model") -MODELS = { - "DG11J1-3A": Model.MODEL_DG11J1_3A, - "DG11J1-91": Model.MODEL_DG11J1_91, - "HISENSE_ARG": Model.MODEL_HISENSE_ARG, -} +MODELS = Model.options("DG11J1-3A", "DG11J1-91", "HISENSE_ARG", prefix="MODEL_") CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend( { diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5879729a22b..5fceb891573 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -194,11 +194,9 @@ WiFiComponent = wifi_ns.class_("WiFiComponent", cg.Component) WiFiAP = wifi_ns.struct("WiFiAP") WiFiPowerSaveMode = wifi_ns.enum("WiFiPowerSaveMode") -WIFI_POWER_SAVE_MODES = { - "NONE": WiFiPowerSaveMode.WIFI_POWER_SAVE_NONE, - "LIGHT": WiFiPowerSaveMode.WIFI_POWER_SAVE_LIGHT, - "HIGH": WiFiPowerSaveMode.WIFI_POWER_SAVE_HIGH, -} +WIFI_POWER_SAVE_MODES = WiFiPowerSaveMode.options( + "NONE", "LIGHT", "HIGH", prefix="WIFI_POWER_SAVE_" +) WiFiBandMode = cg.global_ns.enum("wifi_band_mode_t") WIFI_BAND_MODES = { @@ -208,20 +206,15 @@ WIFI_BAND_MODES = { } WifiMinAuthMode = wifi_ns.enum("WifiMinAuthMode") -WIFI_MIN_AUTH_MODES = { - "WPA": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA, - "WPA2": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA2, - "WPA3": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA3, -} +WIFI_MIN_AUTH_MODES = WifiMinAuthMode.options( + "WPA", "WPA2", "WPA3", prefix="WIFI_MIN_AUTH_MODE_" +) VALIDATE_WIFI_MIN_AUTH_MODE = cv.enum(WIFI_MIN_AUTH_MODES, upper=True) WiFi8266PhyMode = wifi_ns.enum("WiFi8266PhyMode") -WIFI_8266_PHY_MODES = { - "AUTO": WiFi8266PhyMode.WIFI_8266_PHY_MODE_AUTO, - "11B": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11B, - "11G": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11G, - "11N": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11N, -} +WIFI_8266_PHY_MODES = WiFi8266PhyMode.options( + "AUTO", "11B", "11G", "11N", prefix="WIFI_8266_PHY_MODE_" +) WIFI_AP_MODE_FALLBACK = "FALLBACK" WIFI_AP_MODE_WISP = "WISP" diff --git a/esphome/components/xgzp68xx/sensor.py b/esphome/components/xgzp68xx/sensor.py index eb3b042cb7a..b67b2d8a9b4 100644 --- a/esphome/components/xgzp68xx/sensor.py +++ b/esphome/components/xgzp68xx/sensor.py @@ -21,16 +21,17 @@ CONF_K_VALUE = "k_value" xgzp68xx_ns = cg.esphome_ns.namespace("xgzp68xx") XGZP68XXOversampling = xgzp68xx_ns.enum("XGZP68XXOversampling") -OVERSAMPLING_OPTIONS = { - "256X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_256X, - "512X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_512X, - "1024X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_1024X, - "2048X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_2048X, - "4096X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_4096X, - "8192X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_8192X, - "16384X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_16384X, - "32768X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_32768X, -} +OVERSAMPLING_OPTIONS = XGZP68XXOversampling.options( + "256X", + "512X", + "1024X", + "2048X", + "4096X", + "8192X", + "16384X", + "32768X", + prefix="XGZP68XX_OVERSAMPLING_", +) XGZP68XXComponent = xgzp68xx_ns.class_( "XGZP68XXComponent", cg.PollingComponent, i2c.I2CDevice ) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 109b953de5c..ebbfa13af6a 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -57,12 +57,9 @@ AnalogAttrs = zigbee_ns.struct("AnalogAttrs") AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput") report = zigbee_ns.enum("ZigbeeReportT") -REPORT = { - "coordinator": report.ZIGBEE_REPORT_COORDINATOR, - "enable": report.ZIGBEE_REPORT_ENABLE, - "force": report.ZIGBEE_REPORT_FORCE, - "default": report.ZIGBEE_REPORT_DEFAULT, -} +REPORT = report.options( + "coordinator", "enable", "force", "default", prefix="ZIGBEE_REPORT_" +) CONF_CLUSTER = "cluster" CONF_ENDPOINT = "endpoint" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index bb6c153752b..178cb4b6fc8 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -26,38 +26,40 @@ DEVICE_ID = { "CUSTOM_ATTR": 0xFFF2, } cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e") -CLUSTER_ID = { - "BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC, - "ON_OFF": cluster_id.EZB_ZCL_CLUSTER_ID_ON_OFF, - "TIME": cluster_id.EZB_ZCL_CLUSTER_ID_TIME, - "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, - "BINARY_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, - "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, - "ANALOG_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, - "ILLUMINANCE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT, - "TEMPERATURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT, - "PRESSURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT, - "FLOW_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT, - "REL_HUMIDITY_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT, - "OCCUPANCY_SENSING": cluster_id.EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING, - "CARBON_DIOXIDE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT, - "PM2_5_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT, -} +CLUSTER_ID = cluster_id.options( + "BASIC", + "ON_OFF", + "TIME", + "BINARY_INPUT", + "BINARY_OUTPUT", + "ANALOG_INPUT", + "ANALOG_OUTPUT", + "ILLUMINANCE_MEASUREMENT", + "TEMPERATURE_MEASUREMENT", + "PRESSURE_MEASUREMENT", + "FLOW_MEASUREMENT", + "REL_HUMIDITY_MEASUREMENT", + "OCCUPANCY_SENSING", + "CARBON_DIOXIDE_MEASUREMENT", + "PM2_5_MEASUREMENT", + prefix="EZB_ZCL_CLUSTER_ID_", +) CLUSTER_ROLE = { "SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"), "CLIENT": cg.RawExpression("EZB_ZCL_CLUSTER_CLIENT"), } attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e") -ATTR_TYPE = { - "BOOL": attr_type.EZB_ZCL_ATTR_TYPE_BOOL, - "MAP8": attr_type.EZB_ZCL_ATTR_TYPE_MAP8, - "UINT8": attr_type.EZB_ZCL_ATTR_TYPE_UINT8, - "UINT16": attr_type.EZB_ZCL_ATTR_TYPE_UINT16, - "INT8": attr_type.EZB_ZCL_ATTR_TYPE_INT8, - "INT16": attr_type.EZB_ZCL_ATTR_TYPE_INT16, - "ENUM8": attr_type.EZB_ZCL_ATTR_TYPE_ENUM8, - "ENUM16": attr_type.EZB_ZCL_ATTR_TYPE_ENUM16, - "STRING": attr_type.EZB_ZCL_ATTR_TYPE_STRING, - "SINGLE": attr_type.EZB_ZCL_ATTR_TYPE_SINGLE, - "DOUBLE": attr_type.EZB_ZCL_ATTR_TYPE_DOUBLE, -} +ATTR_TYPE = attr_type.options( + "BOOL", + "MAP8", + "UINT8", + "UINT16", + "INT8", + "INT16", + "ENUM8", + "ENUM16", + "STRING", + "SINGLE", + "DOUBLE", + prefix="EZB_ZCL_ATTR_TYPE_", +) From 1147b024d6b8b3150fc5846456988864c8aa80aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:17:59 -1000 Subject: [PATCH 020/120] [core] Add sign_extend helper (#20933) --- esphome/core/helpers.h | 6 ++++++ tests/components/core/helpers_test.cpp | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c51ef4a652d..94921e1d088 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -990,6 +990,12 @@ constexpr uint32_t encode_uint32(uint8_t byte1, uint8_t byte2, uint8_t byte3, ui (static_cast(byte3) << 8) | (static_cast(byte4)); } +/// Sign-extend the low \p BITS bits of \p value, e.g. sign_extend<24>(0xFFFFFE) == -2. Higher bits are ignored. +template constexpr int32_t sign_extend(uint32_t value) { + static_assert(BITS > 0 && BITS <= 32, "BITS must be 1 to 32"); + return static_cast(value << (32 - BITS)) >> (32 - BITS); +} + /// Encode a value from its constituent bytes (from most to least significant) in an array with length sizeof(T). template::value, int> = 0> constexpr T encode_value(const uint8_t *bytes) { T val = 0; diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index d0058a634b2..92a7f841830 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -55,6 +55,21 @@ TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { } } +TEST(HelpersTest, SignExtend) { + EXPECT_EQ(sign_extend<24>(0x000000), 0); + EXPECT_EQ(sign_extend<24>(0x7FFFFF), 8388607); + EXPECT_EQ(sign_extend<24>(0x800000), -8388608); + EXPECT_EQ(sign_extend<24>(0xFFFFFE), -2); + EXPECT_EQ(sign_extend<24>(0xAB000001), 1); // bits above BITS are ignored + EXPECT_EQ(sign_extend<12>(0x7FF), 2047); + EXPECT_EQ(sign_extend<12>(0x800), -2048); + EXPECT_EQ(sign_extend<16>(0xFF38), -200); + EXPECT_EQ(sign_extend<1>(1), -1); + EXPECT_EQ(sign_extend<32>(0xFFFFFFFF), -1); + EXPECT_EQ(sign_extend<32>(0x7FFFFFFF), INT32_MAX); + static_assert(sign_extend<12>(0xFFF) == -1, "usable in constant expressions"); +} + TEST(HelpersTest, SumBytes) { static constexpr uint8_t FRAME[] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x10}; EXPECT_EQ(sum_bytes(FRAME, sizeof(FRAME)), 0xE7); // the Daikin comfort frame checksum From 107bebebad04bdfbf53d7150beb01d152b2be6d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:24:45 -1000 Subject: [PATCH 021/120] [components] Use sign_extend for raw sensor readings (#20934) --- esphome/components/ade7880/ade7880_i2c.cpp | 8 +------- esphome/components/bme280_base/bme280_base.cpp | 10 +++++----- esphome/components/cs5460a/cs5460a.cpp | 3 ++- esphome/components/hx711/hx711.cpp | 4 +--- esphome/components/max31856/max31856.cpp | 9 +++------ esphome/components/max9611/max9611.cpp | 11 ++--------- esphome/components/xdb401/xdb401.cpp | 3 +-- esphome/components/xgzp68xx/xgzp68xx.cpp | 4 +--- 8 files changed, 16 insertions(+), 36 deletions(-) diff --git a/esphome/components/ade7880/ade7880_i2c.cpp b/esphome/components/ade7880/ade7880_i2c.cpp index 294fd430d3e..dfc7a58300f 100644 --- a/esphome/components/ade7880/ade7880_i2c.cpp +++ b/esphome/components/ade7880/ade7880_i2c.cpp @@ -11,12 +11,6 @@ namespace esphome::ade7880 { -// adapted from https://stackoverflow.com/a/55912127/1886371 -template inline T sign_extend(const T &v) noexcept { - using S = struct { signed Val : Bits; }; - return reinterpret_cast(&v)->Val; -} - // Register types // unsigned 8-bit (uint8_t) // signed 10-bit - 16-bit ZP on wire (int16_t, needs sign extension) @@ -49,7 +43,7 @@ uint16_t ADE7880::read_u16_register16_(uint16_t a_register) { int32_t ADE7880::read_s24zp_register16_(uint16_t a_register) { // s24zp means 24 bit signed value in the lower 24 bits of a 32-bit register - int32_t in; + uint32_t in; this->read_register16(a_register, reinterpret_cast(&in), sizeof(in)); return sign_extend<24>(convert_big_endian(in)); } diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 4b74a15c0a6..cf61160f77e 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -3,6 +3,7 @@ #include "bme280_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include #include @@ -144,11 +145,10 @@ void BME280Component::setup() { this->calibration_.h1 = read_u8_(BME280_REGISTER_DIG_H1); this->calibration_.h2 = read_s16_le_(BME280_REGISTER_DIG_H2); this->calibration_.h3 = read_u8_(BME280_REGISTER_DIG_H3); - // h4 and h5 are signed 12-bit values; shift left then arithmetic right shift to sign-extend - int16_t h4_raw = read_u8_(BME280_REGISTER_DIG_H4) << 4 | (read_u8_(BME280_REGISTER_DIG_H4 + 1) & 0x0F); - this->calibration_.h4 = static_cast(h4_raw << 4) >> 4; - int16_t h5_raw = read_u8_(BME280_REGISTER_DIG_H5 + 1) << 4 | (read_u8_(BME280_REGISTER_DIG_H5) >> 4); - this->calibration_.h5 = static_cast(h5_raw << 4) >> 4; + this->calibration_.h4 = + sign_extend<12>(read_u8_(BME280_REGISTER_DIG_H4) << 4 | (read_u8_(BME280_REGISTER_DIG_H4 + 1) & 0x0F)); + this->calibration_.h5 = + sign_extend<12>(read_u8_(BME280_REGISTER_DIG_H5 + 1) << 4 | (read_u8_(BME280_REGISTER_DIG_H5) >> 4)); this->calibration_.h6 = read_u8_(BME280_REGISTER_DIG_H6); uint8_t humid_control_val = 0; diff --git a/esphome/components/cs5460a/cs5460a.cpp b/esphome/components/cs5460a/cs5460a.cpp index 7c4e4847911..4631e5ce3cd 100644 --- a/esphome/components/cs5460a/cs5460a.cpp +++ b/esphome/components/cs5460a/cs5460a.cpp @@ -1,4 +1,5 @@ #include "cs5460a.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::cs5460a { @@ -304,7 +305,7 @@ bool CS5460AComponent::check_status_() { voltage_sensor_->publish_state(raw_voltage * voltage_multiplier_); if (power_sensor_ != nullptr && raw_energy != prev_raw_energy_) { - int32_t raw = (int32_t) (raw_energy << 8) >> 8; /* Sign-extend */ + int32_t raw = sign_extend<24>(raw_energy); power_sensor_->publish_state(raw * power_multiplier_); prev_raw_energy_ = raw_energy; } diff --git a/esphome/components/hx711/hx711.cpp b/esphome/components/hx711/hx711.cpp index 48afc0192de..9a81befcc45 100644 --- a/esphome/components/hx711/hx711.cpp +++ b/esphome/components/hx711/hx711.cpp @@ -67,9 +67,7 @@ bool HX711Sensor::read_sensor_(uint32_t *result) { this->status_clear_warning(); - if (data & 0x800000ULL) { - data |= 0xFF000000ULL; - } + data = static_cast(sign_extend<24>(data)); if (result != nullptr) *result = data; diff --git a/esphome/components/max31856/max31856.cpp b/esphome/components/max31856/max31856.cpp index bdb47216daa..b6bfb6127be 100644 --- a/esphome/components/max31856/max31856.cpp +++ b/esphome/components/max31856/max31856.cpp @@ -1,5 +1,6 @@ #include "max31856.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -53,12 +54,8 @@ void MAX31856Sensor::read_thermocouple_temperature_() { // Faults have been logged, clear it for next loop this->clear_fault_(); } else { - int32_t temp24 = this->read_register24_(MAX31856_LTCBH_REG); - if (temp24 & 0x800000) { - temp24 |= 0xFF000000; // fix sign - } - - temp24 >>= 5; // bottom 5 bits are unused + // bottom 5 bits are unused + const int32_t temp24 = sign_extend<24>(this->read_register24_(MAX31856_LTCBH_REG)) >> 5; float temp_c = temp24; temp_c *= 0.0078125; diff --git a/esphome/components/max9611/max9611.cpp b/esphome/components/max9611/max9611.cpp index 420eb7da7cb..486ceaee148 100644 --- a/esphome/components/max9611/max9611.cpp +++ b/esphome/components/max9611/max9611.cpp @@ -1,17 +1,10 @@ #include "max9611.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/components/i2c/i2c_bus.h" namespace esphome::max9611 { using namespace esphome::i2c; -// Sign extend -// http://graphics.stanford.edu/~seander/bithacks.html#FixedSignExtend -template inline T signextend(const T x) { - struct { - T x : B; - } s; - return s.x = x; -} // Map the gain register to in uV/LSB float gain_to_lsb(MAX9611Multiplexer gain) { float lsb = 0.0; @@ -72,7 +65,7 @@ void MAX9611Component::update() { uint16_t t_register = ((register_map_[TEMP_DATA_BYTE_MSB_ADRR] << 8) | (register_map_[TEMP_DATA_BYTE_LSB_ADRR])) >> 7; float voltage = rs_register * VOUT_LSB; float shunt_voltage = (csa_register * gain_to_lsb(gain_)) / MICRO_VOLTS_PER_VOLT; - float temp = signextend(t_register) * TEMP_LSB; + float temp = sign_extend<9>(t_register) * TEMP_LSB; float amps = shunt_voltage / current_resistor_; float watts = amps * voltage; diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp index 5539a1318fd..11a1a569f3f 100644 --- a/esphome/components/xdb401/xdb401.cpp +++ b/esphome/components/xdb401/xdb401.cpp @@ -127,8 +127,7 @@ i2c::ErrorCode XDB401Component::read_pressure_(float &pressure) { format_hex_pretty_to(pressure_buf, sizeof(pressure_buf), p_data, 3); ESP_LOGV(TAG, "Got pressure data: %s", pressure_buf); - // Sign-extend 24-bit big-endian pressure value to int32_t. - int32_t raw_pressure = static_cast(encode_uint24(p_data[0], p_data[1], p_data[2]) << 8) >> 8; + int32_t raw_pressure = sign_extend<24>(encode_uint24(p_data[0], p_data[1], p_data[2])); ESP_LOGD(TAG, "Pressure data raw %" PRId32, raw_pressure); pressure = (static_cast(raw_pressure) / CONVERT_PRESSURE) * diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index 795db448f0b..b184a855f6f 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -76,9 +76,7 @@ void XGZP68XXComponent::update() { ESP_LOGV(TAG, "Got raw pressure=%" PRIu32 ", raw temperature=%u, K value=%u", pressure_raw, temperature_raw, this->k_value_); - // Sign extend the pressure - float pressure_in_pa = (float) (((int32_t) pressure_raw << 8) >> 8); - pressure_in_pa /= (float) (this->k_value_); + const float pressure_in_pa = static_cast(sign_extend<24>(pressure_raw)) / this->k_value_; float temperature = ((float) (int16_t) temperature_raw) / 256.0f; From 9031df757bf4351bdc088f787630a8536ecefa95 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:33:29 +0200 Subject: [PATCH 022/120] [core][scheduler] add missing process_to_add call (#20961) Co-authored-by: J. Nick Koston --- esphome/core/scheduler.cpp | 5 ++- .../test_scheduler_cancelled_callback.cpp | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/components/core/test_scheduler_cancelled_callback.cpp diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 5d0b96c38f2..1dc95c448b6 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -442,10 +442,10 @@ uint32_t HOT Scheduler::call(uint32_t now) { SchedulerNameLog name_log; bool is_cancelled = is_item_removed_(item); - ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64 "%s", + ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRId64 "ms at %" PRIu64 "%s", LOG_STR_ARG(item->get_type_str()), LOG_STR_ARG(item->get_source()), name_log.format(item->get_name_type(), item->get_name(), item->get_name_hash_or_id()), item->interval, - item->get_next_execution() - now_64, item->get_next_execution(), + (int64_t) (item->get_next_execution() - now_64), item->get_next_execution(), is_cancelled ? LOG_STR_LITERAL(" [CANCELLED]") : LOG_STR_LITERAL("")); old_items.push_back(item); @@ -539,6 +539,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { // We were removed/cancelled in the function call, recycle and continue this->to_remove_decrement_locked_(); this->recycle_item_main_loop_(executed_item); + has_added_items |= !this->to_add_.empty(); continue; } diff --git a/tests/components/core/test_scheduler_cancelled_callback.cpp b/tests/components/core/test_scheduler_cancelled_callback.cpp new file mode 100644 index 00000000000..b4cfd21cc5c --- /dev/null +++ b/tests/components/core/test_scheduler_cancelled_callback.cpp @@ -0,0 +1,44 @@ +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/scheduler.h" + +#include + +#include +#include + +namespace esphome::core::testing { + +class SchedulerTestComponent : public Component {}; + +// On a multi-threaded build next_schedule_in() only sees items_, so a timeout a callback leaves in +// to_add_ would let a loop that sleeps on next_schedule_in() oversleep. The case that used to skip +// the merge is a heap item (a zero timeout would run from the defer queue instead) whose callback +// cancels its own item. +TEST(Scheduler, TimeoutAddedByACancelledCallbackIsVisibleAfterCall) { + Scheduler scheduler; + SchedulerTestComponent component; + bool fired = false; + + scheduler.set_timeout(&component, "first", 1, [&]() { + scheduler.cancel_timeout(&component, "first"); + scheduler.set_timeout(&component, "second", 100, [&fired]() { fired = true; }); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + scheduler.call(millis()); + + auto next = scheduler.next_schedule_in(millis()); + ASSERT_TRUE(next.has_value()); + const uint32_t next_ms = next.has_value() ? *next : 0; + EXPECT_GT(next_ms, 50u); + EXPECT_LE(next_ms, 100u); + EXPECT_FALSE(fired); + + std::this_thread::sleep_for(std::chrono::milliseconds(120)); + scheduler.call(millis()); + EXPECT_TRUE(fired); + EXPECT_FALSE(scheduler.next_schedule_in(millis()).has_value()); + scheduler.trim_freelist(); +} + +} // namespace esphome::core::testing From b8afcdfb3c34b385e317272c62ea830279e41969 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:54:53 -1000 Subject: [PATCH 023/120] [i2c] Write 16-bit registers without a heap allocation (#20907) --- esphome/components/i2c/i2c.cpp | 22 ++++++--- esphome/components/i2c/i2c.h | 2 +- tests/components/i2c/write_bytes_16.cpp | 59 +++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 tests/components/i2c/write_bytes_16.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 8fc0a503276..ba23217486e 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -3,7 +3,6 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" -#include namespace esphome::i2c { @@ -59,11 +58,22 @@ bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { } bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) const { - // we have to copy in order to be able to change byte order - std::unique_ptr temp{new uint16_t[len]}; - for (size_t i = 0; i < len; i++) - temp[i] = htoi2cs(data[i]); - return write_register(a_register, reinterpret_cast(temp.get()), len * 2) == ERROR_OK; + const size_t size = 1 + len * 2; + SmallBufferWithHeapFallback<17> buffer_alloc(size); // Register plus up to 8 words + uint8_t *buffer = buffer_alloc.get(); + + buffer[0] = a_register; + for (size_t i = 0; i < len; i++) { + const uint16_t word = data[i]; + buffer[1 + i * 2] = word >> 8; + buffer[2 + i * 2] = word; + } + return this->bus_->write_readv(this->address_, buffer, size, nullptr, 0) == ERROR_OK; +} + +bool I2CDevice::write_byte_16(uint8_t a_register, uint16_t data) const { + const uint8_t buffer[3] = {a_register, static_cast(data >> 8), static_cast(data)}; + return this->bus_->write_readv(this->address_, buffer, sizeof(buffer), nullptr, 0) == ERROR_OK; } I2CRegister &I2CRegister::operator=(uint8_t value) { diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 00929db6207..0d60f519e32 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -264,7 +264,7 @@ class I2CDevice { bool write_byte(uint8_t a_register, uint8_t data) const { return write_bytes(a_register, &data, 1); } - bool write_byte_16(uint8_t a_register, uint16_t data) const { return write_bytes_16(a_register, &data, 1); } + bool write_byte_16(uint8_t a_register, uint16_t data) const; protected: uint8_t address_{0x00}; ///< store the address of the device on the bus diff --git a/tests/components/i2c/write_bytes_16.cpp b/tests/components/i2c/write_bytes_16.cpp new file mode 100644 index 00000000000..1f9670c62ba --- /dev/null +++ b/tests/components/i2c/write_bytes_16.cpp @@ -0,0 +1,59 @@ +#include + +#include + +#include "esphome/components/i2c/i2c.h" + +namespace esphome::i2c::testing { + +class RecordingBus final : public I2CBus { + public: + std::vector written; + ErrorCode result{ERROR_OK}; + + ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, size_t out_size) override { + this->written.assign(data, data + size); + return this->result; + } +}; + +class WriteBytes16Test : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x40); + } + + RecordingBus bus_; + I2CDevice device_; +}; + +TEST_F(WriteBytes16Test, WritesRegisterThenWordBigEndian) { + EXPECT_TRUE(this->device_.write_byte_16(0x12, 0xABCD)); + EXPECT_EQ(this->bus_.written, (std::vector{0x12, 0xAB, 0xCD})); +} + +TEST_F(WriteBytes16Test, WritesEveryWordBigEndian) { + const uint16_t words[3] = {0x0102, 0x0304, 0xFF00}; + EXPECT_TRUE(this->device_.write_bytes_16(0x7E, words, 3)); + EXPECT_EQ(this->bus_.written, (std::vector{0x7E, 0x01, 0x02, 0x03, 0x04, 0xFF, 0x00})); +} + +TEST_F(WriteBytes16Test, MoreWordsThanTheStackBufferHolds) { + uint16_t words[12]; + std::vector expected{0x01}; + for (uint16_t i = 0; i < 12; i++) { + words[i] = 0x1000 + i; + expected.push_back(0x10); + expected.push_back(i); + } + EXPECT_TRUE(this->device_.write_bytes_16(0x01, words, 12)); + EXPECT_EQ(this->bus_.written, expected); +} + +TEST_F(WriteBytes16Test, ReportsBusFailure) { + this->bus_.result = ERROR_NOT_ACKNOWLEDGED; + EXPECT_FALSE(this->device_.write_byte_16(0x12, 0x0001)); +} + +} // namespace esphome::i2c::testing From 298bd5ec7219f339f53808eed2f5dac5a1a5a2f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:55:28 -1000 Subject: [PATCH 024/120] [core] Add ESP_LOGV_HEX, ESP_LOGVV_HEX and ESP_LOGD_HEX (#20931) --- esphome/core/log.h | 32 ++++++++++++ tests/components/core/test_log_hex.cpp | 67 ++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 tests/components/core/test_log_hex.cpp diff --git a/esphome/core/log.h b/esphome/core/log.h index 840da292aee..d7a0ff6b679 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -160,6 +160,38 @@ int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT #define ESP_LOGV(tag, ...) esph_log_v(tag, __VA_ARGS__) #define ESP_LOGVV(tag, ...) esph_log_vv(tag, __VA_ARGS__) +// Log \p format with up to \p max_bytes of \p data as colon separated hex appended as its last %s argument. +// The stack buffer exists only when the level is compiled in. Needs esphome/core/helpers.h. +#define ESPHOME_LOG_HEX_(log_macro, tag, data, len, max_bytes, format, ...) \ + do { \ + char esph_log_hex_buf_[::esphome::format_hex_pretty_size(max_bytes)]; \ + log_macro(tag, format, __VA_ARGS__ __VA_OPT__(, )::esphome::format_hex_pretty_to(esph_log_hex_buf_, data, len)); \ + } while (0) +#ifdef ESPHOME_LOG_HAS_DEBUG +#define ESP_LOGD_HEX(tag, data, len, max_bytes, format, ...) \ + ESPHOME_LOG_HEX_(ESP_LOGD, tag, data, len, max_bytes, format, __VA_ARGS__) +#else +#define ESP_LOGD_HEX(tag, data, len, max_bytes, format, ...) \ + do { \ + } while (0) +#endif +#ifdef ESPHOME_LOG_HAS_VERBOSE +#define ESP_LOGV_HEX(tag, data, len, max_bytes, format, ...) \ + ESPHOME_LOG_HEX_(ESP_LOGV, tag, data, len, max_bytes, format, __VA_ARGS__) +#else +#define ESP_LOGV_HEX(tag, data, len, max_bytes, format, ...) \ + do { \ + } while (0) +#endif +#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE +#define ESP_LOGVV_HEX(tag, data, len, max_bytes, format, ...) \ + ESPHOME_LOG_HEX_(ESP_LOGVV, tag, data, len, max_bytes, format, __VA_ARGS__) +#else +#define ESP_LOGVV_HEX(tag, data, len, max_bytes, format, ...) \ + do { \ + } while (0) +#endif + #define BYTE_TO_BINARY_PATTERN "%c%c%c%c%c%c%c%c" #define BYTE_TO_BINARY(byte) \ ((byte) &0x80 ? '1' : '0'), ((byte) &0x40 ? '1' : '0'), ((byte) &0x20 ? '1' : '0'), ((byte) &0x10 ? '1' : '0'), \ diff --git a/tests/components/core/test_log_hex.cpp b/tests/components/core/test_log_hex.cpp new file mode 100644 index 00000000000..70de9af0828 --- /dev/null +++ b/tests/components/core/test_log_hex.cpp @@ -0,0 +1,67 @@ +#include + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::testing { + +static const char *const TAG = "log_hex_test"; + +TEST(LogHexTest, EvaluatesArgumentsOnlyWhenTheLevelIsCompiledIn) { + static constexpr uint8_t DATA[] = {0x01, 0xAB, 0xFF}; + int debug_calls = 0; + int verbose_calls = 0; + int very_verbose_calls = 0; + auto count = [](int &calls) { + calls++; + return sizeof(DATA); + }; + ESP_LOGD_HEX(TAG, DATA, count(debug_calls), sizeof(DATA), "Data: %s"); + ESP_LOGV_HEX(TAG, DATA, count(verbose_calls), sizeof(DATA), "Frame %u: %s", 7u); + ESP_LOGVV_HEX(TAG, DATA, count(very_verbose_calls), 2, "Type %02X %s: %s", 0x10, "truncated"); +#ifdef ESPHOME_LOG_HAS_DEBUG + EXPECT_EQ(debug_calls, 1); +#else + EXPECT_EQ(debug_calls, 0); +#endif +#ifdef ESPHOME_LOG_HAS_VERBOSE + EXPECT_EQ(verbose_calls, 1); +#else + EXPECT_EQ(verbose_calls, 0); +#endif +#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE + EXPECT_EQ(very_verbose_calls, 1); +#else + EXPECT_EQ(very_verbose_calls, 0); +#endif +} + +TEST(LogHexTest, IsASingleStatement) { + static constexpr uint8_t DATA[] = {0x12, 0x34}; + bool other_branch = false; + if (DATA[0] == 0x12) // NOLINT(readability-braces-around-statements) + ESP_LOGV_HEX(TAG, DATA, sizeof(DATA), sizeof(DATA), "Data: %s"); + else // NOLINT(readability-braces-around-statements) + other_branch = true; + EXPECT_FALSE(other_branch); +} + +// Stands in for a log macro so the expansion can be checked without the logger backend +static char captured[64]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#define CAPTURE_LOG(tag, ...) snprintf(captured, sizeof(captured), __VA_ARGS__) + +TEST(LogHexTest, HexTextIsTheLastArgument) { + static constexpr uint8_t DATA[] = {0x01, 0xAB, 0xFF}; + ESPHOME_LOG_HEX_(CAPTURE_LOG, TAG, DATA, sizeof(DATA), sizeof(DATA), "Data: %s"); + EXPECT_STREQ(captured, "Data: 01:AB:FF"); +} + +TEST(LogHexTest, ExtraArgumentsComeFirstAndLongDataIsCut) { + static constexpr uint8_t DATA[] = {0x01, 0xAB, 0xFF}; + ESPHOME_LOG_HEX_(CAPTURE_LOG, TAG, DATA, sizeof(DATA), 2, "Type %02X %s: %s", 0x10, "truncated"); + EXPECT_STREQ(captured, "Type 10 truncated: 01:AB"); +} + +} // namespace esphome::testing From 7671d4410c3c00ded377572ac32b3c1070e80c32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:55:53 -1000 Subject: [PATCH 025/120] [i2c] Add check_chip_id helper (#20941) --- esphome/components/i2c/i2c.cpp | 4 + esphome/components/i2c/i2c.h | 18 +++++ tests/components/i2c/test_i2c_chip_id.cpp | 94 +++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 tests/components/i2c/test_i2c_chip_id.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index ba23217486e..59b0cb744fb 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -21,6 +21,10 @@ void I2CBus::i2c_scan_() { } } +void log_wrong_chip_id(uint8_t id, uint8_t expected) { + ESP_LOGE(TAG, "Wrong chip ID 0x%02X, expected 0x%02X", id, expected); +} + ErrorCode I2CDevice::read_register(uint8_t a_register, uint8_t *data, size_t len) { return bus_->write_readv(this->address_, &a_register, 1, data, len); } diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 0d60f519e32..4546047d603 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -2,7 +2,9 @@ #include #include +#include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/optional.h" #include "i2c_bus.h" @@ -12,6 +14,9 @@ namespace esphome::i2c { class I2CDevice; // forward declaration +/// Out of line so each check_chip_id() caller only pays for a call. +void log_wrong_chip_id(uint8_t id, uint8_t expected); + /// @brief This class is used to create I2CRegister objects that act as proxies to read/write internal registers on an /// I2C device. /// @details @@ -208,6 +213,19 @@ class I2CDevice { /// @return an i2c::ErrorCode ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len) const; + /// Read the 8-bit ID at \p a_register and accept it when it matches \p expected in the bits set in \p mask. + /// Otherwise mark \p component failed and return false. + bool check_chip_id(Component *component, uint8_t a_register, uint8_t expected, uint8_t mask = 0xFF) { + uint8_t id; + const ErrorCode err = this->read_register(a_register, &id, 1); + if (err == ERROR_OK && (id & mask) == (expected & mask)) + return true; + if (err == ERROR_OK) + log_wrong_chip_id(id, expected & mask); + component->mark_failed(err == ERROR_OK ? LOG_STR("Wrong chip ID") : LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + /// /// Compat APIs /// All methods below have been added for compatibility reasons. They do not bring any functionality and therefore on diff --git a/tests/components/i2c/test_i2c_chip_id.cpp b/tests/components/i2c/test_i2c_chip_id.cpp new file mode 100644 index 00000000000..c3e3fdd4907 --- /dev/null +++ b/tests/components/i2c/test_i2c_chip_id.cpp @@ -0,0 +1,94 @@ +#include + +#include + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/application.h" +#include "esphome/core/component.h" + +namespace esphome::i2c::testing { + +class IdBus : public I2CBus { + public: + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + ++this->transactions; + if (this->fail) + return ERROR_NOT_ACKNOWLEDGED; + if (write_count != 1 || read_count != 1) + return ERROR_INVALID_ARGUMENT; + this->last_register = write_buffer[0]; + read_buffer[0] = this->id; + return ERROR_OK; + } + + uint8_t id{0}; + uint8_t last_register{0}; + unsigned transactions{0}; + bool fail{false}; +}; + +class ChipDevice : public Component, public I2CDevice {}; + +class I2CChipIdTest : public ::testing::Test { + protected: + void SetUp() override { + new (&App) Application; + static char name[] = "i2c-chip-id-tests"; + static char friendly_name[] = "I2C chip ID tests"; + App.pre_setup(name, sizeof(name) - 1, friendly_name, sizeof(friendly_name) - 1); + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x40); + } + IdBus bus_; + ChipDevice device_; +}; + +TEST_F(I2CChipIdTest, MatchingIdPasses) { + this->bus_.id = 0x33; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x0F, 0x33)); + EXPECT_EQ(this->bus_.last_register, 0x0F); + EXPECT_EQ(this->bus_.transactions, 1u); + EXPECT_FALSE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, WrongIdFails) { + this->bus_.id = 0x32; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x0F, 0x33)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, ReadFailureFails) { + this->bus_.fail = true; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x0F, 0x00)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, MaskAcceptsTwoIds) { + // 0xB1 and 0xB3 differ only in bit 1 + for (uint8_t id : {0xB1, 0xB3}) { + this->bus_.id = id; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x06, 0xB1, 0xFD)); + } + EXPECT_FALSE(this->device_.is_failed()); + this->bus_.id = 0xB5; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x06, 0xB1, 0xFD)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, MaskIgnoresRevisionBits) { + this->bus_.id = 0x27; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x92, 0x24, 0xFC)); + EXPECT_FALSE(this->device_.is_failed()); + this->bus_.id = 0x2B; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x92, 0x24, 0xFC)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, ExpectedBitsOutsideTheMaskAreIgnored) { + this->bus_.id = 0x24; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x92, 0x27, 0xFC)); + EXPECT_FALSE(this->device_.is_failed()); +} + +} // namespace esphome::i2c::testing From a5a883fd190db7e2f13afe27c6df675ca5da63cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:57:00 -1000 Subject: [PATCH 026/120] [core] Add xor_bytes and sum16_bytes checksum helpers (#20921) --- esphome/core/helpers.h | 10 ++++++++++ tests/components/core/helpers_test.cpp | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 94921e1d088..72bd3a0c6ba 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -887,6 +887,16 @@ constexpr uint8_t sum_bytes(const uint8_t *data, size_t len, uint8_t sum = 0) { return std::accumulate(data, data + len, sum); } +/// Add up the bytes of \p data with size \p len, truncated to 16 bits. +constexpr uint16_t sum16_bytes(const uint8_t *data, size_t len, uint16_t sum = 0) { + return std::accumulate(data, data + len, sum); +} + +/// XOR the bytes of \p data with size \p len, starting from \p init. +constexpr uint8_t xor_bytes(const uint8_t *data, size_t len, uint8_t init = 0) { + return std::accumulate(data, data + len, init, std::bit_xor<>()); +} + /// Calculate a CRC-8 checksum of \p data with size \p len. uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc = 0x00, uint8_t poly = 0x8C, bool msb_first = false); diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index 92a7f841830..499aea5a8ad 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -78,6 +78,24 @@ TEST(HelpersTest, SumBytes) { static_assert(sum_bytes(FRAME, 2) == 0xEB, "usable in constant expressions"); } +TEST(HelpersTest, Sum16Bytes) { + static constexpr uint8_t FRAME[] = {0x42, 0x4D, 0xE1, 0x00, 0x01}; + EXPECT_EQ(sum16_bytes(FRAME, sizeof(FRAME)), 0x0171); // the PMSx003 active mode command checksum + EXPECT_EQ(sum16_bytes(FRAME, 0), 0); + EXPECT_EQ(sum16_bytes(FRAME, sizeof(FRAME), 0x1234), 0x13A5); + EXPECT_EQ(sum16_bytes(FRAME, sizeof(FRAME), 0xFFFF), 0x0170); // wraps at 16 bits + static_assert(sum16_bytes(FRAME, 2) == 0x8F, "usable in constant expressions"); +} + +TEST(HelpersTest, XorBytes) { + static constexpr uint8_t FRAME[] = {0x01, 0x03, 0x00, 0x15, 0x00}; + EXPECT_EQ(xor_bytes(FRAME + 1, 3, 0xFF), 0xE9); // the Z-Wave GetVersion frame checksum + EXPECT_EQ(xor_bytes(FRAME, 0), 0); + EXPECT_EQ(xor_bytes(FRAME, 0, 0x5A), 0x5A); + EXPECT_EQ(xor_bytes(FRAME, sizeof(FRAME)), 0x17); + static_assert(xor_bytes(FRAME, 2) == 0x02, "usable in constant expressions"); +} + TEST(StaticVectorTest, ConvertingConstructorFromSmaller) { StaticVector small{0x03, 0x00, 0x10, 0x00, 0x01}; StaticVector big = small; From d2a1f7ee31ea1f51768ea02bd91e573ac0ff1b48 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Fri, 16 Oct 2026 23:02:54 -0400 Subject: [PATCH 027/120] [zephyr] Fix preference log levels and report settings read/save errors (#20810) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/zephyr/preferences.cpp | 29 ++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/esphome/components/zephyr/preferences.cpp b/esphome/components/zephyr/preferences.cpp index 6be5f1ce4a5..d5522da03aa 100644 --- a/esphome/components/zephyr/preferences.cpp +++ b/esphome/components/zephyr/preferences.cpp @@ -5,6 +5,7 @@ #include "preferences.h" #include "esphome/core/log.h" #include +#include #include #include @@ -23,7 +24,11 @@ bool ZephyrPreferenceBackend::load(uint8_t *data, size_t len) { if (len != this->data.size()) { char key_buf[KEY_BUFFER_SIZE]; this->format_key(key_buf, sizeof(key_buf)); - ESP_LOGE(TAG, "size of setting key %s changed, from: %zu, to: %zu", key_buf, this->data.size(), len); + if (this->data.empty()) { + ESP_LOGV(TAG, "setting key %s not found", key_buf); + } else { + ESP_LOGE(TAG, "size of setting key %s changed, from: %zu, to: %zu", key_buf, this->data.size(), len); + } return false; } std::memcpy(data, this->data.data(), len); @@ -87,7 +92,7 @@ bool ZephyrPreferences::load_from_key(uint32_t type, uint8_t *data, size_t len) } bool ZephyrPreferences::sync() { - ESP_LOGD(TAG, "Save settings"); + ESP_LOGV(TAG, "Save settings"); int err = settings_save(); if (err) { ESP_LOGE(TAG, "Cannot save settings, err: %d", err); @@ -117,22 +122,34 @@ int ZephyrPreferences::load_setting(const char *name, size_t len, settings_read_ return 1; } std::vector data(len); - int err = read_cb(cb_arg, data.data(), len); + ssize_t read_len = read_cb(cb_arg, data.data(), len); + if (read_len != static_cast(len)) { + ESP_LOGE(TAG, "Cannot read setting %s, got %d of %zu", name, static_cast(read_len), len); + return 0; + } - ESP_LOGD(TAG, "load setting, name: %s(%" PRIu32 "), len %zu, err %d", name, *type, len, err); + ESP_LOGD(TAG, "load setting, name: %s(%" PRIu32 "), len %zu", name, *type, len); auto *pref = new ZephyrPreferenceBackend(*type, std::move(data)); // NOLINT(cppcoreguidelines-owning-memory) get_preferences()->backends_.push_back(pref); return 0; } int ZephyrPreferences::export_settings(int (*cb)(const char *name, const void *value, size_t val_len)) { + int first_err = 0; for (auto *backend : get_preferences()->backends_) { char name[KEY_BUFFER_SIZE]; backend->format_key(name, sizeof(name)); int err = cb(name, backend->data.data(), backend->data.size()); - ESP_LOGD(TAG, "save in flash, name %s, len %zu, err %d", name, backend->data.size(), err); + if (err) { + ESP_LOGE(TAG, "Cannot save setting %s, err %d", name, err); + if (first_err == 0) { + first_err = err; + } + continue; + } + ESP_LOGV(TAG, "save in flash, name %s, len %zu", name, backend->data.size()); } - return 0; + return first_err; } static ZephyrPreferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From dcdc042a4b88cf6555cd60adcfdbfe6588792555 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 17:53:33 -1000 Subject: [PATCH 028/120] [host] Define a no-op InterruptLock (#20974) --- esphome/components/host/helpers.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index f8b7a0c5f08..4896c441f7d 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -31,6 +31,10 @@ bool random_bytes(uint8_t *data, size_t len) { return true; } +// The host has no interrupts to mask +InterruptLock::InterruptLock() {} +InterruptLock::~InterruptLock() {} + // Host platform uses std::mutex for proper thread synchronization Mutex::Mutex() { handle_ = new std::mutex(); } Mutex::~Mutex() { delete static_cast(handle_); } From 687466b6cddef38738e9c93422e8ea1fee38ab55 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 17 Oct 2026 06:14:51 +0200 Subject: [PATCH 029/120] [logger][usb_cdc_acm][core] Add CDC uart to NRF52 (#11634) --- esphome/components/logger/__init__.py | 16 +- esphome/components/usb_cdc_acm/__init__.py | 119 +++++++-- .../components/usb_cdc_acm/usb_cdc_acm.cpp | 43 +++- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 70 +++++- .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 53 +--- .../usb_cdc_acm/usb_cdc_acm_zephyr.cpp | 230 ++++++++++++++++++ esphome/components/zephyr/__init__.py | 67 ++++- .../components/zephyr_mcumgr/ota/__init__.py | 13 +- esphome/core/defines.h | 2 + .../usb_cdc_acm/test.nrf52-adafruit.yaml | 7 + .../usb_cdc_acm/test.nrf52-mcumgr.yaml | 5 + .../usb_cdc_acm/test.nrf52-xiao-ble.yaml | 8 + tests/unit_tests/test_zephyr_cdc_acm.py | 67 +++++ 13 files changed, 605 insertions(+), 95 deletions(-) create mode 100644 esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp create mode 100644 tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml create mode 100644 tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml create mode 100644 tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml create mode 100644 tests/unit_tests/test_zephyr_cdc_acm.py diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 67137214c1e..cb222b39e34 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -31,9 +31,9 @@ from esphome.components.libretiny.const import ( COMPONENT_RTL87XX, ) from esphome.components.zephyr import ( - zephyr_add_cdc_acm, zephyr_add_overlay, zephyr_add_prj_conf, + zephyr_claim_cdc_acm, ) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -360,6 +360,19 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config: ConfigType) -> None: + # Serial logging over USB CDC writes to CDC ACM port 0 with uart_poll_out() + if ( + CORE.is_nrf52 + and config[CONF_BAUD_RATE] != 0 + and config.get(CONF_HARDWARE_UART) == USB_CDC + ): + zephyr_claim_cdc_acm(0, "logger", write_only=True) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(CoroPriority.EARLY_INIT) async def to_code(config: ConfigType) -> None: baud_rate: int = config[CONF_BAUD_RATE] @@ -521,7 +534,6 @@ async def _late_logger_init(config: ConfigType) -> None: if config[CONF_HARDWARE_UART] == USB_CDC: cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") zephyr_add_prj_conf("UART_LINE_CTRL", True) - zephyr_add_cdc_acm(config, 0) # Register at end for safe mode await cg.register_component(log, config) diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index 08cd13e3d27..bdc1a964ee1 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator + import esphome.codegen as cg from esphome.components import esp32, uart from esphome.components.esp32 import ( @@ -8,15 +10,30 @@ from esphome.components.esp32 import ( VARIANT_ESP32S31, add_idf_sdkconfig_option, ) +from esphome.components.uart import debug_to_code, maybe_empty_debug, uart_ns +from esphome.components.zephyr import zephyr_add_prj_conf, zephyr_claim_cdc_acm import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_RX_BUFFER_SIZE, CONF_TX_BUFFER_SIZE +from esphome.const import ( + CONF_DEBUG, + CONF_DISABLED, + CONF_ID, + CONF_RX_BUFFER_SIZE, + CONF_TX_BUFFER_SIZE, +) +from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] -AUTO_LOAD = ["uart"] -DEPENDENCIES = ["tinyusb"] DOMAIN = "usb_cdc_acm" + +def AUTO_LOAD() -> list[str]: + if CORE.using_zephyr: + return ["uart"] + return ["uart", "tinyusb"] + + CONF_INTERFACES = "interfaces" usb_cdc_acm_ns = cg.esphome_ns.namespace("usb_cdc_acm") @@ -30,9 +47,24 @@ USBCDCACMInstance = usb_cdc_acm_ns.class_( INTERFACE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.All(cv.declare_id(USBCDCACMInstance), uart.mark_unclocked), + cv.Optional(CONF_DEBUG): maybe_empty_debug, + cv.Optional(CONF_DISABLED): cv.All(cv.boolean, cv.only_on_nrf52), } ) + +def _enabled_interfaces(config: ConfigType) -> Iterator[tuple[int, ConfigType]]: + for index, conf in enumerate(config[CONF_INTERFACES]): + if not conf.get(CONF_DISABLED, False): + yield index, conf + + +def _validate_any_interface_enabled(config: ConfigType) -> ConfigType: + if next(_enabled_interfaces(config), None) is None: + raise cv.Invalid("At least one interface must be enabled") + return config + + # Main component schema CONFIG_SCHEMA = cv.All( cv.Schema( @@ -50,37 +82,70 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), - esp32.only_on_variant( - supported=[ - VARIANT_ESP32H4, - VARIANT_ESP32P4, - VARIANT_ESP32S2, - VARIANT_ESP32S3, - VARIANT_ESP32S31, - ], + _validate_any_interface_enabled, + cv.Any( + cv.only_on_nrf52, + cv.All( + cv.only_on_esp32, + esp32.only_on_variant( + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], + ), + ), ), ) +def _final_validate(config: ConfigType) -> None: + if not CORE.using_zephyr: + return + for index, _ in _enabled_interfaces(config): + zephyr_claim_cdc_acm(index, DOMAIN) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - # Create and register interface instances - for interface_index, interface_conf in enumerate(config[CONF_INTERFACES]): - interface = cg.new_Pvariable(interface_conf[CONF_ID]) - await cg.register_parented(interface, var) - cg.add(interface.set_interface_number(interface_index)) - cg.add(var.add_interface(interface)) - - # Configure TinyUSB with the correct number of CDC interfaces num_interfaces = len(config[CONF_INTERFACES]) - add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_COUNT", num_interfaces) - add_idf_sdkconfig_option( - "CONFIG_TINYUSB_CDC_RX_BUFSIZE", config[CONF_RX_BUFFER_SIZE] - ) - add_idf_sdkconfig_option( - "CONFIG_TINYUSB_CDC_TX_BUFSIZE", config[CONF_TX_BUFFER_SIZE] - ) cg.add_define("ESPHOME_MAX_USB_CDC_INSTANCES", num_interfaces) + # Create and register interface instances + for index, conf in _enabled_interfaces(config): + args = ( + (MockObj(f"DEVICE_DT_GET_OR_NULL(DT_NODELABEL(cdc_acm_uart{index}))"),) + if CORE.using_zephyr + else () + ) + interface = cg.new_Pvariable(conf[CONF_ID], *args) + await cg.register_parented(interface, var) + cg.add(interface.set_interface_number(index)) + cg.add(var.add_interface(interface)) + if (debug := conf.get(CONF_DEBUG)) is not None: + cg.add_global(uart_ns.using) + await debug_to_code(debug, interface) + if CORE.using_zephyr: + zephyr_add_prj_conf("UART_LINE_CTRL", True) + zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True) + # Every CDC ACM instance (the logger's and any the board tree adds) holds a transfer + # slot per endpoint; the default of 4 leaves a third instance without any + zephyr_add_prj_conf("USB_MAX_NUM_TRANSFERS", 8) + cg.add_define("ESPHOME_CDC_RX_RING_BUFFER_SIZE", config[CONF_RX_BUFFER_SIZE]) + cg.add_define("ESPHOME_CDC_TX_RING_BUFFER_SIZE", config[CONF_TX_BUFFER_SIZE]) + else: + # Configure TinyUSB with the correct number of CDC interfaces + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_COUNT", num_interfaces) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_RX_BUFSIZE", config[CONF_RX_BUFFER_SIZE] + ) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_TX_BUFSIZE", config[CONF_TX_BUFFER_SIZE] + ) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index f24339b4bf3..da12a10b16a 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -1,7 +1,8 @@ #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ - defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ZEPHYR) #include "usb_cdc_acm.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::usb_cdc_acm { @@ -15,6 +16,7 @@ USBCDCACMComponent *global_usb_cdc_component = nullptr; // NOLINT(cppcoreguidel // USBCDCACMInstance Implementation //============================================================================== +// NOLINTBEGIN(clang-analyzer-unix.Malloc) void USBCDCACMInstance::queue_line_state_event(bool dtr, bool rts) { // Allocate event from pool CDCEvent *event = this->event_pool_.allocate(); @@ -53,6 +55,7 @@ void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_ this->event_queue_.push(event); App.wake_loop_threadsafe(); } +// NOLINTEND(clang-analyzer-unix.Malloc) void USBCDCACMInstance::process_events_() { // Process all pending events from the queue @@ -113,6 +116,44 @@ void USBCDCACMInstance::process_events_() { } } +bool USBCDCACMInstance::peek_byte(uint8_t *data) { + if (this->has_peek_) { + *data = this->peek_buffer_; + return true; + } + + if (this->read_byte(&this->peek_buffer_)) { + *data = this->peek_buffer_; + this->has_peek_ = true; + return true; + } + + return false; +} + +bool USBCDCACMInstance::consume_peek_(uint8_t *&data, size_t &len) { + if (!this->has_peek_) { + return false; + } + // No debug callback here: peek_byte() read this byte through read_array(), which already reported it + *data++ = this->peek_buffer_; + this->has_peek_ = false; + return --len == 0; +} + +void USBCDCACMInstance::record_tx_drop_(size_t dropped) { + // During a sustained host stall the buffer stays full, so this runs for every write; throttle the + // warning. The total is never reset, so each line accounts for every byte dropped so far and the + // first drop since boot is logged unthrottled. + const bool first_drop = this->tx_dropped_bytes_ == 0; + this->tx_dropped_bytes_ += dropped; + const uint32_t now = millis(); + if (first_drop || (now - this->tx_dropped_log_ms_) >= LOG_THROTTLE_MS) { + this->tx_dropped_log_ms_ = now; + ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %zu bytes dropped total", this->itf_, this->tx_dropped_bytes_); + } +} + //============================================================================== // USBCDCACMComponent Implementation //============================================================================== diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 83cb5de89f8..4dc9c54c985 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -1,6 +1,6 @@ #pragma once #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ - defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ZEPHYR) #include "esphome/core/component.h" #include "esphome/core/event_pool.h" @@ -11,14 +11,22 @@ #include #include #include +#ifdef USE_ZEPHYR +#include +#include +#else #include "freertos/ringbuf.h" #include "esp_err.h" #include "tinyusb_cdc_acm.h" +#endif namespace esphome::usb_cdc_acm { static const uint8_t EVENT_QUEUE_SIZE = 12; +// Minimum interval between repeated warnings while a host stall persists +static constexpr uint32_t LOG_THROTTLE_MS = 1000; +#ifndef USE_ZEPHYR // Drain up to out_buf_sz bytes from a byte ring buffer, handling FreeRTOS's wrapped // case with a second read. Shared with the cdc_acm_uart bridge platform, whose worker // tasks drain the same ring buffers. @@ -47,6 +55,7 @@ inline esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, return ESP_OK; } +#endif // Callback types for line coding and line state changes using LineCodingCallback = std::function; @@ -93,12 +102,19 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parenteditf_ = itf; } // Get the CDC port number for this instance uint8_t get_itf() const { return this->itf_; } +#ifdef USE_ZEPHYR + explicit USBCDCACMInstance(const device *uart_dev) : uart_dev_(uart_dev) {} +#else + static void usb_tx_task_fn(void *arg); + void usb_tx_task(); + // Ring buffer accessors for bridge components RingbufHandle_t get_tx_ringbuf() const { return this->usb_tx_ringbuf_; } RingbufHandle_t get_rx_ringbuf() const { return this->usb_rx_ringbuf_; } // Task handle accessor for notifying TX task TaskHandle_t get_tx_task_handle() const { return this->usb_tx_task_handle_; } +#endif // Callback registration for line coding and line state changes void set_line_coding_callback(LineCodingCallback callback) { this->line_coding_callback_ = std::move(callback); } @@ -108,9 +124,6 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parenteddebug_callback_.call(uart::UART_DIRECTION_RX, data[i]); + } +#endif + } + void report_tx_debug_(const uint8_t *data, size_t len) { +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(uart::UART_DIRECTION_TX, data[i]); + } +#endif + } +#ifdef USE_ZEPHYR + static void uart_irq_handler(const device *dev, void *instance); + void uart_rx_process_(); + void uart_tx_process_(); + + const device *uart_dev_; + uint32_t last_line_poll_ms_{0}; + ring_buf rx_ringbuf_; + ring_buf tx_ringbuf_; + std::atomic rx_irq_disabled_{false}; + std::atomic tx_irq_disabled_{false}; + // Set by the IRQ handler when our TX ring buffer and the driver's are both empty + std::atomic tx_drained_{false}; + bool dtr_{false}; + bool rts_{false}; + uint8_t rx_ringbuf_data_[ESPHOME_CDC_RX_RING_BUFFER_SIZE]; + uint8_t tx_ringbuf_data_[ESPHOME_CDC_TX_RING_BUFFER_SIZE]; +#else // True while TX bytes are still in the ring buffer or held by the TX task bool tx_pending_(); TaskHandle_t usb_tx_task_handle_{nullptr}; @@ -146,18 +197,19 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented ops instead of inlining // them; atomic inlines correctly on all platforms. std::atomic usb_tx_busy_{0}; +#endif // Running total of bytes dropped by write_array() (never reset), and the timestamp // of the last "buffer full" log line (throttled so a sustained host stall doesn't // flood the log). - uint32_t tx_dropped_bytes_{0}; + size_t tx_dropped_bytes_{0}; uint32_t tx_dropped_log_ms_{0}; + // User-registered callbacks (called from main loop) + LineCodingCallback line_coding_callback_{nullptr}; + LineStateCallback line_state_callback_{nullptr}; // RX buffer for peek functionality uint8_t peek_buffer_{0}; bool has_peek_{false}; uint8_t itf_{0}; - // User-registered callbacks (called from main loop) - LineCodingCallback line_coding_callback_{nullptr}; - LineStateCallback line_state_callback_{nullptr}; // Lock-free queue and event pool for cross-task event passing // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 9ac94802e17..0f02ddf3c7a 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -29,9 +29,6 @@ static constexpr size_t USB_TX_TASK_STACK_SIZE_VV = 8192; // the final TinyUSB flush share this budget. static constexpr uint32_t FLUSH_TIMEOUT_MS = 100; -// Minimum interval between repeated warnings while a host stall persists. -static constexpr uint32_t LOG_THROTTLE_MS = 1000; - static USBCDCACMInstance *get_instance_by_itf(int itf) { if (global_usb_cdc_component == nullptr) { return nullptr; @@ -278,41 +275,15 @@ void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { // Write data to TX ring buffer BaseType_t send_res = xRingbufferSend(this->usb_tx_ringbuf_, data, len, 0); if (send_res != pdTRUE) { - // During a sustained host stall the ring buffer stays full (that is the intended - // backpressure), so this path runs for every write; throttle the warning so the - // log stays readable. The counter is a running total that is never reset: each - // line reports all bytes dropped so far, so bytes dropped in the tail of one - // stall are still accounted for by the next line, whenever that is. It also makes - // the very first drop since boot detectable, which is logged unthrottled. - const bool first_drop = this->tx_dropped_bytes_ == 0; - this->tx_dropped_bytes_ += len; - const uint32_t now = millis(); - if (first_drop || (now - this->tx_dropped_log_ms_) >= LOG_THROTTLE_MS) { - this->tx_dropped_log_ms_ = now; - ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %" PRIu32 " bytes dropped total", this->itf_, this->tx_dropped_bytes_); - } + // A full ring buffer is the intended backpressure during a host stall + this->record_tx_drop_(len); return; } - // Notify TX task that data is available if (this->usb_tx_task_handle_ != nullptr) { xTaskNotifyGive(this->usb_tx_task_handle_); } -} - -bool USBCDCACMInstance::peek_byte(uint8_t *data) { - if (this->has_peek_) { - *data = this->peek_buffer_; - return true; - } - - if (this->read_byte(&this->peek_buffer_)) { - *data = this->peek_buffer_; - this->has_peek_ = true; - return true; - } - - return false; + this->report_tx_debug_(data, len); } bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { @@ -320,20 +291,12 @@ bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { return true; } + if (this->consume_peek_(data, len)) { + return true; + } size_t original_len = len; size_t bytes_read = 0; - // First, use the peek buffer if available - if (this->has_peek_) { - data[0] = this->peek_buffer_; - this->has_peek_ = false; - bytes_read = 1; - data++; - if (--len == 0) { // Decrement len first, then check it... - return true; // No more to read - } - } - // Read remaining bytes from RX ring buffer size_t rx_size = 0; uint8_t *buf = static_cast(xRingbufferReceiveUpTo(this->usb_rx_ringbuf_, &rx_size, 0, len)); @@ -343,6 +306,7 @@ bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { memcpy(data, buf, rx_size); vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + this->report_rx_debug_(data, rx_size); bytes_read += rx_size; data += rx_size; len -= rx_size; @@ -358,6 +322,7 @@ bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { memcpy(data, buf, rx_size); vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + this->report_rx_debug_(data, rx_size); bytes_read += rx_size; return bytes_read == original_len; @@ -421,7 +386,5 @@ uart::UARTFlushResult USBCDCACMInstance::flush() { } } -void USBCDCACMInstance::check_logger_conflict() {} - } // namespace esphome::usb_cdc_acm #endif diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp new file mode 100644 index 00000000000..ab3f0531b88 --- /dev/null +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp @@ -0,0 +1,230 @@ +#if defined(USE_ZEPHYR) +#include "usb_cdc_acm.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include +#include +#include "esphome/components/zephyr/cdc_acm.h" + +// flush() depends on how the legacy cdc_acm driver reports TX state (see uart_tx_process_()) +#ifdef CONFIG_USB_DEVICE_STACK_NEXT +#error "usb_cdc_acm supports only the legacy USB device stack (CONFIG_USB_DEVICE_STACK_NEXT must be disabled)" +#endif + +//============================================================================== +// USBCDCACMInstance Implementation +//============================================================================== +namespace esphome::usb_cdc_acm { + +ESPHOME_LOG_TAG(TAG, "usb_cdc_acm"); +static constexpr uint32_t FLUSH_TIMEOUT_MS = 500; +static constexpr uint32_t LINE_STATE_POLL_MS = 100; + +void USBCDCACMInstance::uart_tx_process_() { + uint8_t *data; + uint32_t send_len = ring_buf_get_claim(&this->tx_ringbuf_, &data, UINT32_MAX); + if (send_len == 0) { + // Our ring buffer is empty. Here (tx_ready set) cdc_acm reports the free space in its own + // TX ring buffer. tx_ready is cleared before each USB transfer and set again when it + // completes, so a fully free driver buffer means the host has received everything. + if (uart_irq_tx_ready(this->uart_dev_) == CONFIG_USB_CDC_ACM_RINGBUF_SIZE) { + this->tx_drained_ = true; + } + uart_irq_tx_disable(this->uart_dev_); + return; + } + int sent = uart_fifo_fill(this->uart_dev_, data, send_len); + if (sent <= 0) { + // No progress: the USB device is suspended (host asleep, hub power save) or not configured. + // fill() then returns 0 without clearing tx_ready, and irq_update() always returns 1 for + // cdc_acm, so the IRQ handler would loop forever on the cooperative USB work queue and + // block the main loop. Disabling TX ends that loop, because irq_is_pending() only reports + // TX while the TX IRQ is enabled. loop() turns it back on while data is waiting. + ring_buf_get_finish(&this->tx_ringbuf_, 0); + uart_irq_tx_disable(this->uart_dev_); + this->tx_irq_disabled_ = true; + return; + } + ring_buf_get_finish(&this->tx_ringbuf_, sent); +} + +void USBCDCACMInstance::uart_rx_process_() { + uint8_t *data; + uint32_t recv_len = ring_buf_put_claim(&this->rx_ringbuf_, &data, UINT32_MAX); + if (recv_len == 0) { + // Ring buffer full: disable RX IRQ so the ISR stops firing. read_array() re-enables it + // once it has freed space. + uart_irq_rx_disable(this->uart_dev_); + this->rx_irq_disabled_ = true; + return; + } + int rx = uart_fifo_read(this->uart_dev_, data, recv_len); + if (rx < 0) { + ESP_LOGE(TAG, "Failed to read UART FIFO, err %d", rx); + } + ring_buf_put_finish(&this->rx_ringbuf_, rx > 0 ? rx : 0); +} + +void USBCDCACMInstance::uart_irq_handler(const device *dev, void *instance) { + USBCDCACMInstance *thiz = static_cast(instance); + while (uart_irq_update(dev) && uart_irq_is_pending(dev)) { + if (uart_irq_rx_ready(dev)) { + thiz->uart_rx_process_(); + } + + // No early exit here: pending RX data must still be read. The loop ends once neither + // RX nor TX is pending (both process functions disable their IRQ when they cannot progress). + if (uart_irq_tx_ready(dev)) { + thiz->uart_tx_process_(); + } + } +} + +void USBCDCACMInstance::setup() { + if (!device_is_ready(this->uart_dev_)) { + ESP_LOGE(TAG, "UART Bus %s: is not ready.", this->uart_dev_->name); + this->get_parent()->mark_failed(); + return; + } + usb_enable(nullptr); + ring_buf_init(&this->rx_ringbuf_, ESPHOME_CDC_RX_RING_BUFFER_SIZE, this->rx_ringbuf_data_); + ring_buf_init(&this->tx_ringbuf_, ESPHOME_CDC_TX_RING_BUFFER_SIZE, this->tx_ringbuf_data_); + // The driver only reports the bit rate; the frame format is fixed on USB + this->data_bits_ = 8; + this->stop_bits_ = 1; +#if defined(CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT) + zephyr::global_cdc_acm->add_on_rate_callback([this](const device *dev, uint32_t bit_rate) { + if (dev == this->uart_dev_) { + // Queue event for processing in main loop + this->queue_line_coding_event(bit_rate, this->stop_bits_, this->parity_, this->data_bits_); + } + }); +#endif + uart_irq_callback_user_data_set(this->uart_dev_, uart_irq_handler, this); + + uart_irq_rx_enable(this->uart_dev_); +} + +void USBCDCACMInstance::loop() { + // Retry TX stopped by uart_tx_process_() (e.g. after the host resumes from suspend) + if (this->tx_irq_disabled_ && !ring_buf_is_empty(&this->tx_ringbuf_)) { + this->tx_irq_disabled_ = false; + uart_irq_tx_enable(this->uart_dev_); + } + // Line state is polled here on the main loop, so it needs no event queue + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_line_poll_ms_ >= LINE_STATE_POLL_MS) { + this->last_line_poll_ms_ = now; + uint32_t dtr_val = 0; + uint32_t rts_val = 0; + uart_line_ctrl_get(this->uart_dev_, UART_LINE_CTRL_DTR, &dtr_val); + uart_line_ctrl_get(this->uart_dev_, UART_LINE_CTRL_RTS, &rts_val); + const bool dtr = dtr_val != 0; + const bool rts = rts_val != 0; + if (dtr != this->dtr_) { + ESP_LOGD(TAG, "UART Bus %s: %s", this->uart_dev_->name, + dtr ? LOG_STR_LITERAL("Opened") : LOG_STR_LITERAL("Closed")); + } + if (dtr != this->dtr_ || rts != this->rts_) { + ESP_LOGV(TAG, "Line state device %s: DTR=%d, RTS=%d", this->uart_dev_->name, dtr, rts); + this->dtr_ = dtr; + this->rts_ = rts; + if (this->line_state_callback_ != nullptr) { + this->line_state_callback_(dtr, rts); + } + } + } + // Line coding changes arrive from the USB thread through the event queue + this->process_events_(); +} + +void USBCDCACMInstance::dump_config() { + ESP_LOGCONFIG(TAG, + "UART Bus %s:\n" + " Opened: %s\n" + " Baud Rate: %" PRIu32 " baud\n" + " Data Bits: %u\n" + " Parity: %s\n" + " Stop bits: %u", + this->uart_dev_->name, YESNO(this->dtr_), this->baud_rate_, this->data_bits_, + LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); +} + +size_t USBCDCACMInstance::available() { + uint32_t size = ring_buf_size_get(&this->rx_ringbuf_) + (this->has_peek_ ? 1 : 0); + ESP_LOGVV(TAG, "UART Bus %s: available %" PRIu32, this->uart_dev_->name, size); + return size; +} + +bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { + if (len == 0) { + return true; + } + if (this->available() < len) { + return false; + } + + if (this->consume_peek_(data, len)) { + return true; + } + const uint32_t got = ring_buf_get(&this->rx_ringbuf_, data, len); + // Space is free again: resume RX stopped by uart_rx_process_() + if (got > 0 && this->rx_irq_disabled_.exchange(false)) { + uart_irq_rx_enable(this->uart_dev_); + } + if (got != len) { + ESP_LOGE(TAG, "UART CDC unexpected size"); + return false; + } + this->report_rx_debug_(data, len); + return true; +} + +uart::UARTFlushResult USBCDCACMInstance::flush() { + // The driver only moves data while the host reads the port. With no host listening (DTR off) + // the data cannot drain, so do not stall the caller waiting for it. + if (!this->dtr_) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } + // flush() and write_array() both run on the main loop, so nothing is added while we wait. + // The IRQ handler sets tx_drained_ once both our ring buffer and the driver are empty. + this->tx_drained_ = false; + // A stall may have left the flag set; clear it once so a handler run during the wait is not undone. + // The handler sets it again if the device is still suspended. + this->tx_irq_disabled_ = false; + uint32_t start = millis(); + while (true) { + // Wakes the IRQ handler now if the driver is idle, or after the transfer in progress + uart_irq_tx_enable(this->uart_dev_); + // ring_buf_is_empty() is not enough: it is already true while the IRQ handler holds + // claimed bytes that it has not yet copied into the driver. + if (this->tx_drained_ && ring_buf_space_get(&this->tx_ringbuf_) == ESPHOME_CDC_TX_RING_BUFFER_SIZE) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; + } + // USB device suspended or not configured (see uart_tx_process_()): nothing will drain + if (this->tx_irq_disabled_) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } + if (millis() - start > FLUSH_TIMEOUT_MS) { + ESP_LOGW(TAG, "Flush timeout"); + return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; + } + delay(1); + } +} + +void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { + if (!device_is_ready(this->uart_dev_)) { + return; + } + uint32_t recv_len = ring_buf_put(&this->tx_ringbuf_, data, len); + if (recv_len < len) { + this->record_tx_drop_(len - recv_len); + } + uart_irq_tx_enable(this->uart_dev_); + // Only report the bytes that were queued, not the dropped ones + this->report_tx_debug_(data, recv_len); +} + +} // namespace esphome::usb_cdc_acm +#endif diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 4f69f00339e..1ba3ec098cd 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -1,7 +1,7 @@ from collections.abc import Callable from pathlib import Path import textwrap -from typing import TypedDict +from typing import NamedTuple, TypedDict import esphome.codegen as cg import esphome.config_validation as cv @@ -68,6 +68,14 @@ class Section: ) +class CdcAcmUser(NamedTuple): + name: str + # Only writes with uart_poll_out() and never sets the IRQ callback (the logger) + write_only: bool + # Can share the port with write-only users (protocols that skip unframed text) + allow_write_only: bool + + class ZephyrData(TypedDict): board: str bootloader: str @@ -78,6 +86,8 @@ class ZephyrData(TypedDict): kconfig: str sysbuild: bool overlay_builder: list[Callable[[], str]] + # CDC ACM port number -> components that use it + cdc_acm_users: dict[int, list[CdcAcmUser]] def zephyr_set_core_data(config: ConfigType) -> None: @@ -89,6 +99,7 @@ def zephyr_set_core_data(config: ConfigType) -> None: "": "", }, # set empty to make sure that overlay is cleared after config change overlay_builder=[], + cdc_acm_users={}, extra_build_files={}, pm_static=[], kconfig="", @@ -177,6 +188,9 @@ def zephyr_to_code(config: ConfigType) -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _cdc_acm_to_code(config: ConfigType) -> None: + # Runs last so these required options override the optional defaults that + # platforms (nrf52) set during their own to_code + _cdc_acm_add_ports() need_cdc_cb = zephyr_data()[KEY_PRJ_CONF][""].get( "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", (False,) )[0] @@ -205,7 +219,46 @@ def _format_prj_conf_val(value: PrjConfValueType) -> str: raise ValueError -def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None: +def zephyr_claim_cdc_acm( + id: int, + user: str, + *, + write_only: bool = False, + allow_write_only: bool = False, +) -> None: + """Reserve CDC ACM port `id` for `user`. Call from FINAL_VALIDATE_SCHEMA. + + zephyr_to_code() later adds the device tree node and Kconfig options for + every claimed port, so components do not set up the port themselves. + + Components on the same port share one device, and the device has only one + IRQ callback slot: the component set up last takes all received data from + the other. So a port normally has one user. The exception is a `write_only` + user (the logger), which never sets the IRQ callback; it may share the port + with a user that sets `allow_write_only` because its protocol skips text + that is not part of its own frames (mcumgr). + """ + new = CdcAcmUser(user, write_only, allow_write_only) + users = zephyr_data()["cdc_acm_users"].setdefault(id, []) + for other in users: + if other.name == user: + return + if (new.write_only and other.allow_write_only) or ( + other.write_only and new.allow_write_only + ): + continue + raise cv.Invalid( + f"USB CDC ACM port {id} (cdc_acm_uart{id}) is used by both " + f"'{other.name}' and '{user}'. Each port can only be used by one component." + ) + users.append(new) + + +def _cdc_acm_add_ports() -> None: + """Set up every CDC ACM port claimed with zephyr_claim_cdc_acm().""" + users = zephyr_data()["cdc_acm_users"] + if not users: + return framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if CORE.is_nrf52 and framework_ver >= cv.Version(3, 2, 0): zephyr_add_prj_conf("CONFIG_USB_DEVICE_STACK_NEXT", False) @@ -217,12 +270,16 @@ def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None: zephyr_add_prj_conf("USB_DEVICE_REMOTE_WAKEUP", False) # prevent logging when buffer is full zephyr_add_prj_conf("USB_CDC_ACM_LOG_LEVEL_WRN", True) - zephyr_add_overlay( + nodes = "".join( f""" - &zephyr_udc0 {{ cdc_acm_uart{id}: cdc_acm_uart{id} {{ compatible = "zephyr,cdc-acm-uart"; - }}; + }};""" + for id in sorted(users) + ) + zephyr_add_overlay( + f""" + &zephyr_udc0 {{{nodes} }}; """ ) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 44c8be9e374..5b438bbda1c 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -5,9 +5,9 @@ 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 ( HexValue, - zephyr_add_cdc_acm, zephyr_add_overlay, zephyr_add_prj_conf, + zephyr_claim_cdc_acm, zephyr_data, ) from esphome.components.zephyr.const import ( @@ -114,6 +114,11 @@ def _validate_bootloader(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> None: _validate_ble_server(config) _validate_bootloader(config) + if (uart := config[CONF_TRANSPORT].get(CONF_HARDWARE_UART)) is not None: + cdc_id = UARTS[uart][1] + if cdc_id >= 0: + # SMP frames are marked, so log text from the logger on the same port is skipped + zephyr_claim_cdc_acm(cdc_id, "zephyr_mcumgr", allow_write_only=True) FINAL_VALIDATE_SCHEMA = _final_validate @@ -160,11 +165,7 @@ async def to_code(config: ConfigType) -> None: request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: zephyr_add_prj_conf("SERIAL", True) - uart = UARTS[transport[CONF_HARDWARE_UART]] - uart_name = uart[0] - cdc_id = uart[1] - if cdc_id >= 0: - zephyr_add_cdc_acm(config, cdc_id) + uart_name = UARTS[transport[CONF_HARDWARE_UART]][0] zephyr_add_prj_conf("MCUMGR_TRANSPORT_UART", True) zephyr_add_prj_conf("BASE64", True) zephyr_add_prj_conf("CONSOLE", True) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cd0f0d39103..a5fb354199b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -761,6 +761,8 @@ // Default counts for static analysis #define ESPHOME_AREA_COUNT 10 +#define ESPHOME_CDC_RX_RING_BUFFER_SIZE 256 +#define ESPHOME_CDC_TX_RING_BUFFER_SIZE 256 #define ESPHOME_COMPONENT_COUNT 50 #define ESPHOME_DEVICE_COUNT 10 #define ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT 1 diff --git a/tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml b/tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..8657c03ecf8 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml @@ -0,0 +1,7 @@ +# Interface 0 needs CDC ACM port 0, which the logger uses by default on nRF52 +logger: + hardware_uart: UART0 + +usb_cdc_acm: + interfaces: + id: usb_cdc_acm1 diff --git a/tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml b/tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml new file mode 100644 index 00000000000..40d58596e59 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml @@ -0,0 +1,5 @@ +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 + disabled: true + - id: usb_cdc_acm2 diff --git a/tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml b/tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml new file mode 100644 index 00000000000..871e11705ce --- /dev/null +++ b/tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml @@ -0,0 +1,8 @@ +# Interface 0 needs CDC ACM port 0, which the logger uses by default on nRF52 +logger: + baud_rate: 0 + +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 + - id: usb_cdc_acm2 diff --git a/tests/unit_tests/test_zephyr_cdc_acm.py b/tests/unit_tests/test_zephyr_cdc_acm.py new file mode 100644 index 00000000000..8d010cd0388 --- /dev/null +++ b/tests/unit_tests/test_zephyr_cdc_acm.py @@ -0,0 +1,67 @@ +"""Tests for CDC ACM port reservation in the Zephyr component.""" + +import pytest + +from esphome.components.zephyr import CdcAcmUser, zephyr_claim_cdc_acm +from esphome.components.zephyr.const import KEY_ZEPHYR +import esphome.config_validation as cv +from esphome.core import CORE + + +@pytest.fixture(autouse=True) +def clean_zephyr_data() -> None: + CORE.data[KEY_ZEPHYR] = {"cdc_acm_users": {}} + + +def _users(id: int) -> list[str]: + return [user.name for user in CORE.data[KEY_ZEPHYR]["cdc_acm_users"][id]] + + +def test_different_ports_can_be_claimed() -> None: + zephyr_claim_cdc_acm(0, "logger", write_only=True) + zephyr_claim_cdc_acm(1, "usb_cdc_acm") + + assert _users(0) == ["logger"] + assert _users(1) == ["usb_cdc_acm"] + + +def test_same_user_can_claim_a_port_again() -> None: + zephyr_claim_cdc_acm(0, "usb_cdc_acm") + zephyr_claim_cdc_acm(0, "usb_cdc_acm") + + assert CORE.data[KEY_ZEPHYR]["cdc_acm_users"][0] == [ + CdcAcmUser("usb_cdc_acm", False, False) + ] + + +LOGGER = ("logger", {"write_only": True}) +RAW = ("usb_cdc_acm", {}) +MCUMGR = ("zephyr_mcumgr", {"allow_write_only": True}) + + +@pytest.mark.parametrize( + ("first", "second", "ok"), + [ + (LOGGER, RAW, False), + (RAW, LOGGER, False), + (LOGGER, MCUMGR, True), + (MCUMGR, LOGGER, True), + ], +) +def test_logger_sharing( + first: tuple[str, dict[str, bool]], second: tuple[str, dict[str, bool]], ok: bool +) -> None: + zephyr_claim_cdc_acm(0, first[0], **first[1]) + if not ok: + with pytest.raises(cv.Invalid, match="is used by both"): + zephyr_claim_cdc_acm(0, second[0], **second[1]) + return + zephyr_claim_cdc_acm(0, second[0], **second[1]) + assert _users(0) == [first[0], second[0]] + + +def test_two_irq_users_are_rejected_even_if_they_allow_the_logger() -> None: + zephyr_claim_cdc_acm(0, "zephyr_mcumgr", allow_write_only=True) + + with pytest.raises(cv.Invalid, match="'zephyr_mcumgr' and 'usb_cdc_acm'"): + zephyr_claim_cdc_acm(0, "usb_cdc_acm") From 4dc2b5911bd86a6a4cc9634c4e7e98a32f809938 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 18:21:04 -1000 Subject: [PATCH 030/120] [core] Move CONF_DRIVE_STRENGTH to the shared component constants (#20975) --- esphome/components/const/__init__.py | 1 + esphome/components/esp32/gpio.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 5d900bbd5b4..966db92c6fd 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -30,6 +30,7 @@ CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DESCRIPTION = "description" CONF_DRAW_ROUNDING = "draw_rounding" +CONF_DRIVE_STRENGTH = "drive_strength" CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GREEN_CHANNEL = "green_channel" diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 7753574bc85..f81584891b6 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -5,7 +5,7 @@ from typing import Any from esphome import pins import esphome.codegen as cg -from esphome.components.const import CONF_HOLD_STATE +from esphome.components.const import CONF_DRIVE_STRENGTH, CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -238,7 +238,6 @@ DRIVE_STRENGTHS = { } gpio_num_t = cg.global_ns.enum("gpio_num_t") -CONF_DRIVE_STRENGTH = "drive_strength" ESP32_PIN_SCHEMA = cv.All( pins.gpio_base_schema(ESP32InternalGPIOPin, gpio_pin_number_validator).extend( From 6d664d3fef9598588e102abd3c6be9bcbafd8160 Mon Sep 17 00:00:00 2001 From: Zhaoqi Xu Date: Sat, 17 Oct 2026 13:15:04 +0800 Subject: [PATCH 031/120] [web_server] Report numbers without a state as NA (#20973) --- esphome/components/web_server/web_server.cpp | 13 ++++++++----- esphome/components/web_server/web_server.h | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 04cd0ff43d2..48a2ce4a63b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1216,7 +1216,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); json::JsonBuilder builder; - this->number_json_(obj, obj->state, detail, builder); + this->number_json_(obj, detail, builder); send_json(request, builder); return; } @@ -1236,13 +1236,16 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM } void WebServer::number_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { - web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_STATE, builder); + auto *obj = (number::Number *) (source); + web_server->number_json_(obj, DETAIL_STATE, builder); } void WebServer::number_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { - web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_ALL, builder); + auto *obj = (number::Number *) (source); + web_server->number_json_(obj, DETAIL_ALL, builder); } -void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_config, json::JsonBuilder &builder) { +void WebServer::number_json_(number::Number *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); + const float value = obj->has_state() ? obj->state : NAN; const auto uom_ref = obj->get_unit_of_measurement_ref(); const int8_t accuracy = step_to_accuracy_decimals(obj->traits.get_step()); @@ -1250,7 +1253,7 @@ void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_ // Need two buffers: one for value, one for state with UOM char val_buf[VALUE_ACCURACY_MAX_LEN]; char state_buf[VALUE_ACCURACY_MAX_LEN]; - JsonString val_str = linked("\"NaN\""); + JsonString val_str = linked("NaN"); JsonString state_str = linked("NA"); if (!std::isnan(value)) { value_accuracy_to_buf(val_buf, value, accuracy); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index f8ef12f9bff..d545fbf95dd 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -670,7 +670,7 @@ class WebServer final : public Component, public AsyncWebHandler { void cover_json_(cover::Cover *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_NUMBER - void number_json_(number::Number *obj, float value, JsonDetail start_config, json::JsonBuilder &builder); + void number_json_(number::Number *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_DATE void date_json_(datetime::DateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder); From 375900bfcde799161dd3d72f876ec195bfc930c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 19:54:11 -1000 Subject: [PATCH 032/120] [core] Add LineReader to collect lines in a fixed buffer (#20952) --- esphome/core/config.py | 5 +- esphome/core/defines.h | 1 + esphome/core/line_reader.cpp | 10 ++ esphome/core/line_reader.h | 99 ++++++++++++ script/build_helpers.py | 7 +- tests/components/README.md | 5 +- tests/components/core/__init__.py | 5 +- tests/components/core/line_reader_test.cpp | 180 +++++++++++++++++++++ tests/components/esphome/__init__.py | 4 +- tests/script/test_test_helpers.py | 73 ++++++++- tests/testing_helpers.py | 17 +- 11 files changed, 394 insertions(+), 12 deletions(-) create mode 100644 esphome/core/line_reader.cpp create mode 100644 esphome/core/line_reader.h create mode 100644 tests/components/core/line_reader_test.cpp diff --git a/esphome/core/config.py b/esphome/core/config.py index 531a0276471..667d780f71f 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -1019,7 +1019,10 @@ _filter_platform_sources = filter_source_files_from_platform( # util.cpp holds only the out-of-line MQTT connectivity helpers; without # USE_MQTT the header provides inline stubs and the file must not compile. -_filter_define_sources = filter_source_files_from_defines({"util.cpp": "USE_MQTT"}) +# line_reader.cpp compiles only for components that opt in with USE_LINE_READER. +_filter_define_sources = filter_source_files_from_defines( + {"util.cpp": "USE_MQTT", "line_reader.cpp": "USE_LINE_READER"} +) def FILTER_SOURCE_FILES() -> list[str]: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a5fb354199b..756485e0157 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -114,6 +114,7 @@ #define USE_LIGHT_GAMMA_LUT #define USE_LIGHT_RESUME_EFFECT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL +#define USE_LINE_READER #define USE_LOCK #define USE_LOGGER #define USE_LOGGER_LEVEL_LISTENERS diff --git a/esphome/core/line_reader.cpp b/esphome/core/line_reader.cpp new file mode 100644 index 00000000000..e821dd0bc4e --- /dev/null +++ b/esphome/core/line_reader.cpp @@ -0,0 +1,10 @@ +#include "esphome/core/line_reader.h" +#include "esphome/core/log.h" + +namespace esphome { + +void log_line_dropped(const char *tag, size_t max_len) { + ESP_LOGW(tag, "Line over %u chars dropped", static_cast(max_len)); +} + +} // namespace esphome diff --git a/esphome/core/line_reader.h b/esphome/core/line_reader.h new file mode 100644 index 00000000000..201267ec3d8 --- /dev/null +++ b/esphome/core/line_reader.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include + +namespace esphome { + +/// Logs the shared "line dropped" warning; out of line to keep each caller small. Components that call it +/// (directly or via log_if_overflowed) add cg.add_define("USE_LINE_READER") so line_reader.cpp is compiled. +void log_line_dropped(const char *tag, size_t max_len); + +/// Collects bytes into NUL terminated lines of up to N - 1 characters; IGNORE_CHAR bytes are skipped and an +/// overlong line is dropped up to its terminator. +template class LineReader { + // pos_ is the length so far; N means "overflowed on this byte", N + 1 "dropping up to the terminator" + using pos_t = std::conditional_t<(N < 0xFE), uint8_t, uint16_t>; + static_assert(N >= 2 && N < 0xFFFE, "LineReader size out of range"); + static_assert(TERMINATOR_CHAR != IGNORE_CHAR, "LineReader terminator and ignore byte must differ"); + + public: + /// Feed one byte; returns the completed line (empty lines included), valid until the next call, or nullptr. + char *feed(uint8_t c) { + size_t len = 0; + return this->feed(c, len); + } + + /// As feed(c), and sets len to the line length when a line is returned. + char *feed(uint8_t c, size_t &len) { + pos_t pos = this->pos_; + if (pos == N) { + this->pos_ = pos = N + 1; + } + if (c == static_cast(IGNORE_CHAR)) { + return nullptr; + } + if (c == static_cast(TERMINATOR_CHAR)) { + this->pos_ = 0; + if (pos > N) { + return nullptr; + } + this->buf_[pos] = '\0'; + len = pos; + return this->buf_; + } + if (pos < N - 1) { + this->buf_[pos] = static_cast(c); + this->pos_ = pos + 1; + } else if (pos == N - 1) { + this->pos_ = N; + } + return nullptr; + } + + /// Length of the partial line; 0 after a line is returned and while an overlong line is dropped. + size_t length() const { return this->pos_ < N ? this->pos_ : 0; } + + /// The partial line, NUL terminated, for prompts with no line end; the first N - 1 characters on the byte + /// that overflowed, empty while the rest of that line is dropped. + const char *partial() { + this->buf_[this->end_()] = '\0'; + return this->buf_; + } + + /// True only right after the feed() that found the line too long; the rest of that line is then dropped. + bool overflowed() const { return this->pos_ == N; } + + /// Log the shared "line dropped" warning when the last feed() overflowed. + void log_if_overflowed(const char *tag) const { + if (this->overflowed()) { + log_line_dropped(tag, N - 1); + } + } + + /// Forget the partial line and start a new one with the next byte. + void reset() { this->pos_ = 0; } + + /// Start a new line with c, e.g. the byte that overflowed; c must not be TERMINATOR_CHAR or IGNORE_CHAR. + void restart_with(uint8_t c) { + this->buf_[0] = static_cast(c); + this->pos_ = 1; + } + + /// Drop everything up to and including the next terminator. + void discard_line() { this->pos_ = N + 1; } + + protected: + pos_t end_() const { + if (this->pos_ < N) { + return this->pos_; + } + return this->pos_ == N ? N - 1 : 0; + } + + char buf_[N]; + pos_t pos_{0}; +}; + +} // namespace esphome diff --git a/script/build_helpers.py b/script/build_helpers.py index 885a5a14758..c15c7118b23 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -285,10 +285,15 @@ def load_test_manifest_overrides( defines ``override_manifest(manifest)``, it is called to customise the override (e.g. ``manifest.enable_codegen()``). """ + wrapped: dict[str, ComponentManifestOverride] = {} for comp_name in components: - override = _wrap_manifest(comp_name) + # tests/components/core and tests/components/esphome both override the + # "esphome" core manifest, so they share one wrapper + manifest_name = ESPHOME_KEY if comp_name == "core" else comp_name + override = wrapped.get(manifest_name) or _wrap_manifest(manifest_name) if override is None: continue + wrapped[manifest_name] = override if "." in comp_name: domain, component = comp_name.split(".", maxsplit=1) diff --git a/tests/components/README.md b/tests/components/README.md index be5e887767e..a28e7f4f89c 100644 --- a/tests/components/README.md +++ b/tests/components/README.md @@ -69,8 +69,9 @@ Key methods: | Method | Effect | |---|---| -| `manifest.enable_codegen()` | Remove the `to_code` suppression, re-enabling code generation | -| `manifest.restore()` | Clear **all** overrides, reverting every attribute to the original | +| `manifest.enable_codegen()` | Remove the `to_code` suppression, re-enabling code generation; a custom `to_code` set by another override is kept | +| `manifest.add_resources(*files)` | Copy extra `FileResource` files into the build; the component's own file list is still read at build time, after `FILTER_SOURCE_FILES` sees the defines | +| `manifest.restore()` | Clear **all** overrides and added resources, reverting every attribute to the original | The function is called after `to_code` has already been set to `None`, so calling `enable_codegen()` is a deliberate opt-in. diff --git a/tests/components/core/__init__.py b/tests/components/core/__init__.py index 34ca4fbe4f0..6d2829511a7 100644 --- a/tests/components/core/__init__.py +++ b/tests/components/core/__init__.py @@ -1,8 +1,9 @@ -from tests.testing_helpers import ComponentManifestOverride +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code def override_manifest(manifest: ComponentManifestOverride) -> None: # core (esphome/core/config.py) must run its to_code during C++ test builds # because it bootstraps the fundamental application infrastructure that all # components depend on (component registration, event loop, etc.). - manifest.enable_codegen() + # USE_LINE_READER keeps line_reader.cpp in the build for its tests. + add_defines_to_code(manifest, "USE_LINE_READER") diff --git a/tests/components/core/line_reader_test.cpp b/tests/components/core/line_reader_test.cpp new file mode 100644 index 00000000000..3b2fad1ce61 --- /dev/null +++ b/tests/components/core/line_reader_test.cpp @@ -0,0 +1,180 @@ +#include +#include +#include +#include "esphome/core/line_reader.h" + +namespace esphome::core::testing { + +using Lines = std::vector; + +// Feeds a string and collects every completed line. +template static Lines feed_all(R &reader, const std::string &input) { + Lines lines; + for (char c : input) { + const char *line = reader.feed(static_cast(c)); + if (line != nullptr) { + EXPECT_EQ(reader.length(), 0u); + lines.emplace_back(line); + } + } + return lines; +} + +static_assert(sizeof(LineReader<80>) == 81); +static_assert(sizeof(LineReader<1025>) == 1028); +// 0xFD is the largest size with a one byte index; 0xFE switches to two bytes +static_assert(sizeof(LineReader<0xFD>) == 0xFE); +static_assert(sizeof(LineReader<0xFE>) == 0x100); + +TEST(LineReaderTest, CrLfLines) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "abc\r\ndef\r\n"), (Lines{"abc", "def"})); +} + +TEST(LineReaderTest, LfOnlyLines) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "abc\ndef\n"), (Lines{"abc", "def"})); +} + +TEST(LineReaderTest, CrOnlyLinesWithCrTerminator) { + LineReader<16, '\r', '\n'> reader; + EXPECT_EQ(feed_all(reader, "R1234\rR0567\r"), (Lines{"R1234", "R0567"})); + // The LF of a CRLF pair is ignored with this setup + EXPECT_EQ(feed_all(reader, "x\r\ny\r\n"), (Lines{"x", "y"})); +} + +TEST(LineReaderTest, CrInsideLineIsIgnored) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "a\rb\n"), (Lines{"ab"})); +} + +TEST(LineReaderTest, EmptyLinesAreReturned) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "\r\n\nx\n"), (Lines{"", "", "x"})); +} + +TEST(LineReaderTest, LineOfExactlyMaxLengthFits) { + LineReader<6> reader; + EXPECT_EQ(feed_all(reader, "12345\n"), (Lines{"12345"})); + EXPECT_FALSE(reader.overflowed()); +} + +TEST(LineReaderTest, OverlongLineIsDroppedUpToTerminator) { + LineReader<6> reader; + int overflow_count = 0; + Lines lines; + for (char c : std::string("123456789\r\nok\n")) { + const char *line = reader.feed(c); + if (reader.overflowed()) { + overflow_count++; + EXPECT_STREQ(reader.partial(), "12345"); + EXPECT_EQ(reader.length(), 0u); + reader.log_if_overflowed("line_reader_test"); + } + if (line != nullptr) + lines.emplace_back(line); + } + EXPECT_EQ(overflow_count, 1); + EXPECT_EQ(lines, (Lines{"ok"})); +} + +TEST(LineReaderTest, SixteenBitIndexOverflowAndRecovery) { + LineReader<300> reader; + const std::string full(299, 'a'); + EXPECT_EQ(feed_all(reader, full + "\n"), (Lines{full})); + int overflow_count = 0; + Lines lines; + for (char c : full + "bcd\r\nok\n") { + const char *line = reader.feed(c); + if (reader.overflowed()) { + overflow_count++; + EXPECT_EQ(std::string(reader.partial()), full); + } + if (line != nullptr) + lines.emplace_back(line); + } + EXPECT_EQ(overflow_count, 1); + EXPECT_EQ(lines, (Lines{"ok"})); +} + +TEST(LineReaderTest, ResetAfterOverflowRestartsAtNextByte) { + LineReader<4> reader; + Lines lines; + for (char c : std::string("abcdefg\n")) { + const char *line = reader.feed(c); + if (reader.overflowed()) + reader.reset(); + if (line != nullptr) + lines.emplace_back(line); + } + // "abc" fills the buffer, 'd' overflows and is dropped, "efg" is the next line + EXPECT_EQ(lines, (Lines{"efg"})); +} + +TEST(LineReaderTest, RestartWithKeepsOverflowByte) { + LineReader<4> reader; + Lines lines; + for (char c : std::string("abcdef\n")) { + const char *line = reader.feed(c); + if (reader.overflowed()) { + reader.restart_with(c); + } + if (line != nullptr) { + lines.emplace_back(line); + } + } + // 'd' overflows and starts the next line + EXPECT_EQ(lines, (Lines{"def"})); +} + +TEST(LineReaderTest, FeedReportsLength) { + LineReader<16> reader; + size_t len = 99; + char *line = nullptr; + for (char c : std::string("ab\rc\n")) { + line = reader.feed(c, len); + } + ASSERT_NE(line, nullptr); + EXPECT_STREQ(line, "abc"); + EXPECT_EQ(len, 3u); + EXPECT_NE(reader.feed('\n', len), nullptr); + EXPECT_EQ(len, 0u); +} + +TEST(LineReaderTest, PartialForPromptWithoutLineEnd) { + LineReader<32> reader; + EXPECT_EQ(feed_all(reader, "DFRobot:/>"), Lines{}); + EXPECT_STREQ(reader.partial(), "DFRobot:/>"); + EXPECT_EQ(reader.length(), 10u); + reader.reset(); + EXPECT_STREQ(reader.partial(), ""); + EXPECT_EQ(feed_all(reader, "sensorStart\r\n"), (Lines{"sensorStart"})); +} + +TEST(LineReaderTest, DiscardLineDropsUntilTerminator) { + LineReader<16> reader; + feed_all(reader, "half"); + reader.discard_line(); + EXPECT_EQ(reader.length(), 0u); + EXPECT_STREQ(reader.partial(), ""); + EXPECT_EQ(feed_all(reader, "rest\nnext\n"), (Lines{"next"})); +} + +TEST(LineReaderTest, CustomIgnoreByte) { + LineReader<8, '\r', static_cast(0xFF)> reader; + EXPECT_EQ(feed_all(reader, "R12\xFF" + "34\r"), + (Lines{"R1234"})); +} + +TEST(LineReaderTest, LineIsWritable) { + LineReader<16> reader; + char *line = nullptr; + for (char c : std::string("a,b\n")) + line = reader.feed(c); + ASSERT_NE(line, nullptr); + line[1] = '\0'; + EXPECT_STREQ(line, "a"); +} + +} // namespace esphome::core::testing diff --git a/tests/components/esphome/__init__.py b/tests/components/esphome/__init__.py index 713c0d5a63a..c1d86f29db4 100644 --- a/tests/components/esphome/__init__.py +++ b/tests/components/esphome/__init__.py @@ -6,7 +6,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # to_code emits the component count the application needs manifest.enable_codegen() # Only the decoder is under test; its ota platform is not in this build - manifest.resources = manifest.resources + [ + manifest.add_resources( FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.c"), FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.h"), - ] + ) diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index 4b05cab3767..d67d7071aba 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -12,7 +12,7 @@ sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve( import build_helpers # noqa: E402 -from esphome.loader import ComponentManifest # noqa: E402 +from esphome.loader import ComponentManifest, FileResource # noqa: E402 from tests.testing_helpers import ComponentManifestOverride # noqa: E402 # --------------------------------------------------------------------------- @@ -241,6 +241,77 @@ def test_load_wraps_top_level_component(tmp_path: Path) -> None: assert installed.to_code is None +def test_load_core_overrides_esphome_manifest(tmp_path: Path) -> None: + """tests/components/core/__init__.py overrides the "esphome" core manifest.""" + core_dir = tmp_path / "core" + core_dir.mkdir() + (core_dir / "__init__.py").write_text( + textwrap.dedent("""\ + def override_manifest(manifest): + manifest.dependencies = ["injected"] + """) + ) + inner = _make_component_manifest() + + with ( + patch("build_helpers.get_component", return_value=inner) as mock_get, + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["core"], tmp_path) + + mock_get.assert_called_once_with("esphome") + key, installed = mock_set.call_args[0] + assert key == "esphome" + assert installed.dependencies == ["injected"] + + +def test_load_core_and_esphome_share_one_wrapper(tmp_path: Path) -> None: + """Both test dirs override the "esphome" manifest; both overrides apply to one wrapper.""" + for name, dep in (("core", "from_core"), ("esphome", "from_esphome")): + (tmp_path / name).mkdir() + (tmp_path / name / "__init__.py").write_text( + textwrap.dedent(f"""\ + def override_manifest(manifest): + manifest.dependencies = manifest.dependencies + ["{dep}"] + """) + ) + inner = _make_component_manifest() + + with ( + patch("build_helpers.get_component", return_value=inner), + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["core", "esphome"], tmp_path) + + mock_set.assert_called_once() + installed: ComponentManifestOverride = mock_set.call_args[0][1] + assert installed.dependencies == ["from_core", "from_esphome"] + + +def test_enable_codegen_keeps_custom_to_code() -> None: + """enable_codegen() lifts the suppression but keeps a to_code another override set.""" + + async def custom_to_code(config): + pass + + override = ComponentManifestOverride(_make_component_manifest()) + override.to_code = custom_to_code + override.enable_codegen() + assert override.to_code is custom_to_code + + +def test_add_resources_reads_component_resources_lazily() -> None: + """add_resources() appends to the resources computed at read time.""" + inner = MagicMock() + inner.resources = [FileResource("pkg", "a.cpp")] + override = ComponentManifestOverride(inner) + override.add_resources(FileResource("pkg", "extra.c")) + inner.resources = [FileResource("pkg", "a.cpp"), FileResource("pkg", "b.cpp")] + assert [r.resource for r in override.resources] == ["a.cpp", "b.cpp", "extra.c"] + override.restore() + assert [r.resource for r in override.resources] == ["a.cpp", "b.cpp"] + + def test_load_wraps_platform_component(tmp_path: Path) -> None: inner = _make_component_manifest() diff --git a/tests/testing_helpers.py b/tests/testing_helpers.py index 23bef718694..8459a552bad 100644 --- a/tests/testing_helpers.py +++ b/tests/testing_helpers.py @@ -1,6 +1,6 @@ from typing import Any -from esphome.loader import ComponentManifest, _replace_component_manifest +from esphome.loader import ComponentManifest, FileResource, _replace_component_manifest from esphome.types import ConfigType @@ -29,12 +29,16 @@ class ComponentManifestOverride: def __init__(self, wrapped: "ComponentManifest") -> None: object.__setattr__(self, "_wrapped", wrapped) object.__setattr__(self, "_overrides", {}) + object.__setattr__(self, "_extra_resources", []) def __getattr__(self, name: str) -> Any: overrides: dict[str, Any] = object.__getattribute__(self, "_overrides") if name in overrides: return overrides[name] wrapped: ComponentManifest = object.__getattribute__(self, "_wrapped") + if name == "resources": + # Read lazily so FILTER_SOURCE_FILES sees the defines to_code added + return wrapped.resources + object.__getattribute__(self, "_extra_resources") return getattr(wrapped, name) def __setattr__(self, name: str, value: Any) -> None: @@ -48,11 +52,18 @@ class ComponentManifestOverride: custom stub) ``to_code`` to run during C++ unit test builds. """ overrides: dict[str, Any] = object.__getattribute__(self, "_overrides") - overrides.pop("to_code", None) + # Keep a custom to_code another override of the same manifest installed + if overrides.get("to_code") is None: + overrides.pop("to_code", None) + + def add_resources(self, *resources: FileResource) -> None: + """Copy extra source files into the build next to the component's own.""" + object.__getattribute__(self, "_extra_resources").extend(resources) def restore(self) -> None: - """Clear all overrides, reverting to the wrapped manifest's values.""" + """Clear all overrides and added resources, reverting to the wrapped manifest's values.""" object.__getattribute__(self, "_overrides").clear() + object.__getattribute__(self, "_extra_resources").clear() def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> None: From 4e27b2fc125933d94cb9f0362550a55a3fb99816 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 19:54:11 -1000 Subject: [PATCH 033/120] [components] Collect UART lines with LineReader (#20956) --- .../apc_proteous/apc_proteous_cover.cpp | 40 +++--- .../apc_proteous/apc_proteous_cover.h | 6 +- .../components/dfrobot_sen0609/__init__.py | 1 + .../dfrobot_sen0609/dfrobot_sen0609.cpp | 27 +--- .../dfrobot_sen0609/dfrobot_sen0609.h | 5 +- esphome/components/emontx/__init__.py | 1 + esphome/components/emontx/emontx.cpp | 67 ++++------ esphome/components/emontx/emontx.h | 8 +- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp | 55 ++++---- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.h | 6 +- .../components/hydreon_rgxx/hydreon_rgxx.cpp | 122 +++++++++--------- .../components/hydreon_rgxx/hydreon_rgxx.h | 9 +- esphome/components/hydreon_rgxx/sensor.py | 1 + esphome/components/sim800l/__init__.py | 1 + esphome/components/sim800l/sim800l.cpp | 26 ++-- esphome/components/sim800l/sim800l.h | 4 +- esphome/components/smt100/sensor.py | 1 + esphome/components/smt100/smt100.cpp | 31 +---- esphome/components/smt100/smt100.h | 9 +- tests/components/apc_proteous/common.h | 2 +- .../cover/apc_proteous_cover_test.cpp | 8 +- tests/components/dfrobot_sen0609/__init__.py | 14 ++ tests/components/emontx/__init__.py | 11 ++ 23 files changed, 203 insertions(+), 252 deletions(-) create mode 100644 tests/components/dfrobot_sen0609/__init__.py create mode 100644 tests/components/emontx/__init__.py diff --git a/esphome/components/apc_proteous/apc_proteous_cover.cpp b/esphome/components/apc_proteous/apc_proteous_cover.cpp index f6a8432faa0..c17773da29c 100644 --- a/esphome/components/apc_proteous/apc_proteous_cover.cpp +++ b/esphome/components/apc_proteous/apc_proteous_cover.cpp @@ -32,40 +32,35 @@ void APCProteousCover::loop() { 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); + const char *line = this->line_reader_.feed(data); + if (this->line_reader_.overflowed()) { + // Start over from this byte, so a frame right after unterminated noise is still read + ESP_LOGVV(TAG, "rx overflow, discarding"); + this->line_reader_.restart_with(data); + } else if (line != nullptr && line[0] != '\0') { + ESP_LOGVV(TAG, "rx: '%s'", line); + this->parse_response_(line); } } } -void APCProteousCover::parse_response_() { +void APCProteousCover::parse_response_(const char *line) { // 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_); + if (line[0] != '?' || line[1] == '\0' || line[2] != '=' || line[3] == '\0') { + ESP_LOGV(TAG, "Invalid response: %s", line); return; } - const char *hex_str = this->rx_buffer_ + 3; + const char *hex_str = line + 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_); + ESP_LOGW(TAG, "Failed to parse hex value: %s", line); return; } bool state_changed = false; - char type = this->rx_buffer_[1]; + char type = line[1]; if (type == 's') { // bit 0 = operating, bit 1 = direction (0 = opening, 1 = closing) CoverOperation new_operation = COVER_OPERATION_IDLE; @@ -154,11 +149,10 @@ void APCProteousCover::update() { 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; + if (this->line_reader_.length() != 0) { + ESP_LOGVV(TAG, "discarding stale rx '%s' before query", this->line_reader_.partial()); } + this->line_reader_.reset(); const char *query = this->query_s_next_ ? QUERY_S : QUERY_X; ESP_LOGVV(TAG, "tx: %s", query); diff --git a/esphome/components/apc_proteous/apc_proteous_cover.h b/esphome/components/apc_proteous/apc_proteous_cover.h index 03c8eb5c4d1..dd15a1a15c7 100644 --- a/esphome/components/apc_proteous/apc_proteous_cover.h +++ b/esphome/components/apc_proteous/apc_proteous_cover.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include "esphome/components/cover/cover.h" @@ -17,7 +18,7 @@ class APCProteousCover : public cover::Cover, public PollingComponent, public ua protected: void control(const cover::CoverCall &call) override; - void parse_response_(); + void parse_response_(const char *line); void send_command_(const char *cmd); void write_command_(const char *cmd, uint32_t now); void stop_cmd_(uint32_t now); @@ -43,8 +44,7 @@ class APCProteousCover : public cover::Cover, public PollingComponent, public ua 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}; + LineReader line_reader_; uint8_t command_retries_{0}; bool query_s_next_{true}; }; diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py index c1d2f806377..76ca362949a 100644 --- a/esphome/components/dfrobot_sen0609/__init__.py +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -80,5 +80,6 @@ def final_validate_mode(keys: dict[str, str]) -> Callable[[ConfigType], None]: async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], RUN_MODES[config[CONF_MODE]]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp index 5f42d05a827..e1a1dbe89d0 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -210,30 +210,15 @@ void DFRobotSEN0609Component::loop() { } 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; + char *line = this->line_reader_.feed(c); + if (line != nullptr) { + this->handle_line_(line); 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); + this->line_reader_.log_if_overflowed(TAG); // 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; + if (c == '>' && ESPHOME_strcmp_P(this->line_reader_.partial(), ESPHOME_PSTR("DFRobot:/>")) == 0) { + this->line_reader_.reset(); } } diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h index ca70fa14943..2a017492691 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h @@ -24,6 +24,7 @@ #ifdef USE_BUTTON #include "esphome/components/button/button.h" #endif +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include @@ -188,7 +189,7 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice std::array switch_states_{}; #endif - char line_[MAX_LINE_LENGTH]; + LineReader line_reader_; char command_[MAX_COMMAND_LENGTH]{}; float response_[MAX_RESPONSE_VALUES]{}; uint32_t sent_ms_{0}; @@ -210,7 +211,6 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice 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}; @@ -221,7 +221,6 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice 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}; diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 8d2351f1fad..bdb07ed4990 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -132,6 +132,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") # Initialize sensor storage with count from final_validate before any # await, so platform to_code() calls always see it initialized diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index dca5ae2131d..888b12f4bf4 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -55,7 +55,6 @@ static bool supports_lock(const char *version, const char *end) { #endif void EmonTx::setup() { - this->buffer_pos_ = 0; if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { // The line feed ends any boot noise this->write_byte('\n'); @@ -111,11 +110,14 @@ void EmonTx::loop() { if (this->was_paused_) { // Drop what arrived during the pause and resync at the next line start this->was_paused_ = false; - this->buffer_pos_ = 0; - this->discarding_line_ = true; - uint8_t dropped; - while (this->available() > 0 && this->read_byte(&dropped)) - this->discarding_line_ = dropped != '\n'; + uint8_t dropped = 0; + while (this->available() > 0 && this->read_byte(&dropped)) { + } + if (dropped == '\n') { + this->line_reader_.reset(); + } else { + this->line_reader_.discard_line(); + } #ifdef USE_EMONTX_VERSION // The emonTx may have been flashed, or a request dropped, during the pause if (this->wants_version_()) @@ -129,43 +131,24 @@ void EmonTx::loop() { if (!this->read_byte(&received)) break; - if (received == '\n') { - // End of line - process the buffer - if (this->discarding_line_) { - this->discarding_line_ = false; - } else if (this->buffer_pos_ > 0) { - // Null-terminate for safe logging and c_str() use - size_t len = this->buffer_pos_; - this->buffer_[len] = '\0'; - this->buffer_pos_ = 0; - - StringRef line(this->buffer_.data(), len); - ESP_LOGD(TAG, "Received line: %s", line.c_str()); - - // Fire data callbacks for all received lines - this->data_callbacks_.call(line); - - // Check if this line is JSON (starts with '{') - if (this->buffer_[0] == '{') { - ESP_LOGV(TAG, "Line is JSON, parsing..."); - this->parse_json_(this->buffer_.data(), len); - } -#ifdef USE_EMONTX_VERSION - if (this->wants_version_()) { - this->parse_msg_number_(this->buffer_.data(), len); - this->parse_version_line_(this->buffer_.data(), len); - } -#endif - } - } else if (received == '\r' || this->discarding_line_) { - // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause - } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { - ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding line", MAX_LINE_LENGTH); - this->buffer_pos_ = 0; - this->discarding_line_ = true; - } else { - this->buffer_[this->buffer_pos_++] = static_cast(received); + size_t len; + char *line = this->line_reader_.feed(received, len); + this->line_reader_.log_if_overflowed(TAG); + if (line == nullptr || len == 0) { + continue; } + ESP_LOGD(TAG, "Received line: %s", line); + this->data_callbacks_.call(StringRef(line, len)); + if (line[0] == '{') { + ESP_LOGV(TAG, "Line is JSON, parsing..."); + this->parse_json_(line, len); + } +#ifdef USE_EMONTX_VERSION + if (this->wants_version_()) { + this->parse_msg_number_(line, len); + this->parse_version_line_(line, len); + } +#endif } } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index a8d91d36eb6..520e7772a06 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -4,10 +4,10 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include "esphome/components/json/json_util.h" -#include #include #ifdef USE_SENSOR @@ -19,7 +19,7 @@ namespace esphome::emontx { -/// Maximum line length in bytes (plus one byte reserved for null terminator) +/// Maximum line length in characters static constexpr size_t MAX_LINE_LENGTH = 1024; enum class LockOnReboot : uint8_t { @@ -92,18 +92,16 @@ class EmonTx final : public Component, public uart::UARTDevice { #endif LazyCallbackManager json_callbacks_; LazyCallbackManager data_callbacks_; - uint16_t buffer_pos_{0}; std::atomic paused_{false}; bool was_paused_{false}; LockOnReboot lock_on_reboot_{LockOnReboot::LOCK_ON_REBOOT_NEVER}; - bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line #ifdef USE_EMONTX_VERSION bool lock_supported_{false}; // The last version reported is v1.2.0 or later bool unlock_sent_{false}; // AUTO has sent 'emonunlock' since boot uint8_t version_frames_left_{0}; // Data lines still to wait for a version reply; 0 when none is owed uint32_t last_msg_number_{0}; #endif - std::array buffer_{}; + LineReader line_reader_; }; } // namespace esphome::emontx diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 1d58a4317a6..5415b7af74f 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -12,9 +12,6 @@ namespace esphome::hrxl_maxsonar_wr { ESPHOME_LOG_TAG(TAG, "hrxl.maxsonar.wr.sensor"); -static const uint8_t ASCII_CR = 0x0D; -static const uint8_t ASCII_NBSP = 0xFF; -static const int MAX_DATA_LENGTH_BYTES = 6; /** * HRXL sensors output the format "R1234\r" at 6Hz @@ -25,44 +22,36 @@ static const int MAX_DATA_LENGTH_BYTES = 6; void HrxlMaxsonarWrComponent::loop() { uint8_t data; while (this->available() > 0) { - if (this->read_byte(&data)) { - buffer_ += (char) data; - this->check_buffer_(); + if (!this->read_byte(&data)) + continue; + size_t len; + const char *line = this->line_reader_.feed(data, len); + if (line != nullptr) { + ESP_LOGV(TAG, "Read from serial: %s", line); + this->handle_line_(line, len); + } else if (this->line_reader_.overflowed()) { + // No CR in time: report it and start over with the next byte + ESP_LOGW(TAG, "Invalid data read from sensor: %s", this->line_reader_.partial()); + this->line_reader_.reset(); } } } -void HrxlMaxsonarWrComponent::check_buffer_() { - // The sensor seems to inject a rogue ASCII 255 byte from time to time. Get rid of that. - if (this->buffer_.back() == static_cast(ASCII_NBSP)) { - this->buffer_.pop_back(); +void HrxlMaxsonarWrComponent::handle_line_(const char *line, size_t len) { + if (line[0] != 'R') { + ESP_LOGW(TAG, "Invalid data read from sensor: %s", line); return; } + int millimeters = parse_number(line + 1).value_or(0); - // Stop reading at ASCII_CR. Also prevent the buffer from growing - // indefinitely if no ASCII_CR is received after MAX_DATA_LENGTH_BYTES. - if (this->buffer_.back() == static_cast(ASCII_CR) || this->buffer_.length() >= MAX_DATA_LENGTH_BYTES) { - ESP_LOGV(TAG, "Read from serial: %s", this->buffer_.c_str()); - - size_t rpos = this->buffer_.find(static_cast(ASCII_CR)); - - if (this->buffer_.length() <= MAX_DATA_LENGTH_BYTES && this->buffer_[0] == 'R' && rpos != std::string::npos) { - std::string distance = this->buffer_.substr(1, rpos - 1); - int millimeters = parse_number(distance).value_or(0); - - // XL reports in cm instead of mm and reports 3 digits instead of 4 - if (distance.length() == 3) { - millimeters = millimeters * 10; - } - - float meters = float(millimeters) / 1000.0f; - ESP_LOGV(TAG, "Distance from sensor: %d mm, %f m", millimeters, meters); - this->publish_state(meters); - } else { - ESP_LOGW(TAG, "Invalid data read from sensor: %s", this->buffer_.c_str()); - } - this->buffer_.clear(); + // XL reports in cm instead of mm and reports 3 digits instead of 4 + if (len == 4) { + millimeters *= 10; } + + float meters = float(millimeters) / 1000.0f; + ESP_LOGV(TAG, "Distance from sensor: %d mm, %f m", millimeters, meters); + this->publish_state(meters); } void HrxlMaxsonarWrComponent::dump_config() { diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h index 36346c8293e..ada26d21e29 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::hrxl_maxsonar_wr { @@ -15,9 +16,10 @@ class HrxlMaxsonarWrComponent final : public sensor::Sensor, public Component, p void dump_config() override; protected: - void check_buffer_(); + void handle_line_(const char *line, size_t len); - std::string buffer_; + // "R1234" plus the NUL; the sensor ends each reading with CR and sometimes injects a rogue 0xFF byte + LineReader<6, '\r', static_cast(0xFF)> line_reader_; }; } // namespace esphome::hrxl_maxsonar_wr diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index a2f8fc50862..a1d6553073a 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -1,5 +1,6 @@ #include "hydreon_rgxx.h" #include "esphome/core/log.h" +#include namespace esphome::hydreon_rgxx { @@ -7,8 +8,6 @@ ESPHOME_LOG_TAG(TAG, "hydreon_rgxx.sensor"); static constexpr uint32_t REBOOT_INTERVAL_ID = 0; -static const int MAX_DATA_LENGTH_BYTES = 80; -static const uint8_t ASCII_LF = 0x0A; #define HYDREON_RGXX_COMMA , static const char *const PROTOCOL_NAMES[] = {HYDREON_RGXX_PROTOCOL_LIST(, HYDREON_RGXX_COMMA)}; static const char *const IGNORE_STRINGS[] = {HYDREON_RGXX_IGNORE_LIST(, HYDREON_RGXX_COMMA)}; @@ -111,13 +110,14 @@ void HydreonRGxxComponent::update() { void HydreonRGxxComponent::loop() { uint8_t data; while (this->available() > 0) { - if (this->read_byte(&data)) { - buffer_ += (char) data; - if (this->buffer_.back() == static_cast(ASCII_LF) || this->buffer_.length() >= MAX_DATA_LENGTH_BYTES) { - // complete line received - this->process_line_(); - this->buffer_.clear(); - } + if (!this->read_byte(&data)) { + continue; + } + size_t len; + const char *line = this->line_reader_.feed(data, len); + this->line_reader_.log_if_overflowed(TAG); + if (line != nullptr) { + this->process_line_(StringRef(line, len)); } } } @@ -156,33 +156,31 @@ void HydreonRGxxComponent::schedule_reboot_() { }); } -void HydreonRGxxComponent::process_line_() { - ESP_LOGV(TAG, "Read from serial: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); +void HydreonRGxxComponent::process_line_(StringRef line) { + ESP_LOGV(TAG, "Read from serial: %s", line.c_str()); - if (buffer_[0] == ';') { - ESP_LOGI(TAG, "Comment: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + if (line[0] == ';') { + ESP_LOGI(TAG, "Comment: %s", line.c_str()); return; } - std::string::size_type newlineposn = this->buffer_.find('\n'); - if (newlineposn <= 1) { - // allow both \r\n and \n + if (line.empty()) { ESP_LOGD(TAG, "Received empty line"); return; } - if (newlineposn <= 2) { + if (line.size() == 1) { // single character lines, such as acknowledgements - ESP_LOGD(TAG, "Received ack: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGD(TAG, "Received ack: %s", line.c_str()); return; } - if (this->buffer_.find("LensBad") != std::string::npos) { + if (line.find("LensBad") != std::string::npos) { ESP_LOGW(TAG, "Received LensBad!"); this->lens_bad_ = true; } - if (this->buffer_.find("EmSat") != std::string::npos) { + if (line.find("EmSat") != std::string::npos) { ESP_LOGW(TAG, "Received EmSat!"); this->em_sat_ = true; } - if (buffer_.starts_with("PwrDays")) { + if (line.starts_with("PwrDays")) { if (this->boot_count_ <= 0) { this->boot_count_ = 1; } else { @@ -190,7 +188,7 @@ void HydreonRGxxComponent::process_line_() { } this->cancel_interval(REBOOT_INTERVAL_ID); this->no_response_count_ = 0; - ESP_LOGI(TAG, "Boot detected: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGI(TAG, "Boot detected: %s", line.c_str()); if (this->model_ == RG15) { if (this->resolution_ == FORCE_HIGH) { @@ -211,17 +209,18 @@ void HydreonRGxxComponent::process_line_() { } return; } - if (buffer_.starts_with("SW")) { - std::string::size_type majend = this->buffer_.find('.'); - std::string::size_type endversion = this->buffer_.find(' ', 3); + if (line.starts_with("SW")) { + StringRef::size_type majend = line.find('.'); + StringRef::size_type endversion = line.find(' ', 3); if (majend == std::string::npos || endversion == std::string::npos || majend > endversion) { - ESP_LOGW(TAG, "invalid version string: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGW(TAG, "invalid version string: %s", line.c_str()); } - int major = strtol(this->buffer_.substr(3, majend - 3).c_str(), nullptr, 10); - int minor = strtol(this->buffer_.substr(majend + 1, endversion - (majend + 1)).c_str(), nullptr, 10); + // strtol stops at the '.' and the ' ' + int major = line.size() > 3 ? strtol(line.c_str() + 3, nullptr, 10) : 0; + int minor = strtol(line.c_str() + (majend == std::string::npos ? 0 : majend + 1), nullptr, 10); if (major > 10 || minor >= 1000 || minor < 0 || major < 0) { - ESP_LOGW(TAG, "invalid version: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGW(TAG, "invalid version: %s", line.c_str()); } this->sw_version_ = major * 1000 + minor; ESP_LOGI(TAG, "detected sw version %i", this->sw_version_); @@ -229,56 +228,51 @@ void HydreonRGxxComponent::process_line_() { } bool is_data_line = false; for (int i = 0; i < NUM_SENSORS; i++) { - if (this->sensors_[i] != nullptr && this->buffer_.find(PROTOCOL_NAMES[i]) != std::string::npos) { - is_data_line = true; - break; + if (this->sensors_[i] == nullptr) { + continue; } - } - if (is_data_line) { - std::string::size_type tc = this->buffer_.find("TooCold"); - this->too_cold_ |= tc != std::string::npos; - if (this->too_cold_) { - ESP_LOGD(TAG, "Received TooCold"); + StringRef::size_type n = line.find(PROTOCOL_NAMES[i]); + if (n == std::string::npos) { + continue; } - for (int i = 0; i < NUM_SENSORS; i++) { - if (this->sensors_[i] == nullptr) { - continue; - } - std::string::size_type n = this->buffer_.find(PROTOCOL_NAMES[i]); + is_data_line = true; + + if (n == line.find('t', n)) { + // The device temperature ('t') response contains both °C and °F values: + // "t 72F 22C". + // ESPHome uses only °C, only parse °C value (move past 'F'). + n = line.find('F', n); if (n == std::string::npos) { continue; } + n += 1; // move past 'F' + } else { + n += strlen(PROTOCOL_NAMES[i]); // move past protocol name + } - if (n == this->buffer_.find('t', n)) { - // The device temperature ('t') response contains both °C and °F values: - // "t 72F 22C". - // ESPHome uses only °C, only parse °C value (move past 'F'). - n = this->buffer_.find('F', n); - if (n == std::string::npos) { - continue; - } - n += 1; // move past 'F' - } else { - n += strlen(PROTOCOL_NAMES[i]); // move past protocol name - } - - // parse value, starting at str position n - float data = strtof(this->buffer_.substr(n).c_str(), nullptr); - this->sensors_[i]->publish_state(data); - ESP_LOGD(TAG, "Received %s: %f", PROTOCOL_NAMES[i], this->sensors_[i]->get_raw_state()); - this->sensors_received_ |= (1 << i); + // parse value, starting at str position n + float data = strtof(line.c_str() + n, nullptr); + this->sensors_[i]->publish_state(data); + ESP_LOGD(TAG, "Received %s: %f", PROTOCOL_NAMES[i], this->sensors_[i]->get_raw_state()); + this->sensors_received_ |= (1 << i); + } + if (is_data_line) { + StringRef::size_type tc = line.find("TooCold"); + this->too_cold_ |= tc != std::string::npos; + if (this->too_cold_) { + ESP_LOGD(TAG, "Received TooCold"); } if (this->request_temperature_ && this->num_sensors_missing_() == 1) { this->write_str("T\n"); } } else { for (const auto *ignore : IGNORE_STRINGS) { - if (buffer_.starts_with(ignore)) { - ESP_LOGI(TAG, "Ignoring %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + if (line.starts_with(ignore)) { + ESP_LOGI(TAG, "Ignoring %s", line.c_str()); return; } } - ESP_LOGI(TAG, "Got unknown line: %s", this->buffer_.c_str()); + ESP_LOGI(TAG, "Got unknown line: %s", line.c_str()); } } diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.h b/esphome/components/hydreon_rgxx/hydreon_rgxx.h index a7bde6105c6..e0fa8fc2b4a 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.h +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.h @@ -2,10 +2,12 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/string_ref.h" #include "esphome/components/sensor/sensor.h" #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::hydreon_rgxx { @@ -55,10 +57,9 @@ class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDev void set_disable_led(bool disable_led) { this->disable_led_ = disable_led; } protected: - void process_line_(); + /// line is NUL terminated + void process_line_(StringRef line); void schedule_reboot_(); - bool buffer_starts_with_(const std::string &prefix); - bool buffer_starts_with_(const char *prefix); int num_sensors_missing_(); sensor::Sensor *sensors_[NUM_SENSORS] = {nullptr}; @@ -70,7 +71,6 @@ class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDev int16_t boot_count_ = 0; int16_t no_response_count_ = 0; - std::string buffer_; RGModel model_ = RG9; RG15Resolution resolution_ = FORCE_HIGH; @@ -81,6 +81,7 @@ class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDev bool em_sat_ = false; bool request_temperature_ = false; bool disable_led_ = false; + LineReader<80> line_reader_; // bit field showing which sensors we have received data for int sensors_received_ = -1; diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index 8e269fef9a0..222a5308ae1 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -141,6 +141,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index e20c091b75c..efc742610d9 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -67,6 +67,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index e75e5e95dd3..ba07bc3d6d3 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -426,26 +426,20 @@ void Sim800LComponent::loop() { uint8_t byte; this->read_byte(&byte); - if (this->read_pos_ == SIM800L_READ_BUFFER_LENGTH) - this->read_pos_ = 0; + ESP_LOGVV(TAG, "Buffer pos: %u %d", this->line_reader_.length(), byte); // NOLINT - ESP_LOGVV(TAG, "Buffer pos: %u %d", this->read_pos_, byte); // NOLINT - - if (byte == ASCII_CR) - continue; if (byte >= 0x7F) byte = '?'; // need to be valid utf8 string for log functions. - this->read_buffer_[this->read_pos_] = byte; - - if (this->state_ == STATE_SENDING_SMS_2 && this->read_pos_ == 0 && byte == '>') - this->read_buffer_[++this->read_pos_] = ASCII_LF; - - if (this->read_buffer_[this->read_pos_] == ASCII_LF) { - this->read_buffer_[this->read_pos_] = 0; - this->read_pos_ = 0; - this->parse_cmd_(this->read_buffer_); + const char *line = this->line_reader_.feed(byte); + if (line == nullptr && this->state_ == STATE_SENDING_SMS_2 && byte == '>' && this->line_reader_.length() == 1) { + // The send prompt has no line end + line = this->line_reader_.partial(); + this->line_reader_.reset(); + } + if (line != nullptr) { + this->parse_cmd_(line); } else { - this->read_pos_++; + this->line_reader_.log_if_overflowed(TAG); } } if (state_ == STATE_INIT && this->registered_ && diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index bbe8e14cf7e..a6f9544781d 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -10,6 +10,7 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include "esphome/core/automation.h" @@ -95,8 +96,7 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent #endif std::string sender_; std::string message_; - char read_buffer_[SIM800L_READ_BUFFER_LENGTH]; - size_t read_pos_{0}; + LineReader line_reader_; uint8_t parse_index_{0}; uint8_t watch_dog_{0}; bool expect_ack_{false}; diff --git a/esphome/components/smt100/sensor.py b/esphome/components/smt100/sensor.py index dae98bc1897..fba10eab9ae 100644 --- a/esphome/components/smt100/sensor.py +++ b/esphome/components/smt100/sensor.py @@ -80,6 +80,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index 7785bbca2d7..8a8f82b8504 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -12,8 +12,13 @@ void SMT100Component::update() { void SMT100Component::loop() { while (this->available() != 0) { - if (this->readline_(this->read(), this->readline_buffer_, MAX_LINE_LENGTH) > 0) { - char *token = strtok(this->readline_buffer_, ","); + const int readch = this->read(); + if (readch <= 0) + continue; + char *line = this->line_reader_.feed(readch); + this->line_reader_.log_if_overflowed(TAG); + if (line != nullptr && line[0] != '\0') { + char *token = strtok(line, ","); if (!token) continue; int counts = (int) strtol(token, nullptr, 10); @@ -67,26 +72,4 @@ void SMT100Component::dump_config() { LOG_UPDATE_INTERVAL(this); } -int SMT100Component::readline_(int readch, char *buffer, int len) { - int rpos; - - if (readch > 0) { - switch (readch) { - case '\n': // Ignore new-lines - break; - case '\r': // Return on CR - rpos = this->readline_pos_; - this->readline_pos_ = 0; // Reset position index ready for next time - return rpos; - default: - if (this->readline_pos_ < len - 1) { - buffer[this->readline_pos_++] = readch; - buffer[this->readline_pos_] = 0; - } - } - } - // No end of line has been found, so return -1. - return -1; -} - } // namespace esphome::smt100 diff --git a/esphome/components/smt100/smt100.h b/esphome/components/smt100/smt100.h index 55977a5caf6..12641121fe6 100644 --- a/esphome/components/smt100/smt100.h +++ b/esphome/components/smt100/smt100.h @@ -2,12 +2,14 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::smt100 { class SMT100Component final : public PollingComponent, public uart::UARTDevice { - static const uint16_t MAX_LINE_LENGTH = 31; + // 33 characters plus the NUL; the longest line, "15000,80.00,100.00,-20.00,12.00", is 31 + static constexpr uint16_t LINE_BUFFER_SIZE = 34; public: SMT100Component() = default; @@ -25,10 +27,7 @@ class SMT100Component final : public PollingComponent, public uart::UARTDevice { void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } protected: - int readline_(int readch, char *buffer, int len); - - char readline_buffer_[MAX_LINE_LENGTH]{}; - int readline_pos_{0}; + LineReader line_reader_; sensor::Sensor *counts_sensor_{nullptr}; sensor::Sensor *permittivity_sensor_{nullptr}; diff --git a/tests/components/apc_proteous/common.h b/tests/components/apc_proteous/common.h index a3baa5131de..1b595104f75 100644 --- a/tests/components/apc_proteous/common.h +++ b/tests/components/apc_proteous/common.h @@ -51,9 +51,9 @@ class TestableAPCProteousCover : public APCProteousCover { using APCProteousCover::MAX_RESPONSE_LEN; using APCProteousCover::command_retries_; using APCProteousCover::last_command_tx_; + using APCProteousCover::line_reader_; using APCProteousCover::pending_command_; using APCProteousCover::pending_command_time_; - using APCProteousCover::rx_len_; using APCProteousCover::target_position_; }; diff --git a/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp index 566701338b3..3e784f06bfc 100644 --- a/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp +++ b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp @@ -111,7 +111,7 @@ TEST_F(APCProteousCoverTest, MalformedFramesAreIgnored) { 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); + EXPECT_EQ(this->cover_.line_reader_.length(), 0); } TEST_F(APCProteousCoverTest, LineFeedsAreIgnored) { @@ -134,7 +134,7 @@ TEST_F(APCProteousCoverTest, UnterminatedNoiseIsDiscarded) { EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); this->feed_("0123456789ABCDEF0123456789ABCDEF"); - EXPECT_LE(this->cover_.rx_len_, TestableAPCProteousCover::MAX_RESPONSE_LEN); + EXPECT_LE(this->cover_.line_reader_.length(), TestableAPCProteousCover::MAX_RESPONSE_LEN); } TEST_F(APCProteousCoverTest, OpenSendsOpenCommand) { @@ -405,9 +405,9 @@ TEST_F(APCProteousCoverTest, UpdateHoldsOffPollingAfterCommand) { // 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); + EXPECT_NE(this->cover_.line_reader_.length(), 0); this->cover_.update(); - EXPECT_EQ(this->cover_.rx_len_, 0); + EXPECT_EQ(this->cover_.line_reader_.length(), 0); this->feed_("?s=01\r"); EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_OPENING); } diff --git a/tests/components/dfrobot_sen0609/__init__.py b/tests/components/dfrobot_sen0609/__init__.py new file mode 100644 index 00000000000..5cf3c18e999 --- /dev/null +++ b/tests/components/dfrobot_sen0609/__init__.py @@ -0,0 +1,14 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # One empty config entry so to_code runs and sets the define that keeps + # core/line_reader.cpp in the test build. + manifest.multi_conf = False + + async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LINE_READER") + + manifest.to_code = to_code diff --git a/tests/components/emontx/__init__.py b/tests/components/emontx/__init__.py new file mode 100644 index 00000000000..143c58c8699 --- /dev/null +++ b/tests/components/emontx/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Only the define that keeps core/line_reader.cpp in the test build + async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LINE_READER") + + manifest.to_code = to_code From 885f2fe5d86853de93f70f1e6ad500b3d5cf7b12 Mon Sep 17 00:00:00 2001 From: HugoPeters1024 Date: Sat, 17 Oct 2026 10:04:01 +0200 Subject: [PATCH 034/120] [kamstrup_kmp] Fix Kamstrup KMP reads with echoing optical heads (#20897) Co-authored-by: J. Nick Koston --- esphome/components/kamstrup_kmp/kamstrup_kmp.cpp | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index fcf648c31f3..396f4514cfe 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -142,6 +142,9 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { if (data > -1) { if (data == 0x40) { // start of message buffer_len = 0; + } else if (buffer_len == 0) { + // Ignore request echoes until the response starts. + continue; } buffer[buffer_len++] = (uint8_t) data; if (data == 0x0D) { @@ -161,12 +164,7 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { return; } - // Validate message (prefix and suffix) - if (buffer[0] != 0x40) { - ESP_LOGE(TAG, "Received invalid message (prefix mismatch received 0x%02X, expected 0x40)", buffer[0]); - return; - } - + // Only bytes from the 0x40 start byte on are stored, so just the end of the message needs checking if (buffer[buffer_len - 1] != 0x0D) { ESP_LOGE(TAG, "Received invalid message (EOM mismatch received 0x%02X, expected 0x0D)", buffer[buffer_len - 1]); return; @@ -175,7 +173,7 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { // Decode uint8_t msg[20] = {0}; int msg_len = 0; - for (size_t i = 1; i < buffer_len - 1; i++) { + for (size_t i = 1; i < buffer_len - 1 && msg_len < (int) sizeof(msg); i++) { if (buffer[i] == 0x1B) { msg[msg_len++] = buffer[i + 1] ^ 0xFF; i++; @@ -184,8 +182,8 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { } } - // Validate CRC - if (crc16be(msg, msg_len - 2) != encode_uint16(msg[msg_len - 2], msg[msg_len - 1])) { + // Validate CRC; a frame shorter than the CRC itself has nothing to check + if (msg_len < 2 || crc16be(msg, msg_len - 2) != encode_uint16(msg[msg_len - 2], msg[msg_len - 1])) { ESP_LOGE(TAG, "Received invalid message (CRC mismatch)"); return; } From 06b585d3733d634427abb492099d1373a58598fa Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Sat, 17 Oct 2026 10:05:21 +0200 Subject: [PATCH 035/120] [i2s_audio] Fix speaker restart loop with full_duplex (#20984) --- .../components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp | 1 + 1 file changed, 1 insertion(+) 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 73ad924fad7..026a66c1cf1 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -456,6 +456,7 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream } return ESP_ERR_NOT_SUPPORTED; } + this->current_stream_info_ = audio_stream_info; // The speaker task will enable the channel after preloading. return this->acquire_full_duplex_channel_(I2S_EVENT_QUEUE_COUNT); } From eafcca827429681fcd1f0ce6916e391f37b48032 Mon Sep 17 00:00:00 2001 From: Nicolas Liaudat Date: Sat, 17 Oct 2026 10:47:28 +0200 Subject: [PATCH 036/120] [mics_5524] Add datasheet model, diagnostics and advanced wiring (2/2) (#20521) 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/mics_5524/__init__.py | 66 +- esphome/components/mics_5524/coefficients.py | 166 ++++- esphome/components/mics_5524/mics_5524.cpp | 255 ++++++-- esphome/components/mics_5524/mics_5524.h | 77 ++- esphome/components/mics_5524/mics_math.h | 101 ++- esphome/components/mics_5524/sensor.py | 406 +++++++++++- esphome/core/defines.h | 1 + .../mics_5524/config/mics_5524.yaml | 26 + .../mics_5524/test_mics_5524.py | 597 +++++++++++++++--- tests/components/mics_5524/common.h | 4 + tests/components/mics_5524/common.yaml | 7 +- .../mics_5524/sensor/mics_math_test.cpp | 87 +++ .../validate-datasheet.esp32-idf.yaml | 31 + 13 files changed, 1611 insertions(+), 213 deletions(-) create mode 100644 tests/components/mics_5524/validate-datasheet.esp32-idf.yaml diff --git a/esphome/components/mics_5524/__init__.py b/esphome/components/mics_5524/__init__.py index 1b1c4111470..0fdec90a944 100644 --- a/esphome/components/mics_5524/__init__.py +++ b/esphome/components/mics_5524/__init__.py @@ -1,19 +1,83 @@ -"""MiCS-5524 gas sensor platform: the DFRobot_MICS vendor model on a voltage sampler.""" +"""MiCS-5524 gas sensor platform with the DFRobot vendor model and the datasheet fit.""" import esphome.codegen as cg from esphome.components import sensor +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C3, + VARIANT_ESP32C6, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +from esphome.const import PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2 CODEOWNERS = ["@nliaudat"] DOMAIN = "mics_5524" mics_5524_ns = cg.esphome_ns.namespace("mics_5524") +micsmath_ns = mics_5524_ns.namespace("micsmath") +ConversionModel = micsmath_ns.enum("ConversionModel") MiCS5524Component = mics_5524_ns.class_("MiCS5524Component", cg.PollingComponent) MiCS5524GasSensor = mics_5524_ns.class_("MiCS5524GasSensor", sensor.Sensor) +CONF_CONVERSION = "conversion" +CONF_COEFFICIENT_A = "coefficient_a" +CONF_COEFFICIENT_B = "coefficient_b" CONF_VOLTAGE_MULTIPLIER = "voltage_multiplier" +CONF_ADC_INPUT_MAX = "adc_input_max" +CONF_ADC_PIN_MAX = "adc_pin_max" +CONF_ADC_ATTENUATION = "adc_attenuation" +CONF_R1 = "r1" +CONF_R2 = "r2" CONF_VCC = "vcc" +CONF_RL = "rl" +CONF_R0 = "r0" CONF_AIR_REFERENCE = "air_reference" CONF_SAMPLE_COUNT = "sample_count" CONF_SAMPLE_INTERVAL = "sample_interval" +CONF_PERSIST = "persist" +CONF_MIN_PPM = "min_ppm" CONF_MAX_PPM = "max_ppm" +CONF_RATIO_SENSOR = "ratio_sensor" +CONF_RS_SENSOR = "rs_sensor" +CONF_VOLTAGE_SENSOR = "voltage_sensor" + +CONVERSION_MODELS = { + "dfrobot": ConversionModel.CONVERSION_MODEL_DFROBOT, + "datasheet": ConversionModel.CONVERSION_MODEL_DATASHEET, +} + +# Divider guard limits (input max, pin max) in volts by target platform. ESP32 and RP2: VDD and +# the absolute maximum VDD + 0.3 V; a bare ESP8266 A0 measures 0 - 1 V, boards with an onboard +# divider declare more. Any other platform declares both limits. +ADC_LIMITS_V = { + PLATFORM_ESP32: (3.3, 3.6), + PLATFORM_ESP8266: (1.0, 1.0), + PLATFORM_RP2: (3.3, 3.6), +} + +# Top of the recommended input range of each ESP32 ADC attenuation, in volts, from the ESP-IDF ADC +# calibration documentation; above it the reading flattens. The ESP32 row is the most conservative +# and stands in for variants without their own row. "auto" ends up at the widest range. +_ESP32_ATTENUATION_INPUT_MAX_V = { + VARIANT_ESP32: {"0db": 0.95, "2.5db": 1.25, "6db": 1.75, "12db": 2.45}, + VARIANT_ESP32S2: {"0db": 0.75, "2.5db": 1.05, "6db": 1.3, "12db": 2.5}, + VARIANT_ESP32S3: {"0db": 0.95, "2.5db": 1.25, "6db": 1.75, "12db": 3.1}, + VARIANT_ESP32C3: {"0db": 0.75, "2.5db": 1.05, "6db": 1.3, "12db": 2.5}, + VARIANT_ESP32C6: {"0db": 0.95, "2.5db": 1.25, "6db": 1.75, "12db": 3.3}, +} + + +def esp32_attenuation_input_max(variant: str, attenuation: str) -> float: + """Largest input the internal ADC measures at the attenuation, for the variant.""" + table = _ESP32_ATTENUATION_INPUT_MAX_V.get( + variant, _ESP32_ATTENUATION_INPUT_MAX_V[VARIANT_ESP32] + ) + if attenuation in ("11db", "auto"): + attenuation = "12db" + return table[attenuation] + + +# Guard margin (V): the 3.3333 V of a 10k/20k divider still passes adc_input_max: 3.33. +ADC_LIMIT_MARGIN_V = 0.01 diff --git a/esphome/components/mics_5524/coefficients.py b/esphome/components/mics_5524/coefficients.py index cb1b0551f75..2050e982e1a 100644 --- a/esphome/components/mics_5524/coefficients.py +++ b/esphome/components/mics_5524/coefficients.py @@ -1,4 +1,4 @@ -"""Gas table of the DFRobot_MICS vendor model; the formula lives in mics_math.h.""" +"""Gas tables of both MiCS-5524 conversion models; the formulas live in mics_math.h.""" from dataclasses import dataclass @@ -10,6 +10,9 @@ from esphome.const import ( CONF_METHANE, ) +CONVERSION_DFROBOT = "dfrobot" +CONVERSION_DATASHEET = "datasheet" + @dataclass(frozen=True) class VendorCurve: @@ -22,11 +25,158 @@ class VendorCurve: max_ppm: float # upper end of the measuring range -# DFRobot_MICS getGasData() thresholds and gains with the vendor measuring ranges. -GASES: dict[str, VendorCurve] = { - CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), - CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), - CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), - CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), - CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), +@dataclass(frozen=True) +class DatasheetCurve: + """Coefficients of the datasheet fit ppm = a * ratio^b and its detection range.""" + + a: float + b: float + min_ppm: float # bottom of the detection range, read as 0 below it + max_ppm: float + + +@dataclass(frozen=True) +class GasDefinition: + """One gas with the constants of both models.""" + + vendor: VendorCurve + datasheet: DatasheetCurve | None + + +# DFRobot_MICS getGasData() thresholds and gains with the vendor measuring ranges. The CO +# datasheet fit is read off the sensitivity graph of SGX data sheet 1084 rev 8: RS/R0 falls +# from about 3.5 at 1 ppm to about 0.011 at 1000 ppm, a slope of -0.83 per decade. R0 here is +# the resistance in clean air, where the sensor sits at the 1 ppm bottom of its detection +# range, so the ratio is 1 there and ppm = ratio^-1.2. The data sheet calls its own curve +# indicative: the sensitivity factor RS(air) / RS(60 ppm CO) spans 1.2 to 50 between units. +GASES: dict[str, GasDefinition] = { + CONF_CARBON_MONOXIDE: GasDefinition( + vendor=VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + datasheet=DatasheetCurve(1.0, -1.2, 1.0, 1000.0), + ), + CONF_HYDROGEN: GasDefinition( + vendor=VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), + datasheet=None, # no published fit + ), + CONF_ETHANOL: GasDefinition( + vendor=VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), + datasheet=None, + ), + CONF_AMMONIA: GasDefinition( + vendor=VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), + datasheet=None, + ), + CONF_METHANE: GasDefinition( + vendor=VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), + datasheet=None, + ), } + + +def datasheet_gases() -> list[str]: + """Labels of the gases that ship with datasheet coefficients.""" + return [ + definition.vendor.label + for definition in GASES.values() + if definition.datasheet is not None + ] + + +@dataclass(frozen=True) +class ResolvedGas: + """Resolved gas configuration handed to the C++ component.""" + + label: str + conversion: str + p0: float # threshold (vendor model) or a (datasheet model) + p1: float # gain (vendor model) or b (datasheet model) + min_ppm: float + max_ppm: float + + +def resolve_gas( + gas_key: str, + conversion: str, + *, + a: float | None = None, + b: float | None = None, + min_ppm: float | None = None, + max_ppm: float | None = None, +) -> ResolvedGas: + """Merge the built-in curve with the YAML overrides; ValueError on a bad combination.""" + if conversion not in (CONVERSION_DFROBOT, CONVERSION_DATASHEET): + raise ValueError(f"unknown conversion '{conversion}'") + if gas_key not in GASES: + raise ValueError( + f"unknown gas '{gas_key}', expected one of: {', '.join(GASES)}" + ) + + definition = GASES[gas_key] + curve = definition.vendor + + if conversion == CONVERSION_DFROBOT: + if a is not None or b is not None: + raise ValueError( + "'coefficient_a:'/'coefficient_b:' are the coefficients of " + f"conversion: {CONVERSION_DATASHEET} - remove them or switch the " + "conversion" + ) + return ResolvedGas( + label=curve.label, + conversion=conversion, + p0=curve.threshold, + p1=curve.gain, + # The vendor floor is where it stops reporting gas, so it is the default min_ppm. + min_ppm=float(curve.min_ppm if min_ppm is None else min_ppm), + max_ppm=float(curve.max_ppm if max_ppm is None else max_ppm), + ) + + fitted = definition.datasheet + # One coefficient with the shipped other one would mix two fits. + if (a is None) != (b is None): + raise ValueError( + "set both 'coefficient_a:' and 'coefficient_b:' together, or neither " + f"(only {', '.join(datasheet_gases())} ship with built-in " + f"'conversion: {CONVERSION_DATASHEET}' coefficients)" + ) + resolved_a = ( + float(a) if a is not None else (fitted.a if fitted is not None else None) + ) + resolved_b = ( + float(b) if b is not None else (fitted.b if fitted is not None else None) + ) + if resolved_a is None or resolved_b is None: + shipped = ", ".join(datasheet_gases()) + raise ValueError( + f"{curve.label} has no built-in 'conversion: {CONVERSION_DATASHEET}' " + f"coefficients (only {shipped}) - set both 'coefficient_a:' and " + "'coefficient_b:' in the configuration" + ) + if resolved_a <= 0.0: + raise ValueError( + f"'coefficient_a:' must be positive, got {resolved_a:g} - " + "a non-positive fit cannot produce a reading" + ) + # The resistance of a MOS sensor falls as a reducing gas rises; a flat or rising fit + # would read a saturated sensor (ratio 0) as no gas. + if resolved_b >= 0.0: + raise ValueError( + f"'coefficient_b:' must be negative, got {resolved_b:g} - " + "the sensor resistance falls as the gas concentration rises" + ) + if min_ppm is None: + # A user fit has no published floor; the shipped one reads 0 below its range. + min_ppm = fitted.min_ppm if a is None and fitted is not None else 0.0 + return ResolvedGas( + label=curve.label, + conversion=conversion, + p0=resolved_a, + p1=resolved_b, + min_ppm=float(min_ppm), + # A user fit without a published range falls back to the vendor range of the gas. + max_ppm=float( + (fitted.max_ppm if fitted is not None else curve.max_ppm) + if max_ppm is None + else max_ppm + ), + ) diff --git a/esphome/components/mics_5524/mics_5524.cpp b/esphome/components/mics_5524/mics_5524.cpp index 2023661b369..bc559209afe 100644 --- a/esphome/components/mics_5524/mics_5524.cpp +++ b/esphome/components/mics_5524/mics_5524.cpp @@ -10,31 +10,90 @@ namespace esphome::mics_5524 { ESPHOME_LOG_TAG(TAG, "mics_5524"); -void MiCS5524GasSensor::publish_ratio(float ratio) { - const float ppm = micsmath::ppm_from_vendor_curve(ratio, this->curve_); +/// Added to the conversion model, so a value stored for one model is never loaded by the other. +static constexpr uint32_t REFERENCE_PREFERENCE_VERSION = 0x00000010; + +/// Log strings of one conversion model. +struct ModelStrings { + const LogString *conversion; ///< YAML value of `conversion:` + const LogString *reference; ///< name of the clean-air reference + const LogString *key; ///< YAML key of the reference +}; + +static ModelStrings model_strings(micsmath::ConversionModel model) { + if (model == micsmath::CONVERSION_MODEL_DFROBOT) + return {LOG_STR("dfrobot"), LOG_STR("air reference"), LOG_STR("air_reference")}; + return {LOG_STR("datasheet"), LOG_STR("R0"), LOG_STR("r0")}; +} + +void MiCS5524GasSensor::publish_ratio(float ratio, micsmath::ConversionModel model) { + float ppm = micsmath::ppm_from_vendor_curve(ratio, {this->p0_, this->p1_, this->min_ppm_, this->max_ppm_}); +#ifdef USE_MICS_5524_DATASHEET + // One firmware can hold a hub of each model, so the check stays inside the guard + if (model == micsmath::CONVERSION_MODEL_DATASHEET) { + ppm = + micsmath::clamp_ppm(micsmath::ppm_from_power_law(this->p0_, this->p1_, ratio), this->min_ppm_, this->max_ppm_); + } +#endif ESP_LOGD(TAG, "'%s': ratio=%.4f -> %.1f ppm", this->get_name().c_str(), ratio, ppm); this->publish_state(ppm); } void MiCS5524Component::setup() { + if (this->persist_) { + // Written once per calibration, so flash rather than the RTC memory an ESP8266 loses at power off + this->reference_pref_ = global_preferences->make_preference( + this->preference_key_ ^ (REFERENCE_PREFERENCE_VERSION + this->conversion_), true); + } + + if (this->enable_pin_ != nullptr) { + // EN of the breakout is active low; `inverted: true` makes this write switch the module on. + this->enable_pin_->setup(); + this->enable_pin_->digital_write(true); + } + if (this->has_reference_()) { - ESP_LOGI(TAG, "using the air reference from the configuration (%.4f V)", this->reference_); + ESP_LOGI(TAG, "using the %s from the configuration (%.4f) - the sensor is not calibrated at boot", + LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + } else if (this->persist_ && this->load_reference_()) { + ESP_LOGI(TAG, "restored the %s from flash (%.4f)", LOG_STR_ARG(model_strings(this->conversion_).reference), + this->reference_); } if (this->warmup_time_ > 0) { + this->warming_up_ = true; this->stop_poller(); - this->set_timeout(this->warmup_time_, [this]() { this->start_poller(); }); + this->set_timeout(this->warmup_time_, [this]() { + this->warming_up_ = false; + ESP_LOGI(TAG, "warm-up finished, readings are published from now on"); + this->start_poller(); + // A calibration waiting for the warm-up starts when one at boot would have. + if (this->calibration_pending_) { + this->schedule_calibration_( + this->calibration_delay_ > this->warmup_time_ ? this->calibration_delay_ - this->warmup_time_ : 0); + } + }); ESP_LOGI(TAG, "warm-up of %" PRIu32 " s - no readings published before that", this->warmup_time_ / 1000); } - if (this->calibration_samples_ > 0) { - // Nothing is stored across reboots; the warm-up window starts here, so the delay covers it. - const uint32_t delay = std::max(this->calibration_delay_, this->warmup_time_); - this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); - ESP_LOGI(TAG, "air reference calibration in %" PRIu32 " s - the sensor must be in clean air", delay / 1000); + if (this->calibrate_at_boot_) { + // A fixed or restored reference wins. + if (this->has_reference_()) { + ESP_LOGI(TAG, "%s already known (%.4f) - automatic calibration skipped", + LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + } else { + const uint32_t boot_delay = std::max(this->calibration_delay_, this->warmup_time_); + this->calibration_pending_ = true; + // During a warm-up the warm-up timeout starts it, at the same boot_delay. + if (!this->warming_up_) + this->schedule_calibration_(boot_delay); + ESP_LOGI(TAG, "clean-air calibration of the %s in %" PRIu32 " s - the sensor must be in clean air", + LOG_STR_ARG(model_strings(this->conversion_).reference), boot_delay / 1000); + } } else if (!this->has_reference_()) { - ESP_LOGW(TAG, "no air reference available, the PPM values stay unknown\n" - " set 'air_reference:' or enable 'calibration:'"); + ESP_LOGW(TAG, + "no %s available, the PPM values stay unknown\n set it in the configuration or enable 'calibration:'", + LOG_STR_ARG(model_strings(this->conversion_).reference)); } // The loop only samples while a clean-air calibration runs; `update()` publishes. @@ -44,17 +103,25 @@ void MiCS5524Component::setup() { void MiCS5524Component::dump_config() { ESP_LOGCONFIG(TAG, "MiCS-5524:\n" - " VCC: %.2f V, AO multiplier: x%.3f, AO samples per reading: %u\n" + " Conversion: %s, VCC: %.2f V, AO multiplier: x%.3f, AO samples per reading: %u\n" " Warm-up: %" PRIu32 " s, calibration: %s, sample spacing: %" PRIu32 " ms", - this->vcc_, this->voltage_multiplier_, static_cast(this->samples_), this->warmup_time_ / 1000, - this->calibration_samples_ > 0 ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"), + LOG_STR_ARG(model_strings(this->conversion_).conversion), this->vcc_, this->voltage_multiplier_, + static_cast(this->samples_), this->warmup_time_ / 1000, + this->calibrate_at_boot_ ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"), this->sample_interval_); LOG_UPDATE_INTERVAL(this); - if (this->has_reference_()) { - ESP_LOGCONFIG(TAG, " Air reference (vcc - V_AO): %.4f V", this->reference_); - } else { - ESP_LOGCONFIG(TAG, " Air reference: not available yet"); + LOG_PIN(" Enable pin: ", this->enable_pin_); + if (!this->is_vendor_model_()) { + ESP_LOGCONFIG(TAG, " Load resistor: %.1f kOhm", this->rl_); } + if (this->has_reference_()) { + ESP_LOGCONFIG(TAG, " %s: %.4f", LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + } else { + ESP_LOGCONFIG(TAG, " %s: not available yet", LOG_STR_ARG(model_strings(this->conversion_).reference)); + } + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Ratio", this->ratio_sensor_); + LOG_SENSOR(" ", "RS", this->rs_sensor_); #ifdef MICS_5524_GAS_COUNT for (auto *gas : this->gases_) { LOG_SENSOR(" ", "Gas", gas); @@ -71,21 +138,19 @@ float MiCS5524Component::sample_voltage_() { return (sum / static_cast(this->samples_)) * this->voltage_multiplier_; } -void MiCS5524Component::publish_ratio_(float ratio) { -#ifdef MICS_5524_GAS_COUNT - for (auto *gas : this->gases_) { - gas->publish_ratio(ratio); - } +float MiCS5524Component::model_value_(float voltage) { + float value = micsmath::air_value_from_voltage(voltage, this->vcc_); +#ifdef USE_MICS_5524_DATASHEET + if (!this->is_vendor_model_()) + value = micsmath::rs_from_voltage(voltage, this->vcc_, this->rl_); #endif -} - -float MiCS5524Component::read_air_value_() { - const float voltage = this->sample_voltage_(); - const float x = micsmath::air_value_from_voltage(voltage, this->vcc_); - if (std::isfinite(x)) - return x; - // The status flag makes this log once per fault, not once per update. - if (!this->status_has_warning()) { + if (!std::isnan(value)) { + this->input_fault_ = false; + return value; + } + // Logged once per fault; the status flag cannot tell it from a failed calibration. + if (!this->input_fault_) { + this->input_fault_ = true; ESP_LOGW(TAG, "analog output reads %.4f V with vcc %.2f V\n check the AO wiring, 'vcc:' and 'voltage_multiplier:'", voltage, this->vcc_); } @@ -93,27 +158,76 @@ float MiCS5524Component::read_air_value_() { return NAN; } +void MiCS5524Component::publish_ratio_(float ratio) { +#ifdef MICS_5524_GAS_COUNT + for (auto *gas : this->gases_) { + gas->publish_ratio(ratio, this->conversion_); + } +#endif +} + +void MiCS5524Component::publish_diagnostics_(float voltage, float ratio, float rs) { + if (this->voltage_sensor_ != nullptr) + this->voltage_sensor_->publish_state(voltage); + if (this->ratio_sensor_ != nullptr) + this->ratio_sensor_->publish_state(ratio); + // Validation rejects `rs_sensor:` under the vendor model. + if (this->rs_sensor_ != nullptr) + this->rs_sensor_->publish_state(rs); +} + void MiCS5524Component::update() { + if (!this->has_reference_() && this->voltage_sensor_ == nullptr && this->ratio_sensor_ == nullptr && + this->rs_sensor_ == nullptr) + return; + + const float voltage = this->sample_voltage_(); + const float value = this->model_value_(voltage); + float ratio = NAN; + if (this->has_reference_()) { + ratio = this->is_vendor_model_() ? micsmath::ratio_from_air_value(value, this->reference_) + : micsmath::ratio_from_rs(value, this->reference_); + } + this->publish_diagnostics_(voltage, ratio, value); + + // A failed calibration keeps its warning until the retry succeeds. + if (!std::isnan(value) && !this->calibrating_ && !this->calibration_pending_) + this->status_clear_warning(); + // Every gas starts unknown and the reference is never cleared, so there is nothing to republish. if (!this->has_reference_()) return; - const float air_value = this->read_air_value_(); - if (std::isnan(air_value)) { - this->publish_ratio_(NAN); - return; - } - this->status_clear_warning(); - - const float ratio = micsmath::ratio_from_air_value(air_value, this->reference_); - ESP_LOGD(TAG, "V_AO=%.4f V, x=%.4f, ratio=%.4f", this->vcc_ - air_value, air_value, ratio); + ESP_LOGD(TAG, "V_AO=%.4f V, ratio=%.4f", voltage, ratio); this->publish_ratio_(ratio); } -void MiCS5524Component::set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval) { +void MiCS5524Component::set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval, bool persist, + bool at_boot) { this->calibration_delay_ = delay; this->calibration_samples_ = samples; this->sample_interval_ = sample_interval; + this->persist_ = persist; + this->calibrate_at_boot_ = at_boot; +} + +void MiCS5524Component::request_calibration() { + if (this->calibrating_) { + ESP_LOGW(TAG, "calibration already running"); + return; + } + if (this->calibration_pending_) { + ESP_LOGW(TAG, "calibration already requested"); + return; + } + this->calibration_pending_ = true; + if (this->warming_up_) { + // A request must never capture an unstable reading; the warm-up timeout starts it. + ESP_LOGI(TAG, "calibration requested - waiting for the warm-up window to end"); + return; + } + this->schedule_calibration_(0); + ESP_LOGI(TAG, "calibration requested - keep the sensor in clean air"); } void MiCS5524Component::loop() { @@ -131,7 +245,12 @@ void MiCS5524Component::loop() { this->process_calibration_(); } +void MiCS5524Component::schedule_calibration_(uint32_t delay) { + this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); +} + void MiCS5524Component::begin_calibration_() { + this->calibration_pending_ = false; this->calibrating_ = true; this->calibration_last_sample_ = App.get_loop_component_start_time(); this->calibration_count_ = 0; @@ -141,14 +260,15 @@ void MiCS5524Component::begin_calibration_() { this->calibration_max_ = -INFINITY; this->enable_loop(); - ESP_LOGI(TAG, "calibrating the air reference, %" PRIu32 " samples - keep the sensor in clean air", - this->calibration_samples_); + ESP_LOGI(TAG, "calibrating the %s, %" PRIu32 " samples - keep the sensor in clean air", + LOG_STR_ARG(model_strings(this->conversion_).reference), this->calibration_samples_); } void MiCS5524Component::process_calibration_() { this->calibration_attempts_++; - const float reference = this->read_air_value_(); + // The samples are averaged in the units of the active model. + const float reference = this->model_value_(this->sample_voltage_()); if (!std::isnan(reference)) { this->calibration_min_ = std::min(this->calibration_min_, reference); this->calibration_max_ = std::max(this->calibration_max_, reference); @@ -179,28 +299,59 @@ void MiCS5524Component::finish_calibration_() { // A sensor still settling, or not in clean air, varies too much to be the reference. if (!micsmath::calibration_is_stable(this->calibration_min_, this->calibration_max_, reference)) { - ESP_LOGW( - TAG, "clean-air samples spread %.4f V around %.4f V (%.4f - %.4f V)\n the sensor was not settled in clean air", - this->calibration_max_ - this->calibration_min_, reference, this->calibration_min_, this->calibration_max_); + ESP_LOGW(TAG, "clean-air samples spread %.4f around %.4f (%.4f - %.4f)\n the sensor was not settled in clean air", + this->calibration_max_ - this->calibration_min_, reference, this->calibration_min_, + this->calibration_max_); this->schedule_calibration_retry_(); return; } this->reference_ = reference; this->status_clear_warning(); + const bool stored = this->persist_ && this->save_reference_(); + const ModelStrings strings = model_strings(this->conversion_); ESP_LOGI(TAG, - "air reference = %.4f (%" PRIu32 "/%" PRIu32 " samples valid)\n" - " hard-code 'air_reference: %.4f' to skip the calibration at boot", - this->reference_, valid, attempts, this->reference_); + "%s = %.4f (%" PRIu32 "/%" PRIu32 " samples valid)%s\n" + " hard-code '%s: %.4f' to skip the calibration at boot", + LOG_STR_ARG(strings.reference), this->reference_, valid, attempts, + stored ? LOG_STR_LITERAL(", stored in flash") + : this->persist_ ? LOG_STR_LITERAL("") + : LOG_STR_LITERAL(", kept until reboot"), + LOG_STR_ARG(strings.key), this->reference_); } void MiCS5524Component::schedule_calibration_retry_() { this->status_set_warning(); + this->calibration_pending_ = true; // A zero delay would log the same failure about five times per second. const uint32_t delay = std::max(this->calibration_delay_, CALIBRATION_RETRY_MIN_MS); - this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); + this->schedule_calibration_(delay); ESP_LOGW(TAG, "retrying the calibration in %" PRIu32 " s", delay / 1000); } +bool MiCS5524Component::save_reference_() { + if (!this->reference_pref_.save(&this->reference_)) { + ESP_LOGW(TAG, "could not store the %s in flash", LOG_STR_ARG(model_strings(this->conversion_).reference)); + return false; + } + // Once per calibration, so write it out now rather than at the next preferences sync + global_preferences->sync(); + ESP_LOGD(TAG, "%s %.4f saved", LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + return true; +} + +bool MiCS5524Component::load_reference_() { + float stored = 0.0f; + if (!this->reference_pref_.load(&stored)) + return false; + if (!std::isfinite(stored) || stored <= 0.0f) { + ESP_LOGW(TAG, "ignoring the stored %s %.4f, calibrating again", + LOG_STR_ARG(model_strings(this->conversion_).reference), stored); + return false; + } + this->reference_ = stored; + return true; +} + } // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_5524.h b/esphome/components/mics_5524/mics_5524.h index de7263e5d78..6fcdd5baa9f 100644 --- a/esphome/components/mics_5524/mics_5524.h +++ b/esphome/components/mics_5524/mics_5524.h @@ -6,31 +6,37 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/voltage_sampler/voltage_sampler.h" #include "esphome/core/component.h" +#include "esphome/core/gpio.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" #include "mics_math.h" namespace esphome::mics_5524 { -/// One gas of a MiCS-5524: converts the measured ratio with the vendor curve of that gas. +/// One gas of a MiCS-5524: converts the measured ratio with the curve of that gas. class MiCS5524GasSensor : public sensor::Sensor { public: - /// The vendor constants from coefficients.py. - MiCS5524GasSensor(float threshold, float gain, float min_ppm, float max_ppm) - : curve_{threshold, gain, min_ppm, max_ppm} {} + /// `p0`/`p1` are threshold and gain (dfrobot) or a and b (datasheet), from coefficients.py. + MiCS5524GasSensor(float p0, float p1, float min_ppm, float max_ppm) + : p0_(p0), p1_(p1), min_ppm_(min_ppm), max_ppm_(max_ppm) {} - /// Publish the concentration for `ratio`; NAN publishes `unknown`. - void publish_ratio(float ratio); + /// Publish the concentration for `ratio` with the formula of `model`; NAN publishes `unknown`. + void publish_ratio(float ratio, micsmath::ConversionModel model); - float get_min_ppm() const { return this->curve_.min_ppm; } - float get_max_ppm() const { return this->curve_.max_ppm; } + float get_min_ppm() const { return this->min_ppm_; } + float get_max_ppm() const { return this->max_ppm_; } protected: - micsmath::VendorCurve curve_; + float p0_; + float p1_; + float min_ppm_; + float max_ppm_; }; -/// The MiCS-5524 breakout: one analog output, converted for every configured gas against -/// the clean-air reference x_air from `air_reference:` or a calibration at boot. +/// The MiCS-5524 breakout: one analog output, converted for every configured gas against the +/// clean-air reference, x_air (vendor) or R0 (datasheet), from the configuration, flash or a +/// calibration at boot. class MiCS5524Component : public PollingComponent { public: /// `source` must outlive this component, which code generation guarantees. @@ -45,34 +51,57 @@ class MiCS5524Component : public PollingComponent { void register_gas_sensor(MiCS5524GasSensor *gas) { this->gases_.push_back(gas); } #endif + void set_conversion(micsmath::ConversionModel conversion) { this->conversion_ = conversion; } void set_vcc(float vcc) { this->vcc_ = vcc; } + void set_rl(float rl) { this->rl_ = rl; } void set_voltage_multiplier(float multiplier) { this->voltage_multiplier_ = multiplier; } void set_samples(uint8_t samples) { this->samples_ = samples; } void set_warmup_time(uint32_t warmup) { this->warmup_time_ = warmup; } - /// Calibrate at boot: start after `delay` ms, average `samples` readings `sample_interval` ms apart. - void set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval); - void set_air_reference(float air_reference) { this->reference_ = air_reference; } + /// Base of the flash key of the stored reference, a hash of the component ID and the front end. + void set_preference_key(uint32_t key) { this->preference_key_ = key; } + /// Driven active in setup(); EN of the breakout is active low, so `inverted: true` turns that on. + void set_enable_pin(GPIOPin *pin) { this->enable_pin_ = pin; } + /// Calibration parameters: start after `delay` ms, average `samples` readings `sample_interval` ms + /// apart; `persist` keeps the result in flash, `at_boot` runs one at boot. + void set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval, bool persist, bool at_boot); + /// Clean-air reference from the configuration, x_air (dfrobot) or R0 (datasheet). + void set_reference(float reference) { this->reference_ = reference; } + /// Deferred until the warm-up window ends. + void request_calibration(); + SUB_SENSOR(voltage) + SUB_SENSOR(ratio) + // RS only exists in the datasheet model. + SUB_SENSOR(rs) protected: /// Averaged, scaled output voltage (V); the conversions run back to back. float sample_voltage_(); - /// x = vcc - V_AO, or NAN (and a status warning) when the voltage is unusable. - float read_air_value_(); + /// Value of the active model that the reference is taken from, x = VCC - V_AO (vendor) or + /// RS (datasheet), or NAN (and a status warning) when the voltage is unusable. + float model_value_(float voltage); void publish_ratio_(float ratio); + void publish_diagnostics_(float voltage, float ratio, float rs); + /// Start a calibration after `delay` ms; every start goes through here. + void schedule_calibration_(uint32_t delay); void begin_calibration_(); void process_calibration_(); void finish_calibration_(); void schedule_calibration_retry_(); + /// True when the reference reached the storage. + bool save_reference_(); + bool load_reference_(); + bool is_vendor_model_() const { return this->conversion_ == micsmath::CONVERSION_MODEL_DFROBOT; } bool has_reference_() const { return this->reference_ > 0.0f; } voltage_sampler::VoltageSampler *source_{nullptr}; float vcc_{5.0f}; + float rl_{10.0f}; float voltage_multiplier_{1.0f}; uint32_t warmup_time_{0}; + GPIOPin *enable_pin_{nullptr}; uint32_t calibration_delay_{0}; - /// 0 when calibration is disabled. uint32_t calibration_samples_{0}; /// Spacing of the clean-air calibration samples (ms); the ~16 ms loop tick is the floor. uint32_t sample_interval_{20}; @@ -89,16 +118,28 @@ class MiCS5524Component : public PollingComponent { float calibration_min_{0.0f}; float calibration_max_{0.0f}; - /// Clean-air reference x_air in volts, 0 while there is none. + /// Clean-air reference, x_air in volts or R0 in kOhm, 0 while there is none. float reference_{0.0f}; + uint32_t preference_key_{0}; + ESPPreferenceObject reference_pref_; + // Sized by code generation; the host unit tests run none, so the list compiles out there. #ifdef MICS_5524_GAS_COUNT StaticVector gases_; #endif + micsmath::ConversionModel conversion_{micsmath::CONVERSION_MODEL_DFROBOT}; uint8_t samples_{4}; + bool persist_{false}; + bool calibrate_at_boot_{false}; + /// Set from setup() until the warm-up timeout fires. + bool warming_up_{false}; bool calibrating_{false}; + /// A calibration waits for its start time; `calibrating_` takes over from there. + bool calibration_pending_{false}; + /// The out-of-range warning has been logged; the next usable reading clears it. + bool input_fault_{false}; }; } // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_math.h b/esphome/components/mics_5524/mics_math.h index e66def345db..3e56cc09be5 100644 --- a/esphome/components/mics_5524/mics_math.h +++ b/esphome/components/mics_5524/mics_math.h @@ -1,12 +1,21 @@ #pragma once -// Pure math of the MiCS-5524 vendor model; the per-gas constants live in coefficients.py. -// x = VCC - V_ao, ratio = x / x_air (sampled in clean air), ppm = (threshold - ratio) / gain. -// No ESPHome dependencies, so the host tests cover it. +// Pure math of the two MiCS-5524 conversion models; the per-gas constants live in coefficients.py. +// dfrobot: x = VCC - V_ao, ratio = x / x_air (sampled in clean air), ppm = (threshold - ratio) / gain. +// datasheet: RS = (VCC * RL) / V_ao - RL, ratio = RS / R0 (RS in clean air), ppm = a * ratio^b. +// Every helper returns NaN for input that cannot produce a reading. No ESPHome dependencies, so the +// host tests cover it. #include +#include namespace esphome::mics_5524::micsmath { +/// Generated through CONVERSION_MODELS in __init__.py. +enum ConversionModel : uint8_t { + CONVERSION_MODEL_DFROBOT = 0, + CONVERSION_MODEL_DATASHEET = 1, +}; + /// Per-gas constants of the vendor model. struct VendorCurve { float threshold; ///< ratio at or below which the gas counts as detected @@ -15,24 +24,21 @@ struct VendorCurve { float max_ppm; ///< upper end of the measuring range }; -/// x = VCC - V_ao: 0 at VCC (a saturated sensor, never clean air). NaN at or below 0 V (a -/// disconnected pin) and above VCC, which only a wrong vcc or voltage_multiplier produces. +// ------------------------------------------------------------------ vendor model + +/// Share of VCC at or below which the vendor model reads the output as disconnected: clean air +/// sits a volt or two below VCC, so a reading near 0 V is a wiring fault, not gas-free air. +inline constexpr float VENDOR_DEAD_INPUT_FRACTION = 0.05f; + +/// x = VCC - V_ao: 0 at VCC (a saturated sensor, never clean air). NaN at or below +/// VENDOR_DEAD_INPUT_FRACTION of VCC (a disconnected pin) and above VCC, which only a wrong vcc or +/// voltage_multiplier produces. inline float air_value_from_voltage(float voltage, float vcc) { - if (!std::isfinite(voltage) || !std::isfinite(vcc) || voltage <= 0.0f || voltage > vcc) + if (!std::isfinite(voltage) || !std::isfinite(vcc) || voltage <= VENDOR_DEAD_INPUT_FRACTION * vcc || voltage > vcc) return NAN; return vcc - voltage; } -/// Largest relative spread (max - min) / mean a clean-air calibration accepts. -inline constexpr float MAX_CALIBRATION_SPREAD = 0.25f; - -/// True when the clean-air samples spread at most MAX_CALIBRATION_SPREAD of their mean. -inline bool calibration_is_stable(float min_value, float max_value, float mean) { - if (!std::isfinite(min_value) || !std::isfinite(max_value) || !std::isfinite(mean) || mean <= 0.0f) - return false; - return max_value - min_value <= MAX_CALIBRATION_SPREAD * mean; -} - /// ratio = x / x_air (NaN when either value is unusable). inline float ratio_from_air_value(float x, float x_air) { if (!std::isfinite(x) || x < 0.0f || !std::isfinite(x_air) || x_air <= 0.0f) @@ -56,4 +62,67 @@ inline float ppm_from_vendor_curve(float ratio, const VendorCurve &curve) { return ppm; } +// --------------------------------------------------------------- datasheet model + +/// Largest RS the datasheet model reads, as a multiple of RL. The datasheet puts the clean-air +/// resistance of a module below about 1.5 MOhm, 150 x the usual 10k RL, so an output voltage +/// below that of this resistance is a disconnected input. +inline constexpr float MAX_RS_OVER_RL = 200.0f; + +/// Smallest V_ao the datasheet model reads: the output of RS = MAX_RS_OVER_RL * RL. +inline float datasheet_dead_input_voltage(float vcc) { return vcc / (MAX_RS_OVER_RL + 1.0f); } + +/// RS = (VCC * RL) / V - RL in kOhm: 0 at VCC (a saturated sensor). NaN above VCC, which only a +/// wrong vcc or voltage_multiplier produces, and at or below datasheet_dead_input_voltage(). +inline float rs_from_voltage(float voltage, float vcc, float rl) { + if (!std::isfinite(voltage) || !std::isfinite(vcc) || !std::isfinite(rl) || rl <= 0.0f || voltage > vcc || + voltage <= datasheet_dead_input_voltage(vcc)) + return NAN; + return vcc * rl / voltage - rl; +} + +/// ratio = RS / R0 (NaN when either value is unusable); 0 is the saturated end. +inline float ratio_from_rs(float rs, float r0) { + if (!std::isfinite(rs) || rs < 0.0f || !std::isfinite(r0) || r0 <= 0.0f) + return NAN; + return rs / r0; +} + +/// ppm = a * ratio^b; NaN for a ratio or coefficient that cannot produce a reading. Ratio 0, the +/// saturated end, gives infinity on a falling curve, which clamp_ppm turns into max_ppm. +inline float ppm_from_power_law(float a, float b, float ratio) { + if (!std::isfinite(a) || !std::isfinite(b) || !std::isfinite(ratio) || ratio < 0.0f || a <= 0.0f) + return NAN; + + const float ppm = a * powf(ratio, b); + return std::isnan(ppm) ? NAN : ppm; +} + +/// Clamp to the configured range: below min_ppm counts as not detected and reads 0, 0 itself +/// never changes, and a reading past a finite max_ppm (infinity included) is max_ppm. NaN stays +/// NaN. +inline float clamp_ppm(float ppm, float min_ppm, float max_ppm) { + if (std::isnan(ppm)) + return NAN; + if (ppm <= 0.0f) + return 0.0f; + if (std::isfinite(min_ppm) && ppm < min_ppm) + return 0.0f; + if (std::isfinite(max_ppm) && ppm > max_ppm) + return max_ppm; + return std::isfinite(ppm) ? ppm : NAN; +} + +// ----------------------------------------------------------------- calibration + +/// Largest relative spread (max - min) / mean a clean-air calibration accepts. +inline constexpr float MAX_CALIBRATION_SPREAD = 0.25f; + +/// True when the clean-air samples spread at most MAX_CALIBRATION_SPREAD of their mean. +inline bool calibration_is_stable(float min_value, float max_value, float mean) { + if (!std::isfinite(min_value) || !std::isfinite(max_value) || !std::isfinite(mean) || mean <= 0.0f) + return false; + return max_value - min_value <= MAX_CALIBRATION_SPREAD * mean; +} + } // namespace esphome::mics_5524::micsmath diff --git a/esphome/components/mics_5524/sensor.py b/esphome/components/mics_5524/sensor.py index f2b05c8da2e..7b80572a115 100644 --- a/esphome/components/mics_5524/sensor.py +++ b/esphome/components/mics_5524/sensor.py @@ -2,35 +2,85 @@ import logging +from esphome import automation, pins import esphome.codegen as cg from esphome.components import sensor, voltage_sampler +from esphome.components.adc import ATTENUATION_MODES +from esphome.components.esp32 import get_esp32_variant import esphome.config_validation as cv from esphome.const import ( + CONF_ATTENUATION, CONF_CALIBRATION, CONF_DELAY, + CONF_ENABLE_PIN, CONF_ID, + CONF_INTERNAL, + CONF_INVERTED, + CONF_NAME, + CONF_PIN, + CONF_RAW, + CONF_UPDATE_INTERVAL, CONF_VOLTAGE, + CONF_VOLTAGE_DIVIDER, CONF_WARMUP_TIME, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, + UNIT_VOLT, ) +from esphome.core import CORE +from esphome.helpers import fnv1_hash from esphome.types import ConfigType from . import ( + ADC_LIMIT_MARGIN_V, + ADC_LIMITS_V, + CONF_ADC_ATTENUATION, + CONF_ADC_INPUT_MAX, + CONF_ADC_PIN_MAX, CONF_AIR_REFERENCE, + CONF_COEFFICIENT_A, + CONF_COEFFICIENT_B, + CONF_CONVERSION, CONF_MAX_PPM, + CONF_MIN_PPM, + CONF_PERSIST, + CONF_R0, + CONF_R1, + CONF_R2, + CONF_RATIO_SENSOR, + CONF_RL, + CONF_RS_SENSOR, CONF_SAMPLE_COUNT, CONF_SAMPLE_INTERVAL, CONF_VCC, CONF_VOLTAGE_MULTIPLIER, + CONF_VOLTAGE_SENSOR, + CONVERSION_MODELS, MiCS5524Component, MiCS5524GasSensor, + esp32_attenuation_input_max, +) +from .coefficients import ( + CONVERSION_DATASHEET, + CONVERSION_DFROBOT, + GASES, + ResolvedGas, + resolve_gas, ) -from .coefficients import GASES _LOGGER = logging.getLogger(__name__) -AUTO_LOAD = ["sensor", "voltage_sampler"] + +def _auto_load(config: ConfigType | None) -> list[str]: + """Load adc only for a pin: source; it has no host build, so the unit tests cannot link it.""" + if config is not None and CONF_PIN in config: + return ["sensor", "voltage_sampler", "adc"] + return ["sensor", "voltage_sampler"] + + +AUTO_LOAD = _auto_load # The main loop runs every ~16 ms, so a shorter spacing cannot sample more often. MIN_SAMPLE_INTERVAL_MS = 16 @@ -40,10 +90,135 @@ MAX_SAMPLE_COUNT = 16 _request_gas_slot = cg.slot_counter("MICS_5524_GAS_COUNT") +# The generated adc entry lives in the validated config so the ID pass sees its ID. +_KEY_GENERATED_ADC = "_generated_adc" + +DIVIDER_SCHEMA = cv.Schema( + { + cv.Required(CONF_R1): cv.positive_not_null_float, + cv.Required(CONF_R2): cv.positive_not_null_float, + } +) + + +def _build_internal_adc(config: ConfigType) -> ConfigType: + """Validate the hidden ``adc`` entry of a ``pin:`` configuration.""" + from esphome.components.adc import sensor as adc_sensor + + adc_id = f"{config[CONF_ID].id}_adc" + adc_config: ConfigType = { + CONF_ID: adc_id, + CONF_PIN: config[CONF_PIN], + CONF_NAME: f"{adc_id} voltage", + CONF_INTERNAL: True, + CONF_RAW: False, + # sample_count of the component is the only averaging layer. + adc_sensor.CONF_SAMPLES: 1, + adc_sensor.CONF_SAMPLING_MODE: "avg", + CONF_UPDATE_INTERVAL: "never", + } + if (attenuation := config.get(CONF_ADC_ATTENUATION)) is not None: + adc_config[CONF_ATTENUATION] = attenuation + elif CORE.is_esp32: + # The 0 dB default (~1 V) would clip the 3.3 V the divider guard allows. + adc_config[CONF_ATTENUATION] = "12db" + + return adc_sensor.CONFIG_SCHEMA(adc_config) + + +def _voltage_multiplier(config: ConfigType) -> float: + """Output volts per sampler volt: from voltage_divider: when set, else voltage_multiplier:.""" + if (divider := config.get(CONF_VOLTAGE_DIVIDER)) is not None: + return (divider[CONF_R1] + divider[CONF_R2]) / divider[CONF_R2] + return float(config.get(CONF_VOLTAGE_MULTIPLIER, 1.0)) + + +def _preference_key(config: ConfigType) -> int: + """Flash key of the stored reference; a new vcc, scaling or rl must not load an old value.""" + parts = [ + config[CONF_ID].id, + f"{config[CONF_VCC]:g}", + f"{_voltage_multiplier(config):g}", + ] + if (rl := config.get(CONF_RL)) is not None: + parts.append(f"{rl:g}") + return fnv1_hash(":".join(parts)) + + +def _validate_adc_keys(config: ConfigType) -> ConfigType: + """Reject the ADC keys where they would be accepted and ignored.""" + if CONF_VOLTAGE_DIVIDER not in config and CONF_PIN not in config: + for key in (CONF_ADC_INPUT_MAX, CONF_ADC_PIN_MAX): + if key in config: + raise cv.Invalid( + f"'{key}:' only checks a 'pin:' source or a 'voltage_divider:', " + "so it has no effect here", + path=[key], + ) + if CONF_ADC_ATTENUATION in config and CONF_PIN not in config: + raise cv.Invalid( + "'adc_attenuation:' only applies to the ADC of a 'pin:' source - set " + "'attenuation:' on the adc sensor behind 'voltage:' instead", + path=[CONF_ADC_ATTENUATION], + ) + return config + + +def _validate_adc_range(config: ConfigType) -> ConfigType: + """Reject wiring that puts the supply, the largest possible reading, past the sampler limits. + + A ``pin:`` source is always checked; a ``voltage:`` source only through its ``voltage_divider:``, + since the scaling of an external sampler is not known here. + """ + divider = config.get(CONF_VOLTAGE_DIVIDER) + if divider is None and CONF_PIN not in config: + return config + + supply = config[CONF_VCC] + pin_voltage = supply / _voltage_multiplier(config) + if (limits := ADC_LIMITS_V.get(CORE.target_platform)) is None: + if CONF_ADC_INPUT_MAX not in config or CONF_ADC_PIN_MAX not in config: + raise cv.Invalid( + f"no built-in ADC limits for {CORE.target_platform}: declare " + "'adc_input_max:' and 'adc_pin_max:' so the wiring can be checked" + ) + limits = (config[CONF_ADC_INPUT_MAX], config[CONF_ADC_PIN_MAX]) + default_input_max, default_pin_max = limits + if CONF_PIN in config and CORE.is_esp32: + # The attenuation picks the measuring range of the internal ADC + default_input_max = esp32_attenuation_input_max( + get_esp32_variant(), config.get(CONF_ADC_ATTENUATION, "12db") + ) + pin_max = config.get(CONF_ADC_PIN_MAX, default_pin_max) + input_max = config.get(CONF_ADC_INPUT_MAX, default_input_max) + if divider is not None: + wiring = ( + f"the divider brings {supply:g} V down to {pin_voltage:.4g} V on the pin" + ) + remedy = "raise r1/r2" + elif CONF_VOLTAGE_MULTIPLIER in config: + wiring = f"{supply:g} V scaled by 1/{config[CONF_VOLTAGE_MULTIPLIER]:g} is {pin_voltage:.4g} V on the pin" + remedy = "raise 'voltage_multiplier:' to match the wiring" + else: + wiring = f"the {supply:g} V supply reaches the pin unscaled" + remedy = ( + "add a 'voltage_divider:' (or 'voltage_multiplier:' for one already wired)" + ) + if pin_voltage > pin_max + ADC_LIMIT_MARGIN_V: + raise cv.Invalid( + f"{wiring}, above the {pin_max:g} V the ADC tolerates - {remedy}, or " + "declare 'adc_pin_max:' for a sampler that takes more" + ) + if pin_voltage > input_max + ADC_LIMIT_MARGIN_V: + raise cv.Invalid( + f"{wiring}, above the {input_max:g} V measuring range of the sampler - {remedy}, " + "or declare 'adc_input_max:' for a sampler with a wider range" + ) + return config + def _gas_schema(gas_key: str) -> cv.Schema: """Schema of one gas sub-sensor.""" - curve = GASES[gas_key] return sensor.sensor_schema( MiCS5524GasSensor, unit_of_measurement=UNIT_PARTS_PER_MILLION, @@ -52,10 +227,13 @@ def _gas_schema(gas_key: str) -> cv.Schema: icon="mdi:molecule", ).extend( { - # A ceiling above the vendor range could never be reached. - cv.Optional(CONF_MAX_PPM, default=curve.max_ppm): cv.float_range( - min=0, min_included=False, max=curve.max_ppm - ) + # No default: each conversion model has its own ceiling, resolved in resolve_gas. + cv.Optional(CONF_MAX_PPM): cv.positive_not_null_float, + cv.Optional(CONF_MIN_PPM): cv.positive_float, + cv.Inclusive( + CONF_COEFFICIENT_A, "coefficients" + ): cv.positive_not_null_float, + cv.Inclusive(CONF_COEFFICIENT_B, "coefficients"): cv.float_, } ) @@ -70,13 +248,35 @@ CALIBRATION_SCHEMA = cv.Schema( cv.positive_time_period_milliseconds, cv.Range(min=cv.TimePeriod(milliseconds=MIN_SAMPLE_INTERVAL_MS)), ), + cv.Optional(CONF_PERSIST, default=True): cv.boolean, } ) +def _resolve_gas(gas_key: str, config: ConfigType) -> ResolvedGas: + """Resolve one configured gas against the conversion model of its hub.""" + gas = config[gas_key] + return resolve_gas( + gas_key, + config[CONF_CONVERSION], + a=gas.get(CONF_COEFFICIENT_A), + b=gas.get(CONF_COEFFICIENT_B), + min_ppm=gas.get(CONF_MIN_PPM), + max_ppm=gas.get(CONF_MAX_PPM), + ) + + def _validate_config(config: ConfigType) -> ConfigType: - """Reject an air reference at or above the supply; warn when there is none.""" + """Check the reference keys, the conversion and every gas against each other.""" + conversion = config[CONF_CONVERSION] + vendor_model = conversion == CONVERSION_DFROBOT + if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: + if not vendor_model: + raise cv.Invalid( + f"'air_reference:' belongs to 'conversion: {CONVERSION_DFROBOT}', use " + f"'r0:' with 'conversion: {CONVERSION_DATASHEET}'" + ) # The reference is vcc - V_AO in clean air, so a larger one reads 0 ppm forever. if air_reference >= config[CONF_VCC]: raise cv.Invalid( @@ -84,11 +284,73 @@ def _validate_config(config: ConfigType) -> ConfigType: f"({config[CONF_VCC]:g} V): it is how far the analog output sits " "below the supply in clean air" ) - elif CONF_CALIBRATION not in config: + elif CONF_CALIBRATION not in config and CONF_R0 not in config: _LOGGER.warning( - "no air reference configured - set 'air_reference:' or 'calibration:', " - "the PPM values stay unknown until then" + "no clean-air reference configured - set '%s' or 'calibration:', or every " + "PPM value stays unknown until the sensor has been calibrated in clean air", + CONF_AIR_REFERENCE if vendor_model else CONF_R0, ) + + if vendor_model: + for key, reason in ( + (CONF_R0, f"use 'air_reference:' with 'conversion: {CONVERSION_DFROBOT}'"), + ( + CONF_RL, + ( + f"'conversion: {CONVERSION_DFROBOT}' derives everything from the " + "clean-air reference" + ), + ), + ( + CONF_RS_SENSOR, + "the vendor model never computes RS, so the sensor would stay unknown", + ), + ): + if key in config: + raise cv.Invalid( + f"'{key}:' belongs to 'conversion: {CONVERSION_DATASHEET}' - {reason}" + ) + + for gas_key, definition in GASES.items(): + if gas_key not in config: + continue + try: + resolved = _resolve_gas(gas_key, config) + except ValueError as err: + raise cv.Invalid(str(err)) from err + if resolved.max_ppm <= resolved.min_ppm: + raise cv.Invalid( + f"max_ppm ({resolved.max_ppm:g}) must be greater than min_ppm " + f"({resolved.min_ppm:g})" + ) + # Only the vendor model has a documented range to check against. + if vendor_model and resolved.max_ppm > ( + vendor_max_ppm := definition.vendor.max_ppm + ): + raise cv.Invalid( + f"max_ppm ({resolved.max_ppm:g}) is above the {resolved.label} measuring " + f"range of {vendor_max_ppm:g} ppm, so the conversion can never reach it" + ) + return config + + +def _validate_enable_pin(config: ConfigType) -> ConfigType: + """Warn about an EN pin without inverted: true, which keeps the active-low module asleep.""" + if (enable := config.get(CONF_ENABLE_PIN)) is not None and not enable.get( + CONF_INVERTED, False + ): + _LOGGER.warning( + "'enable_pin:' is driven active high, but the EN input of the DFRobot " + "MiCS-5524 breakout is active low: without 'inverted: true' the module stays " + "asleep and every reading stays at 0 V" + ) + return config + + +def _build_pin_source(config: ConfigType) -> ConfigType: + """Build the hidden adc entry inside the validated config so the ID pass sees its ID.""" + if CONF_PIN in config: + config[_KEY_GENERATED_ADC] = _build_internal_adc(config) return config @@ -96,12 +358,24 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(MiCS5524Component), - cv.Required(CONF_VOLTAGE): cv.use_id(voltage_sampler.VoltageSampler), - cv.Optional( - CONF_VOLTAGE_MULTIPLIER, default=1.0 - ): cv.positive_not_null_float, + cv.Optional(CONF_VOLTAGE): cv.use_id(voltage_sampler.VoltageSampler), + # Validated by the hidden adc entry built below. + cv.Optional(CONF_PIN): cv.valid, + cv.Optional(CONF_VOLTAGE_DIVIDER): DIVIDER_SCHEMA, + cv.Optional(CONF_ADC_INPUT_MAX): cv.positive_not_null_float, + cv.Optional(CONF_ADC_PIN_MAX): cv.positive_not_null_float, + cv.Optional(CONF_ADC_ATTENUATION): cv.one_of( + *ATTENUATION_MODES, lower=True + ), + cv.Optional(CONF_CONVERSION, default=CONVERSION_DFROBOT): cv.enum( + CONVERSION_MODELS, lower=True + ), + cv.Optional(CONF_VOLTAGE_MULTIPLIER): cv.positive_not_null_float, cv.Optional(CONF_VCC, default=5.0): cv.positive_not_null_float, + cv.Optional(CONF_RL): cv.positive_not_null_float, cv.Optional(CONF_AIR_REFERENCE): cv.positive_not_null_float, + cv.Optional(CONF_R0): cv.positive_not_null_float, + cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_SAMPLE_COUNT, default=4): cv.int_range( min=1, max=MAX_SAMPLE_COUNT ), @@ -109,45 +383,111 @@ CONFIG_SCHEMA = cv.All( CONF_WARMUP_TIME, default="3min" ): cv.positive_time_period_milliseconds, cv.Optional(CONF_CALIBRATION): CALIBRATION_SCHEMA, + cv.Optional(CONF_VOLTAGE_SENSOR): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RATIO_SENSOR): sensor.sensor_schema( + accuracy_decimals=4, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RS_SENSOR): sensor.sensor_schema( + unit_of_measurement="kΩ", + accuracy_decimals=2, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), **{cv.Optional(gas_key): _gas_schema(gas_key) for gas_key in GASES}, } ).extend(cv.polling_component_schema("30s")), + cv.has_exactly_one_key(CONF_VOLTAGE, CONF_PIN), + cv.has_at_most_one_key(CONF_VOLTAGE_DIVIDER, CONF_VOLTAGE_MULTIPLIER), cv.has_at_least_one_key(*GASES), - cv.has_at_most_one_key(CONF_AIR_REFERENCE, CONF_CALIBRATION), + cv.has_at_most_one_key(CONF_AIR_REFERENCE, CONF_R0, CONF_CALIBRATION), + _validate_adc_keys, + _validate_adc_range, _validate_config, + _validate_enable_pin, + _build_pin_source, ) async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], await cg.get_variable(config[CONF_VOLTAGE])) + source = ( + await cg.get_variable(config[CONF_VOLTAGE]) + if CONF_VOLTAGE in config + else await _create_internal_adc(config) + ) + var = cg.new_Pvariable(config[CONF_ID], source) await cg.register_component(var, config) + cg.add(var.set_conversion(config[CONF_CONVERSION])) + if config[CONF_CONVERSION] == CONVERSION_DATASHEET: + # Only a build with a datasheet model links the power law and its libm + cg.add_define("USE_MICS_5524_DATASHEET") cg.add(var.set_vcc(config[CONF_VCC])) - cg.add(var.set_voltage_multiplier(config[CONF_VOLTAGE_MULTIPLIER])) + if (rl := config.get(CONF_RL)) is not None: + cg.add(var.set_rl(rl)) + cg.add(var.set_voltage_multiplier(_voltage_multiplier(config))) cg.add(var.set_samples(config[CONF_SAMPLE_COUNT])) cg.add(var.set_warmup_time(config[CONF_WARMUP_TIME])) + cg.add(var.set_preference_key(_preference_key(config))) - if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: - cg.add(var.set_air_reference(air_reference)) - elif (calibration := config.get(CONF_CALIBRATION)) is not None: - cg.add( - var.set_calibration( - calibration[CONF_DELAY], - calibration[CONF_SAMPLE_COUNT], - calibration[CONF_SAMPLE_INTERVAL], - ) + if (enable := config.get(CONF_ENABLE_PIN)) is not None: + cg.add(var.set_enable_pin(await cg.gpio_pin_expression(enable))) + if (reference := config.get(CONF_AIR_REFERENCE, config.get(CONF_R0))) is not None: + cg.add(var.set_reference(reference)) + # A request on a hub without `calibration:` uses the schema defaults, never persisted. + calibration = config.get(CONF_CALIBRATION) + at_boot = calibration is not None + if calibration is None: + calibration = CALIBRATION_SCHEMA({}) + cg.add( + var.set_calibration( + calibration[CONF_DELAY], + calibration[CONF_SAMPLE_COUNT], + calibration[CONF_SAMPLE_INTERVAL], + at_boot and calibration[CONF_PERSIST], + at_boot, ) + ) sensors = sensor.sub_sensors(config) - for gas_key, curve in GASES.items(): - if (gas_config := config.get(gas_key)) is None: + await sensors(CONF_VOLTAGE_SENSOR, var.set_voltage_sensor) + await sensors(CONF_RATIO_SENSOR, var.set_ratio_sensor) + await sensors(CONF_RS_SENSOR, var.set_rs_sensor) + + for gas_key in GASES: + if gas_key not in config: continue + resolved = _resolve_gas(gas_key, config) _request_gas_slot(str(var)) await sensors( gas_key, var.register_gas_sensor, - curve.threshold, - curve.gain, - curve.min_ppm, - gas_config[CONF_MAX_PPM], + resolved.p0, + resolved.p1, + resolved.min_ppm, + resolved.max_ppm, ) + + +async def _create_internal_adc(config: ConfigType) -> cg.MockObj: + """Create the ``adc`` sensor of a ``pin:`` configuration and return it.""" + from esphome.components.adc import sensor as adc_sensor + + adc_config = config[_KEY_GENERATED_ADC] + await adc_sensor.to_code(adc_config) + return await cg.get_variable(adc_config[CONF_ID]) + + +CALIBRATION_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.GenerateID(): cv.use_id(MiCS5524Component)} +) + +automation.register_apply_action( + "mics_5524.request_calibration", + CALIBRATION_ACTION_SCHEMA, + automation.ApplyCall("request_calibration()"), +) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 756485e0157..97677334828 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -167,6 +167,7 @@ #define LD2450_POLYGON_ZONE_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define MICS_5524_GAS_COUNT 5 +#define USE_MICS_5524_DATASHEET #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 #define NFC_TAG_LISTENER_COUNT 1 diff --git a/tests/component_tests/mics_5524/config/mics_5524.yaml b/tests/component_tests/mics_5524/config/mics_5524.yaml index 7538fb973e1..3a5f2459dcf 100644 --- a/tests/component_tests/mics_5524/config/mics_5524.yaml +++ b/tests/component_tests/mics_5524/config/mics_5524.yaml @@ -35,3 +35,29 @@ sensor: hydrogen: id: mics_5524_h2 name: MiCS-5524 H2 + + # The datasheet model on the ADC this component owns: the 10k/20k divider puts 3.33 V on + # the pin, so it needs adc_input_max: 3.34 and the 12 dB attenuation it gets by default. + - platform: mics_5524 + id: mics_5524_pin + pin: GPIO32 + voltage_divider: + r1: 10000 + r2: 20000 + adc_input_max: 3.34 + conversion: datasheet + r0: 10.0 + # Diagnostics of this reading, declared by the hub itself. + ratio_sensor: + id: mics_5524_ratio + name: MiCS-5524 ratio + rs_sensor: + id: mics_5524_rs + name: MiCS-5524 RS + voltage_sensor: + id: mics_5524_volts + name: MiCS-5524 volts + carbon_monoxide: + id: mics_5524_co_ds + name: MiCS-5524 CO (datasheet) + max_ppm: 2000.0 diff --git a/tests/component_tests/mics_5524/test_mics_5524.py b/tests/component_tests/mics_5524/test_mics_5524.py index 53dc2f49708..1ab5d9ae1a3 100644 --- a/tests/component_tests/mics_5524/test_mics_5524.py +++ b/tests/component_tests/mics_5524/test_mics_5524.py @@ -2,19 +2,49 @@ from collections.abc import Callable from pathlib import Path +import re import pytest from esphome import config_validation as cv -from esphome.components.mics_5524.coefficients import GASES, VendorCurve -from esphome.components.mics_5524.sensor import CONFIG_SCHEMA +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mics_5524.coefficients import ( + GASES, + DatasheetCurve, + GasDefinition, + VendorCurve, + datasheet_gases, +) +from esphome.components.mics_5524.sensor import CONFIG_SCHEMA, _voltage_multiplier from esphome.const import ( CONF_AMMONIA, CONF_CARBON_MONOXIDE, CONF_ETHANOL, CONF_HYDROGEN, CONF_METHANE, + PlatformFramework, ) +from esphome.helpers import fnv1_hash +from tests.component_tests.types import SetCoreConfigCallable + + +def _gas_arguments(main_cpp: str, variable: str) -> list[str]: + """Arguments of the constructor call that creates one gas sub-sensor.""" + match = re.search( + rf"new\({variable}\) mics_5524::MiCS5524GasSensor\((.*?)\);", + main_cpp, + ) + assert match is not None, f"{variable} was not constructed" + return [argument.strip() for argument in match.group(1).split(",")] + + +def _gas_constants(arguments: list[str]) -> list[float]: + """The numeric arguments of a constructor call, read as floats. + + Comparing numbers rather than their text keeps the test about the values and not + about how code generation happens to format them. + """ + return [float(argument.removesuffix("f")) for argument in arguments] def test_voltage_source_is_a_constructor_argument( @@ -37,21 +67,19 @@ def test_every_gas_becomes_a_sub_sensor_of_its_hub( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The curve is passed to the constructor and the sub-sensor to its own hub.""" + """The constants are passed to the constructor and the sub-sensor to its own hub.""" main_cpp = generate_main(component_config_path("mics_5524.yaml")) - # Threshold, gain, minimum and maximum ppm of the gas. - assert ( - "new(mics_5524_co) mics_5524::MiCS5524GasSensor(0.425f, " - "0.000405f, 1.0f, 500.0f);" in main_cpp - ) + # The vendor threshold and gain and the measuring range; the model lives on the hub. + co = _gas_arguments(main_cpp, "mics_5524_co") + assert _gas_constants(co) == [0.425, 0.000405, 1.0, 500.0] assert "mics_5524_boot->register_gas_sensor(mics_5524_co);" in main_cpp + # A gas without `max_ppm:` keeps the measuring range of the vendor. - assert ( - "new(mics_5524_ch4) mics_5524::MiCS5524GasSensor(0.786f, " - "2.3e-05f, 1000.0f, 25000.0f);" in main_cpp - ) + ch4 = _gas_arguments(main_cpp, "mics_5524_ch4") + assert _gas_constants(ch4) == [0.786, 0.000023, 1000.0, 25000.0] assert "mics_5524_boot->register_gas_sensor(mics_5524_ch4);" in main_cpp + # The gas belongs to the sensor it is configured under, not to the first one. assert "mics_5524_fixed->register_gas_sensor(mics_5524_h2);" in main_cpp assert "mics_5524_boot->register_gas_sensor(mics_5524_h2);" not in main_cpp @@ -64,106 +92,184 @@ def test_shared_options_reach_the_hub( """The analog front end is configured once per physical sensor.""" main_cpp = generate_main(component_config_path("mics_5524.yaml")) + assert ( + "mics_5524_boot->set_conversion(mics_5524::micsmath::CONVERSION_MODEL_DFROBOT);" + in main_cpp + ) assert "mics_5524_boot->set_vcc(5.0f);" in main_cpp + # `rl:` is not configured, so the C++ default stands. + assert "mics_5524_boot->set_rl(" not in main_cpp assert "mics_5524_boot->set_voltage_multiplier(1.5f);" in main_cpp assert "mics_5524_boot->set_samples(3);" in main_cpp assert "mics_5524_boot->set_warmup_time(180000);" in main_cpp - assert "mics_5524_boot->set_calibration(5000, 3, 20);" in main_cpp - assert "mics_5524_fixed->set_air_reference(3.6f);" in main_cpp + # The stored reference is keyed by the component ID and the analog front end, so a + # new vcc, scaling or rl never loads a value captured with the old one. + assert ( + f"mics_5524_boot->set_preference_key({fnv1_hash('mics_5524_boot:5:1.5')}UL);" + in main_cpp + ) + assert ( + f"mics_5524_pin->set_preference_key({fnv1_hash('mics_5524_pin:5:1.5')}UL);" + in main_cpp + ) + assert "mics_5524_boot->set_calibration(5000, 3, 20, true, true);" in main_cpp + assert "mics_5524_fixed->set_reference(3.6f);" in main_cpp # `air_reference:` and `calibration:` exclude each other, so the sensor with the - # hard-coded reference is never calibrated at boot. - assert "mics_5524_fixed->set_calibration(" not in main_cpp + # hard-coded reference only gets the defaults for a requested calibration. + assert "mics_5524_fixed->set_calibration(60000, 10, 20, false, false);" in main_cpp + + +def test_datasheet_conversion_uses_the_shipped_coefficients( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """One conversion model per sensor, and the coefficients of CO are the shipped ones.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + # a = 1.0, b = -1.2, the 1 ppm floor of the data sheet and the configured 2000 ppm ceiling. + co = _gas_arguments(main_cpp, "mics_5524_co_ds") + assert _gas_constants(co) == [1.0, -1.2, 1.0, 2000.0] + assert "mics_5524_pin->register_gas_sensor(mics_5524_co_ds);" in main_cpp + assert ( + "mics_5524_pin->set_conversion(mics_5524::micsmath::CONVERSION_MODEL_DATASHEET);" + in main_cpp + ) + assert "mics_5524_pin->set_reference(10.0f);" in main_cpp + + +def test_diagnostics_reach_the_hub( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The ratio, RS and voltage of every reading can be published to their own sensor.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert "mics_5524_pin->set_ratio_sensor(mics_5524_ratio);" in main_cpp + assert "mics_5524_pin->set_rs_sensor(mics_5524_rs);" in main_cpp + assert "mics_5524_pin->set_voltage_sensor(mics_5524_volts);" in main_cpp + + +def test_pin_creates_an_internal_adc( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A `pin:` configuration owns its ADC, and the divider is part of the reading.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert "new(mics_5524_pin_adc) adc::ADCSensor();" in main_cpp + # Without `adc_attenuation:` an ESP32 gets 12 dB, since the 0 dB default clips at about 1 V. + assert ( + "mics_5524_pin_adc->set_attenuation(adc::ADC_ATTEN_DB_12_COMPAT);" in main_cpp + ) + assert ( + "new(mics_5524_pin) mics_5524::MiCS5524Component(mics_5524_pin_adc);" + in main_cpp + ) + # 10k/20k under the 5 V of the breakout: 3.33 V on the pin, x1.5 in the reading. + assert "mics_5524_pin->set_voltage_multiplier(1.5f);" in main_cpp + + +def _hub(air_reference: float | None = 3.6, **overrides: object) -> dict: + """A minimal valid hub configuration with one hydrogen sub-sensor.""" + config: dict = { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "vcc": 5.0, + "hydrogen": {"name": "MiCS-5524 H2"}, + } + if air_reference is not None: + config["air_reference"] = air_reference + for key, value in overrides.items(): + if value is None: + config.pop(key, None) + else: + config[key] = value + return config def test_air_reference_and_calibration_exclude_each_other() -> None: """Two references for one sensor would silently pick one, so it is an error.""" with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): - CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, - "air_reference": 3.6, - "calibration": {"delay": "5s"}, - } - ) + CONFIG_SCHEMA(_hub(calibration={"delay": "5s"})) @pytest.mark.parametrize("air_reference", [5.0, 6.0]) def test_air_reference_at_or_above_vcc_is_rejected(air_reference: float) -> None: - """A reference at or above the supply would report every gas as 0 ppm forever.""" with pytest.raises(cv.Invalid, match="must be below vcc"): - CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "vcc": 5.0, - "hydrogen": {"name": "MiCS-5524 H2 (reference above vcc)"}, - "air_reference": air_reference, - } - ) + CONFIG_SCHEMA(_hub(air_reference=air_reference)) def test_air_reference_below_vcc_is_accepted() -> None: - config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "vcc": 5.0, - "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, - "air_reference": 3.6, - } - ) - - assert config["air_reference"] == 3.6 + assert CONFIG_SCHEMA(_hub())["air_reference"] == 3.6 def test_at_least_one_gas_is_required() -> None: """A sensor without a gas has nothing to publish.""" with pytest.raises(cv.Invalid, match="Must contain at least one of"): - CONFIG_SCHEMA({"id": "mics_5524", "voltage": "mics_5524_adc", "vcc": 5.0}) - - -def test_max_ppm_above_the_vendor_range_is_rejected() -> None: - """A ceiling the conversion can never reach is a configuration error.""" - with pytest.raises(cv.Invalid, match="value must be at most 1000.0"): CONFIG_SCHEMA( { "id": "mics_5524", "voltage": "mics_5524_adc", - "carbon_monoxide": { - "name": "MiCS-5524 CO (rejected)", - "max_ppm": 2000.0, - }, + "vcc": 5.0, "air_reference": 3.6, } ) +def test_the_sampler_is_given_or_owned() -> None: + """One source only: an existing sampler, or a pin the component reads itself.""" + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA( + {"id": "mics_5524", "vcc": 5.0, "hydrogen": {}, "air_reference": 3.6} + ) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_hub(pin=34)) + + +def test_max_ppm_above_the_vendor_range_is_rejected() -> None: + """A ceiling the conversion can never reach is a configuration error.""" + with pytest.raises(cv.Invalid, match="above the CO measuring range"): + CONFIG_SCHEMA( + _hub(carbon_monoxide={"name": "MiCS-5524 CO (rejected)", "max_ppm": 2000.0}) + ) + + def test_max_ppm_at_the_vendor_range_is_accepted() -> None: config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "carbon_monoxide": {"name": "MiCS-5524 CO (accepted)", "max_ppm": 1000.0}, - "air_reference": 3.6, - } + _hub(carbon_monoxide={"name": "MiCS-5524 CO (accepted)", "max_ppm": 1000.0}) ) assert config["carbon_monoxide"]["max_ppm"] == 1000.0 -def test_max_ppm_defaults_to_the_vendor_range_of_the_gas() -> None: - config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "carbon_monoxide": {"name": "MiCS-5524 CO (default)"}, - "air_reference": 3.6, - } - ) +def test_max_ppm_defaults_to_the_vendor_range_of_the_gas( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """An omitted ceiling still reports the vendor range: it is resolved per model.""" + from esphome.components.mics_5524.coefficients import resolve_gas - assert config["carbon_monoxide"]["max_ppm"] == 1000.0 + resolved = resolve_gas("carbon_monoxide", "dfrobot", max_ppm=None) + assert resolved.max_ppm == 1000.0 + + # End to end: CH4 of the boot sensor has no max_ppm: and keeps 25000 ppm. + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + ch4 = _gas_arguments(main_cpp, "mics_5524_ch4") + assert _gas_constants(ch4)[-1] == 25000.0 + + +def test_max_ppm_must_stay_above_min_ppm() -> None: + with pytest.raises(cv.Invalid, match="must be greater than min_ppm"): + CONFIG_SCHEMA( + _hub( + carbon_monoxide={ + "name": "MiCS-5524 CO (narrow)", + "min_ppm": 100.0, + "max_ppm": 50.0, + } + ) + ) def test_calibration_cannot_sample_faster_than_the_loop() -> None: @@ -173,6 +279,7 @@ def test_calibration_cannot_sample_faster_than_the_loop() -> None: { "id": "mics_5524", "voltage": "mics_5524_adc", + "vcc": 5.0, "hydrogen": {"name": "MiCS-5524 H2 (tick)"}, "calibration": {"sample_interval": "10ms"}, } @@ -180,26 +287,348 @@ def test_calibration_cannot_sample_faster_than_the_loop() -> None: def test_voltage_multiplier_defaults_to_one() -> None: + config = CONFIG_SCHEMA(_hub()) + + assert _voltage_multiplier(config) == 1.0 + + +def test_voltage_divider_derives_the_multiplier( + set_core_config: SetCoreConfigCallable, +) -> None: + """The divider the user wired is turned into the multiplier of every reading.""" + set_core_config(PlatformFramework.ESP32_IDF) config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "hydrogen": {"name": "MiCS-5524 H2 (default)"}, - "air_reference": 3.6, - } + _hub(voltage_divider={"r1": 10000.0, "r2": 20000.0}, adc_input_max=3.34) ) - assert config["voltage_multiplier"] == 1.0 + assert _voltage_multiplier(config) == 1.5 + + +def test_voltage_divider_rejects_an_explicit_multiplier( + set_core_config: SetCoreConfigCallable, +) -> None: + """The divider already says what the multiplier is, so the two would contradict.""" + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): + CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + voltage_multiplier=1.5, + adc_input_max=3.34, + ) + ) + + +def test_voltage_divider_above_the_adc_limit_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A 10k/20k divider on a 5 V sensor puts 3.33 V on a classic ESP32 ADC.""" + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="above the 3.3 V measuring range"): + CONFIG_SCHEMA(_hub(voltage_divider={"r1": 10000.0, "r2": 20000.0})) + + +def test_pin_source_must_be_scaled_into_the_adc_range( + set_core_config: SetCoreConfigCallable, +) -> None: + """A 5 V sensor straight on an ESP32 pin clips; a divider or multiplier that fits passes.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + with pytest.raises(cv.Invalid, match="5 V supply reaches the pin unscaled"): + CONFIG_SCHEMA(_hub(voltage=None, pin=34)) + # Each hub needs its own entity names + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_multiplier=1.5, + adc_input_max=3.34, + hydrogen={"name": "H2 behind a multiplier"}, + ) + ) + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + hydrogen={"name": "H2 behind a divider"}, + ) + ) + + +def test_pin_source_range_follows_the_attenuation( + set_core_config: SetCoreConfigCallable, +) -> None: + """At 6 dB a classic ESP32 measures up to 1.75 V, so 3.33 V on the pin is rejected.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + with pytest.raises(cv.Invalid, match="above the 1.75 V measuring range"): + CONFIG_SCHEMA( + _hub(voltage=None, pin=34, voltage_multiplier=1.5, adc_attenuation="6db") + ) + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_multiplier=3.0, + adc_attenuation="6db", + hydrogen={"name": "H2 at 6 dB"}, + ) + ) + # auto picks the widest range, 2.45 V on a classic ESP32 + with pytest.raises(cv.Invalid, match="above the 2.45 V measuring range"): + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_multiplier=1.5, + adc_attenuation="auto", + hydrogen={"name": "H2 auto"}, + ) + ) + + +def test_min_ppm_zero_disables_the_floor() -> None: + config = CONFIG_SCHEMA(_hub(hydrogen={"name": "MiCS-5524 H2", "min_ppm": 0.0})) + assert config["hydrogen"]["min_ppm"] == 0.0 + + +def test_voltage_divider_limits_follow_the_esp8266_adc( + set_core_config: SetCoreConfigCallable, +) -> None: + """A bare ESP8266 A0 reads 0 - 1 V, so the ESP32 limits must not apply there.""" + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="above the 1 V"): + CONFIG_SCHEMA(_hub(voltage_divider={"r1": 10000.0, "r2": 20000.0})) + config = CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + adc_pin_max=3.6, + hydrogen={"name": "MiCS-5524 H2 (declared limits)"}, + ) + ) + assert config["voltage_divider"]["r1"] == 10000.0 + + +def test_voltage_divider_below_the_declared_limit_is_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + """A sampler with a wider range declares it, and the same divider then passes.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + adc_pin_max=3.4, + ) + ) + + assert config["voltage_divider"]["r1"] == 10000.0 + + +def test_voltage_divider_on_an_unknown_platform_needs_declared_limits( + set_core_config: SetCoreConfigCallable, +) -> None: + """No built-in ADC limits exist for LibreTiny, so the guard cannot run without them.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + with pytest.raises(cv.Invalid, match="no built-in ADC limits for bk72xx"): + CONFIG_SCHEMA(_hub(voltage_divider={"r1": 10000.0, "r2": 20000.0})) + config = CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + adc_pin_max=3.6, + hydrogen={"name": "MiCS-5524 H2 (libretiny)"}, + ) + ) + assert config["voltage_divider"]["r1"] == 10000.0 + + +@pytest.mark.parametrize("key", ["adc_input_max", "adc_pin_max"]) +def test_adc_limits_without_a_divider_are_rejected(key: str) -> None: + """The limits only check a divider, so without one they would be silently ignored.""" + with pytest.raises( + cv.Invalid, + match=f"'{key}:' only checks a 'pin:' source or a 'voltage_divider:'", + ): + CONFIG_SCHEMA(_hub(**{key: 3.34})) + + +def test_adc_attenuation_needs_an_owned_pin() -> None: + """The attenuation configures the hidden ADC of pin:, not an external sampler.""" + with pytest.raises( + cv.Invalid, match="'adc_attenuation:' only applies to the ADC of" + ): + CONFIG_SCHEMA(_hub(adc_attenuation="12db")) + + +def test_datasheet_conversion_is_not_capped_by_the_vendor_range() -> None: + """The datasheet fit has no published range, so a wider one may be configured.""" + config = CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen=None, + carbon_monoxide={"name": "MiCS-5524 CO (datasheet)", "max_ppm": 2000.0}, + ) + ) + + assert config["carbon_monoxide"]["max_ppm"] == 2000.0 + + +def test_datasheet_needs_coefficients_for_gases_without_a_shipped_fit() -> None: + with pytest.raises( + cv.Invalid, match="no built-in 'conversion: datasheet' coefficients" + ): + CONFIG_SCHEMA(_hub(air_reference=None, conversion="datasheet", r0=10.0)) + + +def test_datasheet_accepts_explicit_coefficients() -> None: + config = CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen={ + "name": "MiCS-5524 H2 (fit)", + "coefficient_a": 12.0, + "coefficient_b": -0.8, + }, + ) + ) + + assert config["hydrogen"]["coefficient_a"] == 12.0 + + +def test_coefficients_are_rejected_by_the_vendor_model() -> None: + # A pair under the vendor model is rejected by the model check ... + with pytest.raises(cv.Invalid, match="remove them or switch the conversion"): + CONFIG_SCHEMA( + _hub( + hydrogen={ + "name": "MiCS-5524 H2 (stray)", + "coefficient_a": 12.0, + "coefficient_b": -0.8, + } + ) + ) + # ... while half a pair is already rejected by the schema itself. + with pytest.raises(cv.Invalid, match="coefficients"): + CONFIG_SCHEMA( + _hub(hydrogen={"name": "MiCS-5524 H2 (half)", "coefficient_a": 12.0}) + ) + + +def test_datasheet_rejects_half_a_fit() -> None: + """One coefficient with the shipped value of the other mixes two fits.""" + with pytest.raises(cv.Invalid, match="coefficients"): + CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen=None, + carbon_monoxide={ + "name": "MiCS-5524 CO (half)", + "coefficient_a": 12.0, + }, + ) + ) + + +def test_datasheet_rejects_a_flat_or_rising_fit() -> None: + """A fit whose reading does not rise as the resistance falls reads saturation as no gas.""" + for b in (0.0, 0.8): + with pytest.raises(cv.Invalid, match="'coefficient_b:' must be negative"): + CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen={ + "name": f"MiCS-5524 H2 (fit b={b})", + "coefficient_a": 12.0, + "coefficient_b": b, + }, + ) + ) + + +def test_datasheet_min_ppm_defaults_to_the_shipped_floor_only() -> None: + """The shipped CO fit reads 0 below the 1 ppm of the data sheet; a user fit has no floor.""" + from esphome.components.mics_5524.coefficients import resolve_gas + + assert resolve_gas("carbon_monoxide", "datasheet").min_ppm == 1.0 + assert resolve_gas("carbon_monoxide", "datasheet", a=2.0, b=-1.0).min_ppm == 0.0 + assert resolve_gas("carbon_monoxide", "datasheet", min_ppm=5.0).min_ppm == 5.0 + + +def test_datasheet_rejects_a_non_positive_coefficient() -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen={ + "name": "MiCS-5524 H2 (bad fit)", + "coefficient_a": 0.0, + "coefficient_b": -0.8, + }, + ) + ) + + +def test_rs_sensor_is_rejected_by_the_vendor_model() -> None: + """RS only exists in the datasheet model, so the vendor one cannot publish it.""" + with pytest.raises(cv.Invalid, match="'rs_sensor:' belongs to"): + CONFIG_SCHEMA(_hub(rs_sensor={"name": "MiCS-5524 RS"})) + + +def test_reference_keys_belong_to_one_model() -> None: + with pytest.raises(cv.Invalid, match="'r0:' belongs to 'conversion: datasheet'"): + CONFIG_SCHEMA(_hub(air_reference=None, r0=10.0)) + + with pytest.raises( + cv.Invalid, match="'air_reference:' belongs to 'conversion: dfrobot'" + ): + CONFIG_SCHEMA( + _hub( + air_reference=3.6, + conversion="datasheet", + hydrogen={"name": "MiCS-5524 H2 (wrong model)"}, + ) + ) def test_vendor_table_is_the_datasheet_one() -> None: """The whole table is pinned, not only the gases a config file happens to use.""" expected = { - CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), - CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), - CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), - CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), - CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), + CONF_CARBON_MONOXIDE: GasDefinition( + vendor=VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + datasheet=DatasheetCurve(1.0, -1.2, 1.0, 1000.0), + ), + CONF_HYDROGEN: GasDefinition( + VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), None + ), + CONF_ETHANOL: GasDefinition( + VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), None + ), + CONF_AMMONIA: GasDefinition(VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), None), + CONF_METHANE: GasDefinition( + VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), None + ), } assert expected == GASES + + +def test_only_co_ships_with_datasheet_coefficients() -> None: + assert datasheet_gases() == ["CO"] diff --git a/tests/components/mics_5524/common.h b/tests/components/mics_5524/common.h index 7f46ba9bea3..d80ba354b4d 100644 --- a/tests/components/mics_5524/common.h +++ b/tests/components/mics_5524/common.h @@ -10,8 +10,12 @@ namespace esphome::mics_5524::testing { using micsmath::air_value_from_voltage; using micsmath::calibration_is_stable; +using micsmath::clamp_ppm; +using micsmath::ppm_from_power_law; using micsmath::ppm_from_vendor_curve; using micsmath::ratio_from_air_value; +using micsmath::ratio_from_rs; +using micsmath::rs_from_voltage; // Independent copy of the DFRobot table; the Python tests pin coefficients.py itself. constexpr micsmath::VendorCurve CO{0.425f, 0.000405f, 1.0f, 1000.0f}; diff --git a/tests/components/mics_5524/common.yaml b/tests/components/mics_5524/common.yaml index eb5f69b14ee..86a9c764203 100644 --- a/tests/components/mics_5524/common.yaml +++ b/tests/components/mics_5524/common.yaml @@ -6,7 +6,7 @@ sensor: - platform: mics_5524 id: mics_5524_module - # 10k/20k divider: the 5 V breakout's 3.33 V stays under the ADC limit. + # A 10k/20k divider brings the 5 V output down to 3.33 V on the pin; x1.5 scales it back. voltage: mics_5524_adc voltage_multiplier: 1.5 sample_count: 3 @@ -15,6 +15,11 @@ sensor: delay: 5s sample_count: 3 sample_interval: 20ms + # rs_sensor: only exists in the datasheet model. + voltage_sensor: + name: MiCS-5524 volts + ratio_sensor: + name: MiCS-5524 ratio carbon_monoxide: name: MiCS-5524 CO max_ppm: 500.0 diff --git a/tests/components/mics_5524/sensor/mics_math_test.cpp b/tests/components/mics_5524/sensor/mics_math_test.cpp index 946b4fa2e2e..2f58170774d 100644 --- a/tests/components/mics_5524/sensor/mics_math_test.cpp +++ b/tests/components/mics_5524/sensor/mics_math_test.cpp @@ -51,6 +51,9 @@ TEST(MicsMath, AirValueIsTheVoltageBelowVcc) { // A pin at or below 0 V is disconnected, not a reading of VCC. EXPECT_TRUE(std::isnan(air_value_from_voltage(0.0f, 5.0f))); EXPECT_TRUE(std::isnan(air_value_from_voltage(-0.1f, 5.0f))); + // So is one at or below 5 % of VCC: clean air sits volts above that. + EXPECT_TRUE(std::isnan(air_value_from_voltage(0.25f, 5.0f))); + EXPECT_FLOAT_EQ(4.74f, air_value_from_voltage(0.26f, 5.0f)); // The two ends of the vendor range, and the unusable case, one by one. EXPECT_FLOAT_EQ(1.0f, ratio_from_air_value(air_value_from_voltage(4.0f, 5.0f), 1.0f)); EXPECT_FLOAT_EQ(0.0f, ratio_from_air_value(air_value_from_voltage(5.0f, 5.0f), 1.0f)); @@ -90,4 +93,88 @@ TEST(MicsMath, SaturatedSensorEndToEnd) { EXPECT_FLOAT_EQ(CO.max_ppm, ppm_from_vendor_curve(ratio, CO)); } +TEST(MicsMath, DatasheetResistanceComesFromTheDivider) { + // RS = (VCC * RL) / V_AO - RL: half of the supply across a 10k load resistor. + EXPECT_FLOAT_EQ(10.0f, rs_from_voltage(2.5f, 5.0f, 10.0f)); + EXPECT_FLOAT_EQ(90.0f, rs_from_voltage(0.5f, 5.0f, 10.0f)); + // A pin at the supply is RS = 0, the saturated end; above it would be a negative + // resistance, which only a wrong `vcc:` produces. + EXPECT_FLOAT_EQ(0.0f, rs_from_voltage(5.0f, 5.0f, 10.0f)); + EXPECT_TRUE(std::isnan(rs_from_voltage(5.5f, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(NAN, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(2.5f, 5.0f, 0.0f))); +} + +TEST(MicsMath, DatasheetDeadInputReadsUnknown) { + // The floor is the output of RS = 200 x RL, 5 V / 201 = 24.9 mV: a pin at or below it is + // disconnected, not a module with a huge clean-air resistance. + EXPECT_NEAR(0.02488f, micsmath::datasheet_dead_input_voltage(5.0f), 0.00001f); + EXPECT_TRUE(std::isnan(rs_from_voltage(0.0f, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(0.02f, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(0.0248f, 5.0f, 10.0f))); + // Just above the floor a 1.5 MOhm module, the top of the datasheet range, still reads. + EXPECT_NEAR(1656.7f, rs_from_voltage(0.03f, 5.0f, 10.0f), 0.1f); + // The floor scales with RL, so a 1k load resistor reads up to 200 kOhm. + EXPECT_NEAR(199.0f, rs_from_voltage(0.025f, 5.0f, 1.0f), 0.1f); +} + +TEST(MicsMath, SaturatedDatasheetSensorReadsTheTopOfTheRange) { + // V_AO at VCC -> RS = 0 -> ratio 0 -> infinity on the falling curve -> max_ppm, like the + // vendor model, rather than unknown with a wiring warning. + const float ratio = ratio_from_rs(rs_from_voltage(5.0f, 5.0f, 10.0f), 10.0f); + EXPECT_FLOAT_EQ(0.0f, ratio); + EXPECT_TRUE(std::isinf(ppm_from_power_law(1.0f, -1.2f, ratio))); + EXPECT_FLOAT_EQ(1000.0f, clamp_ppm(ppm_from_power_law(1.0f, -1.2f, ratio), 1.0f, 1000.0f)); + // Without a ceiling there is nothing to report. + EXPECT_TRUE(std::isnan(clamp_ppm(INFINITY, 0.0f, INFINITY))); +} + +TEST(MicsMath, DatasheetRatioNeedsAReference) { + EXPECT_FLOAT_EQ(1.0f, ratio_from_rs(10.0f, 10.0f)); + EXPECT_FLOAT_EQ(0.5f, ratio_from_rs(5.0f, 10.0f)); + // Without a calibrated R0 there is no ratio to report. + EXPECT_TRUE(std::isnan(ratio_from_rs(10.0f, 0.0f))); + EXPECT_TRUE(std::isnan(ratio_from_rs(-1.0f, 10.0f))); + EXPECT_TRUE(std::isnan(ratio_from_rs(NAN, 10.0f))); +} + +TEST(MicsMath, DatasheetCurveFollowsTheShippedCoefficients) { + // The CO fit shipped in `coefficients.py`, a = 1.0 and b = -1.2, follows the data sheet + // graph with R0 at clean air: 1 ppm at RS/R0 = 1, 60 ppm at about 0.033 (a sensitivity + // factor of 30, inside the 1.2 - 50 the data sheet gives) and 1000 ppm at about 0.0032. + EXPECT_FLOAT_EQ(1.0f, ppm_from_power_law(1.0f, -1.2f, 1.0f)); + EXPECT_NEAR(60.0f, ppm_from_power_law(1.0f, -1.2f, 0.0329f), 0.5f); + EXPECT_NEAR(1000.0f, ppm_from_power_law(1.0f, -1.2f, 0.00316f), 5.0f); + // A ratio outside what the fit can express is unusable, not a huge number. + EXPECT_TRUE(std::isnan(ppm_from_power_law(1.0f, -1.2f, -0.1f))); + EXPECT_TRUE(std::isnan(ppm_from_power_law(1.0f, -1.2f, NAN))); + // A non-positive fit cannot produce a reading either. + EXPECT_TRUE(std::isnan(ppm_from_power_law(0.0f, -1.2f, 0.5f))); + EXPECT_TRUE(std::isnan(ppm_from_power_law(-1.0f, -1.2f, 0.5f))); +} + +TEST(MicsMath, ClampingKeepsZeroAndCapsTheRange) { + // 0 ppm means "no gas detected" in both models and is never changed. A reading + // below `min_ppm` counts as not detected and reports 0, the vendor behaviour. + EXPECT_FLOAT_EQ(0.0f, clamp_ppm(0.0f, 5.0f, 100.0f)); + EXPECT_FLOAT_EQ(0.0f, clamp_ppm(2.0f, 5.0f, 100.0f)); + EXPECT_FLOAT_EQ(100.0f, clamp_ppm(200.0f, 5.0f, 100.0f)); + EXPECT_FLOAT_EQ(6.3f, clamp_ppm(6.3f, 5.0f, 100.0f)); + EXPECT_TRUE(std::isnan(clamp_ppm(NAN, 5.0f, 100.0f))); +} + +TEST(MicsMath, DatasheetModelEndToEnd) { + // 2.5 V across a 10k load resistor is RS = 10 kOhm, which is also R0 when the sensor + // is in clean air: the reading is then the 1 ppm floor of the data sheet. + const float rs = rs_from_voltage(2.5f, 5.0f, 10.0f); + const float ratio = ratio_from_rs(rs, 10.0f); + + EXPECT_FLOAT_EQ(1.0f, ratio); + EXPECT_FLOAT_EQ(1.0f, clamp_ppm(ppm_from_power_law(1.0f, -1.2f, ratio), 1.0f, 1000.0f)); + // Twice the resistance is cleaner than the reference air, below the floor, so no gas. + EXPECT_FLOAT_EQ(0.0f, clamp_ppm(ppm_from_power_law(1.0f, -1.2f, ratio_from_rs(20.0f, 10.0f)), 1.0f, 1000.0f)); + // Half the resistance is ratio 0.5: 2^1.2 = 2.3 ppm. + EXPECT_NEAR(2.30f, ppm_from_power_law(1.0f, -1.2f, ratio_from_rs(5.0f, 10.0f)), 0.01f); +} + } // namespace esphome::mics_5524::testing diff --git a/tests/components/mics_5524/validate-datasheet.esp32-idf.yaml b/tests/components/mics_5524/validate-datasheet.esp32-idf.yaml new file mode 100644 index 00000000000..4aad3b6ee50 --- /dev/null +++ b/tests/components/mics_5524/validate-datasheet.esp32-idf.yaml @@ -0,0 +1,31 @@ +# Config-only test: the datasheet model with the wiring the component owns (pin:, voltage_divider:). +sensor: + - platform: mics_5524 + id: mics_5524_dat + pin: GPIO34 + voltage_divider: + r1: 10000 + r2: 20000 + adc_input_max: 3.34 + adc_attenuation: 12db + conversion: datasheet + r0: 10.0 + rl: 10.0 + # The EN input of the breakout is active low, so the pin is inverted. + enable_pin: + number: GPIO25 + inverted: true + ratio_sensor: + name: MiCS-5524 ratio + rs_sensor: + name: MiCS-5524 RS + voltage_sensor: + name: MiCS-5524 volts + carbon_monoxide: + id: mics_5524_dat_co + name: MiCS-5524 CO (datasheet) + max_ppm: 2000.0 + hydrogen: + name: MiCS-5524 H2 (fit) + coefficient_a: 6.3 + coefficient_b: -1.1 From f8d31c3e541e209fb739354449afd4f7bafd1f67 Mon Sep 17 00:00:00 2001 From: Tom Zaspel <40226087+tzabbi@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:47:50 +0200 Subject: [PATCH 037/120] [dfrobot_sen0465] Add DFRobot SEN0465-SEN0476 electrochemical gas sensors (#20880) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + .../dfrobot_sen0465_base/__init__.py | 244 ++++++++++++++++ .../dfrobot_sen0465_base.cpp | 276 ++++++++++++++++++ .../dfrobot_sen0465_base.h | 110 +++++++ .../dfrobot_sen0465_i2c/__init__.py | 2 + .../dfrobot_sen0465_i2c.cpp | 37 +++ .../dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h | 19 ++ .../components/dfrobot_sen0465_i2c/sensor.py | 26 ++ .../dfrobot_sen0465_uart/__init__.py | 2 + .../dfrobot_sen0465_uart.cpp | 62 ++++ .../dfrobot_sen0465_uart.h | 24 ++ .../components/dfrobot_sen0465_uart/sensor.py | 38 +++ esphome/core/defines.h | 2 + .../dfrobot_sen0465_i2c/common.yaml | 24 ++ .../dfrobot_sen0465_i2c/test.esp32-idf.yaml | 3 + .../dfrobot_sen0465_i2c/test.esp8266-ard.yaml | 3 + .../dfrobot_sen0465_uart/common.yaml | 14 + .../dfrobot_sen0465_uart/test.esp32-idf.yaml | 3 + .../test.esp8266-ard.yaml | 3 + .../fixtures/uart_mock_dfrobot_sen0465.yaml | 94 ++++++ .../test_uart_mock_dfrobot_sen0465.py | 106 +++++++ 21 files changed, 1095 insertions(+) create mode 100644 esphome/components/dfrobot_sen0465_base/__init__.py create mode 100644 esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp create mode 100644 esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h create mode 100644 esphome/components/dfrobot_sen0465_i2c/__init__.py create mode 100644 esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp create mode 100644 esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h create mode 100644 esphome/components/dfrobot_sen0465_i2c/sensor.py create mode 100644 esphome/components/dfrobot_sen0465_uart/__init__.py create mode 100644 esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp create mode 100644 esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h create mode 100644 esphome/components/dfrobot_sen0465_uart/sensor.py create mode 100644 tests/components/dfrobot_sen0465_i2c/common.yaml create mode 100644 tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml create mode 100644 tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml create mode 100644 tests/components/dfrobot_sen0465_uart/common.yaml create mode 100644 tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml create mode 100644 tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml create mode 100644 tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml create mode 100644 tests/integration/test_uart_mock_dfrobot_sen0465.py diff --git a/CODEOWNERS b/CODEOWNERS index 55f58dbe696..c9ff7c6bb32 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -157,6 +157,9 @@ esphome/components/delonghi/* @grob6000 esphome/components/dew_point/* @CFlix esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber +esphome/components/dfrobot_sen0465_base/* @tzabbi +esphome/components/dfrobot_sen0465_i2c/* @tzabbi +esphome/components/dfrobot_sen0465_uart/* @tzabbi esphome/components/dfrobot_sen0609/* @remcom esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 diff --git a/esphome/components/dfrobot_sen0465_base/__init__.py b/esphome/components/dfrobot_sen0465_base/__init__.py new file mode 100644 index 00000000000..cb82e894e79 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_base/__init__.py @@ -0,0 +1,244 @@ +from collections.abc import Callable +from dataclasses import dataclass + +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_MODEL, + CONF_TEMPERATURE, + CONF_TEMPERATURE_COMPENSATION, + CONF_THRESHOLD, + CONF_TYPE, + DEVICE_CLASS_CARBON_MONOXIDE, + DEVICE_CLASS_TEMPERATURE, + ICON_GAS_CYLINDER, + ICON_MOLECULE_CO, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, +) +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType + +CODEOWNERS = ["@tzabbi"] +DOMAIN = "dfrobot_sen0465_base" + +CONF_ALARM = "alarm" +CONF_CONCENTRATION = "concentration" + +dfrobot_sen0465_base_ns = cg.esphome_ns.namespace("dfrobot_sen0465_base") +DFRobotSEN0465Component = dfrobot_sen0465_base_ns.class_( + "DFRobotSEN0465Component", cg.PollingComponent +) +AlarmType = dfrobot_sen0465_base_ns.enum("AlarmType", is_class=True) +ALARM_TYPES = { + "LOW": AlarmType.ALARM_TYPE_LOW, + "HIGH": AlarmType.ALARM_TYPE_HIGH, +} + +# One piece of the temperature compensation from the DFRobot_MultiGasSensor library: +# for low < T <= high, compensated = raw / (a * T + b) - (c * T + d) +CompensationRange = tuple[int, int, float, float, float, float] + + +@dataclass(frozen=True) +class Model: + """One gas probe of the series.""" + + gas: str + gas_type: int # Gas type code the sensor reports + unit: str + # Also the resolution of the alarm threshold the sensor expects + accuracy_decimals: int + compensation: tuple[CompensationRange, ...] = () + device_class: str = cv.UNDEFINED + icon: str = ICON_GAS_CYLINDER + + +MODELS: dict[str, Model] = { + "SEN0465": Model("O2", 0x05, UNIT_PERCENT, 1), + "SEN0466": Model( + "CO", + 0x04, + UNIT_PARTS_PER_MILLION, + 0, + ( + (-20, 20, 0.005, 0.9, 0, 0), + (20, 40, 0.005, 0.9, 0.3, -6), + ), + device_class=DEVICE_CLASS_CARBON_MONOXIDE, + icon=ICON_MOLECULE_CO, + ), + "SEN0467": Model( + "H2S", + 0x03, + UNIT_PARTS_PER_MILLION, + 0, + ( + (-20, 20, 0.005, 0.92, 0, 0), + (20, 60, 0.015, -0.3, 0, 0), + ), + ), + "SEN0468": Model( + "Cl2", + 0x31, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0.015, 1.1, -0.0025, 0), + (0, 20, 0, 1.1, 0.005, 0), + (20, 40, 0, 1.1, -0.005, 0.3), + ), + ), + "SEN0469": Model( + "NH3", + 0x02, + UNIT_PARTS_PER_MILLION, + 0, + ( + (-20, 0, 0.006, 0.95, -0.006, 0.25), + (0, 20, 0.006, 0.95, -0.012, 0.25), + (20, 40, 0.005, 1.08, -0.1, 2), + ), + ), + "SEN0470": Model( + "SO2", + 0x2B, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-40, 40, 0.006, 0.95, 0, 0), + (40, 60, 0.006, 0.95, 0.05, -2), + ), + ), + "SEN0471": Model( + "NO2", + 0x2C, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0.005, 0.9, -0.0025, 0.005), + (0, 20, 0.005, 0.9, 0.005, 0.005), + (20, 40, 0.005, 0.9, 0.0025, 0.1), + ), + ), + "SEN0472": Model( + "O3", + 0x2A, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0.015, 1.1, 0, 0.05), + (0, 20, 0, 1.1, 0.01, 0), + (20, 40, 0, 1.1, -0.005, 0.3), + ), + ), + # The library's H2 compensation divides by (0.74 * T + 0.007) below 20 °C, which gives + # nonsense results, so it is not offered. + "SEN0473": Model("H2", 0x06, UNIT_PARTS_PER_MILLION, 0), + "SEN0474": Model( + "HCl", + 0x2E, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0, 1, -0.0075, -0.1), + (0, 20, 0, 1, 0, -0.1), + (20, 50, 0, 1, -0.01, 0.1), + ), + ), + "SEN0475": Model( + "HF", + 0x33, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0, 1, -0.0025, 0), + (0, 20, 0, 1, 0, -0.1), + (20, 40, 0, 1, 0.0375, -0.85), + ), + ), + "SEN0476": Model( + "PH3", + 0x45, + UNIT_PARTS_PER_MILLION, + 1, + ((-20, 40, 0.005, 0.9, 0, 0),), + ), +} + +# Sizes the C++ range list; the largest table entry sets it +COMPENSATION_RANGES = max(len(model.compensation) for model in MODELS.values()) + + +def _model_schema( + model: Model, component_class: MockObjClass, bus_schema: cv.Schema +) -> cv.Schema: + schema = { + cv.GenerateID(): cv.declare_id(component_class), + cv.Optional(CONF_CONCENTRATION): sensor.sensor_schema( + unit_of_measurement=model.unit, + icon=model.icon, + accuracy_decimals=model.accuracy_decimals, + device_class=model.device_class, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + if model.compensation: + schema[cv.Optional(CONF_TEMPERATURE_COMPENSATION, default=False)] = cv.boolean + # The sensor sends the threshold as a 16-bit count of its last decimal place + max_threshold = 0xFFFF / 10**model.accuracy_decimals + schema[cv.Optional(CONF_ALARM)] = cv.Schema( + { + cv.Required(CONF_THRESHOLD): cv.float_range(min=0, max=max_threshold), + cv.Optional(CONF_TYPE, default="HIGH"): cv.enum(ALARM_TYPES, upper=True), + } + ) + return ( + cv.Schema(schema).extend(cv.polling_component_schema("60s")).extend(bus_schema) + ) + + +def dfrobot_sen0465_schema( + component_class: MockObjClass, bus_schema: cv.Schema +) -> Callable[[ConfigType], ConfigType]: + """Return the config schema for one bus variant, with one schema per model.""" + return cv.typed_schema( + { + name: _model_schema(model, component_class, bus_schema) + for name, model in MODELS.items() + }, + key=CONF_MODEL, + upper=True, + ) + + +async def to_code_base(config: ConfigType) -> MockObj: + model = MODELS[config[CONF_MODEL]] + var = cg.new_Pvariable(config[CONF_ID], model.gas_type, model.gas) + await cg.register_component(var, config) + + if config.get(CONF_TEMPERATURE_COMPENSATION): + cg.add_define("USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION") + cg.add_define("DFROBOT_SEN0465_COMPENSATION_RANGES", COMPENSATION_RANGES) + for low, high, a, b, c, d in model.compensation: + cg.add(var.add_compensation_range(low, high, a, b, c, d)) + + if (alarm := config.get(CONF_ALARM)) is not None: + decimals = model.accuracy_decimals + threshold = round(alarm[CONF_THRESHOLD] * 10**decimals) + cg.add(var.set_alarm(alarm[CONF_TYPE], threshold, decimals)) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_CONCENTRATION, var.set_concentration_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + return var diff --git a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp new file mode 100644 index 00000000000..78267d5ff9e --- /dev/null +++ b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp @@ -0,0 +1,276 @@ +#include "dfrobot_sen0465_base.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::dfrobot_sen0465_base { + +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0465"); + +// Protocol from https://github.com/DFRobot/DFRobot_MultiGasSensor +// Request: FF 01 command argument 00 00 00 00 checksum +// Response: FF command conc_high conc_low gas_type decimals temp_high temp_low checksum +static constexpr uint8_t FRAME_ADDRESS = 0x01; + +static constexpr uint8_t NO_COMMAND = 0x00; +static constexpr uint8_t CMD_CHANGE_ACQUIRE_MODE = 0x78; +static constexpr uint8_t CMD_GET_ALL_DATA = 0x88; +static constexpr uint8_t CMD_SET_THRESHOLD_ALARM = 0x89; +static constexpr uint8_t ACQUIRE_MODE_PASSIVE = 0x04; +static constexpr uint8_t ALARM_SWITCH_OFF = 0x00; +static constexpr uint8_t ALARM_SWITCH_ON = 0x01; +static constexpr uint8_t ALARM_LOW_THRESHOLD = 0x00; +static constexpr uint8_t ALARM_HIGH_THRESHOLD = 0x01; +// Byte 2 of the answer to a setting command is 1 when the sensor accepted it +static constexpr uint8_t RESULT_OK = 0x01; + +// The DFRobot examples wait this long after changing the acquire mode +static constexpr uint32_t MODE_CHANGE_DELAY_MS = 1000; +static constexpr uint32_t RETRY_DELAY_MS = 1000; +static constexpr uint8_t MAX_ATTEMPTS = 3; + +// Like FucCheckSum() in the DFRobot library, the checksum covers bytes 1 to 6 only, not byte 7 +static uint8_t frame_checksum(const uint8_t *frame) { + // Two's complement of the byte sum between the head and the checksum + return static_cast(~sum16_bytes(frame + 1, FRAME_SIZE - 3) + 1); +} + +static const LogString *step_name(SetupStep step) { + return step == SetupStep::SETUP_STEP_ACQUIRE_MODE ? LOG_STR("Passive mode") : LOG_STR("Alarm setting"); +} + +void DFRobotSEN0465Component::setup() { + // Answer only when asked, instead of sending a reading every second + this->step_ = SetupStep::SETUP_STEP_ACQUIRE_MODE; + if (!this->send_step_request_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +void DFRobotSEN0465Component::update() { + // The first reading is requested when the setup steps are done + if (this->step_ != SetupStep::SETUP_STEP_DONE) + return; + // A new request would replace the wait for the answer to the previous one + if (this->pending_command_ != NO_COMMAND) { + ESP_LOGV(TAG, "Previous request still waiting for an answer, skipping update"); + return; + } + if (!this->send_command_(CMD_GET_ALL_DATA)) { + ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(); + } +} + +void DFRobotSEN0465Component::dump_config() { + ESP_LOGCONFIG(TAG, + "DFRobot SEN0465 series gas sensor:\n" + " Gas: %s (type 0x%02X)", + this->gas_name_, this->gas_type_); +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + ESP_LOGCONFIG(TAG, " Temperature compensation: %s", YESNO(!this->compensation_.empty())); +#endif + if (this->alarm_type_ == AlarmType::ALARM_TYPE_OFF) { + ESP_LOGCONFIG(TAG, " Alarm: off"); + } else { + float scale = 1.0f; + for (uint8_t i = 0; i < this->alarm_decimals_; i++) + scale *= 10.0f; + const float threshold = this->alarm_threshold_ / scale; + ESP_LOGCONFIG(TAG, " Alarm: %s %.*f", + this->alarm_type_ == AlarmType::ALARM_TYPE_HIGH ? LOG_STR_LITERAL("above") : LOG_STR_LITERAL("below"), + this->alarm_decimals_, threshold); + } + if (this->setup_incomplete_) { + ESP_LOGCONFIG(TAG, " Setup incomplete: yes"); + } + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Concentration", this->concentration_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); +} + +bool DFRobotSEN0465Component::send_command_(uint8_t command, uint8_t arg0, uint8_t arg1, uint8_t arg2, uint8_t arg3) { + uint8_t frame[FRAME_SIZE] = {FRAME_HEAD, FRAME_ADDRESS, command, arg0, arg1, arg2, arg3}; + frame[FRAME_SIZE - 1] = frame_checksum(frame); + if (!this->write_frame(frame)) + return false; + this->pending_command_ = command; + this->read_response(); + return true; +} + +bool DFRobotSEN0465Component::send_step_request_() { + if (this->step_ == SetupStep::SETUP_STEP_ACQUIRE_MODE) + return this->send_command_(CMD_CHANGE_ACQUIRE_MODE, ACQUIRE_MODE_PASSIVE); + if (this->alarm_type_ == AlarmType::ALARM_TYPE_OFF) { + // Switch the alarm off explicitly, the sensor may still have one from an earlier configuration + return this->send_command_(CMD_SET_THRESHOLD_ALARM, ALARM_SWITCH_OFF); + } + return this->send_command_( + CMD_SET_THRESHOLD_ALARM, ALARM_SWITCH_ON, static_cast(this->alarm_threshold_ >> 8), + static_cast(this->alarm_threshold_), + this->alarm_type_ == AlarmType::ALARM_TYPE_HIGH ? ALARM_HIGH_THRESHOLD : ALARM_LOW_THRESHOLD); +} + +void DFRobotSEN0465Component::retry_step_request_() { + if (!this->send_step_request_()) + this->step_failed_(LOG_STR("request not sent")); +} + +void DFRobotSEN0465Component::step_failed_(const LogString *reason) { + this->attempts_++; + ESP_LOGW(TAG, "%s failed: %s (attempt %u of %u)", LOG_STR_ARG(step_name(this->step_)), LOG_STR_ARG(reason), + this->attempts_, MAX_ATTEMPTS); + if (this->attempts_ < MAX_ATTEMPTS) { + this->set_timeout(STEP_TIMEOUT_ID, RETRY_DELAY_MS, [this]() { this->retry_step_request_(); }); + return; + } + // Carry on: readings may still work, but the warning stays because the sensor is not set up as configured + this->setup_incomplete_ = true; + this->status_set_warning(); + this->next_step_(); +} + +void DFRobotSEN0465Component::next_step_() { + this->attempts_ = 0; + if (this->step_ == SetupStep::SETUP_STEP_ACQUIRE_MODE) { + this->step_ = SetupStep::SETUP_STEP_ALARM; + this->set_timeout(STEP_TIMEOUT_ID, MODE_CHANGE_DELAY_MS, [this]() { this->retry_step_request_(); }); + return; + } + this->step_ = SetupStep::SETUP_STEP_DONE; + this->update(); +} + +void DFRobotSEN0465Component::handle_no_response_() { + this->pending_command_ = NO_COMMAND; + if (this->step_ != SetupStep::SETUP_STEP_DONE) { + this->step_failed_(LOG_STR("no response")); + return; + } + ESP_LOGW(TAG, "No response from the sensor"); + this->fail_reading_(); +} + +void DFRobotSEN0465Component::fail_reading_() { + // A frozen gas reading would hide a rising concentration, so the entities go unknown + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); + this->status_set_warning(); +} + +void DFRobotSEN0465Component::handle_response_(const uint8_t *frame) { + // Reject a late answer to an earlier request or a reading sent before passive mode was set + const bool expected = frame[0] == FRAME_HEAD && frame[1] == this->pending_command_; + this->pending_command_ = NO_COMMAND; + + if (this->step_ == SetupStep::SETUP_STEP_DONE) { + if (!expected) { + char hex[format_hex_pretty_size(FRAME_SIZE)]; + ESP_LOGW(TAG, "Unexpected response: %s", format_hex_pretty_to(hex, frame, FRAME_SIZE)); + this->fail_reading_(); + return; + } + this->handle_data_(frame); + return; + } + + ESP_LOGD_HEX(TAG, frame, FRAME_SIZE, FRAME_SIZE, "%s response: %s", LOG_STR_ARG(step_name(this->step_))); + if (!expected) { + this->step_failed_(LOG_STR("unexpected response")); + return; + } + // Like the DFRobot library, the answer to the mode change is accepted without a checksum check + if (this->step_ == SetupStep::SETUP_STEP_ALARM && frame[FRAME_SIZE - 1] != frame_checksum(frame)) { + this->step_failed_(LOG_STR("invalid checksum")); + return; + } + if (frame[2] != RESULT_OK) { + this->step_failed_(LOG_STR("not confirmed")); + return; + } + ESP_LOGD(TAG, "%s confirmed", LOG_STR_ARG(step_name(this->step_))); + this->next_step_(); +} + +void DFRobotSEN0465Component::handle_data_(const uint8_t *frame) { + if (frame[FRAME_SIZE - 1] != frame_checksum(frame)) { + char hex[format_hex_pretty_size(FRAME_SIZE)]; + ESP_LOGW(TAG, "Invalid checksum: %s", format_hex_pretty_to(hex, frame, FRAME_SIZE)); + this->fail_reading_(); + return; + } + + // On-board 10 kOhm NTC (B = 3380) read by a 10-bit ADC at 3 V, as converted by the DFRobot library + float temperature = NAN; + const uint16_t temperature_raw = encode_uint16(frame[6], frame[7]); + if (temperature_raw > 0 && temperature_raw < 1024) { + const float voltage = 3.0f * temperature_raw / 1024.0f; + const float resistance = voltage * 10000.0f / (3.0f - voltage); + temperature = 1.0f / (1.0f / 298.15f + std::log(resistance / 10000.0f) / 3380.13f) - 273.15f; + } else { + // 0 is a disconnected thermistor, 1024 and above a bad frame + ESP_LOGW(TAG, "Temperature reading %u is out of range", temperature_raw); + } + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + + if (frame[4] != this->gas_type_) { + ESP_LOGW(TAG, "Probe reports gas type 0x%02X, expected %s (0x%02X); check the configured model", frame[4], + this->gas_name_, this->gas_type_); + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(NAN); + this->status_set_warning(); + return; + } + + float concentration = encode_uint16(frame[2], frame[3]); + switch (frame[5]) { + case 0: + break; + case 1: + concentration *= 0.1f; + break; + case 2: + concentration *= 0.01f; + break; + default: + ESP_LOGW(TAG, "Invalid number of decimal places: %u", frame[5]); + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(NAN); + this->status_set_warning(); + return; + } + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + if (!this->compensation_.empty()) + concentration = this->compensate_(concentration, temperature); +#endif + if (concentration < 0.0f) + concentration = 0.0f; + + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(concentration); + if (std::isnan(concentration) || std::isnan(temperature) || this->setup_incomplete_) { + this->status_set_warning(); + } else { + this->status_clear_warning(); + } +} + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION +float DFRobotSEN0465Component::compensate_(float concentration, float temperature) const { + for (const auto &range : this->compensation_) { + if (temperature > range.low && temperature <= range.high) + return concentration / (range.a * temperature + range.b) - (range.c * temperature + range.d); + } + ESP_LOGW(TAG, "Temperature %.1f C is outside the compensation range", temperature); + return NAN; +} +#endif + +} // namespace esphome::dfrobot_sen0465_base diff --git a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h new file mode 100644 index 00000000000..cc589b7f134 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h @@ -0,0 +1,110 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +namespace esphome::dfrobot_sen0465_base { + +static constexpr uint8_t FRAME_SIZE = 9; +static constexpr uint8_t FRAME_HEAD = 0xFF; + +// Scheduler ids, shared with the bus variants +static constexpr uint32_t STEP_TIMEOUT_ID = 0; +static constexpr uint32_t RESPONSE_TIMEOUT_ID = 1; + +enum class AlarmType : uint8_t { + ALARM_TYPE_OFF, + ALARM_TYPE_LOW, + ALARM_TYPE_HIGH, +}; + +/// Settings sent once after boot, before the first reading +enum class SetupStep : uint8_t { + SETUP_STEP_ACQUIRE_MODE, + SETUP_STEP_ALARM, + SETUP_STEP_DONE, +}; + +/// DFRobot "Factory Calibrated Electrochemical Gas Sensor" series (SEN0465 to SEN0476). +/// The bus variants only send request frames and deliver the answers. +class DFRobotSEN0465Component : public PollingComponent { + SUB_SENSOR(concentration) + SUB_SENSOR(temperature) + + public: + /// @param gas_type gas type code the configured probe reports + /// @param gas_name name of that gas, used in log messages + DFRobotSEN0465Component(uint8_t gas_type, const char *gas_name) : gas_name_(gas_name), gas_type_(gas_type) {} + + void setup() override; + void update() override; + void dump_config() override; + + /// @param threshold alarm threshold as sent to the sensor, in units of the last decimal place + /// @param decimals decimal places of the threshold, used for logging + void set_alarm(AlarmType type, uint16_t threshold, uint8_t decimals) { + this->alarm_type_ = type; + this->alarm_threshold_ = threshold; + this->alarm_decimals_ = decimals; + } + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + /// For low < T <= high: compensated = raw / (a * T + b) - (c * T + d) + void add_compensation_range(int8_t low, int8_t high, float a, float b, float c, float d) { + this->compensation_.push_back({low, high, a, b, c, d}); + } +#endif + + protected: + /// Sends one complete request frame. + virtual bool write_frame(const uint8_t *frame) = 0; + /// Waits for the answer to a request, then calls either handle_response_() or handle_no_response_(), once. + virtual void read_response() = 0; + + void handle_response_(const uint8_t *frame); + void handle_no_response_(); + /// Publish unknown on both sensors and set the warning when a reading cannot be used + void fail_reading_(); + + /// Sends the request of the current step. + bool send_step_request_(); + /// Sends the request again from a scheduled callback; a failed write counts as a failed attempt. + void retry_step_request_(); + void step_failed_(const LogString *reason); + void next_step_(); + void handle_data_(const uint8_t *frame); + /// Sends a request and waits for its answer; returns false when the request could not be sent. + bool send_command_(uint8_t command, uint8_t arg0 = 0, uint8_t arg1 = 0, uint8_t arg2 = 0, uint8_t arg3 = 0); + + const char *gas_name_; + uint16_t alarm_threshold_{0}; + uint8_t gas_type_; + AlarmType alarm_type_{AlarmType::ALARM_TYPE_OFF}; + uint8_t alarm_decimals_{0}; + SetupStep step_{SetupStep::SETUP_STEP_ACQUIRE_MODE}; + uint8_t attempts_{0}; + /// Command whose answer is awaited, 0 when no request is outstanding + uint8_t pending_command_{0}; + /// A setup step gave up, so the alarm or the mode may not be what was configured + bool setup_incomplete_{false}; + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + struct CompensationRange { + int8_t low; + int8_t high; + float a; + float b; + float c; + float d; + }; + + /// Returns NAN when the temperature is outside every range + float compensate_(float concentration, float temperature) const; + + StaticVector compensation_; +#endif +}; + +} // namespace esphome::dfrobot_sen0465_base diff --git a/esphome/components/dfrobot_sen0465_i2c/__init__.py b/esphome/components/dfrobot_sen0465_i2c/__init__.py new file mode 100644 index 00000000000..898f8f2d5f0 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@tzabbi"] +DOMAIN = "dfrobot_sen0465_i2c" diff --git a/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp new file mode 100644 index 00000000000..aa6312a7ebb --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp @@ -0,0 +1,37 @@ +#include "dfrobot_sen0465_i2c.h" +#include "esphome/core/log.h" + +namespace esphome::dfrobot_sen0465_i2c { + +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0465_i2c"); + +using dfrobot_sen0465_base::FRAME_SIZE; +using dfrobot_sen0465_base::RESPONSE_TIMEOUT_ID; + +static constexpr uint8_t DATA_REGISTER = 0x00; +// The DFRobot library waits 10 ms, but on an ESP32-C6 reading the answer to the alarm command +// 10 ms after sending it timed out +static constexpr uint32_t RESPONSE_DELAY_MS = 100; + +void DFRobotSEN0465I2CComponent::dump_config() { + DFRobotSEN0465Component::dump_config(); + LOG_I2C_DEVICE(this); +} + +bool DFRobotSEN0465I2CComponent::write_frame(const uint8_t *frame) { + return this->write_register(DATA_REGISTER, frame, FRAME_SIZE) == i2c::ERROR_OK; +} + +void DFRobotSEN0465I2CComponent::read_response() { + this->set_timeout(RESPONSE_TIMEOUT_ID, RESPONSE_DELAY_MS, [this]() { + uint8_t frame[FRAME_SIZE]; + // Like the DFRobot library, end the register write with a STOP before reading + if (this->write(&DATA_REGISTER, 1) != i2c::ERROR_OK || this->read(frame, FRAME_SIZE) != i2c::ERROR_OK) { + this->handle_no_response_(); + return; + } + this->handle_response_(frame); + }); +} + +} // namespace esphome::dfrobot_sen0465_i2c diff --git a/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h new file mode 100644 index 00000000000..505cc1aef41 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::dfrobot_sen0465_i2c { + +class DFRobotSEN0465I2CComponent final : public dfrobot_sen0465_base::DFRobotSEN0465Component, public i2c::I2CDevice { + public: + using DFRobotSEN0465Component::DFRobotSEN0465Component; + + void dump_config() override; + + protected: + bool write_frame(const uint8_t *frame) override; + void read_response() override; +}; + +} // namespace esphome::dfrobot_sen0465_i2c diff --git a/esphome/components/dfrobot_sen0465_i2c/sensor.py b/esphome/components/dfrobot_sen0465_i2c/sensor.py new file mode 100644 index 00000000000..85ec983b479 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/sensor.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.dfrobot_sen0465_base import ( + DFRobotSEN0465Component, + dfrobot_sen0465_schema, + to_code_base, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["dfrobot_sen0465_base"] +DEPENDENCIES = ["i2c"] + +dfrobot_sen0465_i2c_ns = cg.esphome_ns.namespace("dfrobot_sen0465_i2c") +DFRobotSEN0465I2CComponent = dfrobot_sen0465_i2c_ns.class_( + "DFRobotSEN0465I2CComponent", DFRobotSEN0465Component, i2c.I2CDevice +) + +# 0x77 is the factory setting of the A0/A1 switches according to the DFRobot examples +CONFIG_SCHEMA = dfrobot_sen0465_schema( + DFRobotSEN0465I2CComponent, i2c.i2c_device_schema(0x77) +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await i2c.register_i2c_device(var, config) diff --git a/esphome/components/dfrobot_sen0465_uart/__init__.py b/esphome/components/dfrobot_sen0465_uart/__init__.py new file mode 100644 index 00000000000..e0598c87c8e --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@tzabbi"] +DOMAIN = "dfrobot_sen0465_uart" diff --git a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp new file mode 100644 index 00000000000..ed1c6733abb --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp @@ -0,0 +1,62 @@ +#include "dfrobot_sen0465_uart.h" + +#include + +namespace esphome::dfrobot_sen0465_uart { + +using dfrobot_sen0465_base::FRAME_HEAD; +using dfrobot_sen0465_base::FRAME_SIZE; +using dfrobot_sen0465_base::RESPONSE_TIMEOUT_ID; + +// The DFRobot library waits up to this long for an answer +static constexpr uint32_t RESPONSE_TIMEOUT_MS = 3000; + +void DFRobotSEN0465UARTComponent::setup() { + // loop() only runs while an answer is expected + this->disable_loop(); + DFRobotSEN0465Component::setup(); +} + +void DFRobotSEN0465UARTComponent::loop() { + // The tail of a reading the probe sent on its own may precede the answer; start at the next frame head. + uint8_t byte; + while (this->rx_count_ == 0 && this->peek_byte(&byte) && byte != FRAME_HEAD) + this->read_byte(&byte); + const size_t available = this->available(); + if (available == 0) + return; + const size_t len = std::min(available, FRAME_SIZE - this->rx_count_); + if (!this->read_array(this->rx_buffer_ + this->rx_count_, len)) + return; + this->rx_count_ += len; + if (this->rx_count_ < FRAME_SIZE) + return; + + this->rx_count_ = 0; + this->cancel_timeout(RESPONSE_TIMEOUT_ID); + this->disable_loop(); + this->handle_response_(this->rx_buffer_); +} + +bool DFRobotSEN0465UARTComponent::write_frame(const uint8_t *frame) { + // Drop anything left over, such as the answer to the mode change or readings sent before passive mode was set + uint8_t byte; + while (this->available() > 0) { + if (!this->read_byte(&byte)) + break; + } + this->rx_count_ = 0; + this->write_array(frame, FRAME_SIZE); + return true; +} + +void DFRobotSEN0465UARTComponent::read_response() { + this->enable_loop(); + this->set_timeout(RESPONSE_TIMEOUT_ID, RESPONSE_TIMEOUT_MS, [this]() { + this->disable_loop(); + this->rx_count_ = 0; + this->handle_no_response_(); + }); +} + +} // namespace esphome::dfrobot_sen0465_uart diff --git a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h new file mode 100644 index 00000000000..e30f1c3353c --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::dfrobot_sen0465_uart { + +class DFRobotSEN0465UARTComponent final : public dfrobot_sen0465_base::DFRobotSEN0465Component, + public uart::UARTDevice { + public: + using DFRobotSEN0465Component::DFRobotSEN0465Component; + + void setup() override; + void loop() override; + + protected: + bool write_frame(const uint8_t *frame) override; + void read_response() override; + + uint8_t rx_buffer_[dfrobot_sen0465_base::FRAME_SIZE]; + uint8_t rx_count_{0}; +}; + +} // namespace esphome::dfrobot_sen0465_uart diff --git a/esphome/components/dfrobot_sen0465_uart/sensor.py b/esphome/components/dfrobot_sen0465_uart/sensor.py new file mode 100644 index 00000000000..36eae742f7d --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/sensor.py @@ -0,0 +1,38 @@ +import esphome.codegen as cg +from esphome.components import uart +from esphome.components.dfrobot_sen0465_base import ( + DFRobotSEN0465Component, + dfrobot_sen0465_schema, + to_code_base, +) +from esphome.types import ConfigType + +from . import DOMAIN + +AUTO_LOAD = ["dfrobot_sen0465_base"] +DEPENDENCIES = ["uart"] + +dfrobot_sen0465_uart_ns = cg.esphome_ns.namespace("dfrobot_sen0465_uart") +DFRobotSEN0465UARTComponent = dfrobot_sen0465_uart_ns.class_( + "DFRobotSEN0465UARTComponent", DFRobotSEN0465Component, uart.UARTDevice +) + +CONFIG_SCHEMA = dfrobot_sen0465_schema( + DFRobotSEN0465UARTComponent, uart.UART_DEVICE_SCHEMA +) + +# The sensor's baud rate is fixed +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 to_code_base(config) + await uart.register_uart_device(var, config) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 97677334828..e1e30d1b783 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -70,6 +70,8 @@ #define USE_DEEP_SLEEP #define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES +#define USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION +#define DFROBOT_SEN0465_COMPENSATION_RANGES 3 #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_EMONTX_VERSION diff --git a/tests/components/dfrobot_sen0465_i2c/common.yaml b/tests/components/dfrobot_sen0465_i2c/common.yaml new file mode 100644 index 00000000000..1106210a67f --- /dev/null +++ b/tests/components/dfrobot_sen0465_i2c/common.yaml @@ -0,0 +1,24 @@ +sensor: + - platform: dfrobot_sen0465_i2c + id: dfrobot_sen0466_i2c_co + i2c_id: i2c_bus + model: SEN0466 + address: 0x77 + temperature_compensation: true + alarm: + threshold: 50 + update_interval: 30s + concentration: + name: CO Concentration + temperature: + name: CO Sensor Temperature + - platform: dfrobot_sen0465_i2c + id: dfrobot_sen0465_i2c_o2 + i2c_id: i2c_bus + model: SEN0465 + address: 0x76 + alarm: + threshold: 19.5 + type: low + concentration: + name: O2 Concentration diff --git a/tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml b/tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml new file mode 100644 index 00000000000..e02720c246d --- /dev/null +++ b/tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + dfrobot_sen0465_i2c: !include common.yaml diff --git a/tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml b/tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml new file mode 100644 index 00000000000..bfb82a5c696 --- /dev/null +++ b/tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + dfrobot_sen0465_i2c: !include common.yaml diff --git a/tests/components/dfrobot_sen0465_uart/common.yaml b/tests/components/dfrobot_sen0465_uart/common.yaml new file mode 100644 index 00000000000..fdd12be8f96 --- /dev/null +++ b/tests/components/dfrobot_sen0465_uart/common.yaml @@ -0,0 +1,14 @@ +sensor: + - platform: dfrobot_sen0465_uart + id: dfrobot_sen0466_uart_co + uart_id: uart_bus + model: SEN0466 + temperature_compensation: true + alarm: + threshold: 35 + type: high + update_interval: 30s + concentration: + name: CO Concentration + temperature: + name: CO Sensor Temperature diff --git a/tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml b/tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml new file mode 100644 index 00000000000..98ff3920a96 --- /dev/null +++ b/tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + dfrobot_sen0465_uart: !include common.yaml diff --git a/tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml b/tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml new file mode 100644 index 00000000000..863aa9e7455 --- /dev/null +++ b/tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + dfrobot_sen0465_uart: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml b/tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml new file mode 100644 index 00000000000..3e40b1c017f --- /dev/null +++ b/tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml @@ -0,0 +1,94 @@ +esphome: + name: uart-mock-dfrobot-sen0465 + +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 + +# A model of a SEN0466 (CO) probe. The first mode change is refused, the first alarm request gets a late +# answer to the mode change instead, and the first data request gets no answer, so the component has to +# retry, reject the wrong answer and wait out the timeout without sending a new request +uart_mock: + id: mock_uart + baud_rate: 9600 + on_tx: + - then: + - lambda: |- + if (data.size() != 9 || data[0] != 0xFF || data[1] != 0x01) + return; + static int mode_requests = 0; + static int alarm_requests = 0; + static int data_requests = 0; + static uint32_t mode_confirmed_ms = 0; + static bool alarm_set = false; + std::vector reply(9, 0); + reply[0] = 0xFF; + reply[1] = data[2]; + switch (data[2]) { + case 0x78: + if (data[3] == 0x04 && ++mode_requests >= 2) { + reply[2] = 0x01; + mode_confirmed_ms = millis(); + } + break; + case 0x89: + ESP_LOGI("sim", "Alarm switch %u threshold %u type %u, %u ms after the mode change", data[3], + (data[4] << 8) | data[5], data[6], millis() - mode_confirmed_ms); + reply[2] = 0x01; + if (++alarm_requests == 1) { + reply[1] = 0x78; // FF 78 01 00 00 00 00 00 87: valid checksum and result, wrong command + break; + } + alarm_set = true; + break; + case 0x88: + if (!alarm_set) + ESP_LOGE("sim", "Data requested before the setup was done"); + if (++data_requests == 1) { + ESP_LOGI("sim", "Data request left unanswered"); + return; + } + if (data_requests == 2) + ESP_LOGI("sim", "Data request answered"); + reply[2] = 0x00; // 20 ppm + reply[3] = 0x14; + reply[4] = 0x04; // CO + reply[5] = 0x00; // no decimal places + reply[6] = 0x01; // NTC ADC 511, a non-zero byte 7 that the checksum must leave out + reply[7] = 0xFF; + break; + default: + return; + } + // The checksum covers bytes 1 to 6 only, as in the DFRobot library + uint8_t sum = 0; + for (int i = 1; i < 7; i++) + sum += reply[i]; + reply[8] = static_cast(~sum + 1); + id(mock_uart).inject_to_rx_buffer(reply); + +sensor: + - platform: dfrobot_sen0465_uart + uart_id: mock_uart + model: SEN0466 + temperature_compensation: true + update_interval: 2s + alarm: + threshold: 35 + type: high + concentration: + name: CO + temperature: + name: Probe Temperature diff --git a/tests/integration/test_uart_mock_dfrobot_sen0465.py b/tests/integration/test_uart_mock_dfrobot_sen0465.py new file mode 100644 index 00000000000..12fd139fad5 --- /dev/null +++ b/tests/integration/test_uart_mock_dfrobot_sen0465.py @@ -0,0 +1,106 @@ +"""Integration test for the DFRobot SEN0465 series over UART against a model of a SEN0466 probe. + +Checks the boot sequence (passive mode with one retry, the wait after it, the alarm setting with a +late answer to the mode change that must be rejected), that no new request is sent while one is +unanswered, and that a reading is decoded and temperature compensated. +""" + +from __future__ import annotations + +import asyncio +import math +from pathlib import Path +import re + +from aioesphomeapi import EntityState, SensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +ALARM_RE = re.compile( + r"Alarm switch (\d+) threshold (\d+) type (\d+), (\d+) ms after the mode change" +) + + +@pytest.mark.asyncio +async def test_uart_mock_dfrobot_sen0465( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Boot sets passive mode and the alarm before the first reading.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + alarm_set: asyncio.Future[re.Match[str]] = loop.create_future() + lines: list[str] = [] + + def line_callback(line: str) -> None: + lines.append(line) + if (match := ALARM_RE.search(line)) and not alarm_set.done(): + alarm_set.set_result(match) + + states: dict[int, EntityState] = {} + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + concentration = require_entity(entities, "co", SensorInfo) + temperature = require_entity(entities, "probe_temperature", SensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity: SensorInfo) -> float | None: + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + try: + async with asyncio.timeout(15.0): + # The unanswered request publishes NAN first; wait for the decoded reading + while (value := state_of(concentration)) is None or math.isnan(value): + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"No reading; log: {lines[-20:]}") + + match = await asyncio.wait_for(alarm_set, timeout=1.0) + switch, threshold, alarm_type, delay_ms = (int(x) for x in match.groups()) + assert (switch, threshold, alarm_type) == (1, 35, 1) + assert delay_ms >= 1000, ( + "the alarm was set before the wait after the mode change" + ) + + assert any( + "Passive mode failed: not confirmed (attempt 1 of 3)" in x for x in lines + ) + assert any("Passive mode confirmed" in x for x in lines) + assert any( + "Alarm setting failed: unexpected response (attempt 1 of 3)" in x + for x in lines + ) + assert any("Alarm setting confirmed" in x for x in lines) + assert not any("Data requested before the setup was done" in x for x in lines) + + # The unanswered request blocks new ones until its timeout, which is longer than the interval + unanswered = next( + i for i, x in enumerate(lines) if "Data request left unanswered" in x + ) + answered = next(i for i, x in enumerate(lines) if "Data request answered" in x) + between = lines[unanswered:answered] + assert any("Previous request still waiting for an answer" in x for x in between) + assert any("No response from the sensor" in x for x in between) + + # NTC ADC 511 converted like the DFRobot library, then compensated with the CO formula + voltage = 3 * 511 / 1024 + resistance = voltage * 10000 / (3 - voltage) + temp = 1 / (1 / 298.15 + math.log(resistance / 10000) / 3380.13) - 273.15 + assert state_of(temperature) == pytest.approx(temp, abs=0.05) + expected = 20 / (0.005 * temp + 0.9) - (0.3 * temp - 6) + assert state_of(concentration) == pytest.approx(expected, abs=0.05) From 1b4721d4451c7d62dca91e571d4f494b440a66e6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:23:12 +1100 Subject: [PATCH 038/120] [core] Script improvements including `choose` (#20547) --- esphome/automation.py | 129 +++++++++- esphome/components/script/__init__.py | 105 +++++++-- esphome/components/script/script.h | 37 ++- esphome/core/defines.h | 1 + tests/components/script/__init__.py | 9 + tests/components/script/common.yaml | 33 +++ .../components/script/test_script_on_stop.cpp | 77 ++++++ .../fixtures/script_on_stop_and_choose.yaml | 145 ++++++++++++ .../test_script_on_stop_and_choose.py | 117 +++++++++ tests/unit_tests/components/test_script.py | 160 +++++++++++++ tests/unit_tests/test_automation.py | 222 ++++++++++++++++++ 11 files changed, 1013 insertions(+), 22 deletions(-) create mode 100644 tests/components/script/__init__.py create mode 100644 tests/components/script/test_script_on_stop.cpp create mode 100644 tests/integration/fixtures/script_on_stop_and_choose.yaml create mode 100644 tests/integration/test_script_on_stop_and_choose.py create mode 100644 tests/unit_tests/components/test_script.py diff --git a/esphome/automation.py b/esphome/automation.py index 32f423d2a34..91cbf0daab1 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_COUNT, CONF_ELSE, CONF_ID, + CONF_IF, CONF_THEN, CONF_TIME, CONF_TIMEOUT, @@ -34,6 +35,9 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, SafeExpType from esphome.util import Registry +CONF_CHOOSE = "choose" +CONF_CHOICE_ID = "choice_id" + def progmem_bytes(name: str, data: bytes | list[int]) -> MockObj: """Shared PROGMEM table for constant bytes; equal payloads share one, empty is nullptr.""" @@ -840,10 +844,39 @@ async def delay_action_to_code( return var +def _wrap_bare_if_condition(value: Any) -> Any: + """Allow a single condition directly under `if:` in place of `condition: {...}`.""" + if not isinstance(value, dict): + return value + # `any` and `all` are also registered conditions, but here they are the if action's own keys + bare = [ + key + for key in value + if key in CONDITION_REGISTRY and key not in (CONF_ANY, CONF_ALL) + ] + if not bare: + return value + if len(bare) > 1: + raise cv.Invalid( + f"Only one condition may be given directly under 'if', found {', '.join(bare)}. " + f"Use '{CONF_CONDITION}', '{CONF_ALL}' or '{CONF_ANY}' to combine conditions" + ) + if other := [k for k in (CONF_CONDITION, CONF_ANY, CONF_ALL) if k in value]: + raise cv.Invalid( + f"'{bare[0]}' given directly under 'if' cannot be used with '{other[0]}'. " + f"Move it into '{other[0]}'" + ) + key = bare[0] + result = {k: v for k, v in value.items() if k != key} + result[CONF_CONDITION] = {key: value[key]} + return result + + @register_action( "if", IfAction, cv.All( + _wrap_bare_if_condition, { cv.Exclusive( CONF_CONDITION, CONF_CONDITION @@ -870,14 +903,104 @@ async def if_action_to_code( cond_conf = next(el for el in config if el in (CONF_ANY, CONF_ALL, CONF_CONDITION)) condition = await build_condition(config[cond_conf], template_arg, args) var = cg.new_Pvariable(action_id, if_template_arg, condition) + then_actions = else_actions = None if CONF_THEN in config: - for action in await build_action_list(config[CONF_THEN], template_arg, args): + then_actions = await build_action_list(config[CONF_THEN], template_arg, args) + if has_else: + else_actions = await build_action_list(config[CONF_ELSE], template_arg, args) + _add_if_branches(var, then_actions, else_actions) + return var + + +def _add_if_branches( + var: MockObj, + then_actions: list[MockObj] | None, + else_actions: list[MockObj] | None, +) -> None: + """Add the branches of an IfAction one action at a time; None leaves a branch out.""" + if then_actions is not None: + for action in then_actions: cg.add(var.add_then_action(action)) cg.add(var.finish_then()) - if has_else: - for action in await build_action_list(config[CONF_ELSE], template_arg, args): + if else_actions is not None: + for action in else_actions: cg.add(var.add_else_action(action)) cg.add(var.finish_else()) + + +def _validate_choice(choice: ConfigType) -> ConfigType: + """A choice is either `if` with `then`, or `else` on its own.""" + if CONF_ELSE in choice: + for key in (CONF_IF, CONF_THEN): + if key in choice: + raise cv.Invalid(f"'{key}' cannot be used with 'else'", path=[key]) + return choice + for key in (CONF_IF, CONF_THEN): + if key not in choice: + raise cv.Invalid(f"'{key}' is required", path=[key]) + return choice + + +def _validate_choose_else(choices: list[ConfigType]) -> list[ConfigType]: + """`else` may only be the last choice, and not the only one.""" + for index, choice in enumerate(choices[:-1]): + if CONF_ELSE in choice: + raise cv.Invalid( + "Only the last choice may be 'else'", path=[index, CONF_ELSE] + ) + if len(choices) == 1 and CONF_ELSE in choices[0]: + raise cv.Invalid( + "At least one choice must have 'if' and 'then'", path=[0, CONF_ELSE] + ) + return choices + + +@register_action( + CONF_CHOOSE, + IfAction, + cv.All( + cv.ensure_list( + cv.All( + cv.Schema( + { + cv.GenerateID(CONF_CHOICE_ID): cv.declare_id(IfAction), + cv.Optional(CONF_IF): validate_potentially_and_condition, + cv.Optional(CONF_THEN): validate_action_list, + cv.Optional(CONF_ELSE): validate_action_list, + } + ), + _validate_choice, + ) + ), + cv.Length(min=1), + _validate_choose_else, + ), + synchronous=True, +) +async def choose_action_to_code( + config: list[ConfigType], + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + # Built as a chain of if/else actions: each choice's else branch holds the next choice, + # and the last choice's else branch holds the `else` actions, if any. + else_actions: list[MockObj] | None = None + if CONF_ELSE in config[-1]: + else_actions = await build_action_list( + config[-1][CONF_ELSE], template_arg, args + ) + config = config[:-1] + var: MockObj | None = None + for index in reversed(range(len(config))): + choice = config[index] + if_template_arg = cg.TemplateArguments(else_actions is not None, *template_arg) + condition = await build_condition(choice[CONF_IF], template_arg, args) + choice_id = action_id if index == 0 else choice[CONF_CHOICE_ID] + var = cg.new_Pvariable(choice_id, if_template_arg, condition) + then_actions = await build_action_list(choice[CONF_THEN], template_arg, args) + _add_if_branches(var, then_actions, else_actions) + else_actions = [var] return var diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index 48a208b7f7f..da149944f21 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -1,9 +1,13 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg +from esphome.components.const import CONF_ON_STOP import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODE, CONF_PARAMETERS, CONF_RESTART from esphome.core import CORE, EsphomeError +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "script" @@ -20,6 +24,7 @@ CONF_SINGLE = "single" CONF_QUEUED = "queued" CONF_PARALLEL = "parallel" CONF_MAX_RUNS = "max_runs" +CONF_SCRIPT_STOP = "script.stop" SCRIPT_MODES = { CONF_SINGLE: SingleScript, @@ -109,22 +114,72 @@ def validate_parameter_type(value): raise cv.Invalid("Parameter type contains invalid characters") -CONFIG_SCHEMA = automation.validate_automation( - { - # Don't declare id as cv.declare_id yet, because the ID type - # depends on the mode. Will be checked later with assign_declare_id - cv.Required(CONF_ID): cv.string_strict, - cv.Optional(CONF_MODE, default=CONF_SINGLE): cv.one_of( - *SCRIPT_MODES, lower=True - ), - cv.Optional(CONF_MAX_RUNS): cv.int_range(min=0, max=100), - cv.Optional(CONF_PARAMETERS, default={}): cv.Schema( - { - validate_parameter_name: validate_parameter_type, - } - ), - }, - extra_validators=cv.All(check_max_runs, assign_declare_id), +def _fill_script_stop_ids(value: Any, script_id: str) -> Any: + """Give `script.stop` actions without an id in a raw action tree the id `script_id`. + + Containers are copied only along changed paths so untouched YAML nodes keep their source locations. + """ + if isinstance(value, list): + result = [ + {CONF_SCRIPT_STOP: {CONF_ID: script_id}} + if item == CONF_SCRIPT_STOP + else _fill_script_stop_ids(item, script_id) + for item in value + ] + if all(new is old for new, old in zip(result, value, strict=True)): + return value + return result + if not isinstance(value, dict): + return value + result = value + for key, item in value.items(): + if key == CONF_SCRIPT_STOP and ( + item is None or (isinstance(item, dict) and CONF_ID not in item) + ): + new_item = {**(item or {}), CONF_ID: script_id} + else: + new_item = _fill_script_stop_ids(item, script_id) + if new_item is not item: + if result is value: + result = value.copy() + result[key] = new_item + return result + + +def _fill_script_stop_ids_in_script(item: Any) -> Any: + if isinstance(item, dict) and isinstance(script_id := item.get(CONF_ID), str): + return _fill_script_stop_ids(item, script_id) + return item + + +def default_script_stop_ids(value: Any) -> Any: + """Make `script.stop` with no id, used inside a script, stop that script.""" + if isinstance(value, list): + return [_fill_script_stop_ids_in_script(item) for item in value] + # A single script may be given as a mapping instead of a list + return _fill_script_stop_ids_in_script(value) + + +CONFIG_SCHEMA = cv.All( + default_script_stop_ids, + automation.validate_automation( + { + # Don't declare id as cv.declare_id yet, because the ID type + # depends on the mode. Will be checked later with assign_declare_id + cv.Required(CONF_ID): cv.string_strict, + cv.Optional(CONF_MODE, default=CONF_SINGLE): cv.one_of( + *SCRIPT_MODES, lower=True + ), + cv.Optional(CONF_MAX_RUNS): cv.int_range(min=0, max=100), + cv.Optional(CONF_PARAMETERS, default={}): cv.Schema( + { + validate_parameter_name: validate_parameter_type, + } + ), + cv.Optional(CONF_ON_STOP): automation.validate_automation(single=True), + }, + extra_validators=cv.All(check_max_runs, assign_declare_id), + ), ) @@ -145,8 +200,12 @@ async def to_code(config): triggers.append((trigger, func_args, conf)) + if any(CONF_ON_STOP in conf for conf in config): + cg.add_define("USE_SCRIPT_ON_STOP") for trigger, func_args, conf in triggers: await automation.build_automation(trigger, func_args, conf) + if (stop_conf := conf.get(CONF_ON_STOP)) is not None: + await automation.build_automation(trigger.get_stop_trigger(), [], stop_conf) @automation.register_action( @@ -205,9 +264,19 @@ async def script_execute_action_to_code(config, action_id, template_arg, args): return var +def _require_script_stop_id(config: ConfigType) -> ConfigType: + # Inside a script the id has already been filled in by default_script_stop_ids + if CONF_ID not in config: + raise cv.Invalid( + "'script.stop' without an id can only be used inside a script", + path=[CONF_ID], + ) + return config + + automation.register_apply_action( - "script.stop", - maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), + CONF_SCRIPT_STOP, + maybe_simple_id({cv.Optional(CONF_ID): cv.use_id(Script)}, _require_script_stop_id), automation.ApplyCall("stop()"), ) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index efd75d27e0e..c84077025a3 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -33,8 +33,18 @@ template class Script : public ScriptLogger, public Triggeris_action_running(); } +#ifdef USE_SCRIPT_ON_STOP + /// Stop all instances of this script. Fires the on_stop trigger once if any instance was running. + virtual void stop() { + if (this->is_action_running()) + this->stop_running_(); + } + + Trigger<> *get_stop_trigger() { return &this->stop_trigger_; } +#else /// Stop all instances of this script. virtual void stop() { this->stop_action(); } +#endif // execute this script using a tuple that contains the arguments void execute_tuple(const std::tuple &tuple) { @@ -57,7 +67,18 @@ template class Script : public ScriptLogger, public Triggertrigger(x...); } + // Stop the running actions early and fire the on_stop trigger. + void stop_running_() { + this->stop_action(); +#ifdef USE_SCRIPT_ON_STOP + this->stop_trigger_.trigger(); +#endif + } + const LogString *name_{nullptr}; +#ifdef USE_SCRIPT_ON_STOP + Trigger<> stop_trigger_; +#endif }; /** A script type for which only a single instance at a time is allowed. @@ -88,7 +109,12 @@ template class RestartScript : public Script { void execute(Ts... x) override { if (this->is_action_running()) { this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), LOG_STR_ARG(this->name_)); - this->stop_action(); + this->stop_running_(); +#ifdef USE_SCRIPT_ON_STOP + // on_stop may have executed this script again; only the newest run may continue + if (this->is_action_running()) + this->stop_action(); +#endif } this->run_actions_(x...); @@ -145,12 +171,21 @@ template class QueueingScript : public Script, public Com } void stop() override { +#ifdef USE_SCRIPT_ON_STOP + // Queued runs are unfinished work too, even between two runs when nothing is running + const bool stopping = this->is_action_running() || this->num_queued_ != 0; +#endif // Clear all queued items to free memory immediately // Resetting the array automatically destroys all unique_ptrs and their contents this->var_queue_.reset(); this->num_queued_ = 0; this->queue_front_ = 0; +#ifdef USE_SCRIPT_ON_STOP + if (stopping) + this->stop_running_(); +#else Script::stop(); +#endif } void loop() override { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e1e30d1b783..3555b3cd1c2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -266,6 +266,7 @@ #define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 +#define USE_SCRIPT_ON_STOP #define USE_SELECT #define USE_SELEC_METER_EM4M #define USE_SENSOR diff --git a/tests/components/script/__init__.py b/tests/components/script/__init__.py new file mode 100644 index 00000000000..ef5a1a33501 --- /dev/null +++ b/tests/components/script/__init__.py @@ -0,0 +1,9 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config): + cg.add_define("USE_SCRIPT_ON_STOP") + + manifest.to_code = to_code_testing diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index f8396e9648f..6a71c2cdef4 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -22,6 +22,25 @@ esphome: - script.is_running: my_script then: - logger.log: my_script is running + - if: + any: + - script.is_running: my_script + - script.is_running: my_script_restart + then: + - logger.log: a script is running + - script.execute: my_script_stops_itself + - choose: + - if: + script.is_running: my_script + then: + - logger.log: first choice + - if: + - lambda: return true; + - script.is_running: my_script_restart + then: + - logger.log: second choice + - else: + - logger.log: default choice globals: - id: nested_total @@ -44,8 +63,22 @@ script: - lambda: 'ESP_LOGD("main", "Hello World!");' - id: my_script_restart mode: restart + on_stop: + - logger.log: my_script_restart stopped then: - lambda: 'ESP_LOGD("main", "Hello World!");' + - id: my_script_stops_itself + on_stop: + then: + - logger.log: my_script_stops_itself stopped + then: + - delay: 1s + - if: + lambda: return true; + then: + - script.stop + - script.stop: + - logger.log: never reached - id: my_script_with_array_params parameters: ints: int[] diff --git a/tests/components/script/test_script_on_stop.cpp b/tests/components/script/test_script_on_stop.cpp new file mode 100644 index 00000000000..5fdda64bc15 --- /dev/null +++ b/tests/components/script/test_script_on_stop.cpp @@ -0,0 +1,77 @@ +#include + +#include "esphome/components/script/script.h" + +namespace esphome::script::testing { + +class CountAction : public Action<> { + public: + explicit CountAction(int *count) : count_(count) {} + + protected: + void play() override { (*this->count_)++; } + int *count_; +}; + +// Stays running until release() is called, like a delay that never expires on its own. +class HoldAction : public Action<> { + public: + void play_complex() override { + this->num_running_++; + this->plays++; + } + void release() { this->play_next_(); } + int plays{0}; + + protected: + void play() override {} +}; + +class QueueingScriptOnStopTest : public ::testing::Test { + protected: + void SetUp() override { + this->script_.set_max_runs(3); + this->automation_.add_action(&this->hold_); + this->stop_automation_.add_action(&this->count_); + } + + QueueingScript<> script_; + Automation<> automation_{&this->script_}; + HoldAction hold_; + int on_stop_calls_{0}; + Automation<> stop_automation_{this->script_.get_stop_trigger()}; + CountAction count_{&this->on_stop_calls_}; +}; + +TEST_F(QueueingScriptOnStopTest, StopWhileRunningFiresOnStopOnceAndDropsQueuedRun) { + this->script_.execute(); + this->script_.execute(); + ASSERT_EQ(this->hold_.plays, 1); + + this->script_.stop(); + EXPECT_EQ(this->on_stop_calls_, 1); + + this->script_.loop(); + EXPECT_EQ(this->hold_.plays, 1); +} + +// After one run ends, the next queued run only starts on the following loop() call +TEST_F(QueueingScriptOnStopTest, StopBetweenQueuedRunsFiresOnStop) { + this->script_.execute(); + this->script_.execute(); + this->hold_.release(); + ASSERT_FALSE(this->script_.is_running()); + + this->script_.stop(); + EXPECT_EQ(this->on_stop_calls_, 1); + + this->script_.loop(); + EXPECT_EQ(this->hold_.plays, 1); +} + +TEST_F(QueueingScriptOnStopTest, StopWhenIdleDoesNotFireOnStop) { + this->script_.stop(); + EXPECT_EQ(this->on_stop_calls_, 0); +} + +} // namespace esphome::script::testing diff --git a/tests/integration/fixtures/script_on_stop_and_choose.yaml b/tests/integration/fixtures/script_on_stop_and_choose.yaml new file mode 100644 index 00000000000..d7edbd90d3f --- /dev/null +++ b/tests/integration/fixtures/script_on_stop_and_choose.yaml @@ -0,0 +1,145 @@ +esphome: + name: test-script-on-stop-choose + +host: + +api: + actions: + - action: run_choose + variables: + value: int + then: + - choose: + - if: + lambda: return value < 10; + then: + - logger.log: + format: "CHOOSE small %d" + args: [value] + - if: + lambda: return value < 100; + then: + - delay: 20ms + - logger.log: + format: "CHOOSE medium %d" + args: [value] + # Also true for smaller values, but only the first true choice runs + - if: + lambda: return value < 1000; + then: + - logger.log: + format: "CHOOSE large %d" + args: [value] + - else: + - logger.log: + format: "CHOOSE default %d" + args: [value] + - logger.log: + format: "CHOOSE done %d" + args: [value] + - action: run_self_stop + then: + - script.execute: self_stop + - action: run_external_stop + then: + - script.execute: long_script + - delay: 50ms + - script.stop: long_script + - action: run_idle_stop + then: + - script.stop: long_script + - logger.log: "IDLE stop done" + - action: run_restart + then: + - script.execute: restart_script + - script.execute: restart_script + - action: run_normal + then: + - script.execute: normal_script + - action: run_reenter + then: + - script.execute: reenter_script + - delay: 20ms + - script.execute: reenter_script + - action: run_parallel_stop + then: + - script.execute: parallel_script + - script.execute: parallel_script + - delay: 20ms + - script.stop: parallel_script + - logger.log: "PARALLEL stop done" + - action: run_queued_stop + then: + - script.execute: queued_script + - script.execute: queued_script + - delay: 20ms + - script.stop: queued_script + - delay: 300ms + - logger.log: "QUEUED stop done" + +logger: + level: DEBUG + +script: + - id: self_stop + on_stop: + - logger.log: "SELF on_stop" + then: + - logger.log: "SELF start" + - delay: 20ms + - if: + condition: + lambda: return true; + then: + - script.stop + - logger.log: "SELF not reached" + + - id: long_script + on_stop: + - logger.log: "LONG on_stop" + then: + - delay: 10s + - logger.log: "LONG finished" + + - id: restart_script + mode: restart + on_stop: + - logger.log: "RESTART on_stop" + then: + - logger.log: "RESTART start" + - delay: 50ms + - logger.log: "RESTART finished" + + - id: normal_script + on_stop: + - logger.log: "NORMAL on_stop" + then: + - logger.log: "NORMAL finished" + + # on_stop executes the same restart script again; only one run may continue + - id: reenter_script + mode: restart + on_stop: + - logger.log: "REENTER on_stop" + - script.execute: reenter_script + then: + - logger.log: "REENTER start" + - delay: 100ms + - logger.log: "REENTER finished" + + - id: parallel_script + mode: parallel + on_stop: + - logger.log: "PARALLEL on_stop" + then: + - delay: 10s + - logger.log: "PARALLEL finished" + + - id: queued_script + mode: queued + on_stop: + - logger.log: "QUEUED on_stop" + then: + - logger.log: "QUEUED start" + - delay: 100ms + - logger.log: "QUEUED finished" diff --git a/tests/integration/test_script_on_stop_and_choose.py b/tests/integration/test_script_on_stop_and_choose.py new file mode 100644 index 00000000000..07631450df3 --- /dev/null +++ b/tests/integration/test_script_on_stop_and_choose.py @@ -0,0 +1,117 @@ +"""Integration test for the script on_stop trigger, script.stop without an id, and the choose action.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import UserService +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_on_stop_and_choose( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test on_stop, self-stopping scripts and choose.""" + lines: list[str] = [] + waiters: list[tuple[str, asyncio.Future[None]]] = [] + + def check_output(line: str) -> None: + lines.append(line) + for text, future in waiters: + if text in line and not future.done(): + future.set_result(None) + + def expect(text: str) -> asyncio.Future[None]: + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + waiters.append((text, future)) + return future + + def count(text: str) -> int: + return sum(text in line for line in lines) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service: dict[str, UserService] = {s.name: s for s in services} + + # choose runs only the first choice whose condition is true, or the else + # choice if none is, then continues + for value, label in ( + (5, "small"), + (50, "medium"), + (500, "large"), + (5000, "default"), + ): + done = expect(f"CHOOSE done {value}") + await client.execute_service(service["run_choose"], {"value": value}) + await asyncio.wait_for(done, timeout=5.0) + assert count(f"CHOOSE {label} {value}") == 1 + assert count("CHOOSE small") == 1 + assert count("CHOOSE medium") == 1 + assert count("CHOOSE large") == 1 + assert count("CHOOSE default") == 1 + + # A bare script.stop inside a script stops that script and fires on_stop + stopped = expect("SELF on_stop") + await client.execute_service(service["run_self_stop"], {}) + await asyncio.wait_for(stopped, timeout=5.0) + + # Stopping a running script from outside fires on_stop + stopped = expect("LONG on_stop") + await client.execute_service(service["run_external_stop"], {}) + await asyncio.wait_for(stopped, timeout=5.0) + + # Stopping a script that is not running does not fire on_stop + idle_done = expect("IDLE stop done") + await client.execute_service(service["run_idle_stop"], {}) + await asyncio.wait_for(idle_done, timeout=5.0) + + # Restarting a running script stops the old run early + finished = expect("RESTART finished") + await client.execute_service(service["run_restart"], {}) + await asyncio.wait_for(finished, timeout=5.0) + + # A script that finishes normally does not fire on_stop + finished = expect("NORMAL finished") + await client.execute_service(service["run_normal"], {}) + await asyncio.wait_for(finished, timeout=5.0) + + # A restart script whose on_stop executes it again ends up with one run + finished = expect("REENTER finished") + await client.execute_service(service["run_reenter"], {}) + await asyncio.wait_for(finished, timeout=5.0) + + # Stopping several parallel runs fires on_stop once + done = expect("PARALLEL stop done") + await client.execute_service(service["run_parallel_stop"], {}) + await asyncio.wait_for(done, timeout=5.0) + + # Stopping a queued script fires on_stop once and drops the queued run + done = expect("QUEUED stop done") + await client.execute_service(service["run_queued_stop"], {}) + await asyncio.wait_for(done, timeout=5.0) + await asyncio.sleep(0.2) + + assert count("SELF not reached") == 0 + assert count("SELF on_stop") == 1 + assert count("LONG finished") == 0 + assert count("LONG on_stop") == 1 + assert count("RESTART start") == 2 + assert count("RESTART on_stop") == 1 + assert count("RESTART finished") == 1 + assert count("NORMAL on_stop") == 0 + assert count("REENTER on_stop") == 1 + assert count("REENTER start") == 3 + assert count("REENTER finished") == 1 + assert count("PARALLEL on_stop") == 1 + assert count("PARALLEL finished") == 0 + assert count("QUEUED on_stop") == 1 + assert count("QUEUED start") == 1 + assert count("QUEUED finished") == 0 diff --git a/tests/unit_tests/components/test_script.py b/tests/unit_tests/components/test_script.py new file mode 100644 index 00000000000..10c6d0a7a38 --- /dev/null +++ b/tests/unit_tests/components/test_script.py @@ -0,0 +1,160 @@ +"""Tests for the script component's config validation.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from esphome import automation +from esphome.components.const import CONF_ON_STOP +from esphome.components.script import ( + CONF_SCRIPT_STOP, + SingleScript, + default_script_stop_ids, + to_code, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MODE, CONF_PARAMETERS, CONF_THEN +from esphome.core import ID +from esphome.cpp_generator import MockObj + + +def test_bare_script_stop_defaults_to_enclosing_script() -> None: + config = [ + { + CONF_ID: "my_script", + CONF_THEN: [ + CONF_SCRIPT_STOP, + {CONF_SCRIPT_STOP: None}, + {CONF_SCRIPT_STOP: {}}, + {CONF_SCRIPT_STOP: "other_script"}, + {CONF_SCRIPT_STOP: {CONF_ID: "other_script"}}, + {"if": {"condition": "x", CONF_THEN: [CONF_SCRIPT_STOP]}}, + ], + "on_stop": [{CONF_SCRIPT_STOP: None}], + }, + {CONF_ID: "second", CONF_THEN: [CONF_SCRIPT_STOP]}, + ] + + result = default_script_stop_ids(config) + + assert result[0][CONF_THEN] == [ + {CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}, + {CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}, + {CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}, + {CONF_SCRIPT_STOP: "other_script"}, + {CONF_SCRIPT_STOP: {CONF_ID: "other_script"}}, + { + "if": { + "condition": "x", + CONF_THEN: [{CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}], + } + }, + ] + assert result[0]["on_stop"] == [{CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}] + assert result[1][CONF_THEN] == [{CONF_SCRIPT_STOP: {CONF_ID: "second"}}] + # The input is not modified + assert config[1][CONF_THEN] == [CONF_SCRIPT_STOP] + + +def test_unchanged_script_config_is_not_copied() -> None: + then = [{"logger.log": "hello"}] + config = [{CONF_ID: "my_script", CONF_THEN: then}] + + result = default_script_stop_ids(config) + + assert result[0] is config[0] + assert result[0][CONF_THEN] is then + + +def test_script_stop_without_id_outside_script_is_invalid() -> None: + with pytest.raises(cv.Invalid, match="can only be used inside a script"): + automation.validate_action(CONF_SCRIPT_STOP) + + +def test_script_stop_with_id_is_valid() -> None: + result = automation.validate_action({CONF_SCRIPT_STOP: "my_script"}) + + assert result[CONF_SCRIPT_STOP][CONF_ID].id == "my_script" + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("my_script", id="not_a_list_or_mapping"), + pytest.param({CONF_THEN: [CONF_SCRIPT_STOP]}, id="mapping_without_id"), + pytest.param(["my_script", {CONF_THEN: [CONF_SCRIPT_STOP]}], id="no_id"), + ], +) +def test_script_stop_ids_left_alone_without_script_id(value: Any) -> None: + assert default_script_stop_ids(value) == value + + +def test_bare_script_stop_in_single_script_mapping() -> None: + config = {CONF_ID: "my_script", CONF_THEN: [CONF_SCRIPT_STOP]} + + result = default_script_stop_ids(config) + + assert result[CONF_THEN] == [{CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}] + + +@pytest.mark.asyncio +async def test_on_stop_builds_stop_trigger_automation() -> None: + stop_conf = {CONF_THEN: []} + config = [ + { + CONF_ID: ID("my_script", is_declaration=True, type=SingleScript), + CONF_MODE: "single", + CONF_PARAMETERS: {}, + CONF_THEN: [], + CONF_ON_STOP: stop_conf, + }, + { + CONF_ID: ID("other_script", is_declaration=True, type=SingleScript), + CONF_MODE: "single", + CONF_PARAMETERS: {}, + CONF_THEN: [], + }, + ] + with ( + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add"), + patch("esphome.codegen.add_define") as add_define, + patch( + "esphome.automation.build_automation", new_callable=AsyncMock + ) as build_automation, + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + await to_code(config) + + add_define.assert_called_once_with("USE_SCRIPT_ON_STOP") + + assert [ + (str(c.args[0]), *c.args[1:]) for c in build_automation.await_args_list + ] == [ + ("my_script", [], config[0]), + ("my_script->get_stop_trigger()", [], stop_conf), + ("other_script", [], config[1]), + ] + + +@pytest.mark.asyncio +async def test_no_on_stop_define_without_on_stop() -> None: + config = [ + { + CONF_ID: ID("my_script", is_declaration=True, type=SingleScript), + CONF_MODE: "single", + CONF_PARAMETERS: {}, + CONF_THEN: [], + } + ] + with ( + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add"), + patch("esphome.codegen.add_define") as add_define, + patch("esphome.automation.build_automation", new_callable=AsyncMock), + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + await to_code(config) + + add_define.assert_not_called() diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index b56d0a2920e..6f4a1ba83a8 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -8,6 +8,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from esphome.automation import ( + CONF_CHOICE_ID, + CONF_CHOOSE, ApplyAction, ApplyCall, ApplyCondition, @@ -25,7 +27,9 @@ from esphome.automation import ( build_parent_callback_automation, build_trigger_automations, build_trigger_callback, + choose_action_to_code, has_non_synchronous_actions, + if_action_to_code, literal_with_length, maybe_simple_id, parent_ref, @@ -40,14 +44,19 @@ from esphome.automation import ( register_simple_condition, string_ref_literal, templatable_bytes, + validate_action, validate_no_deferring_actions, validate_synchronous_automation, ) import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( + CONF_ALL, + CONF_ANY, CONF_AUTOMATION_ID, + CONF_CONDITION, CONF_COUNT, + CONF_ELSE, CONF_ID, CONF_THEN, CONF_TYPE_ID, @@ -1557,6 +1566,219 @@ async def test_build_callback_automation_forwarder(mock_cg: MockCodegen) -> None ) +def test_if_accepts_bare_condition() -> None: + """A single condition directly under `if:` is the same as `condition: {...}`.""" + bare = validate_action({"if": {"or": [], CONF_THEN: [{"delay": "1s"}]}}) + wrapped = validate_action( + {"if": {CONF_CONDITION: {"or": []}, CONF_THEN: [{"delay": "1s"}]}} + ) + assert bare["if"][CONF_CONDITION] == wrapped["if"][CONF_CONDITION] + assert "or" not in bare["if"] + + +@pytest.mark.parametrize("key", [CONF_ANY, CONF_ALL]) +def test_if_any_all_are_not_bare_conditions(key: str) -> None: + """`any:` and `all:` under `if:` are the if action's own keys, not a bare condition.""" + result = validate_action({"if": {key: [{"or": []}], CONF_THEN: [{"delay": "1s"}]}}) + assert key in result["if"] + assert CONF_CONDITION not in result["if"] + + +@pytest.mark.parametrize( + ("value", "match"), + [ + pytest.param( + {"or": [], "and": [], CONF_THEN: []}, + "Only one condition", + id="two_bare", + ), + pytest.param( + {"or": [], CONF_CONDITION: {"and": []}, CONF_THEN: []}, + "cannot be used with 'condition'", + id="bare_and_wrapped", + ), + pytest.param( + {"or": [], CONF_ANY: [{"and": []}], CONF_THEN: []}, + "cannot be used with 'any'", + id="bare_and_any", + ), + ], +) +def test_if_rejects_ambiguous_bare_condition( + value: dict[str, object], match: str +) -> None: + with pytest.raises(cv.Invalid, match=match): + validate_action({"if": value}) + + +def test_if_with_non_dict_value_is_invalid() -> None: + with pytest.raises(cv.Invalid, match="expected a dictionary"): + validate_action({"if": "not a dict"}) + + +CHOICE = {"if": {"and": []}, CONF_THEN: [{"delay": "1s"}]} + + +@pytest.mark.parametrize( + ("value", "match"), + [ + pytest.param([], "at least 1", id="empty"), + pytest.param([{CONF_THEN: [{"delay": "1s"}]}], "'if' is required", id="no_if"), + pytest.param([{"if": {"and": []}}], "'then' is required", id="no_then"), + pytest.param( + [{CONF_ELSE: [{"delay": "1s"}]}], + "At least one choice must have 'if' and 'then'", + id="only_else", + ), + pytest.param( + [{CONF_ELSE: [{"delay": "1s"}]}, CHOICE], + "Only the last choice may be 'else'", + id="else_not_last", + ), + pytest.param( + [CHOICE, {"if": {"and": []}, CONF_ELSE: []}], + "'if' cannot be used with 'else'", + id="else_with_if", + ), + pytest.param( + [CHOICE, {CONF_THEN: [], CONF_ELSE: []}], + "'then' cannot be used with 'else'", + id="else_with_then", + ), + ], +) +def test_choose_invalid(value: list[ConfigType], match: str) -> None: + with pytest.raises(cv.Invalid, match=match): + validate_action({CONF_CHOOSE: value}) + + +def test_choose_valid() -> None: + result = validate_action( + { + CONF_CHOOSE: [ + CHOICE, + {"if": [{"or": []}, {"and": []}], CONF_THEN: [{"delay": "2s"}]}, + {CONF_ELSE: [{"delay": "3s"}]}, + ] + } + ) + + choices = result[CONF_CHOOSE] + assert len(choices) == 3 + assert "and" in choices[1]["if"] + assert CONF_ELSE in choices[2] + assert all(CONF_CHOICE_ID in choice for choice in choices) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("has_else", [True, False]) +async def test_choose_builds_if_else_chain(has_else: bool) -> None: + """Each choice is an IfAction whose else branch holds the next choice, then the else actions.""" + config: list[ConfigType] = [ + {"if": "cond_a", CONF_THEN: "then_a", CONF_CHOICE_ID: ID("choice_a")}, + {"if": "cond_b", CONF_THEN: "then_b", CONF_CHOICE_ID: ID("choice_b")}, + ] + if has_else: + config.append({CONF_ELSE: "else_c", CONF_CHOICE_ID: ID("choice_c")}) + action_id = ID("action") + with ( + patch( + "esphome.automation.build_condition", + new_callable=AsyncMock, + side_effect=lambda conf, *_: f"built_{conf}", + ), + patch( + "esphome.automation.build_action_list", + new_callable=AsyncMock, + side_effect=lambda conf, *_: [MockObj(f"built_{conf}")], + ), + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add") as add, + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + result = await choose_action_to_code(config, action_id, TEMPLATE_ARG, []) + + # Built from the last choice back; the first choice takes the action's own id + created = [ + (c.args[0].id, str(c.args[1]), c.args[2]) for c in new_pvariable.call_args_list + ] + assert created == [ + ("choice_b", f"<{str(has_else).lower()}>", "built_cond_b"), + ("action", "", "built_cond_a"), + ] + assert str(result) == "action" + statements = [str(c.args[0]) for c in add.call_args_list] + expected_b_else = ( + ["choice_b->add_else_action(built_else_c)", "choice_b->finish_else()"] + if has_else + else [] + ) + assert statements == [ + "choice_b->add_then_action(built_then_b)", + "choice_b->finish_then()", + *expected_b_else, + "action->add_then_action(built_then_a)", + "action->finish_then()", + "action->add_else_action(choice_b)", + "action->finish_else()", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("config", "template_arg", "expected"), + [ + pytest.param( + {CONF_CONDITION: "cond", CONF_THEN: "then", CONF_ELSE: "else"}, + "", + [ + "action->add_then_action(built_then)", + "action->finish_then()", + "action->add_else_action(built_else)", + "action->finish_else()", + ], + id="then_and_else", + ), + pytest.param( + {CONF_ANY: "cond", CONF_ELSE: "else"}, + "", + ["action->add_else_action(built_else)", "action->finish_else()"], + id="only_else", + ), + pytest.param( + {CONF_ALL: "cond", CONF_THEN: "then"}, + "", + ["action->add_then_action(built_then)", "action->finish_then()"], + id="only_then", + ), + ], +) +async def test_if_adds_branches_one_action_at_a_time( + config: ConfigType, template_arg: str, expected: list[str] +) -> None: + with ( + patch( + "esphome.automation.build_condition", + new_callable=AsyncMock, + side_effect=lambda conf, *_: f"built_{conf}", + ), + patch( + "esphome.automation.build_action_list", + new_callable=AsyncMock, + side_effect=lambda conf, *_: [MockObj(f"built_{conf}")], + ), + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add") as add, + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + await if_action_to_code(config, ID("action"), TEMPLATE_ARG, []) + + new_pvariable.assert_called_once() + assert str(new_pvariable.call_args.args[1]) == template_arg + assert new_pvariable.call_args.args[2] == "built_cond" + assert [str(c.args[0]) for c in add.call_args_list] == expected + + def test_shadow_iteration_leaves_other_names() -> None: args = [(cg.std_string, "prefix"), (cg.uint32, "iteration_1")] assert _shadow_iteration(args) == args From c0a693dfce2253565aaef43786aaa5277b85726e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 03:18:24 -1000 Subject: [PATCH 039/120] [components] Use xor_bytes and sum16_bytes for checksums (#20922) --- esphome/components/apc1/apc1.cpp | 13 ++---------- esphome/components/dfplayer/dfplayer.cpp | 5 +---- esphome/components/ld2420/ld2420.cpp | 9 ++------- esphome/components/ld6002b/ld6002b.cpp | 8 ++------ esphome/components/pmsa003i/pmsa003i.cpp | 5 +---- esphome/components/pmsx003/pmsx003.cpp | 10 ++-------- esphome/components/rdm6300/rdm6300.cpp | 4 +--- .../seeed_mr60fda2/seeed_mr60fda2.cpp | 20 ++----------------- .../components/shelly_dimmer/stm32flash.cpp | 14 ++++--------- esphome/components/toshiba/toshiba.cpp | 9 ++------- .../components/zwave_proxy/zwave_proxy.cpp | 10 ++-------- 11 files changed, 21 insertions(+), 86 deletions(-) diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp index beca9d85815..dee4bd8d73b 100644 --- a/esphome/components/apc1/apc1.cpp +++ b/esphome/components/apc1/apc1.cpp @@ -28,15 +28,6 @@ static constexpr uint32_t APC1_NO_DATA_TIMEOUT_MS = 15000; // Drop a partial frame when the bytes stop mid-way static constexpr uint32_t APC1_RX_IDLE_TIMEOUT_MS = 200; -// Frames and commands end with the 16-bit sum of every byte before it -static uint16_t apc1_checksum(const uint8_t *data, size_t len) { - uint16_t sum = 0; - for (size_t i = 0; i < len; i++) { - sum += data[i]; - } - return sum; -} - void APC1Component::setup() { if (this->set_pin_ != nullptr) { this->set_pin_->setup(); @@ -157,7 +148,7 @@ void APC1Component::loop() { void APC1Component::parse_frame_(uint16_t total_len) { uint16_t expected_checksum = encode_uint16(this->rx_buffer_[total_len - 2], this->rx_buffer_[total_len - 1]); - uint16_t calculated_checksum = apc1_checksum(this->rx_buffer_.data(), total_len - 2); + uint16_t calculated_checksum = sum16_bytes(this->rx_buffer_.data(), total_len - 2); if (expected_checksum != calculated_checksum) { ESP_LOGW(TAG, "APC1 checksum mismatch: calculated 0x%04X != expected 0x%04X", calculated_checksum, expected_checksum); @@ -343,7 +334,7 @@ void APC1Component::send_command_(APC1Command cmd, uint16_t data) { 0, 0, }; - uint16_t checksum = apc1_checksum(buf.data(), 5); + uint16_t checksum = sum16_bytes(buf.data(), 5); buf[5] = static_cast((checksum >> 8) & 0xFF); buf[6] = static_cast(checksum & 0xFF); this->write_array(buf); diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index c45e895b385..4b031274d26 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -124,10 +124,7 @@ void DFPlayer::play_folder(uint16_t folder, uint16_t file) { void DFPlayer::send_cmd_(uint8_t cmd, uint16_t argument) { uint8_t buffer[10]{0x7e, 0xff, 0x06, cmd, 0x01, (uint8_t) (argument >> 8), (uint8_t) argument, 0x00, 0x00, 0xef}; - uint16_t checksum = 0; - for (uint8_t i = 1; i < 7; i++) - checksum += buffer[i]; - checksum = -checksum; + const uint16_t checksum = static_cast(-sum16_bytes(buffer + 1, 6)); buffer[7] = checksum >> 8; buffer[8] = (uint8_t) checksum; diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index dec7e42de47..e81f117a1b3 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -161,13 +161,8 @@ template uint8_t find_uint8(const StringToUint8 (&arr)[N], const std:: return 0xFF; // Not found } -static uint8_t calc_checksum(void *data, size_t size) { - uint8_t checksum = 0; - uint8_t *data_bytes = (uint8_t *) data; - for (size_t i = 0; i < size; i++) { - checksum ^= data_bytes[i]; // XOR operation - } - return checksum; +static uint8_t calc_checksum(const void *data, size_t size) { + return xor_bytes(static_cast(data), size); } static int32_t get_firmware_int(const char *version_string) { diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 93a2ea341f4..42461228cef 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -1256,12 +1256,8 @@ void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t this->write_byte(static_cast(~header_xor)); if (len > 0 && data != nullptr) { - uint8_t data_xor = 0; - for (uint8_t i = 0; i < len; i++) { - this->write_byte(data[i]); - data_xor ^= data[i]; - } - this->write_byte(static_cast(~data_xor)); + this->write_array(data, len); + this->write_byte(static_cast(~xor_bytes(data, len))); } const uint32_t now = millis(); if (track) { diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 6885795e8a7..5ab0516785d 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -124,10 +124,7 @@ bool PMSA003IComponent::read_data_(PM25AQIData *data) { } // Calculate checksum - uint16_t checksum = 0; - for (uint8_t i = 0; i < CHECKSUM_START_INDEX; i++) { - checksum += buffer[i]; - } + const uint16_t checksum = sum16_bytes(buffer, CHECKSUM_START_INDEX); const uint16_t check = encode_uint16(buffer[CHECKSUM_START_INDEX], buffer[CHECKSUM_START_INDEX + 1]); if (checksum != check) { diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index f99e5adaa2c..efc80e8c650 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -159,10 +159,7 @@ optional PMSX003Component::check_byte_() { } // checksum is without checksum bytes - uint16_t checksum = 0; - for (uint16_t i = 0; i < total_size - 2; i++) { - checksum += this->data_[i]; - } + const uint16_t checksum = sum16_bytes(this->data_, total_size - 2); const uint16_t check = this->get_16_bit_uint_(total_size - 2); if (checksum != check) { @@ -207,10 +204,7 @@ void PMSX003Component::send_command_(Command cmd, uint16_t data) { }; // Calculate checksum - uint16_t checksum = 0; - for (uint8_t i = 0; i < 5; i++) { - checksum += send_data[i]; - } + const uint16_t checksum = sum16_bytes(send_data, 5); send_data[5] = (checksum >> 8) & 0xFF; // Verify Byte 1 send_data[6] = (checksum >> 0) & 0xFF; // Verify Byte 2 diff --git a/esphome/components/rdm6300/rdm6300.cpp b/esphome/components/rdm6300/rdm6300.cpp index 2bdbde91748..12fc9204cf6 100644 --- a/esphome/components/rdm6300/rdm6300.cpp +++ b/esphome/components/rdm6300/rdm6300.cpp @@ -39,9 +39,7 @@ void rdm6300::RDM6300Component::loop() { ESP_LOGW(TAG, "Invalid end byte from RDM6300!"); this->read_state_ = RDM6300_STATE_WAITING_FOR_START; } else { - uint8_t checksum = 0; - for (uint8_t i = 0; i < 5; i++) - checksum ^= this->buffer_[i]; + const uint8_t checksum = xor_bytes(this->buffer_, 5); this->read_state_ = RDM6300_STATE_WAITING_FOR_START; if (checksum != this->buffer_[5]) { ESP_LOGW(TAG, "Checksum from RDM6300 doesn't match! (0x%02X!=0x%02X)", checksum, this->buffer_[5]); diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index 6b5fb087171..d0e4c635eb2 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -62,24 +62,8 @@ void MR60FDA2Component::loop() { } } -/** - * @brief Calculate the checksum for a byte array. - * - * This function calculates the checksum for the provided byte array using an - * XOR-based checksum algorithm. - * - * @param data The byte array to calculate the checksum for. - * @param len The length of the byte array. - * @return The calculated checksum. - */ -static uint8_t calculate_checksum(const uint8_t *data, size_t len) { - uint8_t checksum = 0; - for (size_t i = 0; i < len; i++) { - checksum ^= data[i]; - } - checksum = ~checksum; - return checksum; -} +// The checksum is the inverted XOR of the bytes +static uint8_t calculate_checksum(const uint8_t *data, size_t len) { return ~xor_bytes(data, len); } /** * @brief Validate the checksum of a byte array. diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index 5996929ab84..9d3bd9f1e11 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -27,6 +27,7 @@ #include "debug.h" #include "dev_table.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -725,20 +726,13 @@ stm32_err_t stm32_write_memory(const stm32_unique_ptr &stm, uint32_t address, co return STM32_ERR_UNKNOWN; const unsigned int aligned_len = (len + 3) & ~3; - uint8_t cs = aligned_len - 1; uint8_t buf[256 + 2]; buf[0] = aligned_len - 1; - for (unsigned int i = 0; i < len; i++) { - cs ^= data[i]; - buf[i + 1] = data[i]; - } + memcpy(&buf[1], data, len); /* padding data */ - for (auto i = len; i < aligned_len; i++) { - cs ^= 0xFF; - buf[i + 1] = 0xFF; - } - buf[aligned_len + 1] = cs; + memset(&buf[1 + len], 0xFF, aligned_len - len); + buf[aligned_len + 1] = xor_bytes(buf, aligned_len + 1); stream->write_array(buf, aligned_len + 2); stream->flush(); diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index eae24641c40..2cf9419ef8e 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -544,9 +544,7 @@ void ToshibaClimate::transmit_generic_() { } // The last byte is the xor of all bytes from [4] - for (uint8_t i = 4; i < message_length - 1; i++) { - message[message_length - 1] ^= message[i]; - } + message[message_length - 1] = xor_bytes(message + 4, message_length - 5); // Transmit auto transmit = this->transmitter_->transmit(); @@ -1335,13 +1333,10 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { break; // Generic (old) Toshiba packet default: - uint8_t checksum = 0; // Add back the length of the header (we pruned it above) message_length += TOSHIBA_HEADER_LENGTH; // Validate the second checksum before trusting any more of the message - for (uint8_t i = TOSHIBA_HEADER_LENGTH; i < message_length - 1; i++) { - checksum ^= message[i]; - } + const uint8_t checksum = xor_bytes(message + TOSHIBA_HEADER_LENGTH, message_length - 1 - TOSHIBA_HEADER_LENGTH); // Did our computed checksum and the provided checksum match? if (checksum != message[message_length - 1]) { return false; diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index bfa16f857a0..a7b1fa0ced0 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -34,15 +34,9 @@ static constexpr bool is_bootloader_menu_byte(uint8_t byte) { return byte == 0 || byte == '\r' || byte == '\n' || (byte >= 0x20 && byte <= 0x7E); } -static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { - // Calculate Z-Wave frame checksum - // XOR all bytes between SOF and checksum position (exclusive) +static uint8_t calculate_frame_checksum(const uint8_t *data, size_t length) { // Initial value is 0xFF per Z-Wave protocol specification - uint8_t checksum = 0xFF; - for (uint8_t i = 1; i < length - 1; i++) { - checksum ^= data[i]; - } - return checksum; + return xor_bytes(data + 1, length - 2, 0xFF); } ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; } From cef9418a0f77a47410ee1e9a43a9b26026ee0e5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 03:20:28 -1000 Subject: [PATCH 040/120] [i2c][uart][spi] Add new_i2c_component, new_uart_component and new_spi_component (#20908) --- esphome/codegen.py | 1 + esphome/components/i2c/__init__.py | 11 ++++ esphome/components/spi/__init__.py | 12 ++++ esphome/components/uart/__init__.py | 12 ++++ esphome/cpp_helpers.py | 22 ++++++- .../components/test_new_bus_component.py | 54 +++++++++++++++++ tests/unit_tests/test_cpp_helpers.py | 58 +++++++++++++++++++ 7 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/components/test_new_bus_component.py diff --git a/esphome/codegen.py b/esphome/codegen.py index 9ed36de0d55..64f2ca9f04d 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -58,6 +58,7 @@ from esphome.cpp_helpers import ( # noqa: F401 extract_registry_entry_config, get_slot_count, gpio_pin_expression, + new_bus_component, past_safe_mode, register_component, register_parented, diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 5bf64609f53..84f6d0c2e66 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Coroutine import logging import re import sys @@ -394,6 +395,16 @@ async def register_i2c_device(var: MockObj, config: ConfigType) -> None: cg.add(var.set_i2c_address(config[CONF_ADDRESS])) +def new_i2c_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Create a component on an i2c bus: construct, register, attach to the bus. + + Extra args go to the constructor. + """ + return cg.new_bus_component(register_i2c_device, config, *args) + + def final_validate_device_schema( name: str, *, diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index d6949cc5909..92d5baccf76 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Coroutine import re from typing import Any @@ -504,6 +505,17 @@ async def register_spi_device( cg.add(var.set_psram_dma(psram_dma)) +def new_spi_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Create a component on an SPI bus: construct, register, attach to the bus. + + Extra args go to the constructor. Write-only devices call register_spi_device + themselves with write_only=True. + """ + return cg.new_bus_component(register_spi_device, config, *args) + + def final_validate_device_schema( name: str, *, require_mosi: bool, require_miso: bool ) -> cv.Schema: diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index fc811e385f4..dd6ae47bbfa 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,7 +1,9 @@ +from collections.abc import Coroutine from dataclasses import dataclass, field from logging import getLogger import math import re +from typing import Any from esphome import automation, pins import esphome.codegen as cg @@ -591,6 +593,16 @@ async def register_uart_device(var, config): cg.add(var.set_uart_parent(parent)) +def new_uart_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Create a component on a UART bus: construct, register, attach to the bus. + + Extra args go to the constructor. + """ + return cg.new_bus_component(register_uart_device, config, *args) + + @automation.register_action( "uart.write", UARTWriteAction, diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index fc44d27f472..c744b09728e 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,8 +1,10 @@ -from collections.abc import Callable, Hashable +from collections.abc import Awaitable, Callable, Hashable from dataclasses import dataclass, field import logging +from typing import Any from esphome.const import ( + CONF_ID, CONF_SAFE_MODE, CONF_SETUP_PRIORITY, CONF_TYPE_ID, @@ -12,11 +14,13 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, coroutine, coroutine_with_priority from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import ( + MockObj, RawStatement, add, add_define, add_global, get_variable, + new_Pvariable, ) from esphome.cpp_types import App from esphome.helpers import cpp_string_escape @@ -294,6 +298,22 @@ async def register_component(var, config): return var +async def new_bus_component( + register_device: Callable[[MockObj, ConfigType], Awaitable[None]], + config: ConfigType, + *args: Any, +) -> MockObj: + """Create a component, register it and attach it to its bus. + + Extra args go to the constructor. Bus wrappers must be plain functions + returning this coroutine, so the component source stays their caller. + """ + var = new_Pvariable(config[CONF_ID], *args) + await register_component(var, config) + await register_device(var, config) + return var + + async def register_parented(var, value): if isinstance(value, ID): paren = await get_variable(value) diff --git a/tests/unit_tests/components/test_new_bus_component.py b/tests/unit_tests/components/test_new_bus_component.py new file mode 100644 index 00000000000..54f947d6257 --- /dev/null +++ b/tests/unit_tests/components/test_new_bus_component.py @@ -0,0 +1,54 @@ +"""Tests for the new__component helpers.""" + +from collections.abc import Callable +import inspect +from typing import Any +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from esphome import cpp_helpers +from esphome.components import i2c, spi, uart +from esphome.const import CONF_ID + + +@pytest.mark.parametrize( + ("helper", "module", "register_name"), + [ + (i2c.new_i2c_component, i2c, "register_i2c_device"), + (uart.new_uart_component, uart, "register_uart_device"), + (spi.new_spi_component, spi, "register_spi_device"), + ], +) +@pytest.mark.asyncio +async def test_creates_registers_and_attaches_in_order( + helper: Callable[..., Any], module: Any, register_name: str +) -> None: + config = {CONF_ID: "my_id"} + var = MagicMock(name="var") + order = MagicMock() + order.new_Pvariable.return_value = var + with ( + patch.object(cpp_helpers, "new_Pvariable", order.new_Pvariable), + patch.object( + cpp_helpers, "register_component", AsyncMock(side_effect=order.register) + ), + patch.object(module, register_name, AsyncMock(side_effect=order.bus)), + ): + result = await helper(config, 1, "two") + + assert result is var + assert order.mock_calls == [ + call.new_Pvariable("my_id", 1, "two"), + call.register(var, config), + call.bus(var, config), + ] + + +@pytest.mark.parametrize( + "helper", + [i2c.new_i2c_component, uart.new_uart_component, spi.new_spi_component], +) +def test_wrappers_are_plain_functions(helper: Callable[..., Any]) -> None: + # An async wrapper would stay on the stack and become the component source. + assert not inspect.iscoroutinefunction(helper) diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 725c1daebb4..7f56faddfbc 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -1,4 +1,8 @@ +import importlib.util import logging +from pathlib import Path +import sys +from types import ModuleType from unittest.mock import Mock import pytest @@ -243,3 +247,57 @@ def test_slot_counter_request_after_emit_raises() -> None: assert _define_value("TEST_SLOT_COUNT_TOO_LATE") == "1" with pytest.raises(ValueError, match="TEST_SLOT_COUNT_TOO_LATE"): request() + + +def _load_module( + monkeypatch: pytest.MonkeyPatch, name: str, path: Path, source: str +) -> ModuleType: + path.write_text(source, encoding="utf-8") + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, name, module) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize( + ("wrapper", "expected"), + [ + # A plain wrapper returns the coroutine, so its frame is gone when + # register_component looks for the source; an async one is still on the stack. + ("def new_component(config):\n return ", "fake_dev"), + ("async def new_component(config):\n return await ", "fake_bus"), + ], +) +@pytest.mark.asyncio +async def test_new_bus_component_source_is_the_caller( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, wrapper: str, expected: str +) -> None: + bus = _load_module( + monkeypatch, + "esphome.components.fake_bus", + tmp_path / "fake_bus.py", + "from esphome import cpp_helpers as ch\n" + "async def register_device(var, config):\n" + " pass\n" + f"{wrapper}ch.new_bus_component(register_device, config)\n", + ) + dev = _load_module( + monkeypatch, + "esphome.components.fake_dev", + tmp_path / "fake_dev.py", + "async def to_code(new_component, config):\n" + " return await new_component(config)\n", + ) + base = Mock() + base.__str__ = lambda self: "foo.bar" + var = Mock(base=base) + monkeypatch.setattr(ch, "new_Pvariable", Mock(return_value=var)) + monkeypatch.setattr(ch, "CORE", Mock(component_ids=["foo.bar"], data={})) + monkeypatch.setattr(ch, "add", Mock()) + monkeypatch.setattr(ch, "App", Mock()) + source = Mock(return_value=1) + monkeypatch.setattr(ch, "register_component_source", source) + result = await dev.to_code(bus.new_component, {const.CONF_ID: "foo.bar"}) + assert result is var + source.assert_called_once_with(expected) From 91dfb92c60c30748ce0eaae8dd668c5380a9a29c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 03:22:29 -1000 Subject: [PATCH 041/120] [esp32_ble] Test ble.enabled, ble.enable and ble.disable (#20838) --- tests/components/esp32_ble/common.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/components/esp32_ble/common.yaml b/tests/components/esp32_ble/common.yaml index 76b35fc8f8b..b2114f5dc41 100644 --- a/tests/components/esp32_ble/common.yaml +++ b/tests/components/esp32_ble/common.yaml @@ -1,2 +1,13 @@ esp32_ble: io_capability: keyboard_only + +button: + - platform: template + name: Toggle BLE + on_press: + - if: + condition: ble.enabled + then: + - ble.disable: + else: + - ble.enable: From d08c845c65cef5fbb7e1367a22df917fab2f8c0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:36:49 -1000 Subject: [PATCH 042/120] [components] Create I2C devices with new_i2c_component (#20909) --- esphome/components/ade7880/sensor.py | 4 +--- esphome/components/ads1115/__init__.py | 5 +---- esphome/components/ags10/sensor.py | 5 +---- esphome/components/aht10/sensor.py | 5 +---- esphome/components/aic3204/audio_dac.py | 6 ++---- esphome/components/am2315c/sensor.py | 5 +---- esphome/components/am2320/sensor.py | 5 +---- esphome/components/apds9930/sensor.py | 5 +---- esphome/components/apds9960/__init__.py | 5 +---- esphome/components/as5600/__init__.py | 5 +---- esphome/components/as7341/sensor.py | 5 +---- esphome/components/as734x/__init__.py | 5 +---- esphome/components/at581x/__init__.py | 4 +--- esphome/components/bh1745/__init__.py | 5 +---- esphome/components/bm8563/time.py | 6 ++---- esphome/components/bme680/sensor.py | 5 +---- esphome/components/bme680_bsec/__init__.py | 4 +--- esphome/components/bmi160/sensor.py | 5 +---- esphome/components/bmp085/sensor.py | 5 +---- esphome/components/bq25186/__init__.py | 4 +--- esphome/components/bq27220/sensor.py | 5 +---- esphome/components/bq76972/__init__.py | 5 +---- esphome/components/ccs811/sensor.py | 5 +---- esphome/components/dht12/sensor.py | 5 +---- esphome/components/dps310/sensor.py | 5 +---- esphome/components/ds248x/__init__.py | 6 ++---- esphome/components/ds3231/__init__.py | 5 +---- esphome/components/ee895/sensor.py | 5 +---- esphome/components/emc2101/__init__.py | 6 ++---- esphome/components/ens210/sensor.py | 5 +---- esphome/components/es7210/audio_adc.py | 6 ++---- esphome/components/es7243e/audio_adc.py | 6 ++---- esphome/components/es8156/audio_dac.py | 4 +--- esphome/components/es8311/audio_dac.py | 6 ++---- esphome/components/es8388/audio_dac.py | 5 +---- esphome/components/es9038q2m_katana/audio_dac.py | 6 ++---- esphome/components/ezo_pmp/__init__.py | 4 +--- esphome/components/fusb302b/__init__.py | 5 +---- esphome/components/gdk101/__init__.py | 5 +---- esphome/components/gp8403/__init__.py | 6 ++---- esphome/components/grove_gas_mc_v2/sensor.py | 5 +---- esphome/components/grove_tb6612fng/__init__.py | 4 +--- esphome/components/hdc1080/sensor.py | 5 +---- esphome/components/hdc2010/sensor.py | 5 +---- esphome/components/hdc2080/sensor.py | 5 +---- esphome/components/hdc302x/sensor.py | 5 +---- esphome/components/hm3301/sensor.py | 5 +---- esphome/components/hmc5883l/sensor.py | 5 +---- esphome/components/honeywell_hih_i2c/sensor.py | 5 +---- esphome/components/honeywellabp2_i2c/sensor.py | 5 +---- esphome/components/hte501/sensor.py | 5 +---- esphome/components/htu21d/sensor.py | 5 +---- esphome/components/htu31d/sensor.py | 5 +---- esphome/components/husb238/__init__.py | 5 +---- esphome/components/hyt271/sensor.py | 5 +---- esphome/components/i2c_device/__init__.py | 4 +--- esphome/components/ina219/sensor.py | 5 +---- esphome/components/ina226/sensor.py | 5 +---- esphome/components/ina3221/sensor.py | 5 +---- esphome/components/kmeteriso/sensor.py | 5 +---- esphome/components/lc709203f/sensor.py | 5 +---- esphome/components/lp5562/__init__.py | 5 +---- esphome/components/lps22/sensor.py | 5 +---- esphome/components/ltr390/sensor.py | 5 +---- esphome/components/ltr501/sensor.py | 5 +---- esphome/components/ltr_als_ps/sensor.py | 5 +---- esphome/components/m5stack_8angle/__init__.py | 5 +---- esphome/components/max17043/sensor.py | 4 +--- esphome/components/max17048/sensor.py | 5 +---- esphome/components/max44009/sensor.py | 5 +---- esphome/components/max6956/__init__.py | 4 +--- esphome/components/max9611/sensor.py | 5 +---- esphome/components/mcp23016/__init__.py | 4 +--- esphome/components/mcp4461/__init__.py | 7 ++----- esphome/components/mcp4725/output.py | 4 +--- esphome/components/mcp4728/__init__.py | 5 +---- esphome/components/mcp47a1/output.py | 4 +--- esphome/components/mics_4514/sensor.py | 5 +---- esphome/components/mlx90393/sensor.py | 5 +---- esphome/components/mlx90614/sensor.py | 5 +---- esphome/components/mmc5603/sensor.py | 5 +---- esphome/components/mmc5983/sensor.py | 5 +---- esphome/components/mpl3115a2/sensor.py | 5 +---- esphome/components/mpu6050/sensor.py | 5 +---- esphome/components/mpu6886/sensor.py | 5 +---- esphome/components/ms5611/sensor.py | 5 +---- esphome/components/ms8607/sensor.py | 5 +---- esphome/components/msa3xx/__init__.py | 5 +---- esphome/components/nau7802/sensor.py | 4 +--- esphome/components/npi19/sensor.py | 5 +---- esphome/components/pca6416a/__init__.py | 4 +--- esphome/components/pca9505/__init__.py | 4 +--- esphome/components/pcf8574/__init__.py | 4 +--- esphome/components/pcm5122/audio_dac.py | 4 +--- esphome/components/pi4ioe5v6408/__init__.py | 4 +--- esphome/components/pm2005/sensor.py | 5 +---- esphome/components/pmsa003i/sensor.py | 5 +---- esphome/components/pmwcs3/sensor.py | 5 +---- esphome/components/qmc5883l/sensor.py | 5 +---- esphome/components/qmp6988/sensor.py | 5 +---- esphome/components/rx8111/time.py | 5 +---- esphome/components/rx8130/time.py | 5 +---- esphome/components/scd30/sensor.py | 5 +---- esphome/components/scd4x/sensor.py | 4 +--- esphome/components/sen5x/sensor.py | 4 +--- esphome/components/sen6x/sensor.py | 4 +--- esphome/components/sfa30/sensor.py | 5 +---- esphome/components/sfa40/sensor.py | 4 +--- esphome/components/sgp30/sensor.py | 5 +---- esphome/components/sgp4x/sensor.py | 5 +---- esphome/components/sht3xd/sensor.py | 5 +---- esphome/components/sht4x/sensor.py | 5 +---- esphome/components/shtcx/sensor.py | 5 +---- esphome/components/sps30/sensor.py | 4 +--- esphome/components/stcc4/sensor.py | 5 +---- esphome/components/sx1509/__init__.py | 4 +--- esphome/components/sy6970/__init__.py | 7 ++----- esphome/components/tas2780/audio_dac.py | 6 ++---- esphome/components/tas58xx/audio_dac.py | 4 +--- esphome/components/tca8418/__init__.py | 6 ++---- esphome/components/tca9548a/__init__.py | 4 +--- esphome/components/tca9555/__init__.py | 4 +--- esphome/components/tcs34725/sensor.py | 5 +---- esphome/components/tem3200/sensor.py | 5 +---- esphome/components/tfluna/__init__.py | 5 +---- esphome/components/tlc59208f/__init__.py | 5 +---- esphome/components/tsl2591/sensor.py | 5 +---- esphome/components/ttp229_lsf/__init__.py | 5 +---- esphome/components/veml7700/sensor.py | 5 +---- esphome/components/waveshare_io_ch32v003/__init__.py | 4 +--- esphome/components/xdb401/sensor.py | 5 +---- esphome/components/xgzp68xx/sensor.py | 5 +---- esphome/components/xl9535/__init__.py | 4 +--- tests/unit_tests/components/test_pcf8574.py | 4 +--- 134 files changed, 147 insertions(+), 507 deletions(-) diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index 93c279e2359..541d37603e1 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -282,9 +282,7 @@ async def power_channel(config: ConfigType) -> MockObj: 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) + var = await i2c.new_i2c_component(config) if irq0_pin := config.get(CONF_IRQ0_PIN): pin = await cg.gpio_pin_expression(irq0_pin) diff --git a/esphome/components/ads1115/__init__.py b/esphome/components/ads1115/__init__.py index 1b8405322fa..7358c88db7c 100644 --- a/esphome/components/ads1115/__init__.py +++ b/esphome/components/ads1115/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -27,8 +26,6 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_continuous_mode(config[CONF_CONTINUOUS_MODE])) diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 8fd18671f81..407245407a2 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -4,7 +4,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_ID, CONF_MODE, CONF_TVOC, CONF_VALUE, @@ -58,9 +57,7 @@ FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency= 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TVOC, var.set_tvoc) diff --git a/esphome/components/aht10/sensor.py b/esphome/components/aht10/sensor.py index 974e7d4df4c..6065d702bde 100644 --- a/esphome/components/aht10/sensor.py +++ b/esphome/components/aht10/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, CONF_VARIANT, DEVICE_CLASS_HUMIDITY, @@ -49,9 +48,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_variant(config[CONF_VARIANT])) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index b4942fe3908..52cd24b2021 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -3,7 +3,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MODE +from esphome.const import CONF_MODE from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -41,6 +41,4 @@ automation.register_apply_action( 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 i2c.new_i2c_component(config) diff --git a/esphome/components/am2315c/sensor.py b/esphome/components/am2315c/sensor.py index 39ded956c3f..9e9da3a34ce 100644 --- a/esphome/components/am2315c/sensor.py +++ b/esphome/components/am2315c/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -42,9 +41,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/am2320/sensor.py b/esphome/components/am2320/sensor.py index 30a1b1d8a34..b70ac70e6b2 100644 --- a/esphome/components/am2320/sensor.py +++ b/esphome/components/am2320/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/apds9930/sensor.py b/esphome/components/apds9930/sensor.py index 2b169271c68..9895985438d 100644 --- a/esphome/components/apds9930/sensor.py +++ b/esphome/components/apds9930/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_ILLUMINANCE, DEVICE_CLASS_ILLUMINANCE, STATE_CLASS_MEASUREMENT, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_led_drive(config[CONF_LED_DRIVE])) cg.add(var.set_proximity_gain(config[CONF_PROXIMITY_GAIN])) cg.add(var.set_ambient_gain(config[CONF_AMBIENT_LIGHT_GAIN])) diff --git a/esphome/components/apds9960/__init__.py b/esphome/components/apds9960/__init__.py index 8c19eec5ca1..e32984970f5 100644 --- a/esphome/components/apds9960/__init__.py +++ b/esphome/components/apds9960/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -60,9 +59,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_led_drive(config[CONF_LED_DRIVE])) cg.add(var.set_proximity_gain(config[CONF_PROXIMITY_GAIN])) cg.add(var.set_ambient_gain(config[CONF_AMBIENT_LIGHT_GAIN])) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index 3cc5ed5cc1a..d695e34235f 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_DIR_PIN, CONF_DIRECTION, CONF_HYSTERESIS, - CONF_ID, CONF_POWER_MODE, CONF_RANGE, CONF_WATCHDOG, @@ -209,9 +208,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_direction(config[CONF_DIRECTION])) cg.add(var.set_watchdog(config[CONF_WATCHDOG])) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index 3afab8422f9..65307def3d0 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR, CONF_GAIN, - CONF_ID, DEVICE_CLASS_EMPTY, ICON_BRIGHTNESS_5, STATE_CLASS_MEASUREMENT, @@ -97,9 +96,7 @@ SENSORS = { 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_gain(config[CONF_GAIN])) cg.add(var.set_atime(config[CONF_ATIME])) diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index 61eeaa5a35b..dc45ddd1308 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__init__.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CALIBRATION, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_TYPE, CONF_UPDATE_INTERVAL, ) @@ -166,9 +165,7 @@ def _per_band( 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) + var = await i2c.new_i2c_component(config, cg.RawExpression(f"&as734x::{chip}_CHIP")) 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/at581x/__init__.py b/esphome/components/at581x/__init__.py index fd1a8002921..2ba284bd223 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -73,9 +73,7 @@ CONFIG_SCHEMA = cv.All( 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 i2c.new_i2c_component(config) # Actions diff --git a/esphome/components/bh1745/__init__.py b/esphome/components/bh1745/__init__.py index a922340df71..daba7af4753 100644 --- a/esphome/components/bh1745/__init__.py +++ b/esphome/components/bh1745/__init__.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INTEGRATION_TIME, ) from esphome.types import ConfigType @@ -62,9 +61,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_adc_gain(config[CONF_GAIN])) cg.add(var.set_measurement_time(config[CONF_INTEGRATION_TIME])) diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index c28976eb872..6bbacf307a2 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -2,7 +2,7 @@ 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_DURATION, CONF_ID +from esphome.const import CONF_DURATION from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -50,7 +50,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index 73f48526be6..8883659e979 100644 --- a/esphome/components/bme680/sensor.py +++ b/esphome/components/bme680/sensor.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_GAS_RESISTANCE, CONF_HEATER, CONF_HUMIDITY, - CONF_ID, CONF_IIR_FILTER, CONF_OVERSAMPLING, CONF_PRESSURE, @@ -115,9 +114,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 55fd1c975f5..1562b4f33b4 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -73,9 +73,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_device_id(str(config[CONF_ID]))) cg.add(var.set_temperature_offset(config[CONF_TEMPERATURE_OFFSET])) diff --git a/esphome/components/bmi160/sensor.py b/esphome/components/bmi160/sensor.py index 9ad1b19fa0e..845a37e91f5 100644 --- a/esphome/components/bmi160/sensor.py +++ b/esphome/components/bmi160/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_GYROSCOPE_X, CONF_GYROSCOPE_Y, CONF_GYROSCOPE_Z, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, ICON_ACCELERATION_X, @@ -84,9 +83,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ACCELERATION_X, var.set_accel_x_sensor) diff --git a/esphome/components/bmp085/sensor.py b/esphome/components/bmp085/sensor.py index 5eb8dae7314..898faff8531 100644 --- a/esphome/components/bmp085/sensor.py +++ b/esphome/components/bmp085/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py index 03a100d9297..26799b8ce51 100644 --- a/esphome/components/bq25186/__init__.py +++ b/esphome/components/bq25186/__init__.py @@ -338,9 +338,7 @@ CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id( 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) + var = await i2c.new_i2c_component(config) if config[CONF_CONFIGURE_ON_BOOT]: cg.add(var.set_setup_registers(cg.ArrayInitializer(*pack_registers(config)))) diff --git a/esphome/components/bq27220/sensor.py b/esphome/components/bq27220/sensor.py index a3fcec22432..418632b2c67 100644 --- a/esphome/components/bq27220/sensor.py +++ b/esphome/components/bq27220/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CURRENT, - CONF_ID, CONF_TEMPERATURE, CONF_VOLTAGE, DEVICE_CLASS_BATTERY, @@ -108,9 +107,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/bq76972/__init__.py b/esphome/components/bq76972/__init__.py index 5ccf043c250..672d6428d26 100644 --- a/esphome/components/bq76972/__init__.py +++ b/esphome/components/bq76972/__init__.py @@ -4,7 +4,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_ID, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, @@ -75,9 +74,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED])) cg.add(var.set_reg_disable(config[CONF_REG_DISABLED])) diff --git a/esphome/components/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index 95e51c825a8..80948a6b433 100644 --- a/esphome/components/ccs811/sensor.py +++ b/esphome/components/ccs811/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BASELINE, CONF_ECO2, CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, CONF_TVOC, CONF_VERSION, @@ -61,9 +60,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ECO2, var.set_co2) diff --git a/esphome/components/dht12/sensor.py b/esphome/components/dht12/sensor.py index 88877b2dc26..677b158633a 100644 --- a/esphome/components/dht12/sensor.py +++ b/esphome/components/dht12/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -42,9 +41,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/dps310/sensor.py b/esphome/components/dps310/sensor.py index e3827b484c4..7d6ab7b66c2 100644 --- a/esphome/components/dps310/sensor.py +++ b/esphome/components/dps310/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -50,9 +49,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index 3a2cb34747a..02faa806f7a 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE +from esphome.const import CONF_SLEEP_PIN, CONF_TYPE from esphome.types import ConfigType CODEOWNERS = ["@tomwellnitz"] @@ -86,9 +86,7 @@ def get_channel_count(config: ConfigType) -> int: 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_active_pullup(config[CONF_ACTIVE_PULLUP])) cg.add(var.set_channel_count(get_channel_count(config))) diff --git a/esphome/components/ds3231/__init__.py b/esphome/components/ds3231/__init__.py index 262c951e80f..b3e496a883c 100644 --- a/esphome/components/ds3231/__init__.py +++ b/esphome/components/ds3231/__init__.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@linkedupbits"] @@ -23,9 +22,7 @@ CONFIG_SCHEMA = ( 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 i2c.new_i2c_component(config) automation.register_apply_action( diff --git a/esphome/components/ee895/sensor.py b/esphome/components/ee895/sensor.py index a7110bd6d2e..fb0f8c59551 100644 --- a/esphome/components/ee895/sensor.py +++ b/esphome/components/ee895/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CO2, - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -53,9 +52,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 024ff3c8937..19661dbb584 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_RESOLUTION +from esphome.const import CONF_INVERTED, CONF_RESOLUTION from esphome.types import ConfigType CODEOWNERS = ["@ellull"] @@ -71,9 +71,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) if pwm_config := config.get(CONF_PWM): cg.add(var.set_dac_mode(False)) diff --git a/esphome/components/ens210/sensor.py b/esphome/components/ens210/sensor.py index cb49986f7c5..a390b24eccb 100644 --- a/esphome/components/ens210/sensor.py +++ b/esphome/components/ens210/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/es7210/audio_adc.py b/esphome/components/es7210/audio_adc.py index c2dd40b3e86..09f575629d6 100644 --- a/esphome/components/es7210/audio_adc.py +++ b/esphome/components/es7210/audio_adc.py @@ -3,7 +3,7 @@ from esphome.components import esp32, i2c from esphome.components.audio_adc import AudioAdc from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_ESP32S2 import esphome.config_validation as cv -from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_MIC_GAIN, CONF_SAMPLE_RATE +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_MIC_GAIN, CONF_SAMPLE_RATE from esphome.core import CORE from esphome.types import ConfigType @@ -66,9 +66,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) if channel_gains := config.get(CONF_CHANNEL_GAINS): diff --git a/esphome/components/es7243e/audio_adc.py b/esphome/components/es7243e/audio_adc.py index 49161339822..c354ffcc6fb 100644 --- a/esphome/components/es7243e/audio_adc.py +++ b/esphome/components/es7243e/audio_adc.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_adc import AudioAdc import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MIC_GAIN +from esphome.const import CONF_MIC_GAIN from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -28,8 +28,6 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_mic_gain(config[CONF_MIC_GAIN])) diff --git a/esphome/components/es8156/audio_dac.py b/esphome/components/es8156/audio_dac.py index ebba75e70dd..e00f67571e6 100644 --- a/esphome/components/es8156/audio_dac.py +++ b/esphome/components/es8156/audio_dac.py @@ -48,6 +48,4 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/es8311/audio_dac.py b/esphome/components/es8311/audio_dac.py index 036ee93bd3e..785c91fe1c8 100644 --- a/esphome/components/es8311/audio_dac.py +++ b/esphome/components/es8311/audio_dac.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_MIC_GAIN, CONF_SAMPLE_RATE +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_MIC_GAIN, CONF_SAMPLE_RATE from esphome.types import ConfigType CODEOWNERS = ["@kroimon", "@kahrendt"] @@ -58,9 +58,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_mic_gain(config[CONF_MIC_GAIN])) diff --git a/esphome/components/es8388/audio_dac.py b/esphome/components/es8388/audio_dac.py index 2616cbfa536..ac1cc9f8177 100644 --- a/esphome/components/es8388/audio_dac.py +++ b/esphome/components/es8388/audio_dac.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@P4uLT"] @@ -22,6 +21,4 @@ CONFIG_SCHEMA = ( 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 i2c.new_i2c_component(config) diff --git a/esphome/components/es9038q2m_katana/audio_dac.py b/esphome/components/es9038q2m_katana/audio_dac.py index 0568e5e202a..5f2a5ab557e 100644 --- a/esphome/components/es9038q2m_katana/audio_dac.py +++ b/esphome/components/es9038q2m_katana/audio_dac.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import audio_dac, i2c import esphome.config_validation as cv -from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_SAMPLE_RATE +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_SAMPLE_RATE from esphome.types import ConfigType CONF_DEEMPHASIS = "deemphasis" @@ -73,9 +73,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_filter_shape(config[CONF_FILTER_SHAPE])) cg.add( diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index aed071e8cf5..9d2c90529c0 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -34,9 +34,7 @@ CONFIG_SCHEMA = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) EZO_PMP_NO_ARGS_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index c9dd24463b4..26bf49b28e3 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -4,7 +4,6 @@ from esphome.components import i2c, sensor, text_sensor import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, - CONF_ID, CONF_INTERRUPT_PIN, CONF_ON_CONNECT, CONF_ON_DISCONNECT, @@ -99,9 +98,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - var = cg.new_Pvariable(config[CONF_ID], interrupt_pin) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config, interrupt_pin) cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/gdk101/__init__.py b/esphome/components/gdk101/__init__.py index e0ede6136dc..4463da8e685 100644 --- a/esphome/components/gdk101/__init__.py +++ b/esphome/components/gdk101/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Szewcson"] @@ -29,6 +28,4 @@ CONFIG_SCHEMA = ( 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 i2c.new_i2c_component(config) diff --git a/esphome/components/gp8403/__init__.py b/esphome/components/gp8403/__init__.py index 59bab37d255..d19228fea74 100644 --- a/esphome/components/gp8403/__init__.py +++ b/esphome/components/gp8403/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MODEL, CONF_VOLTAGE +from esphome.const import CONF_MODEL, CONF_VOLTAGE from esphome.types import ConfigType CODEOWNERS = ["@jesserockz", "@sebydocky"] @@ -35,8 +35,6 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_model(config[CONF_MODEL])) cg.add(var.set_voltage(config[CONF_VOLTAGE])) diff --git a/esphome/components/grove_gas_mc_v2/sensor.py b/esphome/components/grove_gas_mc_v2/sensor.py index da687c4cd34..3378d94bf41 100644 --- a/esphome/components/grove_gas_mc_v2/sensor.py +++ b/esphome/components/grove_gas_mc_v2/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CARBON_MONOXIDE, CONF_ETHANOL, - CONF_ID, CONF_NITROGEN_DIOXIDE, CONF_TVOC, DEVICE_CLASS_CARBON_MONOXIDE, @@ -68,9 +67,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) for key in [CONF_TVOC, CONF_CARBON_MONOXIDE, CONF_NITROGEN_DIOXIDE, CONF_ETHANOL]: if sensor_config := config.get(key): diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index 0874613c5cf..501071f42b9 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -38,9 +38,7 @@ CONFIG_SCHEMA = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) automation.register_apply_action( diff --git a/esphome/components/hdc1080/sensor.py b/esphome/components/hdc1080/sensor.py index c35488c96c3..4f5dc69a6c9 100644 --- a/esphome/components/hdc1080/sensor.py +++ b/esphome/components/hdc1080/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/hdc2010/sensor.py b/esphome/components/hdc2010/sensor.py index 9837b9af333..93b469d20d8 100644 --- a/esphome/components/hdc2010/sensor.py +++ b/esphome/components/hdc2010/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/hdc2080/sensor.py b/esphome/components/hdc2080/sensor.py index 63625bbf948..3e511f6a880 100644 --- a/esphome/components/hdc2080/sensor.py +++ b/esphome/components/hdc2080/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -45,9 +44,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 604730f971a..94239ce0310 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -8,7 +8,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DURATION, CONF_HUMIDITY, - CONF_ID, CONF_POWER, CONF_POWER_MODE, CONF_TEMPERATURE, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temp_sensor) diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index d5e15f7c620..a8eefa4865a 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -6,7 +6,6 @@ from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE from esphome.components.const import CONF_AQI import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -89,9 +88,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) diff --git a/esphome/components/hmc5883l/sensor.py b/esphome/components/hmc5883l/sensor.py index 229d6c8b623..72d9148b62e 100644 --- a/esphome/components/hmc5883l/sensor.py +++ b/esphome/components/hmc5883l/sensor.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, CONF_HEADING, - CONF_ID, CONF_OVERSAMPLING, CONF_RANGE, CONF_UPDATE_INTERVAL, @@ -129,9 +128,7 @@ def auto_data_rate(config: ConfigType) -> MockObj: 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) cg.add(var.set_datarate(auto_data_rate(config))) diff --git a/esphome/components/honeywell_hih_i2c/sensor.py b/esphome/components/honeywell_hih_i2c/sensor.py index 8ab2cd16b8b..d06e19747f0 100644 --- a/esphome/components/honeywell_hih_i2c/sensor.py +++ b/esphome/components/honeywell_hih_i2c/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/honeywellabp2_i2c/sensor.py b/esphome/components/honeywellabp2_i2c/sensor.py index 7f41fe21939..2d0f212d812 100644 --- a/esphome/components/honeywellabp2_i2c/sensor.py +++ b/esphome/components/honeywellabp2_i2c/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -56,9 +55,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) if await sensors(CONF_PRESSURE, var.set_pressure_sensor): diff --git a/esphome/components/hte501/sensor.py b/esphome/components/hte501/sensor.py index 80d59862959..8218dc32cbc 100644 --- a/esphome/components/hte501/sensor.py +++ b/esphome/components/hte501/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index bde4b244b4a..6f8d95f63c8 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_HEATER, CONF_HUMIDITY, - CONF_ID, CONF_LEVEL, CONF_MODEL, CONF_STATUS, @@ -61,9 +60,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/htu31d/sensor.py b/esphome/components/htu31d/sensor.py index 41cc54686a4..5fed7143ed0 100644 --- a/esphome/components/htu31d/sensor.py +++ b/esphome/components/htu31d/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/husb238/__init__.py b/esphome/components/husb238/__init__.py index 92bf8c998ff..e0e4b576eb7 100644 --- a/esphome/components/husb238/__init__.py +++ b/esphome/components/husb238/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@latonita"] @@ -28,6 +27,4 @@ CONFIG_SCHEMA = ( 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 i2c.new_i2c_component(config) diff --git a/esphome/components/hyt271/sensor.py b/esphome/components/hyt271/sensor.py index 1fe5d9e14aa..1f13d16bfbc 100644 --- a/esphome/components/hyt271/sensor.py +++ b/esphome/components/hyt271/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/i2c_device/__init__.py b/esphome/components/i2c_device/__init__.py index a118cb3f172..a79b158b2fb 100644 --- a/esphome/components/i2c_device/__init__.py +++ b/esphome/components/i2c_device/__init__.py @@ -23,6 +23,4 @@ CONFIG_SCHEMA = cv.Schema( 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 i2c.new_i2c_component(config) diff --git a/esphome/components/ina219/sensor.py b/esphome/components/ina219/sensor.py index 0a81adc785e..98246b2a819 100644 --- a/esphome/components/ina219/sensor.py +++ b/esphome/components/ina219/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_MAX_CURRENT, CONF_MAX_VOLTAGE, CONF_POWER, @@ -72,9 +71,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_shunt_resistance_ohm(config[CONF_SHUNT_RESISTANCE])) cg.add(var.set_max_current_a(config[CONF_MAX_CURRENT])) diff --git a/esphome/components/ina226/sensor.py b/esphome/components/ina226/sensor.py index 53427fc63e1..40e2e41ffed 100644 --- a/esphome/components/ina226/sensor.py +++ b/esphome/components/ina226/sensor.py @@ -7,7 +7,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_MAX_CURRENT, CONF_POWER, CONF_SHUNT_RESISTANCE, @@ -115,9 +114,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_shunt_resistance_ohm(config[CONF_SHUNT_RESISTANCE])) cg.add(var.set_max_current_a(config[CONF_MAX_CURRENT])) diff --git a/esphome/components/ina3221/sensor.py b/esphome/components/ina3221/sensor.py index e872b73b09f..69e66a3382d 100644 --- a/esphome/components/ina3221/sensor.py +++ b/esphome/components/ina3221/sensor.py @@ -7,7 +7,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_POWER, CONF_SHUNT_RESISTANCE, CONF_SHUNT_VOLTAGE, @@ -199,9 +198,7 @@ def alert_limit_register(current_a: float, shunt_ohm: float) -> int: 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) + var = await i2c.new_i2c_component(config) bus_us, shunt_us = adc_times_us(config) register = ( diff --git a/esphome/components/kmeteriso/sensor.py b/esphome/components/kmeteriso/sensor.py index 5b7087362f9..d0637d27873 100644 --- a/esphome/components/kmeteriso/sensor.py +++ b/esphome/components/kmeteriso/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 67e5942a43b..8ebb14d2ca7 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BATTERY_VOLTAGE, - CONF_ID, CONF_SIZE, CONF_TEMPERATURE, CONF_VOLTAGE, @@ -72,9 +71,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_pack_size(config.get(CONF_SIZE))) cg.add(var.set_pack_voltage(BATTERY_VOLTAGE_OPTIONS[config[CONF_VOLTAGE]])) diff --git a/esphome/components/lp5562/__init__.py b/esphome/components/lp5562/__init__.py index 4a5b77f36bb..f6c9956d70f 100644 --- a/esphome/components/lp5562/__init__.py +++ b/esphome/components/lp5562/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@DennisGaida"] @@ -27,8 +26,6 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if config[CONF_LOGARITHMIC_DIMMING]: cg.add(var.set_logarithmic_dimming(True)) diff --git a/esphome/components/lps22/sensor.py b/esphome/components/lps22/sensor.py index f219639eeea..8590ec5fbeb 100644 --- a/esphome/components/lps22/sensor.py +++ b/esphome/components/lps22/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ltr390/sensor.py b/esphome/components/ltr390/sensor.py index 6c25729beec..69ee5020c48 100644 --- a/esphome/components/ltr390/sensor.py +++ b/esphome/components/ltr390/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_AMBIENT_LIGHT, CONF_GAIN, - CONF_ID, CONF_LIGHT, CONF_RESOLUTION, DEVICE_CLASS_EMPTY, @@ -119,9 +118,7 @@ TYPES = { 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_wfac_value(config[CONF_WINDOW_CORRECTION_FACTOR])) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index 51378e4cddf..ca9416f924f 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM_COUNTS, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INTEGRATION_TIME, CONF_NAME, CONF_REPEAT, @@ -225,9 +224,7 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 223528581e9..5ebaed1f249 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM_COUNTS, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INTEGRATION_TIME, CONF_NAME, CONF_REPEAT, @@ -215,9 +214,7 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) diff --git a/esphome/components/m5stack_8angle/__init__.py b/esphome/components/m5stack_8angle/__init__.py index 96b9782e811..ef686174659 100644 --- a/esphome/components/m5stack_8angle/__init__.py +++ b/esphome/components/m5stack_8angle/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -29,6 +28,4 @@ CONFIG_SCHEMA = cv.Schema( 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 i2c.new_i2c_component(config) diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index 1b621057c67..4fdc6fda544 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -49,9 +49,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BATTERY_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/max17048/sensor.py b/esphome/components/max17048/sensor.py index 30cd0ec59f0..40f676c9fe1 100644 --- a/esphome/components/max17048/sensor.py +++ b/esphome/components/max17048/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BATTERY_VOLTAGE, - CONF_ID, CONF_RATE, DEVICE_CLASS_BATTERY, DEVICE_CLASS_VOLTAGE, @@ -56,9 +55,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor) diff --git a/esphome/components/max44009/sensor.py b/esphome/components/max44009/sensor.py index 09b480335ec..a74dc8242ac 100644 --- a/esphome/components/max44009/sensor.py +++ b/esphome/components/max44009/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_MODE, DEVICE_CLASS_ILLUMINANCE, STATE_CLASS_MEASUREMENT, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) await sensor.register_sensor(var, config) cg.add(var.set_mode(config[CONF_MODE])) diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index b5d5041c968..6dd9dc0b09f 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -51,9 +51,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_brightness_mode(config[CONF_BRIGHTNESS_MODE])) cg.add(var.set_brightness_global(config[CONF_BRIGHTNESS_GLOBAL])) diff --git a/esphome/components/max9611/sensor.py b/esphome/components/max9611/sensor.py index e6309b21ccb..e1f705cdbab 100644 --- a/esphome/components/max9611/sensor.py +++ b/esphome/components/max9611/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, CONF_GAIN, - CONF_ID, CONF_POWER, CONF_SHUNT_RESISTANCE, CONF_TEMPERATURE, @@ -72,9 +71,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_current_resistor(config[CONF_SHUNT_RESISTANCE])) cg.add(var.set_gain(config[CONF_GAIN])) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 7dddf4b0422..ab77bc5a5b3 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -37,9 +37,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/mcp4461/__init__.py b/esphome/components/mcp4461/__init__.py index d5465098f29..8f12212b481 100644 --- a/esphome/components/mcp4461/__init__.py +++ b/esphome/components/mcp4461/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@p1ngb4ck"] @@ -33,12 +32,10 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable( - config[CONF_ID], + await i2c.new_i2c_component( + config, config[CONF_DISABLE_WIPER_0], config[CONF_DISABLE_WIPER_1], config[CONF_DISABLE_WIPER_2], config[CONF_DISABLE_WIPER_3], ) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) diff --git a/esphome/components/mcp4725/output.py b/esphome/components/mcp4725/output.py index 0c1224a1a7f..5dda2145b15 100644 --- a/esphome/components/mcp4725/output.py +++ b/esphome/components/mcp4725/output.py @@ -21,7 +21,5 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) await output.register_output(var, config) diff --git a/esphome/components/mcp4728/__init__.py b/esphome/components/mcp4728/__init__.py index 7156b4aa7fe..54678204bc3 100644 --- a/esphome/components/mcp4728/__init__.py +++ b/esphome/components/mcp4728/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@berfenger"] @@ -27,6 +26,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_STORE_IN_EEPROM]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config, config[CONF_STORE_IN_EEPROM]) diff --git a/esphome/components/mcp47a1/output.py b/esphome/components/mcp47a1/output.py index 91bb3b47dbb..2386ac12abb 100644 --- a/esphome/components/mcp47a1/output.py +++ b/esphome/components/mcp47a1/output.py @@ -22,7 +22,5 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) await output.register_output(var, config) diff --git a/esphome/components/mics_4514/sensor.py b/esphome/components/mics_4514/sensor.py index 3ba560c781a..1dee96e5abc 100644 --- a/esphome/components/mics_4514/sensor.py +++ b/esphome/components/mics_4514/sensor.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CARBON_MONOXIDE, CONF_ETHANOL, CONF_HYDROGEN, - CONF_ID, CONF_METHANE, CONF_NITROGEN_DIOXIDE, DEVICE_CLASS_CARBON_MONOXIDE, @@ -58,9 +57,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) for sensor_type in SENSORS: if sensor_config := config.get(sensor_type): diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index 4ff42c7d9c7..d6a777e7d88 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FILTER, CONF_GAIN, - CONF_ID, CONF_OVERSAMPLING, CONF_RESOLUTION, CONF_TEMPERATURE, @@ -129,9 +128,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_gain(GAIN[config[CONF_GAIN]])) cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) diff --git a/esphome/components/mlx90614/sensor.py b/esphome/components/mlx90614/sensor.py index e818ea708d0..8a4c4e90eff 100644 --- a/esphome/components/mlx90614/sensor.py +++ b/esphome/components/mlx90614/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, @@ -49,9 +48,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT, var.set_ambient_sensor) diff --git a/esphome/components/mmc5603/sensor.py b/esphome/components/mmc5603/sensor.py index 70c70877b75..874b774829e 100644 --- a/esphome/components/mmc5603/sensor.py +++ b/esphome/components/mmc5603/sensor.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, CONF_HEADING, - CONF_ID, CONF_UPDATE_INTERVAL, ICON_MAGNET, ICON_SCREEN_ROTATION, @@ -77,9 +76,7 @@ def auto_data_rate(config: ConfigType) -> MockObj: 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_datarate(auto_data_rate(config))) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/mmc5983/sensor.py b/esphome/components/mmc5983/sensor.py index 48c532b0c67..4ed73664bf7 100644 --- a/esphome/components/mmc5983/sensor.py +++ b/esphome/components/mmc5983/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_X, CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, - CONF_ID, ICON_MAGNET, STATE_CLASS_MEASUREMENT, UNIT_MICROTESLA, @@ -41,9 +40,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FIELD_STRENGTH_X, var.set_x_sensor) diff --git a/esphome/components/mpl3115a2/sensor.py b/esphome/components/mpl3115a2/sensor.py index 8235e2d6183..c97b8df2b32 100644 --- a/esphome/components/mpl3115a2/sensor.py +++ b/esphome/components/mpl3115a2/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ALTITUDE, - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PRESSURE, var.set_pressure) diff --git a/esphome/components/mpu6050/sensor.py b/esphome/components/mpu6050/sensor.py index bd3de2d32cd..093ededcd64 100644 --- a/esphome/components/mpu6050/sensor.py +++ b/esphome/components/mpu6050/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, ICON_BRIEFCASE_DOWNLOAD, @@ -66,9 +65,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ACCEL_X, var.set_accel_x_sensor) diff --git a/esphome/components/mpu6886/sensor.py b/esphome/components/mpu6886/sensor.py index 878540d5160..aa60ad962de 100644 --- a/esphome/components/mpu6886/sensor.py +++ b/esphome/components/mpu6886/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, ICON_BRIEFCASE_DOWNLOAD, @@ -67,9 +66,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ACCEL_X, var.set_accel_x_sensor) diff --git a/esphome/components/ms5611/sensor.py b/esphome/components/ms5611/sensor.py index bf6d4528e68..d6f7012985d 100644 --- a/esphome/components/ms5611/sensor.py +++ b/esphome/components/ms5611/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ms8607/sensor.py b/esphome/components/ms8607/sensor.py index 13377394beb..83609916ef4 100644 --- a/esphome/components/ms8607/sensor.py +++ b/esphome/components/ms8607/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, @@ -64,9 +63,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 3c491ffc898..4623e1d66ff 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -4,7 +4,6 @@ from esphome.components import i2c import esphome.config_validation as cv from esphome.const import ( CONF_CALIBRATION, - CONF_ID, CONF_MIRROR_X, CONF_MIRROR_Y, CONF_OFFSET_X, @@ -121,9 +120,7 @@ MSA_SENSOR_SCHEMA = cv.Schema( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_model(config[CONF_TYPE])) cg.add(var.set_range(MSA_RANGES[config[CONF_RANGE]])) diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 2647d40441a..ac46a9fb786 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -82,9 +82,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) await sensor.register_sensor(var, config) cg.add(var.set_samples_per_second(config[CONF_SAMPLES_PER_SECOND])) diff --git a/esphome/components/npi19/sensor.py b/esphome/components/npi19/sensor.py index 14dcd72384d..e3521383736 100644 --- a/esphome/components/npi19/sensor.py +++ b/esphome/components/npi19/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, @@ -40,9 +39,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 69d8935977c..33f8af9d528 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -41,9 +41,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py index d50e8205a17..4e3acb94e6e 100644 --- a/esphome/components/pca9505/__init__.py +++ b/esphome/components/pca9505/__init__.py @@ -41,9 +41,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 5ede39d7145..f1db7c29748 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -44,9 +44,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_pcf8575(config[CONF_PCF8575])) if (reset := config[CONF_RESET]) != DEFAULT_RESET: cg.add(var.set_reset(reset)) diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index 5091efabeac..cb50b101c13 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -133,9 +133,7 @@ async def pcm5122_pin_to_code(config: ConfigType) -> MockObj: 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index cd901cb448d..4128114b3cc 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -46,9 +46,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_reset(config[CONF_RESET])) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): diff --git a/esphome/components/pm2005/sensor.py b/esphome/components/pm2005/sensor.py index 0e36992141b..76ca2b6862b 100644 --- a/esphome/components/pm2005/sensor.py +++ b/esphome/components/pm2005/sensor.py @@ -4,7 +4,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -65,9 +64,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: """Code generation entry point.""" - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_sensor_type(config[CONF_TYPE])) diff --git a/esphome/components/pmsa003i/sensor.py b/esphome/components/pmsa003i/sensor.py index 14f27f89ccd..04e3b1a70af 100644 --- a/esphome/components/pmsa003i/sensor.py +++ b/esphome/components/pmsa003i/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -116,9 +115,7 @@ TYPES = { 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_standard_units(config[CONF_STANDARD_UNITS])) diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index 61f89afa41b..5420317716b 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, CONF_EC, - CONF_ID, CONF_TEMPERATURE, ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, @@ -63,9 +62,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_E25, var.set_e25_sensor) diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index 06091c4f1ca..deba7eab7e0 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, CONF_HEADING, - CONF_ID, CONF_OVERSAMPLING, CONF_RANGE, CONF_TEMPERATURE, @@ -143,9 +142,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) cg.add(var.set_datarate(config[CONF_DATA_RATE])) diff --git a/esphome/components/qmp6988/sensor.py b/esphome/components/qmp6988/sensor.py index 955e7026062..b357f03ea31 100644 --- a/esphome/components/qmp6988/sensor.py +++ b/esphome/components/qmp6988/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_IIR_FILTER, CONF_OVERSAMPLING, CONF_PRESSURE, @@ -78,9 +77,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if CONF_TEMPERATURE in config: conf = config[CONF_TEMPERATURE] diff --git a/esphome/components/rx8111/time.py b/esphome/components/rx8111/time.py index 0b166807a22..fc6a1cca0d5 100644 --- a/esphome/components/rx8111/time.py +++ b/esphome/components/rx8111/time.py @@ -4,7 +4,6 @@ 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"] @@ -30,7 +29,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index 2082c487593..964141596fd 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -2,7 +2,6 @@ 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 = ["@beormund"] @@ -34,7 +33,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index f3f4ede8c93..3c39f3e7c21 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_AUTOMATIC_SELF_CALIBRATION, CONF_CO2, CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, CONF_TEMPERATURE_OFFSET, CONF_UPDATE_INTERVAL, @@ -79,9 +78,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_automatic_self_calibration(config[CONF_AUTOMATIC_SELF_CALIBRATION])) if CONF_ALTITUDE_COMPENSATION in config: diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index e78ae2a6b22..12a07a3f63a 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -101,9 +101,7 @@ SETTING_MAP = { 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) + var = await i2c.new_i2c_component(config) for key, funcName in SETTING_MAP.items(): if key in config: diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index d45bb2c7916..b3fde64f820 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -219,9 +219,7 @@ SETTING_MAP = { 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) + var = await i2c.new_i2c_component(config) for key, funcName in SETTING_MAP.items(): if cfg := config.get(key): diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index a5c46801887..9893ef80233 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -183,9 +183,7 @@ SENSOR_MAP = { 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) + var = await i2c.new_i2c_component(config) if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) diff --git a/esphome/components/sfa30/sensor.py b/esphome/components/sfa30/sensor.py index b8039af9e3e..8a07b79e149 100644 --- a/esphome/components/sfa30/sensor.py +++ b/esphome/components/sfa30/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, @@ -68,9 +67,7 @@ SENSOR_MAP = { 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) + var = await i2c.new_i2c_component(config) for key, funcName in SENSOR_MAP.items(): if sensor_config := config.get(key): diff --git a/esphome/components/sfa40/sensor.py b/esphome/components/sfa40/sensor.py index 701660eb8fe..ca5f72de6dd 100644 --- a/esphome/components/sfa40/sensor.py +++ b/esphome/components/sfa40/sensor.py @@ -68,9 +68,7 @@ SENSOR_MAP = { async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_wait_for_ready(config[CONF_WAIT_FOR_READY])) for key, func_name in SENSOR_MAP.items(): diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index b56df3fb2e1..5d4c43fccae 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_BASELINE, CONF_COMPENSATION, CONF_ECO2, - CONF_ID, CONF_STORE_BASELINE, CONF_TEMPERATURE_SOURCE, CONF_TVOC, @@ -83,9 +82,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ECO2, var.set_eco2_sensor) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index bf8fbe1a975..866445f2464 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -11,7 +11,6 @@ from esphome.const import ( CONF_COMPENSATION, CONF_GAIN_FACTOR, CONF_GATING_MAX_DURATION_MINUTES, - CONF_ID, CONF_INDEX_OFFSET, CONF_LEARNING_TIME_GAIN_HOURS, CONF_LEARNING_TIME_OFFSET_HOURS, @@ -100,9 +99,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) if CONF_COMPENSATION in config: compensation_config = config[CONF_COMPENSATION] diff --git a/esphome/components/sht3xd/sensor.py b/esphome/components/sht3xd/sensor.py index baee146a22d..8d1aa5d7b4e 100644 --- a/esphome/components/sht3xd/sensor.py +++ b/esphome/components/sht3xd/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -50,9 +49,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_heater_enabled(config[CONF_HEATER_ENABLED])) diff --git a/esphome/components/sht4x/sensor.py b/esphome/components/sht4x/sensor.py index d491d94ba14..48c652224ab 100644 --- a/esphome/components/sht4x/sensor.py +++ b/esphome/components/sht4x/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -84,9 +83,7 @@ TYPES = { 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_precision_value(config[CONF_PRECISION])) cg.add(var.set_heater_power_value(config[CONF_HEATER_POWER])) diff --git a/esphome/components/shtcx/sensor.py b/esphome/components/shtcx/sensor.py index d6ca2b131bd..41d3e72b247 100644 --- a/esphome/components/shtcx/sensor.py +++ b/esphome/components/shtcx/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -47,9 +46,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index b8a4e080213..d86d5b7ecf5 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -117,9 +117,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py index 4385164384a..b103aa39534 100644 --- a/esphome/components/stcc4/sensor.py +++ b/esphome/components/stcc4/sensor.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE, CONF_CO2, CONF_HUMIDITY, - CONF_ID, CONF_MEASUREMENT_MODE, CONF_TEMPERATURE, CONF_TEMPERATURE_SOURCE, @@ -101,9 +100,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index f96479498d6..c44c52a6fc6 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -90,9 +90,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if conf := config.get(CONF_KEYPAD): cg.add(var.set_rows_cols(conf[CONF_KEY_ROWS], conf[CONF_KEY_COLUMNS])) if ( diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 7ed2b4fe869..23aaceb6a3c 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@linkedupbits"] @@ -61,8 +60,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable( - config[CONF_ID], + await i2c.new_i2c_component( + config, config[CONF_ENABLE_STATUS_LED], config[CONF_INPUT_CURRENT_LIMIT], config[CONF_CHARGE_VOLTAGE], @@ -72,5 +71,3 @@ async def to_code(config: ConfigType) -> None: config[CONF_ENABLE_ADC], config[CONF_I2C_WATCHDOG_TIMEOUT], ) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) diff --git a/esphome/components/tas2780/audio_dac.py b/esphome/components/tas2780/audio_dac.py index 9fbee8cc297..407ae55604e 100644 --- a/esphome/components/tas2780/audio_dac.py +++ b/esphome/components/tas2780/audio_dac.py @@ -4,7 +4,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_ID, CONF_POWER_MODE +from esphome.const import CONF_CHANNEL, CONF_POWER_MODE from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -97,9 +97,7 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_amp_level(config[CONF_AMP_LEVEL])) cg.add(var.set_power_mode(config[CONF_POWER_MODE])) diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 94d37832eb4..332d994d5e9 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -168,9 +168,7 @@ for _name, _call in ( async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL]] cg.add_define(f"USE_TAS58XX_{config[CONF_MODEL].upper()}") - var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&{model.model_info}")) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config, cg.RawExpression(f"&{model.model_info}")) cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) cg.add(var.set_dac_mode(config[CONF_DAC_MODE])) diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py index 67b38dc21ee..ee9decd6680 100644 --- a/esphome/components/tca8418/__init__.py +++ b/esphome/components/tca8418/__init__.py @@ -5,7 +5,7 @@ import esphome.codegen as cg from esphome.components import gpio_expander, i2c, key_provider from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_ON_KEY +from esphome.const import CONF_INTERRUPT_PIN, CONF_ON_KEY from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -131,9 +131,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) if (rows := config.get(CONF_ROWS)) is not None: columns = config[CONF_COLUMNS] diff --git a/esphome/components/tca9548a/__init__.py b/esphome/components/tca9548a/__init__.py index 368e34f3250..c28ceb015ea 100644 --- a/esphome/components/tca9548a/__init__.py +++ b/esphome/components/tca9548a/__init__.py @@ -78,9 +78,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) root_bus_id = _root_bus_id(config[CONF_I2C_ID]) if (frequency := config.get(CONF_FREQUENCY)) is not None: await _add_frequency(root_bus_id, int(frequency)) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index a039872e164..0554f9a569d 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -40,9 +40,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index de327f0eba7..12fc3475740 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -11,7 +11,6 @@ from esphome.const import ( CONF_COLOR_TEMPERATURE, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_ILLUMINANCE, CONF_INTEGRATION_TIME, DEVICE_CLASS_ILLUMINANCE, @@ -102,9 +101,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_integration_time(config[CONF_INTEGRATION_TIME])) cg.add(var.set_gain(config[CONF_GAIN])) diff --git a/esphome/components/tem3200/sensor.py b/esphome/components/tem3200/sensor.py index 2b1839c529f..7eb35ad5337 100644 --- a/esphome/components/tem3200/sensor.py +++ b/esphome/components/tem3200/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, @@ -42,9 +41,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/tfluna/__init__.py b/esphome/components/tfluna/__init__.py index 6914894ba78..7bd7e0f5c94 100644 --- a/esphome/components/tfluna/__init__.py +++ b/esphome/components/tfluna/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@candrews"] @@ -30,6 +29,4 @@ CONFIG_SCHEMA = ( 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 i2c.new_i2c_component(config) diff --git a/esphome/components/tlc59208f/__init__.py b/esphome/components/tlc59208f/__init__.py index 5d719e40ba2..c87478715f3 100644 --- a/esphome/components/tlc59208f/__init__.py +++ b/esphome/components/tlc59208f/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -23,6 +22,4 @@ CONFIG_SCHEMA = ( 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 i2c.new_i2c_component(config) diff --git a/esphome/components/tsl2591/sensor.py b/esphome/components/tsl2591/sensor.py index ee6ebaeb75c..7f4e9e43db8 100644 --- a/esphome/components/tsl2591/sensor.py +++ b/esphome/components/tsl2591/sensor.py @@ -31,7 +31,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INFRARED, CONF_INTEGRATION_TIME, CONF_NAME, @@ -136,9 +135,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FULL_SPECTRUM, var.set_full_spectrum_sensor) diff --git a/esphome/components/ttp229_lsf/__init__.py b/esphome/components/ttp229_lsf/__init__.py index e89b177a3ee..1d802ec5c67 100644 --- a/esphome/components/ttp229_lsf/__init__.py +++ b/esphome/components/ttp229_lsf/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -28,6 +27,4 @@ CONFIG_SCHEMA = ( 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 i2c.new_i2c_component(config) diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 9ed98fc4dfd..01e71aef26b 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM_COUNTS, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INFRARED, CONF_INTEGRATION_TIME, CONF_NAME, @@ -156,9 +155,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py index 5e53d30149b..0b89ca8b561 100644 --- a/esphome/components/waveshare_io_ch32v003/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -45,9 +45,7 @@ CONFIG_SCHEMA = ( 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 i2c.new_i2c_component(config) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/xdb401/sensor.py b/esphome/components/xdb401/sensor.py index df274ac09b3..d18122b70f8 100644 --- a/esphome/components/xdb401/sensor.py +++ b/esphome/components/xdb401/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -50,9 +49,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_pressure_range_bar(config[CONF_PRESSURE_RANGE_BAR])) diff --git a/esphome/components/xgzp68xx/sensor.py b/esphome/components/xgzp68xx/sensor.py index b67b2d8a9b4..778fb48647f 100644 --- a/esphome/components/xgzp68xx/sensor.py +++ b/esphome/components/xgzp68xx/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_OVERSAMPLING, CONF_PRESSURE, CONF_TEMPERATURE, @@ -67,9 +66,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 1c6548bb5fe..85330357d2d 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -33,9 +33,7 @@ CONFIG_SCHEMA = ( 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 i2c.new_i2c_component(config) def validate_mode(mode: ConfigType) -> ConfigType: diff --git a/tests/unit_tests/components/test_pcf8574.py b/tests/unit_tests/components/test_pcf8574.py index 43751815358..e5b6519700d 100644 --- a/tests/unit_tests/components/test_pcf8574.py +++ b/tests/unit_tests/components/test_pcf8574.py @@ -22,9 +22,7 @@ async def test_reset_configuration(options: dict, expected: bool) -> None: assert config[CONF_RESET] is expected var = Mock() with ( - patch.object(pcf8574.cg, "new_Pvariable", return_value=var), - patch.object(pcf8574.cg, "register_component", new_callable=AsyncMock), - patch.object(pcf8574.i2c, "register_i2c_device", new_callable=AsyncMock), + patch.object(pcf8574.i2c, "new_i2c_component", AsyncMock(return_value=var)), patch.object(pcf8574.cg, "add"), ): await pcf8574.to_code(config) From 905267a9c30aae83323e8d9f7dcdfddbaac804a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:38:25 -1000 Subject: [PATCH 043/120] [esp32_ble] Build ble.enabled, ble.enable and ble.disable with the apply helpers (#20839) --- esphome/components/esp32_ble/__init__.py | 29 +++++------------------- esphome/components/esp32_ble/ble.h | 21 ----------------- 2 files changed, 6 insertions(+), 44 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 95a83edf76f..6c83d5e44fe 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -296,10 +296,6 @@ NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] esp32_ble_ns = cg.esphome_ns.namespace("esp32_ble") ESP32BLE = esp32_ble_ns.class_("ESP32BLE", cg.Component) -BLEEnabledCondition = esp32_ble_ns.class_("BLEEnabledCondition", automation.Condition) -BLEEnableAction = esp32_ble_ns.class_("BLEEnableAction", automation.Action) -BLEDisableAction = esp32_ble_ns.class_("BLEDisableAction", automation.Action) - IoCapability = esp32_ble_ns.enum("IoCapability") IO_CAPABILITY = { "none": IoCapability.IO_CAP_NONE, @@ -613,27 +609,14 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_ESP32_BLE_UUID") -automation.register_bare_condition( - "ble.enabled", - BLEEnabledCondition, - cv.Schema({}), -) +BLE_AUTOMATION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(ESP32BLE)}) +automation.register_apply_condition("ble.enabled", BLE_AUTOMATION_SCHEMA, "is_active()") -automation.register_bare_action( - "ble.enable", - BLEEnableAction, - cv.Schema({}), - synchronous=True, -) - - -automation.register_bare_action( - "ble.disable", - BLEDisableAction, - cv.Schema({}), - synchronous=True, -) +for _name, _call in (("ble.enable", "enable()"), ("ble.disable", "disable()")): + automation.register_apply_action( + _name, BLE_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) # ble_advertising.cpp is fully #ifdef'd on USE_ESP32_BLE_ADVERTISING, set diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index ef326e1532d..c21daabdbaa 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -259,27 +259,6 @@ class ESP32BLE final : public Component { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern ESP32BLE *global_ble; -template class BLEEnabledCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_ble != nullptr && global_ble->is_active(); } -}; - -template class BLEEnableAction final : public Action { - public: - void play(const Ts &...x) override { - if (global_ble != nullptr) - global_ble->enable(); - } -}; - -template class BLEDisableAction final : public Action { - public: - void play(const Ts &...x) override { - if (global_ble != nullptr) - global_ble->disable(); - } -}; - } // namespace esphome::esp32_ble #endif From a384aae5b18a2cae264f34e8357e5501ed3a42a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:38:45 -1000 Subject: [PATCH 044/120] [button] Add factory reset, restart and query button schema presets (#20915) --- esphome/components/button/__init__.py | 39 +++++++++++++ tests/unit_tests/components/test_button.py | 65 ++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/unit_tests/components/test_button.py diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index f27475bdca3..a4543c06c07 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -15,6 +15,11 @@ from esphome.const import ( DEVICE_CLASS_IDENTIFY, DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_DATABASE, + ICON_RESTART, + ICON_RESTART_ALERT, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( @@ -83,6 +88,40 @@ def button_schema( return _BUTTON_SCHEMA.extend(schema) +def factory_reset_button_schema(class_: MockObjClass) -> cv.Schema: + """Schema for a button that resets the connected module to factory settings. + + For a sensor or radio module's own reset, not the ESPHome node. + """ + return button_schema( + class_, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART_ALERT, + ) + + +def restart_button_schema(class_: MockObjClass) -> cv.Schema: + """Schema for a button that restarts the connected module. + + For a sensor or radio module's own restart, not the ESPHome node; the core + restart button uses the config category instead of diagnostic. + """ + return button_schema( + class_, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_RESTART, + ) + + +def query_button_schema(class_: MockObjClass) -> cv.Schema: + """Schema for a button that reads the settings back from the connected module.""" + return button_schema( + class_, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_DATABASE + ) + + _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation(CONF_ON_PRESS, "add_on_press_callback"), ) diff --git a/tests/unit_tests/components/test_button.py b/tests/unit_tests/components/test_button.py new file mode 100644 index 00000000000..3d1c3b43d47 --- /dev/null +++ b/tests/unit_tests/components/test_button.py @@ -0,0 +1,65 @@ +"""Tests for the button schema presets.""" + +from collections.abc import Callable + +import pytest +import voluptuous as vol + +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ENTITY_CATEGORY, + CONF_ICON, + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_DATABASE, + ICON_RESTART, + ICON_RESTART_ALERT, +) + +MyButton = button.button_ns.class_("MyButton", button.Button) + + +def _defaults(schema: cv.Schema) -> dict[str, str]: + return { + str(key): key.default() + for key in schema.schema + if str(key) in (CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_ICON) + and key.default is not vol.UNDEFINED + } + + +@pytest.mark.parametrize( + ("preset", "expected"), + [ + ( + button.factory_reset_button_schema, + { + CONF_DEVICE_CLASS: DEVICE_CLASS_RESTART, + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_CONFIG, + CONF_ICON: ICON_RESTART_ALERT, + }, + ), + ( + button.restart_button_schema, + { + CONF_DEVICE_CLASS: DEVICE_CLASS_RESTART, + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC, + CONF_ICON: ICON_RESTART, + }, + ), + ( + button.query_button_schema, + { + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC, + CONF_ICON: ICON_DATABASE, + }, + ), + ], +) +def test_preset_defaults( + preset: Callable[..., cv.Schema], expected: dict[str, str] +) -> None: + assert _defaults(preset(MyButton)) == expected From 73af6b05006da1331d72d6dce3088d49bc7ae2c1 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:40:39 +0200 Subject: [PATCH 045/120] [core][scheduler] run process_to_add after component phase (#20993) --- esphome/core/application.h | 2 + .../__init__.py | 23 ++++++++++ .../scheduler_loop_phase_component.cpp | 21 +++++++++ .../scheduler_loop_phase_component.h | 15 ++++++ .../scheduler_loop_phase_timeout.yaml | 20 ++++++++ .../test_scheduler_loop_phase_timeout.py | 46 +++++++++++++++++++ 6 files changed, 127 insertions(+) create mode 100644 tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp create mode 100644 tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h create mode 100644 tests/integration/fixtures/scheduler_loop_phase_timeout.yaml create mode 100644 tests/integration/test_scheduler_loop_phase_timeout.py diff --git a/esphome/core/application.h b/esphome/core/application.h index f9f98a67070..b19d4ae746b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -784,6 +784,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #ifdef USE_RUNTIME_STATS loop_tail_start_us = micros(); #endif + // process scheduler items added in component phase + this->scheduler.process_to_add(); this->last_loop_ = last_op_end_time; now = last_op_end_time; // phase_guard destructor clears in_loop_ at scope exit diff --git a/tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py new file mode 100644 index 00000000000..5aaf79a186b --- /dev/null +++ b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@esphome/tests"] + +scheduler_loop_phase_component_ns = cg.esphome_ns.namespace( + "scheduler_loop_phase_component" +) +SchedulerLoopPhaseComponent = scheduler_loop_phase_component_ns.class_( + "SchedulerLoopPhaseComponent", cg.Component +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(SchedulerLoopPhaseComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp new file mode 100644 index 00000000000..5969dee384b --- /dev/null +++ b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp @@ -0,0 +1,21 @@ +#include "scheduler_loop_phase_component.h" + +#include "esphome/core/log.h" + +namespace esphome::scheduler_loop_phase_component { + +static const char *const TAG = "scheduler_loop_phase_component"; + +void SchedulerLoopPhaseComponent::loop() { + // There might be additional wakeups after the first loop phase because the test harness is connecting. + // The second component phase runs about 5s after boot, so the timeout is queued just before a long sleep. + if (++this->loop_count_ != 2) + return; + + const uint32_t start_time = millis(); + this->set_timeout(100, [start_time]() { + ESP_LOGI(TAG, "SCHEDULER_LOOP_PHASE_RESULT elapsed=%u", static_cast(millis() - start_time)); + }); +} + +} // namespace esphome::scheduler_loop_phase_component diff --git a/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h new file mode 100644 index 00000000000..aa65a19e901 --- /dev/null +++ b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::scheduler_loop_phase_component { + +class SchedulerLoopPhaseComponent : public Component { + public: + void loop() override; + + protected: + uint8_t loop_count_{0}; +}; + +} // namespace esphome::scheduler_loop_phase_component diff --git a/tests/integration/fixtures/scheduler_loop_phase_timeout.yaml b/tests/integration/fixtures/scheduler_loop_phase_timeout.yaml new file mode 100644 index 00000000000..2d2d1dc677d --- /dev/null +++ b/tests/integration/fixtures/scheduler_loop_phase_timeout.yaml @@ -0,0 +1,20 @@ +esphome: + name: scheduler-loop-phase-timeout + on_boot: + priority: -100 + then: + - lambda: |- + App.set_loop_interval(5000); + +host: +api: +logger: + level: INFO + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [scheduler_loop_phase_component] + +scheduler_loop_phase_component: diff --git a/tests/integration/test_scheduler_loop_phase_timeout.py b/tests/integration/test_scheduler_loop_phase_timeout.py new file mode 100644 index 00000000000..a8b2e76a863 --- /dev/null +++ b/tests/integration/test_scheduler_loop_phase_timeout.py @@ -0,0 +1,46 @@ +"""A timeout scheduled by a component loop must affect the current sleep.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +import pytest + +from .conftest import run_binary +from .types import CompileFunction, ConfigWriter + + +@pytest.mark.asyncio +async def test_scheduler_loop_phase_timeout( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, +) -> None: + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + loop = asyncio.get_running_loop() + timeout_result: asyncio.Future[int] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"SCHEDULER_LOOP_PHASE_RESULT elapsed=(\d+)", line) + if match and not timeout_result.done(): + timeout_result.set_result(int(match.group(1))) + + async with run_binary(binary_path, line_callback=on_log_line): + try: + elapsed = await asyncio.wait_for(timeout_result, timeout=15.0) + except TimeoutError: + pytest.fail("loop-phase timeout did not fire within 15 seconds") + + assert elapsed < 1000, ( + f"loop-phase timeout should interrupt the five-second sleep, but fired after {elapsed}ms" + ) From 34f9152d61b98d85b95d8097020746906e36baf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:41:20 -1000 Subject: [PATCH 046/120] [i2c] Add write_registers for constant register tables (#20945) --- esphome/components/i2c/i2c.cpp | 14 +++++ esphome/components/i2c/i2c.h | 14 +++++ .../i2c/test_i2c_write_registers.cpp | 60 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 tests/components/i2c/test_i2c_write_registers.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 59b0cb744fb..61382ccf21e 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -53,6 +53,20 @@ ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, return this->bus_->write_readv(this->address_, buffer, len + 2, nullptr, 0); } +bool I2CDevice::write_registers(const I2CRegValue *table, size_t count) const { + for (size_t i = 0; i < count; i++) { + I2CRegValue entry; + progmem_memcpy(&entry, &table[i], sizeof(entry)); + if (this->bus_->write_readv(this->address_, reinterpret_cast(&entry), sizeof(entry), nullptr, 0) != + ERROR_OK) { + ESP_LOGW(TAG, "Writing register 0x%02X (table entry %u) to 0x%02X failed", entry.reg, static_cast(i), + this->address_); + return false; + } + } + return true; +} + bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { if (read_register(a_register, reinterpret_cast(data), len * 2) != ERROR_OK) return false; diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 4546047d603..6a9a6533436 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -14,6 +14,13 @@ namespace esphome::i2c { class I2CDevice; // forward declaration +/// One register write for I2CDevice::write_registers(); tables of these may live in PROGMEM. +struct I2CRegValue { + uint8_t reg; + uint8_t value; +}; +static_assert(sizeof(I2CRegValue) == 2, "each entry is sent to the bus as is"); + /// Out of line so each check_chip_id() caller only pays for a call. void log_wrong_chip_id(uint8_t id, uint8_t expected); @@ -213,6 +220,13 @@ class I2CDevice { /// @return an i2c::ErrorCode ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len) const; + /// Write each register and value of \p table in order, stopping at the first failure. + /// The table may be in PROGMEM. + bool write_registers(const I2CRegValue *table, size_t count) const; + template bool write_registers(const I2CRegValue (&table)[N]) const { + return this->write_registers(table, N); + } + /// Read the 8-bit ID at \p a_register and accept it when it matches \p expected in the bits set in \p mask. /// Otherwise mark \p component failed and return false. bool check_chip_id(Component *component, uint8_t a_register, uint8_t expected, uint8_t mask = 0xFF) { diff --git a/tests/components/i2c/test_i2c_write_registers.cpp b/tests/components/i2c/test_i2c_write_registers.cpp new file mode 100644 index 00000000000..133c86dcc0a --- /dev/null +++ b/tests/components/i2c/test_i2c_write_registers.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/hal.h" + +namespace esphome::i2c::testing { + +class RegTableBus : public I2CBus { + public: + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + EXPECT_EQ(address, 0x18); + EXPECT_EQ(write_count, 2u); + EXPECT_EQ(read_count, 0u); + // Only this attempt fails, so any write after it would be recorded + if (this->attempts++ == this->fail_at) + return ERROR_NOT_ACKNOWLEDGED; + this->writes.emplace_back(write_buffer[0], write_buffer[1]); + return ERROR_OK; + } + + std::vector> writes; + size_t fail_at{SIZE_MAX}; + size_t attempts{0}; +}; + +static constexpr I2CRegValue INIT[] PROGMEM = {{0x01, 0x3A}, {0x00, 0x80}, {0xF9, 0x00}, {0x01, 0x00}}; + +class I2CWriteRegistersTest : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x18); + } + RegTableBus bus_; + I2CDevice device_; +}; + +TEST_F(I2CWriteRegistersTest, WritesEveryEntryInOrder) { + ASSERT_TRUE(this->device_.write_registers(INIT)); + const std::vector> expected{{0x01, 0x3A}, {0x00, 0x80}, {0xF9, 0x00}, {0x01, 0x00}}; + EXPECT_EQ(this->bus_.writes, expected); +} + +TEST_F(I2CWriteRegistersTest, StopsAtFirstFailure) { + this->bus_.fail_at = 2; + EXPECT_FALSE(this->device_.write_registers(INIT)); + const std::vector> expected{{0x01, 0x3A}, {0x00, 0x80}}; + EXPECT_EQ(this->bus_.writes, expected); +} + +TEST_F(I2CWriteRegistersTest, EmptyTableSucceeds) { + EXPECT_TRUE(this->device_.write_registers(INIT, 0)); + EXPECT_TRUE(this->bus_.writes.empty()); +} + +} // namespace esphome::i2c::testing From 62f8cc343674136751941c5dbcf62b5690788830 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:41:51 -1000 Subject: [PATCH 047/120] [uart] Add read_available helpers to drain available bytes in chunks (#20947) --- esphome/components/uart/uart.h | 54 +++++++ tests/components/uart/uart_read_available.cpp | 144 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 tests/components/uart/uart_read_available.cpp diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index 7a34a20583e..a0840aa26a7 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -1,5 +1,8 @@ #pragma once +#include +#include +#include #include #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -48,6 +51,45 @@ class UARTDevice { size_t available() { return this->parent_->available(); } + /// Pass the bytes available now to on_chunk(data, len) in N byte chunks; returning false stops. + /// Returns false only on a read error. + template bool read_available_chunks(F &&on_chunk) { + return this->read_available_chunks(on_chunk, this->available()); + } + /// Same, reading exactly count bytes; count must not exceed available(). + template bool read_available_chunks(F &&on_chunk, size_t count) { + static_assert(N > 0, "read buffer size must be non-zero"); + uint8_t buf[N]; + while (count > 0) { + const size_t len = std::min(count, N); + if (!this->read_array(buf, len)) { + return false; + } + count -= len; + if (!continue_after(on_chunk, static_cast(buf), len)) { + break; + } + } + return true; + } + + /// Same as read_available_chunks(), calling on_byte(uint8_t) once per byte. + template bool read_available(F &&on_byte) { + return this->read_available(on_byte, this->available()); + } + template bool read_available(F &&on_byte, size_t count) { + return this->read_available_chunks( + [&on_byte](const uint8_t *data, size_t len) { + for (const uint8_t *p = data; p != data + len; p++) { + if (!continue_after(on_byte, *p)) { + return false; + } + } + return true; + }, + count); + } + UARTFlushResult flush() { return this->parent_->flush(); } // Compat APIs @@ -74,6 +116,18 @@ class UARTDevice { UARTParityOptions parity = UART_CONFIG_PARITY_NONE, uint8_t data_bits = 8); protected: + /// Call f(args...) and return its result; a void callback always continues. + template static bool continue_after(F &f, A... args) { + using R = std::invoke_result_t; + if constexpr (std::is_void_v) { + f(args...); + return true; + } else { + static_assert(std::is_same_v, bool>, "callback must return void or bool"); + return f(args...); + } + } + UARTComponent *parent_{nullptr}; }; diff --git a/tests/components/uart/uart_read_available.cpp b/tests/components/uart/uart_read_available.cpp new file mode 100644 index 00000000000..10c3e7fa0ef --- /dev/null +++ b/tests/components/uart/uart_read_available.cpp @@ -0,0 +1,144 @@ +#include +#include "common.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::uart::testing { + +// Feeds bytes from a fixed vector so available() and read_array() agree like a real UART. +class FakeRxUART : public MockUARTComponent { + public: + explicit FakeRxUART(std::vector rx) : rx_(std::move(rx)) { + ON_CALL(*this, available()).WillByDefault(Invoke([this]() { return this->rx_.size() - this->pos_; })); + ON_CALL(*this, read_array(_, _)).WillByDefault(Invoke([this](uint8_t *data, size_t len) { + this->read_sizes.push_back(len); + if (this->fail_after_reads >= 0 && static_cast(this->read_sizes.size()) > this->fail_after_reads) + return false; + if (len > this->rx_.size() - this->pos_) + return false; + std::memcpy(data, this->rx_.data() + this->pos_, len); + this->pos_ += len; + return true; + })); + } + void append(std::initializer_list more) { this->rx_.insert(this->rx_.end(), more); } + size_t remaining() const { return this->rx_.size() - this->pos_; } + + std::vector read_sizes; + int fail_after_reads{-1}; + + protected: + std::vector rx_; + size_t pos_{0}; +}; + +static std::vector make_bytes(size_t n) { + std::vector v(n); + for (size_t i = 0; i < n; i++) + v[i] = static_cast(i); + return v; +} + +TEST(UARTReadAvailableTest, NothingAvailableDoesNotRead) { + ::testing::NiceMock uart(std::vector{}); + UARTDevice dev(&uart); + int calls = 0; + EXPECT_TRUE(dev.read_available([&calls](uint8_t) { calls++; })); + EXPECT_EQ(calls, 0); + EXPECT_TRUE(uart.read_sizes.empty()); +} + +TEST(UARTReadAvailableTest, ReadsEveryByteInChunks) { + ::testing::NiceMock uart(make_bytes(10)); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<4>([&got](uint8_t b) { got.push_back(b); })); + EXPECT_EQ(got, make_bytes(10)); + EXPECT_EQ(uart.read_sizes, (std::vector{4, 4, 2})); +} + +TEST(UARTReadAvailableTest, OnlyBytesAvailableOnEntry) { + ::testing::NiceMock uart(std::vector{1, 2, 3}); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<2>([&](uint8_t b) { + got.push_back(b); + if (b == 1) + uart.append({9, 9}); // arrives while draining; left for the next call + })); + EXPECT_EQ(got, (std::vector{1, 2, 3})); + EXPECT_EQ(uart.remaining(), 2u); +} + +TEST(UARTReadAvailableTest, CountReadsExactlyThatMany) { + ::testing::NiceMock uart(make_bytes(100)); + UARTDevice dev(&uart); + size_t count = 0; + EXPECT_TRUE(dev.read_available<16>([&count](uint8_t) { count++; }, 40)); + EXPECT_EQ(count, 40u); + EXPECT_EQ(uart.read_sizes, (std::vector{16, 16, 8})); + EXPECT_EQ(uart.remaining(), 60u); +} + +TEST(UARTReadAvailableTest, ReadFailureStopsAndReturnsFalse) { + ::testing::NiceMock uart(make_bytes(10)); + uart.fail_after_reads = 1; + UARTDevice dev(&uart); + std::vector got; + EXPECT_FALSE(dev.read_available<4>([&got](uint8_t b) { got.push_back(b); })); + EXPECT_EQ(got, (std::vector{0, 1, 2, 3})); + EXPECT_EQ(uart.read_sizes.size(), 2u); +} + +TEST(UARTReadAvailableTest, ByteCallbackReturningFalseStops) { + ::testing::NiceMock uart(make_bytes(10)); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<4>([&got](uint8_t b) { + got.push_back(b); + return b != 5; + })); + // Stops mid chunk: bytes 6 and 7 were read from the UART but not handed over, 8 and 9 were never read. + EXPECT_EQ(got, (std::vector{0, 1, 2, 3, 4, 5})); + EXPECT_EQ(uart.remaining(), 2u); +} + +TEST(UARTReadAvailableTest, ChunkCallbackGetsWholeChunks) { + ::testing::NiceMock uart(make_bytes(70)); + UARTDevice dev(&uart); + std::vector lens; + std::vector got; + EXPECT_TRUE(dev.read_available_chunks([&](const uint8_t *data, size_t len) { + lens.push_back(len); + got.insert(got.end(), data, data + len); + })); + EXPECT_EQ(lens, (std::vector{64, 6})); + EXPECT_EQ(got, make_bytes(70)); +} + +TEST(UARTReadAvailableTest, ChunkCallbackReturningFalseStops) { + ::testing::NiceMock uart(make_bytes(20)); + UARTDevice dev(&uart); + int chunks = 0; + EXPECT_TRUE(dev.read_available_chunks<8>([&chunks](const uint8_t *, size_t) { + chunks++; + return false; + })); + EXPECT_EQ(chunks, 1); + EXPECT_EQ(uart.remaining(), 12u); +} + +TEST(UARTReadAvailableTest, ChunksWithCountStopEarly) { + ::testing::NiceMock uart(make_bytes(30)); + UARTDevice dev(&uart); + std::vector lens; + EXPECT_TRUE(dev.read_available_chunks<8>( + [&lens](const uint8_t *, size_t len) { + lens.push_back(len); + return lens.size() < 2; + }, + 20)); + EXPECT_EQ(lens, (std::vector{8, 8})); + EXPECT_EQ(uart.remaining(), 14u); +} + +} // namespace esphome::uart::testing From 25b684f7628326deb3b7b86acbd89ef69c359896 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:44:41 -1000 Subject: [PATCH 048/120] [pylontech] Parse each line as it arrives with one fixed buffer (#20959) --- esphome/components/pylontech/__init__.py | 31 +++ esphome/components/pylontech/pylontech.cpp | 192 +++++++------ esphome/components/pylontech/pylontech.h | 66 +++-- .../components/pylontech/sensor/__init__.py | 5 +- .../pylontech/sensor/pylontech_sensor.cpp | 7 +- .../pylontech/sensor/pylontech_sensor.h | 5 +- .../pylontech/text_sensor/__init__.py | 5 +- .../text_sensor/pylontech_text_sensor.cpp | 7 +- .../text_sensor/pylontech_text_sensor.h | 5 +- esphome/core/defines.h | 2 + tests/components/pylontech/__init__.py | 15 ++ tests/components/pylontech/pylontech_test.cpp | 252 ++++++++++++++++++ .../fixtures/uart_mock_pylontech.yaml | 105 ++++++++ tests/integration/test_uart_mock_pylontech.py | 136 ++++++++++ 14 files changed, 716 insertions(+), 117 deletions(-) create mode 100644 tests/components/pylontech/__init__.py create mode 100644 tests/components/pylontech/pylontech_test.cpp create mode 100644 tests/integration/fixtures/uart_mock_pylontech.yaml create mode 100644 tests/integration/test_uart_mock_pylontech.py diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index f9eef4c8096..8bd57f252c1 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -1,9 +1,12 @@ +from dataclasses import dataclass, field import logging import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -24,6 +27,33 @@ PylontechBattery = pylontech_ns.class_("PylontechBattery") CV_NUM_BATTERIES = cv.int_range(1, 16) +_request_listener_slot = cg.slot_counter("PYLONTECH_LISTENER_COUNT") +_request_battery_slot = cg.slot_counter("PYLONTECH_BATTERY_COUNT") + + +@dataclass +class PylontechData: + batteries: set[tuple[str, int]] = field(default_factory=set) + + +def _get_data() -> PylontechData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = PylontechData() + return CORE.data[DOMAIN] + + +async def register_listener(config: ConfigType, listener: MockObj) -> None: + """Register a battery listener; the hub keeps one line slot per distinct battery.""" + hub = await cg.get_variable(config[CONF_PYLONTECH_ID]) + hub_key = str(hub) + _request_listener_slot(hub_key) + data = _get_data() + if (key := (hub_key, config[CONF_BATTERY])) not in data.batteries: + data.batteries.add(key) + _request_battery_slot(hub_key) + cg.add(hub.register_listener(listener)) + + PYLONTECH_COMPONENT_SCHEMA = cv.Schema( { cv.GenerateID(CONF_PYLONTECH_ID): cv.use_id(PylontechComponent), @@ -53,5 +83,6 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 50d8c814fa4..5e48e8b726c 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -1,25 +1,35 @@ #include "pylontech.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#include +#include // Helper macros +#define PARSE_TOKEN_INT(field, field_name) \ + { \ + auto val = parse_number(token_buf); \ + if (val.has_value() && in_range(*val)) { \ + (field) = *val; \ + } else { \ + ESP_LOGV(TAG, "invalid " field_name " in line %s", buffer); \ + this->reject_line_(); \ + return; \ + } \ + } + #define PARSE_INT(field, field_name) \ { \ get_token(token_buf); \ - auto val = parse_number(token_buf); \ - if (val.has_value()) { \ - (field) = val.value(); \ - } else { \ - ESP_LOGD(TAG, "invalid " field_name " in line %s", buffer.substr(0, buffer.size() - 2).c_str()); \ - return; \ - } \ + PARSE_TOKEN_INT(field, field_name) \ } #define PARSE_STR(field, field_name) \ { \ get_token(field); \ if (strlen(field) < 2) { \ - ESP_LOGD(TAG, "too short " field_name " in line %s", buffer.substr(0, buffer.size() - 2).c_str()); \ + ESP_LOGV(TAG, "too short " field_name " in line %s", buffer); \ + this->reject_line_(); \ return; \ } \ } @@ -27,17 +37,26 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech"); -static const int MAX_DATA_LENGTH_BYTES = 256; -static const uint8_t ASCII_LF = 0x0A; + +// Rejects values that would wrap in a narrowed field +template static bool in_range(int value) { + if constexpr (sizeof(T) >= sizeof(int)) { + return true; + } else { + return value >= std::numeric_limits::min() && value <= std::numeric_limits::max(); + } +} PylontechComponent::PylontechComponent() {} void PylontechComponent::dump_config() { ESP_LOGCONFIG(TAG, "pylontech:"); +#ifdef PYLONTECH_LISTENER_COUNT for (PylontechListener *listener : this->listeners_) { listener->dump_config(); } +#endif LOG_UPDATE_INTERVAL(this); } @@ -51,41 +70,70 @@ void PylontechComponent::setup() { void PylontechComponent::update() { this->write_str("pwr\n"); } void PylontechComponent::loop() { + // Drain the UART quickly; pylontech sends large bursts. Logging and publishing wait until it is idle. size_t avail = this->available(); - if (avail > 0) { - // pylontech sends a lot of data very suddenly - // we need to quickly put it all into our own buffer, otherwise the uart's buffer will overflow - int recv = 0; - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; - } - avail -= to_read; - recv += to_read; - - for (size_t i = 0; i < to_read; i++) { - buffer_[buffer_index_write_] += (char) buf[i]; - if (buf[i] == ASCII_LF || buffer_[buffer_index_write_].length() >= MAX_DATA_LENGTH_BYTES) { - // complete line received - buffer_index_write_ = (buffer_index_write_ + 1) % NUM_BUFFERS; +#ifdef PYLONTECH_LISTENER_COUNT + if (avail == 0) { + this->dispatch_one_(); + return; + } +#endif + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(avail, sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; +#ifdef PYLONTECH_LISTENER_COUNT + for (size_t i = 0; i < to_read; i++) { + const char *line = this->line_reader_.feed(buf[i]); + if (line != nullptr) { + this->process_line_(line); + } else if (this->line_reader_.overflowed()) { + this->line_reader_.log_if_overflowed(TAG); + // Tlow.Id sits near the start, so a too long header still sets the column layout + const char *partial = this->line_reader_.partial(); + if (strncmp(partial, "Power", 5) == 0) { + this->parse_header_(partial); } } } - ESP_LOGV(TAG, "received %d bytes", recv); - } else { - // only process one line per call of loop() to not block esphome for too long - if (buffer_index_read_ != buffer_index_write_) { - this->process_line_(buffer_[buffer_index_read_]); - buffer_[buffer_index_read_].clear(); - buffer_index_read_ = (buffer_index_read_ + 1) % NUM_BUFFERS; - } +#endif } } -void PylontechComponent::process_line_(std::string &buffer) { - ESP_LOGV(TAG, "Read from serial: %s", buffer.substr(0, buffer.size() - 2).c_str()); +#ifdef PYLONTECH_LISTENER_COUNT +void PylontechComponent::dispatch_one_() { + for (uint8_t i = 0; this->pending_ != 0 && i < PYLONTECH_BATTERY_COUNT; i++) { + const battery_mask_t bit = 1u << i; + if ((this->pending_ & bit) == 0) { + continue; + } + this->pending_ &= ~bit; + PylontechListener::LineContents *l = &this->slots_[i]; + ESP_LOGV(TAG, "Publishing battery %u", l->bat_num); + for (PylontechListener *listener : this->listeners_) { + if (listener->get_battery() == l->bat_num) { + listener->on_line_read(l); + } + } + return; + } + if (this->rejected_ != 0) { + ESP_LOGW(TAG, "Rejected %u malformed battery lines", this->rejected_); + this->rejected_ = 0; + } +} + +void PylontechComponent::parse_header_(const char *buffer) { + this->has_tlow_id_ = strstr(buffer, "Tlow.Id") != nullptr; + ESP_LOGV(TAG, "header line %s Tlow.Id: %s", this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), + buffer); +} + +// Runs while the UART drains: no logging above VERBOSE and no publishing here +void PylontechComponent::process_line_(const char *buffer) { // clang-format off // example lines to parse: // Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St @@ -96,9 +144,7 @@ void PylontechComponent::process_line_(std::string &buffer) { // 1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal 60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal // clang-format on - PylontechListener::LineContents l{}; - - const char *cursor = buffer.c_str(); + const char *cursor = buffer; char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; // Helper Lambda to extract tokens @@ -116,7 +162,7 @@ void PylontechComponent::process_line_(std::string &buffer) { const char *start = cursor; // Find end of field - while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t' && *cursor != '\r') { + while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { cursor++; } @@ -125,23 +171,33 @@ void PylontechComponent::process_line_(std::string &buffer) { token_buf[token_len] = 0; }; - { - get_token(token_buf); - auto val = parse_number(token_buf); - if (val.has_value() && val.value() > 0) { - l.bat_num = val.value(); - } else if (strcmp(token_buf, "Power") == 0) { + get_token(token_buf); + auto bat = parse_number(token_buf); + if (!bat.has_value()) { + if (strcmp(token_buf, "Power") == 0) { // header line i.e. "Power Volt Curr" and so on - this->has_tlow_id_ = buffer.find("Tlow.Id") != std::string::npos; - ESP_LOGD(TAG, "header line %s Tlow.Id: %s", - this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), - buffer.substr(0, buffer.size() - 2).c_str()); - return; + this->parse_header_(buffer); } else { - ESP_LOGD(TAG, "unknown line %s", buffer.substr(0, buffer.size() - 2).c_str()); - return; + ESP_LOGV(TAG, "unknown line %s", buffer); } + return; } + const unsigned bat_index = static_cast(*bat) - 1u; + if (bat_index >= MAX_BATTERIES || (this->batteries_ & (1u << bat_index)) == 0) { + return; // no listener for this battery + } + const battery_mask_t bat_bit = 1u << bat_index; + // The slot index is the number of listened batteries below this one + uint8_t slot = 0; + for (battery_mask_t below = this->batteries_ & (bat_bit - 1); below != 0; below &= below - 1) { + slot++; + } + const battery_mask_t slot_bit = 1u << slot; + // Cleared until the whole line parses, so a bad line never publishes a half written slot + this->pending_ &= ~slot_bit; + PylontechListener::LineContents &l = this->slots_[slot]; + l.bat_num = *bat; + PARSE_INT(l.volt, "Volt"); PARSE_INT(l.curr, "Curr"); PARSE_INT(l.tempr, "Tempr"); @@ -165,34 +221,20 @@ void PylontechComponent::process_line_(std::string &buffer) { PARSE_STR(l.volt_st, "Volt.St"); PARSE_STR(l.curr_st, "Curr.St"); PARSE_STR(l.temp_st, "Temp.St"); - { - get_token(token_buf); - for (char &i : token_buf) { - if (i == '%') { - i = 0; - break; - } - } - auto coul_val = parse_number(token_buf); - if (coul_val.has_value()) { - l.coulomb = coul_val.value(); - } else { - ESP_LOGD(TAG, "invalid Coulomb in line %s", buffer.substr(0, buffer.size() - 2).c_str()); - return; - } + get_token(token_buf); + if (char *pct = strchr(token_buf, '%')) { + *pct = '\0'; } + PARSE_TOKEN_INT(l.coulomb, "Coulomb"); get_token(token_buf); // Skip Date get_token(token_buf); // Skip Time get_token(token_buf); // Skip B.V.St get_token(token_buf); // Skip B.T.St PARSE_INT(l.mostempr, "Mostempr"); - ESP_LOGD(TAG, "successful line %s", buffer.substr(0, buffer.size() - 2).c_str()); - - for (PylontechListener *listener : this->listeners_) { - listener->on_line_read(&l); - } + this->pending_ |= slot_bit; } +#endif } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index eae5e6e7bca..aa652056dca 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -1,24 +1,42 @@ #pragma once +#include +#include + #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::pylontech { -static const uint8_t NUM_BUFFERS = 20; -static const uint8_t TEXT_SENSOR_MAX_LEN = 14; +static constexpr uint8_t TEXT_SENSOR_MAX_LEN = 14; +// Battery numbers run 1 to MAX_BATTERIES; bit n - 1 of a battery_mask_t stands for battery n +static constexpr uint8_t MAX_BATTERIES = 16; +using battery_mask_t = uint16_t; +static_assert(MAX_BATTERIES <= sizeof(battery_mask_t) * 8, "battery mask too narrow"); class PylontechListener { public: + // No default initializers: the hub's slots stay plain zeroed storage; a parse writes every field struct LineContents { - int bat_num = 0, volt, curr, tempr, tlow, thigh, vlow, vhigh, coulomb, mostempr; - char base_st[TEXT_SENSOR_MAX_LEN] = {0}, volt_st[TEXT_SENSOR_MAX_LEN] = {0}, curr_st[TEXT_SENSOR_MAX_LEN] = {0}, - temp_st[TEXT_SENSOR_MAX_LEN] = {0}; + int32_t curr, tempr, tlow, thigh, mostempr; + uint16_t volt, vlow, vhigh; // mV + uint8_t bat_num, coulomb; + char base_st[TEXT_SENSOR_MAX_LEN], volt_st[TEXT_SENSOR_MAX_LEN], curr_st[TEXT_SENSOR_MAX_LEN], + temp_st[TEXT_SENSOR_MAX_LEN]; }; - virtual void on_line_read(LineContents *line); - virtual void dump_config(); + explicit PylontechListener(uint8_t bat_num) : bat_num_(bat_num) {} + uint8_t get_battery() const { return this->bat_num_; } + + /// Called only with records for this listener's battery. + virtual void on_line_read(LineContents *line) = 0; + virtual void dump_config() = 0; + + protected: + uint8_t bat_num_; }; class PylontechComponent final : public PollingComponent, public uart::UARTDevice { @@ -33,18 +51,34 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic void setup() override; void dump_config() override; - void register_listener(PylontechListener *listener) { this->listeners_.push_back(listener); } +#ifdef PYLONTECH_LISTENER_COUNT + void register_listener(PylontechListener *listener) { + this->listeners_.push_back(listener); + this->batteries_ |= 1u << (listener->get_battery() - 1); + } protected: - void process_line_(std::string &buffer); + void process_line_(const char *buffer); + void parse_header_(const char *buffer); + void dispatch_one_(); + void reject_line_() { + if (this->rejected_ < UINT8_MAX) { + this->rejected_++; + } + } - // ring buffer - std::string buffer_[NUM_BUFFERS]; - int buffer_index_write_ = 0; - int buffer_index_read_ = 0; - bool has_tlow_id_ = false; - - std::vector listeners_{}; + // The longest line, the newer firmware header, is 182 characters + LineReader<200> line_reader_; + StaticVector listeners_; + // One slot per listened battery, in battery order; parsed while the UART drains, published one per + // loop once it is idle. A newer line for a battery replaces the unpublished one. + static_assert(PYLONTECH_BATTERY_COUNT <= MAX_BATTERIES, "more slots than batteries"); + std::array slots_; + battery_mask_t batteries_{0}; // batteries with a listener + battery_mask_t pending_{0}; // bit i set: slots_[i] is waiting to be published + bool has_tlow_id_{false}; + uint8_t rejected_{0}; // malformed battery lines since the last idle pass +#endif }; } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 40391206fbc..51aceace3e7 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -18,7 +18,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns +from .. import CONF_BATTERY, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns, register_listener PylontechSensor = pylontech_ns.class_("PylontechSensor") @@ -92,7 +92,6 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) for marker in TYPES: @@ -100,4 +99,4 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_sensor")(sens)) - cg.add(paren.register_listener(bat)) + await register_listener(config, bat) diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index dc236f47a55..bead0387bef 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -6,12 +6,10 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech.sensor"); -PylontechSensor::PylontechSensor(int8_t bat_num) { this->bat_num_ = bat_num; } - void PylontechSensor::dump_config() { ESP_LOGCONFIG(TAG, "Pylontech Sensor:\n" - " Battery %d", + " Battery %u", this->bat_num_); LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); LOG_SENSOR(" ", "Current", this->current_sensor_); @@ -25,9 +23,6 @@ void PylontechSensor::dump_config() { } void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { - if (this->bat_num_ != line->bat_num) { - return; - } if (this->voltage_sensor_ != nullptr) { this->voltage_sensor_->publish_state(((float) line->volt) / 1000.0f); } diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 1403d3445dd..6b466b74664 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -7,7 +7,7 @@ namespace esphome::pylontech { class PylontechSensor final : public PylontechListener { public: - PylontechSensor(int8_t bat_num); + explicit PylontechSensor(uint8_t bat_num) : PylontechListener(bat_num) {} void dump_config() override; SUB_SENSOR(voltage) @@ -22,9 +22,6 @@ class PylontechSensor final : public PylontechListener { SUB_SENSOR(mos_temperature) void on_line_read(LineContents *line) override; - - protected: - int8_t bat_num_; }; } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/text_sensor/__init__.py b/esphome/components/pylontech/text_sensor/__init__.py index 511eb7d542e..cb3e04a91d8 100644 --- a/esphome/components/pylontech/text_sensor/__init__.py +++ b/esphome/components/pylontech/text_sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType -from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns +from .. import CONF_BATTERY, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns, register_listener PylontechTextSensor = pylontech_ns.class_("PylontechTextSensor") @@ -26,7 +26,6 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) for marker in MARKERS: @@ -34,4 +33,4 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_text_sensor")(var)) - cg.add(paren.register_listener(bat)) + await register_listener(config, bat) diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp index 1beb04064ec..07b8e36df03 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp @@ -6,12 +6,10 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech.textsensor"); -PylontechTextSensor::PylontechTextSensor(int8_t bat_num) { this->bat_num_ = bat_num; } - void PylontechTextSensor::dump_config() { ESP_LOGCONFIG(TAG, "Pylontech Text Sensor:\n" - " Battery %d", + " Battery %u", this->bat_num_); LOG_TEXT_SENSOR(" ", "Base state", this->base_state_text_sensor_); LOG_TEXT_SENSOR(" ", "Voltage state", this->voltage_state_text_sensor_); @@ -20,9 +18,6 @@ void PylontechTextSensor::dump_config() { } void PylontechTextSensor::on_line_read(PylontechListener::LineContents *line) { - if (this->bat_num_ != line->bat_num) { - return; - } if (this->base_state_text_sensor_ != nullptr) { this->base_state_text_sensor_->publish_state(line->base_st); } diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h index 3ba4f1fd4e7..6f229893c0c 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h @@ -7,7 +7,7 @@ namespace esphome::pylontech { class PylontechTextSensor final : public PylontechListener { public: - PylontechTextSensor(int8_t bat_num); + explicit PylontechTextSensor(uint8_t bat_num) : PylontechListener(bat_num) {} void dump_config() override; SUB_TEXT_SENSOR(base_state) @@ -16,9 +16,6 @@ class PylontechTextSensor final : public PylontechListener { SUB_TEXT_SENSOR(temperature_state) void on_line_read(LineContents *line) override; - - protected: - int8_t bat_num_; }; } // namespace esphome::pylontech diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3555b3cd1c2..8d17dc3f514 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -178,6 +178,8 @@ #define PN532_ON_TAG_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_TRIGGER_COUNT 1 +#define PYLONTECH_BATTERY_COUNT 1 +#define PYLONTECH_LISTENER_COUNT 2 #define REMOTE_BASE_DUMPER_COUNT 1 #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME diff --git a/tests/components/pylontech/__init__.py b/tests/components/pylontech/__init__.py new file mode 100644 index 00000000000..c16210128f6 --- /dev/null +++ b/tests/components/pylontech/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # Storage is sized by code generation; the gtests register three batteries. + cg.add_define("PYLONTECH_LISTENER_COUNT", 3) + cg.add_define("PYLONTECH_BATTERY_COUNT", 3) + cg.add_define("USE_LINE_READER") + + # A MULTI_CONF component gets no entry in the host build, so its to_code would never run. + manifest.multi_conf = False + manifest.to_code = to_code_testing diff --git a/tests/components/pylontech/pylontech_test.cpp b/tests/components/pylontech/pylontech_test.cpp new file mode 100644 index 00000000000..479530571c0 --- /dev/null +++ b/tests/components/pylontech/pylontech_test.cpp @@ -0,0 +1,252 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/pylontech/pylontech.h" +#include "esphome/components/uart/uart_component.h" + +namespace esphome::pylontech::testing { + +namespace { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void push_rx(std::initializer_list lines) { + for (const std::string &line : lines) { + this->rx.insert(this->rx.end(), line.begin(), line.end()); + } + } + + void write_array(const uint8_t *data, size_t len) override {} + 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 +}; + +// Every record any listener gets, in publish order +std::vector published; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +class TestListener final : public PylontechListener { + public: + explicit TestListener(uint8_t bat_num) : PylontechListener(bat_num) {} + void on_line_read(LineContents *line) override { + EXPECT_EQ(line->bat_num, this->bat_num_); + published.push_back(*line); + } + void dump_config() override {} +}; + +// clang-format off +constexpr const char *HEADER = + "Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time" + " B.V.St B.T.St MosTempr M.T.St\r\n"; +constexpr const char *BAT1 = + "1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% " + "2021-06-30 20:49:45 Normal Normal 22700 Normal\r\n"; +constexpr const char *BAT2 = + "2 46012 -1255 9100 5300 5500 3047 3091 SysError Low Normal Normal 4% " + "2025-11-28 17:56:33 Low Normal 7800 Normal\r\n"; +constexpr const char *NEW_HEADER = + "Power Volt Curr Tempr Tlow Tlow.Id Thigh Thigh.Id Vlow Vlow.Id Vhigh Vhigh.Id Base.St Volt.St Curr.St Temp.St " + "Coulomb Time B.V.St B.T.St MosTempr M.T.St SysAlarm.St\r\n"; +constexpr const char *NEW_BAT1 = + "1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal " + "60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n"; +// clang-format on + +// An older firmware line for battery bat with pack voltage volt (mV) +std::string bat_line(int bat, int volt) { + char buf[192]; + snprintf(buf, sizeof(buf), + "%d %d 0 17000 16000 18000 3200 3300 Idle Normal Normal Normal 50%% " + "2025-11-28 17:56:33 Normal Normal 16000 Normal\r\n", + bat, volt); + return buf; +} + +class PylontechTest : public ::testing::Test { + protected: + void SetUp() override { + published.clear(); + this->hub_.set_uart_parent(&this->uart_); + for (TestListener &listener : this->listeners_) { + this->hub_.register_listener(&listener); + } + } + + std::vector &published_() { return published; } + + MockUARTComponent uart_; + PylontechComponent hub_; + TestListener listeners_[3]{TestListener(1), TestListener(2), TestListener(3)}; +}; + +} // namespace + +TEST_F(PylontechTest, BurstIsPublishedAfterTheDrainOnePerLoop) { + this->uart_.push_rx({HEADER, BAT1, BAT2}); + this->hub_.loop(); + EXPECT_TRUE(this->uart_.rx.empty()); + EXPECT_TRUE(this->published_().empty()); + + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 2u); + this->hub_.loop(); + EXPECT_EQ(this->published_().size(), 2u); + + const auto &b1 = this->published_()[0]; + EXPECT_EQ(b1.bat_num, 1); + EXPECT_EQ(b1.volt, 50548); + EXPECT_EQ(b1.curr, 8910); + EXPECT_EQ(b1.tempr, 25000); + EXPECT_EQ(b1.tlow, 24200); + EXPECT_EQ(b1.thigh, 25000); + EXPECT_EQ(b1.vlow, 3368); + EXPECT_EQ(b1.vhigh, 3371); + EXPECT_STREQ(b1.base_st, "Charge"); + EXPECT_STREQ(b1.volt_st, "Normal"); + EXPECT_EQ(b1.coulomb, 97); + EXPECT_EQ(b1.mostempr, 22700); + + const auto &b2 = this->published_()[1]; + EXPECT_EQ(b2.bat_num, 2); + EXPECT_EQ(b2.curr, -1255); + EXPECT_STREQ(b2.base_st, "SysError"); + EXPECT_STREQ(b2.volt_st, "Low"); + EXPECT_EQ(b2.coulomb, 4); + EXPECT_EQ(b2.mostempr, 7800); +} + +TEST_F(PylontechTest, NothingIsPublishedWhileBytesKeepArriving) { + this->uart_.push_rx({HEADER, BAT1}); + this->hub_.loop(); + this->uart_.push_rx({BAT2}); + this->hub_.loop(); + EXPECT_TRUE(this->published_().empty()); + this->hub_.loop(); + this->hub_.loop(); + EXPECT_EQ(this->published_().size(), 2u); +} + +TEST_F(PylontechTest, NewerFirmwareSkipsIdColumns) { + this->uart_.push_rx({NEW_HEADER, NEW_BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + const auto &b1 = this->published_()[0]; + EXPECT_EQ(b1.volt, 49405); + EXPECT_EQ(b1.tlow, 13700); + EXPECT_EQ(b1.thigh, 14500); + EXPECT_EQ(b1.vlow, 3293); + EXPECT_EQ(b1.vhigh, 3294); + EXPECT_STREQ(b1.base_st, "Idle"); + EXPECT_EQ(b1.coulomb, 60); + EXPECT_EQ(b1.mostempr, 16600); +} + +TEST_F(PylontechTest, OverlongHeaderStillSetsTheColumnLayout) { + // Past the 199 character line limit: the line is dropped but its start still selects the layout + const std::string pad(40, ' '); + std::string new_header(NEW_HEADER); + new_header.insert(new_header.size() - 2, pad + "Extra.St"); + ASSERT_GT(new_header.size(), 201u); + this->uart_.push_rx({new_header, NEW_BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].thigh, 14500); + EXPECT_EQ(this->published_()[0].mostempr, 16600); + + // An overlong older header switches back + std::string old_header(HEADER); + old_header.insert(old_header.size() - 2, pad + pad + "Extra.St"); + ASSERT_GT(old_header.size(), 201u); + this->uart_.push_rx({old_header, BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[1].thigh, 25000); + EXPECT_EQ(this->published_()[1].mostempr, 22700); +} + +TEST_F(PylontechTest, BatteryWithoutListenerIsIgnored) { + this->uart_.push_rx( + {HEADER, bat_line(5, 49000), bat_line(17, 49000), bat_line(0, 49000), bat_line(INT32_MIN, 49000), BAT1, BAT2}); + for (int i = 0; i < 4; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + EXPECT_EQ(this->published_()[1].bat_num, 2); +} + +TEST_F(PylontechTest, OutOfRangeValueRejectsTheLine) { + this->uart_.push_rx({HEADER, bat_line(1, -1), bat_line(1, 70000), BAT2}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +TEST_F(PylontechTest, InvalidAndUnknownLinesAreSkipped) { + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, "1 abc\r\n", BAT2, "$$\r\n"}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +TEST_F(PylontechTest, RepeatedBatteryLineOverwrites) { + this->uart_.push_rx({HEADER, bat_line(3, 49000), BAT1, bat_line(3, 49500)}); + for (int i = 0; i < 4; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + EXPECT_EQ(this->published_()[1].bat_num, 3); + EXPECT_EQ(this->published_()[1].volt, 49500); + + // The next reply fills the slot again + this->uart_.push_rx({HEADER, bat_line(3, 48000)}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 3u); + EXPECT_EQ(this->published_()[2].volt, 48000); +} + +TEST_F(PylontechTest, BadLineClearsTheBatteryPendingRecord) { + this->uart_.push_rx({HEADER, BAT1, "1 abc\r\n", BAT2}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +} // namespace esphome::pylontech::testing diff --git a/tests/integration/fixtures/uart_mock_pylontech.yaml b/tests/integration/fixtures/uart_mock_pylontech.yaml new file mode 100644 index 00000000000..fcf87cc8246 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_pylontech.yaml @@ -0,0 +1,105 @@ +esphome: + name: uart-mock-pylontech-test + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy pylontech's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + rx_buffer_size: 1024 + auto_start: false + +pylontech: + id: pylontech0 + uart_id: mock_uart + +sensor: + - platform: pylontech + pylontech_id: pylontech0 + battery: 1 + voltage: + name: "Bat1 Voltage" + current: + name: "Bat1 Current" + temperature: + name: "Bat1 Temperature" + temperature_low: + name: "Bat1 Temperature Low" + temperature_high: + name: "Bat1 Temperature High" + voltage_low: + name: "Bat1 Voltage Low" + voltage_high: + name: "Bat1 Voltage High" + coulomb: + name: "Bat1 Coulomb" + mos_temperature: + name: "Bat1 MOS Temperature" + - platform: pylontech + pylontech_id: pylontech0 + battery: 2 + voltage: + name: "Bat2 Voltage" + current: + name: "Bat2 Current" + temperature_high: + name: "Bat2 Temperature High" + coulomb: + name: "Bat2 Coulomb" + +text_sensor: + - platform: pylontech + pylontech_id: pylontech0 + battery: 1 + base_state: + name: "Bat1 Base State" + - platform: pylontech + pylontech_id: pylontech0 + battery: 2 + base_state: + name: "Bat2 Base State" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + # Phase 1: older firmware header and two battery lines + - uart_mock.inject_rx: + id: mock_uart + data: "pwr\r\n@\r\nPower Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St\r\n1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% 2021-06-30 20:49:45 Normal Normal 22700 Normal\r\n2 46012 -1255 9100 5300 5500 3047 3091 SysError Low Normal Normal 4% 2025-11-28 17:56:33 Low Normal 7800 Normal\r\n$$\r\n" + - delay: 300ms + # Phase 2: garbage bytes, ending a line + - uart_mock.inject_rx: + id: mock_uart + data: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF, 0x0D, 0x0A] + - delay: 100ms + # Phase 3: truncated battery line; must not publish + - uart_mock.inject_rx: + id: mock_uart + data: "1 49000 0 17000\r\n" + - delay: 100ms + # Phase 4: line over the 199 character limit; dropped with a warning + - uart_mock.inject_rx: + id: mock_uart + data: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\r\n" + - delay: 100ms + # Phase 5 (recovery): newer firmware header, also over the limit, then its data lines + - uart_mock.inject_rx: + id: mock_uart + data: "Power Volt Curr Tempr Tlow Tlow.Id Thigh Thigh.Id Vlow Vlow.Id Vhigh Vhigh.Id Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St SysAlarm.St Extra.St\r\n1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal 60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n2 48100 -2500 16900 13200 3 14100 1 3201 4 3215 9 Dischg Normal Normal Normal 42% 2025-12-05 00:53:41 Normal Normal 15800 Normal Normal\r\n$$\r\n" diff --git a/tests/integration/test_uart_mock_pylontech.py b/tests/integration/test_uart_mock_pylontech.py new file mode 100644 index 00000000000..fffaa902072 --- /dev/null +++ b/tests/integration/test_uart_mock_pylontech.py @@ -0,0 +1,136 @@ +"""Integration test for the pylontech component with mock UART. + +Phases, all injected after the Start Scenario button press: + 1. Older firmware header and two battery lines publish exact values + 2. Garbage bytes are skipped + 3. A truncated battery line publishes nothing and is counted in a warning + 4. A line over the 199 character limit is dropped with a warning + 5. Recovery: a newer firmware header, itself over the limit, still selects the + layout with the extra Id columns, so its battery lines parse correctly +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, find_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +SENSORS = [ + "bat1_voltage", + "bat1_current", + "bat1_temperature", + "bat1_temperature_low", + "bat1_temperature_high", + "bat1_voltage_low", + "bat1_voltage_high", + "bat1_coulomb", + "bat1_mos_temperature", + "bat2_voltage", + "bat2_current", + "bat2_temperature_high", + "bat2_coulomb", +] +TEXT_SENSORS = ["bat1_base_state", "bat2_base_state"] + +# Values per entity: phase 1 reply, then the phase 5 recovery reply +EXPECTED = { + "bat1_voltage": [50.548, 49.405], + "bat1_current": [8.91, 0.0], + "bat1_temperature": [25.0, 17.6], + "bat1_temperature_low": [24.2, 13.7], + "bat1_temperature_high": [25.0, 14.5], + "bat1_voltage_low": [3.368, 3.293], + "bat1_voltage_high": [3.371, 3.294], + "bat1_coulomb": [97.0, 60.0], + "bat1_mos_temperature": [22.7, 16.6], + "bat2_voltage": [46.012, 48.1], + "bat2_current": [-1.255, -2.5], + "bat2_temperature_high": [5.5, 14.1], + "bat2_coulomb": [4.0, 42.0], +} +EXPECTED_TEXT = { + "bat1_base_state": ["Charge", "Idle"], + "bat2_base_state": ["SysError", "Dischg"], +} + + +@pytest.mark.asyncio +async def test_uart_mock_pylontech( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test pylontech parsing with happy path, garbage, overflow and recovery.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + overflow_lines: list[str] = [] + rejected_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "Line over 199 chars dropped" in line: + overflow_lines.append(line) + if "malformed battery lines" in line: + rejected_lines.append(line) + + collector = SensorStateCollector( + sensor_names=SENSORS, text_sensor_names=TEXT_SENSORS + ) + recovery_received = collector.add_waiter( + lambda: ( + all(len(collector.sensor_states[name]) >= 2 for name in SENSORS) + and all( + len(collector.text_sensor_states[name]) >= 2 for name in TEXT_SENSORS + ) + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(collector.on_state) + ) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + + try: + await asyncio.wait_for(recovery_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the recovery reply. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" text_states: {collector.text_sensor_states}" + ) + # Let any stray publish from the bad phases arrive before the exact checks + await asyncio.sleep(0.2) + + for name, values in EXPECTED.items(): + assert collector.sensor_states[name] == pytest.approx(values), name + for name, values in EXPECTED_TEXT.items(): + assert collector.text_sensor_states[name] == values, name + + # Phase 4 and the phase 5 header both overflow + assert len(overflow_lines) == 2, overflow_lines + # The phase 3 cut line is counted and reported once the UART is idle + assert rejected_lines, "no rejected-line summary logged" From 052a6096e65cb520a299f8d6de253342e30bb7d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:46:16 -1000 Subject: [PATCH 049/120] [components] Log hex dumps with the ESP_LOG*_HEX macros (#20932) --- .../haier_water_heater/hon_water_heater.cpp | 12 +++------ esphome/components/hdmi_cec/hdmi_cec.cpp | 5 +--- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 7 ++---- esphome/components/i2c/i2c_bus_arduino.cpp | 5 +--- esphome/components/i2c/i2c_bus_esp_idf.cpp | 5 +--- .../improv_ble/improv_ble_component.cpp | 7 ++---- esphome/components/ld2410/ld2410.cpp | 10 ++------ esphome/components/ld2412/ld2412.cpp | 10 ++------ esphome/components/ld2450/ld2450.cpp | 10 ++------ esphome/components/ld2460/ld2460.cpp | 5 +--- .../midea_portasplit/midea_portasplit.cpp | 10 ++------ esphome/components/mitsubishi/mitsubishi.cpp | 5 +--- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 5 +--- esphome/components/modbus/modbus.cpp | 6 +---- .../output/modbus_output.cpp | 6 +---- .../switch/modbus_switch.cpp | 6 +---- .../mopeka_std_check/mopeka_std_check.cpp | 7 ++---- esphome/components/pn532_spi/pn532_spi.cpp | 10 ++------ .../seeed_mr60bha2/seeed_mr60bha2.cpp | 10 ++------ .../seeed_mr60fda2/seeed_mr60fda2.cpp | 25 ++++--------------- esphome/components/sonoff_d1/sonoff_d1.cpp | 15 +++-------- .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 10 ++------ esphome/components/xiaomi_ble/xiaomi_ble.cpp | 5 +--- .../components/zwave_proxy/zwave_proxy.cpp | 10 ++------ 24 files changed, 43 insertions(+), 163 deletions(-) diff --git a/esphome/components/haier_water_heater/hon_water_heater.cpp b/esphome/components/haier_water_heater/hon_water_heater.cpp index 3f190de5b83..b2c35c4cca4 100644 --- a/esphome/components/haier_water_heater/hon_water_heater.cpp +++ b/esphome/components/haier_water_heater/hon_water_heater.cpp @@ -46,10 +46,7 @@ void HonWaterHeater::send_frame_(FrameType type, const uint8_t *payload, size_t const size_t length = encode_frame(type, payload, size, wire); if (length == 0) return; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex[format_hex_pretty_size(sizeof(wire))]; - ESP_LOGV(TAG, "TX type=%02X: %s", static_cast(type), format_hex_pretty_to(hex, wire, length)); -#endif + ESP_LOGV_HEX(TAG, wire, length, sizeof(wire), "TX type=%02X: %s", static_cast(type)); this->write_array(wire, length); } @@ -221,11 +218,8 @@ void HonWaterHeater::confirm_control_(const Status &status) { void HonWaterHeater::process_frame_() { const uint8_t *frame = this->parser_.data(); const auto type = static_cast(frame[FRAME_TYPE_OFFSET]); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex[format_hex_pretty_size(MAX_STORED_FRAME)]; - ESP_LOGV(TAG, "RX decoded type=%02X flags=%02X: %s", static_cast(type), frame[FRAME_FLAGS_OFFSET], - format_hex_pretty_to(hex, frame, this->parser_.stored_size())); -#endif + ESP_LOGV_HEX(TAG, frame, this->parser_.stored_size(), MAX_STORED_FRAME, "RX decoded type=%02X flags=%02X: %s", + static_cast(type), frame[FRAME_FLAGS_OFFSET]); this->handle_frame_type_(type); Status status{}; diff --git a/esphome/components/hdmi_cec/hdmi_cec.cpp b/esphome/components/hdmi_cec/hdmi_cec.cpp index 82e354dbae7..e0e9abde7b1 100644 --- a/esphome/components/hdmi_cec/hdmi_cec.cpp +++ b/esphome/components/hdmi_cec/hdmi_cec.cpp @@ -75,10 +75,7 @@ void HDMICEC::loop() { } // 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 + ESP_LOGD_HEX(TAG, frame->bytes().data(), frame->size(), Frame::MAX_LENGTH, "Received: %s"); #ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT this->message_callbacks_.call(frame->initiator_addr(), frame->destination_addr(), frame->payload()); #endif diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index e4845daa57b..8674a1de97c 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -161,11 +161,8 @@ void HlkFm22xComponent::recv_command_() { } } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(HLK_FM22X_MAX_RESPONSE_SIZE)]; - ESP_LOGV(TAG, "Recv type: 0x%.2X, data: %s", response_type, - format_hex_pretty_to(hex_buf, this->recv_buf_.data(), to_store)); -#endif + ESP_LOGV_HEX(TAG, this->recv_buf_.data(), to_store, HLK_FM22X_MAX_RESPONSE_SIZE, "Recv type: 0x%.2X, data: %s", + response_type); byte = this->read(); if (byte != checksum) { diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 215cc1364f8..799004b1c0b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -111,10 +111,7 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe return ERROR_NOT_INITIALIZED; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(I2C_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty_to(hex_buf, write_buffer, write_count)); -#endif + ESP_LOGV_HEX(TAG, write_buffer, write_count, I2C_MAX_LOG_BYTES, "0x%02X TX %s", address); uint8_t status = 0; if (write_count != 0 || read_count == 0) { diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 931bcfa0889..2b1b83ef6f5 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -167,10 +167,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s jobs[num_jobs++].write.total_bytes = 1; } else { if (write_count != 0) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(I2C_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty_to(hex_buf, write_buffer, write_count)); -#endif + ESP_LOGV_HEX(TAG, write_buffer, write_count, I2C_MAX_LOG_BYTES, "0x%02X TX %s", address); jobs[num_jobs++].command = I2C_MASTER_CMD_START; jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; jobs[num_jobs].write.ack_check = true; diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 6e11975b6c3..0d441ee62dc 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -354,11 +354,8 @@ void ImprovBLEComponent::process_incoming_data_() { return; uint8_t length = this->incoming_data_[1]; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(IMPROV_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "Processing bytes - %s", - format_hex_pretty_to(hex_buf, this->incoming_data_.data(), this->incoming_data_.size())); -#endif + ESP_LOGV_HEX(TAG, this->incoming_data_.data(), this->incoming_data_.size(), IMPROV_MAX_LOG_BYTES, + "Processing bytes - %s"); if (this->incoming_data_.size() - 3 == length) { this->set_error_(improv::ERROR_NONE); improv::ImprovCommand command = improv::parse_improv_data(this->incoming_data_); diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 22bd20d1e5a..7461a2ade40 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -607,17 +607,11 @@ void LD2410Component::readline_(int readch) { return; // Not enough data to process yet } if (ld2410::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next message } else if (ld2410::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index c6137f29f7c..704e3845e73 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -702,17 +702,11 @@ void LD2412Component::readline_(int readch) { return; // Not enough data to process yet } if (ld2412::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next message } else if (ld2412::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index cdf542ee8b8..52327d309e3 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -829,17 +829,11 @@ void LD2450Component::readline_(int readch) { } if (this->buffer_data_[this->buffer_pos_ - 2] == DATA_FRAME_FOOTER[0] && this->buffer_data_[this->buffer_pos_ - 1] == DATA_FRAME_FOOTER[1]) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next frame } else if (ld2450::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp index 2efd34a1d8c..abeb6779cf4 100644 --- a/esphome/components/ld2460/ld2460.cpp +++ b/esphome/components/ld2460/ld2460.cpp @@ -168,10 +168,7 @@ void LD2460Component::readline_(uint8_t readch) { return; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Frame: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Frame: %s"); if (is_data) { this->handle_periodic_data_(); diff --git a/esphome/components/midea_portasplit/midea_portasplit.cpp b/esphome/components/midea_portasplit/midea_portasplit.cpp index a1f22226d9c..309e1f880f4 100644 --- a/esphome/components/midea_portasplit/midea_portasplit.cpp +++ b/esphome/components/midea_portasplit/midea_portasplit.cpp @@ -202,10 +202,7 @@ void PortaSplitClimate::send_frame_(uint8_t proto, uint8_t type, const uint8_t * f[10 + len] = crc8(&f[10], (uint8_t) len); f[11 + len] = checksum(f, 11 + len); this->write_array(f, total); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(sizeof(f))]; - ESP_LOGV(TAG, "TX: %s", format_hex_pretty_to(hex_buf, f, total)); -#endif + ESP_LOGV_HEX(TAG, f, total, sizeof(f), "TX: %s"); } void PortaSplitClimate::send_set_command_() { @@ -276,10 +273,7 @@ void PortaSplitClimate::rx_feed_(uint8_t b) { ESP_LOGW(TAG, "RX checksum mismatch, dropping frame"); return; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_FRAME)]; - ESP_LOGV(TAG, "RX: %s", format_hex_pretty_to(hex_buf, this->rx_.data(), total)); -#endif + ESP_LOGV_HEX(TAG, this->rx_.data(), total, MAX_FRAME, "RX: %s"); this->handle_frame_(this->rx_.data(), total); } diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 9fba222d635..19b8bb3fad3 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -387,10 +387,7 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { break; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MITSUBISHI_FRAME_SIZE)]; -#endif - ESP_LOGV(TAG, "Receiving: %s", format_hex_pretty_to(hex_buf, state_frame, MITSUBISHI_FRAME_SIZE)); + ESP_LOGV_HEX(TAG, state_frame, MITSUBISHI_FRAME_SIZE, MITSUBISHI_FRAME_SIZE, "Receiving: %s"); this->publish_state(); return true; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 3261d9d5694..75e8d547c05 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -458,10 +458,7 @@ void MitsubishiCN105::FrameParser::reset_and_dump_buffer_(const char *prefix) { } void MitsubishiCN105::FrameParser::dump_buffer_vv(const char *prefix, const uint8_t *data, size_t len) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char buf[format_hex_pretty_size(READ_BUFFER_SIZE)]; - ESP_LOGVV(TAG, "%s (%zu): %s", prefix, len, format_hex_pretty_to(buf, data, len)); -#endif + ESP_LOGVV_HEX(TAG, data, len, READ_BUFFER_SIZE, "%s (%zu): %s", prefix, len); } } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index a79a7c3bd8c..d135ae58e63 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -1161,11 +1161,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); return false; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, - format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); + ESP_LOGV_HEX(TAG, pdu.data(), pdu.size(), MODBUS_MAX_LOG_BYTES, "Adding frame to tx queue: %" PRIu8 ":%s", address); this->tx_buffer_.emplace_back(device, address, pdu, options, this->next_seq_++); return true; } diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index 9fb40f90053..f49429b1ece 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -100,11 +100,7 @@ void ModbusBinaryOutput::write_state(bool state) { } if (!data.empty()) { this->warn_write_buffer_deprecated_(LOG_STR("binary output"), this->start_address); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Modbus binary output write raw: %s", - format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), MODBUS_OUTPUT_MAX_LOG_BYTES, "Modbus binary output write raw: %s"); // The lambda filled a legacy raw frame (device address + function code + data). if (!this->send_raw_frame_deprecated_(data)) { ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address()); diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index fe2b2762313..c29378c5436 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -70,11 +70,7 @@ void ModbusSwitch::write_state(bool state) { } if (!data.empty()) { this->warn_write_buffer_deprecated_(LOG_STR("switch"), this->start_address); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Modbus Switch write raw: %s", - format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), MODBUS_SWITCH_MAX_LOG_BYTES, "Modbus Switch write raw: %s"); // The lambda filled a legacy raw frame (device address + function code + data). if (!this->send_raw_frame_deprecated_(data)) { ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index f63f2bc190b..831c264d24e 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -64,11 +64,8 @@ bool MopekaStdCheck::parse_device(const ble_device_base::ESPBTDevice &device) { const auto &manu_data = manu_datas[0]; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MOPEKA_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "[%s] Manufacturer data: %s", addr_str, - format_hex_pretty_to(hex_buf, manu_data.data.data(), manu_data.data.size())); + ESP_LOGVV_HEX(TAG, manu_data.data.data(), manu_data.data.size(), MOPEKA_MAX_LOG_BYTES, "[%s] Manufacturer data: %s", + addr_str); if (manu_data.data.size() != MANUFACTURER_DATA_LENGTH) { ESP_LOGE(TAG, "[%s] Unexpected manu_data size (%d)", addr_str, manu_data.data.size()); diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 34c6a3e44f7..eb0ce241f39 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -38,10 +38,7 @@ bool PN532Spi::write_data(const std::span data) { delay(2); // First byte, communication mode: Write data this->write_byte(0x01); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Writing data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), PN532_MAX_LOG_BYTES, "Writing data: %s"); this->write_array(data.data(), data.size()); this->disable(); @@ -65,10 +62,7 @@ bool PN532Spi::read_data(pn532::PN532Frame &data, size_t len) { data[0] = 0x01; this->read_array(data.data() + 1, len); this->disable(); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Read data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), PN532_MAX_LOG_BYTES, "Read data: %s"); return true; } diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index e86909d0491..eec4e7afa6b 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -119,10 +119,7 @@ bool MR60BHA2Component::validate_message_() { if (at == 7) { if (!validate_checksum(data, 7, header_checksum)) { ESP_LOGE(TAG, "HEAD_CKSUM_FRAME ERROR: 0x%02x", header_checksum); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MR60BHA2_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "GET FRAME: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data, 8)); + ESP_LOGV_HEX(TAG, data, 8, MR60BHA2_MAX_LOG_BYTES, "GET FRAME: %s"); return false; } return true; @@ -137,10 +134,7 @@ bool MR60BHA2Component::validate_message_() { if (at == 8 + length) { if (!validate_checksum(data + 8, length, data_checksum)) { ESP_LOGE(TAG, "DATA_CKSUM_FRAME ERROR: 0x%02x", data_checksum); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MR60BHA2_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "GET FRAME: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data, 8 + length)); + ESP_LOGV_HEX(TAG, data, 8 + length, MR60BHA2_MAX_LOG_BYTES, "GET FRAME: %s"); return false; } } diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index d0e4c635eb2..5a0143071c6 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -329,10 +329,7 @@ void MR60FDA2Component::set_install_height(uint8_t index) { float_to_bytes(INSTALL_HEIGHT[index], &send_data[8]); send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND INSTALL HEIGHT FRAME: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND INSTALL HEIGHT FRAME: %s"); } void MR60FDA2Component::set_height_threshold(uint8_t index) { @@ -342,10 +339,7 @@ void MR60FDA2Component::set_height_threshold(uint8_t index) { float_to_bytes(HEIGHT_THRESHOLD[index], &send_data[8]); send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND HEIGHT THRESHOLD: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND HEIGHT THRESHOLD: %s"); } void MR60FDA2Component::set_sensitivity(uint8_t index) { @@ -357,28 +351,19 @@ void MR60FDA2Component::set_sensitivity(uint8_t index) { send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND SET SENSITIVITY: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND SET SENSITIVITY: %s"); } void MR60FDA2Component::get_radar_parameters() { uint8_t send_data[8] = {0x01, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x06, 0xF6}; this->write_array(send_data, 8); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(8)]; -#endif - ESP_LOGV(TAG, "SEND GET PARAMETERS: %s", format_hex_pretty_to(hex_buf, send_data, 8)); + ESP_LOGV_HEX(TAG, send_data, 8, 8, "SEND GET PARAMETERS: %s"); } void MR60FDA2Component::factory_reset() { uint8_t send_data[8] = {0x01, 0x00, 0x00, 0x00, 0x00, 0x21, 0x10, 0xCF}; this->write_array(send_data, 8); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(8)]; -#endif - ESP_LOGV(TAG, "SEND RESET: %s", format_hex_pretty_to(hex_buf, send_data, 8)); + ESP_LOGV_HEX(TAG, send_data, 8, 8, "SEND RESET: %s"); this->get_radar_parameters(); } diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index c72210e932f..ca3b4267e84 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -84,10 +84,7 @@ bool SonoffD1Output::read_command_(uint8_t *cmd, size_t &len) { // Read a minimal packet if (this->read_array(cmd, 6)) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(6)]; - ESP_LOGV(TAG, "[%04d] Reading from dimmer: %s", this->write_count_, format_hex_pretty_to(hex_buf, cmd, 6)); -#endif + ESP_LOGV_HEX(TAG, cmd, 6, 6, "[%04d] Reading from dimmer: %s", this->write_count_); if (cmd[0] != 0xAA || cmd[1] != 0x55) { ESP_LOGW(TAG, "[%04d] RX: wrong header (%x%x, must be AA55)", this->write_count_, cmd[0], cmd[1]); @@ -101,10 +98,7 @@ bool SonoffD1Output::read_command_(uint8_t *cmd, size_t &len) { return false; } if (this->read_array(&cmd[6], cmd[5] + 1 /*checksum suffix*/)) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf2[format_hex_pretty_size(SONOFF_D1_MAX_CMD_SIZE)]; - ESP_LOGV(TAG, "[%04d] %s", this->write_count_, format_hex_pretty_to(hex_buf2, &cmd[6], cmd[5] + 1)); -#endif + ESP_LOGV_HEX(TAG, &cmd[6], cmd[5] + 1, SONOFF_D1_MAX_CMD_SIZE, "[%04d] %s", this->write_count_); // Check the checksum uint8_t valid_checksum = this->calc_checksum_(cmd, cmd[5] + 7); @@ -178,10 +172,7 @@ bool SonoffD1Output::write_command_(uint8_t *cmd, const size_t len, bool needs_a // 2. UART command initiated by this component can clash with a command initiated by RF uint32_t retries = 10; do { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(SONOFF_D1_MAX_CMD_SIZE)]; - ESP_LOGV(TAG, "[%04d] Writing to the dimmer: %s", this->write_count_, format_hex_pretty_to(hex_buf, cmd, len)); -#endif + ESP_LOGV_HEX(TAG, cmd, len, SONOFF_D1_MAX_CMD_SIZE, "[%04d] Writing to the dimmer: %s", this->write_count_); this->write_array(cmd, len); this->write_count_++; if (!needs_ack) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 0f02ddf3c7a..ebf44e41438 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -50,10 +50,7 @@ static void tinyusb_cdc_rx_callback(int itf, cdcacm_event_t *event) { esp_err_t ret = tinyusb_cdcacm_read(static_cast(itf), rx_buf, CONFIG_TINYUSB_CDC_RX_BUFSIZE, &rx_size); ESP_LOGV(TAG, "tinyusb_cdc_rx_callback itf=%d (size: %u)", itf, rx_size); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char rx_hex_buf[format_hex_pretty_size(USB_CDC_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "rx_buf = %s", format_hex_pretty_to(rx_hex_buf, rx_buf, rx_size)); + ESP_LOGVV_HEX(TAG, rx_buf, rx_size, USB_CDC_MAX_LOG_BYTES, "rx_buf = %s"); if (ret == ESP_OK && rx_size > 0) { RingbufHandle_t rx_ringbuf = instance->get_rx_ringbuf(); @@ -194,10 +191,7 @@ void USBCDCACMInstance::usb_tx_task() { } ESP_LOGV(TAG, "USB TX itf=%d: Read %d bytes from buffer", this->itf_, tx_data_size); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char tx_hex_buf[format_hex_pretty_size(USB_CDC_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "data = %s", format_hex_pretty_to(tx_hex_buf, data, tx_data_size)); + ESP_LOGVV_HEX(TAG, data, tx_data_size, USB_CDC_MAX_LOG_BYTES, "data = %s"); // Serial data will be split up into 64 byte chunks to be sent over USB so this // usually will take multiple iterations diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 57f26b6acc7..90ea5c32094 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -286,10 +286,7 @@ optional parse_xiaomi_header(const ble_device_base::ServiceDa bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address) { if ((raw.size() != 19) && ((raw.size() < 22) || (raw.size() > 24))) { ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): data packet has wrong size (%d)!", raw.size()); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(XIAOMI_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " Packet : %s", format_hex_pretty_to(hex_buf, raw.data(), raw.size())); + ESP_LOGVV_HEX(TAG, raw.data(), raw.size(), XIAOMI_MAX_LOG_BYTES, " Packet : %s"); return false; } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index a7b1fa0ced0..7134e223351 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -311,10 +311,7 @@ void ZWaveProxy::send_frame_(const uint8_t *data, size_t length) { return; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(ZWAVE_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "Sending: %s", format_hex_pretty_to(hex_buf, data, length)); + ESP_LOGVV_HEX(TAG, data, length, ZWAVE_MAX_LOG_BYTES, "Sending: %s"); this->write_array(data, length); } @@ -395,10 +392,7 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; } else { this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_ACK; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(ZWAVE_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty_to(hex_buf, this->buffer_.data(), this->buffer_index_)); + ESP_LOGVV_HEX(TAG, this->buffer_.data(), this->buffer_index_, ZWAVE_MAX_LOG_BYTES, "Received frame: %s"); frame_completed = true; } this->response_handler_(); From 7c6fac91fffb63b9f7617311ded8badfc7a824f9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:49:15 -0400 Subject: [PATCH 050/120] [esp32] Bump recommended ESP-IDF to 5.5.6 (#20987) --- esphome/components/esp32/__init__.py | 9 +++++---- platformio.ini | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index aae1a4c41f6..cd602e6c64b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1183,7 +1183,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), - cv.Version(3, 3, 12): cv.Version(5, 5, 5), + cv.Version(3, 3, 12): cv.Version(5, 5, 6), cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), @@ -1207,9 +1207,9 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 5), - "latest": cv.Version(5, 5, 5), - "dev": cv.Version(5, 5, 5), + "recommended": cv.Version(5, 5, 6), + "latest": cv.Version(5, 5, 6), + "dev": cv.Version(5, 5, 6), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { @@ -1219,6 +1219,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(5, 5, 6): cv.Version(55, 3, 312), cv.Version(5, 5, 5): cv.Version(55, 3, 312), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), diff --git a/platformio.ini b/platformio.ini index fe61470a358..e5d0200ea7c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -147,7 +147,7 @@ extends = common:arduino platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -183,7 +183,7 @@ extra_scripts = extends = common:idf platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = espidf lib_deps = From bd06d37d1a0a47c10f1862092ad5e4b4d6513931 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:49:46 -1000 Subject: [PATCH 051/120] [components] Check I2C chip IDs with check_chip_id (#20943) --- esphome/components/apds9306/apds9306.cpp | 11 ++-------- esphome/components/apds9930/apds9930.cpp | 10 +--------- esphome/components/as7341/as7341.cpp | 8 +------- esphome/components/bh1745/bh1745.cpp | 9 +-------- esphome/components/bmi270/bmi270.cpp | 11 +--------- esphome/components/bq25186/bq25186.cpp | 12 +---------- .../es9038q2m_katana/es9038q2m_katana.cpp | 10 +--------- esphome/components/lis3dh/lis3dh.cpp | 10 +--------- esphome/components/mmc5603/mmc5603.cpp | 11 ++-------- esphome/components/mmc5983/mmc5983.cpp | 17 +++------------- esphome/components/mpl3115a2/mpl3115a2.cpp | 9 +-------- esphome/components/msa3xx/msa3xx.cpp | 10 +--------- esphome/components/qmi8658/qmi8658.cpp | 10 +--------- esphome/components/qwiic_pir/qwiic_pir.cpp | 11 +--------- esphome/components/stts22h/stts22h.cpp | 20 +------------------ esphome/components/stts22h/stts22h.h | 1 - esphome/components/tsl2591/tsl2591.cpp | 12 +---------- 17 files changed, 20 insertions(+), 162 deletions(-) diff --git a/esphome/components/apds9306/apds9306.cpp b/esphome/components/apds9306/apds9306.cpp index af123d8c4f3..bcfcce1f3b4 100644 --- a/esphome/components/apds9306/apds9306.cpp +++ b/esphome/components/apds9306/apds9306.cpp @@ -52,16 +52,9 @@ enum { // APDS9306 registers } void APDS9306::setup() { - uint8_t id; - if (!this->read_byte(APDS9306_PART_ID, &id)) { // Part ID register - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + // 0xB1 for APDS9306, 0xB3 for APDS9306-065; they differ only in bit 1 + if (!this->check_chip_id(this, APDS9306_PART_ID, 0xB1, 0xFD)) return; - } - - if (id != 0xB1 && id != 0xB3) { // 0xB1 for APDS9306 0xB3 for APDS9306-065 - this->mark_failed(LOG_STR("APDS9306 has invalid id")); - return; - } // ALS resolution and measurement, see datasheet or init.py for options uint8_t als_meas_rate = ((this->bit_width_ & 0x07) << 4) | (this->measurement_rate_ & 0x07); diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp index 6debc0d9c7b..a4b836beaa1 100644 --- a/esphome/components/apds9930/apds9930.cpp +++ b/esphome/components/apds9930/apds9930.cpp @@ -64,16 +64,8 @@ static constexpr uint16_t ALS_SATURATION = 1024 * (256 - APDS9930_ATIME_VALUE); bool APDS9930Component::write_reg_(uint8_t reg, uint8_t value) { return this->write_byte(reg | APDS9930_CMD, value); } void APDS9930Component::setup() { - uint8_t id; - if (!this->read_byte(APDS9930_ID | APDS9930_CMD, &id)) { - this->mark_failed(LOG_STR("Communication failed")); + if (!this->check_chip_id(this, APDS9930_ID | APDS9930_CMD, APDS9930_CHIP_ID)) return; - } - if (id != APDS9930_CHIP_ID) { - ESP_LOGE(TAG, "Wrong ID 0x%02X (expected 0x%02X)", id, APDS9930_CHIP_ID); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } const uint8_t control = (this->led_drive_ & 0b11) << CONTROL_PDRIVE_SHIFT | CONTROL_PDIODE_CH1 << CONTROL_PDIODE_SHIFT | diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index 60f576f7bb9..cf02884c5e4 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -9,14 +9,8 @@ ESPHOME_LOG_TAG(TAG, "as7341"); void AS7341Component::setup() { LOG_I2C_DEVICE(this); - // Verify device ID - uint8_t id; - this->read_byte(AS7341_ID, &id); - ESP_LOGCONFIG(TAG, " Read ID: 0x%X", id); - if ((id & 0xFC) != (AS7341_CHIP_ID << 2)) { - this->mark_failed(LOG_STR("Wrong chip ID")); + if (!this->check_chip_id(this, AS7341_ID, AS7341_CHIP_ID << 2, 0xFC)) return; - } // Power on (enter IDLE state) if (!this->enable_power(true)) { diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp index 24744c3330d..ee7b9935f4c 100644 --- a/esphome/components/bh1745/bh1745.cpp +++ b/esphome/components/bh1745/bh1745.cpp @@ -63,15 +63,8 @@ static float calculate_cct(uint16_t red, uint16_t green, uint16_t blue, bool low } void BH1745Component::setup() { - uint8_t manufacturer_id; - if (!this->read_byte(REG_MANUFACTURER_ID, &manufacturer_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, REG_MANUFACTURER_ID, MANUFACTURER_ID)) return; - } - if (manufacturer_id != MANUFACTURER_ID) { - this->mark_failed(LOG_STR("Wrong manufacturer ID")); - return; - } uint8_t system_control; if (!this->read_byte(REG_SYSTEM_CONTROL, &system_control) || (system_control & SYSTEM_CONTROL_PART_ID_MASK) != PART_ID) { diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index e3c6d83a722..0e010b03118 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -78,17 +78,8 @@ bool BMI270Component::load_config_file_() { void BMI270Component::setup() { MotionComponent::setup(); // 1. Verify chip ID - uint8_t chip_id = 0; - if (!this->read_byte(BMI270_REG_CHIP_ID, &chip_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, BMI270_REG_CHIP_ID, BMI270_CHIP_ID_VALUE)) 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(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)) { diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp index edea50d1115..91bbacba467 100644 --- a/esphome/components/bq25186/bq25186.cpp +++ b/esphome/components/bq25186/bq25186.cpp @@ -79,19 +79,9 @@ bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { } void BQ25186Component::setup() { - uint8_t mask_id; - if (!this->read_byte(BQ25186_REG_MASK_ID, &mask_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return; - } - // Another charger at this address could lay its registers out differently, so do not write into it - const uint8_t device_id = mask_id & 0x0F; - if (device_id != 0x01) { - ESP_LOGE(TAG, "Unexpected device id 0x%X (expected 0x1)", device_id); - this->mark_failed(LOG_STR("Not a BQ25186")); + if (!this->check_chip_id(this, BQ25186_REG_MASK_ID, 0x01, 0x0F)) return; - } // A failed write only sets the warning status: the chip has identified itself, so a later // apply_configuration may retry diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp index 464f971acef..e3cd3820d7e 100644 --- a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -69,16 +69,8 @@ const LogString *deemphasis_to_str(DeemphasisMode mode) { } // namespace void ES9038Q2MKatana::setup() { - uint8_t chip_id = 0; - if (!this->read_byte(KATANA_REG_CHIP_ID, &chip_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, KATANA_REG_CHIP_ID, KATANA_CHIP_ID)) return; - } - if (chip_id != KATANA_CHIP_ID) { - ESP_LOGE(TAG, "Unexpected chip ID 0x%02X", chip_id); - this->mark_failed(LOG_STR("Unexpected chip ID")); - return; - } if (!this->write_byte(KATANA_REG_RESET, 0x01)) { this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp index f12c00852cd..64e6da4ef95 100644 --- a/esphome/components/lis3dh/lis3dh.cpp +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -27,16 +27,8 @@ static const uint16_t RATE_HZ[] PROGMEM = {1, 10, 25, 50, 100, 200, 400, 1620, 1 void LIS3DHComponent::setup() { MotionComponent::setup(); - uint8_t who_am_i = 0; - if (!this->read_byte(LIS3DH_REG_WHO_AM_I, &who_am_i)) { - this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); + if (!this->check_chip_id(this, LIS3DH_REG_WHO_AM_I, LIS3DH_WHO_AM_I_VALUE)) 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(LOG_STR("Wrong chip ID")); - return; - } if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) { this->mark_failed(LOG_STR("Failed to configure sensor")); diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index fa5ba5985d7..bb7ea34cbdf 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -32,16 +32,9 @@ static const uint8_t MMC56X3_CTRL2_REG = 0x1D; static const uint8_t MMC5603_ODR_REG = 0x1A; void MMC5603Component::setup() { - uint8_t id = 0; - if (!this->read_byte(MMC56X3_PRODUCT_ID, &id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + // Some chips report 0 instead of MMC56X3_CHIP_ID (0x10), so bit 4 is ignored + if (!this->check_chip_id(this, MMC56X3_PRODUCT_ID, 0x00, static_cast(~MMC56X3_CHIP_ID))) return; - } - - if (id != 0 && id != MMC56X3_CHIP_ID) { // ID is not reported correctly by all chips, 0 on some chips - 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 this->mark_failed(LOG_STR("Control 1 failed for set bit")); diff --git a/esphome/components/mmc5983/mmc5983.cpp b/esphome/components/mmc5983/mmc5983.cpp index f7840aafbe9..8a636dacb5d 100644 --- a/esphome/components/mmc5983/mmc5983.cpp +++ b/esphome/components/mmc5983/mmc5983.cpp @@ -14,6 +14,7 @@ constexpr uint8_t IC1_ADDR = 0x0a; constexpr uint8_t IC2_ADDR = 0x0b; constexpr uint8_t IC3_ADDR = 0x0c; constexpr uint8_t PRODUCT_ID_ADDR = 0x2f; +constexpr uint8_t MMC5983_PRODUCT_ID = 0x30; float convert_data_to_millitesla(uint8_t data_17_10, uint8_t data_9_2, uint8_t data_1_0) { int32_t counts = (data_17_10 << 10) | (data_9_2 << 2) | data_1_0; @@ -66,25 +67,13 @@ void MMC5983Component::update() { } void MMC5983Component::setup() { - // Verify product id. - const uint8_t mmc5983_product_id = 0x30; - uint8_t id; - i2c::ErrorCode err = this->read_register(PRODUCT_ID_ADDR, &id, 1); - if (err != i2c::ErrorCode::ERROR_OK) { - ESP_LOGE(TAG, "Reading product id failed with i2c error %d", err); - this->mark_failed(); + if (!this->check_chip_id(this, PRODUCT_ID_ADDR, MMC5983_PRODUCT_ID)) return; - } - if (id != mmc5983_product_id) { - ESP_LOGE(TAG, "Product id 0x%02x does not match expected value 0x%02x", id, mmc5983_product_id); - this->mark_failed(); - return; - } // Initialize Internal Control registers to 0. // Internal Control 0. const uint8_t zero = 0; - err = this->write_register(IC0_ADDR, &zero, 1); + i2c::ErrorCode err = this->write_register(IC0_ADDR, &zero, 1); if (err != i2c::ErrorCode::ERROR_OK) { ESP_LOGE(TAG, "Initializing Internal Control 0 failed with i2c error %d", err); this->mark_failed(); diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index 4a206b30dfe..58f346ab77a 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -8,15 +8,8 @@ namespace esphome::mpl3115a2 { ESPHOME_LOG_TAG(TAG, "mpl3115a2"); void MPL3115A2Component::setup() { - uint8_t whoami = 0xFF; - if (!this->read_byte(MPL3115A2_WHOAMI, &whoami)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, MPL3115A2_WHOAMI, 0xC4)) return; - } - if (whoami != 0xC4) { - this->mark_failed(LOG_STR("MPL3115A2 has invalid id")); - return; - } // reset this->write_byte(MPL3115A2_CTRL_REG1, MPL3115A2_CTRL_REG1_RST); diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index 19a4b067bc7..ed713cb30e1 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -117,16 +117,8 @@ const char *orientation_xy_to_string(OrientationXY orientation) { const char *orientation_z_to_string(bool orientation) { return orientation ? "Downwards looking" : "Upwards looking"; } void MSA3xxComponent::setup() { - uint8_t part_id{0xff}; - if (!this->read_byte(static_cast(RegisterMap::PART_ID), &part_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, static_cast(RegisterMap::PART_ID), MSA_3XX_PART_ID)) 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; - } // Resolution LSB/g // Range : MSA301 : MSA311 diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index da1f735fb9e..f03aac9a80c 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -30,16 +30,8 @@ void QMI8658Component::setup() { MotionComponent::setup(); // 1. Verify chip ID - uint8_t who_am_i = 0; - if (!this->read_byte(QMI8658_REG_WHO_AM_I, &who_am_i)) { - this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); + if (!this->check_chip_id(this, QMI8658_REG_WHO_AM_I, QMI8658_WHO_AM_I_VALUE)) 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(LOG_STR("Wrong chip ID")); - return; - } // 2. Soft reset if (!this->write_byte(QMI8658_REG_RESET, QMI8658_RESET_CMD)) { diff --git a/esphome/components/qwiic_pir/qwiic_pir.cpp b/esphome/components/qwiic_pir/qwiic_pir.cpp index de9be3c8f0c..4b30a21a831 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.cpp +++ b/esphome/components/qwiic_pir/qwiic_pir.cpp @@ -6,17 +6,8 @@ namespace esphome::qwiic_pir { ESPHOME_LOG_TAG(TAG, "qwiic_pir"); 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)) { - this->mark_failed(LOG_STR("Failed to read chip ID")); + if (!this->check_chip_id(this, QWIIC_PIR_CHIP_ID, QWIIC_PIR_DEVICE_ID)) return; - } - - if (chip_id != QWIIC_PIR_DEVICE_ID) { - this->mark_failed(LOG_STR("Unknown chip ID")); - return; - } if (!this->write_byte_16(QWIIC_PIR_DEBOUNCE_TIME, this->debounce_time_)) { this->mark_failed(LOG_STR("Failed to configure debounce time")); diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index 7d66dd0a183..5132b2a3aa5 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -19,11 +19,8 @@ static const uint8_t WHOAMI_STTS22H_IDENTIFICATION = 0xA0; // ID value of STTS2 static const float SENSOR_SCALE = 0.01f; // Sensor resolution in degrees Celsius void STTS22HComponent::setup() { - // Check if device is a STTS22H - if (!this->is_stts22h_sensor_()) { - this->mark_failed(LOG_STR("Device is not a STTS22H sensor")); + if (!this->check_chip_id(this, WHOAMI_REG, WHOAMI_STTS22H_IDENTIFICATION)) return; - } this->initialize_sensor_(); } @@ -58,21 +55,6 @@ float STTS22HComponent::read_temperature_() { return temp_raw_value * SENSOR_SCALE; // Apply sensor resolution } -bool STTS22HComponent::is_stts22h_sensor_() { - uint8_t whoami_value; - if (this->read_register(WHOAMI_REG, &whoami_value, 1) != i2c::NO_ERROR) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return false; - } - - if (whoami_value != WHOAMI_STTS22H_IDENTIFICATION) { - this->mark_failed(LOG_STR("Unexpected WHOAMI identifier. Sensor is not a STTS22H")); - return false; - } - - return true; -} - void STTS22HComponent::initialize_sensor_() { // Read current CTRL_REG configuration uint8_t ctrl_value; diff --git a/esphome/components/stts22h/stts22h.h b/esphome/components/stts22h/stts22h.h index d8d7a485cf6..e1becb4b507 100644 --- a/esphome/components/stts22h/stts22h.h +++ b/esphome/components/stts22h/stts22h.h @@ -14,7 +14,6 @@ class STTS22HComponent final : public sensor::Sensor, public PollingComponent, p protected: void initialize_sensor_(); - bool is_stts22h_sensor_(); float read_temperature_(); }; diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index f1f9ae9cfdb..493f4ad3627 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -62,18 +62,8 @@ void TSL2591Component::setup() { break; } - uint8_t id; - if (!this->read_byte(TSL2591_COMMAND_BIT | TSL2591_REGISTER_DEVICE_ID, &id)) { - ESP_LOGE(TAG, "I2C read failed"); - this->mark_failed(); + if (!this->check_chip_id(this, TSL2591_COMMAND_BIT | TSL2591_REGISTER_DEVICE_ID, 0x50)) return; - } - - if (id != 0x50) { - ESP_LOGE(TAG, "Unknown chip ID"); - this->mark_failed(); - return; - } this->set_integration_time_and_gain(this->integration_time_, this->gain_); this->disable_if_power_saving_(); From 1ead61f11f3349fc7cd41dede4891b5de96cfd1e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:50:11 -0400 Subject: [PATCH 052/120] [esp32] Remove trailing comment from ECDSA test signing key (#20988) --- tests/components/esp32/dummy_signing_key_v1_ecdsa.pem | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem index bd092056069..eb27f9b2c48 100644 --- a/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem +++ b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem @@ -4,4 +4,3 @@ MHcCAQEEIEZIp96p7Z7QN6vxOFE5FdRNm535vW81Ax07KnGxVjiMoAoGCCqGSM49 AwEHoUQDQgAEK+fBQDn1Q+r5lGwcDoMUgeg2Aq16LLrLUz7xWI6mS0PUClzolDIo eaV/Pfjl7zAvkbQQsZq3rTNnr1eGAk5P+A== -----END EC PRIVATE KEY----- -*** DO NOT USE THIS KEY...EVER *** From d16d8c111ebf992f3d9054295298d030533ef1f2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:50:54 -0400 Subject: [PATCH 053/120] [debug] Log uptime in dump_config (#20989) --- esphome/components/debug/debug_component.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 97f8550c5bc..b2cb1910f68 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -34,6 +34,15 @@ void DebugComponent::dump_config() { this->free_heap_ = get_free_heap_(); ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); + uint32_t uptime = static_cast(millis_64() / 1000); + uint32_t days = uptime / 86400; + uptime -= days * 86400; + uint32_t hours = uptime / 3600; + uptime -= hours * 3600; + uint32_t minutes = uptime / 60; + uptime -= minutes * 60; + ESP_LOGD(TAG, "Uptime: %" PRIu32 "d %02" PRIu32 "h %02" PRIu32 "m %02" PRIu32 "s", days, hours, minutes, uptime); + pos = get_device_info_(std::span(device_info_buffer), pos); #ifdef USE_TEXT_SENSOR From 635df9c6ada2d7eb88ce69a60b635dbd45ae8eda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:51:29 -1000 Subject: [PATCH 054/120] [socket] Share the allow-list reject log and config lines through Ipv4Allow (#20983) --- esphome/components/socket/__init__.py | 1 + esphome/components/socket/ipv4_allow.cpp | 36 +++++++++++++++++ esphome/components/socket/ipv4_allow.h | 9 ++++- esphome/components/socket/tcp_listener.cpp | 22 ++-------- esphome/components/socket/tcp_listener.h | 1 - .../socket/test_ipv4_allow_host.cpp | 40 ++++++++++++++++++- 6 files changed, 87 insertions(+), 22 deletions(-) create mode 100644 esphome/components/socket/ipv4_allow.cpp diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index effa7d864fc..09d58d22e9f 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -274,6 +274,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines( "lwip_raw_udp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "bsd_sockets_impl.cpp": "USE_SOCKET_IMPL_BSD_SOCKETS", "lwip_sockets_impl.cpp": "USE_SOCKET_IMPL_LWIP_SOCKETS", + "ipv4_allow.cpp": "USE_SOCKET_IPV4_ALLOW", "ipv4_resolve.cpp": "USE_SOCKET_IPV4_RESOLVE", "tcp_client_link.cpp": "USE_SOCKET_TCP_CLIENT_LINK", "tcp_listener.cpp": "USE_SOCKET_TCP_LISTENER", diff --git a/esphome/components/socket/ipv4_allow.cpp b/esphome/components/socket/ipv4_allow.cpp new file mode 100644 index 00000000000..4791eeda369 --- /dev/null +++ b/esphome/components/socket/ipv4_allow.cpp @@ -0,0 +1,36 @@ +#include "ipv4_allow.h" + +#ifdef USE_SOCKET_IPV4_ALLOW + +#include "esphome/core/log.h" + +#include + +namespace esphome::socket { + +bool Ipv4Allow::admit(const struct sockaddr *peer, socklen_t len, const char *tag, uint32_t now) { + if (this->allows(peer)) { + return true; + } + if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { + this->last_reject_log_ms_ = now; + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(peer, len, std::span(text)); + ESP_LOGW(tag, "Rejected %s", text); + } + return false; +} + +void Ipv4Allow::dump_config(const char *tag) const { + for (size_t i = 0; i < this->count_; i++) { + const Ipv4AllowEntry e = this->entry(i); + // Network order is dotted order, and the contiguous mask's popcount is the prefix. + const auto *b = reinterpret_cast(&e.addr); + ESP_LOGCONFIG(tag, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], + static_cast(__builtin_popcount(e.mask))); + } +} + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/socket/ipv4_allow.h b/esphome/components/socket/ipv4_allow.h index f2f66edc05f..64116691812 100644 --- a/esphome/components/socket/ipv4_allow.h +++ b/esphome/components/socket/ipv4_allow.h @@ -47,6 +47,10 @@ class Ipv4Allow { return false; } + /// allows(peer), logging a refused peer at most once every REJECT_LOG_INTERVAL_MS. + bool admit(const struct sockaddr *peer, socklen_t len, const char *tag, uint32_t now); + /// One config line per allowed network. + void dump_config(const char *tag) const; size_t size() const { return this->count_; } /// A copy of entry i, read from flash. Ipv4AllowEntry entry(size_t i) const { @@ -55,9 +59,12 @@ class Ipv4Allow { return e; } - private: + protected: + static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; + const Ipv4AllowEntry *entries_{nullptr}; size_t count_{0}; + uint32_t last_reject_log_ms_{0}; }; } // namespace esphome::socket diff --git a/esphome/components/socket/tcp_listener.cpp b/esphome/components/socket/tcp_listener.cpp index e1f00e29277..cfd53b9e83f 100644 --- a/esphome/components/socket/tcp_listener.cpp +++ b/esphome/components/socket/tcp_listener.cpp @@ -12,9 +12,6 @@ namespace esphome::socket { // One client at a time; a second connection waits in the stack until the first drops. static constexpr int LISTEN_BACKLOG = 1; -#ifdef USE_SOCKET_IPV4_ALLOW -static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; -#endif void TcpListener::try_listen_(TcpClientLink &link) { this->listen_ = socket_ip_loop_monitored(SOCK_STREAM, IPPROTO_TCP); @@ -57,31 +54,20 @@ void TcpListener::accept_(TcpClientLink &link) { return; } const auto *sa = reinterpret_cast(&peer); - char text[SOCKADDR_STR_LEN]; - format_sockaddr_to(sa, peer_len, std::span(text)); #ifdef USE_SOCKET_IPV4_ALLOW - if (!this->allow_.allows(sa)) { - uint32_t now = App.get_loop_component_start_time(); - if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { - this->last_reject_log_ms_ = now; - ESP_LOGW(this->tag_, "Rejected %s", text); - } + if (!this->allow_.admit(sa, peer_len, this->tag_, App.get_loop_component_start_time())) { return; } #endif + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(sa, peer_len, std::span(text)); link.adopt(std::move(client)); ESP_LOGI(this->tag_, "Client connected from %s", text); } void TcpListener::dump_config() const { #ifdef USE_SOCKET_IPV4_ALLOW - for (size_t i = 0; i < this->allow_.size(); i++) { - Ipv4AllowEntry e = this->allow_.entry(i); - // Network order is dotted order, and the contiguous mask's popcount is the prefix. - const auto *b = reinterpret_cast(&e.addr); - ESP_LOGCONFIG(this->tag_, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], - static_cast(__builtin_popcount(e.mask))); - } + this->allow_.dump_config(this->tag_); #endif } diff --git a/esphome/components/socket/tcp_listener.h b/esphome/components/socket/tcp_listener.h index a42080e641f..6448a969bdd 100644 --- a/esphome/components/socket/tcp_listener.h +++ b/esphome/components/socket/tcp_listener.h @@ -50,7 +50,6 @@ class TcpListener { std::unique_ptr listen_; const char *tag_{nullptr}; #ifdef USE_SOCKET_IPV4_ALLOW - uint32_t last_reject_log_ms_{0}; Ipv4Allow allow_; #endif }; diff --git a/tests/components/socket/test_ipv4_allow_host.cpp b/tests/components/socket/test_ipv4_allow_host.cpp index a8924de3713..ab89a59b78a 100644 --- a/tests/components/socket/test_ipv4_allow_host.cpp +++ b/tests/components/socket/test_ipv4_allow_host.cpp @@ -9,8 +9,8 @@ namespace esphome::socket::testing { -// The size_t count packs into the pointer's padding; no RAM over a uint8_t. -static_assert(sizeof(Ipv4Allow) == 2 * sizeof(void *), "unexpected padding in Ipv4Allow"); +// Entries pointer, count and the reject-log timestamp; nothing else may creep in. +static_assert(sizeof(Ipv4Allow) <= 3 * sizeof(void *), "unexpected padding in Ipv4Allow"); // 192.168.175.20/32 and 192.168.175.0/24, network order, host bits cleared, // mirroring what add_ipv4_allow emits. @@ -26,6 +26,42 @@ static bool allows_peer(const Ipv4Allow &list, const char *ip) { return list.allows(reinterpret_cast(&peer)); } +// Exposes the reject-log timestamp, the only trace the throttle leaves besides the log line. +struct ThrottledAllow : public Ipv4Allow { + using Ipv4Allow::last_reject_log_ms_; +}; + +// admit() with the same production parser. +static bool admits_peer(Ipv4Allow &list, const char *ip, uint32_t now) { + struct sockaddr_storage peer {}; + socklen_t len = set_sockaddr(reinterpret_cast(&peer), sizeof(peer), ip, 0); + EXPECT_NE(len, 0); + return list.admit(reinterpret_cast(&peer), len, "test", now); +} + +TEST(Ipv4Allow, AdmitLogsARejectAtMostOncePerInterval) { + ThrottledAllow list; + list.set(ENTRIES, std::size(ENTRIES)); + EXPECT_TRUE(admits_peer(list, "192.168.175.66", 1000)); + EXPECT_TRUE(admits_peer(list, "::ffff:192.168.175.20", 1000)); + EXPECT_EQ(list.last_reject_log_ms_, 0u); + + EXPECT_FALSE(admits_peer(list, "10.0.0.1", 1000)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + // Inside the interval the peer is still refused, but the log line is skipped. + EXPECT_FALSE(admits_peer(list, "10.0.0.2", 1001)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + EXPECT_FALSE(admits_peer(list, "10.0.0.3", 5999)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + // Exactly one interval later it logs again. + EXPECT_FALSE(admits_peer(list, "10.0.0.4", 6000)); + EXPECT_EQ(list.last_reject_log_ms_, 6000u); + // An admitted peer leaves the throttle alone. + EXPECT_TRUE(admits_peer(list, "192.168.175.1", 6001)); + EXPECT_EQ(list.last_reject_log_ms_, 6000u); + list.dump_config("test"); +} + TEST(Ipv4Allow, EmptyAllowsEveryPeer) { Ipv4Allow list; EXPECT_TRUE(list.allows(htonl(0xC0A8AF01))); From 35276a38bc7e769f8e9c2f32ec1757338a354e34 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 17 Oct 2026 18:18:52 +0100 Subject: [PATCH 055/120] [rtsp_server] Add an RTSP server that streams the esp32_camera picture (#17990) Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/camera/camera.h | 3 +- esphome/components/rtsp_server/__init__.py | 85 +++ .../components/rtsp_server/rtsp_server.cpp | 144 +++++ esphome/components/rtsp_server/rtsp_server.h | 50 ++ .../components/rtsp_server/rtsp_session.cpp | 545 ++++++++++++++++++ esphome/components/rtsp_server/rtsp_session.h | 119 ++++ esphome/core/defines.h | 1 + tests/components/rtsp_server/__init__.py | 13 + tests/components/rtsp_server/common.yaml | 10 + .../rtsp_server/test.esp32-idf.yaml | 3 + .../rtsp_server/test_rtsp_session_host.cpp | 367 ++++++++++++ 12 files changed, 1340 insertions(+), 1 deletion(-) create mode 100644 esphome/components/rtsp_server/__init__.py create mode 100644 esphome/components/rtsp_server/rtsp_server.cpp create mode 100644 esphome/components/rtsp_server/rtsp_server.h create mode 100644 esphome/components/rtsp_server/rtsp_session.cpp create mode 100644 esphome/components/rtsp_server/rtsp_session.h create mode 100644 tests/components/rtsp_server/__init__.py create mode 100644 tests/components/rtsp_server/common.yaml create mode 100644 tests/components/rtsp_server/test.esp32-idf.yaml create mode 100644 tests/components/rtsp_server/test_rtsp_session_host.cpp diff --git a/CODEOWNERS b/CODEOWNERS index c9ff7c6bb32..f23f3613032 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -512,6 +512,7 @@ esphome/components/rp2040_pwm/* @jesserockz esphome/components/rp2_ble_tracker/* @bdraco esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 +esphome/components/rtsp_server/* @max246 esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index 433361d2985..9e3a95220fa 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -11,8 +11,9 @@ namespace esphome::camera { * IDLE: Camera requests to send an image to the API. * API_REQUESTER: API requests a new image. * WEB_REQUESTER: ESP32 web server request an image. Ignored by API. + * RTSP_REQUESTER: RTSP server requests a stream of images. Ignored by API and web server. */ -enum CameraRequester : uint8_t { IDLE, API_REQUESTER, WEB_REQUESTER }; +enum CameraRequester : uint8_t { IDLE, API_REQUESTER, WEB_REQUESTER, RTSP_REQUESTER }; /// Enumeration of different pixel formats. enum PixelFormat : uint8_t { diff --git a/esphome/components/rtsp_server/__init__.py b/esphome/components/rtsp_server/__init__.py new file mode 100644 index 00000000000..86b1d380267 --- /dev/null +++ b/esphome/components/rtsp_server/__init__.py @@ -0,0 +1,85 @@ +import esphome.codegen as cg +from esphome.components import network, socket +from esphome.components.const import CONF_ALLOWED_IPS +from esphome.components.esp32_camera import ( + CONF_JPEG_QUALITY, + CONF_PIXEL_FORMAT, + DOMAIN as ESP32_CAMERA_DOMAIN, + FRAME_SIZES, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MAX_CONNECTIONS, CONF_PORT, CONF_RESOLUTION +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@max246"] +AUTO_LOAD = ["camera", "socket"] +DEPENDENCIES = ["network"] +DOMAIN = "rtsp_server" + +# RFC 2435 stores width and height in units of 8 pixels in one byte each. +RTP_JPEG_MAX_DIMENSION = 255 * 8 + +rtsp_server_ns = cg.esphome_ns.namespace("rtsp_server") +RTSPServer = rtsp_server_ns.class_("RTSPServer", cg.Component) + + +def _consume_rtsp_server_sockets(config: ConfigType) -> ConfigType: + socket.consume_sockets(config[CONF_MAX_CONNECTIONS], DOMAIN)(config) + socket.consume_sockets(1, DOMAIN, socket.SocketType.TCP_LISTEN)(config) + # A 13 KB frame fills the default 5.7 KB TCP send buffer four segments at a time; over Wi-Fi + # that is about 1 fps, so ask for the larger buffers (network: enable_high_performance: false undoes it) + network.require_high_performance_networking() + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RTSPServer), + cv.Optional(CONF_PORT, default=554): cv.port, + cv.Optional(CONF_MAX_CONNECTIONS, default=2): cv.int_range(min=1, max=4), + cv.Optional(CONF_ALLOWED_IPS): socket.IPV4_ALLOW_SCHEMA, + } + ).extend(cv.COMPONENT_SCHEMA), + _consume_rtsp_server_sockets, +) + + +def _final_validate(config: ConfigType) -> None: + camera = fv.full_config.get().get(ESP32_CAMERA_DOMAIN) + if camera is None: + raise cv.Invalid( + f"{DOMAIN} requires a camera, add an '{ESP32_CAMERA_DOMAIN}:' block" + ) + pixel_format = camera[CONF_PIXEL_FORMAT] + # A GRAYSCALE frame converts to a one-component JPEG, which RTP/JPEG cannot carry. + if pixel_format == "GRAYSCALE": + raise cv.Invalid( + f"{DOMAIN} cannot stream '{CONF_PIXEL_FORMAT}: GRAYSCALE', use a color " + f"format in '{ESP32_CAMERA_DOMAIN}:'" + ) + # Other formats are converted to JPEG only when jpeg_quality is set. + if pixel_format != "JPEG" and camera[CONF_JPEG_QUALITY] == 0: + raise cv.Invalid( + f"{DOMAIN} streams JPEG frames, set '{CONF_JPEG_QUALITY}' in " + f"'{ESP32_CAMERA_DOMAIN}:' to convert '{CONF_PIXEL_FORMAT}: {pixel_format}'" + ) + resolution = camera[CONF_RESOLUTION] + # The enum names end in the frame size, e.g. ESP32_CAMERA_SIZE_2560X1440 + width, height = map(int, str(FRAME_SIZES[resolution]).rsplit("_", 1)[1].split("X")) + if max(width, height) > RTP_JPEG_MAX_DIMENSION: + raise cv.Invalid( + f"{DOMAIN} supports frames up to {RTP_JPEG_MAX_DIMENSION} pixels per side, " + f"lower '{CONF_RESOLUTION}' in '{ESP32_CAMERA_DOMAIN}:' (got {width}x{height})" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_PORT]) + await cg.register_component(var, config) + cg.add_define("MAX_RTSP_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) + socket.add_ipv4_allow(var.set_allow, config.get(CONF_ALLOWED_IPS), config[CONF_ID]) diff --git a/esphome/components/rtsp_server/rtsp_server.cpp b/esphome/components/rtsp_server/rtsp_server.cpp new file mode 100644 index 00000000000..3c344e354d7 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_server.cpp @@ -0,0 +1,144 @@ +#include "rtsp_server.h" + +#ifdef USE_CAMERA + +#include +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::rtsp_server { + +ESPHOME_LOG_TAG(TAG, "rtsp_server"); + +void RTSPServer::socket_failed_(const char *what) { + ESP_LOGE(TAG, "Socket %s failed: errno %d", what, errno); + delete this->socket_; + this->socket_ = nullptr; + this->mark_failed(); +} + +void RTSPServer::setup() { + this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); + if (this->socket_ == nullptr) { + ESP_LOGE(TAG, "Socket creation failed: errno %d", errno); + this->mark_failed(); + return; + } + + int enable = 1; + this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)); + + if (this->socket_->setblocking(false) != 0) { + this->socket_failed_("nonblocking"); + return; + } + + struct sockaddr_storage server {}; + socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); + if (sl == 0 || this->socket_->bind((struct sockaddr *) &server, sl) != 0) { + this->socket_failed_("bind"); + return; + } + + if (this->socket_->listen(4) != 0) { + this->socket_failed_("listen"); + return; + } + + camera::Camera::instance()->add_listener(this); +} + +void RTSPServer::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (this->socket_ != nullptr && this->socket_->ready()) { + this->accept_new_connections_(now); + } + + bool playing = false; + for (auto &session : this->sessions_) { + session.loop(now, this->port_); + playing |= session.is_playing(); + } + + if (playing != this->streaming_) { + this->streaming_ = playing; + if (playing) { + camera::Camera::instance()->start_stream(camera::RTSP_REQUESTER); + } else { + camera::Camera::instance()->stop_stream(camera::RTSP_REQUESTER); + } + } +} + +void RTSPServer::accept_new_connections_(uint32_t now) { + while (true) { + struct sockaddr_storage source_addr; + socklen_t addr_len = sizeof(source_addr); + + auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); + if (!sock) + break; +#ifdef USE_SOCKET_IPV4_ALLOW + if (!this->allow_.admit(reinterpret_cast(&source_addr), addr_len, TAG, now)) + continue; +#endif + + RTSPSession *free_slot = nullptr; + for (auto &session : this->sessions_) { + if (!session.is_active()) { + free_slot = &session; + break; + } + } + if (free_slot == nullptr) { + ESP_LOGW(TAG, "Max connections (%d) reached, rejecting connection", MAX_RTSP_CONNECTIONS); + continue; + } + + ESP_LOGD(TAG, "Accepted RTSP connection"); + free_slot->open(std::move(sock), now); + } +} + +void RTSPServer::on_camera_image(const std::shared_ptr &image) { + if (!this->streaming_ || !image->was_requested_by(camera::RTSP_REQUESTER)) + return; + + JpegInfo jpeg; + if (!parse_jpeg_info(image->get_data_buffer(), image->get_data_length(), jpeg) || + jpeg.width > RTP_JPEG_MAX_DIMENSION || jpeg.height > RTP_JPEG_MAX_DIMENSION) { + if (!this->frame_warned_) { + this->frame_warned_ = true; + ESP_LOGW(TAG, + "Dropping frame %" PRIu16 "x%" PRIu16 ": not a baseline 4:2:2/4:2:0 JPEG without restart markers" + " of at most %" PRIu16 " px per side", + jpeg.width, jpeg.height, RTP_JPEG_MAX_DIMENSION); + } + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + for (auto &session : this->sessions_) { + session.on_new_frame(image, jpeg, now); + } +} + +void RTSPServer::dump_config() { + ESP_LOGCONFIG(TAG, + "RTSP Server:\n" + " Port: %u\n" + " Max connections: %u", + this->port_, MAX_RTSP_CONNECTIONS); +#ifdef USE_SOCKET_IPV4_ALLOW + this->allow_.dump_config(TAG); +#endif +} + +float RTSPServer::get_setup_priority() const { return setup_priority::LATE; } + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_server.h b/esphome/components/rtsp_server/rtsp_server.h new file mode 100644 index 00000000000..48b7fd77ed5 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_server.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_CAMERA + +#include +#include + +#include "esphome/components/camera/camera.h" +#ifdef USE_SOCKET_IPV4_ALLOW +#include "esphome/components/socket/ipv4_allow.h" +#endif +#include "esphome/components/socket/socket.h" +#include "esphome/core/component.h" +#include "rtsp_session.h" + +namespace esphome::rtsp_server { + +class RTSPServer : public Component, public camera::CameraListener { + public: + explicit RTSPServer(uint16_t port) : port_(port) {} + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + void on_camera_image(const std::shared_ptr &image) override; +#ifdef USE_SOCKET_IPV4_ALLOW + void set_allow(const socket::Ipv4AllowEntry *entries, size_t count) { this->allow_.set(entries, count); } +#endif + + protected: + void accept_new_connections_(uint32_t now); + void socket_failed_(const char *what); + + socket::ListenSocket *socket_{nullptr}; + std::array sessions_{}; +#ifdef USE_SOCKET_IPV4_ALLOW + socket::Ipv4Allow allow_; +#endif + uint16_t port_; + bool streaming_{false}; + bool frame_warned_{false}; +}; + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_session.cpp b/esphome/components/rtsp_server/rtsp_session.cpp new file mode 100644 index 00000000000..ee188f07b20 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_session.cpp @@ -0,0 +1,545 @@ +#include "rtsp_session.h" + +#ifdef USE_CAMERA + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::rtsp_server { + +ESPHOME_LOG_TAG(TAG, "rtsp_server"); + +static constexpr uint8_t RTP_PAYLOAD_TYPE_JPEG = 26; +static constexpr uint32_t RTP_CLOCK_RATE = 90000; +static constexpr uint32_t SESSION_TIMEOUT_MS = 60000; +static constexpr char REQUEST_END[] = "\r\n\r\n"; +static constexpr size_t REQUEST_END_LEN = sizeof(REQUEST_END) - 1; +static constexpr uint8_t ZERO_FILL[64]{}; +static constexpr char SDP_BODY[] = "v=0\r\n" + "o=- 0 0 IN IP4 0.0.0.0\r\n" + "s=ESPHome RTSP\r\n" + "t=0 0\r\n" + "m=video 0 RTP/AVP 26\r\n" + "a=control:trackID=0\r\n"; + +namespace { + +StringRef trim(const StringRef &s) { + const char *start = s.c_str(); + size_t len = s.size(); + while (len > 0 && (*start == ' ' || *start == '\t')) { + start++; + len--; + } + while (len > 0 && (start[len - 1] == ' ' || start[len - 1] == '\t' || start[len - 1] == '\r')) { + len--; + } + return {start, len}; +} + +const char *find_request_end(const char *buf, size_t len) { + for (size_t i = 0; i + REQUEST_END_LEN <= len; i++) { + if (std::memcmp(buf + i, REQUEST_END, REQUEST_END_LEN) == 0) + return buf + i; + } + return nullptr; +} + +/// Bounded substring search; StringRef::find() uses strstr and could run past the header value. +bool contains(const StringRef &hay, const StringRef &needle) { + if (needle.size() > hay.size()) + return false; + for (size_t i = 0; i + needle.size() <= hay.size(); i++) { + if (std::memcmp(hay.c_str() + i, needle.c_str(), needle.size()) == 0) + return true; + } + return false; +} + +} // namespace + +/// Scans JPEG marker segments (SOF0/1, DQT, SOS) for the dimensions, quantization tables and the start of +/// the entropy-coded scan data. Assumes the standard Huffman tables (true for esp32-camera), so DHT segments +/// are not parsed; receivers rebuild them per RFC 2435. Only three-component 4:2:2 or 4:2:0 frames without +/// restart markers (DRI) can be carried as RFC 2435 types 0 and 1; anything else returns false. +bool parse_jpeg_info(const uint8_t *data, size_t len, JpegInfo &out) { + if (len < 4 || data[0] != 0xFF || data[1] != 0xD8) + return false; // no SOI + + size_t pos = 2; + while (pos + 2 <= len) { + if (data[pos] != 0xFF) { + pos++; // resync on a stray non-marker byte + continue; + } + uint8_t marker = data[pos + 1]; + if (marker == 0xFF) { + pos++; // fill byte before the real marker + continue; + } + if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD9)) { + pos += 2; // standalone marker, no length field (TEM, RSTn, SOI, EOI) + continue; + } + if (pos + 4 > len) + break; + + uint16_t seg_len = encode_uint16(data[pos + 2], data[pos + 3]); + size_t seg_start = pos + 4; + if (seg_len < 2 || seg_start + (seg_len - 2) > len) + return false; + size_t seg_end = seg_start + (seg_len - 2); + + if (marker == 0xC0 || marker == 0xC1) { // SOF0 / SOF1 + if (seg_len < 8 + 3 * 3 || data[seg_start + 5] != 3) + return false; // not three components + out.height = encode_uint16(data[seg_start + 1], data[seg_start + 2]); + out.width = encode_uint16(data[seg_start + 3], data[seg_start + 4]); + const uint8_t sampling = data[seg_start + 7]; // Y component H/V sampling factors + if (sampling == 0x21) { + out.type = 0; // 4:2:2 + } else if (sampling == 0x22) { + out.type = 1; // 4:2:0 + } else { + return false; + } + } else if (marker == 0xDD) { // DRI + return false; + } else if (marker == 0xDB) { // DQT, one or more tables back to back + size_t p = seg_start; + while (p < seg_end) { + uint8_t precision = data[p] >> 4; + uint8_t table_id = data[p] & 0x0F; + size_t table_len = precision == 0 ? QTABLE_LEN : 2 * QTABLE_LEN; + if (p + 1 + table_len > seg_end) + break; + if (precision == 0 && table_id == 0) { + out.qtable0 = &data[p + 1]; + } else if (precision == 0 && table_id == 1) { + out.qtable1 = &data[p + 1]; + } + p += 1 + table_len; + } + } else if (marker == 0xDA) { // SOS, scan data follows + if (out.width == 0 || out.height == 0 || out.qtable0 == nullptr) + return false; + if (out.qtable1 == nullptr) + out.qtable1 = out.qtable0; + out.scan_data = data + seg_end; + out.scan_len = len - seg_end; + if (out.scan_len >= 2 && out.scan_data[out.scan_len - 2] == 0xFF && out.scan_data[out.scan_len - 1] == 0xD9) + out.scan_len -= 2; // the RFC 2435 payload is the entropy-coded data only + return true; + } + + pos = seg_end; + } + return false; +} + +void RTSPSession::open(std::unique_ptr socket, uint32_t now) { + this->socket_ = std::move(socket); + this->socket_->setblocking(false); + int enable = 1; + this->socket_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(enable)); + this->should_close_ = false; + this->close_after_send_ = false; + this->read_pending_ = false; + this->request_len_ = 0; + this->skip_len_ = 0; + this->filler_len_ = 0; + this->cseq_ = 0; + this->last_frame_ms_ = 0; + this->last_activity_ms_ = now; + this->ssrc_ = random_uint32(); + this->sequence_number_ = static_cast(random_uint32()); + this->rtp_timestamp_ = random_uint32(); + format_hex_to(this->session_id_, random_uint32()); +} + +void RTSPSession::close_() { + this->frame_.reset(); + this->socket_.reset(); + this->state_ = State::INIT; + this->iov_pos_ = 0; + this->iov_count_ = 0; + this->filler_len_ = 0; +} + +void RTSPSession::loop(uint32_t now, uint16_t port) { + if (this->socket_ == nullptr) + return; + if (now - this->last_activity_ms_ > SESSION_TIMEOUT_MS) { + ESP_LOGW(TAG, "Session %s timed out", this->session_id_); + this->should_close_ = true; + } else { + this->service_(now, port); + } + if (this->should_close_) + this->close_(); +} + +void RTSPSession::service_(uint32_t now, uint16_t port) { + if (this->frame_ != nullptr && now - this->frame_progress_ms_ > FRAME_STALL_MS) { + ESP_LOGV(TAG, "Session %s stalled, dropping frame", this->session_id_); + this->drop_frame_(); + } + if (!this->flush_(now)) + return; + if (!this->close_after_send_) + this->read_requests_(now, port); + // Drain the current frame until done or the socket backs up; one packet per loop would cap the frame rate. + while (this->flush_(now)) { + if (this->close_after_send_) { + this->should_close_ = true; + return; + } + if (!this->queue_next_packet_()) + return; + } +} + +void RTSPSession::read_requests_(uint32_t now, uint16_t port) { + // A pipelined request may already be buffered; it is handled once the previous response is written. + if (this->process_input_(port)) + return; + if (!this->read_pending_ && !this->socket_->ready()) + return; + this->read_pending_ = false; + + while (true) { + if (this->request_len_ >= this->request_buf_.size()) { + ESP_LOGW(TAG, "Request too large, closing session"); + this->should_close_ = true; + return; + } + ssize_t n = this->socket_->read(this->request_buf_.data() + this->request_len_, + this->request_buf_.size() - this->request_len_); + if (n > 0) { + this->request_len_ += static_cast(n); + this->last_activity_ms_ = now; + if (this->process_input_(port)) { + this->read_pending_ = true; // stopped before EAGAIN, so ready() may not report what is left + return; + } + continue; + } + if (n == 0) { + this->should_close_ = true; + return; + } + if (errno != EAGAIN && errno != EWOULDBLOCK) { + ESP_LOGW(TAG, "Read failed: errno %d", errno); + this->should_close_ = true; + } + return; + } +} + +bool RTSPSession::process_input_(uint16_t port) { + while (this->request_len_ > 0) { + if (this->skip_len_ > 0) { + const size_t len = std::min(this->skip_len_, this->request_len_); + this->consume_input_(len); + this->skip_len_ -= len; + continue; + } + char *buf = this->request_buf_.data(); + if (buf[0] == '$') { // interleaved packet: '$', channel, 16-bit length, payload + if (this->request_len_ < INTERLEAVED_HEADER_LEN) + return false; + this->skip_len_ = + INTERLEAVED_HEADER_LEN + encode_uint16(static_cast(buf[2]), static_cast(buf[3])); + continue; + } + const char *end = find_request_end(buf, this->request_len_); + if (end == nullptr) + return false; // wait for the rest of the request + const size_t len = end - buf; + buf[len] = '\0'; // over the terminator's first CR + this->handle_request_(StringRef(buf, len), port); + this->consume_input_(len + REQUEST_END_LEN); + return true; + } + return false; +} + +void RTSPSession::consume_input_(size_t len) { + this->request_len_ -= len; + std::memmove(this->request_buf_.data(), this->request_buf_.data() + len, this->request_len_); +} + +void RTSPSession::handle_request_(StringRef request, uint16_t port) { + size_t line_end = request.find("\r\n"); + StringRef first_line = line_end == std::string::npos ? request : StringRef(request.c_str(), line_end); + + this->cseq_ = 0; + StringRef transport; + int64_t content_length = 0; + + // Headers first: even a 400 reply has to echo the request's CSeq + size_t pos = line_end == std::string::npos ? request.size() : line_end + 2; + while (pos < request.size()) { + size_t next = request.find("\r\n", pos); + size_t header_end = next == std::string::npos ? request.size() : next; + StringRef header(request.c_str() + pos, header_end - pos); + + size_t colon = header.find(':'); + if (colon != std::string::npos) { + StringRef key = trim(StringRef(header.c_str(), colon)); + StringRef value = trim(StringRef(header.c_str() + colon + 1, header.size() - colon - 1)); + if (str_equals_case_insensitive(key, StringRef::from_lit("CSeq"))) { + this->cseq_ = static_cast(stol(value)); + } else if (str_equals_case_insensitive(key, StringRef::from_lit("Transport"))) { + transport = value; + } else if (str_equals_case_insensitive(key, StringRef::from_lit("Content-Length"))) { + content_length = stol(value); + } + } + + if (next == std::string::npos) + break; + pos = next + 2; + } + + if (content_length < 0 || content_length > static_cast(this->request_buf_.size())) { + this->queue_response_(400, "Bad Request"); + this->close_after_send_ = true; + return; + } + // The body is ignored; it is discarded as it arrives, like an interleaved packet. + this->skip_len_ = static_cast(content_length); + + size_t sp1 = first_line.find(' '); + size_t sp2 = sp1 == std::string::npos ? std::string::npos : first_line.find(' ', sp1 + 1); + if (sp1 == std::string::npos || sp2 == std::string::npos) { + this->queue_response_(400, "Bad Request"); + return; + } + StringRef method(first_line.c_str(), sp1); + + if (str_equals_case_insensitive(method, StringRef::from_lit("OPTIONS"))) { + this->queue_response_(200, "OK", "Public: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN, GET_PARAMETER\r\n"); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("DESCRIBE"))) { + char addr_buf[socket::SOCKADDR_STR_LEN]; + this->socket_->getsockname_to(addr_buf); + const bool ipv6 = std::strchr(addr_buf, ':') != nullptr; + this->queue_response_(200, "OK", + "Content-Base: rtsp://%s%s%s:%u/\r\n" + "Content-Type: application/sdp\r\n" + "Content-Length: %u\r\n", + ipv6 ? "[" : "", addr_buf, ipv6 ? "]" : "", port, + static_cast(sizeof(SDP_BODY) - 1)); + this->iov_[this->iov_count_++] = {const_cast(SDP_BODY), sizeof(SDP_BODY) - 1}; + } else if (str_equals_case_insensitive(method, StringRef::from_lit("SETUP"))) { + this->handle_setup_(transport); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("PLAY"))) { + this->handle_play_(); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("TEARDOWN"))) { + this->stop_playing_(); + this->queue_response_(200, "OK"); + this->close_after_send_ = true; + } else if (str_equals_case_insensitive(method, StringRef::from_lit("GET_PARAMETER"))) { + this->queue_response_(200, "OK"); + } else { + this->queue_response_(501, "Not Implemented"); + } +} + +void RTSPSession::queue_response_(uint16_t code, const char *reason, const char *extra_headers_fmt, ...) { + char *buf = this->send_buf_.data(); + const size_t size = this->send_buf_.size(); + size_t pos = buf_append_printf(buf, size, 0, "RTSP/1.0 %u %s\r\nCSeq: %" PRIu32 "\r\n", code, reason, this->cseq_); + if (this->state_ != State::INIT) + pos = buf_append_printf(buf, size, pos, "Session: %s;timeout=60\r\n", this->session_id_); + if (extra_headers_fmt != nullptr && pos < size) { + va_list args; + va_start(args, extra_headers_fmt); + int written = vsnprintf(buf + pos, size - pos, extra_headers_fmt, args); + va_end(args); + if (written > 0) + pos = std::min(pos + static_cast(written), size); + } + pos = buf_append_str(buf, size, pos, "\r\n"); + this->iov_[0] = {buf, std::min(pos, size - 1)}; + this->iov_pos_ = 0; + this->iov_count_ = 1; + this->sending_packet_ = false; +} + +void RTSPSession::handle_setup_(StringRef transport_header) { + if (!contains(transport_header, StringRef::from_lit("RTP/AVP/TCP"))) { + this->queue_response_(461, "Unsupported Transport"); + return; + } + // A SETUP while streaming stops playback, ending in READY like a first SETUP. + this->stop_playing_(); + // The single video track always uses interleaved channels 0 (RTP) and 1 (RTCP, unused). + this->queue_response_(200, "OK", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n"); +} + +void RTSPSession::handle_play_() { + if (this->state_ == State::INIT) { + this->queue_response_(455, "Method Not Valid In This State"); + return; + } + this->state_ = State::PLAYING; + this->frame_.reset(); // a repeated PLAY may arrive mid-frame + this->queue_response_(200, "OK", "Range: npt=0.000-\r\n"); +} + +void RTSPSession::stop_playing_() { + this->state_ = State::READY; + this->frame_.reset(); +} + +void RTSPSession::on_new_frame(const std::shared_ptr &image, const JpegInfo &jpeg, uint32_t now) { + // Pending output means a frame is in flight or a stalled one is still being padded out. + if (this->state_ != State::PLAYING || this->frame_ != nullptr || this->iov_pos_ < this->iov_count_ || + this->filler_len_ > 0) + return; + this->frame_ = image; + this->jpeg_ = jpeg; + this->frame_sent_ = 0; + this->frame_progress_ms_ = now; + + uint32_t elapsed_ms = this->last_frame_ms_ == 0 ? 0 : now - this->last_frame_ms_; + this->rtp_timestamp_ += static_cast((static_cast(elapsed_ms) * RTP_CLOCK_RATE) / 1000); + this->last_frame_ms_ = now; +} + +bool RTSPSession::queue_next_packet_() { + if (this->frame_ == nullptr) + return false; + if (this->frame_sent_ >= this->jpeg_.scan_len) { + this->frame_.reset(); // its last packet is written + return false; + } + + const bool first_fragment = this->frame_sent_ == 0; + const size_t header_len = + INTERLEAVED_HEADER_LEN + RTP_HEADER_LEN + JPEG_HEADER_LEN + (first_fragment ? QUANT_HEADER_LEN : 0); + const size_t qtables_len = first_fragment ? 2 * QTABLE_LEN : 0; + const size_t payload_len = + std::min(this->jpeg_.scan_len - this->frame_sent_, MAX_PACKET_SIZE - header_len - qtables_len); + const bool last_fragment = this->frame_sent_ + payload_len >= this->jpeg_.scan_len; + const size_t rtp_len = header_len - INTERLEAVED_HEADER_LEN + qtables_len + payload_len; + + auto *buf = reinterpret_cast(this->send_buf_.data()); + size_t pos = 0; + buf[pos++] = '$'; + buf[pos++] = 0; // interleaved channel 0 + buf[pos++] = static_cast(rtp_len >> 8); + buf[pos++] = static_cast(rtp_len); + + buf[pos++] = 0x80; // V=2, P=0, X=0, CC=0 + buf[pos++] = static_cast((last_fragment ? 0x80 : 0x00) | RTP_PAYLOAD_TYPE_JPEG); + buf[pos++] = static_cast(this->sequence_number_ >> 8); + buf[pos++] = static_cast(this->sequence_number_); + this->sequence_number_++; + buf[pos++] = static_cast(this->rtp_timestamp_ >> 24); + buf[pos++] = static_cast(this->rtp_timestamp_ >> 16); + buf[pos++] = static_cast(this->rtp_timestamp_ >> 8); + buf[pos++] = static_cast(this->rtp_timestamp_); + buf[pos++] = static_cast(this->ssrc_ >> 24); + buf[pos++] = static_cast(this->ssrc_ >> 16); + buf[pos++] = static_cast(this->ssrc_ >> 8); + buf[pos++] = static_cast(this->ssrc_); + + // RFC 2435 main JPEG header + buf[pos++] = 0; // type-specific + buf[pos++] = static_cast(this->frame_sent_ >> 16); + buf[pos++] = static_cast(this->frame_sent_ >> 8); + buf[pos++] = static_cast(this->frame_sent_); + buf[pos++] = this->jpeg_.type; + buf[pos++] = 255; // Q >= 128: the quantization tables follow in the first packet + buf[pos++] = static_cast(this->jpeg_.width / 8); + buf[pos++] = static_cast(this->jpeg_.height / 8); + + uint8_t count = 0; + if (first_fragment) { + buf[pos++] = 0; // MBZ + buf[pos++] = 0; // 8-bit precision + buf[pos++] = static_cast(qtables_len >> 8); + buf[pos++] = static_cast(qtables_len); + this->iov_[0] = {buf, header_len}; + this->iov_[1] = {const_cast(this->jpeg_.qtable0), QTABLE_LEN}; + this->iov_[2] = {const_cast(this->jpeg_.qtable1), QTABLE_LEN}; + count = 3; + } else { + this->iov_[0] = {buf, header_len}; + count = 1; + } + this->iov_[count++] = {const_cast(this->jpeg_.scan_data + this->frame_sent_), payload_len}; + this->iov_pos_ = 0; + this->iov_count_ = count; + this->sending_packet_ = true; + this->frame_sent_ += payload_len; + return true; +} + +void RTSPSession::drop_frame_() { + this->frame_.reset(); + if (!this->sending_packet_ || this->iov_pos_ >= this->iov_count_) + return; + // Even an unwritten packet is kept, so output stays pending and on_new_frame() takes nothing until the + // socket drains. Entries after the header point into the frame. + for (uint8_t i = std::max(this->iov_pos_, 1); i < this->iov_count_; i++) + this->filler_len_ += this->iov_[i].iov_len; + this->iov_count_ = std::max(this->iov_pos_, 1); + this->sending_packet_ = false; +} + +bool RTSPSession::flush_(uint32_t now) { + if (this->should_close_) + return false; + while (true) { + if (this->iov_pos_ >= this->iov_count_) { + if (this->filler_len_ == 0) + return true; + this->iov_pos_ = 0; + this->iov_count_ = 0; + while (this->filler_len_ > 0 && this->iov_count_ < this->iov_.size()) { + const size_t len = std::min(this->filler_len_, sizeof(ZERO_FILL)); + this->iov_[this->iov_count_++] = {const_cast(ZERO_FILL), len}; + this->filler_len_ -= len; + } + } + ssize_t written = this->socket_->writev(&this->iov_[this->iov_pos_], this->iov_count_ - this->iov_pos_); + if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return false; // retry next loop + if (written <= 0) { + if (written < 0) { + ESP_LOGW(TAG, "Write failed: errno %d", errno); + } + this->should_close_ = true; + return false; + } + this->last_activity_ms_ = now; + this->frame_progress_ms_ = now; + // Step over what was written; a partial write leaves the rest of the current entry pending. + auto remaining = static_cast(written); + while (remaining > 0) { + struct iovec &entry = this->iov_[this->iov_pos_]; + if (remaining < entry.iov_len) { + entry.iov_base = static_cast(entry.iov_base) + remaining; + entry.iov_len -= remaining; + break; + } + remaining -= entry.iov_len; + this->iov_pos_++; + } + } +} + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_session.h b/esphome/components/rtsp_server/rtsp_session.h new file mode 100644 index 00000000000..b34b7faecba --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_session.h @@ -0,0 +1,119 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_CAMERA + +#include +#include + +#include "esphome/components/camera/camera.h" +#include "esphome/components/socket/socket.h" +#include "esphome/core/string_ref.h" + +namespace esphome::rtsp_server { + +static constexpr size_t INTERLEAVED_HEADER_LEN = 4; +static constexpr size_t RTP_HEADER_LEN = 12; +static constexpr size_t JPEG_HEADER_LEN = 8; +static constexpr size_t QUANT_HEADER_LEN = 4; +static constexpr size_t QTABLE_LEN = 64; // one 8-bit precision quantization table +static constexpr size_t RTP_JPEG_PAYLOAD_SIZE = 1024; +static constexpr size_t MAX_PACKET_SIZE = INTERLEAVED_HEADER_LEN + RTP_HEADER_LEN + JPEG_HEADER_LEN + QUANT_HEADER_LEN + + 2 * QTABLE_LEN + RTP_JPEG_PAYLOAD_SIZE; +// RFC 2435 stores width and height in units of 8 pixels in one byte each. +static constexpr uint16_t RTP_JPEG_MAX_DIMENSION = 255 * 8; +// A frame whose packets make no write progress for this long is dropped, so a stalled client cannot hold +// the camera image (esp32_camera captures nothing while any copy is held). +static constexpr uint32_t FRAME_STALL_MS = 500; + +/// The fields of a JPEG frame that an RFC 2435 RTP/JPEG payload needs. Pointers refer into the frame. +struct JpegInfo { + const uint8_t *qtable0{nullptr}; + const uint8_t *qtable1{nullptr}; // same as qtable0 when the frame has one table + const uint8_t *scan_data{nullptr}; + size_t scan_len{0}; // entropy-coded data, without the trailing EOI marker + uint16_t width{0}; + uint16_t height{0}; + uint8_t type{0}; // RFC 2435 type: 0 = 4:2:2 subsampling, 1 = 4:2:0 +}; + +bool parse_jpeg_info(const uint8_t *data, size_t len, JpegInfo &out); + +/** A single RTSP client: one TCP connection carrying both the RTSP control channel and, once playing, + * RTP/JPEG media interleaved on the same socket (RFC 2326 §10.12). No UDP transport is supported. + * The object is a reusable slot: open() starts a connection, and it closes itself when the connection ends. + */ +class RTSPSession { + public: + bool is_active() const { return this->socket_ != nullptr; } + bool is_playing() const { return this->state_ == State::PLAYING; } + + void open(std::unique_ptr socket, uint32_t now); + void loop(uint32_t now, uint16_t port); + /// Ignored unless playing with no output pending; packets of the frame being sent point into that frame. + void on_new_frame(const std::shared_ptr &image, const JpegInfo &jpeg, uint32_t now); + + protected: + enum class State : uint8_t { INIT, READY, PLAYING }; + + void service_(uint32_t now, uint16_t port); + void close_(); + void read_requests_(uint32_t now, uint16_t port); + /// Consumes buffered input up to and including the next request; true once a request was handled. + bool process_input_(uint16_t port); + void consume_input_(size_t len); + void handle_request_(StringRef request, uint16_t port); + void handle_setup_(StringRef transport_header); + void handle_play_(); + /// Queues "RTSP/1.0 ", CSeq, Session once set up, the extra headers and the blank line. + void queue_response_(uint16_t code, const char *reason, const char *extra_headers_fmt = nullptr, ...) + __attribute__((format(printf, 4, 5))); + void stop_playing_(); + bool queue_next_packet_(); + /// Releases the frame; the frame-backed rest of the current packet is sent as zeros to keep the framing. + void drop_frame_(); + /// Writes what is queued; true once all of it is written, false while backlogged or after a failure. + bool flush_(uint32_t now); + + std::unique_ptr socket_; + // Held until its last packet is written; esp32_camera captures nothing new while any copy is held. + std::shared_ptr frame_; + JpegInfo jpeg_; + size_t frame_sent_{0}; + + uint32_t cseq_{0}; + uint32_t rtp_timestamp_{0}; + uint32_t ssrc_{0}; + uint32_t last_activity_ms_{0}; + uint32_t last_frame_ms_{0}; + uint32_t frame_progress_ms_{0}; + // Bytes of an interleaved packet (RTCP) or request body from the client still to discard. + uint32_t skip_len_{0}; + uint16_t sequence_number_{0}; + uint16_t request_len_{0}; + // Zeros still owed for a packet whose frame was dropped part way through. + uint16_t filler_len_{0}; + + // Pending output: a response or one RTP packet. Packets point at the frame's tables and scan data. + std::array iov_{}; + uint8_t iov_pos_{0}; + uint8_t iov_count_{0}; + + State state_{State::INIT}; + bool should_close_{false}; + bool close_after_send_{false}; + bool sending_packet_{false}; // iov_ holds an RTP packet rather than a response + bool read_pending_{false}; // reading stopped before EAGAIN; read again without waiting for ready() + char session_id_[9]{}; + + // Requests and interleaved packets from the client. A request is NUL terminated in place before it is + // handled, for StringRef::find(), which uses strstr. + std::array request_buf_{}; + // Response text, or the header of the RTP packet being sent. + std::array send_buf_{}; +}; + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8d17dc3f514..dbd6c8d8538 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -268,6 +268,7 @@ #define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 +#define MAX_RTSP_CONNECTIONS 2 #define USE_SCRIPT_ON_STOP #define USE_SELECT #define USE_SELEC_METER_EM4M diff --git a/tests/components/rtsp_server/__init__.py b/tests/components/rtsp_server/__init__.py new file mode 100644 index 00000000000..25a08c171d9 --- /dev/null +++ b/tests/components/rtsp_server/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # No host camera platform emits USE_CAMERA, and the real to_code needs a configured server. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_CAMERA") + cg.add_define("MAX_RTSP_CONNECTIONS", 1) + cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + + manifest.to_code = to_code_testing diff --git a/tests/components/rtsp_server/common.yaml b/tests/components/rtsp_server/common.yaml new file mode 100644 index 00000000000..6f1e0dd078a --- /dev/null +++ b/tests/components/rtsp_server/common.yaml @@ -0,0 +1,10 @@ +rtsp_server: + port: 554 + max_connections: 2 + allowed_ips: + - 192.168.1.0/24 + - 10.0.0.5 + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/rtsp_server/test.esp32-idf.yaml b/tests/components/rtsp_server/test.esp32-idf.yaml new file mode 100644 index 00000000000..d186203c80d --- /dev/null +++ b/tests/components/rtsp_server/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml + rtsp_server: !include common.yaml diff --git a/tests/components/rtsp_server/test_rtsp_session_host.cpp b/tests/components/rtsp_server/test_rtsp_session_host.cpp new file mode 100644 index 00000000000..9d9154f587e --- /dev/null +++ b/tests/components/rtsp_server/test_rtsp_session_host.cpp @@ -0,0 +1,367 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/rtsp_server/rtsp_session.h" + +#if defined(USE_HOST) && defined(USE_CAMERA) + +namespace esphome::rtsp_server::testing { + +static constexpr uint16_t PORT = 554; +static constexpr size_t REQUEST_BUF_SIZE = 512; + +class StubImage : public camera::CameraImage { + public: + explicit StubImage(size_t len) : data_(len, 0x55) {} + uint8_t *get_data_buffer() override { return this->data_.data(); } + size_t get_data_length() override { return this->data_.size(); } + bool was_requested_by(camera::CameraRequester requester) const override { return true; } + + protected: + std::vector data_; +}; + +class RTSPSessionUnderTest : public RTSPSession { + public: + bool has_frame() const { return this->frame_ != nullptr; } +}; + +class RTSPSessionTest : public ::testing::Test { + protected: + void SetUp() override { + signal(SIGPIPE, SIG_IGN); + int fds[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + this->peer_fd_ = fds[1]; + this->session_.open(std::make_unique(fds[0]), this->now_); + } + void TearDown() override { ::close(this->peer_fd_); } + + // Reopens the session on a loopback TCP connection, which (unlike AF_UNIX) accepts partial writes. + void reopen_over_tcp() { + int listener = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(listener, 0); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(addr); + ASSERT_EQ(::bind(listener, reinterpret_cast(&addr), sizeof(addr)), 0); + ASSERT_EQ(::listen(listener, 1), 0); + ASSERT_EQ(::getsockname(listener, reinterpret_cast(&addr), &len), 0); + int client = ::socket(AF_INET, SOCK_STREAM, 0); + int size = 8192; + ::setsockopt(client, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)); + ASSERT_EQ(::connect(client, reinterpret_cast(&addr), sizeof(addr)), 0); + int server = ::accept(listener, nullptr, nullptr); + ::close(listener); + ASSERT_GE(server, 0); + ::setsockopt(server, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)); +#ifdef SO_SNDLOWAT + int lowat = 1; // macOS otherwise refuses writes smaller than its 2 KiB low-water mark + ::setsockopt(server, SOL_SOCKET, SO_SNDLOWAT, &lowat, sizeof(lowat)); +#endif + ::close(this->peer_fd_); + this->peer_fd_ = client; + this->session_.open(std::make_unique(server), this->now_); + } + + void send(const std::string &data) { + ASSERT_EQ(::write(this->peer_fd_, data.data(), data.size()), static_cast(data.size())); + } + void loops(int count) { + for (int i = 0; i < count; i++) { + usleep(1000); // let loopback TCP deliver + this->session_.loop(this->now_, PORT); + } + } + // Everything the session has written so far. + std::string receive() { + usleep(1000); + std::string out; + char buf[4096]; + while (true) { + ssize_t n = ::recv(this->peer_fd_, buf, sizeof(buf), MSG_DONTWAIT); + if (n <= 0) + break; + out.append(buf, n); + } + return out; + } + static std::string request(const char *method, int cseq, const std::string &extra = "") { + char line[48]; + snprintf(line, sizeof(line), "%s rtsp://host/ RTSP/1.0\r\nCSeq: %d\r\n", method, cseq); + return line + extra + "\r\n"; + } + static size_t count(const std::string &haystack, const std::string &needle) { + size_t n = 0; + for (size_t pos = haystack.find(needle); pos != std::string::npos; pos = haystack.find(needle, pos + 1)) + n++; + return n; + } + void start_playing() { + this->send(request("SETUP", 1, "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n")); + this->loops(2); + this->send(request("PLAY", 2)); + this->loops(2); + std::string out = this->receive(); + ASSERT_NE(out.find("CSeq: 2\r\n"), std::string::npos); + ASSERT_TRUE(this->session_.is_playing()); + } + + RTSPSessionUnderTest session_; + int peer_fd_{-1}; + uint32_t now_{1000}; +}; + +TEST_F(RTSPSessionTest, InterleavedRtcpBeforeRequestIsSkipped) { + std::string rtcp("$\x01\x00\x08", 4); + rtcp.append("\x81\xc9\x00\x01\x00\x00\x00\x00", 8); // contains NUL bytes + this->send(rtcp + request("GET_PARAMETER", 7)); + this->loops(1); + std::string out = this->receive(); + EXPECT_EQ(out.rfind("RTSP/1.0 200 OK\r\nCSeq: 7\r\n", 0), 0u) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, InterleavedPacketSplitAcrossReads) { + this->send(std::string("$\x01\x01", 3)); + this->loops(1); + std::string rest("\x00", 1); + rest.append(256, '\0'); + this->send(rest + request("GET_PARAMETER", 8)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 8\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, MalformedRequestLineEchoesCSeq) { + this->send("OPTIONS\r\nCSeq: 9\r\n\r\n"); + this->loops(1); + std::string out = this->receive(); + EXPECT_EQ(out.rfind("RTSP/1.0 400 Bad Request\r\nCSeq: 9\r\n", 0), 0u) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, PipelinedRequestsBothAnswered) { + this->send(request("OPTIONS", 1) + request("GET_PARAMETER", 2)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + size_t first = out.find("CSeq: 1\r\n"); + size_t second = out.find("CSeq: 2\r\n"); + ASSERT_NE(first, std::string::npos); + ASSERT_NE(second, std::string::npos); + EXPECT_LT(first, second); +} + +TEST_F(RTSPSessionTest, RequestBodyIsConsumed) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 6\r\n") + "ping\r\n" + request("OPTIONS", 4)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + EXPECT_EQ(out.find("400"), std::string::npos) << out; + size_t first = out.find("CSeq: 3\r\n"); + size_t second = out.find("CSeq: 4\r\n"); + ASSERT_NE(first, std::string::npos); + ASSERT_NE(second, std::string::npos); + EXPECT_LT(first, second); +} + +TEST_F(RTSPSessionTest, RequestBodySplitAcrossReads) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 6\r\n") + "pi"); + this->loops(2); + this->send("ng\r\n" + request("OPTIONS", 4)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + EXPECT_EQ(out.find("400"), std::string::npos) << out; + EXPECT_NE(out.find("CSeq: 4\r\n"), std::string::npos) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, OversizedContentLengthClosesSession) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 100000\r\n")); + this->loops(2); + EXPECT_EQ(this->receive().rfind("RTSP/1.0 400 Bad Request\r\nCSeq: 3\r\n", 0), 0u); + EXPECT_FALSE(this->session_.is_active()); +} + +static std::string padded_request(size_t total_len) { + std::string head = "GET_PARAMETER rtsp://host/ RTSP/1.0\r\nCSeq: 9\r\nX-Pad: "; + std::string tail = "\r\n\r\n"; + return head + std::string(total_len - head.size() - tail.size(), 'a') + tail; +} + +TEST_F(RTSPSessionTest, RequestOfBufferSizeMinusOneIsHandled) { + this->send(padded_request(REQUEST_BUF_SIZE - 1)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 9\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, RequestFillingTheBufferIsHandled) { + this->send(padded_request(REQUEST_BUF_SIZE)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 9\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, OversizedRequestClosesSession) { + this->send(std::string(REQUEST_BUF_SIZE + 1, 'a')); + this->loops(1); + EXPECT_FALSE(this->session_.is_active()); +} + +// Parses '$' channel length packets after the RTSP responses; false if the stream is not well framed. +static bool parse_interleaved(const std::string &stream, size_t start, std::vector &sequences) { + size_t pos = start; + while (pos < stream.size()) { + if (pos + 4 > stream.size() || stream[pos] != '$') + return false; + size_t len = (static_cast(stream[pos + 2]) << 8) | static_cast(stream[pos + 3]); + if (pos + 4 + len > stream.size() || len < 12) + return false; + sequences.push_back((static_cast(stream[pos + 6]) << 8) | static_cast(stream[pos + 7])); + pos += 4 + len; + } + return true; +} + +static JpegInfo stub_jpeg(StubImage &image) { + JpegInfo jpeg; + jpeg.qtable0 = image.get_data_buffer(); + jpeg.qtable1 = image.get_data_buffer() + QTABLE_LEN; + jpeg.scan_data = image.get_data_buffer() + 2 * QTABLE_LEN; + jpeg.scan_len = image.get_data_length() - 2 * QTABLE_LEN; + jpeg.width = 640; + jpeg.height = 480; + return jpeg; +} + +TEST_F(RTSPSessionTest, StalledWriterDropsFrame) { + this->reopen_over_tcp(); + this->start_playing(); + auto image = std::make_shared(8 * 1024 * 1024); + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(100); // the peer does not read, so the socket buffers fill up + ASSERT_TRUE(this->session_.has_frame()); + EXPECT_GT(image.use_count(), 1); + + this->now_ += FRAME_STALL_MS; + this->loops(1); + EXPECT_TRUE(this->session_.has_frame()) << "dropped before the stall limit"; + + this->now_ += 1; + this->loops(1); + EXPECT_FALSE(this->session_.has_frame()); + EXPECT_EQ(image.use_count(), 1); + EXPECT_TRUE(this->session_.is_active()); + + // Until the socket drains, new frames are refused so the camera can keep capturing for other consumers. + for (int i = 0; i < 5; i++) { + this->now_ += 100; + auto next = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(next, stub_jpeg(*next), this->now_); + this->loops(1); + EXPECT_FALSE(this->session_.has_frame()); + EXPECT_EQ(next.use_count(), 1); + } + + // The packet in flight is completed with zeros, so the interleaved stream stays well framed. + std::string stream; + for (int i = 0; i < 100; i++) { + stream += this->receive(); + this->loops(1); + } + stream += this->receive(); + std::vector sequences; + ASSERT_TRUE(parse_interleaved(stream, 0, sequences)); + ASSERT_FALSE(sequences.empty()); + + // Once drained, the next frame is taken and follows on in sequence. + auto next = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(next, stub_jpeg(*next), this->now_); + EXPECT_TRUE(this->session_.has_frame()); + this->loops(2); + std::vector after; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, after)); + ASSERT_EQ(after.size(), 1u); + EXPECT_EQ(after[0], static_cast(sequences.back() + 1)); + EXPECT_EQ(next.use_count(), 1); +} + +TEST_F(RTSPSessionTest, RepeatPlayContinuesSequence) { + this->start_playing(); + auto image = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(1); + std::vector sequences; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, sequences)); + ASSERT_EQ(sequences.size(), 1u); + + this->send(request("PLAY", 3)); + this->loops(1); + std::string reply = this->receive(); + ASSERT_NE(reply.find("CSeq: 3\r\n"), std::string::npos); + this->now_ += 100; + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(1); + std::vector after; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, after)); + ASSERT_EQ(after.size(), 1u); + EXPECT_EQ(after[0], static_cast(sequences[0] + 1)); +} + +// Minimal JPEG: SOI, DQT (two tables), optional DRI, SOF0, SOS, scan byte, EOI. +static std::vector make_jpeg(uint8_t components, uint8_t y_sampling, bool dri) { + std::vector j = {0xFF, 0xD8, 0xFF, 0xDB, 0x00, 2 + 2 * 65}; + for (uint8_t id = 0; id < 2; id++) { + j.push_back(id); + j.insert(j.end(), 64, 1); + } + if (dri) + j.insert(j.end(), {0xFF, 0xDD, 0x00, 0x04, 0x00, 0x10}); + j.insert(j.end(), + {0xFF, 0xC0, 0x00, static_cast(8 + 3 * components), 8, 0x01, 0xE0, 0x02, 0x80, components}); + for (uint8_t c = 0; c < components; c++) + j.insert(j.end(), {static_cast(c + 1), c == 0 ? y_sampling : static_cast(0x11), + static_cast(c == 0 ? 0 : 1)}); + j.insert(j.end(), {0xFF, 0xDA, 0x00, 0x02, 0x12, 0xFF, 0xD9}); + return j; +} + +TEST(RTSPParseJpeg, AcceptsBaselineColor) { + JpegInfo info; + auto j420 = make_jpeg(3, 0x22, false); + ASSERT_TRUE(parse_jpeg_info(j420.data(), j420.size(), info)); + EXPECT_EQ(info.type, 1); + EXPECT_EQ(info.width, 640); + EXPECT_EQ(info.height, 480); + EXPECT_EQ(info.scan_len, 1u); + JpegInfo info422; + auto j422 = make_jpeg(3, 0x21, false); + ASSERT_TRUE(parse_jpeg_info(j422.data(), j422.size(), info422)); + EXPECT_EQ(info422.type, 0); +} + +TEST(RTSPParseJpeg, RejectsUnsupportedFrames) { + JpegInfo info; + auto gray = make_jpeg(1, 0x11, false); + EXPECT_FALSE(parse_jpeg_info(gray.data(), gray.size(), info)); + auto j444 = make_jpeg(3, 0x11, false); + EXPECT_FALSE(parse_jpeg_info(j444.data(), j444.size(), info)); + auto restart = make_jpeg(3, 0x22, true); + EXPECT_FALSE(parse_jpeg_info(restart.data(), restart.size(), info)); +} + +} // namespace esphome::rtsp_server::testing + +#endif From 4ef17cebdb8c024ee27f73c4ac7f5c2b380675cd Mon Sep 17 00:00:00 2001 From: Hasan Khalil Date: Sat, 17 Oct 2026 14:01:01 -0400 Subject: [PATCH 056/120] [api] Pre-reserve Home Assistant state subscriptions vector (#20986) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/api/api_server.cpp | 3 + esphome/components/homeassistant/__init__.py | 31 ++++++++- .../homeassistant/button/__init__.py | 3 +- .../homeassistant/number/__init__.py | 3 +- .../homeassistant/select/__init__.py | 3 +- .../components/homeassistant/text/__init__.py | 3 +- esphome/core/defines.h | 1 + .../homeassistant/mixed_entities.yaml | 65 +++++++++++++++++++ .../test_state_subscription_count.py | 51 +++++++++++++++ 9 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/homeassistant/mixed_entities.yaml create mode 100644 tests/component_tests/homeassistant/test_state_subscription_count.py diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index a8d14256dcb..99ce39670f2 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -65,6 +65,9 @@ void APIServer::socket_failed_(const LogString *msg) { } void APIServer::setup() { +#if defined(USE_API_HOMEASSISTANT_STATES) && defined(HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT) + this->state_subs_.reserve(HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT); +#endif #ifdef USE_API_NOISE // Always reserve the slot: flash preferences are positional on esp8266, so // a yaml key build must keep the layout of a runtime key build diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 3851bc3c0bc..6f5360bfed1 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -1,8 +1,11 @@ from collections.abc import Callable, Iterable +from dataclasses import dataclass import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -59,7 +62,28 @@ def final_validate_entity_id(config: ConfigType) -> ConfigType: return config -def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: +@dataclass +class HomeAssistantData: + state_subscription_count: int = 0 + + +def _get_data() -> HomeAssistantData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = HomeAssistantData() + return CORE.data[DOMAIN] + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _emit_state_subscription_count_define() -> None: + """Emit the HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT define with the total count.""" + count = _get_data().state_subscription_count + if count > 0: + cg.add_define("HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT", count) + + +def setup_home_assistant_entity( + var: MockObj, config: ConfigType, state_subscription_count: int = 1 +) -> None: from esphome.components.api import wizard if (buffer := wizard.wizard_input_buffer(config[CONF_ID])) is not None: @@ -70,3 +94,8 @@ def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) cg.add_define("USE_API_HOMEASSISTANT_STATES") + if state_subscription_count > 0: + data = _get_data() + if data.state_subscription_count == 0: + CORE.add_job(_emit_state_subscription_count_define) + data.state_subscription_count += state_subscription_count diff --git a/esphome/components/homeassistant/button/__init__.py b/esphome/components/homeassistant/button/__init__.py index f486a841263..7b4ad004e18 100644 --- a/esphome/components/homeassistant/button/__init__.py +++ b/esphome/components/homeassistant/button/__init__.py @@ -37,4 +37,5 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await button.new_button(config) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # Button only calls services, does not subscribe to state + setup_home_assistant_entity(var, config, state_subscription_count=0) diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index 6288db68805..658cb8762c9 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -39,4 +39,5 @@ async def to_code(config: ConfigType) -> None: step=0, ) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + min/max/step fetches in HomeassistantNumber::setup() + setup_home_assistant_entity(var, config, state_subscription_count=4) diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py index 1bdf0f2cade..e6a129cda0d 100644 --- a/esphome/components/homeassistant/select/__init__.py +++ b/esphome/components/homeassistant/select/__init__.py @@ -55,4 +55,5 @@ async def to_code(config: ConfigType) -> None: options=[], ) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + options fetch in HomeassistantSelect::setup() + setup_home_assistant_entity(var, config, state_subscription_count=2) diff --git a/esphome/components/homeassistant/text/__init__.py b/esphome/components/homeassistant/text/__init__.py index 67377e02d05..eb8613f76d5 100644 --- a/esphome/components/homeassistant/text/__init__.py +++ b/esphome/components/homeassistant/text/__init__.py @@ -37,4 +37,5 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await text.new_text(config) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + min/max/mode fetches in HomeassistantText::setup() + setup_home_assistant_entity(var, config, state_subscription_count=4) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index dbd6c8d8538..de4dfd2607b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -319,6 +319,7 @@ #define USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON #define USE_API_HOMEASSISTANT_SERVICES #define USE_API_HOMEASSISTANT_STATES +#define HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT 1 #define USE_API_NOISE #define USE_API_OUTGOING_CONNECTION #define API_OUTGOING_CONNECTION_PORT 6054 diff --git a/tests/component_tests/homeassistant/mixed_entities.yaml b/tests/component_tests/homeassistant/mixed_entities.yaml new file mode 100644 index 00000000000..8c96e1ad521 --- /dev/null +++ b/tests/component_tests/homeassistant/mixed_entities.yaml @@ -0,0 +1,65 @@ +esphome: + name: test-mixed-ha + +esp32: + variant: esp32 + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + +sensor: + - platform: homeassistant + id: ha_sensor_1 + name: HA Sensor 1 + entity_id: sensor.temp_1 + - platform: homeassistant + id: ha_sensor_2 + name: HA Sensor 2 + entity_id: sensor.temp_2 + +binary_sensor: + - platform: homeassistant + id: ha_binary_sensor + name: HA Binary Sensor + entity_id: binary_sensor.motion + +text_sensor: + - platform: homeassistant + id: ha_text_sensor + name: HA Text Sensor + entity_id: sensor.status_text + +switch: + - platform: homeassistant + id: ha_switch + name: HA Switch + entity_id: switch.relay + +select: + - platform: homeassistant + id: ha_select + name: HA Select + entity_id: select.mode + +number: + - platform: homeassistant + id: ha_number + name: HA Number + entity_id: number.target_temp + +text: + - platform: homeassistant + id: ha_text + name: HA Text + entity_id: text.custom_note + +button: + - platform: homeassistant + id: ha_button + name: HA Button + entity_id: button.restart diff --git a/tests/component_tests/homeassistant/test_state_subscription_count.py b/tests/component_tests/homeassistant/test_state_subscription_count.py new file mode 100644 index 00000000000..c3832369117 --- /dev/null +++ b/tests/component_tests/homeassistant/test_state_subscription_count.py @@ -0,0 +1,51 @@ +"""Tests for Home Assistant state subscription count calculation and pre-reservation.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +DIR = Path(__file__).parent + + +def test_mixed_entities_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with mixed entities computes the exact weighted subscription count.""" + generate_main(DIR / "mixed_entities.yaml") + + # sensor (2*1) + binary_sensor (1) + text_sensor (1) + switch (1) + # + select (2) + number (4) + text (4) + button (0) = 15 + define_match = [ + d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" + ] + assert len(define_match) == 1 + assert str(define_match[0].value) == "15" + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) + + +def test_button_only_no_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with only button entities has no state subscriptions.""" + generate_main(DIR / "button_valid.yaml") + + assert not any( + d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" for d in CORE.defines + ) + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) + assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines) + + +def test_select_only_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with 2 select entities calculates 2 * 2 = 4 subscriptions.""" + generate_main(DIR / "select_valid.yaml") + + define_match = [ + d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" + ] + assert len(define_match) == 1 + assert str(define_match[0].value) == "4" + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) From 4f192130f80ccb70813891d99d0a771c5f063fa0 Mon Sep 17 00:00:00 2001 From: Douwe <61123717+dhoeben@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:51:06 +0200 Subject: [PATCH 057/120] [water_heater] Hand the component to the core team (#20999) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- esphome/components/water_heater/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f23f3613032..65ed44ee30b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -688,7 +688,7 @@ esphome/components/version/* @esphome/core esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher -esphome/components/water_heater/* @dhoeben +esphome/components/water_heater/* @esphome/core esphome/components/waveshare_epaper/* @clydebarrow esphome/components/waveshare_io_ch32v003/* @latonita esphome/components/web_server/ota/* @esphome/core diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index a3a7f047069..9277f0ecbf6 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -17,7 +17,7 @@ from esphome.core.entity_helpers import ( from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType -CODEOWNERS = ["@dhoeben"] +CODEOWNERS = ["@esphome/core"] DOMAIN = "water_heater" IS_PLATFORM_COMPONENT = True From 37d6140193c7b215852834b54d05a50f0fe60d8f Mon Sep 17 00:00:00 2001 From: Dane Powell Date: Sat, 17 Oct 2026 11:55:37 -0700 Subject: [PATCH 058/120] [epaper_spi] Fix partial refresh and port waveshare 2.90inv2 (#18959) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: luar123 <49960470+luar123@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/epaper_spi/epaper_spi.cpp | 4 +- esphome/components/epaper_spi/epaper_spi.h | 2 + .../components/epaper_spi/epaper_spi_mono.cpp | 2 +- .../components/epaper_spi/epaper_spi_mono.h | 2 + .../epaper_spi/epaper_waveshare.cpp | 15 ++- .../components/epaper_spi/epaper_waveshare.h | 26 +++- .../components/epaper_spi/models/waveshare.py | 125 +++++++++++++++--- .../epaper_spi/test.esp32-s3-idf.yaml | 17 +++ 8 files changed, 167 insertions(+), 26 deletions(-) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index a0c90a0b686..e95d2fd2869 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -90,7 +90,9 @@ bool EPaperBase::is_idle_() const { return !this->busy_pin_->digital_read(); } -bool EPaperBase::reset() { +bool EPaperBase::reset() { return this->hardware_reset_(); } + +bool EPaperBase::hardware_reset_() { if (this->reset_pin_ != nullptr) { if (this->state_ == EPaperState::RESET) { this->reset_pin_->digital_write(false); diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index dccff3cbef1..046df97cccd 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -129,6 +129,8 @@ class EPaperBase : public Display, bool is_idle_() const; void setup_pins_() const; virtual bool reset(); + /// The reset pin part of reset(), for a driver that must skip what its parent adds to it + bool hardware_reset_(); virtual bool initialise(bool partial); void send_init_sequence_(const uint8_t *sequence, size_t length); void wait_for_idle_(bool should_wait); diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index b6653038c33..a00104edeae 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -68,7 +68,7 @@ bool HOT EPaperMono::transfer_data() { while (this->current_data_index_ != this->y_high_) { size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_ / 8; for (size_t i = 0; i != row_length; i++) { - bytes_to_send[i] = this->send_red_ ? 0 : this->buffer_[data_idx++]; + bytes_to_send[i] = this->send_red_ && !this->send_red_as_image_ ? 0 : this->buffer_[data_idx++]; } ++this->current_data_index_; this->write_array(&bytes_to_send.front(), row_length); // NOLINT diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index 4dc54ae60ba..c7de7a309af 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -21,6 +21,8 @@ class EPaperMono : public EPaperBase { virtual void set_window(); bool transfer_data() override; bool send_red_{true}; + // When set, the red RAM pass writes the image rather than blank data. + bool send_red_as_image_{false}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare.cpp b/esphome/components/epaper_spi/epaper_waveshare.cpp index d4c7ba5fdb5..17808001aa6 100644 --- a/esphome/components/epaper_spi/epaper_waveshare.cpp +++ b/esphome/components/epaper_spi/epaper_waveshare.cpp @@ -8,15 +8,19 @@ bool EpaperWaveshare::initialise(bool partial) { EPaperBase::initialise(partial); if (partial) { this->cmd_data(0x32, this->partial_lut_, this->partial_lut_length_); + this->send_init_sequence_(this->partial_sequence_, this->partial_sequence_length_); this->cmd_data(0x3C, {0x80}); this->cmd_data(0x22, {0xC0}); this->command(0x20); this->next_delay_ = 100; } else { this->cmd_data(0x32, this->lut_, this->lut_length_); + this->send_init_sequence_(this->full_sequence_, this->full_sequence_length_); this->cmd_data(0x3C, {0x05}); } - this->send_red_ = true; + // The previous image must stay in the base RAM for partial updates to compare against, + // so models needing it write the base RAM on full updates only. + this->send_red_ = !(this->base_image_required_ && partial); return true; } @@ -45,4 +49,13 @@ void EpaperWaveshare::refresh_screen(bool partial) { } void EpaperWaveshare::deep_sleep() { this->cmd_data(0x10, {0x01}); } + +bool EpaperWaveshare::reset() { + // Skip the software reset (0x12) EPaperMono adds: it restarts the alternation between the two RAM + // banks, so a partial update would compare against the wrong bank. The RST pulse only wakes the + // controller from sleep. + if (this->must_keep_base_image_()) + return this->hardware_reset_(); + return EPaperMono::reset(); +} } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare.h b/esphome/components/epaper_spi/epaper_waveshare.h index d3ad313d925..b12aeb93822 100644 --- a/esphome/components/epaper_spi/epaper_waveshare.h +++ b/esphome/components/epaper_spi/epaper_waveshare.h @@ -10,21 +10,39 @@ class EpaperWaveshare final : public EPaperMono { public: EpaperWaveshare(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length, const uint8_t *lut, size_t lut_length, const uint8_t *partial_lut, - uint16_t partial_lut_length) + uint16_t partial_lut_length, const uint8_t *full_sequence = nullptr, size_t full_sequence_length = 0, + const uint8_t *partial_sequence = nullptr, size_t partial_sequence_length = 0, + bool base_image_required = false) : EPaperMono(name, width, height, init_sequence, init_sequence_length), lut_(lut), - lut_length_(lut_length), partial_lut_(partial_lut), - partial_lut_length_(partial_lut_length) {} + full_sequence_(full_sequence), + partial_sequence_(partial_sequence), + lut_length_(lut_length), + full_sequence_length_(full_sequence_length), + partial_sequence_length_(partial_sequence_length), + partial_lut_length_(partial_lut_length), + base_image_required_(base_image_required) { + this->send_red_as_image_ = base_image_required; + } protected: bool initialise(bool partial) override; void set_window() override; void refresh_screen(bool partial) override; void deep_sleep() override; + bool reset() override; + // True while the base image in the controller RAM must be preserved for the next update. + bool must_keep_base_image_() const { return this->base_image_required_ && this->update_count_ != 0; } const uint8_t *lut_; - size_t lut_length_; const uint8_t *partial_lut_; + // Commands sent after the LUT, e.g. voltage settings that differ between full and partial updates. + const uint8_t *full_sequence_; + const uint8_t *partial_sequence_; + size_t lut_length_; + size_t full_sequence_length_; + size_t partial_sequence_length_; uint16_t partial_lut_length_; + bool base_image_required_; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/waveshare.py b/esphome/components/epaper_spi/models/waveshare.py index aecda723643..7594c9d0d0d 100644 --- a/esphome/components/epaper_spi/models/waveshare.py +++ b/esphome/components/epaper_spi/models/waveshare.py @@ -1,5 +1,10 @@ +from collections.abc import Sequence +from typing import Any + import esphome.codegen as cg +from esphome.components.mipi import flatten_sequence from esphome.core import ID +from esphome.types import ConfigType from ..display import CONF_INIT_SEQUENCE_ID from . import EpaperModel @@ -7,34 +12,49 @@ from . import EpaperModel class WaveshareModel(EpaperModel): def __init__( - self, name, lut, lut_partial=None, class_name="EpaperWaveshare", **defaults - ): + self, + name: str, + lut: tuple[int, ...], + lut_partial: tuple[int, ...] | None = None, + sequence_full: tuple[tuple[int, ...], ...] = (), + sequence_partial: tuple[tuple[int, ...], ...] = (), + base_image_required: bool = False, + class_name: str = "EpaperWaveshare", + **defaults: Any, + ) -> None: # A partial LUT is what lets EpaperWaveshare do partial refresh defaults.setdefault("partial_update", lut_partial is not None) super().__init__(name, class_name=class_name, **defaults) self.lut = lut self.lut_partial = lut_partial + self.sequence_full = sequence_full + self.sequence_partial = sequence_partial + self.base_image_required = base_image_required - def get_constructor_args(self, config) -> tuple: - lut = ( + def _array_arg( + self, config: ConfigType, suffix: str, data: Sequence[int] | None + ) -> tuple: + if not data: + return cg.nullptr, 0 + return ( cg.static_const_array( - ID(config[CONF_INIT_SEQUENCE_ID].id + "_lut", type=cg.uint8), self.lut + ID(config[CONF_INIT_SEQUENCE_ID].id + suffix, type=cg.uint8), data ), - len(self.lut), + len(data), + ) + + def get_constructor_args(self, config: ConfigType) -> tuple: + return ( + *self._array_arg(config, "_lut", self.lut), + *self._array_arg(config, "_lut_partial", self.lut_partial), + *self._array_arg( + config, "_sequence_full", flatten_sequence(self.sequence_full) + ), + *self._array_arg( + config, "_sequence_partial", flatten_sequence(self.sequence_partial) + ), + self.base_image_required, ) - if self.lut_partial is None: - lut_partial = cg.nullptr, 0 - else: - lut_partial = ( - cg.static_const_array( - ID( - config[CONF_INIT_SEQUENCE_ID].id + "_lut_partial", type=cg.uint8 - ), - self.lut_partial, - ), - len(self.lut_partial), - ) - return *lut, *lut_partial # fmt: off @@ -90,3 +110,70 @@ WaveshareModel( 0x0, 0x0, 0x0, ), ) + +WaveshareModel( + "waveshare-2.9in-v2", + width=128, + height=296, + initsequence=( + (0x01, 0x27, 0x01, 0x00), # driver output control + (0x0C, 0xD7, 0xD6, 0x9D), # booster soft start control + (0x3A, 0x1A), # dummy line period + (0x3B, 0x08), # gate line width + (0x11, 0x03), # data entry mode + (0x21, 0x00, 0x80), # display update control + (0x18, 0x80), # select internal temperature sensor + ), + # The last 6 bytes of the LUTs in the Waveshare sample code are voltage settings + # rather than waveform data, and are sent as separate commands below. + lut=( + 0x80, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, + 0x00, 0x00, 0x10, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x80, 0x66, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x10, 0x66, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x14, 0x08, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0A, 0x0A, 0x00, + 0x0A, 0x0A, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x08, 0x00, 0x01, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, + 0x00, 0x00, 0x00, + ), + lut_partial=( + 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x00, 0x00, 0x00, + ), + sequence_full=( + (0x3F, 0x22), # option for LUT end + (0x03, 0x17), # gate voltage + (0x04, 0x41, 0x00, 0x32), # source voltage: VSH1, VSH2, VSL + (0x2C, 0x36), # VCOM + ), + sequence_partial=( + (0x3F, 0x22), # option for LUT end + (0x03, 0x17), # gate voltage + (0x04, 0x41, 0xB0, 0x32), # source voltage: VSH1, VSH2, VSL + (0x2C, 0x36), # VCOM + (0x37, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00), + ), + base_image_required=True, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index f6091e5715c..6a487064cbc 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -70,6 +70,23 @@ display: allow_other_uses: true number: GPIO4 + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.9in-v2 + full_update_every: 10 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + - platform: epaper_spi spi_id: spi_bus model: waveshare-7.5in-H From 4324b7d084c69d3f6c4657ee3779217e8ee3ce67 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:58:49 +0200 Subject: [PATCH 059/120] [zigbee] bump esp-zigbee-sdk to 2.0.5 (#20998) --- esphome/components/zigbee/zigbee_esp32.cpp | 26 ----------------- esphome/components/zigbee/zigbee_esp32.h | 33 +--------------------- esphome/components/zigbee/zigbee_esp32.py | 12 ++------ esphome/idf_component.yml | 2 +- tests/component_tests/esp32/test_esp32.py | 16 ++++++----- 5 files changed, 14 insertions(+), 75 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 29bbdc2b0b5..35f12b3a4e3 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -388,15 +388,6 @@ void ZigbeeComponent::setup() { }; ezb_af_set_node_power_desc(&desc); - // Finish zigbee data model - for (auto &attr_value : this->attr_values_) { - ezb_zcl_attr_desc_t attr_desc = attr_value.attr_desc; - void *value_p = &attr_value.value; - ezb_zcl_attr_desc_set_value(attr_desc, value_p); - } - // free memory - std::vector().swap(this->attr_values_); - // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network @@ -470,23 +461,6 @@ void ZigbeeComponent::dump_config() { esp_zigbee_lock_release(); } } - -bool ZigbeeComponent::string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id) { - ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); - if (ep_desc == NULL) { - return false; - } - ezb_zcl_cluster_desc_t cluster_desc = ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role); - if (cluster_desc == NULL) { - return false; - } - if (ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE) == NULL) { - return false; - } - ESP_LOGW(TAG, "Attribute 0x%04X already exists in endpoint %u cluster 0x%04X. Can't add new value", attr_id, - endpoint_id, cluster_id); - return true; -} } // namespace esphome::zigbee #endif diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index d5ed7439bd2..93a4f1ba780 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -29,11 +29,6 @@ static constexpr uint8_t MAX_ZB_QUEUE_SIZE = 32; uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); -struct AttrValue { - ezb_zcl_attr_desc_t attr_desc; - uint8_t value[4]; -}; - class ZigbeeAttribute; class ZigbeeComponent final : public Component { @@ -101,7 +96,6 @@ class ZigbeeComponent final : public Component { // automations // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; - std::vector attr_values_; ezb_af_device_desc_t dev_desc_ = ezb_af_create_device_desc(); CallbackManager join_cb_{}; template friend void enqueue_zb_event(Args... args); @@ -119,8 +113,6 @@ class ZigbeeComponent final : public Component { std::atomic joined_ = false; std::atomic join_pending_ = false; std::atomic factory_new_ = false; - // TODO: remove when esp-zigbee-lib fixes set_value before init - bool string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id); }; template @@ -135,22 +127,10 @@ void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint1 // The size byte of the zcl_str must be set to the maximum value, // even though the initial string may be shorter. if constexpr (std::is_same::value) { - if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { - if (attr != nullptr) { - this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; - } - return; - } auto zcl_str = get_zcl_string(value.c_str(), max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; } else if constexpr (std::is_convertible::value) { - if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { - if (attr != nullptr) { - this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; - } - return; - } auto zcl_str = get_zcl_string(value, max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; @@ -170,18 +150,7 @@ void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint if (cluster_desc == NULL) { return; } - // TODO: revert when esp-zigbee-lib fixes set_value before init - ezb_zcl_attr_desc_t attr_desc = ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE); - - if (attr_desc != NULL) { - static_assert(sizeof(*value_p) <= 4); - AttrValue attr_value; - attr_value.attr_desc = attr_desc; - memcpy(&attr_value.value, value_p, sizeof(*value_p)); - attr_values_.push_back(attr_value); - } else { - esphome_zb_cluster_add_attr(cluster_id, cluster_desc, attr_id, value_p); - } + esphome_zb_cluster_add_or_update_attr(cluster_id, cluster_desc, attr_id, value_p); if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 99f075ecb2f..30c94cde6cb 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -13,7 +13,6 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, add_partition, include_builtin_idf_component, - require_mbedtls_ecp, require_mbedtls_tls_extras, require_vfs_select, ) @@ -395,17 +394,12 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.4", + ref="2.0.5", ) - # The esp-zigbee-lib blobs reference mbedtls_ecp_* (Zigbee Direct, install - # code ECDH); keep ECP without relying on esp_wifi's Kconfig select. - require_mbedtls_ecp() - # Zigbee's crypto platform uses AES-CCM and deterministic ECDSA directly. + # Zigbee's crypto platform uses AES-CCM directly. # Keep the esp32 component from trimming them out of mbedTLS. - require_mbedtls_tls_extras( - ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") - ) + require_mbedtls_tls_extras({"CONFIG_MBEDTLS_CCM_C"}) if CONF_WIFI in CORE.config: # zigbee_esp32.cpp uses esp_coexist.h when WiFi is present diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5ed84fa2a3a..dd837d61632 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -54,7 +54,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.4 + version: 2.0.5 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 3cde877f7ad..86b27776dc6 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -1016,8 +1016,8 @@ def _tls_compiled_out(sdkconfig: dict[str, Any]) -> bool: "exclusion_reincludes_nextion.yaml", False, False, False, id="nextion" ), pytest.param("mbedtls_tls_wifi_eap.yaml", False, False, True, id="wifi_eap"), - # zigbee requests ECP for the esp-zigbee-lib blobs, without TLS. - pytest.param("tls_zigbee_c6.yaml", True, False, True, id="zigbee"), + # zigbee needs neither TLS nor ECP + pytest.param("tls_zigbee_c6.yaml", True, True, True, id="zigbee"), # A raw bundle keeps the TLS role but no longer compiles esp-tls. pytest.param( "certificate_bundle_sdkconfig.yaml", False, False, True, id="raw_bundle" @@ -2188,19 +2188,21 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses( assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) +_CCM_EXTRAS = {"CONFIG_MBEDTLS_CCM_C"} + + def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: """Nothing in the Zigbee config links TLS, so the stack is compiled out and - no role is written; the extras trim still runs and Zigbee keeps CCM and - deterministic ECDSA.""" + no role is written; the extras trim still runs and Zigbee keeps CCM.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert _tls_compiled_out(sdkconfig) assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: - assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) + assert sdkconfig.get(name) is (None if name in _CCM_EXTRAS else False) def test_mbedtls_tls_opt_out_keeps_stack_and_trims_role( @@ -2252,10 +2254,10 @@ def test_mbedtls_tls_zigbee_requires_extras( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The Zigbee hooks mark the CCM/deterministic ECDSA as required.""" + """The Zigbee hooks mark the CCM as required.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] - assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS + assert mbedtls.tls_extras_required == _CCM_EXTRAS _VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} From e110613ef1120a20b0a6d730f7c4c6bbf96de709 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:00:37 +0200 Subject: [PATCH 060/120] [core] Construct App once in the C++ unit test main (#21001) Co-authored-by: J. Nick Koston --- tests/components/daikin/daikin_regression_tests.cpp | 3 --- tests/components/daikin/daikin_tests.cpp | 4 ---- tests/components/display/test_test_card.cpp | 5 ----- tests/components/emontx/common.h | 6 ------ tests/components/emontx/emontx_test.cpp | 1 - tests/components/emontx/text_sensor/emontx_version_test.cpp | 1 - tests/components/filter_lifetime/test_filter_lifetime.cpp | 5 ++++- .../haier_water_heater/water_heater/test_hon_controller.cpp | 4 ---- tests/components/i2c/test_i2c_chip_id.cpp | 4 +++- tests/components/main.cpp | 5 +++++ tests/components/mistral_ir/mistral_ir_tests.cpp | 3 --- tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp | 3 --- tests/components/snapshot/snapshot_test.cpp | 3 --- tests/components/tmp102/test_tmp102.cpp | 5 ++++- tests/components/xiaomi_body_scale/common.h | 3 --- 15 files changed, 16 insertions(+), 39 deletions(-) diff --git a/tests/components/daikin/daikin_regression_tests.cpp b/tests/components/daikin/daikin_regression_tests.cpp index fc642a84e9d..6bb41d30f49 100644 --- a/tests/components/daikin/daikin_regression_tests.cpp +++ b/tests/components/daikin/daikin_regression_tests.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include "esphome/components/daikin/daikin.h" @@ -124,8 +123,6 @@ struct Rig { // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_daikin_regression"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/daikin/daikin_tests.cpp b/tests/components/daikin/daikin_tests.cpp index 0664a82571c..3cea96cce4b 100644 --- a/tests/components/daikin/daikin_tests.cpp +++ b/tests/components/daikin/daikin_tests.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include @@ -146,9 +145,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved if (!keep_preferences && global_preferences != nullptr) global_preferences->reset(); - // The test main does not construct App as generated code does; the boost timer needs its scheduler - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_daikin"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/display/test_test_card.cpp b/tests/components/display/test_test_card.cpp index 555611bc093..a97301ac1e9 100644 --- a/tests/components/display/test_test_card.cpp +++ b/tests/components/display/test_test_card.cpp @@ -31,11 +31,6 @@ class TestCardDisplay : public Display { 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_++; }; diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h index 678d582f62f..932fac91cd2 100644 --- a/tests/components/emontx/common.h +++ b/tests/components/emontx/common.h @@ -44,12 +44,6 @@ class MockUARTComponent : public uart::UARTComponent { #endif // defined(USE_ESP8266) || defined(USE_ESP32) }; -// The test main does not construct App as generated code does -inline void construct_app() { - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; -} - inline void cancel_timers(EmonTx *emontx) { for (uint32_t id = 0; id < 3; id++) App.scheduler.cancel_timeout(emontx, id); diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 71bb6c70d36..227fa91a599 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -14,7 +14,6 @@ namespace { class EmonTxTest : public ::testing::Test { protected: void SetUp() override { - construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.add_on_data_callback([this](StringRef line) { this->lines_.emplace_back(line.c_str(), line.size()); }); this->sut_.add_on_json_callback([this](JsonObject, StringRef) { this->json_count_++; }); diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp index 34f6315f3ab..fa51db63e5f 100644 --- a/tests/components/emontx/text_sensor/emontx_version_test.cpp +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -12,7 +12,6 @@ namespace { class EmonTxVersionTest : public ::testing::Test { protected: void SetUp() override { - construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.set_version_text_sensor(&this->version_); } diff --git a/tests/components/filter_lifetime/test_filter_lifetime.cpp b/tests/components/filter_lifetime/test_filter_lifetime.cpp index a4c89edd856..4f33afa24ef 100644 --- a/tests/components/filter_lifetime/test_filter_lifetime.cpp +++ b/tests/components/filter_lifetime/test_filter_lifetime.cpp @@ -39,7 +39,9 @@ class FilterLifetimeTest : public ::testing::Test { unsigned publications_{0}; void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21); host::setup_preferences(); host::host_preferences->reset(); @@ -55,6 +57,7 @@ class FilterLifetimeTest : public ::testing::Test { void TearDown() override { host::host_preferences->reset(); App.~Application(); + new (&App) Application(); // later tests and the test listener use App } void attach(FilterLifetime &filter) { diff --git a/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp index 41b40687d48..0de87d7a7da 100644 --- a/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp +++ b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include "esphome/components/haier_water_heater/hon_water_heater.h" @@ -53,9 +52,6 @@ class TestableHeater : public HonWaterHeater { class HonControllerTest : public ::testing::Test { protected: void SetUp() override { - // The test runner skips generated setup(), which normally constructs App and its scheduler. - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; this->heater_.set_uart_parent(&this->uart_); this->heater_.set_network_pending(false); this->set_clock_(100); diff --git a/tests/components/i2c/test_i2c_chip_id.cpp b/tests/components/i2c/test_i2c_chip_id.cpp index c3e3fdd4907..a3b567d917d 100644 --- a/tests/components/i2c/test_i2c_chip_id.cpp +++ b/tests/components/i2c/test_i2c_chip_id.cpp @@ -33,7 +33,9 @@ class ChipDevice : public Component, public I2CDevice {}; class I2CChipIdTest : public ::testing::Test { protected: void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); static char name[] = "i2c-chip-id-tests"; static char friendly_name[] = "I2C chip ID tests"; App.pre_setup(name, sizeof(name) - 1, friendly_name, sizeof(friendly_name) - 1); diff --git a/tests/components/main.cpp b/tests/components/main.cpp index 4020dd9a505..cbf19f31ba4 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -32,6 +32,11 @@ class ResetAppListener : public ::testing::EmptyTestEventListener { }; void setup() { + // Construct App once, as generated code does, before the logger, the listener or any test touches it. + // A fixture that destroys App must construct it again before returning. + // Placement new needs , which the generated include block already provides + new (&esphome::App) esphome::Application; + // Log functions call global_logger->log_vprintf_() without a null check, // so we must set up a Logger before any test that triggers logging. static esphome::logger::Logger test_logger(0); diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp index 9b489154d02..b7260f95d88 100644 --- a/tests/components/mistral_ir/mistral_ir_tests.cpp +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -84,9 +84,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_mistral_ir"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp index 293c363d5d6..76ffd5a70d4 100644 --- a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp +++ b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp @@ -72,9 +72,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_pd_pioneer_ir"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/snapshot/snapshot_test.cpp b/tests/components/snapshot/snapshot_test.cpp index d63101357a2..92200814181 100644 --- a/tests/components/snapshot/snapshot_test.cpp +++ b/tests/components/snapshot/snapshot_test.cpp @@ -46,9 +46,6 @@ class SnapshotAnimationTest : public ::testing::Test { fs::remove_all(this->dir_); fs::create_directories(this->dir_); setenv("ESPHOME_SNAPSHOT_DIR", this->dir_.c_str(), 1); - // The test main does not construct App as generated code does, and recording needs its scheduler. - static const bool app_constructed = (new (&App) Application(), true); - (void) app_constructed; App.pre_setup("test_snapshot", 10, "", 0); } void TearDown() override { diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index a5b65b060d4..081dfba8d2c 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -71,7 +71,9 @@ class TMP102Test : public ::testing::Test { unsigned publications_{0}; void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); App.pre_setup("tmp102-regression-tests", 23, "TMP102 tests", 12); host::setup_preferences(); host::host_preferences->reset(); @@ -93,6 +95,7 @@ class TMP102Test : public ::testing::Test { App.scheduler.call(millis()); host::host_preferences->reset(); App.~Application(); + new (&App) Application(); // later tests and the test listener use App } static void run_timers(uint32_t duration) { diff --git a/tests/components/xiaomi_body_scale/common.h b/tests/components/xiaomi_body_scale/common.h index 67f1eaae91a..e21b014efff 100644 --- a/tests/components/xiaomi_body_scale/common.h +++ b/tests/components/xiaomi_body_scale/common.h @@ -52,9 +52,6 @@ inline ble_device_base::ESPBTDevice advert(uint64_t address, const Frame &frame) struct Harness { Harness(uint64_t address, const char *key) : scale(address, key) { - // The test main does not construct App as generated code does; the stabilized reset needs its scheduler - static const bool app_constructed = (new (&App) Application(), true); - (void) app_constructed; App.pre_setup("test_scale", 10, "", 0); this->scale.set_weight(&this->weight); this->scale.set_impedance_low(&this->impedance_low); From 5c2b05d0eb5fe08cb1cbb4b7d133e30308c49835 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 09:00:51 -1000 Subject: [PATCH 061/120] [ci] Run the C++ unit tests on macOS without a sanitizer whose runtime aborts (#21000) --- script/cpp_unit_test.py | 57 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index c5179a1b1e9..c2b34e1b409 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -3,7 +3,9 @@ import argparse from functools import partial import os from pathlib import Path +import subprocess import sys +import tempfile from build_helpers import ( PLATFORMIO_GOOGLE_TEST_LIB, @@ -12,6 +14,9 @@ from build_helpers import ( ) from helpers import get_all_components, root_path +from esphome.core import EsphomeError +from esphome.host.toolchain import find_compilers + # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" @@ -20,11 +25,55 @@ BUILD_FLAGS = [ "-g3", # max debug info "-ggdb3", "-DESPHOME_DEBUG", # enable debug assertions - # Enable the address and undefined behavior sanitizers - "-fsanitize=address", - "-fsanitize=undefined", "-fno-omit-frame-pointer", ] +SANITIZER_FLAGS = ["-fsanitize=address", "-fsanitize=undefined"] + + +def sanitizer_flags() -> list[str]: + """The sanitizer flags to build with: all of them, except on macOS the ones whose runtime + does not start. Apple clang 15's AddressSanitizer aborts before main() on macOS 26, which + would fail every test for a toolchain problem, so each flag is tried on an empty program + first. A compiler that cannot build the empty program is an error, not a dropped flag. + CI keeps every flag. + """ + if sys.platform != "darwin": + return SANITIZER_FLAGS + try: + cxx = find_compilers().cxx + except EsphomeError as err: + sys.exit(str(err)) + flags: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + program = Path(tmp) / "probe" + for flag in SANITIZER_FLAGS: + compiled = subprocess.run( + [*cxx, flag, "-x", "c++", "-", "-o", program], + check=False, + input=b"int main() { return 0; }", + capture_output=True, + ) + if compiled.returncode != 0: + sys.exit( + f"{cxx[0]} cannot build an empty program with {flag}:\n" + f"{compiled.stderr.decode(errors='replace').strip()}" + ) + ran = subprocess.run([program], check=False, capture_output=True) + if ran.returncode == 0: + flags.append(flag) + continue + lines = [ + line + for line in ran.stderr.decode(errors="replace").splitlines() + if line.strip() + ] + print( + f"{flag} aborts at start with {cxx[0]}: " + f"{lines[0] if lines else f'exit code {ran.returncode}'}\n" + " building the tests without it; set CXX to a compiler whose runtime works to keep it", + file=sys.stderr, + ) + return flags def run_tests(selected_components: list[str]) -> int: @@ -39,7 +88,7 @@ def run_tests(selected_components: list[str]) -> int: config_prefix="cpptests", friendly_name="CPP Unit Tests", libraries=PLATFORMIO_GOOGLE_TEST_LIB, - build_flags=BUILD_FLAGS, + build_flags=BUILD_FLAGS + sanitizer_flags(), main_entry="main.cpp", label="unit tests", ) From c2c8cb5eb37eb244a82d65cfb319a92fd44756fa Mon Sep 17 00:00:00 2001 From: Matias Fernandez <7898112+MatiasFernandez@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:12:35 -0300 Subject: [PATCH 062/120] [climate_ir_lg] Fix swing command to not include climate state (#17417) Co-authored-by: J. Nick Koston --- .../climate_ir_lg/climate_ir_lg.cpp | 8 +- .../climate/lg_ir_climate_tests.cpp | 90 +++++++++++++++++++ tests/components/climate_ir_lg/common.h | 33 +++++++ 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp create mode 100644 tests/components/climate_ir_lg/common.h diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.cpp b/esphome/components/climate_ir_lg/climate_ir_lg.cpp index 042d1dcb21f..16ed9831fd7 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.cpp +++ b/esphome/components/climate_ir_lg/climate_ir_lg.cpp @@ -135,13 +135,14 @@ void LgIrClimate::transmit_state() { default: return; } - this->transmit_(remote_state); - this->publish_state(); - return; } else { // just toggle swing when advanced_commands_support is not set remote_state |= HEADER_BASIC; remote_state |= BASIC_SWING_TOGGLE; } + // The remote's swing button sends a fixed code with no fan speed or temperature bits, so the + // frame goes out as is instead of falling through to them + this->transmit_(remote_state); + return; } else { // Mode commands const bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF); switch (this->mode) { @@ -208,7 +209,6 @@ void LgIrClimate::transmit_state() { } this->transmit_(remote_state); - this->publish_state(); } bool LgIrClimate::on_receive(remote_base::RemoteReceiveData data) { diff --git a/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp b/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp new file mode 100644 index 00000000000..c8c8e79db65 --- /dev/null +++ b/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp @@ -0,0 +1,90 @@ +#include +#include "../common.h" + +namespace esphome::climate_ir_lg::testing { + +namespace { + +constexpr uint32_t BIT_ONE_LOW = 1600; +// A complete LG frame is header (2) + 28 bit pairs (56) + trailing mark (1) +constexpr size_t FRAME_SIZE = 2 + 28 * 2 + 1; + +// A climate with timings set, so transmit_() produces a frame decode_lg_frame() can read back +void configure(LgIrClimate &sut, MockRemoteTransmitter &transmitter) { + sut.set_transmitter(&transmitter); + sut.set_header_high(8000); + sut.set_header_low(4000); + sut.set_bit_high(600); + sut.set_bit_one_low(BIT_ONE_LOW); + sut.set_bit_zero_low(550); +} + +// COOL at 20 °C with the fan on HIGH: a distinctive state the swing tests must not leak into their frame +void turn_on(LgIrClimate &sut) { + climate::ClimateCall on_call(&sut); + on_call.set_mode(climate::CLIMATE_MODE_COOL); + on_call.set_target_temperature(20); + on_call.set_fan_mode(climate::CLIMATE_FAN_HIGH); + sut.control(on_call); +} + +uint32_t last_frame(const MockRemoteTransmitter &transmitter) { + // Guard against out-of-bounds reads in decode_lg_frame() if the transmission failed + if (transmitter.last_data.size() < FRAME_SIZE) { + ADD_FAILURE() << "no complete frame was transmitted"; + return 0; + } + return decode_lg_frame(transmitter.last_data, BIT_ONE_LOW); +} + +} // namespace + +// A mode command carries the mode, the fan speed and the temperature, with the checksum nibble +TEST(LgIrClimateTests, ModeCommandCarriesModeFanSpeedAndTemperature) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + + turn_on(sut); + + // From OFF: COMMAND_ON_COOL, FAN_MAX, 20 °C encoded as (20 - 15) << 8, checksum 9 + EXPECT_EQ(last_frame(transmitter), 0x8800549u); +} + +// The swing command is a distinct, self-contained IR code: the real remote transmits it +// on its own, independent of the unit's current mode, target temperature, or fan speed. +TEST(LgIrClimateTests, SwingCommandIsIndependentOfClimateState) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + turn_on(sut); + + // Toggle swing -- this must be the fixed swing code below, unaffected by the state set above. + climate::ClimateCall swing_call(&sut); + swing_call.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + sut.control(swing_call); + + // The swing command is always this exact fixed code, regardless of climate state. + EXPECT_EQ(last_frame(transmitter), 0x8810001u); +} + +// With advanced commands the swing frames are fixed codes too, one to start and one to stop the swing +TEST(LgIrClimateTests, AdvancedSwingCommandsAreIndependentOfClimateState) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + sut.set_advanced_commands_support(true); + turn_on(sut); + + climate::ClimateCall swing_on(&sut); + swing_on.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + sut.control(swing_on); + EXPECT_EQ(last_frame(transmitter), 0x8813149u); // HEADER_ADV_SWING | VERT_SWING_ON + + climate::ClimateCall swing_off(&sut); + swing_off.set_swing_mode(climate::CLIMATE_SWING_OFF); + sut.control(swing_off); + EXPECT_EQ(last_frame(transmitter), 0x881315Au); // HEADER_ADV_SWING | VERT_SWING_OFF +} + +} // namespace esphome::climate_ir_lg::testing diff --git a/tests/components/climate_ir_lg/common.h b/tests/components/climate_ir_lg/common.h new file mode 100644 index 00000000000..7e9a79cc615 --- /dev/null +++ b/tests/components/climate_ir_lg/common.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include "esphome/components/climate_ir_lg/climate_ir_lg.h" +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::climate_ir_lg::testing { + +// Records the raw mark/space timings instead of driving real IR hardware. +class MockRemoteTransmitter : public remote_base::RemoteTransmitterBase { + public: + MockRemoteTransmitter() : RemoteTransmitterBase(nullptr) {} + + std::vector last_data; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { this->last_data = this->temp_.get_data(); } +}; + +// Decodes the raw mark/space timings produced by LgIrClimate::transmit_() back into the +// 28-bit value that was transmitted (header + bits, ignoring the trailing mark). +inline uint32_t decode_lg_frame(const std::vector &data, uint32_t bit_one_low) { + uint32_t value = 0; + // data[0]/data[1] are the header mark/space; each bit thereafter is a mark/space pair. + for (size_t i = 0; i < 28; i++) { + int32_t space = data[2 + 2 * i + 1]; + value = (value << 1) | (static_cast(-space) == bit_one_low ? 1 : 0); + } + return value; +} + +} // namespace esphome::climate_ir_lg::testing From 5f8f075e767a7b07e16323dc4645304b38e4b71a Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 17 Oct 2026 21:22:28 +0200 Subject: [PATCH 063/120] [zephyr_coredump] Store a coredump in flash on nRF52 and print it to the next log reader (#13122) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/logger/logger_zephyr.cpp | 38 +++- esphome/components/nrf52/clang_tidy.py | 1 + .../components/zephyr_coredump/__init__.py | 111 ++++++++++ .../components/zephyr_coredump/coredump.cpp | 191 ++++++++++++++++++ esphome/components/zephyr_coredump/coredump.h | 43 ++++ .../components/zephyr_mcumgr/ota/__init__.py | 26 ++- esphome/core/defines.h | 1 + .../zephyr_coredump/__init__.py | 0 .../config/mcumgr_adafruit.yaml | 15 ++ .../zephyr_coredump/test_zephyr_coredump.py | 104 ++++++++++ tests/components/zephyr_coredump/common.yaml | 1 + .../zephyr_coredump/test.nrf52-adafruit.yaml | 2 + .../zephyr_coredump/test.nrf52-mcumgr.yaml | 2 + .../test.nrf52-nrf52840dongle.yaml | 2 + .../zephyr_coredump/test.nrf52-xiao-ble.yaml | 2 + 16 files changed, 526 insertions(+), 14 deletions(-) create mode 100644 esphome/components/zephyr_coredump/__init__.py create mode 100644 esphome/components/zephyr_coredump/coredump.cpp create mode 100644 esphome/components/zephyr_coredump/coredump.h create mode 100644 tests/component_tests/zephyr_coredump/__init__.py create mode 100644 tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml create mode 100644 tests/component_tests/zephyr_coredump/test_zephyr_coredump.py create mode 100644 tests/components/zephyr_coredump/common.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-adafruit.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 65ed44ee30b..539730a4a67 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -727,6 +727,7 @@ esphome/components/xl9535/* @mreditor97 esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 +esphome/components/zephyr_coredump/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 esphome/components/zephyr_pwm/* @wiomoc esphome/components/zhlt01/* @cfeenstra1024 diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 95cc5d4a14b..c74943940c8 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -3,6 +3,9 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "logger.h" +#ifdef USE_CRASH_HANDLER +#include "esphome/components/zephyr_coredump/coredump.h" +#endif #include #include @@ -12,12 +15,6 @@ #include #endif -namespace esphome::zephyr_coredump { - -__attribute__((weak)) void print_coredump() {} - -} // namespace esphome::zephyr_coredump - namespace esphome::logger { // Zephyr 3.7 renamed z_arch_esf_t to struct arch_esf; the old name was later removed. @@ -56,6 +53,14 @@ void Logger::cdc_loop_() { if (!opened) { App.schedule_dump_config(); } +#ifdef USE_CRASH_HANDLER + // The new reader also gets a coredump stored by the last crash; a closed port keeps it for the next one + if (!opened) { + log_crash_to_client(); + } else { + zephyr_coredump::stop_coredump(); + } +#endif opened = !opened; } #endif @@ -103,7 +108,16 @@ void Logger::pre_setup() { const char *reset_reason = zephyr::get_reset_reason(std::span(reason_buffer)); ESP_LOGI(TAG, "Reset reason: %s", reset_reason); dump_crash_(); - zephyr_coredump::print_coredump(); +#endif +#ifdef USE_CRASH_HANDLER +#ifdef USE_LOGGER_UART_SELECTION_USB_CDC + // Nobody reads a USB port this early; cdc_loop_() reports the dump once the port is opened + if (this->uart_ == UART_SELECTION_USB_CDC) + return; +#endif + // Without a UART (baud_rate: 0, or one that did not come up) nothing reaches the console + if (this->uart_dev_ != nullptr) + log_crash_to_client(); #endif } @@ -195,6 +209,16 @@ void k_sys_fatal_error_handler(unsigned int reason, const FatalErrorEsf *esf) { arch_restart(); } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return zephyr_coredump::coredump_pending(); } +void log_crash_to_client() { + // A level that drops the lines would erase the dump unseen; keep it for a client that can see it + if (global_logger->get_log_level(zephyr_coredump::CRASH_LOG_TAG) < ESPHOME_LOG_LEVEL_ERROR) + return; + zephyr_coredump::print_coredump(); +} +#endif + } // namespace esphome::logger extern "C" { diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py index d1f95979767..f4afafa0e49 100644 --- a/esphome/components/nrf52/clang_tidy.py +++ b/esphome/components/nrf52/clang_tidy.py @@ -93,6 +93,7 @@ CONFIG_NVS=y CONFIG_SETTINGS=y """, ), + _TidySubsystem("coredump", "CONFIG_DEBUG_COREDUMP=y\n"), ) _TIDY_PRJ_CONF = "".join( diff --git a/esphome/components/zephyr_coredump/__init__.py b/esphome/components/zephyr_coredump/__init__.py new file mode 100644 index 00000000000..cc36ac7dc4c --- /dev/null +++ b/esphome/components/zephyr_coredump/__init__.py @@ -0,0 +1,111 @@ +import esphome.codegen as cg +from esphome.components.nrf52 import NRF52840_FLASH_SIZE, TESTING_FLASH_SIZE +from esphome.components.nrf52.boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG +from esphome.components.zephyr import ( + Section, + zephyr_add_overlay, + zephyr_add_pm_static, + zephyr_add_prj_conf, + zephyr_data, +) +from esphome.components.zephyr.const import ( + BOOTLOADER_MCUBOOT, + KEY_BOARD, + KEY_BOOTLOADER, + KEY_PM_STATIC, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType + +CODEOWNERS = ["@tomaszduda23"] +DEPENDENCIES = ["logger"] +DOMAIN = "zephyr_coredump" + +Coredump = cg.esphome_ns.namespace(DOMAIN).class_("Coredump", cg.Component) + +# SDK 2.9.2 and later can store every thread's stack; older ones only the faulting thread +DUMP_THREADS_MIN_VERSION = cv.Version(2, 9, 2) +# Zephyr 4.0 (from SDK 3.0) carries the fault stack pointer fix (zephyr PR 79622) +SP_FIX_MIN_VERSION = cv.Version(3, 0, 0) +# One size for every SDK, so enabling the component moves the OTA image slots the same way everywhere +PARTITION_SIZE = 0x8000 + +CONFIG_SCHEMA = cv.All( + cv.Schema({cv.GenerateID(): cv.declare_id(Coredump)}).extend(cv.COMPONENT_SCHEMA), + cv.only_on_nrf52, +) + + +def _framework_version() -> cv.Version: + return CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + + +def _partition_address() -> int: + """Place the partition directly below the lowest section reserved above the + SoftDevice (the bootloader), or at the end of flash with MCUboot.""" + data = zephyr_data() + # The SoftDevice (or MBR) is the only section that can start at 0 + reserved = [s.address for s in data[KEY_PM_STATIC] if s.address > 0] + if reserved: + return min(reserved) - PARTITION_SIZE + if (bootloader := data[KEY_BOOTLOADER]) != BOOTLOADER_MCUBOOT: + raise cv.Invalid( + f"{DOMAIN} does not know the flash layout of bootloader '{bootloader}', " + f"select one of: {', '.join(BOOTLOADER_CONFIG)}" + ) + # MCUboot reserves nothing, so the flash size has to come from the board + board = data[KEY_BOARD] + if "nrf52840" not in board and board not in BOARDS_ZEPHYR: + raise cv.Invalid( + f"{DOMAIN} does not know the flash size of board '{board}', " + "only nRF52840 boards are supported" + ) + flash_size = TESTING_FLASH_SIZE if CORE.testing_mode else NRF52840_FLASH_SIZE + return flash_size - PARTITION_SIZE + + +def _final_validate(config: ConfigType) -> ConfigType: + _partition_address() + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +# Runs before zephyr_mcumgr OTA (OTA_UPDATES), which sizes its image slots below this partition +@coroutine_with_priority(CoroPriority.DIAGNOSTICS) +async def to_code(config: ConfigType) -> None: + # The logger's crash handler hooks: the dump reaches serial, API and MQTT log clients + cg.add_define("USE_CRASH_HANDLER") + zephyr_add_prj_conf("DEBUG_COREDUMP", True) + zephyr_add_prj_conf("DEBUG_COREDUMP_BACKEND_FLASH_PARTITION", True) + framework_ver = _framework_version() + if framework_ver >= DUMP_THREADS_MIN_VERSION: + zephyr_add_prj_conf("DEBUG_COREDUMP_MEMORY_DUMP_THREADS", True) + else: + zephyr_add_prj_conf("DEBUG_COREDUMP_MEMORY_DUMP_MIN", True) + if framework_ver < SP_FIX_MIN_VERSION: + cg.add_define("USE_ZEPHYR_COREDUMP_SP_FIX") + cg.add_build_flag("-Wl,--wrap=z_arm_fatal_error") + cg.add_build_flag("-Wl,--wrap=z_arm_fault") + zephyr_add_prj_conf("PARTITION_MANAGER_ENABLED", True) + addr = _partition_address() + zephyr_add_pm_static( + [Section("coredump_partition", addr, PARTITION_SIZE, "flash_primary")] + ) + zephyr_add_overlay( + f""" + &flash0 {{ + partitions {{ + coredump_partition: partition@{addr:x} {{ + label = "coredump-partition"; + reg = <{addr:#x} {PARTITION_SIZE:#x}>; + }}; + }}; + }}; + """ + ) + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/esphome/components/zephyr_coredump/coredump.cpp b/esphome/components/zephyr_coredump/coredump.cpp new file mode 100644 index 00000000000..7bfbcc927a0 --- /dev/null +++ b/esphome/components/zephyr_coredump/coredump.cpp @@ -0,0 +1,191 @@ +#ifdef USE_ZEPHYR +#include "coredump.h" +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include +#include +// The header has no C++ linkage guards of its own +extern "C" { +#include +} + +namespace esphome::zephyr_coredump { + +ESPHOME_LOG_TAG(TAG, "zephyr_coredump"); + +// Lines in the "#CD:" format that Zephyr's coredump_serial_log_parser.py reads: the log header (time, level, +// tag, line number and colour codes, under 64 characters), "#CD:" and two hex characters per byte must fit the +// logger's line buffer, so a small buffer gets shorter lines +static constexpr size_t LINE_OVERHEAD = 64; +static constexpr size_t CHUNK_SIZE = + std::min(64, (ESPHOME_LOGGER_TX_BUFFER_SIZE - LINE_OVERHEAD) / 2 / 16 * 16); +static_assert(CHUNK_SIZE >= 16, "logger line buffer too small for a coredump line"); +// A USB host starts reading the port only some 100 ms after opening it; until then the CDC driver discards output +static constexpr uint32_t START_DELAY_MS = 1000; +static constexpr uint32_t START_DELAY_ID = 0; +// Between lines, so a loop that runs faster than usual (a stepper moving) cannot overrun the USB CDC ring buffer +static constexpr uint32_t LINE_INTERVAL_MS = 10; + +// On a UART the logger reports the reader before this component exists, so the flag outlives the pointer +static Coredump *global_coredump = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool reader_present = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool coredump_pending() { return global_coredump != nullptr && global_coredump->stored_; } + +void print_coredump() { + reader_present = true; + if (global_coredump != nullptr) + global_coredump->start_print_(); +} + +void stop_coredump() { + reader_present = false; + if (global_coredump != nullptr) + global_coredump->stop_print_(); +} + +void Coredump::setup() { + global_coredump = this; + this->disable_loop(); + // The size comes from the header alone; "has stored dump" also checks the checksum and would hide a + // corrupt dump instead of letting it be reported and erased + const int size = coredump_query(COREDUMP_QUERY_GET_STORED_DUMP_SIZE, nullptr); + if (size <= 0) { + return; + } + this->stored_ = true; + this->total_ = size; + // A bad dump is reported and erased from loop() like a good one, once somebody can read the message + this->verify_ = coredump_cmd(COREDUMP_CMD_VERIFY_STORED_DUMP, nullptr); + if (reader_present) + this->start_print_(); +} + +// A new reader gets the whole dump: a print cut short by the port closing starts over +void Coredump::start_print_() { + if (!this->stored_ || this->printing_) + return; + this->printing_ = true; + this->offset_ = 0; + this->set_timeout(START_DELAY_ID, START_DELAY_MS, [this]() { this->enable_loop(); }); +} + +void Coredump::stop_print_() { + this->printing_ = false; + this->cancel_timeout(START_DELAY_ID); + this->disable_loop(); +} + +void Coredump::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_line_ms_ < LINE_INTERVAL_MS) + return; + this->last_line_ms_ = now; + if (this->verify_ != 1) { + ESP_LOGE(TAG, "Stored coredump verification failed: %d", this->verify_); + this->finish_(0); + return; + } + if (this->offset_ == 0) { + ESP_LOGE(TAG, "#CD:BEGIN#"); + } + uint8_t buf[CHUNK_SIZE]; + char hex[format_hex_size(CHUNK_SIZE)]; + coredump_cmd_copy_arg arg{static_cast(this->offset_), buf, + std::min(CHUNK_SIZE, this->total_ - this->offset_)}; + const int ret = coredump_cmd(COREDUMP_CMD_COPY_STORED_DUMP, &arg); + if (ret > 0) { + ESP_LOGE(TAG, "#CD:%s", format_hex_to(hex, buf, ret)); + this->offset_ += ret; + } + if (ret <= 0 || this->offset_ >= this->total_) { + ESP_LOGE(TAG, "#CD:END#"); + this->finish_(ret < 0 ? ret : 0); + } +} + +// Erase so the dump is not reported again; the next crash writes a new one. Erasing the partition halts the +// CPU for about 85 ms per 4 KB page, so this one call takes most of a second +void Coredump::finish_(int err) { + if (err < 0) { + ESP_LOGE(TAG, "Failed to copy coredump: %d", err); + } + const int erase = coredump_cmd(COREDUMP_CMD_ERASE_STORED_DUMP, nullptr); + if (erase < 0) { + ESP_LOGE(TAG, "Failed to erase coredump: %d", erase); + } + this->stored_ = false; + this->total_ = 0; + this->verify_ = 0; + this->printing_ = false; + this->disable_loop(); +} + +void Coredump::dump_config() { + ESP_LOGCONFIG(TAG, + "Coredump:\n" + " Stored dump: %s\n" + " Size: %" PRIu32 "\n" + " Verified: %s\n" + " Error: %d", + YESNO(this->stored_), this->total_, YESNO(this->verify_ == 1), + coredump_query(COREDUMP_QUERY_GET_ERROR, nullptr)); +} + +} // namespace esphome::zephyr_coredump + +// Backport of https://github.com/zephyrproject-rtos/zephyr/pull/79622 (Zephyr 4.0): give GDB the stack +// pointer from before the exception frame so it can unwind. The Python side links it in with --wrap. +#ifdef USE_ZEPHYR_COREDUMP_SP_FIX +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +#if KERNEL_VERSION_NUMBER >= 0x030700 +using esf_t = struct arch_esf; +#else +using esf_t = z_arch_esf_t; +#endif + +static constexpr uint8_t EXC_RETURN_STACK_FRAME_TYPE_POS = 4; +static constexpr uint32_t EXC_RETURN_STACK_FRAME_TYPE_EXTENDED = 0; + +static uint32_t global_exc_return; + +void __real_z_arm_fault(uint32_t msp, uint32_t psp, uint32_t exc_return, _callee_saved_t *callee_regs); +void __wrap_z_arm_fault(uint32_t msp, uint32_t psp, uint32_t exc_return, _callee_saved_t *callee_regs) { + global_exc_return = exc_return; + __real_z_arm_fault(msp, psp, exc_return, callee_regs); +} + +// Faults from z_arm_fault() come here with the frame z_arm_fault() saved; a spurious interrupt has none. +// Software fatal errors (k_panic, k_oops) reach z_arm_fatal_error() inside its own object, which --wrap cannot +// intercept, so their dumps keep Zephyr's stack pointer. +void __real_z_arm_fatal_error(unsigned int reason, const esf_t *esf); +void __wrap_z_arm_fatal_error(unsigned int reason, const esf_t *esf) { +#if defined(CONFIG_ARMV7_M_ARMV8_M_MAINLINE) || defined(CONFIG_ARMV6_M_ARMV8_M_BASELINE) + if (esf == nullptr) { + __real_z_arm_fatal_error(reason, esf); + return; + } + z_arm_coredump_fault_sp += sizeof(esf->basic); +#if defined(CONFIG_FPU) && defined(CONFIG_FPU_SHARING) + // The thread used the FP registers, so the frame also holds them + if ((global_exc_return & BIT(EXC_RETURN_STACK_FRAME_TYPE_POS)) == EXC_RETURN_STACK_FRAME_TYPE_EXTENDED) { + z_arm_coredump_fault_sp += sizeof(esf->fpu); + } +#endif +#ifndef CONFIG_ARMV8_M_MAINLINE + // PSR bit 9 set: the frame was padded for alignment + if ((esf->basic.xpsr & SCB_CCR_STKALIGN_Msk) == SCB_CCR_STKALIGN_Msk) { + z_arm_coredump_fault_sp |= 0x4; + } +#endif +#endif + __real_z_arm_fatal_error(reason, esf); +} +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif +#endif diff --git a/esphome/components/zephyr_coredump/coredump.h b/esphome/components/zephyr_coredump/coredump.h new file mode 100644 index 00000000000..f56568e3555 --- /dev/null +++ b/esphome/components/zephyr_coredump/coredump.h @@ -0,0 +1,43 @@ +#pragma once +#include "esphome/core/component.h" + +namespace esphome::zephyr_coredump { + +/// The tag the dump is logged under, so a client can check the level in force for it +inline constexpr const char *CRASH_LOG_TAG = "zephyr_coredump"; + +/// Whether a dump from before this boot is waiting to be printed. +bool coredump_pending(); +/// A log reader is present: serial at boot, USB CDC when the port opens, an API or MQTT client subscribing. +/// The stored dump is printed from the start and erased once it has gone out; a print already under way +/// carries on. +void print_coredump(); +/// The reader went away (the USB port was closed); the print stops and the dump stays for the next one. +/// Readers are not told apart: closing the USB port also stops a print an API or MQTT client is receiving, +/// and a client that disconnects does not stop one, the same as the other platforms' crash records. +void stop_coredump(); + +class Coredump : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + + protected: + friend bool coredump_pending(); + friend void print_coredump(); + friend void stop_coredump(); + /// Start printing after a short delay, one line per loop so the USB CDC transmit buffer and the main loop keep up + void start_print_(); + void stop_print_(); + void finish_(int err); + + uint32_t offset_{0}; + uint32_t total_{0}; + uint32_t last_line_ms_{0}; + int verify_{0}; // result of verifying the stored dump, 1 when it is good + bool stored_{false}; + bool printing_{false}; +}; + +} // namespace esphome::zephyr_coredump diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 5b438bbda1c..b6ef932e4f8 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components.nrf52.boards import MCUMGR_OTA_BOOTLOADERS @@ -31,8 +33,11 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@tomaszduda23"] DEPENDENCIES = ["zephyr"] @@ -114,6 +119,12 @@ def _validate_bootloader(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> None: _validate_ble_server(config) _validate_bootloader(config) + if "zephyr_coredump" in fv.full_config.get(): + _LOGGER.warning( + "zephyr_coredump moves the OTA image slots. A device that already runs mcumgr OTA " + "needs one flash over USB after enabling or disabling it; an OTA update alone leaves " + "the installed MCUboot with the old slot layout" + ) if (uart := config[CONF_TRANSPORT].get(CONF_HARDWARE_UART)) is not None: cdc_id = UARTS[uart][1] if cdc_id >= 0: @@ -210,17 +221,18 @@ async def to_code(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader != BOOTLOADER_MCUBOOT: - # Derive partition addresses from the SoftDevice and bootloader sections - # registered with the Partition Manager (moved in testing mode) so that - # the DTS flash map matches what it produces: - # MCUboot sits immediately after the SoftDevice, then slot0, then slot1. + # Derive partition addresses from the sections registered with the + # Partition Manager (moved in testing mode) so that the DTS flash map + # matches what it produces: + # MCUboot sits immediately after the SoftDevice, then slot0, then slot1, + # which ends at the lowest section reserved above (bootloader, coredump). sections = zephyr_data()[KEY_PM_STATIC] mcuboot_size = 0x9000 - sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name) - bl_start = next(s.address for s in sections if "Adafruit" in s.name) + sd_end = next(s.end_address for s in sections if "SoftDevice" in s.name) + top = min(s.address for s in sections if s.address >= sd_end) slot0_start = sd_end + mcuboot_size # Align slot size down to a 4 KB sector boundary - slot_size = ((bl_start - slot0_start) // 2 // 0x1000) * 0x1000 + slot_size = ((top - slot0_start) // 2 // 0x1000) * 0x1000 slot1_start = slot0_start + slot_size def _mcuboot_partition_overlay() -> str: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index de4dfd2607b..331a637b6b3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -756,6 +756,7 @@ #define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_SOFTDEVICE_ID 7 #define USE_SOFTDEVICE_VERSION 1 +#define USE_ZEPHYR_COREDUMP_SP_FIX #define USE_ZIGBEE #define USE_ZIGBEE_WIPE_ON_BOOT #define USE_ZIGBEE_WIPE_ON_BOOT_MAGIC 1 diff --git a/tests/component_tests/zephyr_coredump/__init__.py b/tests/component_tests/zephyr_coredump/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml b/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml new file mode 100644 index 00000000000..cb67b8ef9f7 --- /dev/null +++ b/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml @@ -0,0 +1,15 @@ +esphome: + name: coredumptest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +zephyr_ble_server: + +ota: + - platform: zephyr_mcumgr + +zephyr_coredump: diff --git a/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py b/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py new file mode 100644 index 00000000000..19252188cb8 --- /dev/null +++ b/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py @@ -0,0 +1,104 @@ +"""Tests for the placement of the zephyr_coredump flash partition.""" + +from collections.abc import Callable +import logging +from pathlib import Path +import re + +import pytest + +from esphome.components.nrf52 import set_core_data +from esphome.components.zephyr import zephyr_data +from esphome.components.zephyr.const import KEY_OVERLAY, KEY_PM_STATIC +from esphome.components.zephyr_coredump import ( + DUMP_THREADS_MIN_VERSION, + PARTITION_SIZE, + _partition_address, +) +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + + +def _core_data( + board: str, + bootloader: str, + testing_mode: bool = False, + version: cv.Version = DUMP_THREADS_MIN_VERSION, +) -> None: + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: version} + CORE.testing_mode = testing_mode + set_core_data({"board": board, "bootloader": bootloader}) + + +@pytest.mark.parametrize( + ("board", "bootloader", "testing_mode", "expected"), + [ + # Below the Adafruit bootloader at 0xF4000 + ("adafruit_itsybitsy_nrf52840", "adafruit_nrf52_sd140_v6", False, 0xEC000), + # Below the factory Open DFU bootloader at 0xE0000 + ("nrf52840dongle", "nrf", False, 0xD8000), + # Nothing reserved: the end of the 1 MB flash + ("adafruit_feather_nrf52840", "mcuboot", False, 0xF8000), + # An nRF52840 board whose name does not say so + ("xiao_ble", "mcuboot", False, 0xF8000), + # Testing mode moves the bootloader to the end of the faked 4 MB flash + ("adafruit_itsybitsy_nrf52840", "adafruit_nrf52_sd140_v6", True, 0x3EC000), + ("adafruit_feather_nrf52840", "mcuboot", True, 0x3F8000), + ], +) +def test_partition_address( + board: str, bootloader: str, testing_mode: bool, expected: int +) -> None: + _core_data(board, bootloader, testing_mode) + assert _partition_address() == expected + assert PARTITION_SIZE % 0x1000 == 0 + + +def test_partition_size_is_the_same_on_every_sdk() -> None: + _core_data("adafruit_feather_nrf52840", "mcuboot", version=cv.Version(2, 6, 1)) + assert _partition_address() == 0x100000 - PARTITION_SIZE + + +def test_unknown_flash_size_rejected() -> None: + _core_data("bbc_microbit", "mcuboot") + with pytest.raises(cv.Invalid, match="only nRF52840 boards are supported"): + _partition_address() + + +def test_bootloader_without_layout_rejected() -> None: + """The generic adafruit bootloader registers no sections, so the partition would + land inside the bootloader if it were treated like MCUboot.""" + _core_data("adafruit_itsybitsy_nrf52840", "adafruit") + with pytest.raises(cv.Invalid, match="flash layout of bootloader 'adafruit'"): + _partition_address() + + +def test_mcumgr_slot_ends_below_partition( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The OTA image slot1 must end at or before the coredump partition.""" + generate_main(component_config_path("mcumgr_adafruit.yaml")) + # The logger's crash handler hooks are compiled in for the dump + assert any(d.name == "USE_CRASH_HANDLER" for d in CORE.defines) + sections = {s.name: s for s in zephyr_data()[KEY_PM_STATIC]} + coredump = sections["coredump_partition"] + assert coredump.address == 0xEC000 + match = re.search( + r"slot1_partition: partition@\w+ \{\s*reg = <(0x\w+) (0x\w+)>", + zephyr_data()[KEY_OVERLAY][""], + ) + assert match is not None + start, size = (int(v, 16) for v in match.groups()) + assert start + size <= coredump.address + + +def test_mcumgr_warns_that_the_slots_move( + caplog: pytest.LogCaptureFixture, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + with caplog.at_level(logging.WARNING): + generate_main(component_config_path("mcumgr_adafruit.yaml")) + assert "zephyr_coredump moves the OTA image slots" in caplog.text diff --git a/tests/components/zephyr_coredump/common.yaml b/tests/components/zephyr_coredump/common.yaml new file mode 100644 index 00000000000..e8d8a8b8954 --- /dev/null +++ b/tests/components/zephyr_coredump/common.yaml @@ -0,0 +1 @@ +zephyr_coredump: diff --git a/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml b/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml b/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml b/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml b/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml From f72857151af118e0a05cdf1bd7c99bb09fbedcb2 Mon Sep 17 00:00:00 2001 From: functionpointer Date: Sat, 17 Oct 2026 21:28:52 +0200 Subject: [PATCH 064/120] [pylontech] Add individual cell voltage and temperature sensors (#16852) Co-authored-by: Michael Co-authored-by: ritschys <117527311+ritschys@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/pylontech/__init__.py | 32 ++- esphome/components/pylontech/pylontech.cpp | 225 +++++++++++---- esphome/components/pylontech/pylontech.h | 55 +++- .../components/pylontech/sensor/__init__.py | 48 +++- .../pylontech/sensor/pylontech_sensor.cpp | 23 ++ .../pylontech/sensor/pylontech_sensor.h | 16 +- esphome/core/defines.h | 2 + tests/components/pylontech/__init__.py | 4 +- tests/components/pylontech/common.yaml | 21 ++ tests/components/pylontech/pylontech_test.cpp | 260 +++++++++++++++++- tests/integration/test_uart_mock_pylontech.py | 2 +- 11 files changed, 630 insertions(+), 58 deletions(-) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 8bd57f252c1..e15f8f04c82 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -1,8 +1,10 @@ from dataclasses import dataclass, field import logging +from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE @@ -29,11 +31,13 @@ CV_NUM_BATTERIES = cv.int_range(1, 16) _request_listener_slot = cg.slot_counter("PYLONTECH_LISTENER_COUNT") _request_battery_slot = cg.slot_counter("PYLONTECH_BATTERY_COUNT") +_request_cell_battery_slot = cg.slot_counter("PYLONTECH_CELL_BATTERY_COUNT") @dataclass class PylontechData: batteries: set[tuple[str, int]] = field(default_factory=set) + cell_batteries: set[tuple[str, int]] = field(default_factory=set) def _get_data() -> PylontechData: @@ -42,15 +46,26 @@ def _get_data() -> PylontechData: return CORE.data[DOMAIN] -async def register_listener(config: ConfigType, listener: MockObj) -> None: - """Register a battery listener; the hub keeps one line slot per distinct battery.""" +async def register_listener( + config: ConfigType, listener: MockObj, *, cells: bool = False +) -> None: + """Register a battery listener; the hub keeps one line slot per distinct battery. + + With cells, the hub also reads that battery's cells after every pwr reply and keeps + one cell slot per distinct battery. + """ hub = await cg.get_variable(config[CONF_PYLONTECH_ID]) hub_key = str(hub) _request_listener_slot(hub_key) data = _get_data() - if (key := (hub_key, config[CONF_BATTERY])) not in data.batteries: + key = (hub_key, config[CONF_BATTERY]) + if key not in data.batteries: data.batteries.add(key) _request_battery_slot(hub_key) + if cells and key not in data.cell_batteries: + data.cell_batteries.add(key) + _request_cell_battery_slot(hub_key) + cg.add(hub.add_cell_battery(config[CONF_BATTERY])) cg.add(hub.register_listener(listener)) @@ -80,6 +95,17 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( stop_bits=1, ) +automation.register_apply_action( + "pylontech.set_cell_polling", + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(PylontechComponent), + cv.Required(CONF_ENABLED): cv.templatable(cv.boolean), + } + ), + automation.ApplyField(CONF_ENABLED, "set_cell_polling_enabled", cg.bool_), +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 5e48e8b726c..90cb4a8b855 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -1,7 +1,9 @@ #include "pylontech.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include #include #include @@ -20,13 +22,13 @@ #define PARSE_INT(field, field_name) \ { \ - get_token(token_buf); \ + get_token(cursor, token_buf); \ PARSE_TOKEN_INT(field, field_name) \ } #define PARSE_STR(field, field_name) \ { \ - get_token(field); \ + get_token(cursor, field); \ if (strlen(field) < 2) { \ ESP_LOGV(TAG, "too short " field_name " in line %s", buffer); \ this->reject_line_(); \ @@ -38,6 +40,11 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech"); +#ifdef PYLONTECH_CELL_BATTERY_COUNT +// The pack drops a command that follows its reply without a pause +static constexpr uint32_t BAT_COMMAND_DELAY_MS = 20; +#endif + // Rejects values that would wrap in a narrowed field template static bool in_range(int value) { if constexpr (sizeof(T) >= sizeof(int)) { @@ -47,6 +54,37 @@ template static bool in_range(int value) { } } +#ifdef PYLONTECH_LISTENER_COUNT +// Copies the next whitespace separated token, at most TEXT_SENSOR_MAX_LEN - 1 characters, and moves cursor past it +static void get_token(const char *&cursor, char *token_buf) { + // Skip leading whitespace + while (*cursor == ' ' || *cursor == '\t') { + cursor++; + } + + if (*cursor == '\0') { + token_buf[0] = 0; + return; + } + + const char *start = cursor; + + // Find end of field + while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { + cursor++; + } + + size_t token_len = std::min(static_cast(cursor - start), static_cast(TEXT_SENSOR_MAX_LEN - 1)); + memcpy(token_buf, start, token_len); + token_buf[token_len] = 0; +} + +// Slots are kept in battery order, so a battery's slot index is the number of lower batteries in the mask +static uint8_t slot_index(battery_mask_t batteries, uint8_t bat_index) { + return __builtin_popcount(batteries & ((1u << bat_index) - 1u)); +} +#endif + PylontechComponent::PylontechComponent() {} void PylontechComponent::dump_config() { @@ -67,13 +105,34 @@ void PylontechComponent::setup() { } } -void PylontechComponent::update() { this->write_str("pwr\n"); } +void PylontechComponent::update() { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // A "bat N" that was not sent yet, or whose reply never finished, must not get in the way of "pwr" + this->bat_command_due_ = false; + this->late_reply_ = this->bat_reply_ != 0; + this->bat_reply_ = 0; +#endif + this->write_str("pwr\n"); +} void PylontechComponent::loop() { // Drain the UART quickly; pylontech sends large bursts. Logging and publishing wait until it is idle. size_t avail = this->available(); #ifdef PYLONTECH_LISTENER_COUNT if (avail == 0) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->bat_command_due_ && + static_cast(App.get_loop_component_start_time() - this->bat_command_at_) >= 0) { + this->bat_command_due_ = false; + if (this->cell_polling_enabled_) { + char cmd[9]; // "bat 16\n" + snprintf(cmd, sizeof(cmd), "bat %u\n", this->bat_reply_); + this->write_str(cmd); + } else { + this->bat_reply_ = 0; // turned off during the pause: nothing is coming + } + } +#endif this->dispatch_one_(); return; } @@ -120,20 +179,59 @@ void PylontechComponent::dispatch_one_() { } return; } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->cells_pending_ != 0) { + const uint8_t bat_index = __builtin_ctz(this->cells_pending_); + this->cells_pending_ &= ~(1u << bat_index); + const uint8_t slot = slot_index(this->cell_batteries_, bat_index); + ESP_LOGV(TAG, "Publishing cells of battery %u", bat_index + 1); + for (PylontechListener *listener : this->listeners_) { + if (listener->get_battery() == bat_index + 1) { + listener->on_cells_read(this->cell_slots_[slot], this->cells_read_[slot]); + } + } + return; + } +#endif if (this->rejected_ != 0) { - ESP_LOGW(TAG, "Rejected %u malformed battery lines", this->rejected_); + ESP_LOGW(TAG, "Rejected %u malformed lines", this->rejected_); this->rejected_ = 0; } } void PylontechComponent::parse_header_(const char *buffer) { this->has_tlow_id_ = strstr(buffer, "Tlow.Id") != nullptr; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // The header opens a "pwr" reply, so what follows are battery lines even after a late "bat N" reply + this->bat_reply_ = 0; + this->late_reply_ = false; + this->pwr_reply_ = true; +#endif ESP_LOGV(TAG, "header line %s Tlow.Id: %s", this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), buffer); } // Runs while the UART drains: no logging above VERBOSE and no publishing here void PylontechComponent::process_line_(const char *buffer) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (strcmp(buffer, "$$") == 0) { + // Every reply ends with "$$"; after the "pwr" reply one "bat N" follows per battery with cell sensors. + // A "$$" that ends neither of the two, such as a late reply to an abandoned "bat N", sends nothing. + if (this->bat_reply_ != 0) { + this->cells_pending_ |= 1u << (this->bat_reply_ - 1); + this->send_next_bat_(); + } else if (this->pwr_reply_) { + this->pwr_reply_ = false; + this->send_next_bat_(); + } + return; + } + // The "pwr" header always starts a "pwr" reply, whatever was expected + if (this->bat_reply_ != 0 && strncmp(buffer, "Power", 5) != 0) { + this->parse_cell_line_(buffer); + return; + } +#endif // clang-format off // example lines to parse: // Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St @@ -147,31 +245,7 @@ void PylontechComponent::process_line_(const char *buffer) { const char *cursor = buffer; char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; - // Helper Lambda to extract tokens - auto get_token = [&](char *token_buf) -> void { - // Skip leading whitespace - while (*cursor == ' ' || *cursor == '\t') { - cursor++; - } - - if (*cursor == '\0') { - token_buf[0] = 0; - return; - } - - const char *start = cursor; - - // Find end of field - while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { - cursor++; - } - - size_t token_len = std::min(static_cast(cursor - start), static_cast(TEXT_SENSOR_MAX_LEN - 1)); - memcpy(token_buf, start, token_len); - token_buf[token_len] = 0; - }; - - get_token(token_buf); + get_token(cursor, token_buf); auto bat = parse_number(token_buf); if (!bat.has_value()) { if (strcmp(token_buf, "Power") == 0) { @@ -182,20 +256,20 @@ void PylontechComponent::process_line_(const char *buffer) { } return; } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->late_reply_) { + ESP_LOGV(TAG, "dropped late line %s", buffer); // cell rows of the abandoned "bat N" look like battery lines + return; + } +#endif const unsigned bat_index = static_cast(*bat) - 1u; if (bat_index >= MAX_BATTERIES || (this->batteries_ & (1u << bat_index)) == 0) { return; // no listener for this battery } - const battery_mask_t bat_bit = 1u << bat_index; - // The slot index is the number of listened batteries below this one - uint8_t slot = 0; - for (battery_mask_t below = this->batteries_ & (bat_bit - 1); below != 0; below &= below - 1) { - slot++; - } - const battery_mask_t slot_bit = 1u << slot; + const battery_mask_t slot_bit = 1u << slot_index(this->batteries_, bat_index); // Cleared until the whole line parses, so a bad line never publishes a half written slot this->pending_ &= ~slot_bit; - PylontechListener::LineContents &l = this->slots_[slot]; + PylontechListener::LineContents &l = this->slots_[__builtin_ctz(slot_bit)]; l.bat_num = *bat; PARSE_INT(l.volt, "Volt"); @@ -203,40 +277,99 @@ void PylontechComponent::process_line_(const char *buffer) { PARSE_INT(l.tempr, "Tempr"); PARSE_INT(l.tlow, "Tlow"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Tlow.Id + get_token(cursor, token_buf); // Skip Tlow.Id } PARSE_INT(l.thigh, "Thigh"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Thigh.Id + get_token(cursor, token_buf); // Skip Thigh.Id } PARSE_INT(l.vlow, "Vlow"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Vlow.Id + get_token(cursor, token_buf); // Skip Vlow.Id } PARSE_INT(l.vhigh, "Vhigh"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Vhigh.Id + get_token(cursor, token_buf); // Skip Vhigh.Id } PARSE_STR(l.base_st, "Base.St"); PARSE_STR(l.volt_st, "Volt.St"); PARSE_STR(l.curr_st, "Curr.St"); PARSE_STR(l.temp_st, "Temp.St"); - get_token(token_buf); + get_token(cursor, token_buf); if (char *pct = strchr(token_buf, '%')) { *pct = '\0'; } PARSE_TOKEN_INT(l.coulomb, "Coulomb"); - get_token(token_buf); // Skip Date - get_token(token_buf); // Skip Time - get_token(token_buf); // Skip B.V.St - get_token(token_buf); // Skip B.T.St + get_token(cursor, token_buf); // Skip Date + get_token(cursor, token_buf); // Skip Time + get_token(cursor, token_buf); // Skip B.V.St + get_token(cursor, token_buf); // Skip B.T.St PARSE_INT(l.mostempr, "Mostempr"); this->pending_ |= slot_bit; } + +#ifdef PYLONTECH_CELL_BATTERY_COUNT +// Picks the next cell battery above the one just read and schedules "bat N" for it; the cycle ends when none +// is left or cell polling was turned off +void PylontechComponent::send_next_bat_() { + // Battery n is bit n - 1, so clearing the low bat_reply_ bits leaves the batteries above it + const battery_mask_t remaining = + this->cell_batteries_ & static_cast(~((1u << this->bat_reply_) - 1u)); + if (remaining == 0 || !this->cell_polling_enabled_) { + this->bat_reply_ = 0; + return; + } + const uint8_t bat_index = __builtin_ctz(remaining); + this->bat_reply_ = bat_index + 1; + // The slot is about to be rewritten, so an unpublished record must not go out half filled + this->cells_pending_ &= ~(1u << bat_index); + this->cells_read_[slot_index(this->cell_batteries_, bat_index)] = 0; + // loop() writes the command once the pause has passed and the line is idle + this->bat_command_due_ = true; + this->bat_command_at_ = App.get_loop_component_start_time() + BAT_COMMAND_DELAY_MS; +} + +// Runs while the UART drains, like process_line_ +void PylontechComponent::parse_cell_line_(const char *buffer) { + // clang-format off + // example lines to parse, the reply to "bat N": + // Battery Volt Curr Tempr Base State Volt. State Curr. State Temp. State SOC Coulomb BAL + // 0 3308 -914 18300 Dischg Normal Normal Normal 70% 34015 mAH N + // clang-format on + + const char *cursor = buffer; + char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; + + get_token(cursor, token_buf); + auto cell = parse_number(token_buf); + if (!cell.has_value() || *cell < 0 || *cell >= MAX_CELLS) { + // the header, the echoed command or "Command completed successfully" + ESP_LOGV(TAG, "unknown line %s", buffer); + return; + } + PylontechListener::CellContents c{}; + PARSE_INT(c.volt, "Volt"); + get_token(cursor, token_buf); // Skip Curr + int32_t tempr; // m°C + PARSE_INT(tempr, "Tempr"); + tempr /= 10; + if (!in_range(tempr)) { + ESP_LOGV(TAG, "invalid Tempr in line %s", buffer); + this->reject_line_(); + return; + } + c.tempr = tempr; + + const uint8_t slot = slot_index(this->cell_batteries_, this->bat_reply_ - 1); + this->cell_slots_[slot][*cell] = c; + this->cells_read_[slot] |= 1u << *cell; +} +#endif #endif } // namespace esphome::pylontech +#undef PARSE_TOKEN_INT #undef PARSE_INT #undef PARSE_STR diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index aa652056dca..77403478291 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -16,6 +16,10 @@ static constexpr uint8_t TEXT_SENSOR_MAX_LEN = 14; static constexpr uint8_t MAX_BATTERIES = 16; using battery_mask_t = uint16_t; static_assert(MAX_BATTERIES <= sizeof(battery_mask_t) * 8, "battery mask too narrow"); +// A battery reports cells 0 to MAX_CELLS - 1; bit c of a cell_mask_t stands for cell c +static constexpr uint8_t MAX_CELLS = 15; +using cell_mask_t = uint16_t; +static_assert(MAX_CELLS <= sizeof(cell_mask_t) * 8, "cell mask too narrow"); class PylontechListener { public: @@ -27,12 +31,22 @@ class PylontechListener { char base_st[TEXT_SENSOR_MAX_LEN], volt_st[TEXT_SENSOR_MAX_LEN], curr_st[TEXT_SENSOR_MAX_LEN], temp_st[TEXT_SENSOR_MAX_LEN]; }; + struct CellContents { + int16_t tempr; // 0.01 °C + uint16_t volt; // mV + }; + using CellArray = std::array; explicit PylontechListener(uint8_t bat_num) : bat_num_(bat_num) {} uint8_t get_battery() const { return this->bat_num_; } /// Called only with records for this listener's battery. virtual void on_line_read(LineContents *line) = 0; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + /// Called with the cells of this listener's battery after a "bat N" reply; bit c of read is set when cell c + /// was in the reply. + virtual void on_cells_read(const CellArray &cells, cell_mask_t read) {} +#endif virtual void dump_config() = 0; protected: @@ -51,11 +65,22 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic void setup() override; void dump_config() override; + /// Turn the "bat N" cell readings after each "pwr" reply off or on; nothing to turn off without cell sensors. + void set_cell_polling_enabled(bool enabled) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + this->cell_polling_enabled_ = enabled; +#endif + } + #ifdef PYLONTECH_LISTENER_COUNT void register_listener(PylontechListener *listener) { this->listeners_.push_back(listener); this->batteries_ |= 1u << (listener->get_battery() - 1); } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + /// Read the cells of this battery with "bat N" after every "pwr" reply. + void add_cell_battery(uint8_t bat_num) { this->cell_batteries_ |= 1u << (bat_num - 1); } +#endif protected: void process_line_(const char *buffer); @@ -67,17 +92,41 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic } } - // The longest line, the newer firmware header, is 182 characters - LineReader<200> line_reader_; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + void parse_cell_line_(const char *buffer); + void send_next_bat_(); +#endif + + // Members are ordered by alignment, widest first, so the small ones pack without padding StaticVector listeners_; // One slot per listened battery, in battery order; parsed while the UART drains, published one per // loop once it is idle. A newer line for a battery replaces the unpublished one. static_assert(PYLONTECH_BATTERY_COUNT <= MAX_BATTERIES, "more slots than batteries"); std::array slots_; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // One cell slot per battery with cell sensors, in battery order, filled by its "bat N" reply and published + // to that battery's listeners once the reply has ended and the UART is idle + std::array cell_slots_; + uint32_t bat_command_at_{0}; // loop time the pause before the next "bat N" ends at +#endif battery_mask_t batteries_{0}; // batteries with a listener battery_mask_t pending_{0}; // bit i set: slots_[i] is waiting to be published +#ifdef PYLONTECH_CELL_BATTERY_COUNT + std::array cells_read_; // cells the current reply has filled + battery_mask_t cell_batteries_{0}; // batteries whose cells are read + battery_mask_t cells_pending_{0}; // batteries whose cell slot is waiting to be published +#endif bool has_tlow_id_{false}; - uint8_t rejected_{0}; // malformed battery lines since the last idle pass + uint8_t rejected_{0}; // malformed lines since the last idle pass +#ifdef PYLONTECH_CELL_BATTERY_COUNT + uint8_t bat_reply_{0}; // battery whose "bat N" reply is arriving, 0 while the "pwr" reply arrives + bool pwr_reply_{false}; // a "pwr" header arrived and its "$$" has not + bool bat_command_due_{false}; // a "bat N" waits for the pause after the last reply + bool late_reply_{false}; // an abandoned "bat N" may still answer; its lines are dropped until "pwr" answers + bool cell_polling_enabled_{true}; +#endif + // The longest line, the newer firmware header, is 182 characters + LineReader<200> line_reader_; #endif }; diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 51aceace3e7..490331df70f 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -28,6 +28,13 @@ CONF_TEMPERATURE_HIGH = "temperature_high" CONF_VOLTAGE_LOW = "voltage_low" CONF_VOLTAGE_HIGH = "voltage_high" CONF_MOS_TEMPERATURE = "mos_temperature" +CONF_CELLS = "cells" +CONF_CELL = "cell" + +# A battery reports cells 0 to NUM_CELLS - 1 +NUM_CELLS = 15 + +_request_cell_slot = cg.slot_counter("PYLONTECH_SENSOR_CELL_COUNT") TYPES: dict[str, cv.Schema] = { CONF_VOLTAGE: sensor.sensor_schema( @@ -86,8 +93,34 @@ TYPES: dict[str, cv.Schema] = { ), } +CELL_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_CELL): cv.int_range(min=0, max=NUM_CELLS - 1), + cv.Optional(CONF_VOLTAGE): TYPES[CONF_VOLTAGE], + cv.Optional(CONF_TEMPERATURE): TYPES[CONF_TEMPERATURE], + } + ), + cv.has_at_least_one_key(CONF_VOLTAGE, CONF_TEMPERATURE), +) + + +def _unique_cells(cells: list[ConfigType]) -> list[ConfigType]: + seen: set[int] = set() + for cell in cells: + if (number := cell[CONF_CELL]) in seen: + raise cv.Invalid(f"Cell {number} is listed more than once") + seen.add(number) + return cells + + CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( - {cv.GenerateID(): cv.declare_id(PylontechSensor)} + { + cv.GenerateID(): cv.declare_id(PylontechSensor), + cv.Optional(CONF_CELLS): cv.All( + cv.ensure_list(CELL_SCHEMA), cv.Length(min=1), _unique_cells + ), + } ).extend({cv.Optional(marker): schema for marker, schema in TYPES.items()}) @@ -99,4 +132,15 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_sensor")(sens)) - await register_listener(config, bat) + cells = config.get(CONF_CELLS, []) + for cell_config in cells: + _request_cell_slot(str(bat)) + voltage = cg.nullptr + if voltage_config := cell_config.get(CONF_VOLTAGE): + voltage = await sensor.new_sensor(voltage_config) + temperature = cg.nullptr + if temperature_config := cell_config.get(CONF_TEMPERATURE): + temperature = await sensor.new_sensor(temperature_config) + cg.add(bat.add_cell(cell_config[CONF_CELL], voltage, temperature)) + + await register_listener(config, bat, cells=bool(cells)) diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index bead0387bef..c7612f46609 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -20,6 +20,13 @@ void PylontechSensor::dump_config() { LOG_SENSOR(" ", "Voltage high", this->voltage_high_sensor_); LOG_SENSOR(" ", "Coulomb", this->coulomb_sensor_); LOG_SENSOR(" ", "MOS Temperature", this->mos_temperature_sensor_); +#ifdef PYLONTECH_SENSOR_CELL_COUNT + for (const CellSensors &c : this->cells_) { + ESP_LOGCONFIG(TAG, " Cell %u", c.cell); + LOG_SENSOR(" ", "Voltage", c.voltage); + LOG_SENSOR(" ", "Temperature", c.temperature); + } +#endif } void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { @@ -52,4 +59,20 @@ void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { } } +#ifdef PYLONTECH_SENSOR_CELL_COUNT +void PylontechSensor::on_cells_read(const CellArray &cells, cell_mask_t read) { + for (const CellSensors &c : this->cells_) { + if ((read & (1u << c.cell)) == 0) { + continue; + } + if (c.voltage != nullptr) { + c.voltage->publish_state(((float) cells[c.cell].volt) / 1000.0f); + } + if (c.temperature != nullptr) { + c.temperature->publish_state(((float) cells[c.cell].tempr) / 100.0f); + } + } +} +#endif + } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 6b466b74664..75bc9ba01df 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -17,11 +17,25 @@ class PylontechSensor final : public PylontechListener { SUB_SENSOR(temperature_high) SUB_SENSOR(voltage_low) SUB_SENSOR(voltage_high) - SUB_SENSOR(coulomb) SUB_SENSOR(mos_temperature) void on_line_read(LineContents *line) override; + +#ifdef PYLONTECH_SENSOR_CELL_COUNT + void add_cell(uint8_t cell, sensor::Sensor *voltage, sensor::Sensor *temperature) { + this->cells_.push_back({voltage, temperature, cell}); + } + void on_cells_read(const CellArray &cells, cell_mask_t read) override; + + protected: + struct CellSensors { + sensor::Sensor *voltage; + sensor::Sensor *temperature; + uint8_t cell; + }; + StaticVector cells_; +#endif }; } // namespace esphome::pylontech diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 331a637b6b3..a2480108a72 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -179,7 +179,9 @@ #define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_TRIGGER_COUNT 1 #define PYLONTECH_BATTERY_COUNT 1 +#define PYLONTECH_CELL_BATTERY_COUNT 1 #define PYLONTECH_LISTENER_COUNT 2 +#define PYLONTECH_SENSOR_CELL_COUNT 2 #define REMOTE_BASE_DUMPER_COUNT 1 #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME diff --git a/tests/components/pylontech/__init__.py b/tests/components/pylontech/__init__.py index c16210128f6..15b9ef0e2a6 100644 --- a/tests/components/pylontech/__init__.py +++ b/tests/components/pylontech/__init__.py @@ -5,9 +5,11 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - # Storage is sized by code generation; the gtests register three batteries. + # Storage is sized by code generation; the gtests register three batteries and + # read the cells of two of them. cg.add_define("PYLONTECH_LISTENER_COUNT", 3) cg.add_define("PYLONTECH_BATTERY_COUNT", 3) + cg.add_define("PYLONTECH_CELL_BATTERY_COUNT", 2) cg.add_define("USE_LINE_READER") # A MULTI_CONF component gets no entry in the host build, so its to_code would never run. diff --git a/tests/components/pylontech/common.yaml b/tests/components/pylontech/common.yaml index 537450a3b68..37a1c4514b8 100644 --- a/tests/components/pylontech/common.yaml +++ b/tests/components/pylontech/common.yaml @@ -14,6 +14,15 @@ sensor: id: pyl01_soc mos_temperature: id: pyl01_mos_temperature + cells: + - cell: 0 + voltage: + id: pyl01_cell_0_voltage + temperature: + id: pyl01_cell_0_temperature + - cell: 14 + voltage: + id: pyl01_cell_14_voltage - platform: pylontech pylontech_id: pylontech1 battery: 1 @@ -40,3 +49,15 @@ text_sensor: id: pyl0_current_state temperature_state: id: pyl0_temperature_state + +button: + - platform: template + name: Pylontech cell polling + on_press: + - pylontech.set_cell_polling: + id: pylontech0 + enabled: !lambda return false; + # pylontech1 has no cells, so this does nothing + - pylontech.set_cell_polling: + id: pylontech1 + enabled: true diff --git a/tests/components/pylontech/pylontech_test.cpp b/tests/components/pylontech/pylontech_test.cpp index 479530571c0..6041bd572b1 100644 --- a/tests/components/pylontech/pylontech_test.cpp +++ b/tests/components/pylontech/pylontech_test.cpp @@ -10,6 +10,7 @@ #include #include "esphome/components/pylontech/pylontech.h" +#include "esphome/core/application.h" #include "esphome/components/uart/uart_component.h" namespace esphome::pylontech::testing { @@ -19,6 +20,7 @@ namespace { class MockUARTComponent : public uart::UARTComponent { public: std::vector rx; + std::string tx; // everything the hub wrote, in order void push_rx(std::initializer_list lines) { for (const std::string &line : lines) { @@ -26,7 +28,9 @@ class MockUARTComponent : public uart::UARTComponent { } } - void write_array(const uint8_t *data, size_t len) override {} + void write_array(const uint8_t *data, size_t len) override { + this->tx.append(reinterpret_cast(data), len); + } bool read_array(uint8_t *data, size_t len) override { if (this->rx.size() < len) { return false; @@ -48,6 +52,14 @@ class MockUARTComponent : public uart::UARTComponent { // Every record any listener gets, in publish order std::vector published; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +struct PublishedCells { + uint8_t bat_num; + cell_mask_t read; + PylontechListener::CellArray cells; +}; +// Every cell record any listener gets, in publish order +std::vector published_cells; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + class TestListener final : public PylontechListener { public: explicit TestListener(uint8_t bat_num) : PylontechListener(bat_num) {} @@ -55,6 +67,9 @@ class TestListener final : public PylontechListener { EXPECT_EQ(line->bat_num, this->bat_num_); published.push_back(*line); } + void on_cells_read(const CellArray &cells, cell_mask_t read) override { + published_cells.push_back({this->bat_num_, read, cells}); + } void dump_config() override {} }; @@ -74,8 +89,21 @@ constexpr const char *NEW_HEADER = constexpr const char *NEW_BAT1 = "1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal " "60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n"; +constexpr const char *REPLY_END = "Command completed successfully\r\n$$\r\n"; +constexpr const char *CELL_HEADER = + "Battery Volt Curr Tempr Base State Volt. State Curr. State Temp. State SOC Coulomb BAL\r\n"; // clang-format on +// A line of the "bat N" reply for cell cell with volt (mV) and tempr (m°C); the record keeps 0.01 °C +std::string cell_line(int cell, int volt, int tempr) { + char buf[160]; + snprintf(buf, sizeof(buf), + "%d %d -914 %d Dischg Normal Normal Normal 70%% " + "34015 mAH N\r\n", + cell, volt, tempr); + return buf; +} + // An older firmware line for battery bat with pack voltage volt (mV) std::string bat_line(int bat, int volt) { char buf[192]; @@ -103,6 +131,34 @@ class PylontechTest : public ::testing::Test { TestListener listeners_[3]{TestListener(1), TestListener(2), TestListener(3)}; }; +// The cells of batteries 1 and 3 are read +class PylontechCellTest : public PylontechTest { + protected: + void SetUp() override { + PylontechTest::SetUp(); + published_cells.clear(); + this->hub_.add_cell_battery(1); + this->hub_.add_cell_battery(3); + } + + // Lets the hub drain what was pushed and publish everything pending + void drain_and_publish_() { + for (int i = 0; i < 8; i++) { + this->hub_.loop(); + } + } + + // Lets the pause before a bat command pass: moves the loop clock on and runs one idle loop, which also + // publishes one pending record + void let_pause_pass_() { + this->now_ += 50; + LoopBlockingGuard{nullptr, nullptr, this->now_}; + this->hub_.loop(); + } + + uint32_t now_{1000}; +}; + } // namespace TEST_F(PylontechTest, BurstIsPublishedAfterTheDrainOnePerLoop) { @@ -249,4 +305,206 @@ TEST_F(PylontechTest, BadLineClearsTheBatteryPendingRecord) { EXPECT_EQ(this->published_()[0].bat_num, 2); } +TEST_F(PylontechCellTest, CellsAreReadAfterThePwrReplyOneBatteryAtATime) { + // Each pause's idle loop publishes one pending record: the battery record, then one battery's cells + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + EXPECT_TRUE(this->uart_.tx.empty()); // the pack needs a pause after its reply + EXPECT_TRUE(this->published_().empty()); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + + this->uart_.push_rx({"pylon>bat 1\r\n@\r\n", CELL_HEADER, cell_line(0, 3308, 18300), cell_line(1, 3310, 18400), + cell_line(14, 3299, 17900), REPLY_END}); + this->hub_.loop(); + EXPECT_TRUE(published_cells.empty()); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\n"); + ASSERT_EQ(published_cells.size(), 1u); + + this->uart_.push_rx({"pylon>bat 3\r\n@\r\n", CELL_HEADER, cell_line(5, 3250, 16000), REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\n"); + ASSERT_EQ(published_cells.size(), 2u); + this->hub_.loop(); + ASSERT_EQ(published_cells.size(), 2u); + + const PublishedCells &b1 = published_cells[0]; + EXPECT_EQ(b1.bat_num, 1); + EXPECT_EQ(b1.read, (1u << 0) | (1u << 1) | (1u << 14)); + EXPECT_EQ(b1.cells[0].volt, 3308); + EXPECT_EQ(b1.cells[0].tempr, 1830); + EXPECT_EQ(b1.cells[1].volt, 3310); + EXPECT_EQ(b1.cells[14].volt, 3299); + EXPECT_EQ(b1.cells[14].tempr, 1790); + const PublishedCells &b3 = published_cells[1]; + EXPECT_EQ(b3.bat_num, 3); + EXPECT_EQ(b3.read, 1u << 5); + EXPECT_EQ(b3.cells[5].volt, 3250); + EXPECT_EQ(b3.cells[5].tempr, 1600); + + // The next pwr reply starts the cycle again + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\nbat 1\n"); +} + +TEST_F(PylontechCellTest, CellPollingCanBeTurnedOffAtAnyReplyEnd) { + this->hub_.set_cell_polling_enabled(false); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_EQ(this->published_().size(), 1u); + + this->hub_.set_cell_polling_enabled(true); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + + // Turned off while bat 1 is answering: its cells are still published, bat 3 is not sent + this->hub_.set_cell_polling_enabled(false); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(published_cells.size(), 1u); + EXPECT_EQ(published_cells[0].bat_num, 1); + + // Turned off during the pause before bat 1: the queued command is dropped and the next pwr lines are + // battery lines + this->hub_.set_cell_polling_enabled(true); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->hub_.set_cell_polling_enabled(false); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + this->uart_.push_rx({HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(this->published_().size(), 4u); + EXPECT_EQ(this->published_()[3].bat_num, 2); +} + +TEST_F(PylontechCellTest, UpdateAbandonsAnUnansweredBatReply) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + + // No reply ever comes; the next pwr lines are battery lines again + this->hub_.update(); + this->uart_.push_rx({HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\npwr\nbat 1\n"); + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[1].bat_num, 2); + EXPECT_TRUE(published_cells.empty()); +} + +TEST_F(PylontechCellTest, UpdateCancelsABatCommandThatWasNotSentYet) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->hub_.update(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "pwr\n"); +} + +TEST_F(PylontechCellTest, LateBatReplyAfterUpdateIsNotMistakenForThePwrReply) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(this->published_().size(), 1u); + + // Battery 1's next record is still unpublished when update() abandons bat 1; the pack answers it only + // after the next pwr went out, with cell rows that look like battery lines, then answers pwr + this->uart_.push_rx({HEADER, bat_line(1, 49100), REPLY_END}); + this->hub_.loop(); + this->hub_.update(); + this->uart_.push_rx( + {CELL_HEADER, cell_line(0, 3308, 18300), cell_line(1, 3310, 18400), cell_line(2, 3312, 18500), REPLY_END}); + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + + // The late rows are dropped without touching battery 1's record, its "$$" sends nothing, and the pwr + // reply starts the cycle once + EXPECT_EQ(this->uart_.tx, "bat 1\npwr\nbat 1\n"); + ASSERT_EQ(this->published_().size(), 3u); + EXPECT_EQ(this->published_()[1].bat_num, 1); + EXPECT_EQ(this->published_()[1].volt, 49100); + EXPECT_EQ(this->published_()[2].bat_num, 2); + EXPECT_EQ(this->published_()[2].volt, 46012); + EXPECT_TRUE(published_cells.empty()); +} + +TEST_F(PylontechCellTest, BadCellLinesAreIgnoredAndTheNextReplyStartsEmpty) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), "0 abc -914 18300 Dischg\r\n", + cell_line(15, 3300, 18000), cell_line(14, 70000, 18000), cell_line(13, 3300, 400000), + REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(5, 3250, 16000), cell_line(5, 3251, 16100), REPLY_END}); + this->drain_and_publish_(); + + // The bad line for cell 0 leaves the good one; cell 15 does not exist; 70000 mV and 400 °C are out of + // range; the second cell 5 line replaces the first + ASSERT_EQ(published_cells.size(), 2u); + EXPECT_EQ(published_cells[0].read, 1u << 0); + EXPECT_EQ(published_cells[0].cells[0].volt, 3308); + EXPECT_EQ(published_cells[1].bat_num, 3); + EXPECT_EQ(published_cells[1].read, 1u << 5); + EXPECT_EQ(published_cells[1].cells[5].volt, 3251); + + // A reply that read nothing publishes an empty record, not the old cells + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(published_cells.size(), 3u); + EXPECT_EQ(published_cells[2].bat_num, 1); + EXPECT_EQ(published_cells[2].read, 0u); +} + +TEST_F(PylontechCellTest, ANewReplyUnmarksTheUnpublishedRecordOfItsBattery) { + // A whole cycle arrives with only the idle loops of the pauses: the battery record and battery 1's + // cells are published, battery 3's cells stay pending + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(5, 3250, 16000), REPLY_END}); + this->hub_.loop(); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + ASSERT_EQ(published_cells.size(), 1u); + EXPECT_EQ(published_cells[0].bat_num, 1); + + // The next bat 1 reply is cut short by a gap: battery 3's cells go out, battery 1's wait for "$$" + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3400, 19000)}); + this->hub_.loop(); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(published_cells.size(), 2u); + EXPECT_EQ(published_cells[1].bat_num, 3); + this->uart_.push_rx({REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(published_cells.size(), 3u); + EXPECT_EQ(published_cells[2].bat_num, 1); + EXPECT_EQ(published_cells[2].cells[0].volt, 3400); +} + } // namespace esphome::pylontech::testing diff --git a/tests/integration/test_uart_mock_pylontech.py b/tests/integration/test_uart_mock_pylontech.py index fffaa902072..a434ed5bdee 100644 --- a/tests/integration/test_uart_mock_pylontech.py +++ b/tests/integration/test_uart_mock_pylontech.py @@ -79,7 +79,7 @@ async def test_uart_mock_pylontech( def line_callback(line: str) -> None: if "Line over 199 chars dropped" in line: overflow_lines.append(line) - if "malformed battery lines" in line: + if "malformed lines" in line: rejected_lines.append(line) collector = SensorStateCollector( From 792d650fb2076b64ed4a0d243fbcba22497d6787 Mon Sep 17 00:00:00 2001 From: Simonas Kazlauskas Date: Sat, 17 Oct 2026 22:44:25 +0300 Subject: [PATCH 065/120] [remote_base] Add a protocol for 433MHz A-OK remotes (#20991) --- esphome/components/remote_base/__init__.py | 54 +++++++ .../components/remote_base/aok_protocol.cpp | 80 +++++++++++ esphome/components/remote_base/aok_protocol.h | 26 ++++ esphome/core/defines.h | 1 + tests/components/remote_base/__init__.py | 1 + tests/components/remote_base/aok_test.cpp | 135 ++++++++++++++++++ .../remote_receiver/common-actions.yaml | 5 + .../remote_transmitter/common-buttons.yaml | 17 +++ 8 files changed, 319 insertions(+) create mode 100644 esphome/components/remote_base/aok_protocol.cpp create mode 100644 esphome/components/remote_base/aok_protocol.h create mode 100644 tests/components/remote_base/aok_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 0e0a63e9d94..abb216aad7d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_BUTTON, CONF_CARRIER_FREQUENCY, CONF_CHANNEL, + CONF_CHANNELS, CONF_CHECK, CONF_CODE, CONF_COMMAND, @@ -71,6 +72,7 @@ CONF_CONTROL = "control" CONF_CONTROL_IN_KEY = "control_in_key" CONF_ROLLING_CODE = "rolling_code" CONF_EXTENSION = "extension" +CONF_REMOTE_ID = "remote_id" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -2911,3 +2913,55 @@ register_protocol_action( (CONF_MODE, "mode", IRA211ModeEnum), (CONF_FAN_MODE, "fan", IRA211FanEnum), ) + + +# A-OK +( + AOKData, + AOKBinarySensor, + AOKTrigger, + _, + AOKDumper, +) = declare_protocol("AOK") + +AOK_SCHEMA = cv.Schema( + { + cv.Required(CONF_REMOTE_ID): cv.hex_int_range(0, 0xFFFFFF), + cv.Required(CONF_CHANNELS): cv.hex_uint16_t, + cv.Required(CONF_COMMAND): cv.hex_uint8_t, + } +) + + +@register_binary_sensor("aok", AOKBinarySensor, AOK_SCHEMA) +def aok_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + AOKData, + ("remote_id", config[CONF_REMOTE_ID]), + ("channels", config[CONF_CHANNELS]), + ("command", config[CONF_COMMAND]), + ) + ) + ) + + +@register_trigger("aok", AOKTrigger, AOKData) +def aok_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("aok", AOKDumper) +def aok_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "aok", + "AOK", + AOK_SCHEMA, + (CONF_REMOTE_ID, "remote_id", cg.uint32), + (CONF_CHANNELS, "channels", cg.uint16), + (CONF_COMMAND, "command", cg.uint8), +) diff --git a/esphome/components/remote_base/aok_protocol.cpp b/esphome/components/remote_base/aok_protocol.cpp new file mode 100644 index 00000000000..e0abc1eb351 --- /dev/null +++ b/esphome/components/remote_base/aok_protocol.cpp @@ -0,0 +1,80 @@ +#include "aok_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.aok"); + +// A frame is a long start mark, then 64 bits, then a long gap. A one bit is a long mark and a short space, +// a zero bit is a short mark and a long space. The bits are: header (8), remote id (24), channels (16), +// command (8) and checksum (8), most significant bit first. +static constexpr uint8_t NBITS = 64; +static constexpr uint32_t START_HIGH_US = 5000; +static constexpr uint32_t LONG_US = 600; +static constexpr uint32_t SHORT_US = 285; +static constexpr uint8_t HEADER = 0xA3; +static constexpr uint32_t REMOTE_ID_MASK = 0xFFFFFF; + +// The sum of the six bytes between the header and the checksum +static uint8_t checksum(uint64_t frame) { + uint8_t sum = 0; + for (uint8_t shift = 8; shift < 56; shift += 8) { + sum += frame >> shift; + } + return sum; +} + +void AOKProtocol::encode(RemoteTransmitData *dst, const AOKData &data) { + uint64_t frame = (uint64_t(HEADER) << 56) | (uint64_t(data.remote_id & REMOTE_ID_MASK) << 32) | + (uint64_t(data.channels) << 16) | (uint64_t(data.command) << 8); + frame |= checksum(frame); + + dst->reserve((NBITS + 2) * 2); + dst->item(START_HIGH_US, LONG_US); + for (uint64_t mask = 1ULL << (NBITS - 1); mask; mask >>= 1) { + if (frame & mask) { + dst->item(LONG_US, SHORT_US); + } else { + dst->item(SHORT_US, LONG_US); + } + } + dst->item(LONG_US, START_HIGH_US); +} + +optional AOKProtocol::decode(RemoteReceiveData src) { + // A buffer can hold noise and several repeats of a frame, so look for the first good one. After any + // failure the loop restarts at the next timing, or where the failed frame stopped. + while (src.is_valid()) { + if (!src.expect_pulse_with_gap(START_HIGH_US, LONG_US)) { + src.advance(); + continue; + } + + uint64_t frame = 0; + uint8_t bits = 0; + for (; bits < NBITS; bits++) { + frame <<= 1; + if (src.expect_item(LONG_US, SHORT_US)) { + frame |= 1; + } else if (!src.expect_item(SHORT_US, LONG_US)) { + break; + } + } + + if (bits == NBITS && uint8_t(frame >> 56) == HEADER && uint8_t(frame) == checksum(frame)) { + return AOKData{ + .remote_id = uint32_t(frame >> 32) & REMOTE_ID_MASK, + .channels = uint16_t(frame >> 16), + .command = uint8_t(frame >> 8), + }; + } + } + return {}; +} + +void AOKProtocol::dump(const AOKData &data) { + ESP_LOGI(TAG, "Received A-OK: remote_id=0x%06" PRIX32 ", channels=0x%04X, command=0x%02X", data.remote_id, + data.channels, data.command); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aok_protocol.h b/esphome/components/remote_base/aok_protocol.h new file mode 100644 index 00000000000..19967fa40b6 --- /dev/null +++ b/esphome/components/remote_base/aok_protocol.h @@ -0,0 +1,26 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct AOKData { + uint32_t remote_id; // 24 bits + uint16_t channels; + uint8_t command; + + bool operator==(const AOKData &rhs) const { + return this->remote_id == rhs.remote_id && this->channels == rhs.channels && this->command == rhs.command; + } +}; + +class AOKProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const AOKData &data); + optional decode(RemoteReceiveData src); + void dump(const AOKData &data); +}; + +DECLARE_REMOTE_PROTOCOL(AOK) + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a2480108a72..13dc3492f29 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -186,6 +186,7 @@ #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME #define USE_REMOTE_PROTOCOL_AEHA +#define USE_REMOTE_PROTOCOL_AOK #define USE_REMOTE_PROTOCOL_BEO4 #define USE_REMOTE_PROTOCOL_BRENNENSTUHL #define USE_REMOTE_PROTOCOL_BYRONSX diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index f1045572656..fed957d3378 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -12,6 +12,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "haier", "mirage", "aeha", + "aok", "keeloq", "nexus", "ira211", diff --git a/tests/components/remote_base/aok_test.cpp b/tests/components/remote_base/aok_test.cpp new file mode 100644 index 00000000000..d4fd33d0992 --- /dev/null +++ b/tests/components/remote_base/aok_test.cpp @@ -0,0 +1,135 @@ +#include + +#include "esphome/components/remote_base/aok_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint64_t FRAME = 0xA312345600A10B48ULL; +// The start mark and space, then 64 bits of a mark and a space each, then the closing mark and space +constexpr size_t TIMINGS_SIZE = (1 + 64 + 1) * 2; + +RawTimings encode(const AOKData &data) { + RemoteTransmitData tx; + AOKProtocol().encode(&tx, data); + return tx.get_data(); +} + +optional decode(const RawTimings &timings) { + return AOKProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +// Bit `n` counted from the first one sent starts at timing 2 + 2 * n +RawTimings frame_timings(uint64_t frame) { + RawTimings timings{5000, -600}; + for (int i = 63; i >= 0; i--) { + if ((frame >> i) & 1) { + timings.insert(timings.end(), {600, -285}); + } else { + timings.insert(timings.end(), {285, -600}); + } + } + timings.insert(timings.end(), {600, -5000}); + return timings; +} + +} // namespace + +TEST(AOKProtocolTest, EncodesKnownFrame) { + EXPECT_EQ(encode(AOKData{.remote_id = 0x123456, .channels = 0x00A1, .command = 0x0B}), frame_timings(FRAME)); +} + +TEST(AOKProtocolTest, DecodesKnownFrame) { + auto decoded = decode(frame_timings(FRAME)); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); + EXPECT_EQ(decoded->channels, 0x00A1); + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, RoundTrips) { + for (const AOKData &sent : + {AOKData{0x000000, 0x0000, 0x00}, AOKData{0xFFFFFF, 0xFFFF, 0xFF}, AOKData{0xABCDEF, 0x8001, 0x7E}}) { + const RawTimings timings = encode(sent); + ASSERT_EQ(timings.size(), TIMINGS_SIZE); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_TRUE(*decoded == sent); + } +} + +TEST(AOKProtocolTest, EncodingKeepsRemoteIdToItsFieldWidth) { + auto decoded = decode(encode(AOKData{.remote_id = 0xFF123456, .channels = 0x00A1, .command = 0x0B})); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); +} + +TEST(AOKProtocolTest, RejectsBadChecksum) { EXPECT_FALSE(decode(frame_timings(FRAME ^ 1)).has_value()); } + +TEST(AOKProtocolTest, RejectsBadHeader) { + // The header is not part of the checksum, so only the header check can reject this + EXPECT_FALSE(decode(frame_timings(FRAME ^ (1ULL << 56))).has_value()); +} + +TEST(AOKProtocolTest, SkipsLeadingNoise) { + RawTimings timings{300, -1200, 150, -90}; + const RawTimings frame = frame_timings(FRAME); + timings.insert(timings.end(), frame.begin(), frame.end()); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, UsesRepeatWhenFirstFrameIsCorrupt) { + RawTimings timings = frame_timings(FRAME ^ 1); + const RawTimings good = frame_timings(FRAME); + timings.insert(timings.end(), good.begin(), good.end()); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, UsesRepeatWhenFirstFrameIsCutShort) { + RawTimings timings = frame_timings(FRAME); + timings.resize(timings.size() - 40); + const RawTimings good = frame_timings(FRAME); + timings.insert(timings.end(), good.begin(), good.end()); + EXPECT_TRUE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsBadStart) { + RawTimings timings = frame_timings(FRAME); + timings[0] = 2000; + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsTruncatedFrame) { + RawTimings timings = frame_timings(FRAME); + timings.resize(timings.size() - 6); + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsBadBitTiming) { + RawTimings timings = frame_timings(FRAME); + timings[2 + 2 * 20] = 1200; + EXPECT_FALSE(decode(timings).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index da88548d2ab..833de19ff4f 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -187,6 +187,11 @@ on_toto: - logger.log: format: "on_toto: %u %u %u" args: ["x.rc_code_1", "x.rc_code_2", "x.command"] +on_aok: + then: + - logger.log: + format: "on_aok: %u %u %u" + args: ["x.remote_id", "x.channels", "x.command"] on_hob2hood: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 66ee803d4f9..f9f1beceae9 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -395,6 +395,23 @@ button: repeat: times: 3 wait_time: 10ms + - platform: template + name: A-OK + on_press: + remote_transmitter.transmit_aok: + remote_id: 0x123456 + channels: 0x00A1 + command: 0x0B + - platform: template + name: A-OK lambda with repeat + on_press: + remote_transmitter.transmit_aok: + remote_id: 0x123456 + channels: !lambda return 0x1000; + command: 0x43 + repeat: + times: 8 + wait_time: 5ms - platform: template name: Toto with repeat on_press: From a576092f2577bc2ffb362e8754b70cd6fda03c0b Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:52:01 +0200 Subject: [PATCH 066/120] [remote_base] Send the 72-bit Jarolift TDEF group frame with transmit_keeloq (#20927) Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 19 +++- .../remote_base/keeloq_protocol.cpp | 17 +++- .../components/remote_base/keeloq_protocol.h | 12 ++- .../config/keeloq_suffix.yaml | 8 ++ .../remote_transmitter/test_keeloq_action.py | 10 +- tests/components/remote_base/keeloq_test.cpp | 91 ++++++++++++++++++- .../remote_receiver/common-actions.yaml | 4 +- .../remote_transmitter/common-buttons.yaml | 11 +++ .../components/remote_base/test_keeloq.py | 19 ++++ 9 files changed, 181 insertions(+), 10 deletions(-) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index abb216aad7d..990d2b87e4d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1044,6 +1044,21 @@ KEELOQ_SCHEMA = cv.Schema( ) +def _keeloq_group_alone(config: ConfigType) -> ConfigType: + # The group bits take the places of level and the suffix. + if CONF_GROUP not in config: + return config + if CONF_SUFFIX in config or CONF_SUFFIX_BITS in config: + raise cv.Invalid( + "group cannot be combined with suffix or suffix_bits", [CONF_GROUP] + ) + if config[CONF_LEVEL] is not False: + raise cv.Invalid( + "group sets the bit that level sends; remove level", [CONF_LEVEL] + ) + return config + + def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: # A lambda is only known on the device. A plain value has to fit the width. suffix = config.get(CONF_SUFFIX, 0) @@ -1060,6 +1075,7 @@ KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( # No defaults: an absent key leaves the data at 0 and emits nothing. cv.Optional(CONF_SUFFIX): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), cv.Optional(CONF_SUFFIX_BITS): cv.int_range(min=0, max=16), + cv.Optional(CONF_GROUP): cv.hex_uint8_t, } ) @@ -1097,7 +1113,8 @@ register_protocol_action( (CONF_LEVEL, "vlow", cg.bool_), (CONF_SUFFIX, "suffix", cg.uint16), (CONF_SUFFIX_BITS, "suffix_bits", cg.uint8), - validate=_keeloq_suffix_fits, + automation.ApplyField(CONF_GROUP, "data.set_group", cg.uint8), + validate=cv.All(_keeloq_group_alone, _keeloq_suffix_fits), ) diff --git a/esphome/components/remote_base/keeloq_protocol.cpp b/esphome/components/remote_base/keeloq_protocol.cpp index fff8eb897f0..b91f70227e9 100644 --- a/esphome/components/remote_base/keeloq_protocol.cpp +++ b/esphome/components/remote_base/keeloq_protocol.cpp @@ -93,9 +93,14 @@ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { dst->space(1 * BIT_TIME_US); } - // repeat flag - always sent as a '1' - dst->mark(1 * BIT_TIME_US); - dst->space(2 * BIT_TIME_US); + // repeat flag + if (data.repeat) { + dst->mark(1 * BIT_TIME_US); + dst->space(2 * BIT_TIME_US); + } else { + dst->mark(2 * BIT_TIME_US); + dst->space(1 * BIT_TIME_US); + } const uint8_t extra = data.suffix_bits > NBITS_SUFFIX_MAX ? NBITS_SUFFIX_MAX : data.suffix_bits; for (uint8_t i = 0; i < extra; i++) { @@ -234,6 +239,12 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { } void KeeloqProtocol::dump(const KeeloqData &data) { + if (data.suffix_bits == 6) { + // Jarolift TDEF frame: show the group as transmit_keeloq takes it. + ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", group=0x%02x", + data.address, data.command, data.encrypted, data.group()); + return; + } ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", suffix=0x%04x/%u", data.address, data.command, data.encrypted, data.suffix, data.suffix_bits); } diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index e391212e42d..43675eccaa6 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -9,11 +9,21 @@ struct KeeloqData { uint32_t encrypted; // 32 bit encrypted field uint32_t address; // 28 bit serial number uint8_t command; // Button Status S2-S1-S0-S3 - bool repeat; // Repeated command bit + bool repeat{true}; // Repeated command bit bool vlow; // Battery status bit uint16_t suffix{0}; // Optional bits after the 66-bit HCS301 word uint8_t suffix_bits{0}; + // Jarolift TDEF sends 8 group bits after the 64-bit word where the HCS301 has vlow and repeat. + void set_group(uint8_t group) { + this->vlow = group & 0x01; + this->repeat = group & 0x02; + this->suffix = group >> 2; + this->suffix_bits = 6; + } + // Only a Jarolift TDEF frame (suffix_bits == 6) carries a group; check suffix_bits before using it. + uint8_t group() const { return static_cast((this->suffix << 2) | (this->repeat << 1) | this->vlow); } + bool operator==(const KeeloqData &rhs) const { // 0x10 matches any command. The suffix is not compared: the binary sensor does not take it. if (address != rhs.address) { diff --git a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml index f14bc75f305..a24a50cf8b8 100644 --- a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml +++ b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml @@ -31,3 +31,11 @@ button: code: 0xd19ef0a9 suffix: !lambda return 0x2A; suffix_bits: !lambda return 6; + - platform: template + name: Group frame + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x1234500 + command: 0x8 + code: 0xF94A09F2 + group: 0x0F diff --git a/tests/component_tests/remote_transmitter/test_keeloq_action.py b/tests/component_tests/remote_transmitter/test_keeloq_action.py index cbc1efb0a69..c5137da1b4f 100644 --- a/tests/component_tests/remote_transmitter/test_keeloq_action.py +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -12,12 +12,18 @@ def test_keeloq_suffix_is_set_only_when_configured( ) -> None: main_cpp = generate_main(component_config_path("keeloq_suffix.yaml")) bodies = protocol_call_bodies(main_cpp) - assert len(bodies) == 3 - plain, constant, lambda_ = bodies + assert len(bodies) == 4 + plain, constant, lambda_, group = bodies # The plain word fills only the HCS301 fields. assert "data.suffix" not in plain + assert "set_group" not in plain assert "apply_call.data.suffix = 0x2A;" in constant assert "apply_call.data.suffix_bits = 6;" in constant # A lambda is called inline. assert "apply_call.data.suffix = static_cast(0x2A);" in lambda_ assert "apply_call.data.suffix_bits = static_cast(6);" in lambda_ + # group fills vlow, repeat and the suffix on the device. + assert "apply_call.data.set_group(0x0F);" in group + # It comes after the default level, so the group's bit 0 is what is sent. + assert group.index("data.vlow") < group.index("set_group") + assert "data.suffix" not in group diff --git a/tests/components/remote_base/keeloq_test.cpp b/tests/components/remote_base/keeloq_test.cpp index e96d8b9d01d..c2303b23d35 100644 --- a/tests/components/remote_base/keeloq_test.cpp +++ b/tests/components/remote_base/keeloq_test.cpp @@ -1,4 +1,5 @@ #include +#include #include "esphome/components/remote_base/keeloq_protocol.h" namespace esphome::remote_base::testing { @@ -42,6 +43,40 @@ RawTimings as_received(RawTimings timings, bool drop_tail = false) { return merged; } +// Jarolift TDEF group UP for the channel mask 0x0F0F, from a published test vector. The mask's low byte is +// inside the hopping code, the high byte is the group sent after the 64-bit word. +KeeloqData tdef_frame(uint8_t group) { + KeeloqData data{}; + data.encrypted = 0xF94A09F2; + data.address = 0x1234500; + data.command = 0x8; + data.set_group(group); + return data; +} + +// The data bits after the 10-bit-time header: a 1-bit-time mark is a 1, a 2-bit-time mark a 0. +std::vector sent_bits(const RawTimings &timings) { + std::vector bits; + size_t i = 0; + while (i < timings.size() && timings[i] != -static_cast(10 * BIT_US)) { + i++; + } + for (i++; i < timings.size(); i++) { + if (timings[i] > 0) { + bits.push_back(timings[i] == static_cast(BIT_US)); + } + } + return bits; +} + +uint32_t field(const std::vector &bits, size_t first, size_t count) { + uint32_t value = 0; + for (size_t i = 0; i < count; i++) { + value |= static_cast(bits[first + i]) << i; + } + return value; +} + optional decode(const RawTimings &timings) { return KeeloqProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); } @@ -146,7 +181,7 @@ TEST(KeeloqProtocolTest, ASpaceThatIsNeitherDataNorAGapDropsTheSuffix) { } TEST(KeeloqProtocolTest, ASuffixFollowsARepeatBitOfZero) { - // The encoder always sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. + // frame() sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. for (uint8_t suffix_bits : {0, 8}) { RawTimings timings = as_received(encode(frame(suffix_bits > 0 ? 0xA5 : 0, suffix_bits))); const size_t repeat = timings.size() - 2 * suffix_bits - 2; @@ -174,4 +209,58 @@ TEST(KeeloqProtocolTest, ASuffixEndsAtAGap) { } } +TEST(KeeloqProtocolTest, HcsWordStillEndsWithRepeatOne) { + // 32 code bits, 28 address bits, 4 command bits, vlow, repeat. + std::vector bits = sent_bits(encode(frame(0, 0))); + ASSERT_EQ(bits.size(), 66u); + EXPECT_FALSE(bits[64]); + EXPECT_TRUE(bits[65]); +} + +TEST(KeeloqProtocolTest, TdefGroupFrameSendsTheGroupAfterThe64BitWord) { + std::vector bits = sent_bits(encode(tdef_frame(0x0F))); + ASSERT_EQ(bits.size(), 72u); + EXPECT_EQ(field(bits, 0, 32), 0xF94A09F2u); + EXPECT_EQ(field(bits, 32, 28), 0x1234500u); + EXPECT_EQ(field(bits, 60, 4), 0x8u); + EXPECT_EQ(field(bits, 64, 8), 0x0Fu); +} + +TEST(KeeloqProtocolTest, TdefChannelOneSendsNoChannelTenBit) { + // Channels 1 to 8 have no bit in the group. Bit 65 is the HCS301 repeat bit, which is always 1 + // without a group; a Jarolift receiver reads it as channel 10. + std::vector bits = sent_bits(encode(tdef_frame(0x00))); + ASSERT_EQ(bits.size(), 72u); + EXPECT_EQ(field(bits, 64, 8), 0x00u); +} + +TEST(KeeloqProtocolTest, TdefGroupRoundTrips) { + // 0x0F is channels 9 to 12, 0x80 is channel 16, the last bit of the frame. + for (uint8_t group : {0x0F, 0x80}) { + auto decoded = decode(as_received(encode(tdef_frame(group)))); + ASSERT_TRUE(decoded.has_value()) << int(group); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x1234500u); + EXPECT_EQ(decoded->command, 0x8); + EXPECT_EQ(decoded->encrypted, 0xF94A09F2u); + EXPECT_EQ(decoded->suffix_bits, 6); + EXPECT_EQ(decoded->group(), group); + } + } +} + +TEST(KeeloqProtocolTest, AReceivedRepeatBitOfZeroIsSentAgainAsZero) { + KeeloqData received = frame(0, 0); + received.repeat = false; + std::vector bits = sent_bits(encode(received)); + ASSERT_EQ(bits.size(), 66u); + EXPECT_FALSE(bits[65]); + + auto decoded = decode(as_received(encode(received))); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_FALSE(decoded->repeat); + } +} + } // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 833de19ff4f..b37f838b811 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -71,8 +71,8 @@ on_jvc: on_keeloq: then: - logger.log: - format: "on_keeloq: %lu %lu %u %u %u" - args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits"] + format: "on_keeloq: %lu %lu %u %u %u %u" + args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits", "x.suffix_bits == 6 ? x.group() : 0"] on_haier: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index f9f1beceae9..cc3ff4d4e1a 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -385,6 +385,17 @@ button: code: 0xd19ef0a9 suffix: 0x00 suffix_bits: 8 + - platform: template + name: KeeLoq TDEF group + on_press: + remote_transmitter.transmit_keeloq: + address: 0x1234500 + command: 0x8 + code: 0xF94A09F2 + group: 0x0F + repeat: + times: 2 + wait_time: 0ms - platform: template name: KeeLoq lambda with repeat on_press: diff --git a/tests/unit_tests/components/remote_base/test_keeloq.py b/tests/unit_tests/components/remote_base/test_keeloq.py index bd0f817dd1f..57204d17d84 100644 --- a/tests/unit_tests/components/remote_base/test_keeloq.py +++ b/tests/unit_tests/components/remote_base/test_keeloq.py @@ -12,6 +12,7 @@ from esphome.components.remote_base import ( _keeloq_suffix_fits, ) import esphome.config_validation as cv +from esphome.const import CONF_GROUP from esphome.core import Lambda @@ -43,3 +44,21 @@ def test_transmit_action_checks_the_width() -> None: ) with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): schema({**base, CONF_SUFFIX: 0x100}) + + +def test_group_takes_the_place_of_level_and_suffix() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + base = {"address": 0x1234500, "code": 0xF94A09F2, CONF_GROUP: 0x0F} + assert schema(base)[CONF_GROUP] == 0x0F + with pytest.raises(cv.Invalid, match="group cannot be combined with suffix"): + schema({**base, CONF_SUFFIX_BITS: 6}) + with pytest.raises(cv.Invalid, match="group cannot be combined with suffix"): + schema({**base, CONF_SUFFIX: 0}) + with pytest.raises(cv.Invalid, match="remove level"): + schema({**base, "level": True}) + + +def test_group_is_one_byte() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + with pytest.raises(cv.Invalid): + schema({"address": 0x1, "code": 0x2, CONF_GROUP: 0x100}) From d02da3c8dede540dba7415b2d08ea90df9b9e849 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:54:55 -0500 Subject: [PATCH 067/120] [wifi] Support hidden option in single-network shorthand (#18157) Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 11 +++- tests/component_tests/wifi/test_wifi.py | 51 +++++++++++++++++++ .../validate-hidden-shorthand.esp32-idf.yaml | 5 ++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/wifi/test_wifi.py create mode 100644 tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5fceb891573..5660b1189b8 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -515,10 +515,16 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_PASSWORD in config and CONF_SSID not in config: raise cv.Invalid("Cannot have WiFi password without SSID!") + if CONF_HIDDEN in config and CONF_SSID not in config: + raise cv.Invalid( + "Cannot have 'hidden' without 'ssid'. If you are using the 'networks' " + "list, put 'hidden' inside the network entry instead." + ) + if CONF_SSID in config: # Automatically move single network to 'networks' section config = config.copy() @@ -527,6 +533,8 @@ def _validate(config): network[CONF_PASSWORD] = config.pop(CONF_PASSWORD) if CONF_EAP in config: network[CONF_EAP] = config.pop(CONF_EAP) + if CONF_HIDDEN in config: + network[CONF_HIDDEN] = config.pop(CONF_HIDDEN) if CONF_NETWORKS in config: # In testing mode, merged component tests may have both ssid and networks # Just use the networks list and ignore the single ssid @@ -643,6 +651,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), + cv.Optional(CONF_HIDDEN): cv.boolean, cv.Optional( CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED ): STA_MANUAL_IP_SCHEMA, diff --git a/tests/component_tests/wifi/test_wifi.py b/tests/component_tests/wifi/test_wifi.py new file mode 100644 index 00000000000..3513ee88a69 --- /dev/null +++ b/tests/component_tests/wifi/test_wifi.py @@ -0,0 +1,51 @@ +"""Config-validation tests for the wifi component.""" + +import pytest +from voluptuous import Invalid + +from esphome.components.wifi import _validate +from esphome.const import ( + CONF_HIDDEN, + CONF_NETWORKS, + CONF_PASSWORD, + CONF_SSID, + CONF_USE_ADDRESS, +) + + +def test_hidden_folded_into_networks() -> None: + """The shorthand 'hidden' option is moved into the generated networks entry.""" + config = _validate( + { + CONF_SSID: "MyHomeNetwork", + CONF_PASSWORD: "password1", + CONF_HIDDEN: True, + CONF_USE_ADDRESS: "test.local", + } + ) + assert CONF_SSID not in config + assert CONF_PASSWORD not in config + assert CONF_HIDDEN not in config + networks = config[CONF_NETWORKS] + assert len(networks) == 1 + assert networks[0][CONF_SSID] == "MyHomeNetwork" + assert networks[0][CONF_PASSWORD] == "password1" + assert networks[0][CONF_HIDDEN] is True + + +def test_shorthand_without_hidden_has_no_hidden_key() -> None: + """No 'hidden' key is set on the networks entry unless explicitly configured.""" + config = _validate( + { + CONF_SSID: "MyHomeNetwork", + CONF_PASSWORD: "password1", + CONF_USE_ADDRESS: "test.local", + } + ) + assert CONF_HIDDEN not in config[CONF_NETWORKS][0] + + +def test_hidden_without_ssid_rejected() -> None: + """A 'hidden' option without an SSID has nothing to apply to and is rejected.""" + with pytest.raises(Invalid, match="hidden"): + _validate({CONF_HIDDEN: True, CONF_USE_ADDRESS: "test.local"}) diff --git a/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml b/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml new file mode 100644 index 00000000000..8482c4e2022 --- /dev/null +++ b/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml @@ -0,0 +1,5 @@ +# Exercises the hidden option in the single-network ssid/password shorthand. +wifi: + ssid: MyHiddenNetwork + password: password1 + hidden: true From c9ba6c355c92693e5790bdbc6d2461c094db499c Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:00:31 +0200 Subject: [PATCH 068/120] [hoermann_hcp] Add an actuator error binary sensor (#20299) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../hoermann_hcp/binary_sensor/__init__.py | 19 +++-- .../components/hoermann_hcp/hoermann_hcp.cpp | 20 +++++- .../components/hoermann_hcp/hoermann_hcp.h | 8 +++ .../hoermann_hcp_binary_sensor_test.cpp | 70 +++++++++++++++++++ tests/components/hoermann_hcp/common.yaml | 2 + 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/esphome/components/hoermann_hcp/binary_sensor/__init__.py b/esphome/components/hoermann_hcp/binary_sensor/__init__.py index 3de6a161e65..ec03e3256f6 100644 --- a/esphome/components/hoermann_hcp/binary_sensor/__init__.py +++ b/esphome/components/hoermann_hcp/binary_sensor/__init__.py @@ -1,13 +1,18 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.const import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_PROBLEM, + ENTITY_CATEGORY_DIAGNOSTIC, +) from esphome.types import ConfigType from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns DEPENDENCIES = ["hoermann_hcp"] +CONF_ACTUATOR_ERROR = "actuator_error" CONF_IS_CONNECTED = "is_connected" HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_( @@ -23,14 +28,20 @@ CONFIG_SCHEMA = cv.All( device_class=DEVICE_CLASS_CONNECTIVITY, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend(cv.COMPONENT_SCHEMA), + cv.Optional(CONF_ACTUATOR_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), } ), - cv.has_at_least_one_key(CONF_IS_CONNECTED), + cv.has_at_least_one_key(CONF_IS_CONNECTED, CONF_ACTUATOR_ERROR), ) async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) if (conf := config.get(CONF_IS_CONNECTED)) is not None: - parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) - var = await binary_sensor.new_binary_sensor(conf, parent) + var = await binary_sensor.new_binary_sensor(conf, hub) await cg.register_component(var, conf) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_ACTUATOR_ERROR, hub.set_actuator_error_binary_sensor) diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index bbf1380d178..6c573f0a047 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -187,6 +187,9 @@ void HoermannHcp::dump_config() { "Hoermann HCP bridge:\n" " Modbus server address: 0x%02X", this->get_address()); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Actuator Error", this->actuator_error_binary_sensor_); +#endif #ifdef USE_HOERMANN_HCP_IDENTITY LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_text_sensor_); log_identity_value(this->serial_number_text_sensor_); @@ -551,10 +554,25 @@ void HoermannHcp::on_state_reg_(uint16_t value) { } // Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records -// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. +// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. In the high byte, Hoermann's own bus accessory +// treats either bit of 0x30 as an actuator error; what sets them is not known. void HoermannHcp::on_light_reg_(uint16_t value) { this->set_light_seen_(true); this->set_light_on_((value & 0x0010) != 0); + const bool actuator_error = (value & 0x3000) != 0; +#ifdef USE_BINARY_SENSOR + // Unknown until first reported, then kept like the other entities here while the bus is quiet. + if (this->actuator_error_binary_sensor_ != nullptr) + this->actuator_error_binary_sensor_->publish_state(actuator_error); +#endif + if (this->actuator_error_ == actuator_error) + return; + this->actuator_error_ = actuator_error; + if (actuator_error) { + ESP_LOGW(TAG, "Motor reports an actuator error (0x%04X)", value); + } else { + ESP_LOGI(TAG, "Actuator error cleared"); + } } bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index c16a0890ef9..2fca12f11b4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -6,6 +6,9 @@ #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_HOERMANN_HCP_IDENTITY #include "esphome/components/text_sensor/text_sensor.h" #endif @@ -55,6 +58,9 @@ struct HoermannHcpCommand { }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(actuator_error) +#endif #ifdef USE_HOERMANN_HCP_IDENTITY // The motor is asked for these only when one of them is configured. SUB_TEXT_SENSOR(serial_number) @@ -186,6 +192,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool light_requested_{false}; bool light_command_sent_{false}; bool light_target_{false}; + // Last reported, kept across a quiet bus so a reconnect logs only a real change. + bool actuator_error_{false}; bool door_state_seen_{false}; bool short_broadcast_logged_{false}; // Only the read half right after a 0x17 write carries a command, so a second read without a new write does not. diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp index 6e9b5670805..d22d030770d 100644 --- a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -1,3 +1,5 @@ +#include + #include #include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" @@ -49,4 +51,72 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { EXPECT_EQ(publishes, 1); } +// Nothing has reported the flag yet, so the sensor stays unknown rather than claiming the motor is fine. +TEST(HoermannHcpActuatorErrorTest, UnknownUntilReported) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + connect_controller(door); + door.update(); + EXPECT_FALSE(sensor.has_state()); + // A broadcast without register 6 does not report it either. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + door.update(); + EXPECT_FALSE(sensor.has_state()); +} + +// Either bit of 0x30 in the high byte of register 6 is an error. Nothing else in the register is: 0x0200 is set on +// some installations, and the bits above 0x30 are not part of the flag. +TEST(HoermannHcpActuatorErrorTest, FollowsTheErrorBits) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + const std::pair cases[] = { + {0x0000, false}, {0x1000, true}, {0x0010, false}, {0x2000, true}, {0x0200, false}, + {0x3014, true}, {0x0214, false}, {0x4000, false}, {0x8000, false}, {0xC0FF, false}, + }; + for (const auto &[reg, error] : cases) { + door.on_write_registers(BROADCAST_REG, lamp_broadcast(reg)); + ASSERT_TRUE(sensor.has_state()) << std::hex << reg; + EXPECT_EQ(sensor.state, error) << std::hex << reg; + } +} + +// A quiet bus or a broadcast without register 6 keeps the last value, which the next report replaces. +TEST(HoermannHcpActuatorErrorTest, KeepsTheLastValueWhileNotReported) { + TestableHoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1000)); + ASSERT_TRUE(sensor.state); + door.set_valid_(false); + door.update(); + EXPECT_TRUE(sensor.has_state()); + EXPECT_TRUE(sensor.state); + + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + door.update(); + EXPECT_TRUE(sensor.state); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + EXPECT_FALSE(sensor.state); +} + +// An unchanged flag is not reported again while the lamp next to it switches. +TEST(HoermannHcpActuatorErrorTest, LampChangeDoesNotRepublish) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + int publishes = 0; + sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1000)); + ASSERT_EQ(publishes, 1); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1010)); + EXPECT_EQ(publishes, 1); +} + } // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 21d3d2b8485..e663d5d3e45 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -11,6 +11,8 @@ binary_sensor: - platform: hoermann_hcp is_connected: name: Garage Connected + actuator_error: + name: Garage Actuator Error button: - platform: hoermann_hcp From 5759c6b8e6cce4a5542c27dc320abe56f407b8b0 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:25:40 -0500 Subject: [PATCH 069/120] [deep_sleep] Honor nested wakeup_pin_mode on ESP32 single-pin configs (#16618) Co-authored-by: J. Nick Koston --- esphome/components/deep_sleep/__init__.py | 17 ++++++++-- .../deep_sleep/test_deep_sleep.py | 32 +++++++++++++++++++ ...est_deep_sleep_nested_wakeup_pin_mode.yaml | 13 ++++++++ .../deep_sleep/validate.esp32-idf.yaml | 14 ++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml create mode 100644 tests/components/deep_sleep/validate.esp32-idf.yaml diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index d60a51c3cf7..c9a0ec2196f 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -155,6 +155,12 @@ def validate_wakeup_pin( return processed_pins +_WAKEUP_PIN_MODE_BOTH_LEVELS = ( + "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " + "or under the pin entry, not both" +) + + def validate_config(config: ConfigType) -> ConfigType: wakeup_pins = config.get(CONF_WAKEUP_PIN) has_pin_mode = CONF_WAKEUP_PIN_MODE in config @@ -170,6 +176,14 @@ def validate_config(config: ConfigType) -> ConfigType: "ESP32 does not support multiple entries for 'wakeup_pin'", path=[CONF_WAKEUP_PIN], ) + if wakeup_pins and CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): + if has_pin_mode: + raise cv.Invalid( + _WAKEUP_PIN_MODE_BOTH_LEVELS, + path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], + ) + # ESP32 has one global mode, which to_code() reads from the top level + config[CONF_WAKEUP_PIN_MODE] = pin.pop(CONF_WAKEUP_PIN_MODE) elif has_pin_mode: if multiple_pins: raise cv.Invalid( @@ -178,8 +192,7 @@ def validate_config(config: ConfigType) -> ConfigType: ) if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): raise cv.Invalid( - "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " - "or under the pin entry, not both", + _WAKEUP_PIN_MODE_BOTH_LEVELS, path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], ) # Move the global mode into the single pin entry, which to_code() reads diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index e05109f246a..4671efc7b9f 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -1,5 +1,8 @@ """Tests for the deep sleep component.""" +from collections.abc import Callable +from pathlib import Path + import pytest from esphome import config_validation as cv @@ -186,3 +189,32 @@ def test_deep_sleep_bk72xx_global_wakeup_pin_mode_with_multiple_pins_rejected( with pytest.raises(cv.Invalid, match="define it per pin") as exc: deep_sleep.validate_config(config) assert exc.value.path == [deep_sleep.CONF_WAKEUP_PIN_MODE] + + +def test_deep_sleep_esp32_nested_wakeup_pin_mode( + generate_main: Callable[[str | Path], str], +) -> None: + """On ESP32, a wakeup_pin_mode nested under the pin entry is applied, not dropped.""" + main_cpp = generate_main( + "tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml" + ) + + assert "deepsleep->set_wakeup_pin(" in main_cpp + assert "deepsleep->set_wakeup_pin_mode(" in main_cpp + assert "WAKEUP_PIN_MODE_KEEP_AWAKE" in main_cpp + + +def test_deep_sleep_esp32_wakeup_pin_mode_at_both_levels_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On ESP32, wakeup_pin_mode at the top level and under the pin entry is an error.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = { + CONF_WAKEUP_PIN: [ + {"pin": "GPIO4", deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + ], + deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP", + } + with pytest.raises(cv.Invalid, match="not both") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [CONF_WAKEUP_PIN, 0, deep_sleep.CONF_WAKEUP_PIN_MODE] diff --git a/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml b/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml new file mode 100644 index 00000000000..4bab75ea40f --- /dev/null +++ b/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +deep_sleep: + id: deepsleep + sleep_duration: 1min + wakeup_pin: + - pin: GPIO4 + wakeup_pin_mode: KEEP_AWAKE diff --git a/tests/components/deep_sleep/validate.esp32-idf.yaml b/tests/components/deep_sleep/validate.esp32-idf.yaml new file mode 100644 index 00000000000..3488914c074 --- /dev/null +++ b/tests/components/deep_sleep/validate.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + wakeup_pin: GPIO4 + +packages: + deep_sleep: !include common.yaml + +# Nested per-pin wakeup_pin_mode on ESP32 is hoisted to the top level +deep_sleep: + id: deep_sleep_1 + run_duration: 10s + sleep_duration: 50s + wakeup_pin: + - pin: ${wakeup_pin} + wakeup_pin_mode: KEEP_AWAKE From 325c747dcd19fad67b2bf62fd74bec7e1356f82b Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:41:13 -0500 Subject: [PATCH 070/120] [sen6x] Add CO2 configuration options (#18780) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 143 +++++++++++++++++++++++------ esphome/components/sen6x/sen6x.h | 29 ++++++ esphome/components/sen6x/sensor.py | 32 +++++++ tests/components/sen6x/common.yaml | 8 ++ 4 files changed, 183 insertions(+), 29 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 5087b112a8d..dabf16d760b 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -22,6 +22,9 @@ static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400; static constexpr uint32_t FAN_CLEANING_DELAY = 10000; static constexpr uint32_t SHT_HEATER_DELAY = 20000; static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; +// Accepted range of a runtime pressure source, the same as the schema's ambient_pressure_compensation +static constexpr float AMBIENT_PRESSURE_MIN_HPA = 700.0f; +static constexpr float AMBIENT_PRESSURE_MAX_HPA = 1200.0f; static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; @@ -42,6 +45,9 @@ static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; +static constexpr uint16_t SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL = 0x6711; +static constexpr uint16_t SEN6X_CMD_AMBIENT_PRESSURE = 0x6720; +static constexpr uint16_t SEN6X_CMD_SENSOR_ALTITUDE = 0x6736; static inline void set_read_command_and_words(SEN6XComponent::Sen6xType type, uint16_t &read_cmd, uint8_t &read_words) { read_cmd = SEN6X_CMD_READ_MEASUREMENT; @@ -168,32 +174,54 @@ void SEN6XComponent::setup() { }); } -// One configuration write per invocation, spaced by CMD_EXEC_DELAY. Cases without a -// configured value fall through; each taken case must advance setup_step_index_ so the -// next invocation resumes at the following step. These writes are optional, so a failure -// only warns and the chain continues to the mandatory start-measurements write. +// One configuration write per invocation, spaced by CMD_EXEC_DELAY, then the start command. +// These writes are optional, so a failure only warns and the chain carries on. void SEN6XComponent::run_next_setup_step_() { - switch (this->setup_step_index_) { - // Tuning writes are skipped when setup() disabled the sensor for this variant - case 0: - this->setup_step_index_++; - if (this->voc_sensor_ != nullptr && this->voc_tuning_params_.has_value()) { - this->write_tuning_parameters_(SEN6X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); - break; - } - [[fallthrough]]; - case 1: - this->setup_step_index_++; - if (this->nox_sensor_ != nullptr && this->nox_tuning_params_.has_value()) { - this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); - break; - } - [[fallthrough]]; - default: - this->finish_setup_(); + while (this->setup_step_index_ < SETUP_STEP_COUNT) { + if (this->write_setup_step_(static_cast(this->setup_step_index_++))) { + this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); return; + } } - this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); + this->finish_setup_(); +} + +// Returns false when the step has nothing to write: the setting is not configured, or setup() +// disabled its sensor for this variant +bool SEN6XComponent::write_setup_step_(SetupStep step) { + switch (step) { + case SETUP_STEP_VOC_TUNING: + if (this->voc_sensor_ == nullptr || !this->voc_tuning_params_.has_value()) + return false; + this->write_tuning_parameters_(SEN6X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); + return true; + case SETUP_STEP_NOX_TUNING: + if (this->nox_sensor_ == nullptr || !this->nox_tuning_params_.has_value()) + return false; + this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); + return true; + case SETUP_STEP_CO2_ASC: + if (this->co2_sensor_ == nullptr || !this->co2_asc_.has_value()) + return false; + this->write_setup_register_(SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL, this->co2_asc_.value() ? 1 : 0); + return true; + case SETUP_STEP_ALTITUDE: + if (this->co2_sensor_ == nullptr || this->altitude_compensation_ == ALTITUDE_UNSET) + return false; + this->write_setup_register_(SEN6X_CMD_SENSOR_ALTITUDE, this->altitude_compensation_); + return true; + case SETUP_STEP_AMBIENT_PRESSURE: + if (this->co2_sensor_ == nullptr || this->ambient_pressure_ == PRESSURE_UNSET) + return false; + this->write_ambient_pressure_(this->ambient_pressure_); + return true; + default: + return false; + } +} + +bool SEN6XComponent::write_setup_register_(uint16_t i2c_command, uint16_t value) { + return this->write_config_words_(i2c_command, &value, 1); } void SEN6XComponent::finish_setup_() { @@ -208,14 +236,15 @@ void SEN6XComponent::finish_setup_() { ESP_LOGD(TAG, "Initialized"); } -// Writes one optional configuration command. A failure warns and returns false, but does -// not stop setup: the sensor still measures with that setting left at its default. +// Writes one optional configuration command and reserves its execution time. A failure warns and +// returns false, but does not stop setup: the sensor still measures with that setting left at its default. bool SEN6XComponent::write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len) { if (!this->write_command(i2c_command, data, len)) { ESP_LOGE(TAG, "Write 0x%04X failed, error %d", i2c_command, this->last_error_); this->status_set_warning(); return false; } + this->set_command_wait_(CMD_EXEC_DELAY); return true; } @@ -239,6 +268,22 @@ void SEN6XComponent::dump_config() { this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_, this->firmware_version_minor_, this->address_); LOG_UPDATE_INTERVAL(this); + // setup() clears co2_sensor_ once it detects a variant without CO2 + if (this->co2_sensor_ != nullptr) { + if (this->co2_asc_.has_value()) { + ESP_LOGCONFIG(TAG, " CO2 automatic self-calibration: %s", ONOFF(this->co2_asc_.value())); + } + if (this->altitude_compensation_ != ALTITUDE_UNSET) { + ESP_LOGCONFIG(TAG, " Altitude compensation: %u m", this->altitude_compensation_); + } + if (this->ambient_pressure_ != PRESSURE_UNSET) { + ESP_LOGCONFIG(TAG, " Ambient pressure compensation: %u hPa", this->ambient_pressure_); + } + if (this->ambient_pressure_source_ != nullptr) { + ESP_LOGCONFIG(TAG, " Ambient pressure compensation source: %s", + this->ambient_pressure_source_->get_name().c_str()); + } + } LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM 4.0", this->pm_4_0_sensor_); @@ -255,9 +300,17 @@ void SEN6XComponent::update() { if (!this->initialized_ || !this->measuring_) { return; } - - // Cancel any in-flight polling from a previous update() cycle. + // Cancel any in-flight polling from a previous update() cycle before touching the bus. this->cancel_timeout(TIMEOUT_POLL); + // A command is still executing (an action moments ago): run this cycle once its window has passed + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->update(); }); + return; + } + + if (this->ambient_pressure_source_ != nullptr && this->co2_sensor_ != nullptr) { + this->update_ambient_pressure_compensation_(this->ambient_pressure_source_->state); + } set_read_command_and_words(this->sen6x_type_, this->read_cmd_, this->read_words_); @@ -286,6 +339,11 @@ void SEN6XComponent::poll_data_ready_() { ESP_LOGD(TAG, "Data not ready"); return; } + // A set command (the pressure write above all) gets its execution time before the chain writes again + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->poll_data_ready_(); }); + return; + } ESP_LOGV(TAG, "Data ready polling attempt %u", static_cast(POLL_RETRIES - this->poll_retries_remaining_ + 1)); this->poll_retries_remaining_--; @@ -454,8 +512,35 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } -// True while the previous command's execution or settling time has not elapsed -bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; } +void SEN6XComponent::write_ambient_pressure_(uint16_t pressure_hpa) { + if (this->write_setup_register_(SEN6X_CMD_AMBIENT_PRESSURE, pressure_hpa)) + this->last_ambient_pressure_ = pressure_hpa; +} + +void SEN6XComponent::update_ambient_pressure_compensation_(float pressure_hpa) { + if (std::isnan(pressure_hpa)) + return; + // Range-check before narrowing so out-of-unit sources (e.g. Pa) can't wrap into range + if (pressure_hpa < AMBIENT_PRESSURE_MIN_HPA || pressure_hpa > AMBIENT_PRESSURE_MAX_HPA) { + if (!this->pressure_range_warned_) { + ESP_LOGW(TAG, "Ambient pressure out of range: %.0f hPa", pressure_hpa); + this->pressure_range_warned_ = true; + } + return; + } + this->pressure_range_warned_ = false; + const auto value = static_cast(lroundf(pressure_hpa)); + if (value != this->last_ambient_pressure_) + this->write_ambient_pressure_(value); +} + +// Time left in the previous command's execution or settling window, 0 once it has passed +uint32_t SEN6XComponent::command_wait_remaining_() const { + const uint32_t elapsed = millis() - this->command_started_at_; + return elapsed < this->command_wait_ ? this->command_wait_ - elapsed : 0; +} + +bool SEN6XComponent::command_blocked_() const { return this->command_wait_remaining_() != 0; } void SEN6XComponent::set_command_wait_(uint32_t wait_ms) { this->command_started_at_ = millis(); diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 588a466d714..43d0ce00120 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -20,6 +20,10 @@ struct GasTuning { uint16_t gain_factor; }; +// The CO2 settings a config may leave out: altitude is 0 to 3000 m, pressure 700 to 1200 hPa +inline constexpr uint16_t ALTITUDE_UNSET = 0xFFFF; +inline constexpr uint16_t PRESSURE_UNSET = 0; + class SEN6XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { SUB_SENSOR(pm_1_0) SUB_SENSOR(pm_2_5) @@ -58,17 +62,35 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } + void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } + void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } + void set_ambient_pressure_source(sensor::Sensor *pressure) { this->ambient_pressure_source_ = pressure; } void start_measurement(); void stop_measurement(); void start_fan_cleaning(); void activate_sht_heater(); protected: + enum SetupStep : uint8_t { + SETUP_STEP_VOC_TUNING, + SETUP_STEP_NOX_TUNING, + SETUP_STEP_CO2_ASC, + SETUP_STEP_ALTITUDE, + SETUP_STEP_AMBIENT_PRESSURE, + SETUP_STEP_COUNT, + }; + Sen6xType infer_type_from_product_name_(const std::string &product_name); void run_next_setup_step_(); + bool write_setup_step_(SetupStep step); void finish_setup_(); bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool write_setup_register_(uint16_t i2c_command, uint16_t value); + void write_ambient_pressure_(uint16_t pressure_hpa); + void update_ambient_pressure_compensation_(float pressure_hpa); + uint32_t command_wait_remaining_() const; bool command_blocked_() const; void set_command_wait_(uint32_t wait_ms); void on_measurement_started_(); @@ -80,6 +102,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string serial_number_; optional voc_tuning_params_; optional nox_tuning_params_; + sensor::Sensor *ambient_pressure_source_{nullptr}; Sen6xType sen6x_type_{UNKNOWN}; // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap uint32_t command_started_at_{0}; @@ -87,12 +110,18 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S uint32_t co2_started_at_{0}; uint32_t co2_wait_{0}; uint16_t read_cmd_{0}; + uint16_t altitude_compensation_{ALTITUDE_UNSET}; + uint16_t ambient_pressure_{PRESSURE_UNSET}; + // Last pressure written to the device, used to skip redundant writes + uint16_t last_ambient_pressure_{PRESSURE_UNSET}; + optional co2_asc_; uint8_t setup_step_index_{0}; uint8_t firmware_version_major_{0}; uint8_t firmware_version_minor_{0}; uint8_t poll_retries_remaining_{0}; uint8_t read_words_{0}; bool initialized_{false}; + bool pressure_range_warned_{false}; bool measuring_{false}; bool startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 9893ef80233..b219caafa09 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -6,6 +6,10 @@ from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, + CONF_ALTITUDE_COMPENSATION, + CONF_AMBIENT_PRESSURE_COMPENSATION, + CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE, + CONF_AUTOMATIC_SELF_CALIBRATION, CONF_CO2, CONF_FORMALDEHYDE, CONF_GAIN_FACTOR, @@ -155,6 +159,22 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, device_class=DEVICE_CLASS_CARBON_DIOXIDE, state_class=STATE_CLASS_MEASUREMENT, + ).extend( + { + cv.Optional(CONF_AUTOMATIC_SELF_CALIBRATION): cv.boolean, + cv.Optional(CONF_ALTITUDE_COMPENSATION): cv.All( + cv.float_with_unit( + "altitude", "(m|m a.s.l.|MAMSL|MASL)", optional_unit=True + ), + cv.int_range(min=0, max=3000), + ), + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.int_range( + min=700, max=1200 + ), + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id( + sensor.Sensor + ), + } ), cv.Optional(CONF_FORMALDEHYDE): sensor.sensor_schema( unit_of_measurement="ppb", @@ -209,6 +229,18 @@ async def to_code(config: ConfigType) -> None: args.append(std_initial) args.append(tuning[CONF_GAIN_FACTOR]) cg.add(getattr(var, setter)(*args)) + if co2_cfg := config.get(CONF_CO2): + if (asc := co2_cfg.get(CONF_AUTOMATIC_SELF_CALIBRATION)) is not None: + cg.add(var.set_automatic_self_calibration(asc)) + if (altitude := co2_cfg.get(CONF_ALTITUDE_COMPENSATION)) is not None: + cg.add(var.set_altitude_compensation(altitude)) + if (pressure := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION)) is not None: + cg.add(var.set_ambient_pressure_compensation(pressure)) + if ( + source := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE) + ) is not None: + sens = await cg.get_variable(source) + cg.add(var.set_ambient_pressure_source(sens)) SEN6X_ACTION_SCHEMA = maybe_simple_id( diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 6ab80a76b21..eed0dd51d67 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -1,4 +1,8 @@ sensor: + - platform: template + id: pressure_source + name: Pressure Source + unit_of_measurement: hPa # Test with explicit type parameter - platform: sen6x id: sen6x_sensor @@ -45,6 +49,10 @@ sensor: gain_factor: 180 co2: name: Carbon Dioxide + automatic_self_calibration: true + altitude_compensation: 100 + ambient_pressure_compensation: 1013 + ambient_pressure_compensation_source: pressure_source formaldehyde: name: Formaldehyde address: 0x6B From ae6296019303e7492a0f584cf5c011241e62cf2e Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 17 Oct 2026 15:51:07 -0500 Subject: [PATCH 071/120] [epaper_spi] Add the UC8279 controller and the Xteink X4 Pro (#20545) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/display.py | 14 +- esphome/components/epaper_spi/epaper_spi.cpp | 9 +- esphome/components/epaper_spi/epaper_spi.h | 8 + .../epaper_spi/epaper_spi_uc8179.cpp | 37 +-- .../components/epaper_spi/epaper_spi_uc8179.h | 10 +- .../epaper_spi/epaper_spi_uc8279.cpp | 144 +++++++++++ .../components/epaper_spi/epaper_spi_uc8279.h | 46 ++++ .../epaper_spi/epaper_spi_ultrachip.cpp | 33 +++ .../epaper_spi/epaper_spi_ultrachip.h | 43 ++++ .../components/epaper_spi/models/__init__.py | 14 +- .../components/epaper_spi/models/uc8179.py | 10 +- .../components/epaper_spi/models/uc8279.py | 74 ++++++ .../epaper_spi/config/uc8279_x4pro_test.yaml | 15 ++ tests/component_tests/epaper_spi/test_init.py | 141 ++++++++++- .../display/test_uc8279_transfer.cpp | 237 ++++++++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 24 ++ 16 files changed, 811 insertions(+), 48 deletions(-) create mode 100644 esphome/components/epaper_spi/epaper_spi_uc8279.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_uc8279.h create mode 100644 esphome/components/epaper_spi/epaper_spi_ultrachip.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_ultrachip.h create mode 100644 esphome/components/epaper_spi/models/uc8279.py create mode 100644 tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml create mode 100644 tests/components/epaper_spi/display/test_uc8279_transfer.cpp diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index d04ffd33be8..6a391811112 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -30,6 +30,7 @@ from esphome.const import ( CONF_MIRROR_X, CONF_MIRROR_Y, CONF_MODEL, + CONF_OFFSET_HEIGHT, CONF_PAGES, CONF_RESET_DURATION, CONF_RESET_PIN, @@ -73,12 +74,17 @@ for module_info in pkgutil.iter_modules(models.__path__): MODELS = models.EpaperModel.models -DIMENSION_SCHEMA = cv.Schema( - { + +def dimension_schema(model: models.EpaperModel) -> cv.Schema: + schema = { cv.Required(CONF_WIDTH): cv.int_, cv.Required(CONF_HEIGHT): cv.int_, } -) + # Only models with an offset_height default use it, and the default must survive explicit dimensions + if (offset_height := model.get_default(CONF_OFFSET_HEIGHT, None)) is not None: + schema[cv.Optional(CONF_OFFSET_HEIGHT, default=offset_height)] = cv.positive_int + return cv.Schema(schema) + TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} @@ -135,7 +141,7 @@ def model_schema(config): model.option(CONF_RESET_PIN): pins.gpio_output_pin_schema, cv.GenerateID(): cv.declare_id(class_name), cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8), - cv_dimensions(CONF_DIMENSIONS): DIMENSION_SCHEMA, + cv_dimensions(CONF_DIMENSIONS): dimension_schema(model), model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema), model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list( map_sequence diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index e95d2fd2869..71e87e038b6 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -227,10 +227,12 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::IDLE); return; } + // A full update resets update_count_ to zero, so a non-zero count marks a partial one + this->partial_ = this->update_count_ != 0; this->set_state_(EPaperState::RESET); break; case EPaperState::INITIALISE: - if (!this->initialise(this->update_count_ != 0)) { + if (!this->initialise(this->partial_)) { return; // Not done yet, come back next loop } this->set_state_(EPaperState::TRANSFER_DATA); @@ -250,11 +252,14 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::REFRESH_SCREEN); break; case EPaperState::REFRESH_SCREEN: - this->refresh_screen(this->update_count_ != 0); + this->refresh_screen(this->partial_); this->update_count_ = (this->update_count_ + 1) % this->full_update_every_; this->set_state_(EPaperState::POWER_OFF); break; case EPaperState::POWER_OFF: + if (!this->after_refresh()) { + return; // Not done yet, come back next loop + } this->power_off(); this->set_state_(EPaperState::DEEP_SLEEP); break; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 046df97cccd..d953bf3df81 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -167,6 +167,13 @@ class EPaperBase : public Display, */ virtual void power_off() = 0; + /** + * Called after the refresh, before power off, repeatedly until it returns true. + * Lets a driver do extra work spread over several loops, such as rewriting the previous image. + * @return true when done, false to be called again next loop + */ + virtual bool after_refresh() { return true; } + /** * Place the display into deep sleep */ @@ -201,6 +208,7 @@ class EPaperBase : public Display, uint8_t effective_transform_{}; uint8_t update_count_{}; bool full_update_requested_{}; + bool partial_{}; // the update in progress is a partial one; set when UPDATE moves to RESET // these values represent the bounds of the updated buffer. Note that x_high and y_high // point to the pixel past the last one updated, i.e. may range up to width/height. uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp index 328cd833299..60cc80e91bd 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp @@ -10,12 +10,11 @@ ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8179"); bool EPaperUC8179::initialise(bool partial) { EPaperBase::initialise(partial); // send the model init sequence - this->partial_ = partial; ESP_LOGV(TAG, "Power on"); // POWER ON must precede the waveform/mode registers and the data transfer // (the original driver powers on and busy-waits before writing them). // The state machine busy-waits before entering TRANSFER_DATA. - this->command(0x04); + this->command(CMD_POWER_ON); // Give the busy line time to assert before the state machine polls it this->next_delay_ = 100; return true; @@ -27,21 +26,17 @@ void EPaperUC8179::set_refresh_mode_() { return; // plain full refresh uses the mode set by the init sequence } // Fast and partial refresh use flipped data polarity and a floating border - this->cmd_data(0x50, {0xA9, 0x07}); + this->cmd_data(CMD_CDI, {0xA9, 0x07}); // Force the waveform via the temperature registers: 0x5A selects the fast // full-refresh waveform, 0x6E the partial-refresh waveform - this->cmd_data(0xE0, {0x02}); + this->cmd_data(CMD_CCSET, {0x02}); if (this->partial_) { - this->cmd_data(0xE5, {0x6E}); - this->command(0x91); // enter partial mode - // Set the partial window to the full screen - const uint16_t x_end = this->width_ - 1; - const uint16_t y_end = this->height_ - 1; - this->cmd_data(0x90, {0x00, 0x00, static_cast(x_end >> 8), static_cast(x_end & 0xFF), 0x00, 0x00, - static_cast(y_end >> 8), static_cast(y_end & 0xFF), 0x01}); + this->cmd_data(CMD_TSSET, {0x6E}); + // Partial mode, with the window set to the full screen + this->set_partial_window_(0, this->width_ - 1, 0, this->height_ - 1); } else { - this->cmd_data(0xE5, {0x5A}); - this->command(0x92); // exit partial mode + this->cmd_data(CMD_TSSET, {0x5A}); + this->command(CMD_PARTIAL_OUT); // exit partial mode } } @@ -63,7 +58,7 @@ bool HOT EPaperUC8179::transfer_data() { // Phase 1 (fast full refresh only): previous image via 0x10 (DTM1), inverse of the new image if (two_pass && this->current_data_index_ < buffer_length) { if (this->current_data_index_ == 0) { - this->command(0x10); // DATA START TRANSMISSION 1 (previous image) + this->command(CMD_DTM1); // DATA START TRANSMISSION 1 (previous image) } this->start_data_(); while (this->current_data_index_ < buffer_length) { @@ -86,7 +81,7 @@ bool HOT EPaperUC8179::transfer_data() { const size_t total = offset + buffer_length; if (this->current_data_index_ < total) { if (this->current_data_index_ == offset) { - this->command(0x13); // DATA START TRANSMISSION 2 (new image) + this->command(CMD_DTM2); // DATA START TRANSMISSION 2 (new image) } this->start_data_(); while (this->current_data_index_ < total) { @@ -117,7 +112,7 @@ void EPaperUC8179::power_on() { void EPaperUC8179::refresh_screen(bool /*partial*/) { ESP_LOGV(TAG, "Refresh"); - this->command(0x12); // DISPLAY REFRESH + this->command(CMD_REFRESH); // DISPLAY REFRESH // Delay the next busy poll: the busy line takes a short time to assert after // the refresh command, and polling too early would read it as already idle this->next_delay_ = 100; @@ -125,15 +120,7 @@ void EPaperUC8179::refresh_screen(bool /*partial*/) { void EPaperUC8179::power_off() { ESP_LOGV(TAG, "Power off"); - this->command(0x02); // POWER OFF -} - -void EPaperUC8179::deep_sleep() { - // Deep sleep loses the previous-image RAM that partial refresh compares against - if (!this->is_using_partial_update_()) { - ESP_LOGV(TAG, "Deep sleep"); - this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code - } + this->command(CMD_POWER_OFF); // POWER OFF } } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index fda4297802b..5d85c6bd04a 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -1,6 +1,6 @@ #pragma once -#include "epaper_spi.h" +#include "epaper_spi_ultrachip.h" namespace esphome::epaper_spi { @@ -28,11 +28,11 @@ namespace esphome::epaper_spi { * waveform is forced, partial mode is entered with a full-screen window and * only DTM2 is sent; the controller compares against its previous-image RAM. */ -class EPaperUC8179 final : public EPaperBase { +class EPaperUC8179 final : public EPaperUltraChip { 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) {} + : EPaperUltraChip(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {} protected: bool initialise(bool partial) override; @@ -40,11 +40,7 @@ class EPaperUC8179 final : public EPaperBase { void refresh_screen(bool partial) override; void power_on() override; void power_off() override; - void deep_sleep() override; void set_refresh_mode_(); - - // Set by initialise() so transfer_data() knows which planes to send - bool partial_{}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8279.cpp b/esphome/components/epaper_spi/epaper_spi_uc8279.cpp new file mode 100644 index 00000000000..096b2e54ae7 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8279.cpp @@ -0,0 +1,144 @@ +#include "epaper_spi_uc8279.h" + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8279"); + +static constexpr uint8_t CMD_PSR = 0x00; +static constexpr uint8_t CMD_PFS = 0x03; +static constexpr uint8_t CMD_GATE_SCAN = 0xE1; + +static constexpr uint8_t PSR0_OTP_LUT = 0x17; +static constexpr uint8_t PSR1 = 0x4D; +static constexpr uint8_t PFS = 0x20; +static constexpr uint8_t GATE_SCAN = 0x02; +static constexpr uint8_t CCSET = 0x02; +static constexpr uint8_t TSSET_FULL = 0x1E; +static constexpr uint8_t TSSET_PARTIAL = 0x5A; +static constexpr uint8_t CDI_FULL = 0x97; +static constexpr uint8_t CDI_PARTIAL = 0xD7; + +static constexpr uint16_t RESET_SETTLE_MS = 50; +static constexpr uint16_t BUSY_ASSERT_MS = 10; +static constexpr uint16_t REFRESH_START_MS = 50; + +bool EPaperUC8279::reset() { + // A partial update follows one that left the controller configured, in standby, with DTM1 holding the image + if (this->partial_) + return true; + if (!EPaperBase::reset()) + return false; + this->next_delay_ = RESET_SETTLE_MS; + return true; +} + +bool EPaperUC8279::initialise(bool partial) { + if (partial) + return true; + return EPaperBase::initialise(partial); +} + +void EPaperUC8279::write_gate_(size_t gate, bool white) { + const size_t row_width = this->row_width_; + if (white || gate < this->offset_height_) { + uint8_t white_bytes[MAX_TRANSFER_SIZE]; + memset(white_bytes, 0xFF, sizeof(white_bytes)); + for (size_t written = 0; written != row_width;) { + const size_t length = std::min(row_width - written, MAX_TRANSFER_SIZE); + this->write_array(white_bytes, length); + written += length; + } + return; + } + size_t index = (gate - this->offset_height_) * row_width; + for (size_t remaining = row_width; remaining != 0;) { + size_t length; + const uint8_t *data = this->buffer_.get_span(index, length); + length = std::min(length, remaining); + this->write_array(data, length); + index += length; + remaining -= length; + } +} + +// One plane of one gate's row per step, time-sliced; `base` is the value of current_data_index_ at its first gate +bool HOT EPaperUC8279::write_plane_(uint8_t command, bool white, size_t base) { + const uint32_t start_time = millis(); + const size_t gates = this->gate_count_(); + if (this->current_data_index_ == base) + this->command(command); + this->start_data_(); + while (this->current_data_index_ - base != gates) { + this->write_gate_(this->current_data_index_ - base, white); + ++this->current_data_index_; + if (this->current_data_index_ - base != gates && millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + return true; +} + +// DTM2 gets the new image; a full refresh also writes a white DTM1, so every pixel transitions +bool HOT EPaperUC8279::transfer_data() { + const size_t gates = this->gate_count_(); + if (this->current_data_index_ < gates) { + if (!this->write_plane_(CMD_DTM2, false, 0)) + return false; + // Start DTM1 on the next pass, so this one stays within its time slice + if (!this->partial_) + return false; + } + if (!this->partial_ && !this->write_plane_(CMD_DTM1, true, gates)) + return false; + this->current_data_index_ = 0; + return true; +} + +void EPaperUC8279::power_on() { + ESP_LOGV(TAG, "Power on"); + this->cmd_data(CMD_CDI, {this->partial_ ? CDI_PARTIAL : CDI_FULL}); + this->cmd_data(CMD_CCSET, {CCSET}); + this->cmd_data(CMD_TSSET, {this->partial_ ? TSSET_PARTIAL : TSSET_FULL}); + if (this->partial_) { + this->cmd_data(CMD_PFS, {PFS}); + this->cmd_data(CMD_GATE_SCAN, {GATE_SCAN}); + } + this->command(CMD_POWER_ON); + this->next_delay_ = BUSY_ASSERT_MS; +} + +void EPaperUC8279::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen (%s)", partial ? LOG_STR_LITERAL("partial") : LOG_STR_LITERAL("full")); + if (partial) + this->set_partial_window_(0, this->width_ - 1, this->offset_height_, this->gate_count_() - 1); + // Power-on reloads the panel's stored settings, so the panel setting only takes when written after it + this->cmd_data(CMD_PSR, {PSR0_OTP_LUT, PSR1}); + this->command(CMD_REFRESH); + this->next_delay_ = REFRESH_START_MS; +} + +bool HOT EPaperUC8279::after_refresh() { + // While partial updates are in use, DTM1 holds the image on the panel for the next partial comparison + if (!this->is_using_partial_update_()) + return true; + if (this->current_data_index_ == 0 && this->partial_) + this->command(CMD_PARTIAL_OUT); + if (!this->write_plane_(CMD_DTM1, false, 0)) + return false; + this->current_data_index_ = 0; + return true; +} + +void EPaperUC8279::power_off() { + this->command(CMD_POWER_OFF); + this->next_delay_ = BUSY_ASSERT_MS; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8279.h b/esphome/components/epaper_spi/epaper_spi_uc8279.h new file mode 100644 index 00000000000..fb430855516 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8279.h @@ -0,0 +1,46 @@ +#pragma once + +#include "epaper_spi_ultrachip.h" + +namespace esphome::epaper_spi { + +/** + * Monochrome e-paper displays using the UltraChip UC8279 controller, such as the Xteink X4 Pro. + * + * Buffer layout: 1 bit per pixel, 1=white, 0=black (the base class default). + * + * The controller scans offset_height + height gates; the panel is bonded to the last height of them, + * so every image plane is written for all gates, white outside the panel. + * + * A full refresh writes the new image to DTM2 and white to DTM1. A partial refresh writes only DTM2 + * and drives the pixels that differ from DTM1, which therefore has to hold the image on the panel: + * while partial updates are in use it is rewritten after every refresh, in time slices like the + * image transfer. The panel loses both planes in its own deep sleep, so it then waits in standby + * between updates instead. + */ +class EPaperUC8279 : public EPaperUltraChip { + public: + EPaperUC8279(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length, uint16_t offset_height) + : EPaperUltraChip(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY), + offset_height_(offset_height) {} + + protected: + bool reset() override; + bool initialise(bool partial) override; + bool transfer_data() override; + void power_on() override; + void refresh_screen(bool partial) override; + void power_off() override; + bool after_refresh() override; + + uint16_t gate_count_() const { return this->offset_height_ + this->height_; } + // Writes one gate's row of the image, or white for a gate outside the panel or when white is true + void write_gate_(size_t gate, bool white); + // Writes one plane in time slices, sending `command` at its first gate; true once all gates are written + bool write_plane_(uint8_t command, bool white, size_t base); + + uint16_t offset_height_; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp b/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp new file mode 100644 index 00000000000..d34e022380e --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp @@ -0,0 +1,33 @@ +#include "epaper_spi_ultrachip.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +ESPHOME_LOG_TAG(TAG, "epaper_spi.ultrachip"); + +static constexpr uint8_t hi(uint16_t v) { return static_cast(v >> 8); } +static constexpr uint8_t lo(uint16_t v) { return static_cast(v & 0xFF); } + +void EPaperUltraChip::deep_sleep() { + // Partial updates compare against the previous image, which deep sleep would lose; standby keeps it + if (this->is_using_partial_update_()) + return; + this->send_deep_sleep_(); +} + +void EPaperUltraChip::send_deep_sleep_() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(CMD_DEEP_SLEEP, {DEEP_SLEEP_CHECK}); +} + +void EPaperUltraChip::on_safe_shutdown() { this->send_deep_sleep_(); } + +void EPaperUltraChip::set_partial_window_(uint16_t x_start, uint16_t x_end, uint16_t y_start, uint16_t y_end) { + this->command(CMD_PARTIAL_IN); + this->cmd_data(CMD_PARTIAL_WINDOW, + {hi(x_start), static_cast(lo(x_start) & 0xF8), hi(x_end), + static_cast(lo(x_end) | 0x07), hi(y_start), lo(y_start), hi(y_end), lo(y_end), 0x01}); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ultrachip.h b/esphome/components/epaper_spi/epaper_spi_ultrachip.h new file mode 100644 index 00000000000..4dfd732fa24 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ultrachip.h @@ -0,0 +1,43 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Shared base for the UltraChip UC81xx mono controllers (UC8179, UC8279). + * + * They use the same command set and the same deep-sleep and partial-window handling; the + * concrete drivers differ only in how each refresh is configured and which planes are sent. + */ +class EPaperUltraChip : public EPaperBase { + public: + using EPaperBase::EPaperBase; + + // Nothing carries the image across a restart or the controller's deep sleep, so sleep the panel rather than + // leaving it waiting in standby + void on_safe_shutdown() override; + + protected: + static constexpr uint8_t CMD_POWER_OFF = 0x02; + static constexpr uint8_t CMD_POWER_ON = 0x04; + static constexpr uint8_t CMD_DEEP_SLEEP = 0x07; + static constexpr uint8_t CMD_DTM1 = 0x10; + static constexpr uint8_t CMD_REFRESH = 0x12; + static constexpr uint8_t CMD_DTM2 = 0x13; + static constexpr uint8_t CMD_CDI = 0x50; + static constexpr uint8_t CMD_PARTIAL_WINDOW = 0x90; + static constexpr uint8_t CMD_PARTIAL_IN = 0x91; + static constexpr uint8_t CMD_PARTIAL_OUT = 0x92; + static constexpr uint8_t CMD_CCSET = 0xE0; + static constexpr uint8_t CMD_TSSET = 0xE5; + static constexpr uint8_t DEEP_SLEEP_CHECK = 0xA5; + + void deep_sleep() override; + // Sends the deep-sleep command unconditionally; deep_sleep() skips it while partial updates are in use + void send_deep_sleep_(); + // Enters partial mode and sets the refresh window; x is rounded out to whole bytes + void set_partial_window_(uint16_t x_start, uint16_t x_end, uint16_t y_start, uint16_t y_end); +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index fbedd6371d1..ba8ebb6c873 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -1,12 +1,17 @@ from typing import Any, Self import esphome.config_validation as cv -from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType +def ultrachip_resolution(width: int, gates: int) -> tuple: + """The UC81xx resolution command (0x61): source (width) and gate counts, each big-endian.""" + return (0x61, width >> 8, width & 0xFF, gates >> 8, gates & 0xFF) + + class EpaperModel: models: dict[str, Self] = {} @@ -86,6 +91,13 @@ class EpaperModel: height = self.get_default(CONF_HEIGHT) return width, height + def get_offset_height(self, config) -> int: + # The panel's first row within the controller's addressable area; from explicit dimensions or the model default + dimensions = config.get(CONF_DIMENSIONS, {}) + return dimensions.get( + CONF_OFFSET_HEIGHT, self.get_default(CONF_OFFSET_HEIGHT, 0) + ) + def extend(self, name, **kwargs) -> "EpaperModel": """ Extend the current model with additional parameters or a modified init sequence. diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py index 91c649808a8..84b736081fb 100644 --- a/esphome/components/epaper_spi/models/uc8179.py +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -18,7 +18,7 @@ from typing import Any from esphome.const import CONF_DATA_RATE -from . import EpaperModel +from . import EpaperModel, ultrachip_resolution class UC8179(EpaperModel): @@ -51,13 +51,7 @@ class UC8179(EpaperModel): # PANEL SETTING (black/white mode, LUT from OTP) (0x00, 0x1F), # RESOLUTION SETTING (width x height) - ( - 0x61, - (width >> 8) & 0xFF, - width & 0xFF, - (height >> 8) & 0xFF, - height & 0xFF, - ), + ultrachip_resolution(width, height), # DUAL SPI MODE (disabled) (0x15, 0x00), # VCOM AND DATA INTERVAL SETTING diff --git a/esphome/components/epaper_spi/models/uc8279.py b/esphome/components/epaper_spi/models/uc8279.py new file mode 100644 index 00000000000..deb487883de --- /dev/null +++ b/esphome/components/epaper_spi/models/uc8279.py @@ -0,0 +1,74 @@ +"""Monochrome e-paper displays using the UltraChip UC8279 controller. + +Supported models: +- uc8279: the controller on its own; dimensions and pins must be configured +- xteink-x4pro-uc8279: the Xteink X4 Pro e-reader (ESP32-S3) with its 800x480 UC8279 panel; + the X4 Pro also ships with other panel controllers + +The controller scans offset_height + height gates, and the panel is bonded to the last +height of them; offset_height (under dimensions) is a property of the panel. +""" + +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DATA_RATE, CONF_DIMENSIONS, CONF_OFFSET_HEIGHT +from esphome.types import ConfigType + +from . import EpaperModel, ultrachip_resolution + + +class UC8279(EpaperModel): + def __init__( + self, + name: str, + class_name: str = "EPaperUC8279", + data_rate: str = "10MHz", + **defaults: Any, + ) -> None: + defaults.setdefault(CONF_DATA_RATE, data_rate) + defaults.setdefault("partial_update", True) + defaults.setdefault(CONF_OFFSET_HEIGHT, 0) + defaults.setdefault("width_multiple", 8) + super().__init__(name, class_name=class_name, **defaults) + + def validate_config(self, config: ConfigType) -> ConfigType: + _width, height = self.get_dimensions(config) + if self.get_offset_height(config) + height > 0xFFFF: + raise cv.Invalid( + f"{CONF_OFFSET_HEIGHT} plus the panel height must not exceed {0xFFFF} gates", + path=[CONF_DIMENSIONS, CONF_OFFSET_HEIGHT], + ) + return config + + def get_constructor_args(self, config) -> tuple: + return (self.get_offset_height(config),) + + # fmt: off + def get_init_sequence(self, config: dict) -> tuple: + width, height = self.get_dimensions(config) + gates = self.get_offset_height(config) + height + return ( + (0x00, 0x37, 0x4D), # panel setting; the driver switches it to the OTP LUT before each refresh + ultrachip_resolution(width, gates), # resolution: sources, gates + (0x65, 0x00, 0x00, 0x00, 0x00), # gate and source start + (0x03, 0x20), # power off sequence + (0x30, 0x0E), # PLL + (0xE1, 0x02), # gate scan + ) + # fmt: on + + +uc8279 = UC8279("uc8279") + +uc8279.extend( + "xteink-x4pro-uc8279", + width=800, + height=480, + offset_height=120, + cs_pin=13, + dc_pin=18, + reset_pin=14, + busy_pin={"number": 6, "inverted": True}, + reset_duration="50ms", +) diff --git a/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml b/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml new file mode 100644 index 00000000000..34b55ab4456 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + +spi: + clk_pin: GPIO12 + mosi_pin: GPIO11 + +display: + - platform: epaper_spi + id: epaper_display + model: xteink-x4pro-uc8279 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 81e197abf81..644a505bf82 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -13,7 +13,12 @@ from esphome.components.epaper_spi.display import ( FINAL_VALIDATE_SCHEMA, MODELS, ) -from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, @@ -516,6 +521,140 @@ def test_uc8179_e1001_code_generation( assert "97, 4, 3, 32, 1, 224" in main_cpp +def test_uc8279_width_must_be_a_multiple_of_8( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The UC8279 takes whole bytes of sources, so the width is a multiple of 8.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="multiple of 8"): + run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 804, "height": 480}, + } + ) + + +def test_uc8279_x4pro_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The Xteink X4 Pro generates the UC8279 driver with the panel's gate offset and gate count.""" + main_cpp = generate_main(component_config_path("uc8279_x4pro_test.yaml")) + + assert "epaper_spi::EPaperUC8279" in main_cpp + # 800 sources, 480 rows, and the panel on the last 480 of 600 gates + assert re.search( + r'"XTEINK-X4PRO-UC8279",\s*800,\s*480,.*?,\s*120\)', main_cpp, re.DOTALL + ) + resolution = next( + cmd + for cmd in MODELS["XTEINK-X4PRO-UC8279"].get_init_sequence({}) + if cmd[0] == 0x61 + ) + assert resolution == (0x61, 0x03, 0x20, 0x02, 0x58) + + +def test_uc8279_offset_height_configurable( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The generic uc8279 model takes offset_height under dimensions, reaching the constructor and gate count.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 16, "height": 2, "offset_height": 8}, + } + ) + model = MODELS["UC8279"] + assert model.get_constructor_args(config) == (8,) + resolution = next(cmd for cmd in model.get_init_sequence(config) if cmd[0] == 0x61) + # 16 sources, and 8 + 2 = 10 gates + assert resolution == (0x61, 0x00, 0x10, 0x00, 0x0A) + + +def test_uc8279_offset_height_plus_height_must_fit_16_bits( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """offset_height plus the panel height is the gate count, which the controller holds in 16 bits.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="must not exceed"): + run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 16, "height": 2, "offset_height": 0xFFFF}, + } + ) + + +def test_uc8279_x4pro_explicit_dimensions_keep_the_model_offset( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Writing out the X4 Pro's dimensions without offset_height keeps the panel's offset of 120.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 12, "mosi_pin": 11}) + + config = run_schema_validation( + { + "id": "test_display", + "model": "xteink-x4pro-uc8279", + "dimensions": {"width": 800, "height": 480}, + } + ) + assert MODELS["XTEINK-X4PRO-UC8279"].get_constructor_args(config) == (120,) + + +def test_offset_height_rejected_by_models_without_it( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A model that does not use offset_height rejects it rather than ignoring it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="offset_height"): + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "dimensions": {"width": 200, "height": 200, "offset_height": 8}, + } + ) + + def test_enable_pin_code_generation( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/epaper_spi/display/test_uc8279_transfer.cpp b/tests/components/epaper_spi/display/test_uc8279_transfer.cpp new file mode 100644 index 00000000000..a0a1e1258c5 --- /dev/null +++ b/tests/components/epaper_spi/display/test_uc8279_transfer.cpp @@ -0,0 +1,237 @@ +#include + +#include +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_uc8279.h" + +namespace esphome::epaper_spi::testing { + +/// A reset pin that counts how often it is driven low. +class CountingPin : public RecordingPin { + public: + void digital_write(bool value) override { + if (!value) + this->pulses++; + RecordingPin::digital_write(value); + } + int pulses{0}; +}; + +/// A 16x2 panel bonded to gates 1 and 2 of three, so gate 0 is outside the panel. +class TestableUC8279 : public EPaperUC8279 { + public: + TestableUC8279() : EPaperUC8279("test", 16, 2, nullptr, 0, 1) {} + + void install(spi::SPIDelegate *delegate, uint8_t full_update_every) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_reset_pin(&this->reset_pin); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->set_full_update_every(full_update_every); + } + + void set_frame(std::initializer_list bytes) { + size_t i = 0; + for (const uint8_t byte : bytes) + this->buffer_[i++] = byte; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + /// A whole update with the frame already drawn, run through loop() as the main loop would. + void run_update() { + this->set_auto_clear(false); + this->state_ = EPaperState::UPDATE; + const uint32_t deadline = millis() + 2000; + while (this->state_ != EPaperState::IDLE) { + ASSERT_LT(millis(), deadline) << "update did not finish"; + this->loop(); + } + } + + bool transfer() { return this->transfer_data(); } + + using EPaperUC8279::on_safe_shutdown; + + RecordingPin dc; + CountingPin reset_pin; +}; + +using Bytes = std::vector; + +static size_t count(const Bytes &commands, uint8_t command) { + return std::count(commands.begin(), commands.end(), command); +} + +/// A full refresh writes the new image to DTM2 and white to DTM1, for every gate the controller scans: +/// white on the gate outside the panel, then the panel's rows. +TEST(EPaperUC8279, FullRefreshWritesTheImageAndAWhitePreviousImage) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_TRUE(display.reset_pin.level) << "reset pin left low"; + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.data[0x10].size(), 12u) << "DTM1 is not the white previous image plus the image written after"; + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); + EXPECT_EQ(count(bus.commands, 0x91), 0u) << "a full refresh entered partial mode"; + EXPECT_EQ(bus.data[0x00], (Bytes{0x17, 0x4D})) << "the OTP LUT was not selected before the refresh"; +} + +/// While partial updates are in use, DTM1 is rewritten with the image after every refresh, so the next +/// partial one compares against the image on the panel. The panel then waits in standby, since its own +/// deep sleep loses both planes. +TEST(EPaperUC8279, PreviousImageIsRewrittenAfterEveryRefresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.commands.back(), 0x02) << "the panel was not powered off after DTM1 was written"; + EXPECT_EQ(count(bus.commands, 0x07), 0u) << "the panel was put into deep sleep"; +} + +/// A partial refresh writes only DTM2, without a reset or the configuration, inside a partial window +/// covering the panel's gates. +TEST(EPaperUC8279, PartialRefreshWritesOnlyTheNewImage) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + + EXPECT_EQ(display.reset_pin.pulses, pulses) << "the partial refresh reset the panel"; + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})) + << "DTM1 was written before the refresh, or not rewritten after it"; + EXPECT_EQ(bus.data[0x90], (Bytes{0x00, 0x00, 0x00, 0x0F, 0x00, 0x01, 0x00, 0x02, 0x01})) + << "the partial window does not cover the panel's gates"; + const auto refresh = std::find(bus.commands.begin(), bus.commands.end(), 0x12); + const auto dtm1 = std::find(bus.commands.begin(), bus.commands.end(), 0x10); + EXPECT_LT(refresh, dtm1) << "DTM1 was written before the refresh"; +} + +/// Without partial updates the panel goes into its deep sleep after every update, and the next update +/// resets it and writes both planes again. +TEST(EPaperUC8279, EveryUpdateFullSleepsAndResetsEachTime) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + EXPECT_EQ(bus.data[0x07], (Bytes{0xA5})); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))) << "DTM1 was rewritten without partial updates"; + bus.clear(); + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))); + EXPECT_EQ(count(bus.commands, 0x91), 0u); +} + +/// The rewrite of DTM1 is spread over loop iterations like the image transfer, and the state machine +/// waits for it: the panel is powered off and the update finishes only once all of it is written. +TEST(EPaperUC8279, PreviousImageRewriteIsTimeSliced) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); // every row overruns the time slice + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.commands.back(), 0x02); +} + +/// In a full update the white DTM1 plane starts on the pass after DTM2 ends, so one pass never writes both planes. +TEST(EPaperUC8279, FullTransferWritesOnePlanePerPass) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + + EXPECT_FALSE(display.transfer()); + EXPECT_EQ(count(bus.commands, 0x13), 1u); + EXPECT_EQ(count(bus.commands, 0x10), 0u) << "DTM1 started in the same pass as DTM2"; + + EXPECT_TRUE(display.transfer()); + EXPECT_EQ(count(bus.commands, 0x13), 1u); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))); +} + +/// The full refresh that comes round after partial ones resets the controller, configures it again and writes a white +/// DTM1 over the image it held, as the first full refresh does. +TEST(EPaperUC8279, FullRefreshAfterPartialOnesStartsAfresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 2); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.set_frame({0x0F, 0xF0, 0x00, 0x00}); + display.run_update(); + EXPECT_EQ(display.reset_pin.pulses, pulses + 1) << "the full refresh did not reset the controller"; + EXPECT_EQ(count(bus.commands, 0x91), 0u) << "the full refresh entered partial mode"; + ASSERT_EQ(bus.data[0x10].size(), 12u); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x00, 0x00})); +} + +/// A requested full update, between partial ones, is a full refresh from scratch too. +TEST(EPaperUC8279, RequestedFullUpdateStartsAfresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.request_full_update(); + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + EXPECT_EQ(display.reset_pin.pulses, pulses + 1); + EXPECT_EQ(count(bus.commands, 0x91), 0u); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); +} + +/// Before a restart or the controller's deep sleep the panel goes into its own deep sleep, even while partial updates +/// would otherwise leave it in standby. +TEST(EPaperUC8279, ShutdownPutsThePanelToSleep) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + display.on_safe_shutdown(); + EXPECT_EQ(bus.data[0x07], (Bytes{0xA5})); +} + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 6a487064cbc..144c60dd2ff 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -435,3 +435,27 @@ display: allow_other_uses: true number: GPIO4 inverted: true + + # Xteink X4 Pro (800x480, UC8279) + # Pins overridden to avoid conflicts with the other display configs + # full_update_every > 1 exercises the partial refresh path + - platform: epaper_spi + spi_id: spi_bus + model: xteink-x4pro-uc8279 + full_update_every: 10 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); From 0f6fc503bd0b62cace4ebce4fe202f62462fc2b1 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:01:14 -0500 Subject: [PATCH 072/120] [sen6x] Add temperature compensation, acceleration and startup delay (#18781) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 43 +++++++++++-- esphome/components/sen6x/sen6x.h | 31 ++++++++- esphome/components/sen6x/sensor.py | 63 +++++++++++++++++++ tests/components/sen6x/common.yaml | 10 +++ .../components/sen6x/validate.esp32-idf.yaml | 8 ++- 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index dabf16d760b..42bc3aea6a6 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -1,6 +1,7 @@ #include "sen6x.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include #include namespace esphome::sen6x { @@ -25,7 +26,6 @@ static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; // Accepted range of a runtime pressure source, the same as the schema's ambient_pressure_compensation static constexpr float AMBIENT_PRESSURE_MIN_HPA = 700.0f; static constexpr float AMBIENT_PRESSURE_MAX_HPA = 1200.0f; -static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -45,6 +45,8 @@ static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; +static constexpr uint16_t SEN6X_CMD_TEMPERATURE_COMPENSATION = 0x60B2; +static constexpr uint16_t SEN6X_CMD_TEMPERATURE_ACCELERATION = 0x6100; static constexpr uint16_t SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL = 0x6711; static constexpr uint16_t SEN6X_CMD_AMBIENT_PRESSURE = 0x6720; static constexpr uint16_t SEN6X_CMD_SENSOR_ALTITUDE = 0x6736; @@ -200,6 +202,16 @@ bool SEN6XComponent::write_setup_step_(SetupStep step) { return false; this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); return true; + case SETUP_STEP_TEMPERATURE_COMPENSATION: + if (!this->temperature_compensation_.has_value()) + return false; + this->write_temperature_compensation_(this->temperature_compensation_.value()); + return true; + case SETUP_STEP_TEMPERATURE_ACCELERATION: + if (!this->temperature_acceleration_.has_value()) + return false; + this->write_temperature_acceleration_(this->temperature_acceleration_.value()); + return true; case SETUP_STEP_CO2_ASC: if (this->co2_sensor_ == nullptr || !this->co2_asc_.has_value()) return false; @@ -220,6 +232,18 @@ bool SEN6XComponent::write_setup_step_(SetupStep step) { } } +bool SEN6XComponent::write_temperature_compensation_(const TemperatureCompensation &compensation) { + // Word 4 selects compensation slot 0; other slots are not exposed + uint16_t params[4] = {static_cast(compensation.offset), + static_cast(compensation.normalized_offset_slope), compensation.time_constant, 0}; + return this->write_config_words_(SEN6X_CMD_TEMPERATURE_COMPENSATION, params, 4); +} + +bool SEN6XComponent::write_temperature_acceleration_(const TemperatureAcceleration &acceleration) { + uint16_t params[4] = {acceleration.k, acceleration.p, acceleration.t1, acceleration.t2}; + return this->write_config_words_(SEN6X_CMD_TEMPERATURE_ACCELERATION, params, 4); +} + bool SEN6XComponent::write_setup_register_(uint16_t i2c_command, uint16_t value) { return this->write_config_words_(i2c_command, &value, 1); } @@ -264,10 +288,21 @@ void SEN6XComponent::dump_config() { " Product: %s\n" " Serial: %s\n" " Firmware: %u.%u\n" - " Address: 0x%02X", + " Address: 0x%02X\n" + " Startup delay: %" PRIu32 " ms", this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_, - this->firmware_version_minor_, this->address_); + this->firmware_version_minor_, this->address_, this->startup_delay_ms_); LOG_UPDATE_INTERVAL(this); + if (this->temperature_compensation_.has_value()) { + const auto &comp = this->temperature_compensation_.value(); + ESP_LOGCONFIG(TAG, " Temperature compensation: offset=%.2f slope=%.4f time_constant=%us", comp.offset / 200.0f, + comp.normalized_offset_slope / 10000.0f, comp.time_constant); + } + if (this->temperature_acceleration_.has_value()) { + const auto &accel = this->temperature_acceleration_.value(); + ESP_LOGCONFIG(TAG, " Temperature acceleration: K=%.1f P=%.1f T1=%.1f T2=%.1f", accel.k / 10.0f, accel.p / 10.0f, + accel.t1 / 10.0f, accel.t2 / 10.0f); + } // setup() clears co2_sensor_ once it detects a variant without CO2 if (this->co2_sensor_ != nullptr) { if (this->co2_asc_.has_value()) { @@ -557,7 +592,7 @@ void SEN6XComponent::on_measurement_started_() { } // Values need the warm-up period again after every start this->startup_complete_ = false; - this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; }); + this->set_timeout(TIMEOUT_STARTUP, this->startup_delay_ms_, [this]() { this->startup_complete_ = true; }); } void SEN6XComponent::start_measurement() { diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 43d0ce00120..8cc187ebf2b 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -20,6 +20,21 @@ struct GasTuning { uint16_t gain_factor; }; +// Raw values in the device's fixed-point encoding (offset x200, slope x10000) +struct TemperatureCompensation { + int16_t offset; + int16_t normalized_offset_slope; + uint16_t time_constant; +}; + +// Raw values in the device's fixed-point encoding (all x10) +struct TemperatureAcceleration { + uint16_t k; + uint16_t p; + uint16_t t1; + uint16_t t2; +}; + // The CO2 settings a config may leave out: altitude is 0 to 3000 m, pressure 700 to 1200 hPa inline constexpr uint16_t ALTITUDE_UNSET = 0xFFFF; inline constexpr uint16_t PRESSURE_UNSET = 0; @@ -62,6 +77,13 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void set_startup_delay(uint32_t delay_ms) { this->startup_delay_ms_ = delay_ms; } + void set_temperature_compensation(int16_t offset, int16_t normalized_offset_slope, uint16_t time_constant) { + this->temperature_compensation_ = TemperatureCompensation{offset, normalized_offset_slope, time_constant}; + } + void set_temperature_acceleration(uint16_t k, uint16_t p, uint16_t t1, uint16_t t2) { + this->temperature_acceleration_ = TemperatureAcceleration{k, p, t1, t2}; + } void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } @@ -75,6 +97,8 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S enum SetupStep : uint8_t { SETUP_STEP_VOC_TUNING, SETUP_STEP_NOX_TUNING, + SETUP_STEP_TEMPERATURE_COMPENSATION, + SETUP_STEP_TEMPERATURE_ACCELERATION, SETUP_STEP_CO2_ASC, SETUP_STEP_ALTITUDE, SETUP_STEP_AMBIENT_PRESSURE, @@ -88,6 +112,8 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); bool write_setup_register_(uint16_t i2c_command, uint16_t value); + bool write_temperature_compensation_(const TemperatureCompensation &compensation); + bool write_temperature_acceleration_(const TemperatureAcceleration &acceleration); void write_ambient_pressure_(uint16_t pressure_hpa); void update_ambient_pressure_compensation_(float pressure_hpa); uint32_t command_wait_remaining_() const; @@ -102,14 +128,17 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string serial_number_; optional voc_tuning_params_; optional nox_tuning_params_; + optional temperature_acceleration_; + optional temperature_compensation_; + uint16_t read_cmd_{0}; sensor::Sensor *ambient_pressure_source_{nullptr}; + uint32_t startup_delay_ms_{60000}; Sen6xType sen6x_type_{UNKNOWN}; // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap uint32_t command_started_at_{0}; uint32_t command_wait_{0}; uint32_t co2_started_at_{0}; uint32_t co2_wait_{0}; - uint16_t read_cmd_{0}; uint16_t altitude_compensation_{ALTITUDE_UNSET}; uint16_t ambient_pressure_{PRESSURE_UNSET}; // Last pressure written to the device, used to skip redundant writes diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index b219caafa09..c62aa5af4cd 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -19,13 +19,18 @@ from esphome.const import ( CONF_INDEX_OFFSET, CONF_LEARNING_TIME_GAIN_HOURS, CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_NORMALIZED_OFFSET_SLOPE, CONF_NOX, + CONF_OFFSET, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_STARTUP_DELAY, CONF_STD_INITIAL, CONF_TEMPERATURE, + CONF_TEMPERATURE_COMPENSATION, + CONF_TIME_CONSTANT, CONF_TYPE, CONF_VOC, DEVICE_CLASS_CARBON_DIOXIDE, @@ -47,6 +52,17 @@ from esphome.const import ( ) from esphome.types import ConfigType +CONF_TEMPERATURE_ACCELERATION = "temperature_acceleration" +CONF_K = "k" +CONF_P = "p" +CONF_T1 = "t1" +CONF_T2 = "t2" + +# The device takes these as fixed-point words: offset x200, slope x10000, acceleration x10 +TEMPERATURE_OFFSET_SCALE = 200 +TEMPERATURE_SLOPE_SCALE = 10000 +TEMPERATURE_ACCELERATION_SCALE = 10 + CODEOWNERS = ["@martgras", "@mebner86", "@tuct"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] @@ -93,6 +109,8 @@ def _gas_index_schema( ).extend({cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema(tuning_schema)}) +_acceleration_parameter = cv.float_range(min=0.1, max=6553.5) + CONFIG_SCHEMA = cv.All( cv.rename_key(CONF_VOC, CONF_VOC_INDEX, removed_in="2027.2.0", component="sen6x"), cv.rename_key(CONF_NOX, CONF_NOX_INDEX, removed_in="2027.2.0", component="sen6x"), @@ -182,6 +200,31 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_STARTUP_DELAY, default="60s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(hours=1)), + ), + cv.Optional(CONF_TEMPERATURE_COMPENSATION): cv.Schema( + { + cv.Optional(CONF_OFFSET, default=0): cv.float_range( + min=-163.84, max=163.835 + ), + cv.Optional( + CONF_NORMALIZED_OFFSET_SLOPE, default=0 + ): cv.float_range(min=-3.2768, max=3.2767), + cv.Optional(CONF_TIME_CONSTANT, default=0): cv.int_range( + min=0, max=65535 + ), + } + ), + cv.Optional(CONF_TEMPERATURE_ACCELERATION): cv.Schema( + { + cv.Optional(CONF_K, default=20.0): _acceleration_parameter, + cv.Optional(CONF_P, default=20.0): _acceleration_parameter, + cv.Optional(CONF_T1, default=100.0): _acceleration_parameter, + cv.Optional(CONF_T2, default=300.0): _acceleration_parameter, + } + ), } ) .extend(cv.polling_component_schema("60s")) @@ -208,6 +251,26 @@ async def to_code(config: ConfigType) -> None: if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) + cg.add(var.set_startup_delay(config[CONF_STARTUP_DELAY])) + + if (comp := config.get(CONF_TEMPERATURE_COMPENSATION)) is not None: + cg.add( + var.set_temperature_compensation( + round(comp[CONF_OFFSET] * TEMPERATURE_OFFSET_SCALE), + round(comp[CONF_NORMALIZED_OFFSET_SLOPE] * TEMPERATURE_SLOPE_SCALE), + comp[CONF_TIME_CONSTANT], + ) + ) + if (accel := config.get(CONF_TEMPERATURE_ACCELERATION)) is not None: + cg.add( + var.set_temperature_acceleration( + *( + round(accel[key] * TEMPERATURE_ACCELERATION_SCALE) + for key in (CONF_K, CONF_P, CONF_T1, CONF_T2) + ) + ) + ) + for key, func_name in SENSOR_MAP.items(): if cfg := config.get(key): sens = await sensor.new_sensor(cfg) diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index eed0dd51d67..d276cecbc7c 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -8,6 +8,16 @@ sensor: id: sen6x_sensor type: SEN69C i2c_id: i2c_bus + startup_delay: 30s + temperature_compensation: + offset: -2.5 + normalized_offset_slope: 0.01 + time_constant: 60 + temperature_acceleration: + k: 25.0 + p: 15.0 + t1: 120.0 + t2: 240.0 temperature: name: Temperature accuracy_decimals: 1 diff --git a/tests/components/sen6x/validate.esp32-idf.yaml b/tests/components/sen6x/validate.esp32-idf.yaml index 3ae23af4ac4..927a8d478a9 100644 --- a/tests/components/sen6x/validate.esp32-idf.yaml +++ b/tests/components/sen6x/validate.esp32-idf.yaml @@ -1,5 +1,5 @@ -# Config-only: partial algorithm_tuning blocks, so the schema defaults fill in the -# keys that are left out. +# Config-only: partial algorithm_tuning, temperature_compensation and +# temperature_acceleration blocks, so the schema defaults fill in the keys that are left out. packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml @@ -8,6 +8,10 @@ sensor: id: sen6x_partial_tuning type: SEN65 i2c_id: i2c_bus + temperature_compensation: + offset: 1.5 + temperature_acceleration: + k: 30 voc_index: name: VOC Index algorithm_tuning: From 022733c46b6b41c21e35e35b52c6e6518fbdef97 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:16:45 -0500 Subject: [PATCH 073/120] [sen6x] Add PM number concentration sensors (#18782) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 57 ++++++++++++++++++++++++++++++ esphome/components/sen6x/sen6x.h | 12 +++++++ esphome/components/sen6x/sensor.py | 29 +++++++++++++++ esphome/core/defines.h | 1 + tests/components/sen6x/common.yaml | 10 ++++++ 5 files changed, 109 insertions(+) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 42bc3aea6a6..aaad70dd940 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include #include +#include namespace esphome::sen6x { @@ -38,6 +39,9 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN65 = 0x0446; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; +#ifdef USE_SEN6X_NUMBER_CONCENTRATION +static constexpr uint16_t SEN6X_CMD_READ_NUMBER_CONCENTRATION = 0x0316; +#endif static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; static constexpr uint16_t SEN6X_CMD_STOP_MEASUREMENTS = 0x0104; static constexpr uint16_t SEN6X_CMD_START_FAN_CLEANING = 0x5607; @@ -323,6 +327,13 @@ void SEN6XComponent::dump_config() { LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM 4.0", this->pm_4_0_sensor_); LOG_SENSOR(" ", "PM 10.0", this->pm_10_0_sensor_); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + LOG_SENSOR(" ", "PMC 0.5", this->pmc_0_5_sensor_); + LOG_SENSOR(" ", "PMC 1.0", this->pmc_1_0_sensor_); + LOG_SENSOR(" ", "PMC 2.5", this->pmc_2_5_sensor_); + LOG_SENSOR(" ", "PMC 4.0", this->pmc_4_0_sensor_); + LOG_SENSOR(" ", "PMC 10.0", this->pmc_10_0_sensor_); +#endif LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); LOG_SENSOR(" ", "VOC", this->voc_sensor_); @@ -361,6 +372,10 @@ void SEN6XComponent::update() { // -> write_command (read measurement) // -> timeout I2C_READ_DELAY // -> parse_and_publish_measurements_() + // -> with pmc_* sensors: read_number_concentration_() + // -> write_command (read number concentration) + // -> timeout I2C_READ_DELAY + // -> parse_and_publish_number_concentration_() // // All timeouts share a single ID (TIMEOUT_POLL) since only one is active // at a time. cancel_timeout in update() stops any in-flight chain. @@ -544,9 +559,51 @@ void SEN6XComponent::parse_and_publish_measurements_() { if (this->co2_sensor_ != nullptr) this->co2_sensor_->publish_state(co2); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + // The define says some device in the build has pmc sensors; this one may not + if (this->has_number_concentration_sensor_()) { + this->read_number_concentration_(); // the chain ends there, with the warning cleared + return; + } +#endif this->status_clear_warning(); } +#ifdef USE_SEN6X_NUMBER_CONCENTRATION +bool SEN6XComponent::has_number_concentration_sensor_() const { + return this->pmc_0_5_sensor_ != nullptr || this->pmc_1_0_sensor_ != nullptr || this->pmc_2_5_sensor_ != nullptr || + this->pmc_4_0_sensor_ != nullptr || this->pmc_10_0_sensor_ != nullptr; +} + +void SEN6XComponent::read_number_concentration_() { + if (!this->write_command(SEN6X_CMD_READ_NUMBER_CONCENTRATION)) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read number concentration failed (%d)", this->last_error_); + return; + } + + this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { this->parse_and_publish_number_concentration_(); }); +} + +void SEN6XComponent::parse_and_publish_number_concentration_() { + sensor::Sensor *sensors[] = {this->pmc_0_5_sensor_, this->pmc_1_0_sensor_, this->pmc_2_5_sensor_, + this->pmc_4_0_sensor_, this->pmc_10_0_sensor_}; + uint16_t measurements[std::size(sensors)]; + + if (!this->read_data(measurements, std::size(measurements))) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_); + return; + } + + for (size_t i = 0; i < std::size(sensors); i++) { + if (sensors[i] != nullptr) + sensors[i]->publish_state(measurements[i] == 0xFFFF ? NAN : measurements[i] / 10.0f); + } + this->status_clear_warning(); +} +#endif + void SEN6XComponent::write_ambient_pressure_(uint16_t pressure_hpa) { if (this->write_setup_register_(SEN6X_CMD_AMBIENT_PRESSURE, pressure_hpa)) this->last_ambient_pressure_ = pressure_hpa; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 8cc187ebf2b..f3054a8ffbd 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -44,6 +44,13 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S SUB_SENSOR(pm_2_5) SUB_SENSOR(pm_4_0) SUB_SENSOR(pm_10_0) +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + SUB_SENSOR(pmc_0_5) + SUB_SENSOR(pmc_1_0) + SUB_SENSOR(pmc_2_5) + SUB_SENSOR(pmc_4_0) + SUB_SENSOR(pmc_10_0) +#endif SUB_SENSOR(temperature) SUB_SENSOR(humidity) SUB_SENSOR(voc) @@ -123,6 +130,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + bool has_number_concentration_sensor_() const; + void read_number_concentration_(); + void parse_and_publish_number_concentration_(); +#endif std::string product_name_; std::string serial_number_; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index c62aa5af4cd..953b6529ef6 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -26,6 +26,11 @@ from esphome.const import ( CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_PMC_0_5, + CONF_PMC_1_0, + CONF_PMC_2_5, + CONF_PMC_4_0, + CONF_PMC_10_0, CONF_STARTUP_DELAY, CONF_STD_INITIAL, CONF_TEMPERATURE, @@ -40,12 +45,14 @@ from esphome.const import ( DEVICE_CLASS_PM25, DEVICE_CLASS_TEMPERATURE, ICON_CHEMICAL_WEAPON, + ICON_COUNTER, ICON_MOLECULE_CO2, ICON_RADIATOR, ICON_THERMOMETER, ICON_WATER_PERCENT, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, + UNIT_COUNTS_PER_CUBIC_CENTIMETER, UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PARTS_PER_MILLION, UNIT_PERCENT, @@ -73,6 +80,15 @@ SEN6XComponent = sen6x_ns.class_( ) +_PMC_KEYS = (CONF_PMC_0_5, CONF_PMC_1_0, CONF_PMC_2_5, CONF_PMC_4_0, CONF_PMC_10_0) +_PMC_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS_PER_CUBIC_CENTIMETER, + icon=ICON_COUNTER, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, +) + + def _gas_index_schema( *, index_offset: int, @@ -147,6 +163,11 @@ CONFIG_SCHEMA = cv.All( device_class=DEVICE_CLASS_PM10, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_PMC_0_5): _PMC_SCHEMA, + cv.Optional(CONF_PMC_1_0): _PMC_SCHEMA, + cv.Optional(CONF_PMC_2_5): _PMC_SCHEMA, + cv.Optional(CONF_PMC_4_0): _PMC_SCHEMA, + cv.Optional(CONF_PMC_10_0): _PMC_SCHEMA, cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, icon=ICON_THERMOMETER, @@ -236,6 +257,11 @@ SENSOR_MAP = { CONF_PM_2_5: "set_pm_2_5_sensor", CONF_PM_4_0: "set_pm_4_0_sensor", CONF_PM_10_0: "set_pm_10_0_sensor", + CONF_PMC_0_5: "set_pmc_0_5_sensor", + CONF_PMC_1_0: "set_pmc_1_0_sensor", + CONF_PMC_2_5: "set_pmc_2_5_sensor", + CONF_PMC_4_0: "set_pmc_4_0_sensor", + CONF_PMC_10_0: "set_pmc_10_0_sensor", CONF_TEMPERATURE: "set_temperature_sensor", CONF_HUMIDITY: "set_humidity_sensor", CONF_VOC_INDEX: "set_voc_sensor", @@ -250,6 +276,9 @@ async def to_code(config: ConfigType) -> None: if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) + # The number concentration read and its sensors compile in only when one is configured + if any(key in config for key in _PMC_KEYS): + cg.add_define("USE_SEN6X_NUMBER_CONCENTRATION") cg.add(var.set_startup_delay(config[CONF_STARTUP_DELAY])) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 13dc3492f29..1fa6f7822a5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -275,6 +275,7 @@ #define USE_SCRIPT_ON_STOP #define USE_SELECT #define USE_SELEC_METER_EM4M +#define USE_SEN6X_NUMBER_CONCENTRATION #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index d276cecbc7c..6e39256d159 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -40,6 +40,16 @@ sensor: name: PM <10µm Weight concentration id: sen6x_pm_10_0 accuracy_decimals: 1 + pmc_0_5: + name: PM <0.5µm Number concentration + pmc_1_0: + name: PM <1µm Number concentration + pmc_2_5: + name: PM <2.5µm Number concentration + pmc_4_0: + name: PM <4µm Number concentration + pmc_10_0: + name: PM <10µm Number concentration nox_index: name: NOx Index algorithm_tuning: From 1a28a9220ba1f77f8d9576562adb0baacba3ed0c Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 18 Oct 2026 00:35:46 +0200 Subject: [PATCH 074/120] [uart] Add a binary sensor that fires on a byte sequence (#14032) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/uart/binary_sensor/__init__.py | 101 ++++++++++++++++++ .../uart/binary_sensor/uart_binary_sensor.cpp | 50 +++++++++ .../uart/binary_sensor/uart_binary_sensor.h | 37 +++++++ esphome/core/defines.h | 1 + esphome/core/helpers.h | 8 ++ tests/components/uart/test.esp32-idf.yaml | 10 ++ tests/components/uart/test.esp8266-ard.yaml | 10 ++ 7 files changed, 217 insertions(+) create mode 100644 esphome/components/uart/binary_sensor/__init__.py create mode 100644 esphome/components/uart/binary_sensor/uart_binary_sensor.cpp create mode 100644 esphome/components/uart/binary_sensor/uart_binary_sensor.h diff --git a/esphome/components/uart/binary_sensor/__init__.py b/esphome/components/uart/binary_sensor/__init__.py new file mode 100644 index 00000000000..07ab31ab19c --- /dev/null +++ b/esphome/components/uart/binary_sensor/__init__.py @@ -0,0 +1,101 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor, uart +import esphome.config_validation as cv +from esphome.const import CONF_DATA, CONF_UART_ID +from esphome.core import CORE, ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import uart_ns, validate_raw_data + +DEPENDENCIES = ["uart"] +DOMAIN = "uart_binary_sensor" + +CONF_HUB_ID = "hub_id" + +UARTBinarySensorHub = uart_ns.class_( + "UARTBinarySensorHub", uart.UARTDevice, cg.Component +) + +# Every sensor on a bus is one matcher of that bus's hub; the define is the largest hub +_request_matcher_slot = cg.slot_counter("UART_BINARY_SENSOR_COUNT") + +CONFIG_SCHEMA = ( + # Initially off, so the first sequence after boot is an OFF to ON edge for on_press + binary_sensor.binary_sensor_schema(binary_sensor.BinarySensorInitiallyOff) + .extend( + { + cv.GenerateID(CONF_HUB_ID): cv.declare_id(UARTBinarySensorHub), + # An empty pattern would match every byte; the hub's buffer index is a byte + cv.Required(CONF_DATA): cv.All( + validate_raw_data, cv.Length(min=1, max=255) + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +_validate_bus = uart.final_validate_device_schema("uart binary_sensor", require_rx=True) + + +def _one_hub_per_bus(config: ConfigType) -> ConfigType: + """The first matching sensor takes the bytes and clears them, so a sequence that contains + another on the same bus would leave one of them dead; and the sensors of a bus share the hub + the first one creates, so only that one can name it and claim the bus's receive side.""" + data = bytes(config[CONF_DATA]) + first = True + for other in fv.full_config.get().get("binary_sensor", []): + if ( + other.get("platform") != "uart" + or other[CONF_UART_ID] != config[CONF_UART_ID] + ): + continue + if other is config: + break # Every sensor before this one on the bus has been seen + first = False + other_data = bytes(other[CONF_DATA]) + if other_data in data or data in other_data: + raise cv.Invalid( + f"Another uart binary_sensor on '{config[CONF_UART_ID]}' has data this pattern " + "would match as well" + ) + if config[CONF_HUB_ID].is_manual: + raise cv.Invalid( + f"Set hub_id on the first uart binary_sensor of '{config[CONF_UART_ID]}'; " + "the others share its hub" + ) + if first: + _validate_bus(config) + return config + + +FINAL_VALIDATE_SCHEMA = _one_hub_per_bus + + +def _hubs() -> dict[ID, cg.MockObj]: + """The hub of each bus, keyed by the bus id.""" + return CORE.data.setdefault(DOMAIN, {}) + + +async def _hub_for(config: ConfigType) -> cg.MockObj: + """The one hub that reads the bus for every sensor on it, created by the first sensor.""" + hubs = _hubs() + if (hub := hubs.get(config[CONF_UART_ID])) is None: + hub = hubs[config[CONF_UART_ID]] = cg.new_Pvariable(config[CONF_HUB_ID]) + await cg.register_component(hub, config) + await uart.register_uart_device(hub, config) + return hub + + +async def to_code(config: ConfigType) -> None: + hub = await _hub_for(config) + var = await binary_sensor.new_binary_sensor(config) + data = config[CONF_DATA] + _request_matcher_slot(str(hub)) + cg.add( + hub.add_event_matcher( + var, automation.progmem_bytes("uart_binary_sensor_data", data), len(data) + ) + ) diff --git a/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp new file mode 100644 index 00000000000..9bf9e726561 --- /dev/null +++ b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp @@ -0,0 +1,50 @@ +#include "uart_binary_sensor.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::uart { + +ESPHOME_LOG_TAG(TAG, "uart.binary_sensor"); + +void UARTBinarySensorHub::dump_config() { + ESP_LOGCONFIG(TAG, "UART Binary Sensor:"); + for (const auto &matcher : this->matchers_) { + LOG_BINARY_SENSOR(" ", "Pattern", matcher.sensor); + } +} + +void UARTBinarySensorHub::loop() { + while (this->available()) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + this->buffer_.push_overwrite(byte); + for (const auto &matcher : this->matchers_) { + // The byte just read has to be the pattern's last one before the rest is compared + if (progmem_read_byte(&matcher.data[matcher.data_len - 1]) != byte || !this->matches_(matcher)) { + continue; + } + // A momentary press, as the remote receiver binary sensors publish theirs + matcher.sensor->publish_state(true); + matcher.sensor->publish_state(false); + this->buffer_.clear(); + break; + } + } +} + +// Whether the pattern, apart from its last byte, ends the buffer +bool UARTBinarySensorHub::matches_(const EventMatcher &matcher) const { + if (this->buffer_.size() < matcher.data_len) { + return false; + } + const size_t start = this->buffer_.size() - matcher.data_len; + for (size_t i = 0; i + 1 < matcher.data_len; i++) { + if (this->buffer_[start + i] != progmem_read_byte(&matcher.data[i])) { + return false; + } + } + return true; +} + +} // namespace esphome::uart diff --git a/esphome/components/uart/binary_sensor/uart_binary_sensor.h b/esphome/components/uart/binary_sensor/uart_binary_sensor.h new file mode 100644 index 00000000000..71ccda9ef21 --- /dev/null +++ b/esphome/components/uart/binary_sensor/uart_binary_sensor.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +// One per bus: reads the bytes once and presses each sensor whose pattern ends the stream +class UARTBinarySensorHub : public UARTDevice, public Component { + public: + void setup() override { this->buffer_.init(this->max_len_); } + void loop() override; + void dump_config() override; + + // The pattern lives in flash; called once per sensor by the generated code + void add_event_matcher(binary_sensor::BinarySensor *sensor, const uint8_t *data, uint8_t data_len) { + this->matchers_.push_back({sensor, data, data_len}); + this->max_len_ = std::max(this->max_len_, data_len); + } + + protected: + struct EventMatcher { + binary_sensor::BinarySensor *sensor; + const uint8_t *data; + uint8_t data_len; + }; + + bool matches_(const EventMatcher &matcher) const; + + StaticVector matchers_; + FixedRingBuffer buffer_; + uint8_t max_len_{0}; +}; + +} // namespace esphome::uart diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1fa6f7822a5..24f66a87518 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -167,6 +167,7 @@ #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 +#define UART_BINARY_SENSOR_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define MICS_5524_GAS_COUNT 5 #define USE_MICS_5524_DATASHEET diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 72bd3a0c6ba..2989655a29a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -576,6 +576,14 @@ template::max()> ConstIterator begin() const { return ConstIterator(this, 0); } ConstIterator end() const { return ConstIterator(this, this->count_); } + /// The element `pos` places after the oldest one; the sum is widened so a small index type cannot truncate it + const T &operator[](index_type pos) const { + size_t index = static_cast(this->head_) + pos; + if (index >= this->capacity_) + index -= this->capacity_; + return this->data_[index]; + } + protected: T *data_{nullptr}; index_type head_{0}; diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index 3bb87aa3cc7..02b53a89e44 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -120,3 +120,13 @@ event: event_types: - "string_event_A": "*A#" - "bytes_event_B": [0x2A, 0x42, 0x23] + +binary_sensor: + - platform: uart + id: up + uart_id: uart_id + data: [0x1B, 0x5B, 0x41] + - platform: uart + id: down + uart_id: uart_id + data: [0x1B, 0x5B, 0x42] diff --git a/tests/components/uart/test.esp8266-ard.yaml b/tests/components/uart/test.esp8266-ard.yaml index 09d23e2925e..b8f07fa9d62 100644 --- a/tests/components/uart/test.esp8266-ard.yaml +++ b/tests/components/uart/test.esp8266-ard.yaml @@ -55,3 +55,13 @@ event: event_types: - "string_event_A": "*A#" - "bytes_event_B": [0x2A, 0x42, 0x23] + +binary_sensor: + - platform: uart + id: up + uart_id: uart_id + data: [0x1B, 0x5B, 0x41] + - platform: uart + id: down + uart_id: uart_id + data: [0x1B, 0x5B, 0x42] From 818d43f742e3fc18c22bb6d2e41f46d1e0debb73 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 17 Oct 2026 18:25:36 -0500 Subject: [PATCH 075/120] [epaper_spi] Run the loop continuously between the steps of an update (#20554) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/epaper_spi.cpp | 27 ++- esphome/components/epaper_spi/epaper_spi.h | 4 + tests/components/epaper_spi/common.h | 8 + .../epaper_spi/display/test_busy_timeout.cpp | 11 +- .../epaper_spi/display/test_fast_loop.cpp | 212 ++++++++++++++++++ 5 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_fast_loop.cpp diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 71e87e038b6..fccb5325a7f 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -133,7 +133,9 @@ void EPaperBase::update() { } void EPaperBase::wait_for_idle_(bool should_wait) { - this->waiting_for_idle_start_ = millis(); + // The wait starts when the step's delay ends, or now for a wait in the middle of a step + const uint32_t now = millis(); + this->waiting_for_idle_start_ = static_cast(this->delay_until_ - now) > 0 ? this->delay_until_ : now; this->waiting_for_idle_ = should_wait; } @@ -147,19 +149,30 @@ void EPaperBase::loop() { auto now = millis(); // using modulus arithmetic to handle wrap-around int diff = now - this->delay_until_; - if (diff < 0) + if (diff < 0) { + // Running the loop continuously cannot shorten a delay longer than one loop interval + if (-diff > static_cast(App.get_loop_interval())) { + this->high_frequency_loop_.stop(); + } else { + this->high_frequency_loop_.start(); + } return; + } if (this->waiting_for_idle_) { if (this->is_idle_()) { this->waiting_for_idle_ = false; + this->high_frequency_loop_.start(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); #endif } else { - if (now - this->waiting_for_idle_start_ > this->busy_timeout_seconds_ * 1000u) { + const uint32_t busy_for = now - this->waiting_for_idle_start_; + if (busy_for > this->busy_timeout_seconds_ * 1000u) { this->abandon_update_(); return; } + if (busy_for > FAST_BUSY_POLL_MS) + this->high_frequency_loop_.stop(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE if (now - this->waiting_for_idle_last_print_ >= 1000) { ESP_LOGV(TAG, "Waiting for idle in state %s", this->epaper_state_to_string_()); @@ -170,6 +183,8 @@ void EPaperBase::loop() { } } this->process_state_(); + if (this->is_failed()) + this->high_frequency_loop_.stop(); } // The busy pin never cleared: give up on this update so later ones are not refused, and make the next one full @@ -275,16 +290,20 @@ void EPaperBase::process_state_() { void EPaperBase::set_state_(EPaperState state, uint16_t delay) { ESP_LOGV(TAG, "Exit state %s", this->epaper_state_to_string_()); this->state_ = state; - this->wait_for_idle_(state > EPaperState::SHOULD_WAIT); // allow subclasses to nominate delays if (delay == 0) delay = this->next_delay_; this->next_delay_ = 0; this->delay_until_ = millis() + delay; + this->wait_for_idle_(state > EPaperState::SHOULD_WAIT); ESP_LOGV(TAG, "Enter state %s, delay %u, wait_for_idle=%s", this->epaper_state_to_string_(), delay, TRUEFALSE(this->waiting_for_idle_)); + // Each step of an update otherwise waits for the next loop iteration; loop() slows down again for long waits if (state == EPaperState::IDLE) { + this->high_frequency_loop_.stop(); this->disable_loop(); + } else { + this->high_frequency_loop_.start(); } } diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index d953bf3df81..d6fe4238800 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -5,6 +5,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/split_buffer/split_buffer.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome::epaper_spi { using namespace display; @@ -31,6 +32,8 @@ static constexpr uint8_t SWAP_XY = 4; static constexpr uint32_t MAX_TRANSFER_TIME = 10; // Transfer in 10ms blocks to allow the loop to run static constexpr size_t MAX_TRANSFER_SIZE = 128; +// Longer busy waits are the waveform or a stuck pin, and are polled at the normal loop pace +static constexpr uint32_t FAST_BUSY_POLL_MS = 50; static constexpr uint8_t DELAY_FLAG = 0xFF; class EPaperBase : public Display, @@ -202,6 +205,7 @@ class EPaperBase : public Display, GPIOPin *reset_pin_{}; std::vector enable_pins_{}; bool waiting_for_idle_{}; + HighFrequencyLoopRequester high_frequency_loop_; uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state uint8_t transform_{}; diff --git a/tests/components/epaper_spi/common.h b/tests/components/epaper_spi/common.h index f21e674fde2..9631f1e0776 100644 --- a/tests/components/epaper_spi/common.h +++ b/tests/components/epaper_spi/common.h @@ -50,6 +50,14 @@ class RecordingPin : public GPIOPin { bool level{true}; }; +/// A busy pin the test can hold at "busy" and release. +class SwitchableBusyPin : public RecordingPin { + public: + bool digital_read() override { return this->busy; } + + bool busy{false}; +}; + /// SPI delegate that records what reaches the bus, filing each payload under the command it /// followed; commands are the bytes written while D/C is low. Optionally burns wall-clock time on /// each data row, so a transfer can be driven past its yield deadline. diff --git a/tests/components/epaper_spi/display/test_busy_timeout.cpp b/tests/components/epaper_spi/display/test_busy_timeout.cpp index 7080619b2ff..533c795f272 100644 --- a/tests/components/epaper_spi/display/test_busy_timeout.cpp +++ b/tests/components/epaper_spi/display/test_busy_timeout.cpp @@ -7,14 +7,8 @@ #include "esphome/components/epaper_spi/epaper_spi_t133a01.h" namespace esphome::epaper_spi::testing { - -/// A busy pin the test can hold at "busy" and release. -class SwitchableBusyPin : public RecordingPin { - public: - bool digital_read() override { return this->busy; } - - bool busy{false}; -}; +// Other test files define fixtures with the same names; keep these to this file +namespace { template void loop_until_idle(T &display) { const uint32_t deadline = millis() + 1000; @@ -142,4 +136,5 @@ TEST(EPaperBusyTimeout, AbandonedUpdatePowersThePanelOff) { << "the panel was not powered off (POF) when the update was abandoned"; } +} // namespace } // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_fast_loop.cpp b/tests/components/epaper_spi/display/test_fast_loop.cpp new file mode 100644 index 00000000000..3a2a905281e --- /dev/null +++ b/tests/components/epaper_spi/display/test_fast_loop.cpp @@ -0,0 +1,212 @@ +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" + +namespace esphome::epaper_spi::testing { +// Other test files define fixtures with the same names; keep these to this file +namespace { + +constexpr uint8_t MALFORMED_INIT_SEQUENCE[] = {0x01}; + +class TestableSSD1683 : public EPaperSSD1683 { + public: + explicit TestableSSD1683(const uint8_t *init_sequence = nullptr, size_t init_sequence_length = 0) + : EPaperSSD1683("test", 16, 2, init_sequence, init_sequence_length) {} + // The request count is shared by the whole test binary, so never leave it raised + ~TestableSSD1683() { this->high_frequency_loop_.stop(); } + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_busy_pin(&this->busy); + this->set_auto_clear(false); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void draw_whole_panel() { + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + void draw_nothing() { + this->x_low_ = this->width_; + this->y_low_ = this->height_; + this->x_high_ = 0; + this->y_high_ = 0; + } + + /// Runs the loop for the given time, or until the display is idle. + void loop_for(uint32_t ms) { + const uint32_t until = millis() + ms; + while (this->state_ != EPaperState::IDLE && millis() < until) + this->loop(); + } + + EPaperState state() const { return this->state_; } + + RecordingPin dc, reset; + SwitchableBusyPin busy; +}; + +/// With no long waits the loop runs continuously from the start of an update to its end, then goes back to its +/// normal pace. +TEST(EPaperFastLoop, LoopRunsContinuouslyForShortStepsAndStopsAfterward) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + ASSERT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.draw_whole_panel(); + display.update(); + bool always_fast = true; + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::IDLE && millis() < deadline) { + always_fast &= HighFrequencyLoopRequester::is_high_frequency(); + display.loop(); + } + + ASSERT_EQ(display.state(), EPaperState::IDLE); + EXPECT_TRUE(always_fast); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()) << "the loop was left running continuously"; +} + +/// An update with nothing drawn ends straight away and does not leave the loop running continuously. +TEST(EPaperFastLoop, UpdateWithNothingDrawnStopsTheFastLoop) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.draw_nothing(); + display.update(); + display.loop(); + + ASSERT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A display marked failed part way through an update is no longer looped, so it must stop the fast loop itself. +TEST(EPaperFastLoop, FailedDisplayStopsTheFastLoop) { + TestableSSD1683 display(MALFORMED_INIT_SEQUENCE, sizeof(MALFORMED_INIT_SEQUENCE)); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (!display.is_failed() && millis() < deadline) + display.loop(); + + ASSERT_TRUE(display.is_failed()); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A long busy wait, such as the waveform or a stuck busy pin, is polled at the normal pace. Once the pin clears, +/// the next steps run continuously again. +TEST(EPaperFastLoop, LongBusyWaitUsesTheNormalPace) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.busy.busy = true; + display.draw_whole_panel(); + display.update(); + display.loop_for(FAST_BUSY_POLL_MS + 20); + + ASSERT_NE(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.busy.busy = false; + display.loop(); + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); + display.loop_for(1000); + EXPECT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A delay longer than a loop interval, such as a long reset pulse, is waited out at the normal pace. +TEST(EPaperFastLoop, LongDelayUsesTheNormalPace) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_reset_pin(&display.reset); + display.set_reset_duration(200); + + display.draw_whole_panel(); + display.update(); + display.loop_for(50); + + ASSERT_EQ(display.state(), EPaperState::RESET_END); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.loop_for(1000); + EXPECT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A step that takes several loop passes gets the fast loop back once a long busy wait before it has ended. +TEST(EPaperFastLoop, FastLoopResumesWhenALongBusyWaitEnds) { + TestableSSD1683 display; + // every row of data overruns a transfer slice, so the transfer takes a loop pass per row + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); + display.install(&bus); + + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::TRANSFER_DATA && millis() < deadline) + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + display.busy.busy = true; + display.loop_for(FAST_BUSY_POLL_MS + 20); + ASSERT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.busy.busy = false; + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA) << "the transfer should take more than one pass"; + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); +} + +class TestableT133A01 : public EPaperT133A01 { + public: + TestableT133A01() : EPaperT133A01("test", 8, 2, nullptr, 0) {} + ~TestableT133A01() { this->high_frequency_loop_.stop(); } + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_cs_pins(&this->cs, &this->cs1); + this->set_busy_pin(&this->busy); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void start_transfer() { this->set_state_(EPaperState::TRANSFER_DATA); } + + RecordingPin dc, cs, cs1; + SwitchableBusyPin busy; +}; + +/// A busy wait that starts in the middle of a step is polled continuously for its first moments, however long +/// the step has already run. +TEST(EPaperFastLoop, WaitInTheMiddleOfAStepIsPolledFast) { + TestableT133A01 display; + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + display.start_transfer(); + const uint32_t until = millis() + FAST_BUSY_POLL_MS + 20; + while (millis() < until) { + } + // the transfer waits for the busy pin after its first command, then sends a row and yields + display.loop(); + display.busy.busy = true; + display.loop(); + + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); +} + +} // namespace +} // namespace esphome::epaper_spi::testing From d1ec52e95a7506bbb4365d1f8db3aa37234e6df6 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:34:15 -0500 Subject: [PATCH 076/120] [sen6x] Add device status binary sensors (#18784) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/binary_sensor.py | 53 +++++++++++++ esphome/components/sen6x/sen6x.cpp | 90 ++++++++++++++++++++++- esphome/components/sen6x/sen6x.h | 35 +++++++++ esphome/core/defines.h | 1 + tests/components/sen6x/common.yaml | 18 +++++ 5 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sen6x/binary_sensor.py diff --git a/esphome/components/sen6x/binary_sensor.py b/esphome/components/sen6x/binary_sensor.py new file mode 100644 index 00000000000..9662e65a378 --- /dev/null +++ b/esphome/components/sen6x/binary_sensor.py @@ -0,0 +1,53 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .sensor import SEN6XComponent, sen6x_ns + +CONF_SEN6X_ID = "sen6x_id" +CONF_FAN_ERROR = "fan_error" +CONF_FAN_SPEED_WARNING = "fan_speed_warning" +CONF_RHT_ERROR = "rht_error" +CONF_GAS_ERROR = "gas_error" +CONF_CO2_ERROR = "co2_error" +CONF_HCHO_ERROR = "hcho_error" +CONF_PM_ERROR = "pm_error" + +Sen6xStatus = sen6x_ns.enum("Sen6xStatus", is_class=True) + +# Each key watches one bit of the device status register +_STATUS_KINDS = ( + (CONF_FAN_ERROR, Sen6xStatus.SEN6X_STATUS_FAN_ERROR), + (CONF_FAN_SPEED_WARNING, Sen6xStatus.SEN6X_STATUS_FAN_SPEED_WARNING), + (CONF_RHT_ERROR, Sen6xStatus.SEN6X_STATUS_RHT_ERROR), + (CONF_GAS_ERROR, Sen6xStatus.SEN6X_STATUS_GAS_ERROR), + (CONF_CO2_ERROR, Sen6xStatus.SEN6X_STATUS_CO2_ERROR), + (CONF_HCHO_ERROR, Sen6xStatus.SEN6X_STATUS_HCHO_ERROR), + (CONF_PM_ERROR, Sen6xStatus.SEN6X_STATUS_PM_ERROR), +) + +_request_status_slot = cg.slot_counter("SEN6X_STATUS_SENSOR_COUNT") + +_STATUS_SCHEMA = binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_SEN6X_ID): cv.use_id(SEN6XComponent), + **{cv.Optional(key): _STATUS_SCHEMA for key, _ in _STATUS_KINDS}, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_SEN6X_ID]) + for key, status in _STATUS_KINDS: + if (conf := config.get(key)) is None: + continue + sens = await binary_sensor.new_binary_sensor(conf) + _request_status_slot(str(hub)) + cg.add(hub.add_status_binary_sensor(sens, status)) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index aaad70dd940..9f95ed192c0 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -9,6 +9,29 @@ namespace esphome::sen6x { ESPHOME_LOG_TAG(TAG, "sen6x"); +#ifdef SEN6X_STATUS_SENSOR_COUNT +// Bit of the status register behind each kind; the CO2 error bit is 9 on SEN66 and 12 on SEN63C/SEN69C +static uint8_t status_bit(Sen6xStatus status, SEN6XComponent::Sen6xType type) { + switch (status) { + case Sen6xStatus::SEN6X_STATUS_FAN_ERROR: + return 4; + case Sen6xStatus::SEN6X_STATUS_FAN_SPEED_WARNING: + return 21; + case Sen6xStatus::SEN6X_STATUS_RHT_ERROR: + return 6; + case Sen6xStatus::SEN6X_STATUS_GAS_ERROR: + return 7; + case Sen6xStatus::SEN6X_STATUS_CO2_ERROR: + return type == SEN6XComponent::SEN66 ? 9 : 12; + case Sen6xStatus::SEN6X_STATUS_HCHO_ERROR: + return 10; + case Sen6xStatus::SEN6X_STATUS_PM_ERROR: + return 11; + } + return 0; +} +#endif + static constexpr uint8_t POLL_RETRIES = 24; // 24 attempts static constexpr uint32_t I2C_READ_DELAY = 20; // 20 ms to wait for I2C read to complete static constexpr uint32_t CMD_EXEC_DELAY = 20; // execution time of set commands (datasheet section 4.8) @@ -31,6 +54,7 @@ static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; static constexpr uint16_t SEN6X_CMD_GET_SERIAL_NUMBER = 0xD033; +static constexpr uint16_t SEN6X_CMD_READ_AND_CLEAR_DEVICE_STATUS = 0xD210; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT = 0x0300; // SEN66 only! static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN62 = 0x04A3; @@ -158,6 +182,22 @@ void SEN6XComponent::setup() { ESP_LOGE(TAG, "Formaldehyde requires SEN68 or SEN69C"); this->hcho_sensor_ = nullptr; } +#ifdef SEN6X_STATUS_SENSOR_COUNT + // A status sensor for a feature this variant lacks is dropped like the measurement sensors above, so + // an empty table skips the status read; the kept ones learn their register bit here + size_t kept = 0; + for (auto entry : this->status_sensors_) { + if ((entry.status == Sen6xStatus::SEN6X_STATUS_GAS_ERROR && !has_voc_nox) || + (entry.status == Sen6xStatus::SEN6X_STATUS_CO2_ERROR && !has_co2) || + (entry.status == Sen6xStatus::SEN6X_STATUS_HCHO_ERROR && !has_hcho)) { + ESP_LOGE(TAG, "'%s' needs a variant with that feature", entry.sensor->get_name().c_str()); + continue; + } + entry.bit = status_bit(entry.status, this->sen6x_type_); + this->status_sensors_[kept++] = entry; + } + this->status_sensors_.resize(kept); +#endif // Step 3: Read firmware version and start measurements in next loop iteration this->set_timeout(0, [this]() { @@ -340,6 +380,11 @@ void SEN6XComponent::dump_config() { LOG_SENSOR(" ", "NOx", this->nox_sensor_); LOG_SENSOR(" ", "HCHO", this->hcho_sensor_); LOG_SENSOR(" ", "CO2", this->co2_sensor_); +#ifdef SEN6X_STATUS_SENSOR_COUNT + for (const auto &entry : this->status_sensors_) { + LOG_BINARY_SENSOR(" ", "Device status", entry.sensor); + } +#endif } void SEN6XComponent::update() { @@ -363,6 +408,11 @@ void SEN6XComponent::update() { // Polling uses chained timeouts to guarantee each I2C operation completes // before the next begins. The flow is: // + // read_device_status_() + // with status sensors: write_command (read and clear device status) + // -> timeout I2C_READ_DELAY -> parse_and_publish_device_status_() + // -> poll_data_ready_() + // // poll_data_ready_() // -> write_command (data ready status) // -> timeout I2C_READ_DELAY @@ -380,10 +430,13 @@ void SEN6XComponent::update() { // All timeouts share a single ID (TIMEOUT_POLL) since only one is active // at a time. cancel_timeout in update() stops any in-flight chain. this->poll_retries_remaining_ = POLL_RETRIES; - this->poll_data_ready_(); + this->read_device_status_(); } void SEN6XComponent::poll_data_ready_() { + // stop_measurement() may have run from a publish callback while the chain was in flight + if (!this->measuring_) + return; if (this->poll_retries_remaining_ == 0) { this->status_set_warning(); ESP_LOGD(TAG, "Data not ready"); @@ -569,6 +622,41 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } +// Reads the status register first, so the error sensors update even when the measurement read fails, then runs +// the measurement poll chain. Read And Clear (0xD210) is used because error flags are sticky on the device; +// clearing each cycle makes the sensors report whether a problem occurred since the last update. +void SEN6XComponent::read_device_status_() { +#ifdef SEN6X_STATUS_SENSOR_COUNT + if (!this->status_sensors_.empty()) { + // The pressure write in update() may still be executing; the device rejects commands until it is done + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->read_device_status_(); }); + return; + } + if (this->write_command(SEN6X_CMD_READ_AND_CLEAR_DEVICE_STATUS)) { + this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { this->parse_and_publish_device_status_(); }); + return; + } + ESP_LOGD(TAG, "Read device status failed (%d)", this->last_error_); + } +#endif + this->poll_data_ready_(); +} + +#ifdef SEN6X_STATUS_SENSOR_COUNT +void SEN6XComponent::parse_and_publish_device_status_() { + uint16_t words[2]; + if (this->read_data(words, 2)) { + const uint32_t status = (static_cast(words[0]) << 16) | words[1]; + for (const auto &entry : this->status_sensors_) + entry.sensor->publish_state(((status >> entry.bit) & 1) != 0); + } else { + ESP_LOGD(TAG, "Read device status failed (%d)", this->last_error_); + } + this->poll_data_ready_(); +} +#endif + #ifdef USE_SEN6X_NUMBER_CONCENTRATION bool SEN6XComponent::has_number_concentration_sensor_() const { return this->pmc_0_5_sensor_ != nullptr || this->pmc_1_0_sensor_ != nullptr || this->pmc_2_5_sensor_ != nullptr || diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index f3054a8ffbd..e73f93f5a4c 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -4,9 +4,32 @@ #include "esphome/core/optional.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" +#ifdef SEN6X_STATUS_SENSOR_COUNT +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/helpers.h" +#endif namespace esphome::sen6x { +#ifdef SEN6X_STATUS_SENSOR_COUNT +// Bits of the device status register (datasheet section 4.3) a binary sensor can watch +enum class Sen6xStatus : uint8_t { + SEN6X_STATUS_FAN_ERROR, + SEN6X_STATUS_FAN_SPEED_WARNING, + SEN6X_STATUS_RHT_ERROR, + SEN6X_STATUS_GAS_ERROR, + SEN6X_STATUS_CO2_ERROR, + SEN6X_STATUS_HCHO_ERROR, + SEN6X_STATUS_PM_ERROR, +}; + +struct StatusSensor { + binary_sensor::BinarySensor *sensor; + Sen6xStatus status; + uint8_t bit; // of the status register, set at setup once the variant is known +}; +#endif + // The NOx algorithm requires std_initial to stay at 50 (Sensirion datasheet) static constexpr uint16_t NOX_STD_INITIAL = 50; @@ -91,6 +114,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void set_temperature_acceleration(uint16_t k, uint16_t p, uint16_t t1, uint16_t t2) { this->temperature_acceleration_ = TemperatureAcceleration{k, p, t1, t2}; } +#ifdef SEN6X_STATUS_SENSOR_COUNT + void add_status_binary_sensor(binary_sensor::BinarySensor *sensor, Sen6xStatus status) { + this->status_sensors_.push_back({sensor, status, 0}); + } +#endif void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } @@ -130,6 +158,10 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); + void read_device_status_(); +#ifdef SEN6X_STATUS_SENSOR_COUNT + void parse_and_publish_device_status_(); +#endif #ifdef USE_SEN6X_NUMBER_CONCENTRATION bool has_number_concentration_sensor_() const; void read_number_concentration_(); @@ -138,6 +170,9 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string product_name_; std::string serial_number_; +#ifdef SEN6X_STATUS_SENSOR_COUNT + StaticVector status_sensors_; +#endif optional voc_tuning_params_; optional nox_tuning_params_; optional temperature_acceleration_; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 24f66a87518..935952d3b31 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -167,6 +167,7 @@ #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 +#define SEN6X_STATUS_SENSOR_COUNT 1 #define UART_BINARY_SENSOR_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define MICS_5524_GAS_COUNT 5 diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 6e39256d159..f6fee2cdf58 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -77,6 +77,24 @@ sensor: name: Formaldehyde address: 0x6B +binary_sensor: + - platform: sen6x + sen6x_id: sen6x_sensor + fan_error: + name: Fan Error + fan_speed_warning: + name: Fan Speed Warning + rht_error: + name: RHT Error + gas_error: + name: Gas Error + co2_error: + name: CO2 Error + hcho_error: + name: HCHO Error + pm_error: + name: PM Error + button: - platform: template name: Fan Cleaning From 89afb111ad86c087a7d60a0171068d610acaf318 Mon Sep 17 00:00:00 2001 From: C H R I S T I A N Date: Sun, 18 Oct 2026 02:06:46 +0200 Subject: [PATCH 077/120] [mt6701] Add MT6701 (MagnTek magnetic encoder) component (#17643) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/mt6701/__init__.py | 9 + esphome/components/mt6701/mt6701.cpp | 28 +++ esphome/components/mt6701/mt6701.h | 33 +++ esphome/components/mt6701/sensor/__init__.py | 51 +++++ .../mt6701/sensor/mt6701_sensor.cpp | 23 ++ .../components/mt6701/sensor/mt6701_sensor.h | 19 ++ esphome/components/mt6701_i2c/__init__.py | 202 ++++++++++++++++++ esphome/components/mt6701_i2c/mt6701_i2c.cpp | 184 ++++++++++++++++ esphome/components/mt6701_i2c/mt6701_i2c.h | 79 +++++++ esphome/components/mt6701_spi/__init__.py | 47 ++++ .../mt6701_spi/binary_sensor/__init__.py | 46 ++++ .../mt6701_spi_binary_sensors.cpp | 29 +++ .../binary_sensor/mt6701_spi_binary_sensors.h | 25 +++ esphome/components/mt6701_spi/mt6701_spi.cpp | 67 ++++++ esphome/components/mt6701_spi/mt6701_spi.h | 41 ++++ .../mt6701_spi/text_sensor/__init__.py | 36 ++++ .../text_sensor/mt6701_spi_field_status.cpp | 28 +++ .../text_sensor/mt6701_spi_field_status.h | 19 ++ tests/component_tests/mt6701/__init__.py | 0 .../mt6701/config/mt6701_i2c_modes.yaml | 19 ++ .../config/mt6701_i2c_modes_uvw_pwm.yaml | 21 ++ tests/component_tests/mt6701/test_mt6701.py | 95 ++++++++ tests/components/mt6701_i2c/common.yaml | 33 +++ .../components/mt6701_i2c/test.esp32-ard.yaml | 3 + .../mt6701_i2c/test.esp32-c3-idf.yaml | 3 + .../components/mt6701_i2c/test.esp32-idf.yaml | 3 + .../mt6701_i2c/test.esp8266-ard.yaml | 3 + tests/components/mt6701_i2c/test.host.yaml | 3 + .../mt6701_i2c/test.rp2040-ard.yaml | 3 + tests/components/mt6701_spi/__init__.py | 6 + tests/components/mt6701_spi/common.yaml | 27 +++ .../mt6701_spi/mt6701_spi_frame_test.cpp | 96 +++++++++ .../components/mt6701_spi/test.esp32-ard.yaml | 6 + .../mt6701_spi/test.esp32-c3-idf.yaml | 6 + .../components/mt6701_spi/test.esp32-idf.yaml | 6 + .../mt6701_spi/test.esp8266-ard.yaml | 6 + .../mt6701_spi/test.rp2040-ard.yaml | 6 + 38 files changed, 1314 insertions(+) create mode 100644 esphome/components/mt6701/__init__.py create mode 100644 esphome/components/mt6701/mt6701.cpp create mode 100644 esphome/components/mt6701/mt6701.h create mode 100644 esphome/components/mt6701/sensor/__init__.py create mode 100644 esphome/components/mt6701/sensor/mt6701_sensor.cpp create mode 100644 esphome/components/mt6701/sensor/mt6701_sensor.h create mode 100644 esphome/components/mt6701_i2c/__init__.py create mode 100644 esphome/components/mt6701_i2c/mt6701_i2c.cpp create mode 100644 esphome/components/mt6701_i2c/mt6701_i2c.h create mode 100644 esphome/components/mt6701_spi/__init__.py create mode 100644 esphome/components/mt6701_spi/binary_sensor/__init__.py create mode 100644 esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp create mode 100644 esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h create mode 100644 esphome/components/mt6701_spi/mt6701_spi.cpp create mode 100644 esphome/components/mt6701_spi/mt6701_spi.h create mode 100644 esphome/components/mt6701_spi/text_sensor/__init__.py create mode 100644 esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp create mode 100644 esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h create mode 100644 tests/component_tests/mt6701/__init__.py create mode 100644 tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml create mode 100644 tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml create mode 100644 tests/component_tests/mt6701/test_mt6701.py create mode 100644 tests/components/mt6701_i2c/common.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-ard.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-c3-idf.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-idf.yaml create mode 100644 tests/components/mt6701_i2c/test.esp8266-ard.yaml create mode 100644 tests/components/mt6701_i2c/test.host.yaml create mode 100644 tests/components/mt6701_i2c/test.rp2040-ard.yaml create mode 100644 tests/components/mt6701_spi/__init__.py create mode 100644 tests/components/mt6701_spi/common.yaml create mode 100644 tests/components/mt6701_spi/mt6701_spi_frame_test.cpp create mode 100644 tests/components/mt6701_spi/test.esp32-ard.yaml create mode 100644 tests/components/mt6701_spi/test.esp32-c3-idf.yaml create mode 100644 tests/components/mt6701_spi/test.esp32-idf.yaml create mode 100644 tests/components/mt6701_spi/test.esp8266-ard.yaml create mode 100644 tests/components/mt6701_spi/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 539730a4a67..0def07d0237 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -428,6 +428,9 @@ esphome/components/mpl3115a2/* @kbickar esphome/components/mpu6886/* @fabaff esphome/components/ms8607/* @e28eta esphome/components/msa3xx/* @latonita +esphome/components/mt6701/* @slimcdk +esphome/components/mt6701_i2c/* @slimcdk +esphome/components/mt6701_spi/* @slimcdk esphome/components/nau7802/* @cujomalainey esphome/components/network/* @esphome/core esphome/components/nextion/* @edwardtfn @senexcrenshaw diff --git a/esphome/components/mt6701/__init__.py b/esphome/components/mt6701/__init__.py new file mode 100644 index 00000000000..9325b8299f1 --- /dev/null +++ b/esphome/components/mt6701/__init__.py @@ -0,0 +1,9 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@slimcdk"] +DOMAIN = "mt6701" + +mt6701_ns = cg.esphome_ns.namespace("mt6701") +MT6701Component = mt6701_ns.class_("MT6701Component", cg.Component) + +CONF_MT6701_ID = "mt6701_id" diff --git a/esphome/components/mt6701/mt6701.cpp b/esphome/components/mt6701/mt6701.cpp new file mode 100644 index 00000000000..46316b8e551 --- /dev/null +++ b/esphome/components/mt6701/mt6701.cpp @@ -0,0 +1,28 @@ +#include "mt6701.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701 { + +bool MT6701Component::read_encoder() { + if (this->is_failed() || this->suspend_sampling_) + return false; + + uint16_t count; + if (!this->read_count(count)) { + if (this->consecutive_errors_ < MAX_CONSECUTIVE_ERRORS) { + this->consecutive_errors_++; + if (this->consecutive_errors_ == MAX_CONSECUTIVE_ERRORS) + this->status_set_warning(LOG_STR("repeated read/CRC errors")); + } + return false; + } + if (this->consecutive_errors_ != 0) { + this->consecutive_errors_ = 0; + this->status_clear_warning(); + } + + this->count_ = count; + return true; +} + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/mt6701.h b/esphome/components/mt6701/mt6701.h new file mode 100644 index 00000000000..4795441f167 --- /dev/null +++ b/esphome/components/mt6701/mt6701.h @@ -0,0 +1,33 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::mt6701 { + +// A 14-bit absolute angle: one revolution is 16384 counts +static constexpr float COUNTS_TO_DEGREES = 360.0f / 16384; +// Consecutive read or CRC failures tolerated before the component reports a warning +static constexpr uint8_t MAX_CONSECUTIVE_ERRORS = 5; + +/// Bus-independent core; the I2C and SSI hubs provide read_count(), the entities poll it +class MT6701Component : public Component { + public: + /// Read a fresh angle; false on a bus or CRC error, while suspended, or when setup failed + bool read_encoder(); + + /// Most recent absolute angle as a raw count in the range [0, 16383]. + uint16_t get_count() const { return this->count_; } + /// Most recent absolute shaft angle in degrees, in the range [0, 360). + float get_angle_degrees() const { return this->count_ * COUNTS_TO_DEGREES; } + + protected: + /// The current 14-bit count from the bus; false on a bus or CRC error + virtual bool read_count(uint16_t &count) = 0; + + uint16_t count_{0}; + uint8_t consecutive_errors_{0}; + // Set by the I2C hub while the EEPROM is programmed; no bus access until it clears + bool suspend_sampling_{false}; +}; + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/sensor/__init__.py b/esphome/components/mt6701/sensor/__init__.py new file mode 100644 index 00000000000..617524b1917 --- /dev/null +++ b/esphome/components/mt6701/sensor/__init__.py @@ -0,0 +1,51 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ICON_ROTATE_RIGHT, + STATE_CLASS_MEASUREMENT, + UNIT_DEGREES, + UNIT_EMPTY, +) +from esphome.types import ConfigType + +from .. import CONF_MT6701_ID, MT6701Component, mt6701_ns + +AUTO_LOAD = ["mt6701"] + +MT6701Sensor = mt6701_ns.class_("MT6701Sensor", sensor.Sensor, cg.PollingComponent) + +CONF_RAW_COUNT = "raw_count" + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + MT6701Sensor, + unit_of_measurement=UNIT_DEGREES, + accuracy_decimals=2, + icon=ICON_ROTATE_RIGHT, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.GenerateID(CONF_MT6701_ID): cv.use_id(MT6701Component), + cv.Optional(CONF_RAW_COUNT): sensor.sensor_schema( + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + icon=ICON_ROTATE_RIGHT, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_MT6701_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_RAW_COUNT, var.set_raw_count_sensor) diff --git a/esphome/components/mt6701/sensor/mt6701_sensor.cpp b/esphome/components/mt6701/sensor/mt6701_sensor.cpp new file mode 100644 index 00000000000..c849ef91397 --- /dev/null +++ b/esphome/components/mt6701/sensor/mt6701_sensor.cpp @@ -0,0 +1,23 @@ +#include "mt6701_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701 { + +ESPHOME_LOG_TAG(TAG, "mt6701.sensor"); + +void MT6701Sensor::update() { + if (!this->parent_->read_encoder()) + return; + + this->publish_state(this->parent_->get_angle_degrees()); + if (this->raw_count_sensor_ != nullptr) + this->raw_count_sensor_->publish_state(this->parent_->get_count()); +} + +void MT6701Sensor::dump_config() { + LOG_SENSOR("", "MT6701 Sensor", this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Raw Count", this->raw_count_sensor_); +} + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/sensor/mt6701_sensor.h b/esphome/components/mt6701/sensor/mt6701_sensor.h new file mode 100644 index 00000000000..2bf03dd7225 --- /dev/null +++ b/esphome/components/mt6701/sensor/mt6701_sensor.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/mt6701/mt6701.h" + +namespace esphome::mt6701 { + +/// The shaft angle in degrees, with the raw count as an optional sub-sensor +class MT6701Sensor final : public PollingComponent, public Parented, public sensor::Sensor { + SUB_SENSOR(raw_count) + + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701_i2c/__init__.py b/esphome/components/mt6701_i2c/__init__.py new file mode 100644 index 00000000000..17734e00459 --- /dev/null +++ b/esphome/components/mt6701_i2c/__init__.py @@ -0,0 +1,202 @@ +from typing import Any + +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_DIRECTION, CONF_HYSTERESIS, CONF_ID, CONF_MODE +from esphome.types import ConfigType + +from ..mt6701 import MT6701Component + +CODEOWNERS = ["@slimcdk"] +AUTO_LOAD = ["mt6701"] +DEPENDENCIES = ["i2c"] +DOMAIN = "mt6701_i2c" +MULTI_CONF = True + +mt6701_i2c_ns = cg.esphome_ns.namespace("mt6701_i2c") +MT6701I2CComponent = mt6701_i2c_ns.class_( + "MT6701I2CComponent", MT6701Component, i2c.I2CDevice +) + +CONF_ZERO_OFFSET = "zero_offset" +CONF_OUTPUT_MODE = "output_mode" +CONF_ABZ = "abz" +CONF_PULSES_PER_REVOLUTION = "pulses_per_revolution" +CONF_Z_PULSE_WIDTH = "z_pulse_width" +CONF_UVW = "uvw" +CONF_POLE_PAIRS = "pole_pairs" +CONF_OUT_PIN = "out_pin" +CONF_PWM_FREQUENCY = "pwm_frequency" +CONF_PWM_POLARITY = "pwm_polarity" +CONF_ANALOG_START = "analog_start" +CONF_ANALOG_STOP = "analog_stop" + +# DIR bit: 1 counts clockwise +DIRECTION = { + "CLOCKWISE": 1, + "COUNTERCLOCKWISE": 0, +} + +# Output LSBs to the HYST register code +HYSTERESIS = { + 0: 4, + 0.25: 5, + 0.5: 6, + 1: 0, + 2: 1, + 4: 2, + 8: 3, +} + +Z_PULSE_WIDTH = { + "1LSB": 0, + "2LSB": 1, + "4LSB": 2, + "8LSB": 3, + "12LSB": 4, + "16LSB": 5, + "180DEG": 6, +} + +OUTPUT_MODE = { + "ABZ": 0, + "UVW": 1, +} + +OUT_PIN_MODE = { + "ANALOG": 0, + "PWM": 1, +} + +PWM_FREQUENCY = { + "994.4HZ": 0, + "497.2HZ": 1, +} + +PWM_POLARITY = { + "HIGH": 0, + "LOW": 1, +} + +# Zero offset and analog start/stop are 12-bit positions over one revolution +RESOLUTION_12BIT = 4096 +MAX_POSITION_12BIT = RESOLUTION_12BIT - 1 +ANGLE_TO_POSITION_12 = RESOLUTION_12BIT / 360 + + +def _angle_to_position_12(value: Any) -> int: + value = cv.float_range(min=0, max=360)( + cv.float_with_unit("angle", "(°|deg)")(value) + ) + # 360° is not representable; clamp instead of wrapping to 0 + return min(round(value * ANGLE_TO_POSITION_12), MAX_POSITION_12BIT) + + +def _percent_to_position_12(value: Any) -> int: + value = cv.percentage(value) + return min(round(value * RESOLUTION_12BIT), MAX_POSITION_12BIT) + + +def position12(value: Any) -> int: + """A 12-bit position: a raw count, an angle (``45deg``, ``90°``) or a share of a turn (``25%``).""" + if isinstance(value, str) and value.endswith("%"): + return _percent_to_position_12(value) + if isinstance(value, str) and value.endswith(("°", "deg")): + return _angle_to_position_12(value) + return cv.int_range(min=0, max=MAX_POSITION_12BIT)(value) + + +def validate_hysteresis(value: Any) -> int: + value = cv.float_(value) + if value not in HYSTERESIS: + raise cv.Invalid( + f"Hysteresis must be one of {sorted(HYSTERESIS)} (LSBs), got {value}" + ) + return HYSTERESIS[value] + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MT6701I2CComponent), + cv.Optional(CONF_DIRECTION): cv.enum(DIRECTION, upper=True), + cv.Optional(CONF_ZERO_OFFSET): position12, + cv.Optional(CONF_HYSTERESIS): validate_hysteresis, + cv.Optional(CONF_OUTPUT_MODE): cv.enum(OUTPUT_MODE, upper=True), + cv.Optional(CONF_ABZ): cv.Schema( + { + cv.Optional(CONF_PULSES_PER_REVOLUTION): cv.int_range( + min=1, max=1024 + ), + cv.Optional(CONF_Z_PULSE_WIDTH): cv.enum(Z_PULSE_WIDTH, upper=True), + } + ), + cv.Optional(CONF_UVW): cv.Schema( + { + cv.Required(CONF_POLE_PAIRS): cv.int_range(min=1, max=16), + } + ), + cv.Optional(CONF_OUT_PIN): cv.Schema( + { + cv.Required(CONF_MODE): cv.enum(OUT_PIN_MODE, upper=True), + cv.Optional(CONF_PWM_FREQUENCY): cv.enum(PWM_FREQUENCY, upper=True), + cv.Optional(CONF_PWM_POLARITY): cv.enum(PWM_POLARITY, upper=True), + cv.Optional(CONF_ANALOG_START): position12, + cv.Optional(CONF_ANALOG_STOP): position12, + } + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x06)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + if (direction := config.get(CONF_DIRECTION)) is not None: + cg.add(var.set_direction(direction)) + if (zero_offset := config.get(CONF_ZERO_OFFSET)) is not None: + cg.add(var.set_zero_offset(zero_offset)) + if (hysteresis := config.get(CONF_HYSTERESIS)) is not None: + cg.add(var.set_hysteresis(hysteresis)) + if (output_mode := config.get(CONF_OUTPUT_MODE)) is not None: + cg.add(var.set_output_mode(output_mode)) + + if abz := config.get(CONF_ABZ): + if (ppr := abz.get(CONF_PULSES_PER_REVOLUTION)) is not None: + cg.add(var.set_abz_pulses_per_revolution(ppr)) + if (width := abz.get(CONF_Z_PULSE_WIDTH)) is not None: + cg.add(var.set_z_pulse_width(width)) + + if uvw := config.get(CONF_UVW): + cg.add(var.set_uvw_pole_pairs(uvw[CONF_POLE_PAIRS])) + + if out_pin := config.get(CONF_OUT_PIN): + cg.add(var.set_out_pin_mode(out_pin[CONF_MODE])) + if (freq := out_pin.get(CONF_PWM_FREQUENCY)) is not None: + cg.add(var.set_pwm_frequency(freq)) + if (pol := out_pin.get(CONF_PWM_POLARITY)) is not None: + cg.add(var.set_pwm_polarity(pol)) + if (start := out_pin.get(CONF_ANALOG_START)) is not None: + cg.add(var.set_analog_start(start)) + if (stop := out_pin.get(CONF_ANALOG_STOP)) is not None: + cg.add(var.set_analog_stop(stop)) + + +MT6701_SAVE_EEPROM_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(MT6701I2CComponent), + } +) + +automation.register_apply_action( + "mt6701_i2c.save_eeprom", + MT6701_SAVE_EEPROM_ACTION_SCHEMA, + automation.ApplyCall("save_eeprom()"), +) diff --git a/esphome/components/mt6701_i2c/mt6701_i2c.cpp b/esphome/components/mt6701_i2c/mt6701_i2c.cpp new file mode 100644 index 00000000000..cf1094ee225 --- /dev/null +++ b/esphome/components/mt6701_i2c/mt6701_i2c.cpp @@ -0,0 +1,184 @@ +#include "mt6701_i2c.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_i2c { + +ESPHOME_LOG_TAG(TAG, "mt6701_i2c"); + +// Registers apply_config_() may write, each read-modify-written once +static const uint8_t CONFIG_REGS[] PROGMEM = { + REG_ABZ_MUX_DIR, REG_RES_H, REG_ABZ_RES_L, REG_CONFIG_H, REG_ZERO_L, + REG_HYST_L, REG_OUT, REG_A_HIGH, REG_A_START_L, REG_A_STOP_L, +}; +static constexpr uint8_t CONFIG_REG_COUNT = sizeof(CONFIG_REGS); + +void MT6701I2CComponent::setup() { + uint16_t count; + if (!this->read_count(count)) { + this->mark_failed(LOG_STR("not responding on I2C")); + return; + } + this->apply_config_(); +} + +void MT6701I2CComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 (I2C):"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + if (this->direction_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Direction bit: %u", this->direction_); + } + if (this->zero_offset_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Zero offset: %u", this->zero_offset_); + } + if (this->hysteresis_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Hysteresis code: %u", this->hysteresis_); + } + if (this->output_mode_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Output mode: %s", this->output_mode_ != 0 ? LOG_STR_LITERAL("UVW") : LOG_STR_LITERAL("ABZ")); + } + if (this->abz_pulses_per_revolution_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " ABZ pulses/rev: %u", this->abz_pulses_per_revolution_); + } + if (this->z_pulse_width_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Z pulse width code: %u", this->z_pulse_width_); + } + if (this->uvw_pole_pairs_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " UVW pole pairs: %u", this->uvw_pole_pairs_); + } + if (this->out_pin_mode_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Output pin mode: %s", + this->out_pin_mode_ != 0 ? LOG_STR_LITERAL("PWM") : LOG_STR_LITERAL("analog")); + } + if (this->pwm_frequency_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " PWM frequency code: %u", this->pwm_frequency_); + } + if (this->pwm_polarity_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " PWM polarity code: %u", this->pwm_polarity_); + } + if (this->analog_start_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Analog start: %u", this->analog_start_); + } + if (this->analog_stop_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Analog stop: %u", this->analog_stop_); + } +} + +bool MT6701I2CComponent::read_count(uint16_t &count) { + // Single-byte reads, 0x03 first; datasheet 1.9 documents no burst read + uint8_t high; + uint8_t low; + if (this->read_register(REG_ANGLE_H, &high, 1) != i2c::ERROR_OK || + this->read_register(REG_ANGLE_L, &low, 1) != i2c::ERROR_OK) + return false; + count = encode_uint16(high, low) >> 2; + return true; +} + +bool MT6701I2CComponent::update_register_(uint8_t reg, uint8_t mask, uint8_t value) { + uint8_t current; + if (this->read_register(reg, ¤t, 1) != i2c::ERROR_OK) + return false; + uint8_t updated = (current & ~mask) | (value & mask); + if (updated == current) + return true; + return this->write_register(reg, &updated, 1) == i2c::ERROR_OK; +} + +void MT6701I2CComponent::apply_config_() { + // Collect the bits per register first; several options share one register + uint8_t masks[CONFIG_REG_COUNT] = {}; + uint8_t values[CONFIG_REG_COUNT] = {}; + auto add = [&masks, &values](uint8_t reg, uint8_t mask, uint8_t value) { + for (uint8_t i = 0; i < CONFIG_REG_COUNT; i++) { + if (progmem_read_byte(&CONFIG_REGS[i]) == reg) { + masks[i] |= mask; + values[i] |= value & mask; + return; + } + } + }; + + if (this->direction_ != UNSET_U8) + add(REG_ABZ_MUX_DIR, 0x02, this->direction_ << 1); + if (this->output_mode_ != UNSET_U8) + add(REG_ABZ_MUX_DIR, 0x40, (this->output_mode_ & 0x01) << 6); + if (this->zero_offset_ != UNSET_U16) { + add(REG_CONFIG_H, 0x0F, this->zero_offset_ >> 8); + add(REG_ZERO_L, 0xFF, this->zero_offset_ & 0xFF); + } + if (this->hysteresis_ != UNSET_U8) { + add(REG_CONFIG_H, 0x80, (this->hysteresis_ & 0x04) << 5); // HYST[2] -> bit7 + add(REG_HYST_L, 0xC0, (this->hysteresis_ & 0x03) << 6); // HYST[1:0] -> bits7:6 + } + if (this->abz_pulses_per_revolution_ != UNSET_U16) { + // ABZ_RES holds pulses per revolution minus 1 + uint16_t res = this->abz_pulses_per_revolution_ - 1; + add(REG_RES_H, 0x03, res >> 8); + add(REG_ABZ_RES_L, 0xFF, res & 0xFF); + } + if (this->z_pulse_width_ != UNSET_U8) + add(REG_CONFIG_H, 0x70, (this->z_pulse_width_ & 0x07) << 4); + if (this->uvw_pole_pairs_ != UNSET_U8) { + // UVW_RES holds pole pairs minus 1 + add(REG_RES_H, 0xF0, (this->uvw_pole_pairs_ - 1) << 4); + } + if (this->out_pin_mode_ != UNSET_U8) + add(REG_OUT, 0x20, (this->out_pin_mode_ & 0x01) << 5); + if (this->pwm_frequency_ != UNSET_U8) + add(REG_OUT, 0x80, (this->pwm_frequency_ & 0x01) << 7); + if (this->pwm_polarity_ != UNSET_U8) + add(REG_OUT, 0x40, (this->pwm_polarity_ & 0x01) << 6); + if (this->analog_start_ != UNSET_U16) { + add(REG_A_HIGH, 0x0F, this->analog_start_ >> 8); + add(REG_A_START_L, 0xFF, this->analog_start_ & 0xFF); + } + if (this->analog_stop_ != UNSET_U16) { + add(REG_A_HIGH, 0xF0, (this->analog_stop_ >> 8) << 4); + add(REG_A_STOP_L, 0xFF, this->analog_stop_ & 0xFF); + } + + bool ok = true; + for (uint8_t i = 0; i < CONFIG_REG_COUNT; i++) { + if (masks[i] != 0) + ok &= this->update_register_(progmem_read_byte(&CONFIG_REGS[i]), masks[i], values[i]); + } + if (!ok) { + // A half-configured chip must not be sampled or have its EEPROM saved + this->mark_failed(LOG_STR("configuration register write failed")); + } +} + +void MT6701I2CComponent::save_eeprom() { + if (this->is_failed()) { + // A failed component gets no timeouts, so the bus would stay suspended + ESP_LOGW(TAG, "Not programming EEPROM: component is marked failed"); + return; + } + if (this->suspend_sampling_) { + ESP_LOGW(TAG, "EEPROM programming already in progress"); + return; + } + ESP_LOGW(TAG, "Programming MT6701 EEPROM. The EEPROM has a limited number of write " + "cycles, so avoid doing this repeatedly. Supply must be 4.5-5.5 V."); + this->suspend_sampling_ = true; + + bool ok = this->write_byte(REG_EEPROM_KEY, EEPROM_KEY_VALUE) && this->write_byte(REG_EEPROM_CMD, EEPROM_CMD_VALUE); + if (!ok) { + ESP_LOGE(TAG, "Failed to start EEPROM programming"); + this->suspend_sampling_ = false; + return; + } + + // suspend_sampling_ keeps a second save out, so the timer needs no name + this->set_timeout(EEPROM_PROGRAM_DELAY_MS, [this]() { + this->suspend_sampling_ = false; + ESP_LOGI(TAG, "EEPROM programming finished. Power-cycle the MT6701 to verify the stored configuration"); + }); +} + +} // namespace esphome::mt6701_i2c diff --git a/esphome/components/mt6701_i2c/mt6701_i2c.h b/esphome/components/mt6701_i2c/mt6701_i2c.h new file mode 100644 index 00000000000..7d2d91d6986 --- /dev/null +++ b/esphome/components/mt6701_i2c/mt6701_i2c.h @@ -0,0 +1,79 @@ +#pragma once + +#include "esphome/components/mt6701/mt6701.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::mt6701_i2c { + +// Angle: D[13:6] at 0x03, D[5:0] in bits 7:2 of 0x04 +static constexpr uint8_t REG_ANGLE_H = 0x03; +static constexpr uint8_t REG_ANGLE_L = 0x04; + +// EEPROM programming (datasheet 8.2) needs more than 600 ms left alone afterwards +static constexpr uint8_t REG_EEPROM_KEY = 0x09; +static constexpr uint8_t REG_EEPROM_CMD = 0x0A; +static constexpr uint8_t EEPROM_KEY_VALUE = 0xB3; +static constexpr uint8_t EEPROM_CMD_VALUE = 0x05; +static constexpr uint32_t EEPROM_PROGRAM_DELAY_MS = 700; + +// Configuration registers; reserved bits make every write a read-modify-write +static constexpr uint8_t REG_ABZ_MUX_DIR = 0x29; // bit6 ABZ_MUX, bit1 DIR +static constexpr uint8_t REG_RES_H = 0x30; // bits7:4 UVW_RES, bits1:0 ABZ_RES[9:8] +static constexpr uint8_t REG_ABZ_RES_L = 0x31; // ABZ_RES[7:0] +static constexpr uint8_t REG_CONFIG_H = 0x32; // bit7 HYST[2], bits6:4 Z_PULSE_WIDTH, bits3:0 ZERO[11:8] +static constexpr uint8_t REG_ZERO_L = 0x33; // ZERO[7:0] +static constexpr uint8_t REG_HYST_L = 0x34; // bits7:6 HYST[1:0] +static constexpr uint8_t REG_OUT = 0x38; // bit7 PWM_FREQ, bit6 PWM_POL, bit5 OUT_MODE +static constexpr uint8_t REG_A_HIGH = 0x3E; // bits7:4 A_STOP[11:8], bits3:0 A_START[11:8] +static constexpr uint8_t REG_A_START_L = 0x3F; // A_START[7:0] +static constexpr uint8_t REG_A_STOP_L = 0x40; // A_STOP[7:0] + +/// MT6701 over I2C, with the configuration registers +class MT6701I2CComponent final : public mt6701::MT6701Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + // Options left unset are never written, so the chip keeps its EEPROM defaults + void set_direction(uint8_t direction) { this->direction_ = direction; } + void set_zero_offset(uint16_t position) { this->zero_offset_ = position; } + void set_hysteresis(uint8_t hysteresis) { this->hysteresis_ = hysteresis; } + void set_output_mode(uint8_t output_mode) { this->output_mode_ = output_mode; } + void set_abz_pulses_per_revolution(uint16_t pulses) { this->abz_pulses_per_revolution_ = pulses; } + void set_z_pulse_width(uint8_t width) { this->z_pulse_width_ = width; } + void set_uvw_pole_pairs(uint8_t pole_pairs) { this->uvw_pole_pairs_ = pole_pairs; } + void set_out_pin_mode(uint8_t out_pin_mode) { this->out_pin_mode_ = out_pin_mode; } + void set_pwm_frequency(uint8_t pwm_frequency) { this->pwm_frequency_ = pwm_frequency; } + void set_pwm_polarity(uint8_t pwm_polarity) { this->pwm_polarity_ = pwm_polarity; } + void set_analog_start(uint16_t position) { this->analog_start_ = position; } + void set_analog_stop(uint16_t position) { this->analog_stop_ = position; } + + /// Write the registers to the EEPROM: pauses sampling for the programming time. + /// The EEPROM wears, so only an explicit action calls this. + void save_eeprom(); + + protected: + bool read_count(uint16_t &count) override; + + void apply_config_(); + bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); + + // Unset options sit above their field's range + static constexpr uint16_t UNSET_U16 = 0xFFFF; + static constexpr uint8_t UNSET_U8 = 0xFF; + + uint16_t zero_offset_{UNSET_U16}; + uint16_t abz_pulses_per_revolution_{UNSET_U16}; + uint16_t analog_start_{UNSET_U16}; + uint16_t analog_stop_{UNSET_U16}; + uint8_t direction_{UNSET_U8}; + uint8_t hysteresis_{UNSET_U8}; + uint8_t output_mode_{UNSET_U8}; + uint8_t z_pulse_width_{UNSET_U8}; + uint8_t uvw_pole_pairs_{UNSET_U8}; + uint8_t out_pin_mode_{UNSET_U8}; + uint8_t pwm_frequency_{UNSET_U8}; + uint8_t pwm_polarity_{UNSET_U8}; +}; + +} // namespace esphome::mt6701_i2c diff --git a/esphome/components/mt6701_spi/__init__.py b/esphome/components/mt6701_spi/__init__.py new file mode 100644 index 00000000000..6f45e347b7d --- /dev/null +++ b/esphome/components/mt6701_spi/__init__.py @@ -0,0 +1,47 @@ +import esphome.codegen as cg +from esphome.components import spi +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from ..mt6701 import MT6701Component + +CODEOWNERS = ["@slimcdk"] +AUTO_LOAD = ["mt6701"] +DEPENDENCIES = ["spi"] +DOMAIN = "mt6701_spi" +MULTI_CONF = True + +mt6701_spi_ns = cg.esphome_ns.namespace("mt6701_spi") +MT6701SPIComponent = mt6701_spi_ns.class_( + "MT6701SPIComponent", MT6701Component, spi.SPIDevice +) + +CONF_MT6701_SPI_ID = "mt6701_spi_id" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MT6701SPIComponent), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend( + spi.spi_device_schema( + cs_pin_required=True, + default_data_rate=1_000_000, + default_mode="MODE1", + ) + ) +) + +# SSI only reads, so the bus needs its input line +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + "mt6701_spi", require_mosi=False, require_miso=True +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await spi.register_spi_device(var, config) diff --git a/esphome/components/mt6701_spi/binary_sensor/__init__.py b/esphome/components/mt6701_spi/binary_sensor/__init__.py new file mode 100644 index 00000000000..f06dc31aa5f --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/__init__.py @@ -0,0 +1,46 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_UPDATE_INTERVAL, + DEVICE_CLASS_PROBLEM, + ENTITY_CATEGORY_DIAGNOSTIC, +) +from esphome.types import ConfigType + +from .. import CONF_MT6701_SPI_ID, MT6701SPIComponent, mt6701_spi_ns + +DEPENDENCIES = ["mt6701_spi"] + +CONF_PUSH_BUTTON = "push_button" +CONF_TRACK_LOSS = "track_loss" + +MT6701SPIBinarySensors = mt6701_spi_ns.class_( + "MT6701SPIBinarySensors", cg.Component, cg.Parented.template(MT6701SPIComponent) +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(MT6701SPIBinarySensors), + cv.GenerateID(CONF_MT6701_SPI_ID): cv.use_id(MT6701SPIComponent), + # A press has to be seen between two reads, so the default is short + cv.Optional( + CONF_UPDATE_INTERVAL, default="50ms" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_PUSH_BUTTON): binary_sensor.binary_sensor_schema(), + cv.Optional(CONF_TRACK_LOSS): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_MT6701_SPI_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_PUSH_BUTTON, var.set_push_button_binary_sensor) + await binary_sensors(CONF_TRACK_LOSS, var.set_track_loss_binary_sensor) diff --git a/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp new file mode 100644 index 00000000000..9af88d056e2 --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp @@ -0,0 +1,29 @@ +#include "mt6701_spi_binary_sensors.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi.binary_sensor"); + +void MT6701SPIBinarySensors::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_read_ < this->update_interval_) + return; + this->last_read_ = now; + if (!this->parent_->read_encoder()) + return; + const uint8_t status = this->parent_->get_status(); + if (this->push_button_binary_sensor_ != nullptr) + this->push_button_binary_sensor_->publish_state((status & 0x04) != 0); + if (this->track_loss_binary_sensor_ != nullptr) + this->track_loss_binary_sensor_->publish_state((status & 0x08) != 0); +} + +void MT6701SPIBinarySensors::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 status:\n Update Interval: %" PRIu32 " ms", this->update_interval_); + LOG_BINARY_SENSOR(" ", "Push button", this->push_button_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Track loss", this->track_loss_binary_sensor_); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h new file mode 100644 index 00000000000..646f6341c40 --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/mt6701_spi/mt6701_spi.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::mt6701_spi { + +// Reads the encoder at its own interval and publishes the status flags of each frame +class MT6701SPIBinarySensors : public Component, public Parented { + SUB_BINARY_SENSOR(push_button) + SUB_BINARY_SENSOR(track_loss) + + public: + void set_update_interval(uint32_t update_interval) { this->update_interval_ = update_interval; } + void loop() override; + void dump_config() override; + + protected: + uint32_t update_interval_{50}; + uint32_t last_read_{0}; +}; + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/mt6701_spi.cpp b/esphome/components/mt6701_spi/mt6701_spi.cpp new file mode 100644 index 00000000000..493b7ded3f7 --- /dev/null +++ b/esphome/components/mt6701_spi/mt6701_spi.cpp @@ -0,0 +1,67 @@ +#include "mt6701_spi.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi"); + +// The 6-bit CRC (x^6 + x + 1) over 18 bits is crc8 with the polynomial shifted up by two +static constexpr uint8_t CRC6_AS_CRC8_POLY = 0x0C; + +bool decode_frame(const uint8_t frame[3], uint16_t &count, uint8_t &status) { + if (crc8(frame, 3, 0x00, CRC6_AS_CRC8_POLY, true) != 0) + return false; + const uint32_t bits = encode_uint24(frame[0], frame[1], frame[2]); + count = bits >> 10; + status = (bits >> 6) & 0x0F; + return true; +} + +ProbeResult probe_result(uint8_t valid_frames, bool any_nonzero) { + if (valid_frames < PROBE_MIN_VALID) + return ProbeResult::PROBE_RESULT_NO_ENCODER; + return any_nonzero ? ProbeResult::PROBE_RESULT_OK : ProbeResult::PROBE_RESULT_ALL_ZERO; +} + +void MT6701SPIComponent::setup() { + this->spi_setup(); + // All-zero frames only warn: an encoder at rest looks like a stuck-low data line + uint8_t valid = 0; + bool nonzero = false; + for (uint8_t i = 0; i < PROBE_FRAMES && (valid < PROBE_MIN_VALID || !nonzero); i++) { + uint16_t count; + if (this->read_count(count)) { + valid++; + nonzero |= count != 0 || this->status_ != 0; + } + } + switch (probe_result(valid, nonzero)) { + case ProbeResult::PROBE_RESULT_NO_ENCODER: + this->mark_failed(LOG_STR("no valid frame received, check the wiring and CS pin")); + break; + case ProbeResult::PROBE_RESULT_ALL_ZERO: + ESP_LOGW(TAG, "Every frame read zero: the data line may be stuck low"); + break; + case ProbeResult::PROBE_RESULT_OK: + break; + } +} + +void MT6701SPIComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 (SSI/SPI):"); + LOG_PIN(" CS Pin: ", this->cs_); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +bool MT6701SPIComponent::read_count(uint16_t &count) { + uint8_t buffer[3] = {0, 0, 0}; + this->enable(); + this->read_array(buffer, 3); + this->disable(); + return decode_frame(buffer, count, this->status_); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/mt6701_spi.h b/esphome/components/mt6701_spi/mt6701_spi.h new file mode 100644 index 00000000000..fed7217a1e0 --- /dev/null +++ b/esphome/components/mt6701_spi/mt6701_spi.h @@ -0,0 +1,41 @@ +#pragma once + +#include "esphome/components/mt6701/mt6701.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::mt6701_spi { + +/// Decode a 24-bit SSI frame (14-bit angle, 4 status bits, 6-bit CRC); false when the CRC fails +bool decode_frame(const uint8_t frame[3], uint16_t &count, uint8_t &status); + +// Noise passes the 6-bit CRC 1 time in 64, so setup wants several good frames +static constexpr uint8_t PROBE_FRAMES = 10; +static constexpr uint8_t PROBE_MIN_VALID = 3; + +enum class ProbeResult : uint8_t { + PROBE_RESULT_NO_ENCODER, + PROBE_RESULT_ALL_ZERO, // a stuck-low data line, or an encoder at rest + PROBE_RESULT_OK, +}; +ProbeResult probe_result(uint8_t valid_frames, bool any_nonzero); + +/// MT6701 over SSI: read-only, but each frame carries the field, push button and track-loss flags. +/// Data shifts out on the rising clock edge, so it is sampled on the falling edge with the clock +/// idle low; a clock idling high reads an extra leading bit. +class MT6701SPIComponent final : public mt6701::MT6701Component, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + + /// The 4-bit status of the last good frame: bit3 track loss, bit2 push button, bits1:0 field strength + uint8_t get_status() const { return this->status_; } + + protected: + bool read_count(uint16_t &count) override; + + uint8_t status_{0}; +}; + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/text_sensor/__init__.py b/esphome/components/mt6701_spi/text_sensor/__init__.py new file mode 100644 index 00000000000..bbec4a40e4f --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_MAGNET +from esphome.types import ConfigType + +from .. import CONF_MT6701_SPI_ID, MT6701SPIComponent, mt6701_spi_ns + +DEPENDENCIES = ["mt6701_spi"] + +MT6701SPIFieldStatus = mt6701_spi_ns.class_( + "MT6701SPIFieldStatus", + text_sensor.TextSensor, + cg.PollingComponent, + cg.Parented.template(MT6701SPIComponent), +) + +CONFIG_SCHEMA = ( + text_sensor.text_sensor_schema( + MT6701SPIFieldStatus, + icon=ICON_MAGNET, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ) + .extend( + { + cv.GenerateID(CONF_MT6701_SPI_ID): cv.use_id(MT6701SPIComponent), + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = await text_sensor.new_text_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_MT6701_SPI_ID]) diff --git a/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp new file mode 100644 index 00000000000..5db30c4b077 --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp @@ -0,0 +1,28 @@ +#include "mt6701_spi_field_status.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi.text_sensor"); + +// Indexed by Mg[1:0]; 0b11 is reserved in the datasheet +PROGMEM_STRING_TABLE(FieldStatusStrings, "OK", "TOO_STRONG", "TOO_WEAK", "UNKNOWN"); + +void MT6701SPIFieldStatus::update() { + if (!this->parent_->read_encoder()) + return; + char text[FieldStatusStrings::MAX_LENGTH + 1]; + ESPHOME_strncpy_P(text, + reinterpret_cast(FieldStatusStrings::get_progmem_str( + this->parent_->get_status() & 0x03, FieldStatusStrings::LAST_INDEX)), + sizeof(text)); + this->publish_state(text); +} + +void MT6701SPIFieldStatus::dump_config() { + LOG_TEXT_SENSOR("", "MT6701 field status", this); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h new file mode 100644 index 00000000000..200cc78a7fb --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/mt6701_spi/mt6701_spi.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::mt6701_spi { + +// Reports the magnetic field strength the status bits Mg[1:0] carry +class MT6701SPIFieldStatus : public text_sensor::TextSensor, + public PollingComponent, + public Parented { + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::mt6701_spi diff --git a/tests/component_tests/mt6701/__init__.py b/tests/component_tests/mt6701/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml b/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml new file mode 100644 index 00000000000..135ee0087ac --- /dev/null +++ b/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml @@ -0,0 +1,19 @@ +esphome: + name: mt6701-modes-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2c: + - id: i2c_bus + sda: GPIO21 + scl: GPIO22 + +mt6701_i2c: + id: encoder_hub + i2c_id: i2c_bus + output_mode: ABZ + out_pin: + mode: ANALOG diff --git a/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml b/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml new file mode 100644 index 00000000000..a509bc481df --- /dev/null +++ b/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml @@ -0,0 +1,21 @@ +esphome: + name: mt6701-modes-uvw-pwm-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2c: + - id: i2c_bus + sda: GPIO21 + scl: GPIO22 + +mt6701_i2c: + id: encoder_hub + i2c_id: i2c_bus + output_mode: UVW + uvw: + pole_pairs: 7 + out_pin: + mode: PWM diff --git a/tests/component_tests/mt6701/test_mt6701.py b/tests/component_tests/mt6701/test_mt6701.py new file mode 100644 index 00000000000..05a9ff93286 --- /dev/null +++ b/tests/component_tests/mt6701/test_mt6701.py @@ -0,0 +1,95 @@ +"""Config-validation tests for the mt6701 encoder component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.mt6701_i2c import DIRECTION, position12, validate_hysteresis +import esphome.config_validation as cv + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # raw 12-bit counts pass through unchanged + (0, 0), + (2048, 2048), + (4095, 4095), + # angles are converted to counts + ("0deg", 0), + ("45deg", 512), + ("90°", 1024), + # a full turn is not representable and must clamp to the max, not wrap to 0 + ("360deg", 4095), + # percentages of a full revolution + ("25%", 1024), + ("50%", 2048), + ("100%", 4095), + ], +) +def test_position12_valid(value: int | str, expected: int) -> None: + assert position12(value) == expected + + +@pytest.mark.parametrize("value", [-1, 4096, 5000, "400deg", "-10deg"]) +def test_position12_rejects_out_of_range(value: int | str) -> None: + with pytest.raises(cv.Invalid): + position12(value) + + +@pytest.mark.parametrize( + ("lsb", "register_code"), + [(0, 4), (0.25, 5), (0.5, 6), (1, 0), (2, 1), (4, 2), (8, 3)], +) +def test_hysteresis_maps_lsb_to_register_code(lsb: float, register_code: int) -> None: + assert validate_hysteresis(lsb) == register_code + + +@pytest.mark.parametrize("value", [3, 5, 16]) +def test_hysteresis_rejects_unsupported_step(value: int) -> None: + with pytest.raises(cv.Invalid): + validate_hysteresis(value) + + +def test_direction_follows_datasheet_semantics() -> None: + # Datasheet section 8.1: DIR=1 makes the angle increase clockwise. + assert DIRECTION["CLOCKWISE"] == 1 + assert DIRECTION["COUNTERCLOCKWISE"] == 0 + + +@pytest.mark.parametrize( + ("config_file", "output_mode_call", "out_pin_call"), + [ + # ABZ and ANALOG are register code 0 + ( + "mt6701_i2c_modes.yaml", + "set_output_mode(0)", + "set_out_pin_mode(0)", + ), + # UVW and PWM are register code 1 + ( + "mt6701_i2c_modes_uvw_pwm.yaml", + "set_output_mode(1)", + "set_out_pin_mode(1)", + ), + ], +) +def test_enum_options_generate_register_codes( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + output_mode_call: str, + out_pin_call: str, +) -> None: + """cv.enum returns the key string; codegen must emit its register code. + + Both codes are pinned, so that hardcoding a constant or reading a different + field is caught. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert output_mode_call in main_cpp + assert out_pin_call in main_cpp diff --git a/tests/components/mt6701_i2c/common.yaml b/tests/components/mt6701_i2c/common.yaml new file mode 100644 index 00000000000..fbcf4512f08 --- /dev/null +++ b/tests/components/mt6701_i2c/common.yaml @@ -0,0 +1,33 @@ +mt6701_i2c: + id: mt6701_i2c_encoder + i2c_id: i2c_bus + address: 0x06 + direction: CLOCKWISE + zero_offset: 45deg + hysteresis: 1 + output_mode: ABZ + abz: + pulses_per_revolution: 1024 + z_pulse_width: 1LSB + uvw: + pole_pairs: 8 + out_pin: + mode: PWM + pwm_frequency: 994.4Hz + pwm_polarity: HIGH + analog_start: 0deg + analog_stop: 360deg + +sensor: + - platform: mt6701 + mt6701_id: mt6701_i2c_encoder + name: MT6701 I2C Angle + update_interval: 100ms + raw_count: + name: MT6701 I2C Raw Count + +button: + - platform: template + name: MT6701 I2C Save EEPROM + on_press: + - mt6701_i2c.save_eeprom: mt6701_i2c_encoder diff --git a/tests/components/mt6701_i2c/test.esp32-ard.yaml b/tests/components/mt6701_i2c/test.esp32-ard.yaml new file mode 100644 index 00000000000..dced81081e2 --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml b/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml new file mode 100644 index 00000000000..3837e73028f --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-c3-idf.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp32-idf.yaml b/tests/components/mt6701_i2c/test.esp32-idf.yaml new file mode 100644 index 00000000000..a4ce3b5d391 --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp8266-ard.yaml b/tests/components/mt6701_i2c/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7639027368f --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.host.yaml b/tests/components/mt6701_i2c/test.host.yaml new file mode 100644 index 00000000000..dd5d5faf9d7 --- /dev/null +++ b/tests/components/mt6701_i2c/test.host.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/host.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.rp2040-ard.yaml b/tests/components/mt6701_i2c/test.rp2040-ard.yaml new file mode 100644 index 00000000000..06082480a6a --- /dev/null +++ b/tests/components/mt6701_i2c/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_spi/__init__.py b/tests/components/mt6701_spi/__init__.py new file mode 100644 index 00000000000..7937e0202db --- /dev/null +++ b/tests/components/mt6701_spi/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The frame tests call the decoder directly; no bus is driven + manifest.dependencies = manifest.dependencies + ["spi"] diff --git a/tests/components/mt6701_spi/common.yaml b/tests/components/mt6701_spi/common.yaml new file mode 100644 index 00000000000..093ba44dfed --- /dev/null +++ b/tests/components/mt6701_spi/common.yaml @@ -0,0 +1,27 @@ +mt6701_spi: + id: mt6701_spi_encoder + spi_id: spi_bus + cs_pin: ${cs_pin} + +sensor: + - platform: mt6701 + mt6701_id: mt6701_spi_encoder + name: MT6701 SPI Angle + update_interval: 100ms + raw_count: + name: MT6701 SPI Raw Count + +binary_sensor: + - platform: mt6701_spi + mt6701_spi_id: mt6701_spi_encoder + update_interval: 20ms + push_button: + name: MT6701 SPI Button + track_loss: + name: MT6701 SPI Track Loss + +text_sensor: + - platform: mt6701_spi + mt6701_spi_id: mt6701_spi_encoder + name: MT6701 SPI Field Status + update_interval: 10s diff --git a/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp b/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp new file mode 100644 index 00000000000..43392601aca --- /dev/null +++ b/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/mt6701_spi/mt6701_spi.h" + +namespace esphome::mt6701_spi::testing { + +namespace { + +// The datasheet's CRC: x^6 + x + 1 over the 18 data bits, most significant bit first, zero seed +uint8_t crc6(uint32_t data18) { + uint8_t crc = 0; + for (int bit = 17; bit >= 0; bit--) { + const uint8_t in = ((data18 >> bit) & 1) ^ ((crc >> 5) & 1); + crc = ((crc << 1) & 0x3F) ^ (in ? 0x03 : 0); + } + return crc; +} + +// A frame as the chip sends it: 14 angle bits, 4 status bits, 6 CRC bits +std::array make_frame(uint16_t count, uint8_t status) { + const uint32_t data18 = (static_cast(count & 0x3FFF) << 4) | (status & 0x0F); + const uint32_t frame = (data18 << 6) | crc6(data18); + return {static_cast(frame >> 16), static_cast(frame >> 8), static_cast(frame)}; +} + +} // namespace + +TEST(MT6701SpiFrame, DecodesANonZeroReading) { + const auto frame = make_frame(0x1234, 0b0101); + uint16_t count = 0; + uint8_t status = 0; + ASSERT_TRUE(decode_frame(frame.data(), count, status)); + EXPECT_EQ(count, 0x1234); + EXPECT_EQ(status, 0b0101); +} + +TEST(MT6701SpiFrame, AZeroReadingIsValid) { + const auto frame = make_frame(0, 0); + EXPECT_EQ(frame, (std::array{0, 0, 0})); + uint16_t count = 1; + uint8_t status = 1; + ASSERT_TRUE(decode_frame(frame.data(), count, status)); + EXPECT_EQ(count, 0); + EXPECT_EQ(status, 0); +} + +TEST(MT6701SpiFrame, EveryAngleAndStatusRoundTrips) { + for (uint32_t count = 0; count < 16384; count += 37) { + for (uint8_t status = 0; status < 16; status++) { + const auto frame = make_frame(count, status); + uint16_t got_count = 0; + uint8_t got_status = 0; + ASSERT_TRUE(decode_frame(frame.data(), got_count, got_status)) << count << " " << status; + EXPECT_EQ(got_count, count); + EXPECT_EQ(got_status, status); + } + } +} + +TEST(MT6701SpiFrame, ACorruptedBitFailsTheCrc) { + const auto good = make_frame(0x2ACE, 0b1010); + for (int bit = 0; bit < 24; bit++) { + auto frame = good; + frame[2 - bit / 8] ^= 1 << (bit % 8); + uint16_t count = 0; + uint8_t status = 0; + EXPECT_FALSE(decode_frame(frame.data(), count, status)) << "bit " << bit; + } +} + +TEST(MT6701SpiFrame, StatusBitsAreTheFieldPushAndTrackFlags) { + uint16_t count = 0; + uint8_t status = 0; + // Mg[1:0] = 2 (field too weak), Mg[2] push button, Mg[3] loss of track + ASSERT_TRUE(decode_frame(make_frame(100, 0b1110).data(), count, status)); + EXPECT_EQ(status & 0x03, 2); + EXPECT_TRUE(status & 0x04); + EXPECT_TRUE(status & 0x08); +} + +TEST(MT6701SpiProbe, ThreeGoodFramesProveTheEncoder) { + EXPECT_EQ(probe_result(0, false), ProbeResult::PROBE_RESULT_NO_ENCODER); + EXPECT_EQ(probe_result(PROBE_MIN_VALID - 1, true), ProbeResult::PROBE_RESULT_NO_ENCODER); + EXPECT_EQ(probe_result(PROBE_MIN_VALID, true), ProbeResult::PROBE_RESULT_OK); + EXPECT_EQ(probe_result(PROBE_FRAMES, true), ProbeResult::PROBE_RESULT_OK); +} + +TEST(MT6701SpiProbe, AllZeroFramesOnlyWarn) { + EXPECT_EQ(probe_result(PROBE_MIN_VALID, false), ProbeResult::PROBE_RESULT_ALL_ZERO); + EXPECT_EQ(probe_result(PROBE_FRAMES, false), ProbeResult::PROBE_RESULT_ALL_ZERO); +} + +} // namespace esphome::mt6701_spi::testing diff --git a/tests/components/mt6701_spi/test.esp32-ard.yaml b/tests/components/mt6701_spi/test.esp32-ard.yaml new file mode 100644 index 00000000000..6d06dd7bc59 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-ard.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp32-c3-idf.yaml b/tests/components/mt6701_spi/test.esp32-c3-idf.yaml new file mode 100644 index 00000000000..c9de669b984 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-c3-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO7 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-c3-idf.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp32-idf.yaml b/tests/components/mt6701_spi/test.esp32-idf.yaml new file mode 100644 index 00000000000..642c4d90300 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp8266-ard.yaml b/tests/components/mt6701_spi/test.esp8266-ard.yaml new file mode 100644 index 00000000000..e7b3a4401da --- /dev/null +++ b/tests/components/mt6701_spi/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO15 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.rp2040-ard.yaml b/tests/components/mt6701_spi/test.rp2040-ard.yaml new file mode 100644 index 00000000000..23435b8fb1f --- /dev/null +++ b/tests/components/mt6701_spi/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + mt6701_spi: !include common.yaml From 928cf7d69671b7f6971ece696cfa8bcf77915612 Mon Sep 17 00:00:00 2001 From: Michele Porelli Date: Sat, 17 Oct 2026 17:59:40 -0700 Subject: [PATCH 078/120] [libretiny] Emulate rtc preference storage on the Beken BK7231T, BK7231N and BK7238 with flash and the reset reason (#21007) --- .../components/libretiny/preference_backend.h | 10 ++ esphome/components/libretiny/preferences.cpp | 95 +++++++++++++++++-- esphome/components/libretiny/preferences.h | 11 ++- esphome/core/defines.h | 1 + esphome/preferences.py | 42 ++++++-- tests/unit_tests/test_preferences.py | 53 +++++++++++ 6 files changed, 192 insertions(+), 20 deletions(-) diff --git a/esphome/components/libretiny/preference_backend.h b/esphome/components/libretiny/preference_backend.h index f7f8279ac08..00d76d7d862 100644 --- a/esphome/components/libretiny/preference_backend.h +++ b/esphome/components/libretiny/preference_backend.h @@ -1,6 +1,8 @@ #pragma once #ifdef USE_LIBRETINY +#include "esphome/core/defines.h" + #include #include @@ -20,6 +22,14 @@ class LibreTinyPreferenceBackend final { uint32_t key; struct fdb_kvdb *db; struct fdb_blob *blob; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + // storage: rtc lives under its own FlashDB name prefix, so the database can drop every rtc entry + // at once after a boot that RTC memory would not have survived + bool rtc{false}; + bool is_rtc() const { return this->rtc; } +#else + static constexpr bool is_rtc() { return false; } +#endif }; class LibreTinyPreferences; diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index a868e5042e2..9d21e88b14b 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -4,6 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include #include namespace esphome::libretiny { @@ -13,20 +14,58 @@ ESPHOME_LOG_TAG(TAG, "preferences"); struct NVSData { uint32_t key; SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.) +#ifdef USE_LIBRETINY_RTC_PREFERENCES + bool rtc; + bool is_rtc() const { return this->rtc; } +#else + static constexpr bool is_rtc() { return false; } +#endif }; static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// The FlashDB name of a key: its decimal digits, with a prefix for storage: rtc so one pass over the +// database finds every rtc entry. Without rtc storage is_rtc() is a constant false and the branch folds away. +static constexpr size_t KEY_NAME_SIZE = UINT32_MAX_STR_SIZE + 1; +static constexpr char RTC_KEY_PREFIX = 'r'; +static void key_name(char (&buf)[KEY_NAME_SIZE], uint32_t key, bool rtc) { + char *digits = buf; + if (rtc) + *digits++ = RTC_KEY_PREFIX; + uint32_to_str(std::span(digits, UINT32_MAX_STR_SIZE), key); +} + +#ifdef USE_LIBRETINY_RTC_PREFERENCES +// Whether this boot followed a reset that RTC memory survives on ESP32 and ESP8266. LibreTiny chips +// have no such memory, so storage: rtc keeps preferences in flash and uses this to forget them. +static bool boot_kept_rtc_preferences() { + switch (lt_get_reboot_reason()) { + case REBOOT_REASON_SOFTWARE: + case REBOOT_REASON_WATCHDOG: + case REBOOT_REASON_CRASH: + case REBOOT_REASON_SLEEP_GPIO: + case REBOOT_REASON_SLEEP_RTC: + case REBOOT_REASON_SLEEP_USB: + return true; + default: // power-on, brown-out, reset pin, debugger, unknown + return false; + } +} +#endif // USE_LIBRETINY_RTC_PREFERENCES + bool LibreTinyPreferenceBackend::save(const uint8_t *data, size_t len) { - // try find in pending saves and update that + // try find in pending saves and update that; a flash and an rtc entry may share a key for (auto &obj : s_pending_save) { - if (obj.key == this->key) { + if (obj.key == this->key && obj.is_rtc() == this->is_rtc()) { obj.data.set(data, len); return true; } } NVSData save{}; save.key = this->key; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + save.rtc = this->rtc; +#endif save.data.set(data, len); s_pending_save.push_back(std::move(save)); ESP_LOGVV(TAG, "s_pending_save: key: %" PRIu32 ", len: %zu", this->key, len); @@ -36,7 +75,7 @@ bool LibreTinyPreferenceBackend::save(const uint8_t *data, size_t len) { bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) { // try find in pending saves and load from that for (auto &obj : s_pending_save) { - if (obj.key == this->key) { + if (obj.key == this->key && obj.is_rtc() == this->is_rtc()) { if (obj.data.size() != len) { // size mismatch return false; @@ -46,8 +85,8 @@ bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) { } } - char key_str[UINT32_MAX_STR_SIZE]; - uint32_to_str(key_str, this->key); + char key_str[KEY_NAME_SIZE]; + key_name(key_str, this->key, this->is_rtc()); fdb_blob_make(this->blob, data, len); size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob); if (actual_len != len) { @@ -64,21 +103,57 @@ void LibreTinyPreferences::open() { fdb_err_t err = fdb_kvdb_init(&this->db, "esphome", "kvs", NULL, NULL); if (err != FDB_NO_ERR) { LT_E("fdb_kvdb_init(...) failed: %d", err); - } else { - LT_I("Preferences initialized"); + return; + } + LT_I("Preferences initialized"); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + if (!boot_kept_rtc_preferences()) + this->discard_rtc_entries_(); +#endif +} + +#ifdef USE_LIBRETINY_RTC_PREFERENCES +ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type, bool in_flash) { + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type, !in_flash))); +} + +// One pass over the database deletes every rtc entry. FlashDB only marks a deleted entry's status in place +// and its iterator steps by address, so deleting the current entry is safe. This runs before the logger +// exists, so it logs through LibreTiny; a failed delete is a flash fault the next write will report as well. +void LibreTinyPreferences::discard_rtc_entries_() { + uint16_t discarded = 0; + fdb_kv_iterator iterator; + fdb_kv_iterator_init(&iterator); + while (fdb_kv_iterate(&this->db, &iterator)) { + const char *name = iterator.curr_kv.name; + if (name[0] != RTC_KEY_PREFIX) + continue; + if (const fdb_err_t err = fdb_kv_del(&this->db, name); err != FDB_NO_ERR) { + LT_E("Failed to discard rtc preference %s: %d", name, err); + continue; + } + discarded++; + } + if (discarded != 0) { + LT_D("Discarded %u rtc preferences after a hard reset", discarded); } } +#endif ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type) { // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type))); } -LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type) { +LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type, bool rtc) { LibreTinyPreferenceBackend backend; backend.key = type; backend.db = &this->db; backend.blob = &this->blob; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + backend.rtc = rtc; +#endif return backend; } @@ -97,8 +172,8 @@ bool LibreTinyPreferences::sync() { uint32_t last_key = 0; for (const auto &save : s_pending_save) { - char key_str[UINT32_MAX_STR_SIZE]; - uint32_to_str(key_str, save.key); + char key_str[KEY_NAME_SIZE]; + key_name(key_str, save.key, save.is_rtc()); ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str); if (this->is_changed_(&this->db, save, key_str)) { ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size()); diff --git a/esphome/components/libretiny/preferences.h b/esphome/components/libretiny/preferences.h index fd86c48b20d..4b3737f07fd 100644 --- a/esphome/components/libretiny/preferences.h +++ b/esphome/components/libretiny/preferences.h @@ -1,6 +1,7 @@ #pragma once #ifdef USE_LIBRETINY +#include "esphome/core/defines.h" #include "esphome/core/preference_backend.h" #include @@ -12,9 +13,14 @@ class LibreTinyPreferences final : public PreferencesMixin public: using PreferencesMixin::make_preference; void open(); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash); +#else + // Without rtc storage every preference lives in flash; inline so builds that don't use rtc pay nothing ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { return this->make_preference(length, type); } +#endif ESPPreferenceObject make_preference(size_t length, uint32_t type); /// One-shot read of a stored preference by key, without allocating a backend bool load_from_key(uint32_t type, uint8_t *data, size_t len); @@ -25,8 +31,11 @@ class LibreTinyPreferences final : public PreferencesMixin struct fdb_blob blob; protected: - LibreTinyPreferenceBackend make_backend_(uint32_t type); + LibreTinyPreferenceBackend make_backend_(uint32_t type, bool rtc = false); bool is_changed_(fdb_kvdb_t db, const NVSData &to_save, const char *key_str); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + void discard_rtc_entries_(); +#endif }; void setup_preferences(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 935952d3b31..852ea3439a9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -717,6 +717,7 @@ #else #define USE_BK72XX_BLE_TRACKER #endif +#define USE_LIBRETINY_RTC_PREFERENCES #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_SUSPEND_LOOP #define USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/preferences.py b/esphome/preferences.py index c9a465d6252..b31dcecf874 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -3,8 +3,11 @@ Preferences can be stored either in flash (durable across power loss) or in RTC memory (fast, survives deep sleep and soft resets but not power loss). The flash-vs-RTC choice is only meaningful on platforms whose preferences backend -honors the ``in_flash`` flag — currently ESP32 and ESP8266. On other platforms -the value is accepted only as ``flash`` (the sole supported backend). +honors the ``in_flash`` flag — currently ESP32, ESP8266 and the Beken BK7231T, +BK7231N and BK7238. LibreTiny chips have no RTC memory: there ``rtc`` keeps the +preference in flash and discards it after a reset that RTC memory would not +survive. On other platforms the value is accepted only as ``flash`` (the sole +supported backend). Components include :func:`storage_schema` in their config and convert the chosen value with :func:`is_in_flash` when calling ``make_preference``. @@ -20,7 +23,7 @@ STORAGE_RTC = "rtc" def rtc_supported() -> bool: - """Whether the target has RTC-backed preferences (not ESP32-C2/C61); call at validation time.""" + """Whether the target has RTC-backed preferences (ESP8266, ESP32 except C2/C61, some Beken chips); call at validation time.""" if CORE.is_esp8266: return True if CORE.is_esp32: @@ -29,7 +32,26 @@ def rtc_supported() -> bool: # Mirrors the C++ SOC_RTC_MEM_SUPPORTED guard in the ESP32 backend return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) - return False + return _libretiny_rtc_supported() + + +def _libretiny_rtc_supported() -> bool: + """Whether this LibreTiny family accepts ``rtc``, emulated with flash plus the reset reason. + + The emulation is only as good as the family's ``lt_get_reboot_reason()``, so families are + listed once that reporting has been checked on hardware: a chip-enable reset and a power cut + both report a power-on, a software, watchdog or crash reset does not. + """ + if not CORE.is_libretiny: + return False + from esphome.components.libretiny import get_libretiny_family + from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231T, + FAMILY_BK7238, + ) + + return get_libretiny_family() in (FAMILY_BK7231T, FAMILY_BK7231N, FAMILY_BK7238) def _default_storage() -> str: @@ -58,15 +80,17 @@ def storage_schema(): def request_rtc_storage() -> None: - """Compile the RTC-backed storage into the ESP32 preferences backend. + """Compile the RTC-backed storage into the ESP32 or LibreTiny preferences backend. - The RTC storage region is left out of ESP32 builds unless something asks for - it, so unused builds don't reserve RTC memory. Call this from ``to_code`` - when a config option selects RTC storage. No-op on other platforms (ESP8266 - always has its RTC backend). + It is left out of ESP32 and LibreTiny builds unless something asks for it, so + unused builds don't reserve RTC memory or check the reset reason. Call this + from ``to_code`` when a config option selects RTC storage. No-op on other + platforms (ESP8266 always has its RTC backend). """ if CORE.is_esp32: cg.add_define("USE_ESP32_RTC_PREFERENCES") + elif _libretiny_rtc_supported(): + cg.add_define("USE_LIBRETINY_RTC_PREFERENCES") def validate_rtc_storage(value): diff --git a/tests/unit_tests/test_preferences.py b/tests/unit_tests/test_preferences.py index a1ab78bfbff..da56aa8d6fa 100644 --- a/tests/unit_tests/test_preferences.py +++ b/tests/unit_tests/test_preferences.py @@ -10,15 +10,26 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C3, VARIANT_ESP32C61, ) +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, + FAMILY_RTL8710B, + KEY_FAMILY, + KEY_LIBRETINY, +) import esphome.config_validation as cv from esphome.const import ( CONF_STORAGE, KEY_CORE, KEY_TARGET_PLATFORM, KEY_VARIANT, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, + PLATFORM_RTL87XX, ) from esphome.core import CORE @@ -191,3 +202,45 @@ def test_validate_storage_rejects_unknown_value() -> None: _set_esp32(VARIANT_ESP32) with pytest.raises(cv.Invalid): preferences.validate_storage("nvs") + + +def _set_libretiny(platform: str, family: str) -> None: + _set_platform(platform) + CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family} + + +@pytest.mark.parametrize("family", [FAMILY_BK7231T, FAMILY_BK7231N, FAMILY_BK7238]) +def test_rtc_allowed_on_tested_beken_families(family: str) -> None: + # Emulated with flash plus the reset reason, which was checked on hardware. + _set_libretiny(PLATFORM_BK72XX, family) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize( + ("platform", "family"), + [ + (PLATFORM_BK72XX, FAMILY_BK7251), + (PLATFORM_RTL87XX, FAMILY_RTL8710B), + ], +) +def test_rtc_rejected_on_other_libretiny_families(platform: str, family: str) -> None: + _set_libretiny(platform, family) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_default_storage_bk7231t_is_flash() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7231T) + assert _validate({})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +def test_request_rtc_storage_bk7231t_emits_define() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7231T) + preferences.request_rtc_storage() + assert "USE_LIBRETINY_RTC_PREFERENCES" in _define_names() + + +def test_request_rtc_storage_other_libretiny_family_is_a_no_op() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7251) + preferences.request_rtc_storage() + assert "USE_LIBRETINY_RTC_PREFERENCES" not in _define_names() From bb9067acd719ffc59915668004e6492ea2b3e291 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:07:16 +0200 Subject: [PATCH 079/120] [tfluna] Recover from transient runtime reconfiguration failures (#20992) Co-authored-by: J. Nick Koston --- esphome/components/tfluna/tfluna.cpp | 64 ++++-- esphome/components/tfluna/tfluna.h | 6 + tests/components/tfluna/test_tfluna.cpp | 282 ++++++++++++++++++++++++ 3 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 tests/components/tfluna/test_tfluna.cpp diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp index e3d7045d8e6..706213024d6 100644 --- a/esphome/components/tfluna/tfluna.cpp +++ b/esphome/components/tfluna/tfluna.cpp @@ -42,31 +42,25 @@ void TFLuna::dump_config() { #endif } -void TFLuna::setup() { +bool TFLuna::configure_() { uint8_t mode; if (!this->read_byte(MODE_REGISTER, &mode)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } if (mode != MODE_TRIGGER) { if (!this->write_byte(MODE_REGISTER, MODE_TRIGGER)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } if (!this->write_byte(SAVE_REGISTER, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } } uint8_t raw_version[3]; if (!this->read_bytes(VERSION_REVISION_REGISTER, raw_version, sizeof(raw_version))) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); - return; + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return true; } else { snprintf(this->version_, sizeof(this->version_), "%d.%d.%d", raw_version[2], raw_version[1], raw_version[0]); #ifdef USE_TEXT_SENSOR @@ -75,12 +69,26 @@ void TFLuna::setup() { } #endif } + return true; +} + +void TFLuna::setup() { + if (!this->configure_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void TFLuna::reconfigure_() { + this->reconfigure_pending_ = !this->configure_(); + if (this->reconfigure_pending_) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } } bool TFLuna::read_data_() { uint8_t buf[8]; if (!this->read_bytes(DISTANCE_LOW_REGISTER, buf, sizeof(buf))) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } // Layout: @@ -125,34 +133,50 @@ void TFLuna::read_data_timeout_() { this->attempt_++; this->set_timeout(READ_RETRY_TIMEOUT_ID, READ_RETRY_MS, [this]() { this->read_data_timeout_(); }); } else { - this->status_set_warning("Hung device, restarting..."); + this->status_set_warning(LOG_STR("Hung device, restarting...")); this->restart(); } } } void TFLuna::update() { + if (this->is_failed()) { + return; + } this->cancel_timeout(READ_RETRY_TIMEOUT_ID); this->attempt_ = 0; + if (this->reconfigure_pending_) { + this->reconfigure_(); + if (this->reconfigure_pending_) { + return; + } + } if (!this->write_byte(TRIGGER_ONESHOT_REGISTER, 0x01)) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->read_data_timeout_(); } void TFLuna::factory_reset() { - if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) { return; } - this->status_set_warning("Factory reset issued; waiting for device to become ready"); - this->set_timeout(SETUP_TIMEOUT_ID, 100, [this]() { this->setup(); }); + if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->reconfigure_pending_ = false; + this->status_set_warning(LOG_STR("Factory reset issued; waiting for device to become ready")); + this->set_timeout(SETUP_TIMEOUT_ID, 100, [this]() { this->reconfigure_(); }); } void TFLuna::restart() { + if (this->is_failed()) { + return; + } if (!this->write_byte(SHUTDOWN_REBOOT_REGISTER, 0x02)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/tfluna/tfluna.h b/esphome/components/tfluna/tfluna.h index 0cc866ef1df..84de5f05d58 100644 --- a/esphome/components/tfluna/tfluna.h +++ b/esphome/components/tfluna/tfluna.h @@ -51,7 +51,13 @@ class TFLuna : public i2c::I2CDevice, public PollingComponent { void read_data_timeout_(); + [[nodiscard]] bool configure_(); + + void reconfigure_(); + uint8_t attempt_ = 0; + + bool reconfigure_pending_ = false; }; } // namespace esphome::tfluna diff --git a/tests/components/tfluna/test_tfluna.cpp b/tests/components/tfluna/test_tfluna.cpp new file mode 100644 index 00000000000..30236263c74 --- /dev/null +++ b/tests/components/tfluna/test_tfluna.cpp @@ -0,0 +1,282 @@ +#include + +#include +#include +#include "esphome/components/tfluna/tfluna.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::tfluna::testing { + +static constexpr uint8_t TIMESTAMP_LOW = 0x06; +static constexpr uint8_t VERSION = 0x0A; +static constexpr uint8_t SAVE = 0x20; +static constexpr uint8_t RESTART = 0x21; +static constexpr uint8_t MODE = 0x23; +static constexpr uint8_t MODE_TRIGGER = 0x01; +static constexpr uint8_t TRIGGER = 0x24; +static constexpr uint8_t RESET = 0x29; +static constexpr uint8_t NO_FAULT = 0xFF; // outside the register file + +struct Fault { + bool read; + uint8_t reg; +}; + +// Register file behind read_register (register byte, then read) and write_register +// (register byte + data, no read). A fault is one NACK, then the bus heals. +class RegisterBus final : public i2c::I2CBus { + public: + std::array regs{}; + uint8_t fail_read{NO_FAULT}; + uint8_t fail_write{NO_FAULT}; + unsigned mode_writes{0}; + unsigned save_writes{0}; + + i2c::ErrorCode write_readv(uint8_t /*address*/, const uint8_t *out, size_t out_len, uint8_t *in, + size_t in_len) override { + if (out_len == 0 || out[0] + (out_len - 1) + in_len > this->regs.size()) + return i2c::ERROR_INVALID_ARGUMENT; + const uint8_t reg = out[0]; + if (out_len > 1) { + if (reg == this->fail_write) { + this->fail_write = NO_FAULT; + return i2c::ERROR_NOT_ACKNOWLEDGED; + } + std::copy(out + 1, out + out_len, this->regs.begin() + reg); + this->mode_writes += reg == MODE; + this->save_writes += reg == SAVE; + if (reg == RESET) + this->regs[MODE] = 0; // fixture choice: defaults are not trigger mode + if (reg == TRIGGER) + this->regs[TIMESTAMP_LOW]++; // fixture choice: every trigger yields a new frame + } + if (in_len != 0) { + if (reg == this->fail_read) { + this->fail_read = NO_FAULT; + return i2c::ERROR_NOT_ACKNOWLEDGED; + } + std::copy_n(this->regs.begin() + reg, in_len, in); + } + return i2c::ERROR_OK; + } +}; + +class TFLunaTest : public ::testing::Test { + protected: + void SetUp() override { + // App lives for the whole binary; a timer another test left would fire on a dead object in run_timers() + ASSERT_TRUE(scheduler_idle()); + this->owns_scheduler_ = true; + this->bus_.regs[VERSION] = 3; + this->bus_.regs[VERSION + 1] = 2; + this->bus_.regs[VERSION + 2] = 1; + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x10); + } + + // Timeouts capture &device_ and running one publishes it to App; drop both before the fixture dies + void TearDown() override { + if (!this->owns_scheduler_) + return; // SetUp already failed; leave the other test's timers unrun + App.scheduler.cancel_timeout(&this->device_, uint32_t{0}); + App.scheduler.cancel_timeout(&this->device_, uint32_t{1}); + App.scheduler.call(millis()); + EXPECT_FALSE(App.scheduler.next_schedule_in(millis()).has_value()); + // Recycled items hold no callback; free them so a later App reset has none of ours to lose + App.scheduler.trim_freelist(); + LoopBlockingGuard clear{nullptr, nullptr, millis()}; + } + + // Real clock past the existing 100 ms reset timeout; no new device timing + // Timers are serviced like the main loop during the real wait instead of one long sleep + static void run_timers() { + const uint32_t start = millis(); + while (millis() - start < 110) { + App.scheduler.call(millis()); + delay(1); + } + App.scheduler.call(millis()); + } + + // Threaded hosts' next_schedule_in() skips timers still staged for the heap; merging them first runs no callback + static bool scheduler_idle() { + App.scheduler.process_to_add(); + return !App.scheduler.next_schedule_in(millis()).has_value(); + } + + void fail_at_(Fault fault) { (fault.read ? this->bus_.fail_read : this->bus_.fail_write) = fault.reg; } + + // One NACK at the read-mode, write-mode or SAVE step; fatal at boot, retried by the next update at runtime + void expect_boot_nack_marks_failed_(Fault fault) { + this->fail_at_(fault); + this->device_.setup(); + EXPECT_TRUE(this->device_.is_failed()); + } + + void expect_runtime_nack_recovers_(Fault fault) { + this->device_.setup(); + this->device_.factory_reset(); + this->fail_at_(fault); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); + } + + RegisterBus bus_; + TFLuna device_; + bool owns_scheduler_{false}; +}; + +// A timeout another suite left staged must block ownership before any callback can run +TEST_F(TFLunaTest, StagedForeignTimeoutBlocksOwnership) { + TFLuna foreign; + bool fired = false; + App.scheduler.set_timeout(&foreign, uint32_t{0}, 1000, [&fired]() { fired = true; }); + EXPECT_FALSE(scheduler_idle()); + EXPECT_FALSE(fired); + // Drop the timer before foreign and fired leave scope; no ASSERT may return early above this + EXPECT_TRUE(App.scheduler.cancel_timeout(&foreign, uint32_t{0})); + App.scheduler.call(millis()); + EXPECT_FALSE(fired); + EXPECT_TRUE(scheduler_idle()); +} + +TEST_F(TFLunaTest, BootSelectsTriggerModeAndSaves) { + this->device_.setup(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 1u); + EXPECT_EQ(this->bus_.save_writes, 1u); +} + +TEST_F(TFLunaTest, NormalFactoryResetRestoresTriggerMode) { + this->device_.setup(); + this->device_.factory_reset(); + EXPECT_TRUE(this->device_.status_has_warning()); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_EQ(this->bus_.save_writes, 2u); + this->device_.update(); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, ResetWriteNackSchedulesNothing) { + this->device_.setup(); + this->bus_.fail_write = RESET; + this->device_.factory_reset(); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + EXPECT_EQ(this->bus_.mode_writes, 1u); + this->device_.update(); // no reconfigure was left pending; the next good read clears the warning + EXPECT_EQ(this->bus_.mode_writes, 1u); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, DoublePressInWindowReconfiguresOnce) { + this->device_.setup(); + this->device_.factory_reset(); + this->device_.factory_reset(); + run_timers(); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); +} + +// Original: the first NACK marks the component failed, so Scheduler::call() drops the second reset's timeout +TEST_F(TFLunaTest, ResetWhileReconfigurePendingWaitsForWindowThenRecovers) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->device_.factory_reset(); + this->device_.update(); // inside the 100 ms window: no early reconfigure + EXPECT_EQ(this->bus_.mode_writes, 1u); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 2u); + this->device_.update(); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, ResetWriteNackKeepsPendingReconfigure) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->bus_.fail_write = RESET; + this->device_.factory_reset(); + run_timers(); + this->device_.update(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, UpdateSkipsPollingWhileReconfigureFails) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->bus_.fail_read = MODE; + this->device_.update(); + EXPECT_EQ(this->bus_.regs[TIMESTAMP_LOW], 0); // no trigger issued + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, RestartWriteNackWarnsThenNextReadClears) { + this->device_.setup(); + this->bus_.fail_write = RESTART; + this->device_.restart(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +// A boot failure is permanent; runtime entry points must not touch the bus +TEST_F(TFLunaTest, FailedComponentSkipsRuntimeWrites) { + this->bus_.fail_read = MODE; + this->device_.setup(); + ASSERT_TRUE(this->device_.is_failed()); + this->device_.factory_reset(); + this->device_.restart(); + this->device_.update(); + EXPECT_TRUE(this->device_.is_failed()); + EXPECT_FALSE(this->device_.status_has_warning()); + EXPECT_EQ(this->bus_.regs[RESET], 0); + EXPECT_EQ(this->bus_.regs[RESTART], 0); + EXPECT_EQ(this->bus_.regs[TIMESTAMP_LOW], 0); + EXPECT_EQ(this->bus_.mode_writes, 0u); +} + +TEST_F(TFLunaTest, BootReadModeNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{true, MODE}); } + +TEST_F(TFLunaTest, BootWriteModeNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{false, MODE}); } + +TEST_F(TFLunaTest, BootSaveNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{false, SAVE}); } + +TEST_F(TFLunaTest, RuntimeReadModeNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{true, MODE}); +} + +TEST_F(TFLunaTest, RuntimeWriteModeNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{false, MODE}); +} + +TEST_F(TFLunaTest, RuntimeSaveNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{false, SAVE}); +} + +} // namespace esphome::tfluna::testing From e6f1ed8d510a9d8b5b3bae431df4d9f117c632aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:07:42 -1000 Subject: [PATCH 080/120] [tcp_uart] Expose the connection entities as platforms (#21009) --- esphome/components/tcp_uart/__init__.py | 29 +++-------------- esphome/components/tcp_uart/binary_sensor.py | 25 +++++++++++++++ esphome/components/tcp_uart/sensor.py | 31 +++++++++++++++++++ esphome/components/tcp_uart/tcp_uart.cpp | 6 ++++ esphome/components/tcp_uart/tcp_uart.h | 7 +++++ tests/components/tcp_uart/benchmark.yaml | 5 +-- tests/components/tcp_uart/common.yaml | 8 +++++ .../tcp_uart/test-client.esp32-idf.yaml | 8 +++++ .../integration/fixtures/tcp_uart_bridge.yaml | 4 +++ 9 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 esphome/components/tcp_uart/binary_sensor.py create mode 100644 esphome/components/tcp_uart/sensor.py diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index df9c1720ee6..1e120275602 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_DATA_BITS, CONF_HOST, CONF_PARITY, @@ -11,23 +10,16 @@ from esphome.components.const import ( CONF_STOP_BITS, ) import esphome.config_validation as cv -from esphome.const import ( - CONF_BAUD_RATE, - CONF_ID, - CONF_PORT, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, -) +from esphome.const import CONF_BAUD_RATE, CONF_ID, CONF_PORT from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DEPENDENCIES = ["network"] -AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["uart", "socket"] DOMAIN = "tcp_uart" MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_TCP_UART_ID = "tcp_uart_id" tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart") TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) @@ -46,15 +38,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -100,7 +83,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_parity(config[CONF_PARITY])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/binary_sensor.py b/esphome/components/tcp_uart/binary_sensor.py new file mode 100644 index 00000000000..8337973c837 --- /dev/null +++ b/esphome/components/tcp_uart/binary_sensor.py @@ -0,0 +1,25 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/tcp_uart/sensor.py b/esphome/components/tcp_uart/sensor.py new file mode 100644 index 00000000000..099ca8734e1 --- /dev/null +++ b/esphome/components/tcp_uart/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp index 3dead1fb65c..fa465aa55a8 100644 --- a/esphome/components/tcp_uart/tcp_uart.cpp +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -17,9 +17,11 @@ void TcpUart::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -38,7 +40,9 @@ void TcpUart::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -58,9 +62,11 @@ void TcpUart::sync_link_() { // Unread bytes of the last session stay readable while down, never into the next one. this->rx_start_ = this->rx_end_ = 0; } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h index 1e1d48e4c20..6ecfd1a0862 100644 --- a/esphome/components/tcp_uart/tcp_uart.h +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -23,7 +26,9 @@ class TcpUart : public uart::UARTComponent, public Component { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -64,7 +69,9 @@ class TcpUart : public uart::UARTComponent, public Component { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif #ifdef USE_SENSOR sensor::Sensor *disconnects_sensor_{nullptr}; uint32_t disconnects_{0}; diff --git a/tests/components/tcp_uart/benchmark.yaml b/tests/components/tcp_uart/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/tcp_uart/benchmark.yaml +++ b/tests/components/tcp_uart/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/tcp_uart/common.yaml b/tests/components/tcp_uart/common.yaml index 97ac0247da3..97bcd24f6a7 100644 --- a/tests/components/tcp_uart/common.yaml +++ b/tests/components/tcp_uart/common.yaml @@ -10,8 +10,16 @@ tcp_uart: allowed_ips: - 192.0.2.20 - 192.0.2.0/24 + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server connected: name: TCP UART Server Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server disconnects: name: TCP UART Server Disconnects diff --git a/tests/components/tcp_uart/test-client.esp32-idf.yaml b/tests/components/tcp_uart/test-client.esp32-idf.yaml index 74bd26a98bc..921ce0cbc1a 100644 --- a/tests/components/tcp_uart/test-client.esp32-idf.yaml +++ b/tests/components/tcp_uart/test-client.esp32-idf.yaml @@ -7,7 +7,15 @@ tcp_uart: host: 192.0.2.10 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: TCP UART Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 disconnects: name: TCP UART Disconnects diff --git a/tests/integration/fixtures/tcp_uart_bridge.yaml b/tests/integration/fixtures/tcp_uart_bridge.yaml index 12791e7dd46..74a8710b19e 100644 --- a/tests/integration/fixtures/tcp_uart_bridge.yaml +++ b/tests/integration/fixtures/tcp_uart_bridge.yaml @@ -20,6 +20,10 @@ tcp_uart: host: 127.0.0.1 port: 18125 reconnect_interval: 3s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: Bridge Connected From 42e849967a94eb8f82f6654b88cb142c05d8be3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:14 -1000 Subject: [PATCH 081/120] [bq76972] Expose the sensors as a sensor platform (#21006) --- esphome/components/bq76972/__init__.py | 60 +--------------- esphome/components/bq76972/bq76972.cpp | 8 +++ esphome/components/bq76972/bq76972.h | 9 ++- esphome/components/bq76972/sensor.py | 73 ++++++++++++++++++++ tests/components/bq76972/common.yaml | 96 ++++++++++++++------------ 5 files changed, 142 insertions(+), 104 deletions(-) create mode 100644 esphome/components/bq76972/sensor.py diff --git a/esphome/components/bq76972/__init__.py b/esphome/components/bq76972/__init__.py index 672d6428d26..46f674da269 100644 --- a/esphome/components/bq76972/__init__.py +++ b/esphome/components/bq76972/__init__.py @@ -1,18 +1,10 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import i2c, sensor +from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ADDRESS, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, - UNIT_CELSIUS, - UNIT_VOLT, -) +from esphome.const import CONF_ADDRESS from esphome.types import ConfigType -AUTO_LOAD = ["sensor"] CODEOWNERS = ["@limpkin"] DEPENDENCIES = ["i2c"] DOMAIN = "bq76972" @@ -25,37 +17,7 @@ BQ76972Component = bq76972_ns.class_( CONF_REG_DISABLED = "reg_disabled" CONF_CRC_ENABLED = "crc_enabled" -CONF_STACK_VOLTAGE = "stack_voltage" - -# Ordered as the chip reports them, starting at command 0x68 -CONF_TEMPERATURES = [ - "internal_temperature", - "cfetoff_temperature", - "dfetoff_temperature", - "alert_temperature", - "ts1_temperature", - "ts2_temperature", - "ts3_temperature", - "hdq_temperature", - "dchg_temperature", - "ddsg_temperature", -] - -CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)] - -voltage_sensor_schema = sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=3, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, -) - -temp_sensor_schema = sensor.sensor_schema( - unit_of_measurement=UNIT_CELSIUS, - accuracy_decimals=1, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, -) +CONF_BQ76972_ID = "bq76972_id" CONFIG_SCHEMA = ( cv.Schema( @@ -63,9 +25,6 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(BQ76972Component), cv.Optional(CONF_CRC_ENABLED, default=False): cv.boolean, cv.Optional(CONF_REG_DISABLED, default=False): cv.boolean, - cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema, - **{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES}, - **{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES}, } ) .extend(i2c.i2c_device_schema(0x08)) @@ -79,19 +38,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED])) cg.add(var.set_reg_disable(config[CONF_REG_DISABLED])) - sensors = sensor.sub_sensors(config) - await sensors(CONF_STACK_VOLTAGE, var.set_stack_voltage_sensor) - - for idx, key in enumerate(CONF_TEMPERATURES): - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(var.set_temperature_sensor(sens, idx)) - - for idx, key in enumerate(CONF_CELL_VOLTAGES): - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(var.set_cell_sensor(sens, idx)) - BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( { diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp index ea229307d5c..9426fbb2526 100644 --- a/esphome/components/bq76972/bq76972.cpp +++ b/esphome/components/bq76972/bq76972.cpp @@ -172,10 +172,12 @@ const LogString *BQ76972Component::configure_() { const bool write_regs = !regs_disabled; bool write_thermistors = false; +#ifdef USE_SENSOR for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { if (i != BQ76972_TS1_INDEX && this->temperature_sensors_[i] != nullptr) write_thermistors = true; } +#endif if (write_regs || write_thermistors) { if (!this->enter_cfg_update_()) @@ -201,12 +203,14 @@ const LogString *BQ76972Component::write_config_(bool write_regs) { !this->write_subcommand_(BQ76972_MEM_REG0_CONFIG, &zero, 1)) return LOG_STR("Writing regulator config failed"); } +#ifdef USE_SENSOR for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { if (i == BQ76972_TS1_INDEX || this->temperature_sensors_[i] == nullptr) continue; if (!this->write_subcommand_(BQ76972_MEM_TEMP_PIN_CONFIG_BASE + i, &BQ76972_PIN_CONFIG_THERMISTOR, 1)) return LOG_STR("Writing thermistor pin config failed"); } +#endif return nullptr; } @@ -248,6 +252,7 @@ void BQ76972Component::program_i2c_address(uint8_t address) { ESP_LOGI(TAG, "I2C address set to 0x%02X", address); } +#ifdef USE_SENSOR template static bool any_set(const std::array &sensors) { for (auto *sensor : sensors) { if (sensor != nullptr) @@ -294,5 +299,8 @@ void BQ76972Component::update() { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } +#else +void BQ76972Component::update() {} +#endif } // namespace esphome::bq76972 diff --git a/esphome/components/bq76972/bq76972.h b/esphome/components/bq76972/bq76972.h index d27a4ce96f4..ccc4f45cfaf 100644 --- a/esphome/components/bq76972/bq76972.h +++ b/esphome/components/bq76972/bq76972.h @@ -1,8 +1,11 @@ #pragma once -#include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif #include @@ -19,9 +22,11 @@ class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; } void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; } +#ifdef USE_SENSOR void set_stack_voltage_sensor(sensor::Sensor *s) { this->stack_voltage_sensor_ = s; } void set_cell_sensor(sensor::Sensor *s, size_t index) { this->cell_sensors_[index] = s; } void set_temperature_sensor(sensor::Sensor *s, size_t index) { this->temperature_sensors_[index] = s; } +#endif void program_i2c_address(uint8_t address); @@ -43,9 +48,11 @@ class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { bool reg_disable_{false}; bool crc_mode_{false}; +#ifdef USE_SENSOR sensor::Sensor *stack_voltage_sensor_{nullptr}; std::array cell_sensors_{}; std::array temperature_sensors_{}; +#endif }; } // namespace esphome::bq76972 diff --git a/esphome/components/bq76972/sensor.py b/esphome/components/bq76972/sensor.py new file mode 100644 index 00000000000..0c7efc41235 --- /dev/null +++ b/esphome/components/bq76972/sensor.py @@ -0,0 +1,73 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_VOLT, +) +from esphome.types import ConfigType + +from . import CONF_BQ76972_ID, BQ76972Component + +DEPENDENCIES = ["bq76972"] + +CONF_STACK_VOLTAGE = "stack_voltage" + +# Ordered as the chip reports them, starting at command 0x68 +CONF_TEMPERATURES = [ + "internal_temperature", + "cfetoff_temperature", + "dfetoff_temperature", + "alert_temperature", + "ts1_temperature", + "ts2_temperature", + "ts3_temperature", + "hdq_temperature", + "dchg_temperature", + "ddsg_temperature", +] + +CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)] + +voltage_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) + +temp_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_BQ76972_ID): cv.use_id(BQ76972Component), + cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema, + **{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES}, + **{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES}, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ76972_ID]) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_STACK_VOLTAGE, hub.set_stack_voltage_sensor) + + for idx, key in enumerate(CONF_TEMPERATURES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_temperature_sensor(sens, idx)) + + for idx, key in enumerate(CONF_CELL_VOLTAGES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_cell_sensor(sens, idx)) diff --git a/tests/components/bq76972/common.yaml b/tests/components/bq76972/common.yaml index 4b2e62eb20b..7270a5ce03c 100644 --- a/tests/components/bq76972/common.yaml +++ b/tests/components/bq76972/common.yaml @@ -1,60 +1,64 @@ bq76972: - id: bq76972_id + id: bms i2c_id: i2c_bus address: 0x08 crc_enabled: true reg_disabled: true update_interval: 10s - stack_voltage: - name: "Pack Total Voltage" - internal_temperature: - name: "Temperature - Internal" - ts1_temperature: - name: "Temperature - Pin TS1" - ts2_temperature: - name: "Temperature - Pin TS2" - ts3_temperature: - name: "Temperature - Pin TS3" - hdq_temperature: - name: "Temperature - Pin HDQ" - cell_1_voltage: - name: "Cell 1 Voltage" - cell_2_voltage: - name: "Cell 2 Voltage" - cell_3_voltage: - name: "Cell 3 Voltage" - cell_4_voltage: - name: "Cell 4 Voltage" - cell_5_voltage: - name: "Cell 5 Voltage" - cell_6_voltage: - name: "Cell 6 Voltage" - cell_7_voltage: - name: "Cell 7 Voltage" - cell_8_voltage: - name: "Cell 8 Voltage" - cell_9_voltage: - name: "Cell 9 Voltage" - cell_10_voltage: - name: "Cell 10 Voltage" - cell_11_voltage: - name: "Cell 11 Voltage" - cell_12_voltage: - name: "Cell 12 Voltage" - cell_13_voltage: - name: "Cell 13 Voltage" - cell_14_voltage: - name: "Cell 14 Voltage" - cell_15_voltage: - name: "Cell 15 Voltage" - cell_16_voltage: - name: "Cell 16 Voltage" button: - platform: template name: "Program BQ I2C Address" on_press: - bq76972.program_i2c_address: - id: bq76972_id + id: bms address: 0x09 - bq76972.program_i2c_address: !lambda "return 0x08;" + +sensor: + - platform: bq76972 + bq76972_id: bms + stack_voltage: + name: "Pack Total Voltage" + internal_temperature: + name: "Temperature - Internal" + ts1_temperature: + name: "Temperature - Pin TS1" + ts2_temperature: + name: "Temperature - Pin TS2" + ts3_temperature: + name: "Temperature - Pin TS3" + hdq_temperature: + name: "Temperature - Pin HDQ" + cell_1_voltage: + name: "Cell 1 Voltage" + cell_2_voltage: + name: "Cell 2 Voltage" + cell_3_voltage: + name: "Cell 3 Voltage" + cell_4_voltage: + name: "Cell 4 Voltage" + cell_5_voltage: + name: "Cell 5 Voltage" + cell_6_voltage: + name: "Cell 6 Voltage" + cell_7_voltage: + name: "Cell 7 Voltage" + cell_8_voltage: + name: "Cell 8 Voltage" + cell_9_voltage: + name: "Cell 9 Voltage" + cell_10_voltage: + name: "Cell 10 Voltage" + cell_11_voltage: + name: "Cell 11 Voltage" + cell_12_voltage: + name: "Cell 12 Voltage" + cell_13_voltage: + name: "Cell 13 Voltage" + cell_14_voltage: + name: "Cell 14 Voltage" + cell_15_voltage: + name: "Cell 15 Voltage" + cell_16_voltage: + name: "Cell 16 Voltage" From e034662dc6cf1f82fc19bb15d5a9d9ef109da6f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:32 -1000 Subject: [PATCH 082/120] [uart_tcp] Expose the connection entities as platforms (#21008) --- esphome/components/uart_tcp/__init__.py | 23 ++------------ esphome/components/uart_tcp/binary_sensor.py | 25 +++++++++++++++ esphome/components/uart_tcp/sensor.py | 31 +++++++++++++++++++ esphome/components/uart_tcp/uart_tcp.cpp | 6 ++++ esphome/components/uart_tcp/uart_tcp.h | 7 +++++ tests/components/uart_tcp/benchmark.yaml | 5 +-- tests/components/uart_tcp/common.yaml | 8 +++++ .../uart_tcp/test-client.esp32-idf.yaml | 8 +++++ tests/components/uart_tcp/test.host.yaml | 8 +++++ .../integration/fixtures/uart_tcp_bridge.yaml | 8 +++-- 10 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 esphome/components/uart_tcp/binary_sensor.py create mode 100644 esphome/components/uart_tcp/sensor.py diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 9d62d48d7c2..c7eba4a26a5 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_HOST, CONF_RECONNECT_INTERVAL, CONF_ROLE, @@ -15,9 +14,6 @@ from esphome.const import ( CONF_ID, CONF_PORT, CONF_UART_ID, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, ) from esphome.core import CORE import esphome.final_validate as fv @@ -26,10 +22,10 @@ from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DOMAIN = "uart_tcp" DEPENDENCIES = ["network", "uart"] -AUTO_LOAD = ["binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["socket"] MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_UART_TCP_ID = "uart_tcp_id" uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp") UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice) @@ -43,15 +39,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -144,7 +131,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/binary_sensor.py b/esphome/components/uart_tcp/binary_sensor.py new file mode 100644 index 00000000000..c8d19319337 --- /dev/null +++ b/esphome/components/uart_tcp/binary_sensor.py @@ -0,0 +1,25 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/uart_tcp/sensor.py b/esphome/components/uart_tcp/sensor.py new file mode 100644 index 00000000000..ad44b56ad09 --- /dev/null +++ b/esphome/components/uart_tcp/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp index d634ac579f2..ca6c2204bdd 100644 --- a/esphome/components/uart_tcp/uart_tcp.cpp +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -16,9 +16,11 @@ void UartTcp::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -37,7 +39,9 @@ void UartTcp::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -57,9 +61,11 @@ void UartTcp::sync_link_() { // The driver kept whatever arrived while the link was down. this->discard_uart_(); } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/uart_tcp/uart_tcp.h b/esphome/components/uart_tcp/uart_tcp.h index 256e059133a..6080cc95514 100644 --- a/esphome/components/uart_tcp/uart_tcp.h +++ b/esphome/components/uart_tcp/uart_tcp.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -22,7 +25,9 @@ class UartTcp final : public Component, public uart::UARTDevice { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -53,7 +58,9 @@ class UartTcp final : public Component, public uart::UARTDevice { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif // Loop start time of the last socket-to-UART write; sizes the next paced write. uint32_t last_write_ms_{0}; #ifdef USE_SENSOR diff --git a/tests/components/uart_tcp/benchmark.yaml b/tests/components/uart_tcp/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/uart_tcp/benchmark.yaml +++ b/tests/components/uart_tcp/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/uart_tcp/common.yaml b/tests/components/uart_tcp/common.yaml index fd34757d0ee..cf86dc9da89 100644 --- a/tests/components/uart_tcp/common.yaml +++ b/tests/components/uart_tcp/common.yaml @@ -10,7 +10,15 @@ uart_tcp: allowed_ips: - 192.168.1.10 - 192.168.1.0/24 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test-client.esp32-idf.yaml b/tests/components/uart_tcp/test-client.esp32-idf.yaml index 5ba900347c4..6bce693f854 100644 --- a/tests/components/uart_tcp/test-client.esp32-idf.yaml +++ b/tests/components/uart_tcp/test-client.esp32-idf.yaml @@ -12,7 +12,15 @@ uart_tcp: host: 192.0.2.20 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test.host.yaml b/tests/components/uart_tcp/test.host.yaml index a0b82c568df..b793ae144c3 100644 --- a/tests/components/uart_tcp/test.host.yaml +++ b/tests/components/uart_tcp/test.host.yaml @@ -8,7 +8,15 @@ uart_tcp: uart_id: uart_bus host: 127.0.0.1 port: 44502 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/integration/fixtures/uart_tcp_bridge.yaml b/tests/integration/fixtures/uart_tcp_bridge.yaml index 7ad28316f75..cf6c8fad64b 100644 --- a/tests/integration/fixtures/uart_tcp_bridge.yaml +++ b/tests/integration/fixtures/uart_tcp_bridge.yaml @@ -24,11 +24,15 @@ uart_tcp: port: 18126 allowed_ips: - 127.0.0.1 - connected: - name: Bridge Connected - id: denied_bridge uart_id: denied_bus role: server port: 18127 allowed_ips: - 192.0.2.1 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge + connected: + name: Bridge Connected From 1197f22f0067961440d07af468c59a38a6e7f420 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:49 -1000 Subject: [PATCH 083/120] [fusb302b] Expose the sensors as platforms (#21004) --- esphome/components/fusb302b/__init__.py | 37 +----------------- esphome/components/fusb302b/sensor.py | 45 ++++++++++++++++++++++ esphome/components/fusb302b/text_sensor.py | 27 +++++++++++++ tests/components/fusb302b/common.yaml | 20 +++++++--- 4 files changed, 88 insertions(+), 41 deletions(-) create mode 100644 esphome/components/fusb302b/sensor.py create mode 100644 esphome/components/fusb302b/text_sensor.py diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index 26bf49b28e3..a1770605498 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -1,30 +1,21 @@ from esphome import automation, pins import esphome.codegen as cg -from esphome.components import i2c, sensor, text_sensor +from esphome.components import i2c import esphome.config_validation as cv from esphome.const import ( - CONF_CURRENT, 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_FUSB302B_ID = "fusb302b_id" CONF_ON_POWER_READY = "on_power_ready" CONF_ON_REQUEST_FAILED = "on_request_failed" CONF_REQUEST_VOLTAGE = "request_voltage" @@ -47,24 +38,6 @@ 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({}), @@ -101,12 +74,6 @@ async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config, interrupt_pin) 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) diff --git a/esphome/components/fusb302b/sensor.py b/esphome/components/fusb302b/sensor.py new file mode 100644 index 00000000000..34dd35a60c7 --- /dev/null +++ b/esphome/components/fusb302b/sensor.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_CURRENT, + CONF_VOLTAGE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_VOLT, +) +from esphome.types import ConfigType + +from . import CONF_FUSB302B_ID, FUSB302B + +DEPENDENCIES = ["fusb302b"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_FUSB302B_ID): cv.use_id(FUSB302B), + 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, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_FUSB302B_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, hub.set_voltage_sensor) + await sensors(CONF_CURRENT, hub.set_current_sensor) diff --git a/esphome/components/fusb302b/text_sensor.py b/esphome/components/fusb302b/text_sensor.py new file mode 100644 index 00000000000..ee18af469e4 --- /dev/null +++ b/esphome/components/fusb302b/text_sensor.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_FLASH +from esphome.types import ConfigType + +from . import CONF_FUSB302B_ID, FUSB302B + +DEPENDENCIES = ["fusb302b"] + +CONF_CONTRACT = "contract" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_FUSB302B_ID): cv.use_id(FUSB302B), + cv.Optional(CONF_CONTRACT): text_sensor.text_sensor_schema( + icon=ICON_FLASH, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_FUSB302B_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_CONTRACT, hub.set_contract_text_sensor) diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml index e2c4ee87ca7..60c8cc615e9 100644 --- a/tests/components/fusb302b/common.yaml +++ b/tests/components/fusb302b/common.yaml @@ -3,12 +3,6 @@ fusb302b: i2c_id: i2c_bus 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: @@ -26,3 +20,17 @@ fusb302b: - fusb302b.request_voltage: id: pd voltage: !lambda "return 9;" + +sensor: + - platform: fusb302b + fusb302b_id: pd + voltage: + name: PD Voltage + current: + name: PD Current + +text_sensor: + - platform: fusb302b + fusb302b_id: pd + contract: + name: PD Contract From 3b7847d951a9cc70a8153885df2d254b600424c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:13:49 -1000 Subject: [PATCH 084/120] [components] Create SPI devices with new_spi_component (#20920) --- esphome/components/adc128s102/__init__.py | 5 +---- esphome/components/ads1118/__init__.py | 5 +---- esphome/components/atm90e26/sensor.py | 5 +---- esphome/components/cc1101/__init__.py | 4 +--- esphome/components/cs5460a/sensor.py | 4 +--- esphome/components/honeywellabp/sensor.py | 5 +---- esphome/components/mcp3008/__init__.py | 5 +---- esphome/components/sx126x/__init__.py | 4 +--- esphome/components/sx127x/__init__.py | 4 +--- 9 files changed, 9 insertions(+), 32 deletions(-) diff --git a/esphome/components/adc128s102/__init__.py b/esphome/components/adc128s102/__init__.py index 125b4113c57..56df70a170c 100644 --- a/esphome/components/adc128s102/__init__.py +++ b/esphome/components/adc128s102/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -20,6 +19,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/ads1118/__init__.py b/esphome/components/ads1118/__init__.py index 98129ecbea3..e7cc1b4f45a 100644 --- a/esphome/components/ads1118/__init__.py +++ b/esphome/components/ads1118/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@solomondg1"] @@ -26,6 +25,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/atm90e26/sensor.py b/esphome/components/atm90e26/sensor.py index 5a6b667535f..ed116faaba0 100644 --- a/esphome/components/atm90e26/sensor.py +++ b/esphome/components/atm90e26/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CURRENT, CONF_FORWARD_ACTIVE_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_LINE_FREQUENCY, CONF_POWER, CONF_POWER_FACTOR, @@ -125,9 +124,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 2660d5bbde7..5375d6bf396 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -274,9 +274,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) for opt in CONFIG_MAP: key = opt.schema diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 54e452efc88..eab99fde147 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -103,9 +103,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) cg.add(var.set_samples(config[CONF_SAMPLES])) cg.add(var.set_phase_offset(config[CONF_PHASE_OFFSET])) diff --git a/esphome/components/honeywellabp/sensor.py b/esphome/components/honeywellabp/sensor.py index d7e7cf29a4e..2549610b8ed 100644 --- a/esphome/components/honeywellabp/sensor.py +++ b/esphome/components/honeywellabp/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, spi import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -52,9 +51,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) sensors = sensor.sub_sensors(config) if await sensors(CONF_PRESSURE, var.set_pressure_sensor): diff --git a/esphome/components/mcp3008/__init__.py b/esphome/components/mcp3008/__init__.py index f29050f9fe5..aa3fb5c64ef 100644 --- a/esphome/components/mcp3008/__init__.py +++ b/esphome/components/mcp3008/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -22,6 +21,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 075aad534e7..3af042ea72b 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -238,9 +238,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) if CONF_ON_PACKET in config: await automation.build_automation( var.get_packet_trigger(), diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 4b54c646338..08660415a62 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -221,9 +221,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) if CONF_ON_PACKET in config: await automation.build_automation( var.get_packet_trigger(), From ec9a207f42c7869d64daa1c49c95a68e3ef23681 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:15:21 -1000 Subject: [PATCH 085/120] [components] Create UART devices with new_uart_component (#20919) --- esphome/components/apc1/sensor.py | 5 +---- esphome/components/bl0906/sensor.py | 4 +--- esphome/components/bl0939/sensor.py | 5 +---- esphome/components/bl0940/sensor.py | 5 +---- esphome/components/bl0942/sensor.py | 5 +---- esphome/components/cm1106/sensor.py | 5 +---- esphome/components/cse7761/sensor.py | 5 +---- esphome/components/cse7766/sensor.py | 5 +---- esphome/components/daly_bms/__init__.py | 6 ++---- esphome/components/dfplayer/__init__.py | 6 ++---- esphome/components/dfrobot_sen0395/__init__.py | 4 +--- esphome/components/dfrobot_sen0609/__init__.py | 4 +--- esphome/components/gcja5/sensor.py | 5 +---- esphome/components/gps/__init__.py | 4 +--- esphome/components/hc8/sensor.py | 4 +--- esphome/components/hlk_fm22x/__init__.py | 5 +---- esphome/components/hlw8032/sensor.py | 5 +---- esphome/components/hydreon_rgxx/sensor.py | 5 +---- esphome/components/kamstrup_kmp/sensor.py | 5 +---- esphome/components/ld2410/__init__.py | 4 +--- esphome/components/ld2410s/__init__.py | 5 +---- esphome/components/ld2412/__init__.py | 6 ++---- esphome/components/ld2420/__init__.py | 5 +---- esphome/components/ld2450/__init__.py | 6 ++---- esphome/components/ld2460/__init__.py | 6 ++---- esphome/components/ld6002b/__init__.py | 6 ++---- esphome/components/mhz19/sensor.py | 4 +--- esphome/components/micronova/__init__.py | 5 +---- esphome/components/mitsubishi_cn105/__init__.py | 4 +--- esphome/components/mk2pvrouter/__init__.py | 6 ++---- esphome/components/pipsolar/__init__.py | 5 +---- esphome/components/pm1006/sensor.py | 5 +---- esphome/components/pmsx003/sensor.py | 5 +---- esphome/components/pylontech/__init__.py | 5 +---- esphome/components/pzem004t/sensor.py | 5 +---- esphome/components/rd03d/__init__.py | 6 ++---- esphome/components/rdm6300/__init__.py | 6 ++---- esphome/components/rf_bridge/__init__.py | 5 +---- esphome/components/sds011/sensor.py | 5 +---- esphome/components/seeed_mr60bha2/__init__.py | 5 +---- esphome/components/seeed_mr60fda2/__init__.py | 5 +---- esphome/components/senseair/sensor.py | 4 +--- esphome/components/serial_proxy/__init__.py | 6 ++---- esphome/components/sim800l/__init__.py | 6 ++---- esphome/components/sm300d2/sensor.py | 5 +---- esphome/components/sml/__init__.py | 6 ++---- esphome/components/smt100/sensor.py | 5 +---- esphome/components/sun_gtil2/__init__.py | 5 +---- esphome/components/systa_bus/__init__.py | 5 +---- esphome/components/t6615/sensor.py | 5 +---- esphome/components/teleinfo/__init__.py | 5 +---- esphome/components/tmc22xx/__init__.py | 4 +--- esphome/components/tuya/__init__.py | 6 ++---- esphome/components/uart_tcp/__init__.py | 4 +--- esphome/components/ufm01/__init__.py | 4 +--- esphome/components/uponor_smatrix/__init__.py | 6 ++---- esphome/components/vbus/__init__.py | 5 +---- esphome/components/zwave_proxy/__init__.py | 6 ++---- 58 files changed, 73 insertions(+), 220 deletions(-) diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py index b64a49903f2..f3f7e0203f9 100644 --- a/esphome/components/apc1/sensor.py +++ b/esphome/components/apc1/sensor.py @@ -7,7 +7,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ECO2, CONF_HUMIDITY, - CONF_ID, CONF_PM_0_3UM, CONF_PM_0_5UM, CONF_PM_1_0, @@ -165,9 +164,7 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 25c50555dbc..d8e2f092c06 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -147,9 +147,7 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FREQUENCY, var.set_frequency_sensor) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 5e33759dea4..45a2e6e983d 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_MODE, CONF_VOLTAGE, @@ -112,9 +111,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if (mode := config[CONF_MODE]) != DEFAULT_MODE: cg.add(var.set_mode(mode)) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 1cf7e8e6e38..962abf636d1 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CURRENT, CONF_ENERGY, CONF_EXTERNAL_TEMPERATURE, - CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_POWER, CONF_REFERENCE_VOLTAGE, @@ -227,9 +226,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/bl0942/sensor.py b/esphome/components/bl0942/sensor.py index 397471cf7a9..a5e6ffb64db 100644 --- a/esphome/components/bl0942/sensor.py +++ b/esphome/components/bl0942/sensor.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CURRENT, CONF_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_LINE_FREQUENCY, CONF_POWER, CONF_RESET, @@ -97,9 +96,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index fa179f9667d..43c1f44d3d1 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_BASELINE, CONF_CO2, CONF_CYCLE, - CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, ICON_MOLECULE_CO2, STATE_CLASS_MEASUREMENT, @@ -68,9 +67,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: """Code generation entry point.""" - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 1618cb43962..17c23242f7f 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -3,7 +3,6 @@ from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( CONF_FREQUENCY, - CONF_ID, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -121,9 +120,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_CURRENT_INPUT]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config, config[CONF_CURRENT_INPUT]) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cse7766/sensor.py b/esphome/components/cse7766/sensor.py index 86b6965e3a2..d5162230db0 100644 --- a/esphome/components/cse7766/sensor.py +++ b/esphome/components/cse7766/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_APPARENT_POWER, CONF_CURRENT, CONF_ENERGY, - CONF_ID, CONF_POWER, CONF_POWER_FACTOR, CONF_REACTIVE_POWER, @@ -94,9 +93,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/daly_bms/__init__.py b/esphome/components/daly_bms/__init__.py index 01d0eef857f..e2234985d43 100644 --- a/esphome/components/daly_bms/__init__.py +++ b/esphome/components/daly_bms/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.const import CONF_ADDRESS from esphome.types import ConfigType CODEOWNERS = ["@s1lvi0"] @@ -37,7 +37,5 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 69b745df9cf..719a76ff07d 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -5,7 +5,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_LOOP import esphome.config_validation as cv -from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME +from esphome.const import CONF_DEVICE, CONF_FILE, CONF_VOLUME DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] @@ -51,9 +51,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index d4e7aed91d8..801c2654a01 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -42,9 +42,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) automation.register_apply_action( diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py index 76ca362949a..d588d70efd1 100644 --- a/esphome/components/dfrobot_sen0609/__init__.py +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -79,7 +79,5 @@ def final_validate_mode(keys: dict[str, str]) -> Callable[[ConfigType], None]: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], RUN_MODES[config[CONF_MODE]]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, RUN_MODES[config[CONF_MODE]]) diff --git a/esphome/components/gcja5/sensor.py b/esphome/components/gcja5/sensor.py index 49907443ff9..85110dafc25 100644 --- a/esphome/components/gcja5/sensor.py +++ b/esphome/components/gcja5/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -113,9 +112,7 @@ TYPES = { async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) for key, funcName in TYPES.items(): if key in config: diff --git a/esphome/components/gps/__init__.py b/esphome/components/gps/__init__.py index cc77dff496b..be1b13b70e9 100644 --- a/esphome/components/gps/__init__.py +++ b/esphome/components/gps/__init__.py @@ -96,9 +96,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("gps", require_rx=True async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) # Pre-create all sensor variables so automations that reference # sibling sensors don't deadlock waiting for unregistered IDs. diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 10074fbf10f..7ef0ce14c50 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -51,9 +51,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index 72d3cc2ada3..a013fe884d3 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -4,7 +4,6 @@ from esphome.components import uart import esphome.config_validation as cv from esphome.const import ( CONF_DIRECTION, - CONF_ID, CONF_NAME, CONF_ON_ENROLLMENT_DONE, CONF_ON_ENROLLMENT_FAILED, @@ -90,9 +89,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/hlw8032/sensor.py b/esphome/components/hlw8032/sensor.py index f2a8ffb2161..09cfe7320b4 100644 --- a/esphome/components/hlw8032/sensor.py +++ b/esphome/components/hlw8032/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_APPARENT_POWER, CONF_CURRENT, CONF_CURRENT_RESISTOR, - CONF_ID, CONF_POWER, CONF_POWER_FACTOR, CONF_VOLTAGE, @@ -75,9 +74,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index 222a5308ae1..eeab5303922 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_MODEL, CONF_MOISTURE, CONF_RESOLUTION, @@ -140,10 +139,8 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add_define( "HYDREON_RGXX_PROTOCOL_LIST(F, sep)", diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index f6c236b72df..ae00f86e264 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_COMMAND, CONF_CUSTOM, CONF_FLOW, - CONF_ID, CONF_POWER, CONF_VOLUME, DEVICE_CLASS_EMPTY, @@ -113,9 +112,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) # Standard sensors for key in [ diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index e2728aa73c7..a03d87d9168 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -72,9 +72,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) CALIBRATION_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py index ecc90455d1c..f4e09cc1867 100644 --- a/esphome/components/ld2410s/__init__.py +++ b/esphome/components/ld2410s/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -37,6 +36,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/ld2412/__init__.py b/esphome/components/ld2412/__init__.py index fd63f78c3d4..9a1772e780d 100644 --- a/esphome/components/ld2412/__init__.py +++ b/esphome/components/ld2412/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.const import CONF_THROTTLE from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -43,6 +43,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/ld2420/__init__.py b/esphome/components/ld2420/__init__.py index 87a54d278a7..b92e84d5965 100644 --- a/esphome/components/ld2420/__init__.py +++ b/esphome/components/ld2420/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@descipher"] @@ -36,6 +35,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 066ace871f8..dfca220b6a7 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -3,7 +3,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE +from esphome.const import CONF_ON_DATA, CONF_THROTTLE from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -63,7 +63,5 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld2460/__init__.py b/esphome/components/ld2460/__init__.py index 88c3a173efe..84cba297190 100644 --- a/esphome/components/ld2460/__init__.py +++ b/esphome/components/ld2460/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.const import CONF_ON_DATA from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -41,7 +41,5 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index c7529f93149..48017a8f00e 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_WAKEUP_PIN +from esphome.const import CONF_WAKEUP_PIN from esphome.types import ConfigType from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE @@ -62,9 +62,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if wakeup_pin_config := config.get(CONF_WAKEUP_PIN): pin = await cg.gpio_pin_expression(wakeup_pin_config) diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 4daeef5059c..5c5b24f6f48 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -73,9 +73,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index ff06d0b913d..3745b1084ba 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -4,7 +4,6 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.cpp_generator import MockObj @@ -106,9 +105,7 @@ async def to_code_micronova_listener( async def to_code(config: ConfigType) -> None: enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) - var = cg.new_Pvariable(config[CONF_ID], enable_rx_pin) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, enable_rx_pin) CORE.add_job(_final_step) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 442869eec53..ba51bd3a806 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -90,9 +90,7 @@ async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add( var.set_telemetry_request_min_interval( config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] diff --git a/esphome/components/mk2pvrouter/__init__.py b/esphome/components/mk2pvrouter/__init__.py index 7df1f7104f5..3d84c40c9ed 100644 --- a/esphome/components/mk2pvrouter/__init__.py +++ b/esphome/components/mk2pvrouter/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TAG +from esphome.const import CONF_TAG from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -65,6 +65,4 @@ async def register_mk2pvrouter_listener(mk2pvrouter: MockObj, var: MockObj) -> N async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/pipsolar/__init__.py b/esphome/components/pipsolar/__init__.py index 54337b0b130..77b97b24719 100644 --- a/esphome/components/pipsolar/__init__.py +++ b/esphome/components/pipsolar/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -29,6 +28,4 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/pm1006/sensor.py b/esphome/components/pm1006/sensor.py index 98c64066761..fc1bec8296b 100644 --- a/esphome/components/pm1006/sensor.py +++ b/esphome/components/pm1006/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_2_5, CONF_UPDATE_INTERVAL, DEVICE_CLASS_PM25, @@ -58,9 +57,7 @@ FINAL_VALIDATE_SCHEMA = validate_interval_uart async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index 723b99a5040..55f0c0678b2 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_PM_0_3UM, CONF_PM_0_5UM, CONF_PM_1_0, @@ -328,9 +327,7 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add(var.set_type(config[CONF_TYPE])) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index e15f8f04c82..3fc194da057 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -6,7 +6,6 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -108,7 +107,5 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/pzem004t/sensor.py b/esphome/components/pzem004t/sensor.py index 2b19cf84985..9a2b73ea4cc 100644 --- a/esphome/components/pzem004t/sensor.py +++ b/esphome/components/pzem004t/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, CONF_ENERGY, - CONF_ID, CONF_POWER, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, @@ -65,9 +64,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/rd03d/__init__.py b/esphome/components/rd03d/__init__.py index 96bf4ffd743..ff2eaebad59 100644 --- a/esphome/components/rd03d/__init__.py +++ b/esphome/components/rd03d/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.const import CONF_THROTTLE from esphome.types import ConfigType CODEOWNERS = ["@jasstrong"] @@ -41,9 +41,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if CONF_TRACKING_MODE in config: cg.add(var.set_tracking_mode(config[CONF_TRACKING_MODE])) diff --git a/esphome/components/rdm6300/__init__.py b/esphome/components/rdm6300/__init__.py index 86eb2cd7380..18d8e551f95 100644 --- a/esphome/components/rdm6300/__init__.py +++ b/esphome/components/rdm6300/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_TAG, CONF_TRIGGER_ID +from esphome.const import CONF_ON_TAG, CONF_TRIGGER_ID from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -37,9 +37,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index e967335c180..e970df9faa8 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CODE, CONF_DURATION, CONF_HIGH, - CONF_ID, CONF_LENGTH, CONF_LOW, CONF_PROTOCOL, @@ -70,9 +69,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index fe1481df9a5..93a2b9b0212 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_2_5, CONF_PM_10_0, CONF_RX_ONLY, @@ -85,9 +84,7 @@ async def to_code(config: ConfigType) -> None: # Pop update_interval before register_component so it doesn't generate # a set_update_interval call — sds011 handles this via set_update_interval_min update_interval = config.pop(CONF_UPDATE_INTERVAL, None) - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if update_interval is not None: cg.add(var.set_update_interval_min(update_interval)) diff --git a/esphome/components/seeed_mr60bha2/__init__.py b/esphome/components/seeed_mr60bha2/__init__.py index 7a85567510a..bf91e4b1e27 100644 --- a/esphome/components/seeed_mr60bha2/__init__.py +++ b/esphome/components/seeed_mr60bha2/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] @@ -38,6 +37,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/seeed_mr60fda2/__init__.py b/esphome/components/seeed_mr60fda2/__init__.py index cc227415831..081581683a2 100644 --- a/esphome/components/seeed_mr60fda2/__init__.py +++ b/esphome/components/seeed_mr60fda2/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] @@ -39,6 +38,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 2494e9a6d1f..5222d56a106 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -49,9 +49,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 6c3327e556f..35b65f6bae3 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -19,7 +19,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.usb_uart import is_usb_uart_channel import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_NAME, CONF_UART_ID +from esphome.const import CONF_NAME, CONF_UART_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv @@ -94,9 +94,7 @@ async def _add_serial_proxy_count_define() -> None: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add(cg.App.register_serial_proxy(var)) cg.add(var.set_name(config[CONF_NAME])) cg.add(var.set_port_type(config[CONF_PORT_TYPE])) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index efc742610d9..2500e4684bf 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MESSAGE +from esphome.const import CONF_MESSAGE DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] @@ -66,10 +66,8 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/sm300d2/sensor.py b/esphome/components/sm300d2/sensor.py index 4245283ab48..b63db068e63 100644 --- a/esphome/components/sm300d2/sensor.py +++ b/esphome/components/sm300d2/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CO2, CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_PM_2_5, CONF_PM_10_0, CONF_TEMPERATURE, @@ -95,9 +94,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index 35ca231f023..587cb13662b 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -5,7 +5,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.const import CONF_ON_DATA from esphome.types import ConfigType CODEOWNERS = ["@alengwenus"] @@ -50,9 +50,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/smt100/sensor.py b/esphome/components/smt100/sensor.py index fba10eab9ae..115434ee9ac 100644 --- a/esphome/components/smt100/sensor.py +++ b/esphome/components/smt100/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_COUNTS, CONF_DIELECTRIC_CONSTANT, - CONF_ID, CONF_MOISTURE, CONF_PERMITTIVITY, CONF_TEMPERATURE, @@ -79,10 +78,8 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_COUNTS, var.set_counts_sensor) diff --git a/esphome/components/sun_gtil2/__init__.py b/esphome/components/sun_gtil2/__init__.py index 61643cf4883..b66f7075bea 100644 --- a/esphome/components/sun_gtil2/__init__.py +++ b/esphome/components/sun_gtil2/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] @@ -27,6 +26,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py index ae44017112b..ee193c4d3a2 100644 --- a/esphome/components/systa_bus/__init__.py +++ b/esphome/components/systa_bus/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -39,6 +38,4 @@ async def register_systa_bus_listener(systa_bus: MockObj, var: MockObj) -> None: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/t6615/sensor.py b/esphome/components/t6615/sensor.py index 4b055516e79..42223b373ab 100644 --- a/esphome/components/t6615/sensor.py +++ b/esphome/components/t6615/sensor.py @@ -3,7 +3,6 @@ from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( CONF_CO2, - CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, @@ -44,9 +43,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 5a6a01f6695..1353b6a912f 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@0hax", "@FredM67"] @@ -53,6 +52,4 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_HISTORICAL_MODE]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, config[CONF_HISTORICAL_MODE]) diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index ce06cb82e3c..a3ac518d8eb 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -162,9 +162,7 @@ def final_validate(config: ConfigType) -> ConfigType: async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await stepper.register_stepper(var, config) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index c05db51cdf9..51db405512c 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -2,7 +2,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID +from esphome.const import CONF_SENSOR_DATAPOINT, CONF_TIME_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -83,9 +83,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if (time_id := config.get(CONF_TIME_ID)) is not None: time_ = await cg.get_variable(time_id) cg.add(var.set_time_id(time_)) diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index c7eba4a26a5..4a69029e6f7 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -116,9 +116,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if config[CONF_ROLE] == "server": socket.require_tcp_listener() cg.add(var.set_server(True)) diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index 1846958e591..ced3e3d0af5 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -66,9 +66,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) if _get_data().clear_action_used: cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index 5dc32259ea2..e23f4157a7e 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID, CONF_TIME_ID +from esphome.const import CONF_ADDRESS, CONF_TIME_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -66,9 +66,7 @@ UPONOR_SMATRIX_DEVICE_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: cg.add_global(uponor_smatrix_ns.using) - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if time_id := config.get(CONF_TIME_ID): time_ = await cg.get_variable(time_id) diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index 533acfbbb92..92709915ad4 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] @@ -40,6 +39,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index e451fe79f4d..7ed4ec4b59e 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI +from esphome.const import CONF_POWER_SAVE_MODE, CONF_WIFI import esphome.final_validate as fv from esphome.types import ConfigType @@ -38,7 +38,5 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) cg.add_define("USE_ZWAVE_PROXY") From ec642f928f7c1d0b8806316d6c6ecb8072ecc37b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:15:36 -1000 Subject: [PATCH 086/120] [i2c] Add little-endian and signed byte decoding helpers (#20938) --- esphome/components/i2c/i2c.cpp | 5 ++ esphome/components/i2c/i2c.h | 18 ++++ esphome/core/helpers.h | 37 ++++++-- tests/components/core/test_encode_value.cpp | 48 +++++++++++ tests/components/i2c/test_i2c_le.cpp | 96 +++++++++++++++++++++ 5 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 tests/components/core/test_encode_value.cpp create mode 100644 tests/components/i2c/test_i2c_le.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 61382ccf21e..9d079f9f89e 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -94,6 +94,11 @@ bool I2CDevice::write_byte_16(uint8_t a_register, uint16_t data) const { return this->bus_->write_readv(this->address_, buffer, sizeof(buffer), nullptr, 0) == ERROR_OK; } +bool I2CDevice::write_byte_16_le(uint8_t a_register, uint16_t data) const { + const uint8_t buffer[3] = {a_register, static_cast(data), static_cast(data >> 8)}; + return this->bus_->write_readv(this->address_, buffer, sizeof(buffer), nullptr, 0) == ERROR_OK; +} + I2CRegister &I2CRegister::operator=(uint8_t value) { this->parent_->write_register(this->register_, &value, 1); return *this; diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 6a9a6533436..d5690b92063 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -280,6 +280,21 @@ class I2CDevice { bool read_byte_16(uint8_t a_register, uint16_t *data) { return read_bytes_16(a_register, data, 1); } + /// Read len 16-bit values stored least significant byte first; no conversion is compiled on little-endian hosts. + bool read_bytes_16_le(uint8_t a_register, uint16_t *data, uint8_t len) { + if (this->read_register(a_register, reinterpret_cast(data), len * 2) != ERROR_OK) + return false; + for (size_t i = 0; i < len; i++) + data[i] = convert_little_endian(data[i]); + return true; + } + bool read_bytes_16_le(uint8_t a_register, int16_t *data, uint8_t len) { + return this->read_bytes_16_le(a_register, reinterpret_cast(data), len); + } + + /// Read a 16-bit value stored least significant byte first. + bool read_byte_16_le(uint8_t a_register, uint16_t *data) { return this->read_bytes_16_le(a_register, data, 1); } + bool write_bytes(uint8_t a_register, const uint8_t *data, uint8_t len) const { return write_register(a_register, data, len) == ERROR_OK; } @@ -298,6 +313,9 @@ class I2CDevice { bool write_byte_16(uint8_t a_register, uint16_t data) const; + /// Write a 16-bit value least significant byte first. + bool write_byte_16_le(uint8_t a_register, uint16_t data) const; + protected: uint8_t address_{0x00}; ///< store the address of the device on the bus I2CBus *bus_{nullptr}; ///< pointer to I2CBus instance diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 2989655a29a..c3e752c9690 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1015,19 +1015,42 @@ template constexpr int32_t sign_extend(uint32_t value) { } /// Encode a value from its constituent bytes (from most to least significant) in an array with length sizeof(T). -template::value, int> = 0> constexpr T encode_value(const uint8_t *bytes) { - T val = 0; - for (size_t i = 0; i < sizeof(T); i++) { - val <<= 8; - val |= bytes[i]; +/// A signed T takes the two's complement of the assembled bits. +template::value, int> = 0> constexpr T encode_value(const uint8_t *bytes) { + if constexpr (sizeof(T) == 1) { + return static_cast(bytes[0]); + } else { + std::make_unsigned_t val = 0; + for (size_t i = 0; i < sizeof(T); i++) { + val <<= 8; + val |= bytes[i]; + } + return static_cast(val); } - return val; } /// Encode a value from its constituent bytes (from most to least significant) in an std::array with length sizeof(T). -template::value, int> = 0> +template::value, int> = 0> constexpr T encode_value(const std::array bytes) { return encode_value(bytes.data()); } +/// Encode a value from its constituent bytes (from least to most significant) in an array with length sizeof(T). +/// A signed T takes the two's complement of the assembled bits. +template::value, int> = 0> +constexpr T encode_value_le(const uint8_t *bytes) { + // Loops here grow 16-bit callers by up to 48 bytes on ESP8266 through different inlining + if constexpr (sizeof(T) == 1) { + return static_cast(bytes[0]); + } else if constexpr (sizeof(T) == 2) { + return static_cast(encode_uint16(bytes[1], bytes[0])); + } else { + std::make_unsigned_t val = 0; + for (size_t i = sizeof(T); i > 0; i--) { + val <<= 8; + val |= bytes[i - 1]; + } + return static_cast(val); + } +} /// Decode a value into its constituent bytes (from most to least significant). template::value, int> = 0> constexpr std::array decode_value(T val) { diff --git a/tests/components/core/test_encode_value.cpp b/tests/components/core/test_encode_value.cpp new file mode 100644 index 00000000000..c76ae50935a --- /dev/null +++ b/tests/components/core/test_encode_value.cpp @@ -0,0 +1,48 @@ +#include + +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static constexpr uint8_t BYTES[] = {0x12, 0x34, 0x56, 0x78}; + +static_assert(encode_value_le(BYTES) == 0x3412); +static_assert(encode_value(BYTES) == 0x1234); + +TEST(EncodeValue, LittleEndianUnsigned) { + EXPECT_EQ(encode_value_le(BYTES), 0x12); + EXPECT_EQ(encode_value_le(BYTES + 1), 0x5634); + EXPECT_EQ(encode_value_le(BYTES), 0x78563412u); +} + +TEST(EncodeValue, BigEndianSigned) { + static constexpr uint8_t MINUS_ONE[] = {0xFF, 0xFF, 0xFF, 0xFF}; + static constexpr uint8_t MIN16[] = {0x80, 0x00}; + static constexpr uint8_t MIN32[] = {0x80, 0x00, 0x00, 0x00}; + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MIN16), INT16_MIN); + EXPECT_EQ(encode_value(MIN32), INT32_MIN); +} + +TEST(EncodeValue, LittleEndianSigned) { + static constexpr uint8_t MIN16[] = {0x00, 0x80}; + static constexpr uint8_t MAX16[] = {0xFF, 0x7F}; + static constexpr uint8_t MINUS_TWO[] = {0xFE, 0xFF}; + static constexpr uint8_t MINUS_256[] = {0x00, 0xFF, 0xFF, 0xFF}; + EXPECT_EQ(encode_value_le(MIN16), INT16_MIN); + EXPECT_EQ(encode_value_le(MAX16), INT16_MAX); + EXPECT_EQ(encode_value_le(MINUS_TWO), -2); + EXPECT_EQ(encode_value_le(MINUS_256), -256); +} + +TEST(EncodeValue, BoolStillCompiles) { + static constexpr uint8_t ONE[] = {0x01}; + static_assert(encode_value(ONE)); + static_assert(encode_value_le(ONE)); +} + +} // namespace esphome::core::testing diff --git a/tests/components/i2c/test_i2c_le.cpp b/tests/components/i2c/test_i2c_le.cpp new file mode 100644 index 00000000000..973490f28d3 --- /dev/null +++ b/tests/components/i2c/test_i2c_le.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include +#include + +#include "esphome/components/i2c/i2c.h" + +namespace esphome::i2c::testing { + +class MemoryBus : public I2CBus { + public: + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + if (this->fail) + return ERROR_NOT_ACKNOWLEDGED; + if (write_count == 0) + return ERROR_INVALID_ARGUMENT; + uint8_t reg = write_buffer[0]; + for (size_t i = 1; i < write_count; i++) + this->memory[reg + i - 1] = write_buffer[i]; + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = this->memory[reg + i]; + this->last_write_count = write_count; + return ERROR_OK; + } + + std::array memory{}; + size_t last_write_count{0}; + bool fail{false}; +}; + +class I2CLittleEndianTest : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x40); + } + MemoryBus bus_; + I2CDevice device_; +}; + +TEST_F(I2CLittleEndianTest, ReadByte16LittleEndian) { + this->bus_.memory[4] = 0x34; + this->bus_.memory[5] = 0x12; + uint16_t value = 0; + ASSERT_TRUE(this->device_.read_byte_16_le(4, &value)); + EXPECT_EQ(value, 0x1234); + ASSERT_TRUE(this->device_.read_byte_16(4, &value)); + EXPECT_EQ(value, 0x3412); +} + +TEST_F(I2CLittleEndianTest, ReadBytes16LittleEndian) { + const uint8_t raw[6] = {0x34, 0x12, 0xFF, 0xFF, 0x00, 0x80}; + std::copy(raw, raw + 6, this->bus_.memory.begin() + 8); + uint16_t values[3] = {}; + ASSERT_TRUE(this->device_.read_bytes_16_le(8, values, 3)); + EXPECT_EQ(values[0], 0x1234); + EXPECT_EQ(values[1], 0xFFFF); + EXPECT_EQ(values[2], 0x8000); + int16_t signed_values[3] = {}; + ASSERT_TRUE(this->device_.read_bytes_16_le(8, signed_values, 3)); + EXPECT_EQ(signed_values[0], 0x1234); + EXPECT_EQ(signed_values[1], -1); + EXPECT_EQ(signed_values[2], INT16_MIN); +} + +TEST_F(I2CLittleEndianTest, ReadBytes16FailureReturnsFalse) { + this->bus_.fail = true; + int16_t values[2] = {}; + EXPECT_FALSE(this->device_.read_bytes_16_le(0, values, 2)); +} + +TEST_F(I2CLittleEndianTest, ReadFailureReturnsFalse) { + this->bus_.fail = true; + uint16_t value = 0xBEEF; + EXPECT_FALSE(this->device_.read_byte_16_le(0, &value)); + EXPECT_EQ(value, 0xBEEF); +} + +TEST_F(I2CLittleEndianTest, WriteByte16LittleEndian) { + ASSERT_TRUE(this->device_.write_byte_16_le(2, 0xA1B2)); + EXPECT_EQ(this->bus_.last_write_count, 3u); + EXPECT_EQ(this->bus_.memory[2], 0xB2); + EXPECT_EQ(this->bus_.memory[3], 0xA1); + uint16_t value = 0; + ASSERT_TRUE(this->device_.read_byte_16_le(2, &value)); + EXPECT_EQ(value, 0xA1B2); +} + +TEST_F(I2CLittleEndianTest, WriteFailureReturnsFalse) { + this->bus_.fail = true; + EXPECT_FALSE(this->device_.write_byte_16_le(2, 0xA1B2)); +} + +} // namespace esphome::i2c::testing From c1687aba4c441a2783cd36745311d0acb1f2bcf6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:16:21 -1000 Subject: [PATCH 087/120] [components] Let core report communication failures in dump_config (5/5) (#20874) --- esphome/components/shtcx/shtcx.cpp | 6 +--- esphome/components/sps30/sps30.cpp | 31 ++----------------- esphome/components/sps30/sps30.h | 10 ------ .../components/ssd1306_i2c/ssd1306_i2c.cpp | 7 +---- esphome/components/ssd1306_i2c/ssd1306_i2c.h | 2 -- .../components/ssd1327_i2c/ssd1327_i2c.cpp | 7 +---- esphome/components/ssd1327_i2c/ssd1327_i2c.h | 2 -- esphome/components/st7567_i2c/st7567_i2c.cpp | 7 +---- esphome/components/st7567_i2c/st7567_i2c.h | 2 -- esphome/components/stcc4/stcc4.cpp | 12 ++----- esphome/components/sts3x/sts3x.cpp | 7 ++--- esphome/components/stts22h/stts22h.cpp | 3 -- esphome/components/sx126x/sx126x.cpp | 8 ++--- esphome/components/sx127x/sx127x.cpp | 11 ++----- esphome/components/sx1509/sx1509.cpp | 9 ++---- esphome/components/sy6970/sy6970.cpp | 6 +--- esphome/components/tc74/tc74.cpp | 5 +-- esphome/components/tca8418/tca8418.cpp | 15 +++------ esphome/components/tca9555/tca9555.cpp | 7 ++--- esphome/components/tcs34725/tcs34725.cpp | 13 +++----- .../components/tlc59208f/tlc59208f_output.cpp | 19 +++--------- esphome/components/tmc22xx/tmc22xx.cpp | 3 -- esphome/components/tmp102/tmp102.cpp | 5 +-- esphome/components/tmp1075/tmp1075.cpp | 6 ++-- esphome/components/tmp117/tmp117.cpp | 7 ++--- esphome/components/tsl2561/tsl2561.cpp | 8 ++--- esphome/components/tsl2591/tsl2591.cpp | 4 +-- esphome/components/ttp229_lsf/ttp229_lsf.cpp | 11 +------ esphome/components/ttp229_lsf/ttp229_lsf.h | 4 --- esphome/components/veml3235/veml3235.cpp | 11 ++----- esphome/components/veml7700/veml7700.cpp | 7 +---- .../waveshare_io_ch32v003.cpp | 6 +--- esphome/components/xgzp68xx/xgzp68xx.cpp | 7 ++--- esphome/components/xl9535/xl9535.cpp | 6 +--- 34 files changed, 54 insertions(+), 220 deletions(-) diff --git a/esphome/components/shtcx/shtcx.cpp b/esphome/components/shtcx/shtcx.cpp index 68f2c4ea21e..814e09162ec 100644 --- a/esphome/components/shtcx/shtcx.cpp +++ b/esphome/components/shtcx/shtcx.cpp @@ -29,8 +29,7 @@ void SHTCXComponent::setup() { uint16_t device_id_register; if (!this->write_command(SHTCX_COMMAND_READ_ID_REGISTER) || !this->read_data(&device_id_register, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -53,9 +52,6 @@ void SHTCXComponent::dump_config() { " Model: %s (%04x)", LOG_STR_ARG(shtcx_type_to_string(this->type_)), this->sensor_id_); 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_); diff --git a/esphome/components/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index 3a2ebb14c39..42628e64c91 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -29,15 +29,13 @@ void SPS30Component::setup() { this->set_timeout(500, [this]() { /// Firmware version identification if (!this->get_register(SPS30_CMD_GET_FIRMWARE_VERSION, raw_firmware_version_, 1)) { - this->error_code_ = FIRMWARE_VERSION_READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read firmware version")); return; } /// Serial number identification uint16_t raw_serial_number[8]; if (!this->get_register(SPS30_CMD_GET_SERIAL_NUMBER, raw_serial_number, 8, 1)) { - this->error_code_ = SERIAL_NUMBER_READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read serial number")); return; } @@ -75,31 +73,6 @@ void SPS30Component::setup() { void SPS30Component::dump_config() { ESP_LOGCONFIG(TAG, "SPS30:"); 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_REQUEST_FAILED: - ESP_LOGW(TAG, "Unable to request serial number"); - break; - case SERIAL_NUMBER_READ_FAILED: - ESP_LOGW(TAG, "Unable to read serial number"); - break; - case FIRMWARE_VERSION_REQUEST_FAILED: - ESP_LOGW(TAG, "Unable to request firmware version"); - break; - case FIRMWARE_VERSION_READ_FAILED: - ESP_LOGW(TAG, "Unable to read firmware version"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Serial number: %s\n" diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index 10b89c844bc..c1e47f089ac 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -42,16 +42,6 @@ class SPS30Component final : public PollingComponent, public sensirion_common::S bool start_continuous_measurement_(); - enum ErrorCode : uint8_t { - COMMUNICATION_FAILED, - FIRMWARE_VERSION_REQUEST_FAILED, - FIRMWARE_VERSION_READ_FAILED, - SERIAL_NUMBER_REQUEST_FAILED, - SERIAL_NUMBER_READ_FAILED, - MEASUREMENT_INIT_FAILED, - UNKNOWN - } error_code_{UNKNOWN}; - sensor::Sensor *pm_1_0_sensor_{nullptr}; sensor::Sensor *pm_2_5_sensor_{nullptr}; sensor::Sensor *pm_4_0_sensor_{nullptr}; diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp index 1cdfed9f502..7081d766e32 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp @@ -10,8 +10,7 @@ void I2CSSD1306::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; } @@ -32,10 +31,6 @@ void I2CSSD1306::dump_config() { LOG_I2C_DEVICE(this); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CSSD1306::command(uint8_t value) { this->write_byte(0x00, value); } void HOT I2CSSD1306::write_display_data() { diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.h b/esphome/components/ssd1306_i2c/ssd1306_i2c.h index 54c7d862870..e77f4464f5f 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.h +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.h @@ -14,8 +14,6 @@ class I2CSSD1306 final : public ssd1306_base::SSD1306, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::ssd1306_i2c diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp index 98328c800c1..11cf3296a15 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp @@ -10,8 +10,7 @@ void I2CSSD1327::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; } @@ -23,10 +22,6 @@ void I2CSSD1327::dump_config() { ESP_LOGCONFIG(TAG, " Model: %s", this->model_str_()); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CSSD1327::command(uint8_t value) { this->write_byte(0x00, value); } void HOT I2CSSD1327::write_display_data() { diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.h b/esphome/components/ssd1327_i2c/ssd1327_i2c.h index 75f854d3da3..7c4ae3bec11 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.h +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.h @@ -14,8 +14,6 @@ class I2CSSD1327 final : public ssd1327_base::SSD1327, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::ssd1327_i2c diff --git a/esphome/components/st7567_i2c/st7567_i2c.cpp b/esphome/components/st7567_i2c/st7567_i2c.cpp index 657a60f9549..ad672270a8a 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.cpp +++ b/esphome/components/st7567_i2c/st7567_i2c.cpp @@ -10,8 +10,7 @@ void I2CST7567::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; } ST7567::setup(); @@ -28,10 +27,6 @@ void I2CST7567::dump_config() { LOG_I2C_DEVICE(this); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CST7567::command(uint8_t value) { this->write_byte(0x00, value); } diff --git a/esphome/components/st7567_i2c/st7567_i2c.h b/esphome/components/st7567_i2c/st7567_i2c.h index eea3068e030..f17ec419785 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.h +++ b/esphome/components/st7567_i2c/st7567_i2c.h @@ -14,8 +14,6 @@ class I2CST7567 final : public st7567_base::ST7567, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::st7567_i2c diff --git a/esphome/components/stcc4/stcc4.cpp b/esphome/components/stcc4/stcc4.cpp index ac7a998e5d6..a5e47584eb0 100644 --- a/esphome/components/stcc4/stcc4.cpp +++ b/esphome/components/stcc4/stcc4.cpp @@ -77,8 +77,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) (uint64_t(raw_product_id[4]) << 16) | raw_product_id[5]; ESP_LOGD(TAG, "Product ID: 0x%08" PRIX32 ", Serial: 0x%016" PRIX64, product_id, serial_number); if (product_id != STCC4_PRODUCT_ID) { - ESP_LOGE(TAG, "Unsupported product ID"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Unsupported product ID")); return; } @@ -106,8 +105,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) // Start continuous measurement if (!this->write_command(STCC4_CMD_START_CONTINUOUS_MEASUREMENT)) { - ESP_LOGE(TAG, "Failed to start continuous measurement"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Failed to start continuous measurement")); return; } this->schedule_continuous_update_(false); @@ -123,8 +121,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) return; } - ESP_LOGE(TAG, "Failed to stop continuous measurements and read product ID"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Failed to stop continuous measurements and read product ID")); } void STCC4Component::finish_setup_() { @@ -143,9 +140,6 @@ void STCC4Component::finish_setup_() { void STCC4Component::dump_config() { ESP_LOGCONFIG(TAG, "STCC4:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " Measurement mode: %s", this->measurement_mode_ == MeasurementMode::CONTINUOUS ? LOG_STR_LITERAL("Continuous (1s)") : LOG_STR_LITERAL("Single shot")); diff --git a/esphome/components/sts3x/sts3x.cpp b/esphome/components/sts3x/sts3x.cpp index 8b97b2f4997..fd9569b951d 100644 --- a/esphome/components/sts3x/sts3x.cpp +++ b/esphome/components/sts3x/sts3x.cpp @@ -18,13 +18,13 @@ static const uint16_t STS3X_COMMAND_FETCH_DATA = 0xE000; void STS3XComponent::setup() { if (!this->write_command(STS3X_COMMAND_READ_SERIAL_NUMBER)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint16_t raw_serial_number[2]; if (!this->read_data(raw_serial_number, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint32_t serial_number = (uint32_t(raw_serial_number[0]) << 16); @@ -33,9 +33,6 @@ void STS3XComponent::setup() { void STS3XComponent::dump_config() { ESP_LOGCONFIG(TAG, "STS3x:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "STS3x", this); diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index 5132b2a3aa5..fa96c6a0227 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -37,9 +37,6 @@ void STTS22HComponent::dump_config() { LOG_SENSOR("", "STTS22H", this); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } float STTS22HComponent::read_temperature_() { diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 0bac0eb0466..6c95fe5f7a6 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -164,7 +164,7 @@ void SX126x::configure() { this->read_register_(REG_VERSION_STRING, (uint8_t *) this->version_, sizeof(this->version_)); this->version_[sizeof(this->version_) - 1] = '\0'; if (strncmp(this->version_, "SX126", 5) != 0 && strncmp(this->version_, "LLCC68", 6) != 0) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unsupported chip version")); return; } @@ -494,8 +494,7 @@ void SX126x::wait_busy_() { uint32_t start = millis(); while (this->busy_pin_->digital_read()) { if (millis() - start > BUSY_TIMEOUT_MS) { - ESP_LOGE(TAG, "Wait busy timeout"); - this->mark_failed(); + this->mark_failed(LOG_STR("Wait busy timeout")); break; } } @@ -562,9 +561,6 @@ void SX126x::dump_config() { if (this->whitening_enable_) { ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->whitening_initial_); } - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring SX126x failed"); - } } } // namespace esphome::sx126x diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index fb816094c29..f48e8de3a35 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -81,7 +81,7 @@ void SX127x::configure() { // check silicon version to make sure hw is ok if (this->read_register_(REG_VERSION) != 0x12) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unsupported chip version")); return; } @@ -367,8 +367,7 @@ void SX127x::run_image_cal() { uint32_t start = millis(); while (this->read_register_(REG_IMAGE_CAL) & IMAGE_CAL_RUNNING) { if (millis() - start > 20) { - ESP_LOGE(TAG, "Image cal failure"); - this->mark_failed(); + this->mark_failed(LOG_STR("Image cal failure")); break; } } @@ -390,8 +389,7 @@ void SX127x::set_mode_(uint8_t modulation, uint8_t mode) { break; } if (millis() - start > 20) { - ESP_LOGE(TAG, "Set mode failure"); - this->mark_failed(); + this->mark_failed(LOG_STR("Set mode failure")); return; } } @@ -508,9 +506,6 @@ void SX127x::dump_config() { this->preamble_polarity_, this->preamble_size_, this->preamble_detect_, this->preamble_errors_); } } - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring SX127x failed"); - } } } // namespace esphome::sx127x diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 169d35331f8..d883982bcd5 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -9,18 +9,18 @@ ESPHOME_LOG_TAG(TAG, "sx1509"); void SX1509Component::setup() { ESP_LOGV(TAG, " Resetting devices"); if (!this->write_byte(REG_RESET, 0x12)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->write_byte(REG_RESET, 0x34); uint16_t data; if (!this->read_byte_16(REG_INTERRUPT_MASK_A, &data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (data != 0xFF00) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unexpected reset value")); return; } clock_(INTERNAL_CLOCK_2MHZ); @@ -31,9 +31,6 @@ void SX1509Component::setup() { void SX1509Component::dump_config() { ESP_LOGCONFIG(TAG, "SX1509:"); - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up SX1509 failed!"); - } LOG_I2C_DEVICE(this); } diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 5e1611ea558..754cfb14ca8 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -60,8 +60,7 @@ void SY6970Component::setup() { // Try to read chip ID uint8_t reg_value; if (!this->read_byte(SY6970_REG_DEVICE_ID, ®_value)) { - ESP_LOGE(TAG, "Failed to communicate with SY6970"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -116,9 +115,6 @@ void SY6970Component::dump_config() { LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Communication with SY6970 failed!"); - } } void SY6970Component::update() { diff --git a/esphome/components/tc74/tc74.cpp b/esphome/components/tc74/tc74.cpp index 98428cd1225..7bba2b7fd75 100644 --- a/esphome/components/tc74/tc74.cpp +++ b/esphome/components/tc74/tc74.cpp @@ -16,7 +16,7 @@ static const uint8_t TC74_DATA_READY_MASK = 0x40; void TC74Component::setup() { uint8_t config_reg; if (this->read_register(TC74_REGISTER_CONFIGURATION, &config_reg, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->data_ready_ = config_reg & TC74_DATA_READY_MASK; @@ -27,9 +27,6 @@ void TC74Component::update() { this->read_temperature_(); } void TC74Component::dump_config() { LOG_SENSOR("", "TC74", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Connection with TC74 failed!"); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp index 7add67e9b09..204bc18c1e5 100644 --- a/esphome/components/tca8418/tca8418.cpp +++ b/esphome/components/tca8418/tca8418.cpp @@ -15,21 +15,18 @@ void TCA8418Component::setup() { // The configuration register reads back, so use it to check the device is there. uint8_t config; if (!this->read_byte(TCA8418_REG_CFG, &config)) { - ESP_LOGE(TAG, "Failed to read from device - check wiring / address"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read from device - check wiring / address")); return; } if (!this->configure_pins_()) { - ESP_LOGE(TAG, "Failed to configure pins"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to configure pins")); return; } // Report key events, and keep the interrupt asserted while events remain queued. if (!this->write_byte(TCA8418_REG_CFG, TCA8418_CFG_KEY_INT_EN | TCA8418_CFG_INT_CFG)) { - ESP_LOGE(TAG, "Failed to write the configuration register"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to write the configuration register")); return; } @@ -43,8 +40,7 @@ void TCA8418Component::setup() { // Discard anything the device queued before it was configured, and clear // every interrupt flag: leaving one set holds the interrupt output asserted. if (!this->drain_events_(false)) { - ESP_LOGE(TAG, "Failed to clear the event queue"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to clear the event queue")); return; } @@ -162,9 +158,6 @@ void TCA8418Component::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); - } } } // namespace esphome::tca8418 diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 7a59c403f31..2b234983a20 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -16,11 +16,11 @@ ESPHOME_LOG_TAG(TAG, "tca9555"); void TCA9555Component::setup() { if (!this->read_gpio_modes_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->read_gpio_outputs_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -36,9 +36,6 @@ void TCA9555Component::dump_config() { ESP_LOGCONFIG(TAG, "TCA9555:"); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 8e79ac1c162..979137a9729 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -19,23 +19,23 @@ static const uint8_t TCS34725_REGISTER_CRGBDATAL = TCS34725_COMMAND_BIT | 0x14; void TCS34725Component::setup() { uint8_t id; if (this->read_register(TCS34725_REGISTER_ID, &id, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->write_config_register_(TCS34725_REGISTER_ATIME, this->integration_reg_) != i2c::ERROR_OK || this->write_config_register_(TCS34725_REGISTER_CONTROL, this->gain_reg_) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->write_config_register_(TCS34725_REGISTER_ENABLE, 0x01) != i2c::ERROR_OK) { // Power on (internal oscillator on) - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(3); if (this->write_config_register_(TCS34725_REGISTER_ENABLE, 0x03) != i2c::ERROR_OK) { // Power on (internal oscillator on) + RGBC ADC Enable - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -43,9 +43,6 @@ void TCS34725Component::setup() { void TCS34725Component::dump_config() { ESP_LOGCONFIG(TAG, "TCS34725:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Clear Channel", this->clear_sensor_); @@ -292,7 +289,7 @@ void TCS34725Component::update() { set_gain((TCS34725Gain) gain_reg_val_new); if (this->write_config_register_(TCS34725_REGISTER_ATIME, this->integration_reg_) != i2c::ERROR_OK || this->write_config_register_(TCS34725_REGISTER_CONTROL, this->gain_reg_) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); ESP_LOGW(TAG, "TCS34725I update timing failed!"); } else { this->integration_time_ = integration_time_next; diff --git a/esphome/components/tlc59208f/tlc59208f_output.cpp b/esphome/components/tlc59208f/tlc59208f_output.cpp index d76845b4f03..f73dcbd611b 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.cpp +++ b/esphome/components/tlc59208f/tlc59208f_output.cpp @@ -65,32 +65,27 @@ void TLC59208FOutput::setup() { // Reset all devices on the bus if (this->bus_->write_readv(TLC59208F_SWRST_ADDR >> 1, TLC59208F_SWRST_SEQ, sizeof TLC59208F_SWRST_SEQ, nullptr, 0) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "RESET failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("RESET failed")); return; } // Auto increment registers, and respond to all-call address if (!this->write_byte(TLC59208F_REG_MODE1, TLC59208F_MODE1_AI2 | TLC59208F_MODE1_ALLCALL)) { - ESP_LOGE(TAG, "MODE1 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("MODE1 failed")); return; } if (!this->write_byte(TLC59208F_REG_MODE2, this->mode_)) { - ESP_LOGE(TAG, "MODE2 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("MODE2 failed")); return; } // Set all 3 outputs to be individually controlled // TODO: think of a way to support group dimming if (!this->write_byte(TLC59208F_REG_LEDOUT0, (LDR_PWM << 6) | (LDR_PWM << 4) | (LDR_PWM << 2) | (LDR_PWM << 0))) { - ESP_LOGE(TAG, "LEDOUT0 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("LEDOUT0 failed")); return; } if (!this->write_byte(TLC59208F_REG_LEDOUT1, (LDR_PWM << 6) | (LDR_PWM << 4) | (LDR_PWM << 2) | (LDR_PWM << 0))) { - ESP_LOGE(TAG, "LEDOUT1 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("LEDOUT1 failed")); return; } delayMicroseconds(500); @@ -103,10 +98,6 @@ void TLC59208FOutput::dump_config() { "TLC59208F:\n" " Mode: 0x%02X", this->mode_); - - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up TLC59208F failed!"); - } } void TLC59208FOutput::loop() { diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 7bb756819bd..f68496bd0ca 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -256,9 +256,6 @@ void TMC22XXStepper::dump_config() { LOG_PIN(" INDEX Pin: ", this->index_pin_); LOG_PIN(" DIAG Pin: ", this->diag_pin_); LOG_STEPPER(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void TMC22XXStepper::loop() { diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index 06d94f1ba86..7639c00cf95 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -96,7 +96,7 @@ void TMP102Component::setup_configured_() { #endif if (!this->write_configuration_()) { this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SETUP_FAILED); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } #ifdef USE_TMP102_NUMBER @@ -123,9 +123,6 @@ void TMP102Component::setup_configured_() { void TMP102Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP102:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); #ifdef USE_TMP102_BINARY_SENSOR diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 9b152c1ad34..0cb2456cbd3 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -20,7 +20,7 @@ void TMP1075Sensor::setup() { uint8_t cfg; if (!this->read_byte(REG_CFGR, &cfg)) { ESP_LOGE(TAG, "'%s' - unable to read", this->name_.c_str()); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -42,10 +42,8 @@ void TMP1075Sensor::update() { void TMP1075Sensor::dump_config() { LOG_SENSOR("", "TMP1075 Sensor", this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " limit low : %.4f °C\n" " limit high : %.4f °C\n" diff --git a/esphome/components/tmp117/tmp117.cpp b/esphome/components/tmp117/tmp117.cpp index 5925b297c2b..3c76e098b1b 100644 --- a/esphome/components/tmp117/tmp117.cpp +++ b/esphome/components/tmp117/tmp117.cpp @@ -25,22 +25,19 @@ void TMP117Component::update() { } void TMP117Component::setup() { if (!this->write_config_(this->config_)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } int16_t data; if (!this->read_data_(&data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void TMP117Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP117:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this); } diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index d1d1d66a265..f887b7d32ca 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -18,13 +18,13 @@ static const uint8_t TSL2561_REGISTER_DATA_1 = 0x0E; void TSL2561Sensor::setup() { uint8_t id; if (!this->tsl2561_read_byte(TSL2561_REGISTER_ID, &id)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint8_t timing; if (!this->tsl2561_read_byte(TSL2561_REGISTER_TIMING, &timing)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -40,10 +40,6 @@ void TSL2561Sensor::dump_config() { LOG_SENSOR("", "TSL2561", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } - int gain = this->gain_ == TSL2561_GAIN_1X ? 1 : 16; ESP_LOGCONFIG(TAG, " Gain: %dx\n" diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index 493f4ad3627..46b0f4c8054 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -73,10 +73,8 @@ void TSL2591Component::dump_config() { ESP_LOGCONFIG(TAG, "TSL2591:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Name: %s", this->name_); TSL2591ComponentGain raw_gain = this->component_gain_; diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.cpp b/esphome/components/ttp229_lsf/ttp229_lsf.cpp index 7ed06c6d6f2..bf5c3f67284 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.cpp +++ b/esphome/components/ttp229_lsf/ttp229_lsf.cpp @@ -8,22 +8,13 @@ ESPHOME_LOG_TAG(TAG, "ttp229_lsf"); void TTP229LSFComponent::setup() { uint8_t data[2]; if (this->read(data, 2) != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void TTP229LSFComponent::dump_config() { ESP_LOGCONFIG(TAG, "ttp229:"); 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 TTP229LSFComponent::loop() { uint16_t touched = 0; diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.h b/esphome/components/ttp229_lsf/ttp229_lsf.h index 50e2baa7f7a..e221ded0855 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.h +++ b/esphome/components/ttp229_lsf/ttp229_lsf.h @@ -26,10 +26,6 @@ class TTP229LSFComponent final : public Component, public i2c::I2CDevice { protected: std::vector channels_{}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - } error_code_{NONE}; }; } // namespace esphome::ttp229_lsf diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 3a856696782..09340f3bdee 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -21,16 +21,14 @@ static const uint8_t MAX_ADJUSTMENTS_PER_UPDATE = 2; void VEML3235Sensor::setup() { uint8_t device_id[] = {0, 0}; if (!this->refresh_config_reg()) { - ESP_LOGE(TAG, "Unable to write configuration"); - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to write configuration")); return; } if ((this->read_register(ID_REG, device_id, sizeof device_id) != i2c::ERROR_OK)) { - ESP_LOGE(TAG, "Unable to read ID"); - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read ID")); } else if (device_id[0] != DEVICE_ID) { ESP_LOGE(TAG, "Incorrect device ID - expected 0x%.2x, read 0x%.2x", DEVICE_ID, device_id[0]); - this->mark_failed(); + this->mark_failed(LOG_STR("Incorrect device ID")); } } @@ -187,9 +185,6 @@ void VEML3235Sensor::dump_config() { LOG_SENSOR("", "VEML3235", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Auto-gain enabled: %s", YESNO(this->auto_gain_)); if (this->auto_gain_) { diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 236b4bb2a98..210e68705bb 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -80,8 +80,7 @@ static const char *get_gain_str(Gain gain) { void VEML7700Component::setup() { auto err = this->configure_(); if (err != i2c::ERROR_OK) { - ESP_LOGW(TAG, "Sensor configuration failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Sensor configuration failed")); } else { this->state_ = State::INITIAL_SETUP_COMPLETED; } @@ -109,10 +108,6 @@ void VEML7700Component::dump_config() { LOG_SENSOR(" ", "FAKE_IR channel lux", this->fake_infrared_sensor_); LOG_SENSOR(" ", "Actual gain", this->actual_gain_sensor_); LOG_SENSOR(" ", "Actual integration time", this->actual_integration_time_sensor_); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void VEML7700Component::update() { diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 159390086f5..0347cfa083c 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -20,8 +20,7 @@ void WaveshareIOCH32V003Component::setup() { bool step2 = this->write_gpio_outputs_(); if (!step1 || !step2) { - ESP_LOGE(TAG, "Failed to initialize Waveshare IO expander"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to initialize Waveshare IO expander")); return; } @@ -46,9 +45,6 @@ void WaveshareIOCH32V003Component::loop() { this->reset_pin_cache_(); } void WaveshareIOCH32V003Component::dump_config() { ESP_LOGCONFIG(TAG, "WaveshareIO:"); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } uint16_t WaveshareIOCH32V003Component::get_adc_value() { diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index b184a855f6f..8ab42d24fe0 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -93,11 +93,11 @@ void XGZP68XXComponent::setup() { // Display some sample bits to confirm we are talking to the sensor if (i2c::ErrorCode::ERROR_OK != this->read_register(SYSCONFIG_ADDRESS, &config1, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (i2c::ErrorCode::ERROR_OK != this->read_register(PCONFIG_ADDRESS, &config2, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ESP_LOGD(TAG, "sys_config 0x%x, p_config 0x%x", config1, config2); @@ -111,9 +111,6 @@ void XGZP68XXComponent::dump_config() { ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->pressure_oversampling_)); } LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, " Connection failed"); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index 228274b3211..9be536353c8 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -9,7 +9,7 @@ void XL9535Component::setup() { // Check to see if the device can read from the register uint8_t port = 0; if (this->read_register(XL9535_INPUT_PORT_0_REGISTER, &port, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -17,10 +17,6 @@ void XL9535Component::setup() { void XL9535Component::dump_config() { ESP_LOGCONFIG(TAG, "XL9535:"); LOG_I2C_DEVICE(this); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool XL9535Component::digital_read(uint8_t pin) { From 74bcec34ad22e136bd9169050f2e47e148f9f5f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:18:36 -1000 Subject: [PATCH 088/120] [i2c] Add I2CDevice::update_register for register read-modify-write (#20854) --- esphome/components/i2c/i2c.cpp | 9 ++ esphome/components/i2c/i2c.h | 5 ++ .../i2c/test_i2c_update_register.cpp | 82 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 tests/components/i2c/test_i2c_update_register.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 9d079f9f89e..8aa1fbe3cf3 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -75,6 +75,15 @@ bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { return true; } +bool I2CDevice::update_register(uint8_t a_register, uint8_t mask, uint8_t value) { + uint8_t data; + if (!this->read_byte(a_register, &data)) + return false; + const uint8_t updated = (data & ~mask) | (value & mask); + ESP_LOGV(TAG, "Register 0x%02X: 0x%02X -> 0x%02X", a_register, data, updated); + return this->write_byte(a_register, updated); +} + bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) const { const size_t size = 1 + len * 2; SmallBufferWithHeapFallback<17> buffer_alloc(size); // Register plus up to 8 words diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index d5690b92063..1c3137c6d5f 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -316,6 +316,11 @@ class I2CDevice { /// Write a 16-bit value least significant byte first. bool write_byte_16_le(uint8_t a_register, uint16_t data) const; + /// Read a register, replace the bits in @p mask with those of @p value and write it back. + /// The write always happens; bits of @p value outside @p mask are ignored. Logs both values at VERBOSE. + /// @return false if the read or the write failed (nothing is written after a failed read) + bool update_register(uint8_t a_register, uint8_t mask, uint8_t value); + protected: uint8_t address_{0x00}; ///< store the address of the device on the bus I2CBus *bus_{nullptr}; ///< pointer to I2CBus instance diff --git a/tests/components/i2c/test_i2c_update_register.cpp b/tests/components/i2c/test_i2c_update_register.cpp new file mode 100644 index 00000000000..f973a726cad --- /dev/null +++ b/tests/components/i2c/test_i2c_update_register.cpp @@ -0,0 +1,82 @@ +#include + +#include +#include + +#include "esphome/components/i2c/i2c.h" + +namespace esphome::i2c::testing { + +static constexpr uint8_t ADDRESS = 0x40; +static constexpr uint8_t REG = 0x12; + +class RegisterBus final : public I2CBus { + public: + std::array regs{}; + std::vector writes; + bool fail_read{false}; + bool fail_write{false}; + + 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); + if (out_size == 0) + return ERROR_TIMEOUT; + const uint8_t reg = out[0]; + if (in_size > 0) { + if (this->fail_read) + return ERROR_TIMEOUT; + for (size_t i = 0; i < in_size; i++) + in[i] = this->regs[reg + i]; + return ERROR_OK; + } + if (this->fail_write) + return ERROR_TIMEOUT; + this->writes.push_back(reg); + for (size_t i = 1; i < out_size; i++) + this->regs[reg + i - 1] = out[i]; + return ERROR_OK; + } +}; + +class UpdateRegisterTest : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(ADDRESS); + } + + RegisterBus bus_; + I2CDevice device_; +}; + +TEST_F(UpdateRegisterTest, ReplacesOnlyMaskedBits) { + this->bus_.regs[REG] = 0b1010'0101; + EXPECT_TRUE(this->device_.update_register(REG, 0b0000'1111, 0b0000'0011)); + EXPECT_EQ(this->bus_.regs[REG], 0b1010'0011); +} + +TEST_F(UpdateRegisterTest, IgnoresValueBitsOutsideMask) { + this->bus_.regs[REG] = 0x00; + EXPECT_TRUE(this->device_.update_register(REG, 0x0F, 0xFF)); + EXPECT_EQ(this->bus_.regs[REG], 0x0F); +} + +TEST_F(UpdateRegisterTest, WritesEvenWhenUnchanged) { + this->bus_.regs[REG] = 0x3C; + EXPECT_TRUE(this->device_.update_register(REG, 0x0C, 0x0C)); + EXPECT_EQ(this->bus_.regs[REG], 0x3C); + EXPECT_EQ(this->bus_.writes, std::vector{REG}); +} + +TEST_F(UpdateRegisterTest, FailedReadWritesNothing) { + this->bus_.fail_read = true; + EXPECT_FALSE(this->device_.update_register(REG, 0xFF, 0x01)); + EXPECT_TRUE(this->bus_.writes.empty()); +} + +TEST_F(UpdateRegisterTest, FailedWriteReturnsFalse) { + this->bus_.fail_write = true; + EXPECT_FALSE(this->device_.update_register(REG, 0xFF, 0x01)); +} + +} // namespace esphome::i2c::testing From 32dc3b85351dd5ffc90fac187f9e70c040bf2deb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:19:24 -1000 Subject: [PATCH 089/120] [http_request] Test ota.http_request.flash with every field (#20829) --- tests/components/http_request/common.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/components/http_request/common.yaml b/tests/components/http_request/common.yaml index 62d0a7941aa..f20bd1a23f0 100644 --- a/tests/components/http_request/common.yaml +++ b/tests/components/http_request/common.yaml @@ -42,6 +42,20 @@ button: md5: 0123456789abcdef0123456789abcdef url: http://my.ha.net:8123/local/esphome/firmware.bin + - ota.http_request.flash: + id: http_request_ota + md5_url: !lambda 'return std::string("http://my.ha.net:8123/local/esphome/firmware.md5");' + username: user + password: !lambda 'return std::string("pass");' + url: http://my.ha.net:8123/local/esphome/firmware.bin + + - ota.http_request.flash: + id: http_request_ota + md5: !lambda 'return std::string("0123456789abcdef0123456789abcdef");' + username: !lambda 'return std::string("user");' + password: pass + url: !lambda 'return std::string("http://my.ha.net:8123/local/esphome/firmware.bin");' + - logger.log: "This message should be not displayed (reboot)" update: From 68fa7b9847075aab6ecf58008a047d574093c53b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:20:00 -1000 Subject: [PATCH 090/120] [wifi] Test the wifi.connected and wifi.enabled conditions (#20841) --- tests/components/wifi/common.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/wifi/common.yaml b/tests/components/wifi/common.yaml index 10a8a61c661..63bd820e673 100644 --- a/tests/components/wifi/common.yaml +++ b/tests/components/wifi/common.yaml @@ -14,6 +14,14 @@ esphome: condition: wifi.ap_active then: - logger.log: "WiFi AP is active!" + - if: + condition: wifi.connected + then: + - logger.log: "WiFi is connected!" + - if: + condition: wifi.enabled + then: + - logger.log: "WiFi is enabled!" - wifi.roam wifi: From 626d84bc641007476d8eec710904e6aabb13cae2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:20:52 -1000 Subject: [PATCH 091/120] [sensor][number] Test in_range with every bound combination (#20847) --- tests/components/template/common-base.yaml | 32 ++++ .../fixtures/in_range_conditions.yaml | 158 ++++++++++++++++++ tests/integration/test_in_range_conditions.py | 62 +++++++ 3 files changed, 252 insertions(+) create mode 100644 tests/integration/fixtures/in_range_conditions.yaml create mode 100644 tests/integration/test_in_range_conditions.py diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index f87a4e0320e..76a5e0b1a2f 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -76,6 +76,38 @@ esphome: date: "2021-01-01" binary_sensor: + - platform: template + id: template_sensor_above + condition: + sensor.in_range: + id: template_template_sens + above: 10.0 + - platform: template + id: template_sensor_between + condition: + sensor.in_range: + id: template_template_sens + above: 10.0 + below: 30.0 + - platform: template + id: template_number_above + condition: + number.in_range: + id: template_number + above: 10.0 + - platform: template + id: template_number_below + condition: + number.in_range: + id: template_number + below: 90.0 + - platform: template + id: template_number_between + condition: + number.in_range: + id: template_number + above: 10.0 + below: 90.0 - platform: template id: template_some_binary_sensor name: "Garage Door Open" diff --git a/tests/integration/fixtures/in_range_conditions.yaml b/tests/integration/fixtures/in_range_conditions.yaml new file mode 100644 index 00000000000..f7f636aa584 --- /dev/null +++ b/tests/integration/fixtures/in_range_conditions.yaml @@ -0,0 +1,158 @@ +esphome: + name: test-in-range-conditions + +host: + +api: + actions: + - action: run_cases + then: + - script.execute: + id: check + label: neg + value: !lambda "return -10.0f;" + - script.wait: check + - script.execute: + id: check + label: low + value: !lambda "return 0.0f;" + - script.wait: check + - script.execute: + id: check + label: mid + value: !lambda "return 5.0f;" + - script.wait: check + - script.execute: + id: check + label: high + value: !lambda "return 10.0f;" + - script.wait: check + - script.execute: + id: check + label: over + value: !lambda "return 20.0f;" + - script.wait: check + - script.execute: + id: check + label: nan + value: !lambda "return NAN;" + - script.wait: check + - script.execute: + id: check + label: neginf + value: !lambda "return -INFINITY;" + - script.wait: check + - script.execute: + id: check + label: posinf + value: !lambda "return INFINITY;" + - script.wait: check + - logger.log: "INRANGE done" + +logger: + level: DEBUG + +sensor: + - platform: template + id: range_sensor + name: Range Sensor + +number: + - platform: template + id: range_number + name: Range Number + optimistic: true + min_value: -100 + max_value: 100 + step: 0.5 + +script: + - id: check + parameters: + label: string + value: float + then: + # Set the states directly so NaN and infinities reach the conditions unchanged + - lambda: |- + id(range_sensor).publish_state(value); + id(range_number).publish_state(value); + - if: + condition: + sensor.in_range: + id: range_sensor + above: 0 + then: + - logger.log: + format: "INRANGE %s sensor_above 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_above 0" + args: [label.c_str()] + - if: + condition: + sensor.in_range: + id: range_sensor + below: 10 + then: + - logger.log: + format: "INRANGE %s sensor_below 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_below 0" + args: [label.c_str()] + - if: + condition: + sensor.in_range: + id: range_sensor + above: 0 + below: 10 + then: + - logger.log: + format: "INRANGE %s sensor_both 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_both 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + above: 0 + then: + - logger.log: + format: "INRANGE %s number_above 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_above 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + below: 10 + then: + - logger.log: + format: "INRANGE %s number_below 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_below 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + above: 0 + below: 10 + then: + - logger.log: + format: "INRANGE %s number_both 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_both 0" + args: [label.c_str()] diff --git a/tests/integration/test_in_range_conditions.py b/tests/integration/test_in_range_conditions.py new file mode 100644 index 00000000000..667f7fe43d4 --- /dev/null +++ b/tests/integration/test_in_range_conditions.py @@ -0,0 +1,62 @@ +"""Test sensor.in_range and number.in_range against a fixed truth table.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Bounds in the fixture: above 0, below 10, both inclusive. A NaN state is never in range. +# Columns: above only, below only, both bounds. +EXPECTED = { + "neg": (0, 1, 0), # -10 + "low": (1, 1, 1), # 0, the lower bound + "mid": (1, 1, 1), # 5 + "high": (1, 1, 1), # 10, the upper bound + "over": (1, 0, 0), # 20 + "nan": (0, 0, 0), + "neginf": (0, 1, 0), + "posinf": (1, 0, 0), +} +CONDITIONS = ("above", "below", "both") +RESULT_RE = re.compile(r"INRANGE (\w+) (sensor|number)_(above|below|both) ([01])") + + +@pytest.mark.asyncio +async def test_in_range_conditions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Each in_range condition gives the same result for sensors and numbers.""" + loop = asyncio.get_running_loop() + results: dict[tuple[str, str, str], int] = {} + done = loop.create_future() + + def check_output(line: str) -> None: + if match := RESULT_RE.search(line): + label, entity, condition, value = match.groups() + results[(label, entity, condition)] = int(value) + elif "INRANGE done" in line and not done.done(): + done.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service = next((s for s in services if s.name == "run_cases"), None) + assert service is not None, "run_cases action not found" + await client.execute_service(service, {}) + await asyncio.wait_for(done, timeout=10.0) + + expected = { + (label, entity, condition): row[index] + for label, row in EXPECTED.items() + for entity in ("sensor", "number") + for index, condition in enumerate(CONDITIONS) + } + assert results == expected From 21362f8c92ab8e8d5002569ef508e0a98c9afc6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:21:18 -1000 Subject: [PATCH 092/120] [ethernet] Test the ethernet.connected condition (#20844) --- tests/components/ethernet/test-w5500.esp32-idf.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/components/ethernet/test-w5500.esp32-idf.yaml b/tests/components/ethernet/test-w5500.esp32-idf.yaml index c99dfaf5281..ebd7aa360ab 100644 --- a/tests/components/ethernet/test-w5500.esp32-idf.yaml +++ b/tests/components/ethernet/test-w5500.esp32-idf.yaml @@ -38,6 +38,11 @@ button: - platform: template name: "Disable Ethernet" on_press: + - if: + condition: + ethernet.connected: + then: + - logger.log: "Disabling connected Ethernet" - ethernet.disable: binary_sensor: From 34e6d3ec9c75a21efb5ba73ea0587f9aeca678e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:21:36 -1000 Subject: [PATCH 093/120] [pylontech] Restore the CONF_ID import (#21013) --- esphome/components/pylontech/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 3fc194da057..ed18556002f 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -6,6 +6,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv +from esphome.const import CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType From a2754f655c1f5d297cd163ed27798e38bcd391e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:06 -1000 Subject: [PATCH 094/120] [dfrobot_sen0609][http_request] Use ESPHOME_strstr_P (#20825) --- .../dfrobot_sen0609/dfrobot_sen0609.cpp | 18 +++++------------- .../http_request/http_request_arduino.cpp | 3 ++- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp index e1a1dbe89d0..802142bb8de 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -64,15 +64,6 @@ 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; @@ -94,9 +85,9 @@ static uint8_t split_fields(char *str, char **fields, uint8_t max_fields) { static bool answer_means_done(Operation operation, const char *line) { switch (operation) { case Operation::OPERATION_STOP: - return contains(line, ESPHOME_PSTR("stopped already")); + return ESPHOME_strstr_P(line, ESPHOME_PSTR("stopped already")) != nullptr; case Operation::OPERATION_START: - return contains(line, ESPHOME_PSTR("started already")); + return ESPHOME_strstr_P(line, ESPHOME_PSTR("started already")) != nullptr; case Operation::OPERATION_SAVE: return starts_with(line, ESPHOME_PSTR("no parameter")); default: @@ -261,11 +252,12 @@ void DFRobotSEN0609Component::handle_line_(char *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"))) { + } else if (ESPHOME_strstr_P(line, ESPHOME_PSTR("not stopped")) != nullptr) { // 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"))) { + } else if (starts_with(line, ESPHOME_PSTR("Error")) || + ESPHOME_strstr_P(line, ESPHOME_PSTR("not recognized")) != nullptr) { ESP_LOGW(TAG, "Command '%s' rejected: %s", this->command_, line); this->fail_operation_(); } else { diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 2062122adf6..814d598132e 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -10,6 +10,7 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" // Include BearSSL error constants for TLS failure diagnostics #ifdef USE_ESP8266 @@ -39,7 +40,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons const uint32_t start = millis(); - bool secure = strstr(url, "https:") != nullptr; + bool secure = ESPHOME_strstr_P(url, ESPHOME_PSTR("https:")) != nullptr; container->set_secure(secure); container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms)); From af5efabdc6313eb5a841c54796afc351b7426977 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:22 -1000 Subject: [PATCH 095/120] [light] Test light.toggle with a transition length (#20832) --- tests/components/light/common.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index c1e58c673f6..95b587df45d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -83,6 +83,12 @@ esphome: call.perform(); - light.toggle: test_binary_light + - light.toggle: + id: test_monochromatic_light + transition_length: 500ms + - light.toggle: + id: test_monochromatic_light + transition_length: !lambda "return 250;" - light.turn_off: test_rgb_light - if: condition: From 5df6c65cc249adef35af836f089eb7a5a91f4fa8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:35 -1000 Subject: [PATCH 096/120] [api] Test api.connected without options and with a lambda (#20835) --- tests/components/api/common-base.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 7f6cb83f16c..e64ba88f112 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -5,6 +5,17 @@ esphome: condition: api.connected: state_subscription_only: true + - if: + condition: + api.connected: + then: + - logger.log: "API client connected" + - if: + condition: + api.connected: + state_subscription_only: !lambda "return true;" + then: + - logger.log: "API client subscribed to states" - homeassistant.event: event: esphome.button_pressed data: From 1dbad7b951d9d215706a71751b3eaaf5e3e7d8ec Mon Sep 17 00:00:00 2001 From: Lorenzo Prosseda Date: Sun, 18 Oct 2026 01:37:58 +0000 Subject: [PATCH 097/120] [airton] Add support to climate for Airton IR A/C units (#7753) Co-authored-by: Arjan <44190435+vingerha@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/airton/__init__.py | 2 + esphome/components/airton/airton.cpp | 319 ++++++++++++++++++ esphome/components/airton/airton.h | 137 ++++++++ esphome/components/airton/climate.py | 70 ++++ esphome/components/airton/select/__init__.py | 37 ++ esphome/components/airton/select/direction.h | 17 + esphome/components/airton/switch/__init__.py | 41 +++ .../components/airton/switch/airton_switch.h | 19 ++ tests/components/airton/common-bare.yaml | 13 + tests/components/airton/common.yaml | 34 ++ tests/components/airton/test.esp32-idf.yaml | 4 + tests/components/airton/test.esp8266-ard.yaml | 4 + 13 files changed, 700 insertions(+) create mode 100644 esphome/components/airton/__init__.py create mode 100644 esphome/components/airton/airton.cpp create mode 100644 esphome/components/airton/airton.h create mode 100644 esphome/components/airton/climate.py create mode 100644 esphome/components/airton/select/__init__.py create mode 100644 esphome/components/airton/select/direction.h create mode 100644 esphome/components/airton/switch/__init__.py create mode 100644 esphome/components/airton/switch/airton_switch.h create mode 100644 tests/components/airton/common-bare.yaml create mode 100644 tests/components/airton/common.yaml create mode 100644 tests/components/airton/test.esp32-idf.yaml create mode 100644 tests/components/airton/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 0def07d0237..19f1194bd95 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -30,6 +30,9 @@ esphome/components/airthings_ble/* @jeromelaban esphome/components/airthings_wave_base/* @jeromelaban @ncareau esphome/components/airthings_wave_mini/* @ncareau esphome/components/airthings_wave_plus/* @jeromelaban @precurse +esphome/components/airton/* @Procsiab +esphome/components/airton/select/* @Procsiab +esphome/components/airton/switch/* @Procsiab esphome/components/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/alpha3/* @jan-hofmeier esphome/components/am2315c/* @swoboda1337 diff --git a/esphome/components/airton/__init__.py b/esphome/components/airton/__init__.py new file mode 100644 index 00000000000..675b3186690 --- /dev/null +++ b/esphome/components/airton/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@Procsiab"] +DOMAIN = "airton" diff --git a/esphome/components/airton/airton.cpp b/esphome/components/airton/airton.cpp new file mode 100644 index 00000000000..a20ddc510c2 --- /dev/null +++ b/esphome/components/airton/airton.cpp @@ -0,0 +1,319 @@ +#include "airton.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::airton { + +ESPHOME_LOG_TAG(TAG, "airton.climate"); + +// Distinct from the climate's own RESTORE_STATE_VERSION, which shares the entity's preference key +static constexpr uint32_t RESTORE_SETTINGS_VERSION = 0x2E86A1F3UL; + +// From IRutils.h of IRremoteESP8266 library +static uint8_t checksum(const uint8_t *frame) { + return static_cast(0x7F - sum_bytes(frame, AIRTON_STATE_FRAME_SIZE - 1)) ^ 0x2C; +} + +void AirtonClimate::setup() { + climate_ir::ClimateIR::setup(); + // The flap position is the one source of truth for the swing mode the climate state restored + this->settings_pref_ = this->make_entity_preference(RESTORE_SETTINGS_VERSION); + this->settings_pref_.load(&this->settings_); + this->sync_swing_mode_(); + this->publish_settings_(); + this->publish_state(); +} + +void AirtonClimate::publish_settings_() { +#ifdef USE_SWITCH + if (this->sleep_mode_switch_ != nullptr) + this->sleep_mode_switch_->publish_state(this->settings_.sleep_state); + if (this->display_switch_ != nullptr) + this->display_switch_->publish_state(this->settings_.display_state); +#endif +#ifdef USE_SELECT + if (this->vertical_direction_select_ != nullptr) + this->vertical_direction_select_->publish_state(static_cast(this->settings_.vertical_direction)); +#endif +} + +void AirtonClimate::save_settings_() { this->settings_pref_.save(&this->settings_); } + +void AirtonClimate::sync_swing_mode_() { + this->swing_mode = this->settings_.vertical_direction == VerticalDirection::VERTICAL_DIRECTION_SWING + ? climate::CLIMATE_SWING_VERTICAL + : climate::CLIMATE_SWING_OFF; +} + +// The unit has no sleep mode while it only fans or picks the mode itself +bool AirtonClimate::sleep_mode_allowed_() const { + return this->mode != climate::CLIMATE_MODE_FAN_ONLY && this->mode != climate::CLIMATE_MODE_HEAT_COOL; +} + +// Each setter stores and saves before it publishes: an automation on the entity runs inside +// publish_state() and may set the value again, which must not be undone afterwards +bool AirtonClimate::set_sleep_mode_state(bool state) { + state = state && this->sleep_mode_allowed_(); + const bool changed = state != this->settings_.sleep_state; + if (changed) { + this->settings_.sleep_state = state; + this->save_settings_(); + } +#ifdef USE_SWITCH + if (this->sleep_mode_switch_ != nullptr) + this->sleep_mode_switch_->publish_state(state); +#endif + return changed; +} + +bool AirtonClimate::set_display_state(bool state) { + const bool changed = state != this->settings_.display_state; + if (changed) { + this->settings_.display_state = state; + this->save_settings_(); + } +#ifdef USE_SWITCH + if (this->display_switch_ != nullptr) + this->display_switch_->publish_state(state); +#endif + return changed; +} + +bool AirtonClimate::set_vertical_direction_state(VerticalDirection direction) { + const bool changed = direction != this->settings_.vertical_direction; + if (changed) { + this->settings_.vertical_direction = direction; + this->sync_swing_mode_(); + this->save_settings_(); + } +#ifdef USE_SELECT + if (this->vertical_direction_select_ != nullptr) + this->vertical_direction_select_->publish_state(static_cast(direction)); +#endif + return changed; +} + +void AirtonClimate::send_settings() { + this->transmit_state(); + this->publish_state(); +} + +void AirtonClimate::control(const climate::ClimateCall &call) { + // ClimateIR::control() sends and publishes the whole state afterwards + // Only a change of swing mode moves the flap, so a repeated swing-off keeps a fixed position + if (auto swing_mode = call.get_swing_mode(); swing_mode.has_value() && *swing_mode != this->swing_mode) { + this->set_vertical_direction_state(*swing_mode == climate::CLIMATE_SWING_VERTICAL + ? VerticalDirection::VERTICAL_DIRECTION_SWING + : VerticalDirection::VERTICAL_DIRECTION_OFF); + } + ClimateIR::control(call); +} + +void AirtonClimate::transmit_state() { + // Entering fan-only or automatic mode drops sleep mode, as the remote does + if (!this->sleep_mode_allowed_()) + this->set_sleep_mode_state(false); + // Sampled valid state + // Power: On, Mode: 2 (Dry), Fan: 1 (Quiet), Temp: 20C, Swing(V): On, Econo: Off, Turbo: Off, Light: On, Health: On, + // Sleep: Off. 0x74C461041A11D3 + // The turbo bit above the fan speed stays clear, as the remote sends it + uint8_t remote_state[AIRTON_STATE_FRAME_SIZE] = { + AIRTON_HEADER_BYTE0, + AIRTON_HEADER_BYTE1, + static_cast(this->operation_mode_() | (this->fan_speed_() << 4)), + this->temperature_(), + static_cast(this->settings_.vertical_direction), + this->operation_settings_(), + 0, + }; + remote_state[6] = checksum(remote_state); + + ESP_LOGV(TAG, "Sending: %02X %02X %02X %02X %02X %02X %02X", remote_state[6], remote_state[5], remote_state[4], + remote_state[3], remote_state[2], remote_state[1], remote_state[0]); + + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + data->set_carrier_frequency(AIRTON_IR_FREQUENCY); + data->mark(AIRTON_HEADER_MARK); + data->space(AIRTON_HEADER_SPACE); + for (uint8_t payload_byte : remote_state) { + for (uint8_t payload_bit_cursor = 0; payload_bit_cursor < 8; payload_bit_cursor++) { + data->mark(AIRTON_BIT_MARK); + bool bit = payload_byte & (1 << payload_bit_cursor); + data->space(bit ? AIRTON_ONE_SPACE : AIRTON_ZERO_SPACE); + } + } + + data->mark(AIRTON_BIT_MARK); + data->space(AIRTON_MESSAGE_SPACE); + + transmit.perform(); +} + +uint8_t AirtonClimate::operation_mode_() { + uint8_t operating_mode = 0b1000; // First bit is for power state + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + operating_mode |= AIRTON_MODE_COOL; + break; + case climate::CLIMATE_MODE_DRY: + operating_mode |= AIRTON_MODE_DRY; + break; + case climate::CLIMATE_MODE_HEAT: + operating_mode |= AIRTON_MODE_HEAT; + break; + case climate::CLIMATE_MODE_HEAT_COOL: + operating_mode |= AIRTON_MODE_AUTO; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + operating_mode |= AIRTON_MODE_FAN; + break; + case climate::CLIMATE_MODE_OFF: + default: + operating_mode = 0b0111 & this->previous_mode_; // Set previous mode with power state bit off + } + this->previous_mode_ = operating_mode; + return operating_mode; +} + +uint8_t AirtonClimate::fan_speed_() { + uint8_t fan_speed; + switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { + case climate::CLIMATE_FAN_LOW: + fan_speed = AIRTON_FAN_1; + break; + case climate::CLIMATE_FAN_MEDIUM: + fan_speed = AIRTON_FAN_3; + break; + case climate::CLIMATE_FAN_HIGH: + fan_speed = AIRTON_FAN_5; + break; + case climate::CLIMATE_FAN_AUTO: + default: + fan_speed = AIRTON_FAN_AUTO; + } + return fan_speed; +} + +uint8_t AirtonClimate::temperature_() { + switch (this->mode) { + case climate::CLIMATE_MODE_HEAT_COOL: + return AIRTON_AUTO_MODE_TEMP - AIRTON_TEMP_MIN; + default: + return static_cast(roundf(clamp(this->target_temperature, AIRTON_TEMP_MIN, AIRTON_TEMP_MAX))) - + AIRTON_TEMP_MIN; + } +} + +// The bits of this packet's byte have the following meanings (from MSB to LSB) +// Light, Health, Unknown, HeatOn, Unknown, NotAutoOn, Sleep, Econo +uint8_t AirtonClimate::operation_settings_() { + uint8_t settings = 0; + if (this->mode == climate::CLIMATE_MODE_HEAT) { // Set heating bit if on the corresponding mode + settings |= (1 << 4); + } + if (this->settings_.display_state) { + settings |= (1 << 7); + } + if (this->settings_.sleep_state) { + settings |= (1 << 1); + } + settings |= 0b01000000; // Health, as the remote always sends it + if (this->mode != climate::CLIMATE_MODE_HEAT_COOL) { + settings |= 0b00000100; // NotAutoOn: clear only while the unit picks the mode itself + } + return settings; +} + +void AirtonClimate::parse_state_frame_(const uint8_t *frame) { + uint8_t mode = frame[2]; + // A later "off" repeats the mode the remote last sent, as the remote itself does + this->previous_mode_ = mode & 0b00001111; + if (mode & 0b00001000) { // The power bit + switch (mode & 0b00000111) { + case AIRTON_MODE_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + case AIRTON_MODE_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + case AIRTON_MODE_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + case AIRTON_MODE_AUTO: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + break; + case AIRTON_MODE_FAN: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + } + } else { + this->mode = climate::CLIMATE_MODE_OFF; + } + uint8_t fan_mode = (frame[2] & 0b01110000) >> 4; + + switch (fan_mode) { + case AIRTON_FAN_1: + case AIRTON_FAN_2: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + case AIRTON_FAN_3: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case AIRTON_FAN_4: + case AIRTON_FAN_5: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + case AIRTON_FAN_AUTO: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + } + + this->target_temperature = (frame[3] & 0b00001111) + AIRTON_TEMP_MIN; + + const uint8_t direction = frame[4] & 0b00001111; + if (direction <= static_cast(VerticalDirection::VERTICAL_DIRECTION_DOWN)) { + this->set_vertical_direction_state(static_cast(direction)); + } else { + ESP_LOGV(TAG, "Unknown vertical direction %u", direction); + } + this->set_display_state((frame[5] & 0b10000000) != 0); + this->set_sleep_mode_state((frame[5] & 0b00000010) != 0); + + this->publish_state(); +} + +bool AirtonClimate::on_receive(remote_base::RemoteReceiveData data) { + uint8_t remote_state[AIRTON_STATE_FRAME_SIZE] = {}; + if (!data.expect_item(AIRTON_HEADER_MARK, AIRTON_HEADER_SPACE)) { + ESP_LOGV(TAG, "Wrong header encoding detected!"); + return false; + } + for (int i = 0; i < AIRTON_STATE_FRAME_SIZE; i++) { + for (int j = 0; j < 8; j++) { + if (data.expect_item(AIRTON_BIT_MARK, AIRTON_ONE_SPACE)) { + remote_state[i] |= 1 << j; + } else if (!data.expect_item(AIRTON_BIT_MARK, AIRTON_ZERO_SPACE)) { + ESP_LOGV(TAG, "Wrong modulation encoding for: Byte %d, bit %d", i, j); + return false; + } + } + } + + if (remote_state[0] != AIRTON_HEADER_BYTE0 || remote_state[1] != AIRTON_HEADER_BYTE1) { + ESP_LOGV(TAG, "Wrong header contents: %02X %02X", remote_state[1], remote_state[0]); + return false; + } + const uint8_t expected = checksum(remote_state); + if (remote_state[AIRTON_STATE_FRAME_SIZE - 1] != expected) { + ESP_LOGV(TAG, "Checksum error: calculated %02X, received %02X", expected, + remote_state[AIRTON_STATE_FRAME_SIZE - 1]); + return false; + } + ESP_LOGV(TAG, "Received: %02X %02X %02X %02X %02X %02X %02X", remote_state[6], remote_state[5], remote_state[4], + remote_state[3], remote_state[2], remote_state[1], remote_state[0]); + this->parse_state_frame_(remote_state); + return true; +} + +} // namespace esphome::airton diff --git a/esphome/components/airton/airton.h b/esphome/components/airton/airton.h new file mode 100644 index 00000000000..46762f421a8 --- /dev/null +++ b/esphome/components/airton/airton.h @@ -0,0 +1,137 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/core/defines.h" +#include "esphome/core/preferences.h" + +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif + +namespace esphome::airton { + +// Values for Airton SMVH09B-2A2A3NH IR Controllers +static constexpr uint8_t AIRTON_TEMP_MIN = 16; // Celsius +static constexpr uint8_t AIRTON_TEMP_MAX = 31; // Celsius +static constexpr uint8_t AIRTON_AUTO_MODE_TEMP = 25; // The fixed setpoint the unit uses in auto mode + +static constexpr uint8_t AIRTON_MODE_AUTO = 0b000; +static constexpr uint8_t AIRTON_MODE_COOL = 0b001; +static constexpr uint8_t AIRTON_MODE_HEAT = 0b100; +static constexpr uint8_t AIRTON_MODE_DRY = 0b010; +static constexpr uint8_t AIRTON_MODE_FAN = 0b011; + +static constexpr uint8_t AIRTON_FAN_AUTO = 0b000; +static constexpr uint8_t AIRTON_FAN_1 = 0b001; +static constexpr uint8_t AIRTON_FAN_2 = 0b010; +static constexpr uint8_t AIRTON_FAN_3 = 0b011; +static constexpr uint8_t AIRTON_FAN_4 = 0b100; +static constexpr uint8_t AIRTON_FAN_5 = 0b101; + +static constexpr uint32_t AIRTON_IR_FREQUENCY = 38000; +static constexpr uint32_t AIRTON_HEADER_MARK = 6630; +static constexpr uint32_t AIRTON_HEADER_SPACE = 3350; +static constexpr uint32_t AIRTON_BIT_MARK = 400; +static constexpr uint32_t AIRTON_ONE_SPACE = 1260; +static constexpr uint32_t AIRTON_ZERO_SPACE = 430; +static constexpr uint32_t AIRTON_MESSAGE_SPACE = 100000; + +static constexpr uint8_t AIRTON_STATE_FRAME_SIZE = 7; +// Every frame starts with these two bytes +static constexpr uint8_t AIRTON_HEADER_BYTE0 = 0xD3; +static constexpr uint8_t AIRTON_HEADER_BYTE1 = 0x11; + +// Position of the air outlet flap; the values are the nibble the frame carries and the index +// of the option in the vertical direction select +enum class VerticalDirection : uint8_t { + VERTICAL_DIRECTION_OFF = 0, + VERTICAL_DIRECTION_SWING = 1, + VERTICAL_DIRECTION_UP = 2, + VERTICAL_DIRECTION_MIDDLE_UP = 3, + VERTICAL_DIRECTION_MIDDLE = 4, + VERTICAL_DIRECTION_MIDDLE_DOWN = 5, + VERTICAL_DIRECTION_DOWN = 6, +}; + +// The on/off settings the switch platform exposes +enum class AirtonSetting : uint8_t { + AIRTON_SETTING_SLEEP_MODE, + AIRTON_SETTING_DISPLAY, +}; + +// The settings the unit keeps that the climate state does not carry; saved to flash when they change +struct AirtonSettings { + bool sleep_state; + bool display_state; + VerticalDirection vertical_direction; +}; + +class AirtonClimate : public climate_ir::ClimateIR { + public: + AirtonClimate() + : climate_ir::ClimateIR(AIRTON_TEMP_MIN, AIRTON_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + void setup() override; + void control(const climate::ClimateCall &call) override; + + // The direction the settings start from until a saved one is restored in setup() + void set_default_vertical_direction(VerticalDirection direction) { this->settings_.vertical_direction = direction; } + + // The setters update the state, publish their entity and save; they return whether anything changed. + // A change made here (switch, select, action) is then sent with send_settings(); a frame from the + // remote only reports what the unit already did. + bool set_sleep_mode_state(bool state); + bool set_display_state(bool state); + bool set_vertical_direction_state(VerticalDirection direction); + void send_settings(); + void set_setting(AirtonSetting setting, bool state) { + const bool changed = setting == AirtonSetting::AIRTON_SETTING_DISPLAY ? this->set_display_state(state) + : this->set_sleep_mode_state(state); + if (changed) + this->send_settings(); + } + +#ifdef USE_SWITCH + void set_sleep_mode_switch(switch_::Switch *sw) { this->sleep_mode_switch_ = sw; } + void set_display_switch(switch_::Switch *sw) { this->display_switch_ = sw; } +#endif +#ifdef USE_SELECT + void set_vertical_direction_select(select::Select *sel) { this->vertical_direction_select_ = sel; } +#endif + + protected: + void publish_settings_(); + void save_settings_(); + void sync_swing_mode_(); + bool sleep_mode_allowed_() const; + + void transmit_state() override; + uint8_t operation_mode_(); + uint8_t fan_speed_(); + uint8_t temperature_(); + uint8_t operation_settings_(); + + bool on_receive(remote_base::RemoteReceiveData data) override; + void parse_state_frame_(const uint8_t *frame); + +#ifdef USE_SWITCH + switch_::Switch *sleep_mode_switch_{nullptr}; + switch_::Switch *display_switch_{nullptr}; +#endif +#ifdef USE_SELECT + select::Select *vertical_direction_select_{nullptr}; +#endif + ESPPreferenceObject settings_pref_; + // The unit's own defaults: display lit, no sleep mode, flap still + AirtonSettings settings_{false, true, VerticalDirection::VERTICAL_DIRECTION_OFF}; + // The last mode sent, so turning off repeats it with the power bit cleared + uint8_t previous_mode_{0}; +}; + +} // namespace esphome::airton diff --git a/esphome/components/airton/climate.py b/esphome/components/airton/climate.py new file mode 100644 index 00000000000..fdfef45aaa4 --- /dev/null +++ b/esphome/components/airton/climate.py @@ -0,0 +1,70 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import climate_ir +import esphome.config_validation as cv +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@Procsiab"] + +airton_ns = cg.esphome_ns.namespace("airton") +AirtonClimate = airton_ns.class_("AirtonClimate", climate_ir.ClimateIR) + +CONF_AIRTON_ID = "airton_id" +CONF_SLEEP_MODE = "sleep_mode" +CONF_VERTICAL_DIRECTION = "vertical_direction" + +VerticalDirection = airton_ns.enum("VerticalDirection", is_class=True) +# In the order of the enum, so the option index is the direction value +VERTICAL_DIRECTIONS = { + "off": VerticalDirection.VERTICAL_DIRECTION_OFF, + "swing": VerticalDirection.VERTICAL_DIRECTION_SWING, + "up": VerticalDirection.VERTICAL_DIRECTION_UP, + "middle-up": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_UP, + "middle": VerticalDirection.VERTICAL_DIRECTION_MIDDLE, + "middle-down": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_DOWN, + "down": VerticalDirection.VERTICAL_DIRECTION_DOWN, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(AirtonClimate).extend( + { + cv.Optional(CONF_VERTICAL_DIRECTION, default="off"): cv.enum( + VERTICAL_DIRECTIONS + ), + } +) + +AIRTON_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(AirtonClimate), + } +) + +for _name, _call in ( + ( + "climate.airton.display_on", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_DISPLAY, true)", + ), + ( + "climate.airton.display_off", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_DISPLAY, false)", + ), + ( + "climate.airton.sleep_on", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_SLEEP_MODE, true)", + ), + ( + "climate.airton.sleep_off", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_SLEEP_MODE, false)", + ), +): + automation.register_apply_action( + _name, AIRTON_ACTION_SCHEMA, automation.ApplyCall(_call) + ) + + +async def to_code(config: ConfigType) -> None: + var = await climate_ir.new_climate_ir(config) + # The default until a saved setting is restored in setup(); off is the member's initial value + if (direction := config[CONF_VERTICAL_DIRECTION]) != "off": + cg.add(var.set_default_vertical_direction(direction)) diff --git a/esphome/components/airton/select/__init__.py b/esphome/components/airton/select/__init__.py new file mode 100644 index 00000000000..1772701611a --- /dev/null +++ b/esphome/components/airton/select/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import ( + CONF_AIRTON_ID, + CONF_VERTICAL_DIRECTION, + VERTICAL_DIRECTIONS, + AirtonClimate, + airton_ns, +) + +CODEOWNERS = ["@Procsiab"] + +VerticalDirectionSelect = airton_ns.class_("VerticalDirectionSelect", select.Select) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AIRTON_ID): cv.use_id(AirtonClimate), + cv.Optional(CONF_VERTICAL_DIRECTION): select.select_schema( + VerticalDirectionSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_AIRTON_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_VERTICAL_DIRECTION, + hub.set_vertical_direction_select, + options=list(VERTICAL_DIRECTIONS), + ) diff --git a/esphome/components/airton/select/direction.h b/esphome/components/airton/select/direction.h new file mode 100644 index 00000000000..4697385c161 --- /dev/null +++ b/esphome/components/airton/select/direction.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../airton.h" + +namespace esphome::airton { + +class VerticalDirectionSelect : public select::Select, public Parented { + protected: + // The options are in the order of the enum, so the index is the direction; the parent publishes the state + void control(size_t index) override { + if (this->parent_->set_vertical_direction_state(static_cast(index))) + this->parent_->send_settings(); + } +}; + +} // namespace esphome::airton diff --git a/esphome/components/airton/switch/__init__.py b/esphome/components/airton/switch/__init__.py new file mode 100644 index 00000000000..94cbb5c8c4a --- /dev/null +++ b/esphome/components/airton/switch/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import CONF_DISPLAY, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import CONF_AIRTON_ID, CONF_SLEEP_MODE, AirtonClimate, airton_ns + +CODEOWNERS = ["@Procsiab"] + +AirtonSwitch = airton_ns.class_("AirtonSwitch", switch.Switch) +AirtonSetting = airton_ns.enum("AirtonSetting", is_class=True) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AIRTON_ID): cv.use_id(AirtonClimate), + cv.Optional(CONF_DISPLAY): switch.switch_schema( + AirtonSwitch, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="DISABLED", + ), + cv.Optional(CONF_SLEEP_MODE): switch.switch_schema( + AirtonSwitch, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="DISABLED", + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_AIRTON_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches( + CONF_DISPLAY, hub.set_display_switch, AirtonSetting.AIRTON_SETTING_DISPLAY + ) + await switches( + CONF_SLEEP_MODE, + hub.set_sleep_mode_switch, + AirtonSetting.AIRTON_SETTING_SLEEP_MODE, + ) diff --git a/esphome/components/airton/switch/airton_switch.h b/esphome/components/airton/switch/airton_switch.h new file mode 100644 index 00000000000..d887bacad64 --- /dev/null +++ b/esphome/components/airton/switch/airton_switch.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../airton.h" + +namespace esphome::airton { + +// One of the unit's on/off settings; the parent sends the state and publishes it back +class AirtonSwitch : public switch_::Switch, public Parented { + public: + explicit AirtonSwitch(AirtonSetting setting) : setting_(setting) {} + + protected: + void write_state(bool state) override { this->parent_->set_setting(this->setting_, state); } + + AirtonSetting setting_; +}; + +} // namespace esphome::airton diff --git a/tests/components/airton/common-bare.yaml b/tests/components/airton/common-bare.yaml new file mode 100644 index 00000000000..e2529bbf155 --- /dev/null +++ b/tests/components/airton/common-bare.yaml @@ -0,0 +1,13 @@ +# The climate with USE_SWITCH defined by another platform and USE_SELECT not defined at all; the ESP8266 build +# covers this path and the ESP32 build the full one +climate: + - platform: airton + id: airton_bare + name: Airton Climate + transmitter_id: xmitr + receiver_id: rcvr + +switch: + - platform: template + name: Unrelated Switch + optimistic: true diff --git a/tests/components/airton/common.yaml b/tests/components/airton/common.yaml new file mode 100644 index 00000000000..b5b053f3734 --- /dev/null +++ b/tests/components/airton/common.yaml @@ -0,0 +1,34 @@ +esphome: + on_boot: + then: + - climate.airton.display_on: airton_climate + - climate.airton.display_off: + id: airton_climate + - climate.airton.sleep_on: airton_climate + - climate.airton.sleep_off: airton_climate + +sensor: + - platform: template + id: airton_sensor + lambda: "return 21;" + +climate: + - platform: airton + id: airton_climate + name: Airton Climate + transmitter_id: xmitr + receiver_id: rcvr + sensor: airton_sensor + vertical_direction: middle + +switch: + - platform: airton + sleep_mode: + name: Sleep Mode + display: + name: Display Light + +select: + - platform: airton + vertical_direction: + name: Vertical Direction diff --git a/tests/components/airton/test.esp32-idf.yaml b/tests/components/airton/test.esp32-idf.yaml new file mode 100644 index 00000000000..9e2cd6bed7e --- /dev/null +++ b/tests/components/airton/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + airton: !include common.yaml diff --git a/tests/components/airton/test.esp8266-ard.yaml b/tests/components/airton/test.esp8266-ard.yaml new file mode 100644 index 00000000000..3b62daa505f --- /dev/null +++ b/tests/components/airton/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + airton: !include common-bare.yaml From ee117886b90829b58ab6e203a78c7da1fd300341 Mon Sep 17 00:00:00 2001 From: Bofr5555 <65888788+Bofr5555@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:42:55 +0200 Subject: [PATCH 098/120] [vevor_dtx] Add Vevor weather station component (#17224) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/vevor_dtx/__init__.py | 48 ++++ esphome/components/vevor_dtx/binary_sensor.py | 26 +++ esphome/components/vevor_dtx/sensor.py | 107 +++++++++ esphome/components/vevor_dtx/text_sensor.py | 24 ++ esphome/components/vevor_dtx/vevor_dtx.cpp | 159 +++++++++++++ esphome/components/vevor_dtx/vevor_dtx.h | 63 ++++++ tests/components/vevor_dtx/__init__.py | 16 ++ tests/components/vevor_dtx/common.yaml | 39 ++++ .../components/vevor_dtx/test.esp32-idf.yaml | 3 + .../vevor_dtx/test.esp8266-ard.yaml | 3 + .../components/vevor_dtx/test.rp2040-ard.yaml | 3 + .../validate-two-hubs.esp32-idf.yaml | 21 ++ tests/components/vevor_dtx/vevor_dtx_test.cpp | 208 ++++++++++++++++++ .../common/remote_receiver/rp2040-ard.yaml | 12 + 15 files changed, 733 insertions(+) create mode 100644 esphome/components/vevor_dtx/__init__.py create mode 100644 esphome/components/vevor_dtx/binary_sensor.py create mode 100644 esphome/components/vevor_dtx/sensor.py create mode 100644 esphome/components/vevor_dtx/text_sensor.py create mode 100644 esphome/components/vevor_dtx/vevor_dtx.cpp create mode 100644 esphome/components/vevor_dtx/vevor_dtx.h create mode 100644 tests/components/vevor_dtx/__init__.py create mode 100644 tests/components/vevor_dtx/common.yaml create mode 100644 tests/components/vevor_dtx/test.esp32-idf.yaml create mode 100644 tests/components/vevor_dtx/test.esp8266-ard.yaml create mode 100644 tests/components/vevor_dtx/test.rp2040-ard.yaml create mode 100644 tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml create mode 100644 tests/components/vevor_dtx/vevor_dtx_test.cpp create mode 100644 tests/test_build_components/common/remote_receiver/rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 19f1194bd95..1f1375ba968 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -691,6 +691,7 @@ esphome/components/vbus/* @ssieb esphome/components/veml3235/* @kbx81 esphome/components/veml7700/* @latonita esphome/components/version/* @esphome/core +esphome/components/vevor_dtx/* @Bofr5555 esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher diff --git a/esphome/components/vevor_dtx/__init__.py b/esphome/components/vevor_dtx/__init__.py new file mode 100644 index 00000000000..03522c6f1d1 --- /dev/null +++ b/esphome/components/vevor_dtx/__init__.py @@ -0,0 +1,48 @@ +import esphome.codegen as cg +from esphome.components import remote_base +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import TimePeriod +from esphome.types import ConfigType + +AUTO_LOAD = ["remote_base"] +CODEOWNERS = ["@Bofr5555"] +DEPENDENCIES = ["remote_receiver"] +DOMAIN = "vevor_dtx" +MULTI_CONF = True + +CONF_BIT_TIME = "bit_time" +CONF_STATION_ID = "station_id" +CONF_VEVOR_DTX_ID = "vevor_dtx_id" + +vevor_dtx_ns = cg.esphome_ns.namespace("vevor_dtx") +VevorDtxComponent = vevor_dtx_ns.class_( + "VevorDtxComponent", cg.Component, remote_base.RemoteReceiverListener +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(VevorDtxComponent), + cv.Optional(CONF_BIT_TIME, default="88us"): cv.All( + cv.positive_time_period_microseconds, + cv.Range( + min=TimePeriod(microseconds=50), + max=TimePeriod(microseconds=150), + ), + ), + cv.Optional(CONF_STATION_ID): cv.hex_uint16_t, + } + ) + .extend(remote_base.REMOTE_LISTENER_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await remote_base.register_listener(var, config) + cg.add(var.set_bit_time_us(config[CONF_BIT_TIME])) + if (station_id := config.get(CONF_STATION_ID)) is not None: + cg.add(var.set_station_id(station_id)) diff --git a/esphome/components/vevor_dtx/binary_sensor.py b/esphome/components/vevor_dtx/binary_sensor.py new file mode 100644 index 00000000000..02ed7f291cc --- /dev/null +++ b/esphome/components/vevor_dtx/binary_sensor.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_BATTERY +from esphome.types import ConfigType + +from . import CONF_VEVOR_DTX_ID, VevorDtxComponent + +DEPENDENCIES = ["vevor_dtx"] + +CONF_LOW_BATTERY = "low_battery" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_VEVOR_DTX_ID): cv.use_id(VevorDtxComponent), + cv.Optional(CONF_LOW_BATTERY): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_BATTERY + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_VEVOR_DTX_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_LOW_BATTERY, hub.set_low_battery_binary_sensor) diff --git a/esphome/components/vevor_dtx/sensor.py b/esphome/components/vevor_dtx/sensor.py new file mode 100644 index 00000000000..2a44912222b --- /dev/null +++ b/esphome/components/vevor_dtx/sensor.py @@ -0,0 +1,107 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_HUMIDITY, + CONF_ILLUMINANCE, + CONF_TEMPERATURE, + CONF_WIND_DIRECTION_DEGREES, + CONF_WIND_SPEED, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_WIND_DIRECTION, + DEVICE_CLASS_WIND_SPEED, + ICON_COUNTER, + ICON_GRAIN, + ICON_SIGN_DIRECTION, + ICON_WEATHER_WINDY, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_CELSIUS, + UNIT_DEGREES, + UNIT_LUX, + UNIT_METER_PER_SECOND, + UNIT_MILLIMETER, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from . import CONF_VEVOR_DTX_ID, VevorDtxComponent + +DEPENDENCIES = ["vevor_dtx"] + +CONF_RAIN = "rain" +CONF_TX_COUNTER = "tx_counter" +CONF_UV_INDEX = "uv_index" +CONF_WIND_GUST = "wind_gust" + +_WIND_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_METER_PER_SECOND, + icon=ICON_WEATHER_WINDY, + accuracy_decimals=1, + device_class=DEVICE_CLASS_WIND_SPEED, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_VEVOR_DTX_ID): cv.use_id(VevorDtxComponent), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_WIND_SPEED): _WIND_SCHEMA, + cv.Optional(CONF_WIND_GUST): _WIND_SCHEMA, + cv.Optional(CONF_WIND_DIRECTION_DEGREES): sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREES, + icon=ICON_SIGN_DIRECTION, + accuracy_decimals=0, + device_class=DEVICE_CLASS_WIND_DIRECTION, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RAIN): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETER, + icon=ICON_GRAIN, + accuracy_decimals=1, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_UV_INDEX): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TX_COUNTER): sensor.sensor_schema( + accuracy_decimals=0, + icon=ICON_COUNTER, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_VEVOR_DTX_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_HUMIDITY, hub.set_humidity_sensor) + await sensors(CONF_WIND_SPEED, hub.set_wind_speed_sensor) + await sensors(CONF_WIND_GUST, hub.set_wind_gust_sensor) + await sensors(CONF_WIND_DIRECTION_DEGREES, hub.set_wind_direction_sensor) + await sensors(CONF_RAIN, hub.set_rain_sensor) + await sensors(CONF_UV_INDEX, hub.set_uv_index_sensor) + await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor) + await sensors(CONF_TX_COUNTER, hub.set_tx_counter_sensor) diff --git a/esphome/components/vevor_dtx/text_sensor.py b/esphome/components/vevor_dtx/text_sensor.py new file mode 100644 index 00000000000..38810b8d4db --- /dev/null +++ b/esphome/components/vevor_dtx/text_sensor.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_SENSOR_ID +from esphome.types import ConfigType + +from . import CONF_VEVOR_DTX_ID, VevorDtxComponent + +DEPENDENCIES = ["vevor_dtx"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_VEVOR_DTX_ID): cv.use_id(VevorDtxComponent), + cv.Optional(CONF_SENSOR_ID): text_sensor.text_sensor_schema( + icon="mdi:identifier", + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_VEVOR_DTX_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_SENSOR_ID, hub.set_sensor_id_text_sensor) diff --git a/esphome/components/vevor_dtx/vevor_dtx.cpp b/esphome/components/vevor_dtx/vevor_dtx.cpp new file mode 100644 index 00000000000..b42c3803fe5 --- /dev/null +++ b/esphome/components/vevor_dtx/vevor_dtx.cpp @@ -0,0 +1,159 @@ +#include "vevor_dtx.h" + +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::vevor_dtx { + +ESPHOME_LOG_TAG(TAG, "vevor_dtx"); + +// The five sync bytes and the two constant header bytes that start every frame, matched bit by bit +static constexpr uint64_t SYNC_AND_HEADER = 0xAAAACACA54AA00ULL; +static constexpr uint64_t SYNC_AND_HEADER_MASK = (1ULL << 56) - 1; +static constexpr uint8_t HEADER_SIZE = 2; +// Wind arrives in 1/8.333 km/h steps and gust in 1/1.25 km/h (0.8 km/h) steps; both are published in m/s +static constexpr float WIND_SPEED_MS_PER_RAW = 1.0f / (8.333f * 3.6f); +static constexpr float WIND_GUST_MS_PER_RAW = 1.0f / (1.25f * 3.6f); +static constexpr float RAIN_MM_PER_RAW = 0.233f; + +// Wind, direction, rain and light arrive with an offset of one on each byte; 0x00 wraps to 0xFF +static uint16_t offset_word(uint8_t high, uint8_t low) { + return encode_uint16(static_cast(high - 1), static_cast(low - 1)); +} + +void VevorDtxComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Vevor DTX:\n" + " Bit time: %u us", + this->bit_time_us_); + if (this->station_id_ != 0) { + ESP_LOGCONFIG(TAG, " Station ID: 0x%04X", this->station_id_); + } +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "Wind speed", this->wind_speed_sensor_); + LOG_SENSOR(" ", "Wind gust", this->wind_gust_sensor_); + LOG_SENSOR(" ", "Wind direction", this->wind_direction_sensor_); + LOG_SENSOR(" ", "Rain", this->rain_sensor_); + LOG_SENSOR(" ", "UV index", this->uv_index_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "TX counter", this->tx_counter_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Sensor ID", this->sensor_id_text_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Low battery", this->low_battery_binary_sensor_); +#endif +} + +bool VevorDtxComponent::on_receive(remote_base::RemoteReceiveData data) { + Payload payload{}; + if (!this->decode_timings_(data.get_raw_data(), payload)) { + ESP_LOGVV(TAG, "No Vevor DTX frame in %" PRId32 " timings", data.size()); + return false; + } + return this->publish_(payload); +} + +// The frame is NRZ at a fixed bit time: a high run of n bit times is n one bits. The bits are shifted +// through a register until the sync word appears, then the payload is collected and its checksum tested. +// A run is never treated as a gap, since a payload may hold long runs of zeros; a gap simply shifts zeros +// through the register, and one longer than a frame is cut to a frame's worth, which has the same effect. +bool VevorDtxComponent::decode_timings_(const remote_base::RawTimings &timings, Payload &payload) const { + uint64_t stream = 0; + uint16_t collected = 0; // payload bits gathered since the sync word, 0 while still searching for it + + for (const int32_t timing : timings) { + const uint32_t duration = std::abs(timing); + const uint32_t cells = + std::clamp((duration + this->bit_time_us_ / 2) / this->bit_time_us_, 1, FRAME_BITS); + const uint64_t bit = timing > 0 ? 1 : 0; + for (uint32_t i = 0; i < cells; i++) { + stream = (stream << 1) | bit; + if (collected == 0) { + if ((stream & SYNC_AND_HEADER_MASK) != SYNC_AND_HEADER) { + continue; + } + payload[0] = uint8_t(SYNC_AND_HEADER >> 8); + payload[1] = uint8_t(SYNC_AND_HEADER); + collected = HEADER_SIZE * 8; + continue; + } + payload[collected / 8] = uint8_t((payload[collected / 8] << 1) | bit); + if (++collected < FRAME_BITS) { + continue; + } + // The byte after the checksum repeats the counter plus one; it rejects frames whose sum matches by chance + if (sum_bytes(payload.data(), CHECKSUM_INDEX) == payload[CHECKSUM_INDEX] && + payload[TX_COUNTER_INDEX + 2] == uint8_t(payload[TX_COUNTER_INDEX] + 1)) { + return true; + } + ESP_LOGV(TAG, "Rejected frame: sum 0x%02X received 0x%02X, counter %u followed by %u", + sum_bytes(payload.data(), CHECKSUM_INDEX), payload[CHECKSUM_INDEX], payload[TX_COUNTER_INDEX], + payload[TX_COUNTER_INDEX + 2]); + collected = 0; + } + } + return false; +} + +bool VevorDtxComponent::publish_(const Payload &payload) { + const uint16_t sensor_id = encode_uint16(payload[2], payload[3]); + if (this->station_id_ != 0 && sensor_id != this->station_id_) { + ESP_LOGV(TAG, "Ignoring station 0x%04X", sensor_id); + return false; + } + const float temperature = (float(encode_uint16(payload[5], payload[6])) - 500.0f) / 10.0f; + const uint8_t humidity = payload[7]; + const float wind_speed = float(offset_word(payload[8], payload[9])) * WIND_SPEED_MS_PER_RAW; + const float wind_gust = float(payload[10]) * WIND_GUST_MS_PER_RAW; + const float wind_direction = float(offset_word(payload[11], payload[12]) & 0x0FFF); + const float rain = float(offset_word(payload[13], payload[14])) * RAIN_MM_PER_RAW; + const int8_t uv_index = int8_t(payload[15] & 0x1F) - 1; + // The top bit of the light word selects a tenfold range + const uint16_t lux_raw = offset_word(payload[16], payload[17]); + const float illuminance = (lux_raw & 0x8000) != 0 ? float(lux_raw & 0x7FFF) * 10.0f : float(lux_raw); + const uint8_t tx_counter = payload[18]; + + ESP_LOGD(TAG, + "Vevor DTX id=0x%04X status=0x%02X temp=%.1fC hum=%u%% wind=%.1fm/s gust=%.1fm/s dir=%.0f " + "rain=%.1f uv=%d raw=0x%02X lux=%.0f raw=0x%04X tx=%u", + sensor_id, payload[4], temperature, humidity, wind_speed, wind_gust, wind_direction, rain, uv_index, + payload[15], illuminance, lux_raw, tx_counter); + +#ifdef USE_SENSOR + const auto publish = [](sensor::Sensor *sensor, float value) { + if (sensor != nullptr) + sensor->publish_state(value); + }; + publish(this->temperature_sensor_, temperature); + publish(this->humidity_sensor_, humidity); + publish(this->wind_speed_sensor_, wind_speed); + publish(this->wind_gust_sensor_, wind_gust); + publish(this->wind_direction_sensor_, wind_direction); + publish(this->rain_sensor_, rain); + publish(this->uv_index_sensor_, uv_index); + publish(this->illuminance_sensor_, illuminance); + publish(this->tx_counter_sensor_, tx_counter); +#endif +#ifdef USE_TEXT_SENSOR + if (this->sensor_id_text_sensor_ != nullptr) { + char text[7]; + snprintf(text, sizeof(text), "0x%04X", sensor_id); + this->sensor_id_text_sensor_->publish_state(text); + } +#endif +#ifdef USE_BINARY_SENSOR + if (this->low_battery_binary_sensor_ != nullptr) + this->low_battery_binary_sensor_->publish_state((payload[4] & 0x80) != 0); +#endif + return true; +} + +} // namespace esphome::vevor_dtx diff --git a/esphome/components/vevor_dtx/vevor_dtx.h b/esphome/components/vevor_dtx/vevor_dtx.h new file mode 100644 index 00000000000..56eea99415d --- /dev/null +++ b/esphome/components/vevor_dtx/vevor_dtx.h @@ -0,0 +1,63 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/remote_base/remote_base.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 + +namespace esphome::vevor_dtx { + +class VevorDtxComponent : public Component, public remote_base::RemoteReceiverListener { +#ifdef USE_SENSOR + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(wind_speed) + SUB_SENSOR(wind_gust) + SUB_SENSOR(wind_direction) + SUB_SENSOR(rain) + SUB_SENSOR(uv_index) + SUB_SENSOR(illuminance) + SUB_SENSOR(tx_counter) +#endif +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(sensor_id) +#endif +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(low_battery) +#endif + + public: + void dump_config() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + void set_bit_time_us(uint8_t bit_time_us) { this->bit_time_us_ = bit_time_us; } + void set_station_id(uint16_t station_id) { this->station_id_ = station_id; } + + protected: + static constexpr uint8_t PAYLOAD_SIZE = 21; + static constexpr uint8_t CHECKSUM_INDEX = 19; + static constexpr uint8_t TX_COUNTER_INDEX = 18; + // The frame ends with the counter echo; trailing zero bits that merge into the receiver's idle entry come + // back when that entry is cut to a frame's worth of zeros + static constexpr uint16_t FRAME_BITS = PAYLOAD_SIZE * 8; + + using Payload = std::array; + + bool decode_timings_(const remote_base::RawTimings &timings, Payload &payload) const; + bool publish_(const Payload &payload); + + uint16_t station_id_{0}; // 0 accepts every station in range + uint8_t bit_time_us_{88}; +}; + +} // namespace esphome::vevor_dtx diff --git a/tests/components/vevor_dtx/__init__.py b/tests/components/vevor_dtx/__init__.py new file mode 100644 index 00000000000..d2fd62c61b2 --- /dev/null +++ b/tests/components/vevor_dtx/__init__.py @@ -0,0 +1,16 @@ +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # The gtests attach every entity kind the hub can publish to + for platform in ("sensor", "binary_sensor", "text_sensor"): + CORE.testing_ensure_platform_registered(platform) + + manifest.to_code = to_code_testing + # The frames come from the test, not from a receiver on a GPIO + manifest.dependencies = ["remote_base", "sensor", "binary_sensor", "text_sensor"] + # The host test config holds one plain hub entry; a multi-instance component would get an empty list + manifest.multi_conf = False diff --git a/tests/components/vevor_dtx/common.yaml b/tests/components/vevor_dtx/common.yaml new file mode 100644 index 00000000000..2d5de1e69d6 --- /dev/null +++ b/tests/components/vevor_dtx/common.yaml @@ -0,0 +1,39 @@ +vevor_dtx: + id: vevor_dtx_hub + receiver_id: rcvr + bit_time: 88us + station_id: 0x1234 + +sensor: + - platform: vevor_dtx + vevor_dtx_id: vevor_dtx_hub + temperature: + name: Outdoor Temperature + humidity: + name: Outdoor Humidity + wind_speed: + name: Wind Speed + wind_gust: + name: Wind Gust + wind_direction_degrees: + name: Wind Direction + rain: + name: Rain + uv_index: + name: UV Index + illuminance: + name: Illuminance + tx_counter: + name: TX Counter + +text_sensor: + - platform: vevor_dtx + vevor_dtx_id: vevor_dtx_hub + sensor_id: + name: Sensor ID + +binary_sensor: + - platform: vevor_dtx + vevor_dtx_id: vevor_dtx_hub + low_battery: + name: Low Battery diff --git a/tests/components/vevor_dtx/test.esp32-idf.yaml b/tests/components/vevor_dtx/test.esp32-idf.yaml new file mode 100644 index 00000000000..493b275f743 --- /dev/null +++ b/tests/components/vevor_dtx/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + vevor_dtx: !include common.yaml diff --git a/tests/components/vevor_dtx/test.esp8266-ard.yaml b/tests/components/vevor_dtx/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4ac20e92d06 --- /dev/null +++ b/tests/components/vevor_dtx/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + vevor_dtx: !include common.yaml diff --git a/tests/components/vevor_dtx/test.rp2040-ard.yaml b/tests/components/vevor_dtx/test.rp2040-ard.yaml new file mode 100644 index 00000000000..910202057f7 --- /dev/null +++ b/tests/components/vevor_dtx/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/rp2040-ard.yaml + vevor_dtx: !include common.yaml diff --git a/tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml b/tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml new file mode 100644 index 00000000000..43c3df671cd --- /dev/null +++ b/tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml @@ -0,0 +1,21 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + +# Two stations on one receiver, each with its own hub +vevor_dtx: + - id: roof_station + receiver_id: rcvr + station_id: 0x1234 + - id: garden_station + receiver_id: rcvr + station_id: 0x5678 + +sensor: + - platform: vevor_dtx + vevor_dtx_id: roof_station + temperature: + name: Roof Temperature + - platform: vevor_dtx + vevor_dtx_id: garden_station + temperature: + name: Garden Temperature diff --git a/tests/components/vevor_dtx/vevor_dtx_test.cpp b/tests/components/vevor_dtx/vevor_dtx_test.cpp new file mode 100644 index 00000000000..33fb2d94778 --- /dev/null +++ b/tests/components/vevor_dtx/vevor_dtx_test.cpp @@ -0,0 +1,208 @@ +#include + +#include + +#include "esphome/components/vevor_dtx/vevor_dtx.h" +#include "esphome/core/helpers.h" + +namespace esphome::vevor_dtx::testing { + +static constexpr uint32_t BIT_TIME_US = 88; +static constexpr int32_t FRAME_GAP_US = -20000; + +class VevorDtxUnderTest : public VevorDtxComponent { + public: + using VevorDtxComponent::decode_timings_; + using VevorDtxComponent::Payload; +}; + +// Payload of one transmission: station 0x1234 with a low battery, 21.5 °C, 55 %, 9.96 km/h wind, +// 20 km/h gust, 180°, 23.3 mm of rain, UV index 5, 1234 lx, transmission counter `tx` +static VevorDtxUnderTest::Payload sample_payload(uint8_t tx = 7) { + VevorDtxUnderTest::Payload p{0xAA, 0x00, 0x12, 0x34, 0x80, 0x02, 0xCB, 55, 0x01, 0x54, 25, + 0x01, 0xB5, 0x01, 0x65, 6, 0x05, 0xD3, tx, 0, uint8_t(tx + 1)}; + p[19] = sum_bytes(p.data(), 19); + return p; +} + +// Run lengths of the NRZ bit stream for the sync bytes plus payload, framed by the inter-frame gap on both +// sides; `lead_bits` extra one bits in front shift the byte alignment the receiver sees +static remote_base::RawTimings timings(const VevorDtxUnderTest::Payload &payload, uint8_t lead_bits = 0) { + std::vector bits(lead_bits, true); + const auto push_byte = [&bits](uint8_t byte) { + for (int i = 7; i >= 0; i--) { + bits.push_back(((byte >> i) & 1) != 0); + } + }; + for (uint8_t byte : {0xAA, 0xAA, 0xCA, 0xCA, 0x54}) { + push_byte(byte); + } + for (uint8_t byte : payload) { + push_byte(byte); + } + remote_base::RawTimings out{FRAME_GAP_US}; + bool level = bits[0]; + uint32_t run = 0; + for (bool bit : bits) { + if (bit == level) { + run++; + continue; + } + out.push_back(level ? int32_t(run * BIT_TIME_US) : -int32_t(run * BIT_TIME_US)); + level = bit; + run = 1; + } + out.push_back(level ? int32_t(run * BIT_TIME_US) : -int32_t(run * BIT_TIME_US)); + out.push_back(FRAME_GAP_US); + return out; +} + +static remote_base::RemoteReceiveData receive_data(const remote_base::RawTimings &raw) { + return remote_base::RemoteReceiveData(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); +} + +TEST(VevorDtxTest, DecodesFrame) { + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload payload{}; + ASSERT_TRUE(hub.decode_timings_(timings(sample_payload()), payload)); + EXPECT_EQ(payload, sample_payload()); +} + +TEST(VevorDtxTest, DecodesFrameAtEveryBitOffset) { + VevorDtxUnderTest hub; + for (uint8_t lead = 1; lead < 8; lead++) { + VevorDtxUnderTest::Payload payload{}; + ASSERT_TRUE(hub.decode_timings_(timings(sample_payload(), lead), payload)) << "lead bits " << int(lead); + EXPECT_EQ(payload, sample_payload()); + } +} + +// A payload may hold a run of zeros longer than any inter-bit gap; it must not be taken for a frame gap +TEST(VevorDtxTest, DecodesAFrameWithALongZeroRun) { + auto payload = sample_payload(0); + payload[7] = 49; + payload[16] = 0x02; + payload[17] = 0x00; + payload[20] = 1; + payload[19] = sum_bytes(payload.data(), 19); + ASSERT_EQ(payload[19], 0); // 32 zero bits in a row across bytes 17 to 20 + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload decoded{}; + ASSERT_TRUE(hub.decode_timings_(timings(payload), decoded)); + EXPECT_EQ(decoded[7], 49); + EXPECT_EQ(decoded[19], 0); +} + +// The receiver ends its capture at idle, so the frame's trailing low bits merge into one long gap +TEST(VevorDtxTest, DecodesAFrameWhoseTailMergesIntoTheIdleGap) { + // Counter 15 is echoed as 0x10, so the frame's last four bits are low + const auto payload = sample_payload(15); + ASSERT_EQ(payload[20] & 0x0F, 0); + auto raw = timings(payload); + // Drop the trailing zero run and the gap, and end the capture in one idle entry instead + raw.pop_back(); + if (raw.back() < 0) + raw.pop_back(); + raw.push_back(FRAME_GAP_US); + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload decoded{}; + ASSERT_TRUE(hub.decode_timings_(raw, decoded)); + EXPECT_EQ(decoded[19], payload[19]); +} + +// The byte after the checksum must echo the counter plus one; the checksum does not cover it +TEST(VevorDtxTest, RejectsAWrongCounterEcho) { + auto payload = sample_payload(); + payload[20] = payload[18] + 2; + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload decoded{}; + EXPECT_FALSE(hub.decode_timings_(timings(payload), decoded)); +} + +TEST(VevorDtxTest, RejectsBadChecksum) { + VevorDtxUnderTest hub; + auto corrupted = sample_payload(); + corrupted[7] ^= 0x01; + VevorDtxUnderTest::Payload payload{}; + EXPECT_FALSE(hub.decode_timings_(timings(corrupted), payload)); +} + +TEST(VevorDtxTest, PublishesDecodedValues) { + VevorDtxUnderTest hub; + sensor::Sensor temperature, humidity, wind_speed, wind_gust, wind_direction, rain, uv_index, illuminance, tx_counter; + text_sensor::TextSensor sensor_id; + binary_sensor::BinarySensor low_battery; + hub.set_temperature_sensor(&temperature); + hub.set_humidity_sensor(&humidity); + hub.set_wind_speed_sensor(&wind_speed); + hub.set_wind_gust_sensor(&wind_gust); + hub.set_wind_direction_sensor(&wind_direction); + hub.set_rain_sensor(&rain); + hub.set_uv_index_sensor(&uv_index); + hub.set_illuminance_sensor(&illuminance); + hub.set_tx_counter_sensor(&tx_counter); + hub.set_sensor_id_text_sensor(&sensor_id); + hub.set_low_battery_binary_sensor(&low_battery); + + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload())))); + + EXPECT_NEAR(temperature.state, 21.5f, 0.01f); + EXPECT_NEAR(humidity.state, 55.0f, 0.01f); + EXPECT_NEAR(wind_speed.state, 9.96f / 3.6f, 0.01f); + EXPECT_NEAR(wind_gust.state, 20.0f / 3.6f, 0.01f); + EXPECT_NEAR(wind_direction.state, 180.0f, 0.01f); + EXPECT_NEAR(rain.state, 23.3f, 0.01f); + EXPECT_NEAR(uv_index.state, 5.0f, 0.01f); + EXPECT_NEAR(illuminance.state, 1234.0f, 0.01f); + EXPECT_NEAR(tx_counter.state, 7.0f, 0.01f); + EXPECT_EQ(sensor_id.state, "0x1234"); + EXPECT_TRUE(low_battery.state); +} + +// A byte that wrapped past 0xFF and a light word with the tenfold bit set +TEST(VevorDtxTest, UndoesTheOffsetPerByte) { + VevorDtxUnderTest hub; + sensor::Sensor rain, illuminance; + hub.set_rain_sensor(&rain); + hub.set_illuminance_sensor(&illuminance); + auto payload = sample_payload(); + payload[13] = 0x01; // rain 255: the low byte is 255 + 1 wrapped to 0 + payload[14] = 0x00; + payload[16] = 0x80; // light 0x7F00 = 32512 lx without the tenfold bit + payload[17] = 0x01; + payload[19] = sum_bytes(payload.data(), 19); + EXPECT_TRUE(hub.on_receive(receive_data(timings(payload)))); + EXPECT_NEAR(rain.state, 255 * 0.233f, 0.01f); + EXPECT_NEAR(illuminance.state, 32512.0f, 0.01f); +} + +TEST(VevorDtxTest, DecodesAFrameAfterNoise) { + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload payload{}; + // A noise burst, the gap, then the frame: the gap resets the search and the frame still decodes + remote_base::RawTimings raw{FRAME_GAP_US, 300, -500, 1000, -176}; + const auto frame = timings(sample_payload()); + raw.insert(raw.end(), frame.begin(), frame.end()); + ASSERT_TRUE(hub.decode_timings_(raw, payload)); + EXPECT_EQ(payload, sample_payload()); +} + +TEST(VevorDtxTest, IgnoresOtherStations) { + VevorDtxUnderTest hub; + hub.set_station_id(0x9999); + EXPECT_FALSE(hub.on_receive(receive_data(timings(sample_payload())))); + hub.set_station_id(0x1234); + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload())))); +} + +TEST(VevorDtxTest, TxCounterIsTheStationsByte) { + VevorDtxUnderTest hub; + sensor::Sensor tx_counter; + hub.set_tx_counter_sensor(&tx_counter); + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload(250))))); + EXPECT_NEAR(tx_counter.state, 250.0f, 0.01f); + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload(3))))); + EXPECT_NEAR(tx_counter.state, 3.0f, 0.01f); +} + +} // namespace esphome::vevor_dtx::testing diff --git a/tests/test_build_components/common/remote_receiver/rp2040-ard.yaml b/tests/test_build_components/common/remote_receiver/rp2040-ard.yaml new file mode 100644 index 00000000000..fe13e2dbc1b --- /dev/null +++ b/tests/test_build_components/common/remote_receiver/rp2040-ard.yaml @@ -0,0 +1,12 @@ +# Common remote_receiver configuration for RP2040 Arduino tests +# Provides a shared remote receiver that all components can use +# Components will auto-use this receiver if they don't specify receiver_id + +substitutions: + remote_receiver_pin: GPIO5 + +remote_receiver: + id: rcvr + pin: ${remote_receiver_pin} + dump: all + tolerance: 25% From 1307faef06d60941f65d4cbcfec46e5dbd9981ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:31:17 -1000 Subject: [PATCH 099/120] [core] Add Component::status_comm_result (#20863) --- esphome/core/component.cpp | 9 ++++++ esphome/core/component.h | 5 ++++ .../core/test_status_comm_result.cpp | 30 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 tests/components/core/test_status_comm_result.cpp diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index edcd922aebd..f65ec469ef0 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -290,6 +290,15 @@ void Component::status_set_warning(const LogString *message) { ESP_LOGW(TAG, "%s set Warning flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } +bool Component::status_comm_result(bool ok) { return this->status_comm_result(ok, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } +bool Component::status_comm_result(bool ok, const LogString *message) { + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(message); + } + return ok; +} void Component::status_set_error() { this->status_set_error((const LogString *) nullptr); } void Component::status_set_error(const LogString *message) { if (this->set_error_flag_(message) && message != nullptr) { diff --git a/esphome/core/component.h b/esphome/core/component.h index 0e95f8d4e90..df51af65df5 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -285,6 +285,11 @@ class Component { void status_set_warning(const char *message); void status_set_warning(const LogString *message); + /// Clears the warning when `ok`, else sets it with `message` (default "Communication failed"); returns `ok`. + /// Replaces the `if (!read()) { status_set_warning(...); return; } status_clear_warning();` pattern. + bool status_comm_result(bool ok); + bool status_comm_result(bool ok, const LogString *message); + void status_set_error(); // Set error flag without message void status_set_error(const LogString *message); diff --git a/tests/components/core/test_status_comm_result.cpp b/tests/components/core/test_status_comm_result.cpp new file mode 100644 index 00000000000..125b43a1b8a --- /dev/null +++ b/tests/components/core/test_status_comm_result.cpp @@ -0,0 +1,30 @@ +#include + +#include "esphome/core/component.h" +#include "esphome/core/log.h" + +namespace esphome { + +TEST(StatusCommResult, FailureSetsWarningAndSuccessClearsIt) { + Component component; + EXPECT_FALSE(component.status_comm_result(false)); + EXPECT_TRUE(component.status_has_warning()); + EXPECT_TRUE(component.status_comm_result(true)); + EXPECT_FALSE(component.status_has_warning()); +} + +TEST(StatusCommResult, CustomMessageOverload) { + Component component; + EXPECT_FALSE(component.status_comm_result(false, LOG_STR("Bad CRC"))); + EXPECT_TRUE(component.status_has_warning()); + EXPECT_TRUE(component.status_comm_result(true, LOG_STR("Bad CRC"))); + EXPECT_FALSE(component.status_has_warning()); +} + +TEST(StatusCommResult, SuccessWithoutWarningStaysClear) { + Component component; + EXPECT_TRUE(component.status_comm_result(true)); + EXPECT_FALSE(component.status_has_warning()); +} + +} // namespace esphome From 968d57c56b0b2d1e209cc2f8f44b40dde31101b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:32:13 -1000 Subject: [PATCH 100/120] [core][time] Add BCD helpers and a shared RTC BCD calendar codec (#20860) --- esphome/components/time/real_time_clock.cpp | 33 +++++++++++++ esphome/components/time/real_time_clock.h | 6 +++ esphome/core/helpers.h | 5 ++ tests/components/core/test_bcd.cpp | 26 +++++++++++ tests/components/time/bcd_calendar.cpp | 51 +++++++++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 tests/components/core/test_bcd.cpp create mode 100644 tests/components/time/bcd_calendar.cpp diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 5f6b7d150f9..a46f9712291 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -107,4 +107,37 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { this->time_sync_callback_.call(); } +// Bits of each calendar byte that hold the value, byte 0 lowest; byte 3 is the one-hot weekday. +// Packed into one constant so it stays in code rather than RAM on ESP8266. +static constexpr uint64_t BCD_CALENDAR_MASKS = 0xFF1F3F7F3F7F7FULL; + +ESPTime decode_bcd_calendar(const uint8_t *raw) { + uint8_t value[7]; + for (size_t i = 0; i < 7; i++) + value[i] = bcd_to_uint8(raw[i] & static_cast(BCD_CALENDAR_MASKS >> (8 * i))); + const uint8_t weekday = raw[3] & static_cast(BCD_CALENDAR_MASKS >> 24); + ESPTime time{ + .second = value[0], + .minute = value[1], + .hour = value[2], + .day_of_week = static_cast(weekday ? __builtin_ctz(weekday) + 1 : 1), + .day_of_month = value[4], + .day_of_year = 1, + .month = value[5], + .year = static_cast(value[6] + 2000), + .is_dst = false, + .timestamp = 0, + }; + time.recalc_timestamp_utc(false); + return time; +} + +void encode_bcd_calendar(const ESPTime &time, uint8_t *raw) { + const uint8_t value[7] = { + time.second, time.minute, time.hour, 0, time.day_of_month, time.month, static_cast(time.year % 100)}; + for (size_t i = 0; i < 7; i++) + raw[i] = uint8_to_bcd(value[i]); + raw[3] = 1 << (time.day_of_week - 1); +} + } // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index f98adb67b5b..a87778e470c 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -44,4 +44,10 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; +/// Decode the 7-byte BCD calendar (second, minute, hour, one-hot weekday, day, month, year since 2000) +/// shared by Epson RTCs such as the RX8025T, RX8111 and RX8130, and fill in the UTC timestamp. +ESPTime decode_bcd_calendar(const uint8_t *raw); +/// Encode @p time into that 7-byte layout; @p time must be valid (is_valid()), with the year 2000 to 2099. +void encode_bcd_calendar(const ESPTime &time, uint8_t *raw); + } // namespace esphome::time diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c3e752c9690..d0e3c91ab0b 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1062,6 +1062,11 @@ constexpr std::array decode_value(T val) { return ret; } +/// Decode a two-digit packed BCD byte (tens in the high nibble) to its value, e.g. 0x59 to 59. +constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } +/// Encode a value from 0 to 99 as a two-digit packed BCD byte, e.g. 59 to 0x59. +constexpr uint8_t uint8_to_bcd(uint8_t value) { return ((value / 10) << 4) | (value % 10); } + /// Reverse the order of 8 bits. inline uint8_t reverse_bits(uint8_t x) { x = ((x & 0xAA) >> 1) | ((x & 0x55) << 1); diff --git a/tests/components/core/test_bcd.cpp b/tests/components/core/test_bcd.cpp new file mode 100644 index 00000000000..5d90d5aafcd --- /dev/null +++ b/tests/components/core/test_bcd.cpp @@ -0,0 +1,26 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static_assert(bcd_to_uint8(0x59) == 59); +static_assert(uint8_to_bcd(59) == 0x59); + +TEST(Bcd, RoundTripsEveryTwoDigitValue) { + for (uint8_t value = 0; value < 100; value++) { + const uint8_t bcd = uint8_to_bcd(value); + EXPECT_EQ(bcd >> 4, value / 10); + EXPECT_EQ(bcd & 0x0F, value % 10); + EXPECT_EQ(bcd_to_uint8(bcd), value); + } +} + +TEST(Bcd, DecodesBoundaryBytes) { + EXPECT_EQ(bcd_to_uint8(0x00), 0); + EXPECT_EQ(bcd_to_uint8(0x09), 9); + EXPECT_EQ(bcd_to_uint8(0x10), 10); + EXPECT_EQ(bcd_to_uint8(0x99), 99); +} + +} // namespace esphome::core::testing diff --git a/tests/components/time/bcd_calendar.cpp b/tests/components/time/bcd_calendar.cpp new file mode 100644 index 00000000000..c66454fe1b6 --- /dev/null +++ b/tests/components/time/bcd_calendar.cpp @@ -0,0 +1,51 @@ +#include + +#include + +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::time::testing { + +// 2026-04-16 23:59:58, a Thursday (day_of_week 5, one-hot bit 4). +static constexpr uint8_t RAW[7] = {0x58, 0x59, 0x23, 0x10, 0x16, 0x04, 0x26}; + +TEST(BcdCalendar, DecodesEveryField) { + const ESPTime time = decode_bcd_calendar(RAW); + EXPECT_EQ(time.second, 58); + EXPECT_EQ(time.minute, 59); + EXPECT_EQ(time.hour, 23); + EXPECT_EQ(time.day_of_week, 5); + EXPECT_EQ(time.day_of_month, 16); + EXPECT_EQ(time.month, 4); + EXPECT_EQ(time.year, 2026); + EXPECT_EQ(time.timestamp, 1776383998); + EXPECT_TRUE(time.is_valid(true, false)); +} + +TEST(BcdCalendar, MasksStatusBits) { + // Bit 7 of seconds/minutes, the top two bits of hour and day and the top three of month are not time. + const uint8_t raw[7] = {0xD8, 0xD9, 0xE3, 0x90, 0xD6, 0xE4, 0x26}; + const ESPTime time = decode_bcd_calendar(raw); + EXPECT_EQ(time.second, 58); + EXPECT_EQ(time.minute, 59); + EXPECT_EQ(time.hour, 23); + EXPECT_EQ(time.day_of_week, 5); + EXPECT_EQ(time.day_of_month, 16); + EXPECT_EQ(time.month, 4); +} + +TEST(BcdCalendar, EmptyWeekdayDecodesAsSunday) { + uint8_t raw[7]; + std::copy(RAW, RAW + 7, raw); + raw[3] = 0; + EXPECT_EQ(decode_bcd_calendar(raw).day_of_week, 1); +} + +TEST(BcdCalendar, EncodeRoundTrips) { + uint8_t raw[7]; + encode_bcd_calendar(decode_bcd_calendar(RAW), raw); + for (size_t i = 0; i < 7; i++) + EXPECT_EQ(raw[i], RAW[i]) << "byte " << i; +} + +} // namespace esphome::time::testing From a8ab035b59c1594f59c80f04f33fbf1eae4c1be5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:33:05 -1000 Subject: [PATCH 101/120] [lock] Filter on_lock and on_unlock with when (#20858) --- esphome/components/lock/__init__.py | 10 ++++++---- esphome/components/lock/automation.h | 22 ---------------------- 2 files changed, 6 insertions(+), 26 deletions(-) delete mode 100644 esphome/components/lock/automation.h diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index fb9a79f81fe..af12fc61b28 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -32,8 +32,6 @@ LockCall = lock_ns.class_("LockCall") LockPublishAction = lock_ns.class_("LockPublishAction", automation.Action) -LockStateForwarder = lock_ns.class_("LockStateForwarder") - LockState = lock_ns.enum("LockState") LOCK_STATES = LockState.options( @@ -90,12 +88,16 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_LOCK, "add_on_state_callback", - forwarder=LockStateForwarder.template(LockState.LOCK_STATE_LOCKED), + params=[(LockState, "state")], + forward=[], + when="state == lock::LOCK_STATE_LOCKED", ), automation.CallbackAutomation( CONF_ON_UNLOCK, "add_on_state_callback", - forwarder=LockStateForwarder.template(LockState.LOCK_STATE_UNLOCKED), + params=[(LockState, "state")], + forward=[], + when="state == lock::LOCK_STATE_UNLOCKED", ), ) diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h deleted file mode 100644 index e578a056439..00000000000 --- a/esphome/components/lock/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "esphome/components/lock/lock.h" -#include "esphome/core/automation.h" -#include "esphome/core/component.h" - -namespace esphome::lock { - -/// Callback forwarder that triggers an Automation<> only when a specific lock state is entered. -/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. -template struct LockStateForwarder { - Automation<> *automation; - void operator()(LockState state) const { - if (state == State) - this->automation->trigger(); - } -}; - -static_assert(sizeof(LockStateForwarder) <= sizeof(void *)); -static_assert(std::is_trivially_copyable_v>); - -} // namespace esphome::lock From 5ad63e3bb628fabf69e6a9a8f9deb74d31120b10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:38:07 -1000 Subject: [PATCH 102/120] [http_request] Only treat a URL as secure when its scheme is https (#20867) --- esphome/components/http_request/http_request_arduino.cpp | 3 ++- esphome/components/http_request/http_request_idf.cpp | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 814d598132e..e24531094da 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -40,7 +40,8 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons const uint32_t start = millis(); - bool secure = ESPHOME_strstr_P(url, ESPHOME_PSTR("https:")) != nullptr; + // Only the scheme counts; "https:" later in the URL (a query string) does not make it secure + bool secure = ESPHOME_strncasecmp_P(url, ESPHOME_PSTR("https:"), 6) == 0; container->set_secure(secure); container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms)); diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 879abb662b7..cc04c5472c0 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -9,6 +9,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE #include "esp_crt_bundle.h" @@ -77,7 +78,8 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch return nullptr; } - bool secure = strstr(url, "https:") != nullptr; + // Only the scheme counts; "https:" later in the URL (a query string) does not make it secure + bool secure = ESPHOME_strncasecmp_P(url, ESPHOME_PSTR("https:"), 6) == 0; esp_http_client_config_t config = {}; From 6580f4f314d1e6734547745f7655b3251f90816c Mon Sep 17 00:00:00 2001 From: embe <31192394+mbrudka@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:43:07 +0200 Subject: [PATCH 103/120] [micro_wake_word] Allow configurable capture buffer duration (#20942) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/micro_wake_word/__init__.py | 12 ++++++ .../micro_wake_word/micro_wake_word.cpp | 4 +- .../micro_wake_word/micro_wake_word.h | 5 +++ .../micro_wake_word/test_init.py | 37 ++++++++++++++++++- .../test-internal.esp32-idf.yaml | 1 + .../micro_wake_word/test.esp32-s3-idf.yaml | 1 + 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index ca65fa1a5e6..6756e93c486 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -48,6 +48,7 @@ CONF_TASK_CORE = "task_core" CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" +CONF_RING_BUFFER_DURATION = "ring_buffer_duration" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" CONF_PREFER_INTERNAL_RAM = "prefer_internal_ram" CONF_PROBABILITY_CUTOFF = "probability_cutoff" @@ -433,6 +434,11 @@ def _validate_task_core(config: ConfigType) -> ConfigType: return config +# The capture buffer holds at least one feature window and at most a second of audio +MIN_RING_BUFFER_DURATION = cv.TimePeriod(milliseconds=120) +MAX_RING_BUFFER_DURATION = cv.TimePeriod(seconds=1) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -455,6 +461,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, cv.Optional(CONF_TASK_CORE, default=-1): cv.int_range(min=-1, max=1), cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, + cv.Optional(CONF_RING_BUFFER_DURATION): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=MIN_RING_BUFFER_DURATION, max=MAX_RING_BUFFER_DURATION), + ), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter." @@ -635,6 +645,8 @@ async def to_code(config): cg.add(var.add_wake_word_model(wake_word_model)) cg.add(var.set_features_step_size(feature_step_size)) + if (ring_buffer_duration := config.get(CONF_RING_BUFFER_DURATION)) is not None: + cg.add(var.set_ring_buffer_duration_ms(ring_buffer_duration.total_milliseconds)) cg.add(var.set_stop_after_detection(config[CONF_STOP_AFTER_DETECTION])) if on_wake_word_detection_config := config.get(CONF_ON_WAKE_WORD_DETECTED): diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 872dd7bd79a..0c9d071e6f3 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -27,8 +27,6 @@ static const ssize_t DETECTION_QUEUE_LENGTH = 5; static const size_t DATA_TIMEOUT_MS = 50; -static const uint32_t RING_BUFFER_DURATION_MS = 120; - #ifdef CONFIG_IDF_TARGET_ESP32P4 // ESP32-P4 PIE-optimized esp-nn kernels (e.g. depthwise_conv_s8_ch1_pie) require // significantly more stack than other variants, causing stack protection faults at 3072. @@ -181,7 +179,7 @@ void MicroWakeWord::inference_task(void *params) { if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { // Round ring buffer size down to a frame multiple so the wrap boundary never splits an int16 sample. const size_t ring_buffer_size = - (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + (stream_info.ms_to_bytes(this_mww->ring_buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); if (temp_ring_buffer == nullptr) { xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 862d40733bf..5aa87cf1b73 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -24,6 +24,8 @@ namespace esphome::micro_wake_word { +static constexpr uint16_t DEFAULT_RING_BUFFER_DURATION_MS = 120; + enum State { STARTING, DETECTING_WAKE_WORD, @@ -54,6 +56,8 @@ class MicroWakeWord final : public Component void set_features_step_size(uint8_t step_size) { this->features_step_size_ = step_size; } + void set_ring_buffer_duration_ms(uint16_t duration_ms) { this->ring_buffer_duration_ms_ = duration_ms; } + void set_microphone_source(microphone::MicrophoneSource *microphone_source) { this->microphone_source_ = microphone_source; } @@ -126,6 +130,7 @@ class MicroWakeWord final : public Component bool task_stack_in_psram_{false}; uint8_t features_step_size_; + uint16_t ring_buffer_duration_ms_{DEFAULT_RING_BUFFER_DURATION_MS}; // at most a second, see the schema // Audio frontend handles generating spectrogram features struct FrontendConfig frontend_config_; diff --git a/tests/component_tests/micro_wake_word/test_init.py b/tests/component_tests/micro_wake_word/test_init.py index 5e57653585f..0d2e6b87985 100644 --- a/tests/component_tests/micro_wake_word/test_init.py +++ b/tests/component_tests/micro_wake_word/test_init.py @@ -7,8 +7,15 @@ from typing import Any import pytest -from esphome.components.micro_wake_word import LOCAL_SCHEMA +from esphome.components.micro_wake_word import ( + CONF_RING_BUFFER_DURATION, + CONFIG_SCHEMA, + LOCAL_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import PlatformFramework from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable MANIFEST: dict[str, Any] = { "type": "micro", @@ -27,6 +34,34 @@ MANIFEST: dict[str, Any] = { } +def test_ring_buffer_duration_is_absent_by_default( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + assert CONF_RING_BUFFER_DURATION not in CONFIG_SCHEMA({}) + + +@pytest.mark.parametrize( + ("duration", "expected_ms"), + [("120ms", 120), ("400ms", 400), ("800ms", 800), ("1s", 1000)], +) +def test_ring_buffer_duration_is_configurable( + duration: str, expected_ms: int, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({CONF_RING_BUFFER_DURATION: duration}) + assert config[CONF_RING_BUFFER_DURATION].total_milliseconds == expected_ms + + +@pytest.mark.parametrize("duration", ["119ms", "1001ms", "0ms"]) +def test_ring_buffer_duration_schema_rejects_out_of_range( + duration: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_RING_BUFFER_DURATION: duration}) + + def _registered_files() -> list[Path]: """Files components registered for bundling this run.""" data = CORE.data.get("bundle") diff --git a/tests/components/micro_wake_word/test-internal.esp32-idf.yaml b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml index bddab918c2a..0b072cadcc6 100644 --- a/tests/components/micro_wake_word/test-internal.esp32-idf.yaml +++ b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml @@ -7,3 +7,4 @@ packages: micro_wake_word: prefer_internal_ram: true + ring_buffer_duration: 800ms diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index 3ed671a764d..f4709dd5502 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -8,3 +8,4 @@ packages: micro_wake_word: task_core: 1 prefer_internal_ram: true + ring_buffer_duration: 400ms From 265754c4672d01a4e3380e580afd1057281d5b8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 17:43:16 -1000 Subject: [PATCH 104/120] [components] Drop get_setup_priority overrides that return the default (#21014) --- esphome/components/bmi270/bmi270.h | 1 - esphome/components/lsm6ds/lsm6ds.h | 1 - esphome/components/motion/motion_component.h | 1 - esphome/components/qmi8658/qmi8658.h | 1 - esphome/components/resampler/speaker/resampler_speaker.h | 1 - esphome/components/router/speaker/router_speaker.h | 2 -- esphome/components/sen6x/sen6x.h | 1 - .../sensor/waveshare_io_ch32v003_sensor.cpp | 2 -- .../waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h | 1 - esphome/components/xpt2046/touchscreen/xpt2046.cpp | 2 -- esphome/components/xpt2046/touchscreen/xpt2046.h | 1 - 11 files changed, 14 deletions(-) diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index a1fefdffe80..da12ea7973c 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -136,7 +136,6 @@ class BMI270Component final : public motion::MotionComponent, public i2c::I2CDev // Lifecycle void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } // Configuration setters void set_accel_range(BMI270AccelRange r) { this->accel_range_ = r; } diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index d754b08a252..9b3c442dbf0 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -96,7 +96,6 @@ class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDev public: void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } // Configuration setters (called from Python codegen) void set_accel_range(LSM6DSAccelRange r) { this->accel_range_ = r; } diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index 2ca920dbe0e..e2674dbdef6 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -65,7 +65,6 @@ class MotionComponent : public PollingComponent { void setup() override; void update() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } void set_matrix(const std::array &m) { memcpy(this->base_matrix_, m.data(), sizeof(this->base_matrix_)); diff --git a/esphome/components/qmi8658/qmi8658.h b/esphome/components/qmi8658/qmi8658.h index ce31a2b7a9f..aea668d7e5a 100644 --- a/esphome/components/qmi8658/qmi8658.h +++ b/esphome/components/qmi8658/qmi8658.h @@ -88,7 +88,6 @@ class QMI8658Component : public motion::MotionComponent, public i2c::I2CDevice { // Lifecycle void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } // Configuration setters void set_accel_range(QMI8658AccelRange r) { this->accel_range_ = r; } diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index 3255bf1fe8c..22ecb9e5207 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -16,7 +16,6 @@ namespace esphome::resampler { class ResamplerSpeaker final : public Component, public speaker::Speaker { public: - float get_setup_priority() const override { return esphome::setup_priority::DATA; } void dump_config() override; void setup() override; void loop() override; diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 2ce4c1a906d..f743d00fcce 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -14,8 +14,6 @@ namespace esphome::router { class Router final : public Component, public speaker::Speaker { public: - float get_setup_priority() const override { return setup_priority::DATA; } - void setup() override; void loop() override; void dump_config() override; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index e73f93f5a4c..234ad75cc40 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -82,7 +82,6 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S SUB_SENSOR(hcho) public: - float get_setup_priority() const override { return setup_priority::DATA; } void setup() override; void dump_config() override; void update() override; diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp index 71cb9ba79fd..42623319b60 100644 --- a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp @@ -6,8 +6,6 @@ namespace esphome::waveshare_io_ch32v003 { ESPHOME_LOG_TAG(TAG, "waveshare_io_ch32v003.sensor"); -float WaveshareIOCH32V003Sensor::get_setup_priority() const { return setup_priority::DATA; } - void WaveshareIOCH32V003Sensor::dump_config() { ESP_LOGCONFIG(TAG, "WaveshareIOCH32V003Sensor:\n" diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h index 01beab5137b..2e7af7b07dc 100644 --- a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h @@ -19,7 +19,6 @@ class WaveshareIOCH32V003Sensor : public sensor::Sensor, void update() override; void dump_config() override; - float get_setup_priority() const override; float sample() override; protected: diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index afb8f6cd5c8..903f8d6859a 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -82,8 +82,6 @@ void XPT2046Component::dump_config() { LOG_UPDATE_INTERVAL(this); } -// float XPT2046Component::get_setup_priority() const { return setup_priority::DATA; } - int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t value3) { int16_t delta_a, delta_b, delta_c; int16_t reta = 0; diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.h b/esphome/components/xpt2046/touchscreen/xpt2046.h index 8fe9b7cc436..0b74fe2c203 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.h +++ b/esphome/components/xpt2046/touchscreen/xpt2046.h @@ -22,7 +22,6 @@ class XPT2046Component final : public Touchscreen, void setup() override; void dump_config() override; - // float get_setup_priority() const override; protected: static int16_t best_two_avg(int16_t value1, int16_t value2, int16_t value3); From 2fe1333cba6d8efc8219153b992f238076d2bc4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 18:34:38 -1000 Subject: [PATCH 105/120] [display] Add add_writer for display lambdas (#21027) --- esphome/components/display/__init__.py | 15 ++++++++ tests/unit_tests/components/test_display.py | 41 +++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/unit_tests/components/test_display.py diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 22e3d9320b0..be8586b3b25 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -27,6 +27,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.final_validate import full_config +from esphome.types import ConfigType DOMAIN = "display" IS_PLATFORM_COMPONENT = True @@ -291,6 +292,20 @@ async def register_display(var, config): await setup_display_core_(var, config) +async def add_writer( + var: cg.MockObj, config: ConfigType, ref: cg.MockObj = DisplayRef +) -> None: + """Pass the configured lambda, if any, to var.set_writer. + + ref is the type of `it`; drivers with their own drawing class pass theirs. + """ + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(ref, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) + + @automation.register_action( "display.page.show", DisplayPageShowAction, diff --git a/tests/unit_tests/components/test_display.py b/tests/unit_tests/components/test_display.py new file mode 100644 index 00000000000..a98dd80ec0e --- /dev/null +++ b/tests/unit_tests/components/test_display.py @@ -0,0 +1,41 @@ +"""Tests for display.add_writer.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from esphome import codegen as cg +from esphome.components import display +from esphome.const import CONF_LAMBDA + + +@pytest.mark.parametrize( + ("kwargs", "ref"), + [({}, display.DisplayRef), ({"ref": "MyDisplayRef"}, "MyDisplayRef")], +) +@pytest.mark.asyncio +async def test_add_writer_sets_the_lambda(kwargs: dict, ref: object) -> None: + var = MagicMock(name="var") + lambda_config = MagicMock(name="lambda") + with ( + patch.object(cg, "process_lambda", AsyncMock(return_value="fn")) as process, + patch.object(cg, "add") as add, + ): + await display.add_writer(var, {CONF_LAMBDA: lambda_config}, **kwargs) + + process.assert_awaited_once_with(lambda_config, [(ref, "it")], return_type=cg.void) + var.set_writer.assert_called_once_with("fn") + add.assert_called_once_with(var.set_writer.return_value) + + +@pytest.mark.asyncio +async def test_add_writer_without_lambda_does_nothing() -> None: + var = MagicMock(name="var") + with ( + patch.object(cg, "process_lambda", AsyncMock()) as process, + patch.object(cg, "add") as add, + ): + await display.add_writer(var, {}) + + process.assert_not_awaited() + add.assert_not_called() From 0bb78186f2e4a08b608e9e8587f40a6415a9afb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 18:34:38 -1000 Subject: [PATCH 106/120] [components] Set display lambdas with display.add_writer (#21028) --- esphome/components/addressable_light/display.py | 6 +----- esphome/components/hub75/display.py | 7 +------ esphome/components/inkplate/display.py | 6 +----- esphome/components/it8951/display.py | 6 +----- esphome/components/lcd_gpio/display.py | 11 ++--------- esphome/components/lcd_pcf8574/display.py | 12 +++--------- esphome/components/max7219/display.py | 8 ++------ esphome/components/max7219digit/display.py | 7 +------ esphome/components/mipi_dsi/display.py | 7 +------ esphome/components/mipi_rgb/display.py | 7 +------ esphome/components/pcd8544/display.py | 6 +----- esphome/components/pixoo/display.py | 8 ++------ .../pvvx_mithermometer/display/__init__.py | 7 +------ esphome/components/sdl/display.py | 7 +------ esphome/components/sh1122_base/__init__.py | 8 ++------ esphome/components/snapshot/display/__init__.py | 7 +------ esphome/components/ssd1306_base/__init__.py | 7 +------ esphome/components/ssd1322_base/__init__.py | 14 ++------------ esphome/components/ssd1325_base/__init__.py | 14 ++------------ esphome/components/ssd1327_base/__init__.py | 8 ++------ esphome/components/ssd1331_base/__init__.py | 8 ++------ esphome/components/ssd1351_base/__init__.py | 8 ++------ esphome/components/st7567_base/__init__.py | 7 +------ esphome/components/st7701s/display.py | 7 +------ esphome/components/st7920/display.py | 8 ++------ esphome/components/tm1621/display.py | 7 +------ esphome/components/tm1637/display.py | 7 +------ esphome/components/tm1638/display.py | 8 +------- 28 files changed, 40 insertions(+), 183 deletions(-) diff --git a/esphome/components/addressable_light/display.py b/esphome/components/addressable_light/display.py index 1db01b40f90..3315d01f51a 100644 --- a/esphome/components/addressable_light/display.py +++ b/esphome/components/addressable_light/display.py @@ -56,8 +56,4 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_pixel_mapper(pixel_mapper_template_)) - if lambda_config := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lambda_config, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 408126e1ad1..b6aa35b8a7b 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_CLK_PIN, CONF_GAMMA_CORRECT, CONF_ID, - CONF_LAMBDA, CONF_OE_PIN, CONF_ROTATION, CONF_UPDATE_INTERVAL, @@ -603,11 +602,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], hub75_config) await display.register_display(var, config) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) automation.register_apply_action( diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 93eecbbe919..c7600d39bbc 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -164,11 +164,7 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await i2c.register_i2c_device(var, config) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) cg.add(var.set_greyscale(config[CONF_GREYSCALE])) if transform := config.get(CONF_TRANSFORM): diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index d9c80ebf43d..28636e5e26e 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -366,11 +366,7 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=False) - if lambda_config := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lambda_config, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) if reset_pin := config.get(CONF_RESET_PIN): cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) if busy_pin := config.get(CONF_BUSY_PIN): diff --git a/esphome/components/lcd_gpio/display.py b/esphome/components/lcd_gpio/display.py index 10e21c87d19..0e99b471b3c 100644 --- a/esphome/components/lcd_gpio/display.py +++ b/esphome/components/lcd_gpio/display.py @@ -1,12 +1,11 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import lcd_base +from esphome.components import display, lcd_base import esphome.config_validation as cv from esphome.const import ( CONF_DATA_PINS, CONF_ENABLE_PIN, CONF_ID, - CONF_LAMBDA, CONF_RS_PIN, CONF_RW_PIN, ) @@ -54,10 +53,4 @@ async def to_code(config: ConfigType) -> None: rw = await cg.gpio_pin_expression(config[CONF_RW_PIN]) cg.add(var.set_rw_pin(rw)) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], - [(GPIOLCDDisplay.operator("ref"), "it")], - return_type=cg.void, - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, GPIOLCDDisplay.operator("ref")) diff --git a/esphome/components/lcd_pcf8574/display.py b/esphome/components/lcd_pcf8574/display.py index 85a79e99cec..d97ef5b5699 100644 --- a/esphome/components/lcd_pcf8574/display.py +++ b/esphome/components/lcd_pcf8574/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg -from esphome.components import i2c, lcd_base +from esphome.components import display, i2c, lcd_base import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA +from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -24,10 +24,4 @@ async def to_code(config: ConfigType) -> None: await lcd_base.setup_lcd_display(var, config) await i2c.register_i2c_device(var, config) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], - [(PCF8574LCDDisplay.operator("ref"), "it")], - return_type=cg.void, - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, PCF8574LCDDisplay.operator("ref")) diff --git a/esphome/components/max7219/display.py b/esphome/components/max7219/display.py index b21f66b553d..76943c65280 100644 --- a/esphome/components/max7219/display.py +++ b/esphome/components/max7219/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import display, spi import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTENSITY, CONF_LAMBDA, CONF_NUM_CHIPS +from esphome.const import CONF_ID, CONF_INTENSITY, CONF_NUM_CHIPS from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -36,8 +36,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_intensity(config[CONF_INTENSITY])) cg.add(var.set_reverse(config[CONF_REVERSE_ENABLE])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(MAX7219ComponentRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, MAX7219ComponentRef) diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index 2271bc09f48..1a982946f68 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_FLIP_X, CONF_ID, CONF_INTENSITY, - CONF_LAMBDA, CONF_NUM_CHIPS, CONF_STATE, ) @@ -97,11 +96,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_reverse(config[CONF_REVERSE_ENABLE])) cg.add(var.set_flip_x(config[CONF_FLIP_X])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(MAX7219ComponentRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, MAX7219ComponentRef) MAX7219_OFF_ACTION_SCHEMA = automation.maybe_simple_id( diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 97af7a33243..6dd96d21bcb 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -45,7 +45,6 @@ from esphome.const import ( CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, @@ -224,8 +223,4 @@ async def to_code(config: ConfigType) -> None: if model.rotation_as_transform(config): config[CONF_ROTATION] = 0 await display.register_display(var, config) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index e973d78ae4d..4c87bf19987 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -65,7 +65,6 @@ from esphome.const import ( CONF_IGNORE_STRAPPING_WARNING, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MODEL, CONF_NUMBER, CONF_RED, @@ -340,8 +339,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_vsync_pin(pin)) await display.register_display(var, config) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/pcd8544/display.py b/esphome/components/pcd8544/display.py index 42cef674884..c63d18b3cc4 100644 --- a/esphome/components/pcd8544/display.py +++ b/esphome/components/pcd8544/display.py @@ -54,8 +54,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_contrast(config[CONF_CONTRAST])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/pixoo/display.py b/esphome/components/pixoo/display.py index 764f06d603a..3aaf378f727 100644 --- a/esphome/components/pixoo/display.py +++ b/esphome/components/pixoo/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import display, spi import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA, CONF_MODEL +from esphome.const import CONF_ID, CONF_MODEL from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -36,8 +36,4 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) - if (lambda_config := config.get(CONF_LAMBDA)) is not None: - lambda_ = await cg.process_lambda( - lambda_config, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/pvvx_mithermometer/display/__init__.py b/esphome/components/pvvx_mithermometer/display/__init__.py index 436f83630c1..1b8c0b5be30 100644 --- a/esphome/components/pvvx_mithermometer/display/__init__.py +++ b/esphome/components/pvvx_mithermometer/display/__init__.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, CONF_DISCONNECT_DELAY, CONF_ID, - CONF_LAMBDA, CONF_TIME_ID, CONF_VALIDITY_PERIOD, ) @@ -49,8 +48,4 @@ async def to_code(config: ConfigType) -> None: time_ = await cg.get_variable(config[CONF_TIME_ID]) cg.add(var.set_time(time_)) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(PVVXDisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, PVVXDisplayRef) diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index 77b0001c556..58dff4430eb 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_DIMENSIONS, CONF_HEIGHT, CONF_ID, - CONF_LAMBDA, CONF_POSITION, CONF_WIDTH, CONF_X, @@ -189,8 +188,4 @@ async def to_code(config: ConfigType) -> None: else: cg.add(var.set_position(position[CONF_X], position[CONF_Y])) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/sh1122_base/__init__.py b/esphome/components/sh1122_base/__init__.py index 4f2b026a5ed..e8828685368 100644 --- a/esphome/components/sh1122_base/__init__.py +++ b/esphome/components/sh1122_base/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN +from esphome.const import CONF_BRIGHTNESS, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -36,8 +36,4 @@ async def setup_sh1122(var: MockObj, config: ConfigType) -> None: reset = await cg.gpio_pin_expression(reset_pin) cg.add(var.set_reset_pin(reset)) cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if (lambda_config := config.get(CONF_LAMBDA)) is not None: - lambda_ = await cg.process_lambda( - lambda_config, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/snapshot/display/__init__.py b/esphome/components/snapshot/display/__init__.py index 68429f164bd..eaecc869197 100644 --- a/esphome/components/snapshot/display/__init__.py +++ b/esphome/components/snapshot/display/__init__.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_DIMENSIONS, CONF_HEIGHT, CONF_ID, - CONF_LAMBDA, CONF_WIDTH, PLATFORM_HOST, ) @@ -54,8 +53,4 @@ async def to_code(config: ConfigType) -> None: (width, height) = dimensions cg.add(var.set_dimensions(width, height)) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index 9577f9d4f7d..b51fb6f8f22 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_FLIP_X, CONF_FLIP_Y, CONF_INVERT, - CONF_LAMBDA, CONF_MODEL, CONF_OFFSET_X, CONF_OFFSET_Y, @@ -100,8 +99,4 @@ async def setup_ssd1306(var: MockObj, config: ConfigType) -> None: cg.add(var.init_offset_y(config[CONF_OFFSET_Y])) if CONF_INVERT in config: cg.add(var.init_invert(config[CONF_INVERT])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1322_base/__init__.py b/esphome/components/ssd1322_base/__init__.py index d66ae8d5c97..72f8bd27235 100644 --- a/esphome/components/ssd1322_base/__init__.py +++ b/esphome/components/ssd1322_base/__init__.py @@ -2,13 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import ( - CONF_BRIGHTNESS, - CONF_EXTERNAL_VCC, - CONF_LAMBDA, - CONF_MODEL, - CONF_RESET_PIN, -) +from esphome.const import CONF_BRIGHTNESS, CONF_EXTERNAL_VCC, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -46,8 +40,4 @@ async def setup_ssd1322(var: MockObj, config: ConfigType) -> None: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: cg.add(var.set_external_vcc(config[CONF_EXTERNAL_VCC])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1325_base/__init__.py b/esphome/components/ssd1325_base/__init__.py index fbbf329d53b..e148808d15f 100644 --- a/esphome/components/ssd1325_base/__init__.py +++ b/esphome/components/ssd1325_base/__init__.py @@ -2,13 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import ( - CONF_BRIGHTNESS, - CONF_EXTERNAL_VCC, - CONF_LAMBDA, - CONF_MODEL, - CONF_RESET_PIN, -) +from esphome.const import CONF_BRIGHTNESS, CONF_EXTERNAL_VCC, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -50,8 +44,4 @@ async def setup_ssd1325(var: MockObj, config: ConfigType) -> None: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: cg.add(var.set_external_vcc(config[CONF_EXTERNAL_VCC])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1327_base/__init__.py b/esphome/components/ssd1327_base/__init__.py index 2c9c0faf3b2..93e4c75d3b1 100644 --- a/esphome/components/ssd1327_base/__init__.py +++ b/esphome/components/ssd1327_base/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN +from esphome.const import CONF_BRIGHTNESS, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -37,8 +37,4 @@ async def setup_ssd1327(var: MockObj, config: ConfigType) -> None: cg.add(var.set_reset_pin(reset)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1331_base/__init__.py b/esphome/components/ssd1331_base/__init__.py index 2345acdf650..5f71570b6ae 100644 --- a/esphome/components/ssd1331_base/__init__.py +++ b/esphome/components/ssd1331_base/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_RESET_PIN +from esphome.const import CONF_BRIGHTNESS, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -28,8 +28,4 @@ async def setup_ssd1331(var: MockObj, config: ConfigType) -> None: cg.add(var.set_reset_pin(reset)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1351_base/__init__.py b/esphome/components/ssd1351_base/__init__.py index f693004d633..2c44cd18403 100644 --- a/esphome/components/ssd1351_base/__init__.py +++ b/esphome/components/ssd1351_base/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN +from esphome.const import CONF_BRIGHTNESS, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -38,8 +38,4 @@ async def setup_ssd1351(var: MockObj, config: ConfigType) -> None: cg.add(var.set_reset_pin(reset)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/st7567_base/__init__.py b/esphome/components/st7567_base/__init__.py index 8fdc0a7f7af..a045cf34ccd 100644 --- a/esphome/components/st7567_base/__init__.py +++ b/esphome/components/st7567_base/__init__.py @@ -4,7 +4,6 @@ from esphome.components import display import esphome.config_validation as cv from esphome.const import ( CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, CONF_RESET_PIN, @@ -51,8 +50,4 @@ async def setup_st7567(var: MockObj, config: ConfigType) -> None: cg.add(var.init_mirror_x(transform[CONF_MIRROR_X])) cg.add(var.init_mirror_y(transform[CONF_MIRROR_Y])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 2805837ee01..79430f14176 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -30,7 +30,6 @@ from esphome.const import ( CONF_IGNORE_STRAPPING_WARNING, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, CONF_NUMBER, @@ -235,11 +234,7 @@ async def to_code(config: ConfigType) -> None: (width, height) = dimensions cg.add(var.set_dimensions(width, height)) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) pin = await cg.gpio_pin_expression(config[CONF_DE_PIN]) cg.add(var.set_de_pin(pin)) diff --git a/esphome/components/st7920/display.py b/esphome/components/st7920/display.py index 853e38a2795..ce4fe5d364c 100644 --- a/esphome/components/st7920/display.py +++ b/esphome/components/st7920/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import display, spi import esphome.config_validation as cv -from esphome.const import CONF_HEIGHT, CONF_ID, CONF_LAMBDA, CONF_WIDTH +from esphome.const import CONF_HEIGHT, CONF_ID, CONF_WIDTH from esphome.types import ConfigType AUTO_LOAD = ["display"] @@ -31,11 +31,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await spi.register_spi_device(var, config, write_only=True) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(ST7920Ref, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, ST7920Ref) cg.add(var.set_width(config[CONF_WIDTH])) cg.add(var.set_height(config[CONF_HEIGHT])) diff --git a/esphome/components/tm1621/display.py b/esphome/components/tm1621/display.py index e8a40507d84..4a83d363fe4 100644 --- a/esphome/components/tm1621/display.py +++ b/esphome/components/tm1621/display.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CS_PIN, CONF_DATA_PIN, CONF_ID, - CONF_LAMBDA, CONF_READ_PIN, CONF_WRITE_PIN, ) @@ -40,8 +39,4 @@ async def to_code(config: ConfigType) -> None: write = await cg.gpio_pin_expression(config[CONF_WRITE_PIN]) cg.add(var.set_write_pin(write)) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(TM1621DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, TM1621DisplayRef) diff --git a/esphome/components/tm1637/display.py b/esphome/components/tm1637/display.py index ec4d32561b8..ebdd9449d65 100644 --- a/esphome/components/tm1637/display.py +++ b/esphome/components/tm1637/display.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_ID, CONF_INTENSITY, CONF_INVERTED, - CONF_LAMBDA, CONF_LENGTH, ) from esphome.types import ConfigType @@ -46,8 +45,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_inverted(config[CONF_INVERTED])) cg.add(var.set_length(config[CONF_LENGTH])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(TM1637DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, TM1637DisplayRef) diff --git a/esphome/components/tm1638/display.py b/esphome/components/tm1638/display.py index d6491129c66..74ee8b3293d 100644 --- a/esphome/components/tm1638/display.py +++ b/esphome/components/tm1638/display.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_DIO_PIN, CONF_ID, CONF_INTENSITY, - CONF_LAMBDA, CONF_STB_PIN, ) from esphome.types import ConfigType @@ -47,9 +46,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_intensity(config[CONF_INTENSITY])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(TM1638ComponentRef, "it")], return_type=cg.void - ) - - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, TM1638ComponentRef) From 032a2b7200a8895f9821cc6bbbfaa91f18b9ba78 Mon Sep 17 00:00:00 2001 From: Michele Porelli Date: Sat, 17 Oct 2026 23:58:40 -0700 Subject: [PATCH 107/120] [core] Let entities choose flash or RTC storage for their preferences (#21017) Co-authored-by: J. Nick Koston --- esphome/core/entity_base.cpp | 13 +++- esphome/core/entity_base.h | 12 +++- .../host/entity_preference_test.cpp | 71 +++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 tests/components/host/entity_preference_test.cpp diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index cbb14a37302..51cdb19eb42 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -136,7 +136,7 @@ StringRef EntityBase::get_object_id_to(std::span buf) c return StringRef(buf.data(), len); } -ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { +uint32_t EntityBase::entity_preference_key_(uint32_t version) { // The key hashes the sanitized object_id, so multiple entity names can collide on one // key and overwrite each other's stored preferences ("Living Room" and "living_room", // or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name @@ -144,9 +144,16 @@ ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t ve // esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85 #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - uint32_t key = this->get_preference_hash() ^ version; + return this->get_preference_hash() ^ version; #pragma GCC diagnostic pop - return global_preferences->make_preference(size, key); +} + +ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { + return global_preferences->make_preference(size, this->entity_preference_key_(version)); +} + +ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version, bool in_flash) { + return global_preferences->make_preference(size, this->entity_preference_key_(version), in_flash); } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 8796e9f067a..3250ad74730 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -190,6 +190,12 @@ class EntityBase { return this->make_entity_preference_(sizeof(T), version); } + /// As make_entity_preference(), stored in flash or RTC memory where the backend supports the choice. + template ESPPreferenceObject make_entity_preference(uint32_t version, bool in_flash) { + static_assert(std::is_trivially_copyable::value, "T must be trivially copyable"); + return this->make_entity_preference_(sizeof(T), version, in_flash); + } + protected: friend void ::setup(); friend void ::original_setup(); @@ -206,9 +212,11 @@ class EntityBase { void set_device_(Device *device) { this->device_ = device; } #endif - /// Non-template helper for make_entity_preference() to avoid code bloat. - /// When the preference hash algorithm changes, migration logic goes here. + /// Non-template helpers for make_entity_preference() to avoid code bloat. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); + ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version, bool in_flash); + /// Key for both helpers; preference hash migration goes here. + uint32_t entity_preference_key_(uint32_t version); void calc_object_id_(); diff --git a/tests/components/host/entity_preference_test.cpp b/tests/components/host/entity_preference_test.cpp new file mode 100644 index 00000000000..3ee6f21132d --- /dev/null +++ b/tests/components/host/entity_preference_test.cpp @@ -0,0 +1,71 @@ +#ifdef USE_HOST +#include +#include +#include +#include +#include "esphome/components/host/preferences.h" +#include "esphome/core/application.h" +#include "esphome/core/entity_base.h" + +namespace esphome::host::testing { +namespace fs = std::filesystem; + +/// Same object_id_hash_ means same preference key. +class TestEntity : public EntityBase { + public: + void set_test_id(uint32_t hash) { this->object_id_hash_ = hash; } +}; + +class EntityPreferenceTest : public ::testing::Test { + protected: + void SetUp() override { + this->temp_dir_ = fs::temp_directory_path() / "esphome_entity_preference_test"; + fs::remove_all(this->temp_dir_); + fs::create_directories(this->temp_dir_); + setenv("ESPHOME_PREFDIR", this->temp_dir_.c_str(), 1); + App.pre_setup("test_entity_pref", strlen("test_entity_pref"), "", 0); + setup_preferences(); + // Clear the in-memory store so values do not leak between tests. + host_preferences->reset(); + } + + void TearDown() override { + unsetenv("ESPHOME_PREFDIR"); + fs::remove_all(this->temp_dir_); + } + + fs::path temp_dir_; +}; + +// in_flash must not change the key; host ignores the storage type, so only the key is checked. +TEST_F(EntityPreferenceTest, InFlashOverloadKeepsTheEntityKey) { + TestEntity entity; + entity.set_test_id(0x6001); + for (const bool in_flash : {false, true}) { + const uint32_t saved = in_flash ? 0xF1A5 : 0x0C7C; + auto with_storage = entity.make_entity_preference(7, in_flash); + ASSERT_TRUE(with_storage.save(&saved)); + global_preferences->sync(); + + uint32_t loaded = 0; + auto without_storage = entity.make_entity_preference(7); + ASSERT_TRUE(without_storage.load(&loaded)) << "in_flash=" << in_flash; + EXPECT_EQ(loaded, saved) << "in_flash=" << in_flash; + } +} + +TEST_F(EntityPreferenceTest, InFlashOverloadStillAppliesTheVersion) { + TestEntity entity; + entity.set_test_id(0x6002); + const uint32_t saved = 42; + auto v1 = entity.make_entity_preference(1, true); + ASSERT_TRUE(v1.save(&saved)); + global_preferences->sync(); + + uint32_t loaded = 0; + auto v2 = entity.make_entity_preference(2, true); + EXPECT_FALSE(v2.load(&loaded)) << "a different version must give a different key"; +} + +} // namespace esphome::host::testing +#endif // USE_HOST From 54a28b3e3b55bde5337a302695942845d1f6f10c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:37:48 -1000 Subject: [PATCH 108/120] [bl0906] Read stale receive bytes instead of spinning on flush() (#21035) --- esphome/components/bl0906/bl0906.cpp | 4 +- .../fixtures/uart_mock_bl0906_stray_rx.yaml | 64 +++++++++++++++++++ .../test_uart_mock_bl0906_stray_rx.py | 57 +++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml create mode 100644 tests/integration/test_uart_mock_bl0906_stray_rx.py diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 38edb6ed919..35f663ffa0c 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -20,7 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data) void BL0906::loop() { while (this->available()) - this->flush(); + this->read(); if (this->current_stage_ == STAGE_IDLE) { // Woken up between cycles to drain the action queue. Go back to sleep. @@ -92,7 +92,7 @@ void BL0906::advance_stage_() { void BL0906::setup() { while (this->available()) - this->flush(); + this->read(); this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE)); // Calibration (1: register address; 2: value before calibration; 3: value after calibration) this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1 diff --git a/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml new file mode 100644 index 00000000000..d5f8a7837ce --- /dev/null +++ b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml @@ -0,0 +1,64 @@ +esphome: + name: uart-mock-bl0906-stray-rx + +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 + +# Answers register reads; the first temperature answer has one stray trailing byte +uart_mock: + id: mock_uart + baud_rate: 19200 + on_tx: + - then: + - lambda: |- + // Command and register address arrive as separate one-byte writes + static bool read_pending = false; + static bool stray_sent = false; + if (data.size() != 1) + return; + if (!read_pending) { + read_pending = data[0] == 0x35; + return; + } + read_pending = false; + const uint8_t address = data[0]; + uint32_t raw; + switch (address) { + case 0x5E: // temperature: (300 - 64) * 12.5 / 59 - 40 = 10 C + raw = 300; + break; + case 0x16: // voltage + raw = 0x2A1B3C; + break; + default: + return; + } + const uint8_t l = raw & 0xFF, m = (raw >> 8) & 0xFF, h = (raw >> 16) & 0xFF; + std::vector reply{l, m, h, static_cast((address + l + m + h) ^ 0xFF)}; + if (address == 0x5E && !stray_sent) { + stray_sent = true; + reply.push_back(0xAA); + } + id(mock_uart).inject_to_rx_buffer(reply); + +sensor: + - platform: bl0906 + uart_id: mock_uart + update_interval: 1s + temperature: + name: Temperature + voltage: + name: Voltage diff --git a/tests/integration/test_uart_mock_bl0906_stray_rx.py b/tests/integration/test_uart_mock_bl0906_stray_rx.py new file mode 100644 index 00000000000..5f8a635c7f5 --- /dev/null +++ b/tests/integration/test_uart_mock_bl0906_stray_rx.py @@ -0,0 +1,57 @@ +"""A BL0906 that leaves a stray byte in the receive buffer keeps reading.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import EntityState, SensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_bl0906_stray_rx( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A stray received byte does not stop the read cycle.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + lines: list[str] = [] + states: dict[int, EntityState] = {} + + async with ( + run_compiled(yaml_config, line_callback=lines.append), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + temperature = require_entity(entities, "temperature", SensorInfo) + voltage = require_entity(entities, "voltage", SensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity: SensorInfo) -> float | None: + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + try: + async with asyncio.timeout(10.0): + while state_of(voltage) is None: + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"No voltage reading; log: {lines[-20:]}") + + assert any("RX inject 5 bytes" in x for x in lines), "stray byte not sent" + assert state_of(temperature) == pytest.approx(10.0, abs=0.01) + uref = 1.097 * 100000 / (13162 * 100 * 1000) + assert state_of(voltage) == pytest.approx(0x2A1B3C * uref, rel=1e-5) + assert not any("Junk on wire" in x for x in lines) From 0922df9bb3ee60c0c698dd8f81980d67087ed7a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:39:02 -1000 Subject: [PATCH 109/120] [uart] Add discard_available to drop stale receive bytes (#21029) --- esphome/components/uart/uart.cpp | 7 ++++ esphome/components/uart/uart.h | 3 ++ tests/components/uart/uart_read_available.cpp | 38 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index 56b8e6401e8..1c545af8956 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -33,6 +33,13 @@ void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UART } } +void UARTDevice::discard_available() { + UARTComponent *parent = this->parent_; + uint8_t byte; + while (parent->available() && parent->read_byte(&byte)) { + } +} + const LogString *parity_to_str(UARTParityOptions parity) { return UARTParityStrings::get_log_str(static_cast(parity), UARTParityStrings::LAST_INDEX); } diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index a0840aa26a7..e8f98304bc9 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -90,6 +90,9 @@ class UARTDevice { count); } + /// Read and drop every byte waiting in the receive buffer. + void discard_available(); + UARTFlushResult flush() { return this->parent_->flush(); } // Compat APIs diff --git a/tests/components/uart/uart_read_available.cpp b/tests/components/uart/uart_read_available.cpp index 10c3e7fa0ef..6adc0b475df 100644 --- a/tests/components/uart/uart_read_available.cpp +++ b/tests/components/uart/uart_read_available.cpp @@ -141,4 +141,42 @@ TEST(UARTReadAvailableTest, ChunksWithCountStopEarly) { EXPECT_EQ(uart.remaining(), 14u); } +TEST(UARTReadAvailableTest, DiscardDropsEveryWaitingByte) { + ::testing::NiceMock uart(make_bytes(5)); + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_EQ(uart.remaining(), 0u); +} + +TEST(UARTReadAvailableTest, DiscardWithNothingAvailableDoesNotRead) { + ::testing::NiceMock uart(std::vector{}); + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_TRUE(uart.read_sizes.empty()); +} + +TEST(UARTReadAvailableTest, DiscardDropsBytesThatArriveWhileDraining) { + ::testing::NiceMock uart(std::vector{1, 2}); + bool appended = false; + ON_CALL(uart, available()).WillByDefault(Invoke([&]() -> size_t { + if (!appended && uart.read_sizes.size() == 1) { + uart.append({9, 9}); + appended = true; + } + return uart.remaining(); + })); + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_TRUE(appended); + EXPECT_EQ(uart.remaining(), 0u); +} + +TEST(UARTReadAvailableTest, DiscardStopsOnReadFailure) { + ::testing::NiceMock uart(make_bytes(5)); + uart.fail_after_reads = 2; + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_EQ(uart.read_sizes.size(), 3u); +} + } // namespace esphome::uart::testing From 3665bcc4e37d3fe43c34ebafa278d743f597449b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:40:35 -1000 Subject: [PATCH 110/120] [ble_device_base][ble_client][modbus] Add new_ble_device_component, new_ble_node_component and new_modbus_client_component (#21025) --- esphome/components/ble_client/__init__.py | 10 +++++++++ .../components/ble_device_base/__init__.py | 10 ++++++++- esphome/components/modbus/__init__.py | 9 +++++++- .../components/test_new_bus_component.py | 22 +++++++++++++++++-- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index b783ef1c153..64dd02c4dc0 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Coroutine +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -171,6 +174,13 @@ async def register_ble_node(var, config): cg.add(parent.register_ble_node(var)) +def new_ble_node_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Construct, register and attach to the BLE client; args go to the constructor.""" + return cg.new_bus_component(register_ble_node, config, *args) + + BLE_WRITE_ACTION_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(BLEClient), diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index e8283457c6c..9f089034143 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -24,8 +24,9 @@ AES-CCM decryption for encrypted advertisements is provided portably in ble_aes_ccm.h. """ -from collections.abc import Callable +from collections.abc import Callable, Coroutine import re +from typing import Any import esphome.codegen as cg from esphome.components.const import CONF_WINDOW @@ -182,6 +183,13 @@ async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj return var +def new_ble_device_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Construct, register and attach to the BLE hub; args go to the constructor.""" + return cg.new_bus_component(register_ble_device, config, *args) + + # ---- shared validation / codegen helpers (platform-neutral) ---- diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 4e4d7fe1608..2c3aba950fe 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import logging from typing import Any, Literal, NamedTuple @@ -390,6 +390,13 @@ async def register_modbus_client_device(var: MockObj, config: ConfigType) -> Non cg.add(var.set_address(config[CONF_ADDRESS])) +def new_modbus_client_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, MockObj]: + """Construct, register and attach to the modbus bus; args go to the constructor.""" + return cg.new_bus_component(register_modbus_client_device, config, *args) + + async def register_modbus_server_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/tests/unit_tests/components/test_new_bus_component.py b/tests/unit_tests/components/test_new_bus_component.py index 54f947d6257..2fa5c67b24e 100644 --- a/tests/unit_tests/components/test_new_bus_component.py +++ b/tests/unit_tests/components/test_new_bus_component.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from esphome import cpp_helpers -from esphome.components import i2c, spi, uart +from esphome.components import ble_client, ble_device_base, i2c, modbus, spi, uart from esphome.const import CONF_ID @@ -18,6 +18,17 @@ from esphome.const import CONF_ID (i2c.new_i2c_component, i2c, "register_i2c_device"), (uart.new_uart_component, uart, "register_uart_device"), (spi.new_spi_component, spi, "register_spi_device"), + ( + ble_device_base.new_ble_device_component, + ble_device_base, + "register_ble_device", + ), + (ble_client.new_ble_node_component, ble_client, "register_ble_node"), + ( + modbus.new_modbus_client_component, + modbus, + "register_modbus_client_device", + ), ], ) @pytest.mark.asyncio @@ -47,7 +58,14 @@ async def test_creates_registers_and_attaches_in_order( @pytest.mark.parametrize( "helper", - [i2c.new_i2c_component, uart.new_uart_component, spi.new_spi_component], + [ + i2c.new_i2c_component, + uart.new_uart_component, + spi.new_spi_component, + ble_device_base.new_ble_device_component, + ble_client.new_ble_node_component, + modbus.new_modbus_client_component, + ], ) def test_wrappers_are_plain_functions(helper: Callable[..., Any]) -> None: # An async wrapper would stay on the stack and become the component source. From 270d9894811b31957c0a7f689ba09393dca83290 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:41:33 -1000 Subject: [PATCH 111/120] [core] Add cg.add_pin and cg.add_variable for optional pins and ids (#21018) --- AGENTS.md | 4 +++ esphome/codegen.py | 2 ++ esphome/cpp_helpers.py | 22 ++++++++++++++- tests/unit_tests/test_cpp_helpers.py | 40 +++++++++++++++++++++++++++- 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5581a255dba..b2d3b442bf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,6 +398,10 @@ file does, and it is the authority when they disagree. The most useful starting await buttons(CONF_RESTART, var.set_restart_button) ``` + - **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])` + and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass + `config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for `set_parent`. + * **Automations (Triggers, Actions, Conditions):** Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true). diff --git a/esphome/codegen.py b/esphome/codegen.py index 64f2ca9f04d..cf569b7bff7 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -53,6 +53,8 @@ from esphome.cpp_generator import ( # noqa: F401 with_local_variable, ) from esphome.cpp_helpers import ( # noqa: F401 + add_pin, + add_variable, build_registry_entry, build_registry_list, extract_registry_entry_config, diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index c744b09728e..ba26d14bc98 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -24,7 +24,7 @@ from esphome.cpp_generator import ( ) from esphome.cpp_types import App from esphome.helpers import cpp_string_escape -from esphome.types import ConfigFragmentType, ConfigType +from esphome.types import ConfigFragmentType, ConfigType, Expression from esphome.util import Registry, RegistryEntry _LOGGER = logging.getLogger(__name__) @@ -231,6 +231,26 @@ async def gpio_pin_expression(conf): return await coroutine(pins.PIN_SCHEMA_REGISTRY[CORE.target_platform][0])(conf) +async def add_pin( + setter: Callable[[MockObj], Expression], pin_config: ConfigType | None +) -> MockObj | None: + """Pass the pin built from ``pin_config`` to ``setter`` and return it; skip None.""" + if (pin := await gpio_pin_expression(pin_config)) is not None: + add(setter(pin)) + return pin + + +async def add_variable( + setter: Callable[[MockObj], Expression], id_: ID | None +) -> MockObj | None: + """Pass the variable declared by ``id_`` to ``setter`` and return it; skip None.""" + if id_ is None: + return None + var = await get_variable(id_) + add(setter(var)) + return var + + def set_setup_priority(var, priority: float) -> None: """Emit a setup-priority override for the given component. diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 7f56faddfbc..4c3a425ae3e 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -1,9 +1,11 @@ +from collections.abc import Awaitable, Callable import importlib.util import logging from pathlib import Path import sys from types import ModuleType -from unittest.mock import Mock +from typing import Any +from unittest.mock import AsyncMock, Mock import pytest @@ -301,3 +303,39 @@ async def test_new_bus_component_source_is_the_caller( result = await dev.to_code(bus.new_component, {const.CONF_ID: "foo.bar"}) assert result is var source.assert_called_once_with(expected) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("helper", [ch.add_pin, ch.add_variable]) +async def test_add_helpers_skip_none( + monkeypatch: pytest.MonkeyPatch, helper: Callable[..., Awaitable[Any]] +) -> None: + add_mock = Mock() + monkeypatch.setattr(ch, "add", add_mock) + setter = Mock() + + assert await helper(setter, None) is None + setter.assert_not_called() + add_mock.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("helper", "lookup"), + [("add_pin", "gpio_pin_expression"), ("add_variable", "get_variable")], +) +async def test_add_helpers_pass_value_to_setter( + monkeypatch: pytest.MonkeyPatch, helper: str, lookup: str +) -> None: + value = Mock() + lookup_mock = AsyncMock(return_value=value) + monkeypatch.setattr(ch, lookup, lookup_mock) + add_mock = Mock() + monkeypatch.setattr(ch, "add", add_mock) + setter = Mock() + arg = Mock() + + assert await getattr(ch, helper)(setter, arg) is value + lookup_mock.assert_awaited_once_with(arg) + setter.assert_called_once_with(value) + add_mock.assert_called_once_with(setter.return_value) From 2a416fbce6b674f855fe99bc98d9ffe6b0a9dec0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:45:21 -1000 Subject: [PATCH 112/120] [core] Document that hub entities go in platforms in AGENTS.md (#21003) --- AGENTS.md | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b2d3b442bf6..c165ddc350f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,7 +287,8 @@ file does, and it is the authority when they disagree. The most useful starting `from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other metadata constants. CI (`lint_component_domain`) fails without it. - `DEPENDENCIES`: List of required components - - `AUTO_LOAD`: Components to automatically load + - `AUTO_LOAD`: Components to automatically load. Not a way to put entities under the component's + own key; see "Entities of a hub" below. - `CONFLICTS_WITH`: Incompatible components - `CODEOWNERS`: GitHub usernames responsible for maintenance - `MULTI_CONF`: Whether multiple instances are allowed @@ -379,6 +380,26 @@ file does, and it is the authority when they disagree. The most useful starting var = await switch.new_switch(config) ``` + - **Entities of a hub:** a new component's entities go in platform files (`sensor.py`, + `binary_sensor.py`, ...) that take the hub's id, never under the hub's own key with their platforms + added to `AUTO_LOAD` to make that work. Auto loading a platform because the C++ uses its type is + fine. See https://developers.esphome.io/architecture/components/#exposing-multiple-entities. + ```yaml + # Bad - entities under the hub's key + my_hub: + connected: + name: Connected + + # Good - the hub, then one platform entry that holds its entities + my_hub: + id: hub1 + binary_sensor: + - platform: my_hub + my_hub_id: hub1 # optional with a single hub + connected: + name: Connected + ``` + - **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or `sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`, `sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented`, @@ -389,13 +410,19 @@ file does, and it is the authority when they disagree. The most useful starting itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are. ```python + # my_hub/sensor.py async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_MY_HUB_ID]) sensors = sensor.sub_sensors(config) - await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) - await sensors(CONF_HUMIDITY, var.set_humidity_sensor) - buttons = button.sub_buttons(config, parent=var) - await buttons(CONF_RESTART, var.set_restart_button) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_HUMIDITY, hub.set_humidity_sensor) + + + # my_hub/button.py + async def to_code(config): + hub = await cg.get_variable(config[CONF_MY_HUB_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_RESTART, hub.set_restart_button) ``` - **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])` From 0cd1ae01f120572f4a26a90e9b89ac8c82a89fee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:52:14 -1000 Subject: [PATCH 113/120] [components] Use cv.uint8_t and cv.uint16_t for full byte ranges (#21015) --- esphome/components/am43/cover/__init__.py | 2 +- esphome/components/as7341/sensor.py | 2 +- esphome/components/as734x/__init__.py | 2 +- esphome/components/grove_tb6612fng/__init__.py | 2 +- esphome/components/hub75/display.py | 4 ++-- esphome/components/lc709203f/sensor.py | 2 +- esphome/components/ltr501/sensor.py | 8 ++------ esphome/components/ltr_als_ps/sensor.py | 8 ++------ esphome/components/lvgl/lv_validation.py | 4 ++-- esphome/components/lvgl/widgets/animimg.py | 2 +- esphome/components/micronova/sensor/__init__.py | 4 +--- esphome/components/mipi/__init__.py | 2 +- esphome/components/mixer/speaker/__init__.py | 4 +--- esphome/components/nextion/base_component.py | 4 ++-- esphome/components/opentherm/schema.py | 8 ++++---- esphome/components/sen6x/sensor.py | 4 +--- esphome/components/sx127x/__init__.py | 2 +- esphome/components/template/text/__init__.py | 4 ++-- esphome/components/tmc22xx/__init__.py | 2 +- .../components/waveshare_io_ch32v003/output/__init__.py | 8 ++------ esphome/components/wled/__init__.py | 2 +- 21 files changed, 31 insertions(+), 49 deletions(-) diff --git a/esphome/components/am43/cover/__init__.py b/esphome/components/am43/cover/__init__.py index d1783b77df9..5257866a885 100644 --- a/esphome/components/am43/cover/__init__.py +++ b/esphome/components/am43/cover/__init__.py @@ -19,7 +19,7 @@ CONFIG_SCHEMA = ( cover.cover_schema(Am43Component) .extend( { - cv.Optional(CONF_PIN, default=8888): cv.int_range(min=0, max=0xFFFF), + cv.Optional(CONF_PIN, default=8888): cv.uint16_t, cv.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, } ) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index 65307def3d0..9f5b4be89e3 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -73,7 +73,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_CLEAR): SENSOR_SCHEMA, cv.Optional(CONF_NIR): SENSOR_SCHEMA, cv.Optional(CONF_GAIN, default="X8"): cv.enum(GAIN_OPTIONS), - cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255), + cv.Optional(CONF_ATIME, default=29): cv.uint8_t, cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534), } ) diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index dc45ddd1308..e82be0ea66f 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__init__.py @@ -127,7 +127,7 @@ def _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_ATIME, default=29): cv.uint8_t, 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( diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index 501071f42b9..3bb4095ba17 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -47,7 +47,7 @@ automation.register_apply_action( { cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), - cv.Required(CONF_SPEED): cv.templatable(cv.int_range(min=0, max=255)), + cv.Required(CONF_SPEED): cv.templatable(cv.uint8_t), cv.Required(CONF_DIRECTION): cv.enum(DIRECTION_TYPE, upper=True), } ), diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index b6aa35b8a7b..e63b94d3833 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -397,7 +397,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SHIFT_DRIVER): cv.enum(SHIFT_DRIVERS, upper=True), # Display configuration cv.Optional(CONF_DOUBLE_BUFFER): cv.boolean, - cv.Optional(CONF_BRIGHTNESS): cv.int_range(min=0, max=255), + cv.Optional(CONF_BRIGHTNESS): cv.uint8_t, cv.Optional(CONF_BIT_DEPTH): cv.int_range(min=4, max=12), cv.Optional(CONF_GAMMA_CORRECT): cv.enum( {"LINEAR": 0, "CIE1931": 1, "GAMMA_2_2": 2}, upper=True @@ -610,7 +610,7 @@ automation.register_apply_action( cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HUB75Display), - cv.Required(CONF_BRIGHTNESS): cv.templatable(cv.int_range(min=0, max=255)), + cv.Required(CONF_BRIGHTNESS): cv.templatable(cv.uint8_t), }, key=CONF_BRIGHTNESS, ), diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 8ebb14d2ca7..a5b02f22242 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -60,7 +60,7 @@ CONFIG_SCHEMA = ( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend( { - cv.Required(CONF_B_CONSTANT): cv.int_range(0, 0xFFFF), + cv.Required(CONF_B_CONSTANT): cv.uint16_t, } ), } diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index ca9416f924f..a46b312bd07 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -138,12 +138,8 @@ CONFIG_SCHEMA = cv.All( CONF_PS_COOLDOWN, default="5s" ): cv.positive_time_period_seconds, cv.Optional(CONF_PS_GAIN, default="1X"): cv.enum(PS_GAINS, upper=True), - cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.int_range( - min=0, max=65535 - ), - cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.int_range( - min=0, max=65535 - ), + cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.uint16_t, + cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.uint16_t, cv.Optional(CONF_ON_PS_HIGH_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_ON_PS_LOW_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_AMBIENT_LIGHT): cv.maybe_simple_value( diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 5ebaed1f249..0deb01c2830 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -133,12 +133,8 @@ CONFIG_SCHEMA = cv.All( CONF_PS_COOLDOWN, default="5s" ): cv.positive_time_period_seconds, cv.Optional(CONF_PS_GAIN, default="16X"): cv.enum(PS_GAINS, upper=True), - cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.int_range( - min=0, max=65535 - ), - cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.int_range( - min=0, max=65535 - ), + cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.uint16_t, + cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.uint16_t, cv.Optional(CONF_ON_PS_HIGH_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_ON_PS_LOW_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_AMBIENT_LIGHT): cv.maybe_simple_value( diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index d8a96de639a..646e472e7fe 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -236,7 +236,7 @@ def id_name(value): def stop_value(value): - return cv.int_range(0, 255)(value) + return cv.uint8_t(value) def _image_validator(value): @@ -398,7 +398,7 @@ lv_brightness = LValidator( def _percentage_validator(value): - value = cv.Any(cv.percentage, cv.float_range(0.0, 1.0), cv.int_range(0, 255))(value) + value = cv.Any(cv.percentage, cv.float_range(0.0, 1.0), cv.uint8_t)(value) if isinstance(value, int): return value / 255.0 return value diff --git a/esphome/components/lvgl/widgets/animimg.py b/esphome/components/lvgl/widgets/animimg.py index b6d59df7f25..8540b3f5529 100644 --- a/esphome/components/lvgl/widgets/animimg.py +++ b/esphome/components/lvgl/widgets/animimg.py @@ -17,7 +17,7 @@ CONF_ANIMIMG = "animimg" def lv_repeat_count(value): if isinstance(value, str) and value.lower() in ("forever", "infinite"): value = 0xFFFF - return cv.int_range(min=0, max=0xFFFF)(value) + return cv.uint16_t(value) ANIMIMG_BASE_SCHEMA = cv.Schema( diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index 6091718d658..7062e436ce4 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -86,9 +86,7 @@ CONFIG_SCHEMA = cv.Schema( is_polling_component=True, ) ) - .extend( - {cv.Optional(CONF_FAN_RPM_OFFSET, default=0): cv.int_range(min=0, max=255)} - ), + .extend({cv.Optional(CONF_FAN_RPM_OFFSET, default=0): cv.uint8_t}), cv.Optional(CONF_WATER_TEMPERATURE): sensor.sensor_schema( MicroNovaSensor, unit_of_measurement=UNIT_CELSIUS, diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index b2351e23a44..4db1ebdc5ed 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -235,7 +235,7 @@ def map_sequence(value): cv.Range(TimePeriod(milliseconds=1), TimePeriod(milliseconds=255)), )(value) return DELAY_FLAG, delay_value.total_milliseconds - value = cv.All(cv.ensure_list(cv.int_range(0, 255)), cv.Length(1, 254))(value) + value = cv.All(cv.ensure_list(cv.uint8_t), cv.Length(1, 254))(value) return tuple(value) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 44473b12c32..9d0a55ebb97 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -147,9 +147,7 @@ automation.register_apply_action( cv.Schema( { cv.GenerateID(): cv.use_id(SourceSpeaker), - cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable( - cv.int_range(min=0, max=255) - ), + cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable(cv.uint8_t), cv.Optional(CONF_DURATION, default="0.0s"): cv.templatable( cv.positive_time_period_milliseconds ), diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 74a50a95d4c..17c1b263ed8 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -72,7 +72,7 @@ CONFIG_TEXT_COMPONENT_SCHEMA = CONFIG_BASE_COMPONENT_SCHEMA.extend( cv.Schema( { cv.Required(CONF_COMPONENT_NAME): NextionName, - cv.Optional(CONF_FONT_ID): cv.int_range(min=0, max=255), + cv.Optional(CONF_FONT_ID): cv.uint8_t, } ) ) @@ -89,7 +89,7 @@ CONFIG_BINARY_SENSOR_SCHEMA = CONFIG_BASE_COMPONENT_SCHEMA.extend( CONFIG_SENSOR_COMPONENT_SCHEMA = CONFIG_BINARY_SENSOR_SCHEMA.extend( cv.Schema( { - cv.Optional(CONF_FONT_ID): cv.int_range(min=0, max=255), + cv.Optional(CONF_FONT_ID): cv.uint8_t, } ) ) diff --git a/esphome/components/opentherm/schema.py b/esphome/components/opentherm/schema.py index 7f3ea2df36d..5c735c4aa81 100644 --- a/esphome/components/opentherm/schema.py +++ b/esphome/components/opentherm/schema.py @@ -844,7 +844,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_hb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=1, ), @@ -854,7 +854,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_lb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=1, ), @@ -874,7 +874,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_hb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=5, ), @@ -884,7 +884,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_lb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=5, ), diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 953b6529ef6..b928f97774e 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -233,9 +233,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_NORMALIZED_OFFSET_SLOPE, default=0 ): cv.float_range(min=-3.2768, max=3.2767), - cv.Optional(CONF_TIME_CONSTANT, default=0): cv.int_range( - min=0, max=65535 - ), + cv.Optional(CONF_TIME_CONSTANT, default=0): cv.uint16_t, } ), cv.Optional(CONF_TEMPERATURE_ACCELERATION): cv.Schema( diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 08660415a62..3bd3b35a2ed 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -205,7 +205,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_PREAMBLE_POLARITY, default=0xAA): cv.All( cv.hex_int, cv.one_of(0xAA, 0x55) ), - cv.Optional(CONF_PREAMBLE_SIZE, default=0): cv.int_range(min=0, max=65535), + cv.Optional(CONF_PREAMBLE_SIZE, default=0): cv.uint16_t, cv.Required(CONF_RST_PIN): pins.internal_gpio_output_pin_schema, cv.Optional(CONF_RX_FLOOR, default=-94): cv.float_range(min=-128, max=-1), cv.Optional(CONF_RX_START, default=True): cv.boolean, diff --git a/esphome/components/template/text/__init__.py b/esphome/components/template/text/__init__.py index 1266370cb21..68e6eadd7b8 100644 --- a/esphome/components/template/text/__init__.py +++ b/esphome/components/template/text/__init__.py @@ -51,8 +51,8 @@ CONFIG_SCHEMA = cv.All( text.text_schema(TemplateText) .extend( { - cv.Optional(CONF_MIN_LENGTH, default=0): cv.int_range(min=0, max=255), - cv.Optional(CONF_MAX_LENGTH, default=255): cv.int_range(min=0, max=255), + cv.Optional(CONF_MIN_LENGTH, default=0): cv.uint8_t, + cv.Optional(CONF_MAX_LENGTH, default=255): cv.uint8_t, cv.Optional(CONF_PATTERN): cv.string, cv.Optional(CONF_LAMBDA): cv.returning_lambda, cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index a3ac518d8eb..1018443e002 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -242,7 +242,7 @@ def register_actions(domain: str, cls: MockObjClass) -> None: ), cv.Exclusive(CONF_IHOLD, "hold"): cv.templatable(cv.int_range(0, 31)), cv.Optional(CONF_IHOLDDELAY): cv.templatable(cv.int_range(0, 15)), - cv.Optional(CONF_TPOWERDOWN): cv.templatable(cv.int_range(0, 255)), + cv.Optional(CONF_TPOWERDOWN): cv.templatable(cv.uint8_t), cv.Optional(CONF_STANDSTILL_MODE): cv.templatable( cv.enum(STANDSTILL_MODES, lower=True) ), diff --git a/esphome/components/waveshare_io_ch32v003/output/__init__.py b/esphome/components/waveshare_io_ch32v003/output/__init__.py index 7438769928d..5463ddcc652 100644 --- a/esphome/components/waveshare_io_ch32v003/output/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/output/__init__.py @@ -39,12 +39,8 @@ def validate_pwm_limits(config: ConfigType) -> ConfigType: CONF_SAFE_PWM_LEVELS_SCHEMA = cv.Schema( { - cv.Optional(CONF_MIN_VALUE, default=DUTY_DEFAULT_MIN): cv.int_range( - min=0, max=255 - ), - cv.Optional(CONF_MAX_VALUE, default=DUTY_DEFAULT_MAX): cv.int_range( - min=0, max=255 - ), + cv.Optional(CONF_MIN_VALUE, default=DUTY_DEFAULT_MIN): cv.uint8_t, + cv.Optional(CONF_MAX_VALUE, default=DUTY_DEFAULT_MAX): cv.uint8_t, } ) diff --git a/esphome/components/wled/__init__.py b/esphome/components/wled/__init__.py index 6b3263e6a3b..59f09c2bf6d 100644 --- a/esphome/components/wled/__init__.py +++ b/esphome/components/wled/__init__.py @@ -22,7 +22,7 @@ CONF_BLANK_ON_START = "blank_on_start" "WLED", { cv.Optional(CONF_PORT, default=21324): cv.port, - cv.Optional(CONF_SYNC_GROUP_MASK, default=0): cv.int_range(min=0, max=255), + cv.Optional(CONF_SYNC_GROUP_MASK, default=0): cv.uint8_t, cv.Optional(CONF_BLANK_ON_START, default=True): cv.boolean, }, ) From ed9616d512c3fe136ea58d2e2398162559e39c2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:10:22 -1000 Subject: [PATCH 114/120] [components] Set pins with cg.add_pin (#21019) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/a4988/stepper.py | 11 +-- esphome/components/ac_dimmer/output.py | 6 +- esphome/components/adc/sensor.py | 3 +- esphome/components/ade7880/sensor.py | 13 +--- esphome/components/ade7953_base/__init__.py | 4 +- esphome/components/apc1/sensor.py | 9 +-- esphome/components/as3935/__init__.py | 3 +- esphome/components/as5600/__init__.py | 4 +- .../axs15231/touchscreen/__init__.py | 6 +- esphome/components/bp1658cj/__init__.py | 6 +- esphome/components/bp5758d/__init__.py | 6 +- esphome/components/cap1188/__init__.py | 4 +- esphome/components/cc1101/__init__.py | 4 +- esphome/components/cd74hc4067/__init__.py | 12 +-- .../cdc_acm_uart/bridge/__init__.py | 8 +- esphome/components/chsc6x/touchscreen.py | 3 +- .../components/cst226/touchscreen/__init__.py | 6 +- .../components/cst328/touchscreen/__init__.py | 6 +- .../components/cst816/touchscreen/__init__.py | 6 +- .../cst9220/touchscreen/__init__.py | 6 +- esphome/components/deep_sleep/__init__.py | 3 +- esphome/components/dht/sensor.py | 4 +- esphome/components/ds248x/__init__.py | 4 +- esphome/components/duty_cycle/sensor.py | 3 +- .../ektf2232/touchscreen/__init__.py | 6 +- esphome/components/epaper_spi/display.py | 11 +-- esphome/components/esp32_dac/output.py | 3 +- esphome/components/esp8266_pwm/output.py | 3 +- .../components/fingerprint_grow/__init__.py | 9 +-- .../components/ft5x06/touchscreen/__init__.py | 4 +- esphome/components/ft63x6/touchscreen.py | 8 +- .../components/gpio/binary_sensor/__init__.py | 3 +- esphome/components/gpio/one_wire/__init__.py | 3 +- esphome/components/gpio/output/__init__.py | 3 +- esphome/components/gpio/switch/__init__.py | 3 +- esphome/components/gsl3670/touchscreen.py | 9 +-- .../components/gt911/touchscreen/__init__.py | 6 +- esphome/components/hbridge/switch/__init__.py | 6 +- esphome/components/hlw8012/sensor.py | 9 +-- esphome/components/hx711/sensor.py | 6 +- esphome/components/icnt86/touchscreen.py | 9 +-- esphome/components/inkplate/display.py | 75 +++++-------------- esphome/components/it8951/display.py | 6 +- esphome/components/lcd_gpio/display.py | 12 +-- .../lilygo_t5_47/touchscreen/__init__.py | 3 +- esphome/components/mics_5524/sensor.py | 3 +- esphome/components/mipi_dsi/display.py | 4 +- esphome/components/mipi_rgb/display.py | 24 ++---- esphome/components/mipi_spi/display.py | 9 +-- esphome/components/mlx90393/sensor.py | 4 +- esphome/components/modbus/__init__.py | 5 +- esphome/components/my9231/__init__.py | 6 +- esphome/components/nrf52/__init__.py | 4 +- esphome/components/opentherm/__init__.py | 7 +- esphome/components/pcd8544/display.py | 6 +- esphome/components/pn7160/__init__.py | 9 +-- esphome/components/pn71xx/__init__.py | 7 +- esphome/components/power_supply/__init__.py | 3 +- esphome/components/pulse_counter/sensor.py | 3 +- esphome/components/pulse_meter/sensor.py | 3 +- esphome/components/pulse_width/sensor.py | 3 +- esphome/components/qmc5883l/sensor.py | 4 +- esphome/components/rc522/__init__.py | 4 +- esphome/components/rotary_encoder/sensor.py | 10 +-- esphome/components/rp2040_pwm/output.py | 3 +- esphome/components/serial_proxy/__init__.py | 9 +-- esphome/components/sh1122_base/__init__.py | 4 +- esphome/components/shelly_dimmer/light.py | 6 +- .../components/sigma_delta_output/output.py | 4 +- esphome/components/slow_pwm/output.py | 4 +- esphome/components/sm10bit_base/__init__.py | 6 +- esphome/components/sm16716/__init__.py | 6 +- esphome/components/sm2135/__init__.py | 6 +- esphome/components/sn74hc165/__init__.py | 13 +--- esphome/components/sn74hc595/__init__.py | 13 +--- esphome/components/spd2010/touchscreen.py | 6 +- esphome/components/spi/__init__.py | 13 +--- esphome/components/ssd1306_base/__init__.py | 4 +- esphome/components/ssd1306_spi/display.py | 3 +- esphome/components/ssd1322_base/__init__.py | 4 +- esphome/components/ssd1322_spi/display.py | 3 +- esphome/components/ssd1325_base/__init__.py | 4 +- esphome/components/ssd1325_spi/display.py | 3 +- esphome/components/ssd1327_base/__init__.py | 4 +- esphome/components/ssd1327_spi/display.py | 3 +- esphome/components/ssd1331_base/__init__.py | 4 +- esphome/components/ssd1331_spi/display.py | 3 +- esphome/components/ssd1351_base/__init__.py | 4 +- esphome/components/ssd1351_spi/display.py | 3 +- .../components/st7123/touchscreen/__init__.py | 6 +- esphome/components/st7567_base/__init__.py | 4 +- esphome/components/st7567_spi/display.py | 3 +- esphome/components/st7701s/display.py | 21 ++---- .../components/status_led/light/__init__.py | 8 +- esphome/components/sx126x/__init__.py | 10 +-- esphome/components/sx127x/__init__.py | 7 +- esphome/components/tas58xx/audio_dac.py | 4 +- esphome/components/tca8418/__init__.py | 3 +- esphome/components/tlc5947/__init__.py | 13 +--- esphome/components/tlc5971/__init__.py | 6 +- esphome/components/tm1621/display.py | 12 +-- esphome/components/tm1637/display.py | 6 +- esphome/components/tm1638/display.py | 11 +-- esphome/components/tm1651/__init__.py | 6 +- esphome/components/tmc22xx/__init__.py | 3 +- .../tt21100/touchscreen/__init__.py | 9 +-- esphome/components/ttp229_bsf/__init__.py | 6 +- esphome/components/tuya/__init__.py | 4 +- esphome/components/tuya/climate/__init__.py | 8 +- esphome/components/tx20/sensor.py | 3 +- esphome/components/uln2003/stepper.py | 12 +-- esphome/components/ultrasonic/sensor.py | 6 +- esphome/components/vl53l0x/sensor.py | 4 +- esphome/components/wiegand/__init__.py | 6 +- esphome/components/x9c/output.py | 9 +-- .../xpt2046/touchscreen/__init__.py | 4 +- esphome/components/zyaura/sensor.py | 7 +- 117 files changed, 227 insertions(+), 564 deletions(-) diff --git a/esphome/components/a4988/stepper.py b/esphome/components/a4988/stepper.py index 7a19bd550da..f97f30903a4 100644 --- a/esphome/components/a4988/stepper.py +++ b/esphome/components/a4988/stepper.py @@ -23,11 +23,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await stepper.register_stepper(var, config) - step_pin = await cg.gpio_pin_expression(config[CONF_STEP_PIN]) - cg.add(var.set_step_pin(step_pin)) - dir_pin = await cg.gpio_pin_expression(config[CONF_DIR_PIN]) - cg.add(var.set_dir_pin(dir_pin)) - - if sleep_pin_config := config.get(CONF_SLEEP_PIN): - sleep_pin = await cg.gpio_pin_expression(sleep_pin_config) - cg.add(var.set_sleep_pin(sleep_pin)) + await cg.add_pin(var.set_step_pin, config[CONF_STEP_PIN]) + await cg.add_pin(var.set_dir_pin, config[CONF_DIR_PIN]) + await cg.add_pin(var.set_sleep_pin, config.get(CONF_SLEEP_PIN)) diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index 7d1b66a6905..90d88acc2a3 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -68,10 +68,8 @@ async def to_code(config: ConfigType) -> None: config[CONF_MIN_POWER] = 0.1 await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_GATE_PIN]) - cg.add(var.set_gate_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN]) - cg.add(var.set_zero_cross_pin(pin)) + await cg.add_pin(var.set_gate_pin, config[CONF_GATE_PIN]) + await cg.add_pin(var.set_zero_cross_pin, config[CONF_ZERO_CROSS_PIN]) cg.add(var.set_zero_cross_interrupt_type(config[CONF_ZERO_CROSS_INTERRUPT_TYPE])) cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE])) cg.add(var.set_method(config[CONF_METHOD])) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index b44d88d56e3..1c10aed4a15 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -164,8 +164,7 @@ async def to_code(config: ConfigType) -> None: # Remove before 2027.2.0 cg.add(var.set_is_temperature()) elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) # Skip the setters when the config matches the C++ initializers. if config[CONF_RAW]: diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index 541d37603e1..7e01b35b73b 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -284,16 +284,9 @@ async def power_channel(config: ConfigType) -> MockObj: async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if irq0_pin := config.get(CONF_IRQ0_PIN): - pin = await cg.gpio_pin_expression(irq0_pin) - cg.add(var.set_irq0_pin(pin)) - - pin = await cg.gpio_pin_expression(config[CONF_IRQ1_PIN]) - cg.add(var.set_irq1_pin(pin)) - - if reset_pin := config.get(CONF_RESET_PIN): - pin = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_irq0_pin, config.get(CONF_IRQ0_PIN)) + await cg.add_pin(var.set_irq1_pin, config[CONF_IRQ1_PIN]) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if frequency := config.get(CONF_FREQUENCY): cg.add(var.set_frequency(frequency)) diff --git a/esphome/components/ade7953_base/__init__.py b/esphome/components/ade7953_base/__init__.py index 808de99984c..bd86c4d6d9d 100644 --- a/esphome/components/ade7953_base/__init__.py +++ b/esphome/components/ade7953_base/__init__.py @@ -169,9 +169,7 @@ ADE7953_CONFIG_SCHEMA = cv.Schema( async def register_ade7953(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - if irq_pin_config := config.get(CONF_IRQ_PIN): - irq_pin = await cg.gpio_pin_expression(irq_pin_config) - cg.add(var.set_irq_pin(irq_pin)) + await cg.add_pin(var.set_irq_pin, config.get(CONF_IRQ_PIN)) cg.add(var.set_pga_v(PGA_GAINS[config.get(CONF_VOLTAGE_PGA_GAIN)])) cg.add(var.set_pga_ia(PGA_GAINS[config.get(CONF_CURRENT_PGA_GAIN_A)])) diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py index f3f7e0203f9..61cfdc591b0 100644 --- a/esphome/components/apc1/sensor.py +++ b/esphome/components/apc1/sensor.py @@ -191,13 +191,8 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_RS3, var.set_rs3_sensor) await sensors(CONF_ERROR_CODE, var.set_error_code_sensor) - if (set_pin_config := config.get(CONF_SET_PIN)) is not None: - set_pin = await cg.gpio_pin_expression(set_pin_config) - cg.add(var.set_set_pin(set_pin)) - - if (reset_pin_config := config.get(CONF_RESET_PIN)) is not None: - reset_pin = await cg.gpio_pin_expression(reset_pin_config) - cg.add(var.set_reset_pin(reset_pin)) + await cg.add_pin(var.set_set_pin, config.get(CONF_SET_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) APC1_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/as3935/__init__.py b/esphome/components/as3935/__init__.py index b7b5b09c626..fe36d73b590 100644 --- a/esphome/components/as3935/__init__.py +++ b/esphome/components/as3935/__init__.py @@ -48,8 +48,7 @@ AS3935_SCHEMA = cv.Schema( async def setup_as3935(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - irq_pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) - cg.add(var.set_irq_pin(irq_pin)) + await cg.add_pin(var.set_irq_pin, config[CONF_IRQ_PIN]) cg.add(var.set_indoor(config[CONF_INDOOR])) cg.add(var.set_noise_level(config[CONF_NOISE_LEVEL])) cg.add(var.set_watchdog_threshold(config[CONF_WATCHDOG_THRESHOLD])) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index d695e34235f..a118334f02c 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -218,9 +218,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_fast_filter(config[CONF_FAST_FILTER])) cg.add(var.set_start_position(config[CONF_START_POSITION])) - if dir_pin_config := config.get(CONF_DIR_PIN): - pin = await cg.gpio_pin_expression(dir_pin_config) - cg.add(var.set_dir_pin(pin)) + await cg.add_pin(var.set_dir_pin, config.get(CONF_DIR_PIN)) if (end_position_config := config.get(CONF_END_POSITION, None)) is not None: cg.add(var.set_end_position(end_position_config)) diff --git a/esphome/components/axs15231/touchscreen/__init__.py b/esphome/components/axs15231/touchscreen/__init__.py index 2616cb281bb..27f2348b6d8 100644 --- a/esphome/components/axs15231/touchscreen/__init__.py +++ b/esphome/components/axs15231/touchscreen/__init__.py @@ -31,7 +31,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/bp1658cj/__init__.py b/esphome/components/bp1658cj/__init__.py index 76b3d427a45..c2c560c7dbb 100644 --- a/esphome/components/bp1658cj/__init__.py +++ b/esphome/components/bp1658cj/__init__.py @@ -34,9 +34,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_max_power_color_channels(config[CONF_MAX_POWER_COLOR_CHANNELS])) cg.add(var.set_max_power_white_channels(config[CONF_MAX_POWER_WHITE_CHANNELS])) diff --git a/esphome/components/bp5758d/__init__.py b/esphome/components/bp5758d/__init__.py index bd6b6ca1106..b3a05268cb3 100644 --- a/esphome/components/bp5758d/__init__.py +++ b/esphome/components/bp5758d/__init__.py @@ -25,7 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) diff --git a/esphome/components/cap1188/__init__.py b/esphome/components/cap1188/__init__.py index 24f6e627d2c..ae17b0477c4 100644 --- a/esphome/components/cap1188/__init__.py +++ b/esphome/components/cap1188/__init__.py @@ -39,9 +39,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_touch_threshold(config[CONF_TOUCH_THRESHOLD])) cg.add(var.set_allow_multiple_touches(config[CONF_ALLOW_MULTIPLE_TOUCHES])) - if reset_pin_config := config.get(CONF_RESET_PIN): - pin = await cg.gpio_pin_expression(reset_pin_config) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 5375d6bf396..4f2ca2ad266 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -281,9 +281,7 @@ async def to_code(config): if key in config: cg.add(getattr(var, f"set_{key}")(config[key])) - if CONF_GDO0_PIN in config: - gdo0_pin = await cg.gpio_pin_expression(config[CONF_GDO0_PIN]) - cg.add(var.set_gdo0_pin(gdo0_pin)) + await cg.add_pin(var.set_gdo0_pin, config.get(CONF_GDO0_PIN)) if CONF_ON_PACKET in config: await automation.build_automation( var.get_packet_trigger(), diff --git a/esphome/components/cd74hc4067/__init__.py b/esphome/components/cd74hc4067/__init__.py index 655b8db819d..6eb4fe8248e 100644 --- a/esphome/components/cd74hc4067/__init__.py +++ b/esphome/components/cd74hc4067/__init__.py @@ -39,13 +39,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin_s0 = await cg.gpio_pin_expression(config[CONF_PIN_S0]) - cg.add(var.set_pin_s0(pin_s0)) - pin_s1 = await cg.gpio_pin_expression(config[CONF_PIN_S1]) - cg.add(var.set_pin_s1(pin_s1)) - pin_s2 = await cg.gpio_pin_expression(config[CONF_PIN_S2]) - cg.add(var.set_pin_s2(pin_s2)) - pin_s3 = await cg.gpio_pin_expression(config[CONF_PIN_S3]) - cg.add(var.set_pin_s3(pin_s3)) + await cg.add_pin(var.set_pin_s0, config[CONF_PIN_S0]) + await cg.add_pin(var.set_pin_s1, config[CONF_PIN_S1]) + await cg.add_pin(var.set_pin_s2, config[CONF_PIN_S2]) + await cg.add_pin(var.set_pin_s3, config[CONF_PIN_S3]) cg.add(var.set_switch_delay(config[CONF_DELAY])) diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py index 133ea92a483..40d44747430 100644 --- a/esphome/components/cdc_acm_uart/bridge/__init__.py +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -93,9 +93,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], uart_component, usb_cdc) await cg.register_component(var, config) - if dtr_pin_config := config.get(CONF_DTR_PIN): - dtr_pin = await cg.gpio_pin_expression(dtr_pin_config) - cg.add(var.set_dtr_pin(dtr_pin)) - if rts_pin_config := config.get(CONF_RTS_PIN): - rts_pin = await cg.gpio_pin_expression(rts_pin_config) - cg.add(var.set_rts_pin(rts_pin)) + await cg.add_pin(var.set_dtr_pin, config.get(CONF_DTR_PIN)) + await cg.add_pin(var.set_rts_pin, config.get(CONF_RTS_PIN)) diff --git a/esphome/components/chsc6x/touchscreen.py b/esphome/components/chsc6x/touchscreen.py index de974d2a79d..f97feba3c51 100644 --- a/esphome/components/chsc6x/touchscreen.py +++ b/esphome/components/chsc6x/touchscreen.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) diff --git a/esphome/components/cst226/touchscreen/__init__.py b/esphome/components/cst226/touchscreen/__init__.py index 459cba61cd6..ddffd8720c2 100644 --- a/esphome/components/cst226/touchscreen/__init__.py +++ b/esphome/components/cst226/touchscreen/__init__.py @@ -32,7 +32,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/cst328/touchscreen/__init__.py b/esphome/components/cst328/touchscreen/__init__.py index 9bc7744b7c3..9569a7875bd 100644 --- a/esphome/components/cst328/touchscreen/__init__.py +++ b/esphome/components/cst328/touchscreen/__init__.py @@ -33,7 +33,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/cst816/touchscreen/__init__.py b/esphome/components/cst816/touchscreen/__init__.py index 029a544a911..88d5de6ad1a 100644 --- a/esphome/components/cst816/touchscreen/__init__.py +++ b/esphome/components/cst816/touchscreen/__init__.py @@ -32,7 +32,5 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) cg.add(var.set_skip_probe(config[CONF_SKIP_PROBE])) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/cst9220/touchscreen/__init__.py b/esphome/components/cst9220/touchscreen/__init__.py index 393685e67b3..15bfa64c70f 100644 --- a/esphome/components/cst9220/touchscreen/__init__.py +++ b/esphome/components/cst9220/touchscreen/__init__.py @@ -31,7 +31,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index c9a0ec2196f..7230b1e8e9c 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -379,8 +379,7 @@ async def to_code(config: ConfigType) -> None: ) ) else: - pin = await cg.gpio_pin_expression(wakeup_pins[0][CONF_PIN]) - cg.add(var.set_wakeup_pin(pin)) + await cg.add_pin(var.set_wakeup_pin, wakeup_pins[0][CONF_PIN]) if CONF_WAKEUP_PIN_MODE in config: cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE])) if CONF_RUN_DURATION in config: diff --git a/esphome/components/dht/sensor.py b/esphome/components/dht/sensor.py index da8a5775b15..d000add3013 100644 --- a/esphome/components/dht/sensor.py +++ b/esphome/components/dht/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) -from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType dht_ns = cg.esphome_ns.namespace("dht") @@ -59,8 +58,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index 02faa806f7a..623a7547d40 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -107,6 +107,4 @@ async def to_code(config: ConfigType) -> None: if CONF_ACTIVE_PULLUP_RESISTANCE in config: cg.add(var.set_val_rwpu(config[CONF_ACTIVE_PULLUP_RESISTANCE])) - if CONF_SLEEP_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_SLEEP_PIN]) - cg.add(var.set_sleep_pin(pin)) + await cg.add_pin(var.set_sleep_pin, config.get(CONF_SLEEP_PIN)) diff --git a/esphome/components/duty_cycle/sensor.py b/esphome/components/duty_cycle/sensor.py index b7aa1777c83..deb3d02d7a7 100644 --- a/esphome/components/duty_cycle/sensor.py +++ b/esphome/components/duty_cycle/sensor.py @@ -27,5 +27,4 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/ektf2232/touchscreen/__init__.py b/esphome/components/ektf2232/touchscreen/__init__.py index 7636b6993a1..8707f3b4e36 100644 --- a/esphome/components/ektf2232/touchscreen/__init__.py +++ b/esphome/components/ektf2232/touchscreen/__init__.py @@ -35,7 +35,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(interrupt_pin)) - reset_pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset_pin)) + await cg.add_pin(var.set_interrupt_pin, config[CONF_INTERRUPT_PIN]) + await cg.add_pin(var.set_reset_pin, config[CONF_RESET_PIN]) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 6a391811112..75a3426f162 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -243,20 +243,15 @@ async def to_code(config): config = await model.to_code(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) if CONF_LAMBDA in config: lambda_ = await cg.process_lambda( config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void ) cg.add(var.set_writer(lambda_)) - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) - if busy_pin := config.get(CONF_BUSY_PIN): - busy = await cg.gpio_pin_expression(busy_pin) - cg.add(var.set_busy_pin(busy)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await cg.add_pin(var.set_busy_pin, config.get(CONF_BUSY_PIN)) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] cg.add(var.set_enable_pins(enable)) diff --git a/esphome/components/esp32_dac/output.py b/esphome/components/esp32_dac/output.py index c87a9e2a1d0..7a27121a96b 100644 --- a/esphome/components/esp32_dac/output.py +++ b/esphome/components/esp32_dac/output.py @@ -49,5 +49,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index 62ec1fe003b..00b4dc563e4 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -46,8 +46,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) # Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY). if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY: diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 2ef654afc55..2007ca60471 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -146,13 +146,8 @@ async def to_code(config): new_password = config[CONF_NEW_PASSWORD] cg.add(var.set_new_password(new_password)) - if CONF_SENSING_PIN in config: - sensing_pin = await cg.gpio_pin_expression(config[CONF_SENSING_PIN]) - cg.add(var.set_sensing_pin(sensing_pin)) - - if CONF_SENSOR_POWER_PIN in config: - sensor_power_pin = await cg.gpio_pin_expression(config[CONF_SENSOR_POWER_PIN]) - cg.add(var.set_sensor_power_pin(sensor_power_pin)) + await cg.add_pin(var.set_sensing_pin, config.get(CONF_SENSING_PIN)) + await cg.add_pin(var.set_sensor_power_pin, config.get(CONF_SENSOR_POWER_PIN)) if CONF_IDLE_PERIOD_TO_SLEEP in config: idle_period_to_sleep_ms = config[CONF_IDLE_PERIOD_TO_SLEEP] diff --git a/esphome/components/ft5x06/touchscreen/__init__.py b/esphome/components/ft5x06/touchscreen/__init__.py index 3ebff693c00..b56043ec4d7 100644 --- a/esphome/components/ft5x06/touchscreen/__init__.py +++ b/esphome/components/ft5x06/touchscreen/__init__.py @@ -28,6 +28,4 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) await touchscreen.register_touchscreen(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - pin = await cg.gpio_pin_expression(interrupt_pin) - cg.add(var.set_interrupt_pin(pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) diff --git a/esphome/components/ft63x6/touchscreen.py b/esphome/components/ft63x6/touchscreen.py index 0d8537bde90..7ac7a3f3ce4 100644 --- a/esphome/components/ft63x6/touchscreen.py +++ b/esphome/components/ft63x6/touchscreen.py @@ -37,9 +37,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): - interrupt_pin = await cg.gpio_pin_expression(interrupt_pin_config) - cg.add(var.set_interrupt_pin(interrupt_pin)) - if reset_pin_config := config.get(CONF_RESET_PIN): - reset_pin = await cg.gpio_pin_expression(reset_pin_config) - cg.add(var.set_reset_pin(reset_pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 8a40e4e7325..22b7cdb520b 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -129,8 +129,7 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) if config[CONF_USE_INTERRUPT]: cg.add_define("USE_GPIO_BINARY_SENSOR_INTERRUPT") diff --git a/esphome/components/gpio/one_wire/__init__.py b/esphome/components/gpio/one_wire/__init__.py index feb8b53dff6..f2b0ca0be68 100644 --- a/esphome/components/gpio/one_wire/__init__.py +++ b/esphome/components/gpio/one_wire/__init__.py @@ -23,5 +23,4 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/gpio/output/__init__.py b/esphome/components/gpio/output/__init__.py index 645dd61f802..fc4041fdc6d 100644 --- a/esphome/components/gpio/output/__init__.py +++ b/esphome/components/gpio/output/__init__.py @@ -45,5 +45,4 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 481b8d01dad..ec826697eb1 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -33,8 +33,7 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) if (interlock := config.get(CONF_INTERLOCK)) is not None: cg.add_define("USE_GPIO_SWITCH_INTERLOCK") diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py index 703887864bd..5f0edd3d41b 100644 --- a/esphome/components/gsl3670/touchscreen.py +++ b/esphome/components/gsl3670/touchscreen.py @@ -226,13 +226,8 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if CONF_INTERRUPT_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(pin)) - - if CONF_RESET_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) # Firmware table data = _read_firmware(config) diff --git a/esphome/components/gt911/touchscreen/__init__.py b/esphome/components/gt911/touchscreen/__init__.py index fa929d4ba0d..c2fb09fb59e 100644 --- a/esphome/components/gt911/touchscreen/__init__.py +++ b/esphome/components/gt911/touchscreen/__init__.py @@ -28,7 +28,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/hbridge/switch/__init__.py b/esphome/components/hbridge/switch/__init__.py index 294be6ed5fe..5dcd8dd3d24 100644 --- a/esphome/components/hbridge/switch/__init__.py +++ b/esphome/components/hbridge/switch/__init__.py @@ -35,10 +35,8 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - on_pin = await cg.gpio_pin_expression(config[CONF_ON_PIN]) - cg.add(var.set_on_pin(on_pin)) - off_pin = await cg.gpio_pin_expression(config[CONF_OFF_PIN]) - cg.add(var.set_off_pin(off_pin)) + await cg.add_pin(var.set_on_pin, config[CONF_ON_PIN]) + await cg.add_pin(var.set_off_pin, config[CONF_OFF_PIN]) cg.add(var.set_pulse_length(config[CONF_PULSE_LENGTH])) cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) if wait_time := config.get(CONF_WAIT_TIME): diff --git a/esphome/components/hlw8012/sensor.py b/esphome/components/hlw8012/sensor.py index 007f7c53030..04357d564da 100644 --- a/esphome/components/hlw8012/sensor.py +++ b/esphome/components/hlw8012/sensor.py @@ -98,12 +98,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - sel = await cg.gpio_pin_expression(config[CONF_SEL_PIN]) - cg.add(var.set_sel_pin(sel)) - cf = await cg.gpio_pin_expression(config[CONF_CF_PIN]) - cg.add(var.set_cf_pin(cf)) - cf1 = await cg.gpio_pin_expression(config[CONF_CF1_PIN]) - cg.add(var.set_cf1_pin(cf1)) + await cg.add_pin(var.set_sel_pin, config[CONF_SEL_PIN]) + await cg.add_pin(var.set_cf_pin, config[CONF_CF_PIN]) + await cg.add_pin(var.set_cf1_pin, config[CONF_CF1_PIN]) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/hx711/sensor.py b/esphome/components/hx711/sensor.py index 2739589c663..4f4c106f4ef 100644 --- a/esphome/components/hx711/sensor.py +++ b/esphome/components/hx711/sensor.py @@ -39,8 +39,6 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - dout_pin = await cg.gpio_pin_expression(config[CONF_DOUT_PIN]) - cg.add(var.set_dout_pin(dout_pin)) - sck_pin = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_sck_pin(sck_pin)) + await cg.add_pin(var.set_dout_pin, config[CONF_DOUT_PIN]) + await cg.add_pin(var.set_sck_pin, config[CONF_CLK_PIN]) cg.add(var.set_gain(config[CONF_GAIN])) diff --git a/esphome/components/icnt86/touchscreen.py b/esphome/components/icnt86/touchscreen.py index 5d7a7386120..9a45ac4ac32 100644 --- a/esphome/components/icnt86/touchscreen.py +++ b/esphome/components/icnt86/touchscreen.py @@ -31,10 +31,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): - cg.add( - var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin_config)) - ) - - if reset_pin_config := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin_config))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index c7600d39bbc..5e90b1ff490 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -184,59 +184,22 @@ async def to_code(config: ConfigType) -> None: waveform = [element for tupl in waveform for element in tupl] cg.add(var.set_waveform(waveform, False)) - ckv = await cg.gpio_pin_expression(config[CONF_CKV_PIN]) - cg.add(var.set_ckv_pin(ckv)) - - gmod = await cg.gpio_pin_expression(config[CONF_GMOD_PIN]) - cg.add(var.set_gmod_pin(gmod)) - - gpio0_enable = await cg.gpio_pin_expression(config[CONF_GPIO0_ENABLE_PIN]) - cg.add(var.set_gpio0_enable_pin(gpio0_enable)) - - oe = await cg.gpio_pin_expression(config[CONF_OE_PIN]) - cg.add(var.set_oe_pin(oe)) - - powerup = await cg.gpio_pin_expression(config[CONF_POWERUP_PIN]) - cg.add(var.set_powerup_pin(powerup)) - - sph = await cg.gpio_pin_expression(config[CONF_SPH_PIN]) - cg.add(var.set_sph_pin(sph)) - - spv = await cg.gpio_pin_expression(config[CONF_SPV_PIN]) - cg.add(var.set_spv_pin(spv)) - - vcom = await cg.gpio_pin_expression(config[CONF_VCOM_PIN]) - cg.add(var.set_vcom_pin(vcom)) - - wakeup = await cg.gpio_pin_expression(config[CONF_WAKEUP_PIN]) - cg.add(var.set_wakeup_pin(wakeup)) - - cl = await cg.gpio_pin_expression(config[CONF_CL_PIN]) - cg.add(var.set_cl_pin(cl)) - - le = await cg.gpio_pin_expression(config[CONF_LE_PIN]) - cg.add(var.set_le_pin(le)) - - display_data_0 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_0_PIN]) - cg.add(var.set_display_data_0_pin(display_data_0)) - - display_data_1 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_1_PIN]) - cg.add(var.set_display_data_1_pin(display_data_1)) - - display_data_2 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_2_PIN]) - cg.add(var.set_display_data_2_pin(display_data_2)) - - display_data_3 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_3_PIN]) - cg.add(var.set_display_data_3_pin(display_data_3)) - - display_data_4 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_4_PIN]) - cg.add(var.set_display_data_4_pin(display_data_4)) - - display_data_5 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_5_PIN]) - cg.add(var.set_display_data_5_pin(display_data_5)) - - display_data_6 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_6_PIN]) - cg.add(var.set_display_data_6_pin(display_data_6)) - - display_data_7 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_7_PIN]) - cg.add(var.set_display_data_7_pin(display_data_7)) + await cg.add_pin(var.set_ckv_pin, config[CONF_CKV_PIN]) + await cg.add_pin(var.set_gmod_pin, config[CONF_GMOD_PIN]) + await cg.add_pin(var.set_gpio0_enable_pin, config[CONF_GPIO0_ENABLE_PIN]) + await cg.add_pin(var.set_oe_pin, config[CONF_OE_PIN]) + await cg.add_pin(var.set_powerup_pin, config[CONF_POWERUP_PIN]) + await cg.add_pin(var.set_sph_pin, config[CONF_SPH_PIN]) + await cg.add_pin(var.set_spv_pin, config[CONF_SPV_PIN]) + await cg.add_pin(var.set_vcom_pin, config[CONF_VCOM_PIN]) + await cg.add_pin(var.set_wakeup_pin, config[CONF_WAKEUP_PIN]) + await cg.add_pin(var.set_cl_pin, config[CONF_CL_PIN]) + await cg.add_pin(var.set_le_pin, config[CONF_LE_PIN]) + await cg.add_pin(var.set_display_data_0_pin, config[CONF_DISPLAY_DATA_0_PIN]) + await cg.add_pin(var.set_display_data_1_pin, config[CONF_DISPLAY_DATA_1_PIN]) + await cg.add_pin(var.set_display_data_2_pin, config[CONF_DISPLAY_DATA_2_PIN]) + await cg.add_pin(var.set_display_data_3_pin, config[CONF_DISPLAY_DATA_3_PIN]) + await cg.add_pin(var.set_display_data_4_pin, config[CONF_DISPLAY_DATA_4_PIN]) + await cg.add_pin(var.set_display_data_5_pin, config[CONF_DISPLAY_DATA_5_PIN]) + await cg.add_pin(var.set_display_data_6_pin, config[CONF_DISPLAY_DATA_6_PIN]) + await cg.add_pin(var.set_display_data_7_pin, config[CONF_DISPLAY_DATA_7_PIN]) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 28636e5e26e..bb0c1f10fbb 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -367,10 +367,8 @@ async def to_code(config: ConfigType) -> None: await spi.register_spi_device(var, config, write_only=False) await display.add_writer(var, config) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) - if busy_pin := config.get(CONF_BUSY_PIN): - cg.add(var.set_busy_pin(await cg.gpio_pin_expression(busy_pin))) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await cg.add_pin(var.set_busy_pin, config.get(CONF_BUSY_PIN)) if enable_pins := config.get(CONF_ENABLE_PIN): cg.add( var.set_enable_pins( diff --git a/esphome/components/lcd_gpio/display.py b/esphome/components/lcd_gpio/display.py index 0e99b471b3c..a312187c7f6 100644 --- a/esphome/components/lcd_gpio/display.py +++ b/esphome/components/lcd_gpio/display.py @@ -43,14 +43,8 @@ async def to_code(config: ConfigType) -> None: await lcd_base.setup_lcd_display(var, config) pins_ = [await cg.gpio_pin_expression(conf) for conf in config[CONF_DATA_PINS]] cg.add(var.set_data_pins(*pins_)) - enable = await cg.gpio_pin_expression(config[CONF_ENABLE_PIN]) - cg.add(var.set_enable_pin(enable)) - - rs = await cg.gpio_pin_expression(config[CONF_RS_PIN]) - cg.add(var.set_rs_pin(rs)) - - if CONF_RW_PIN in config: - rw = await cg.gpio_pin_expression(config[CONF_RW_PIN]) - cg.add(var.set_rw_pin(rw)) + await cg.add_pin(var.set_enable_pin, config[CONF_ENABLE_PIN]) + await cg.add_pin(var.set_rs_pin, config[CONF_RS_PIN]) + await cg.add_pin(var.set_rw_pin, config.get(CONF_RW_PIN)) await display.add_writer(var, config, GPIOLCDDisplay.operator("ref")) diff --git a/esphome/components/lilygo_t5_47/touchscreen/__init__.py b/esphome/components/lilygo_t5_47/touchscreen/__init__.py index 1e70f379a1a..5c68ddf6eed 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/__init__.py +++ b/esphome/components/lilygo_t5_47/touchscreen/__init__.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(interrupt_pin)) + await cg.add_pin(var.set_interrupt_pin, config[CONF_INTERRUPT_PIN]) diff --git a/esphome/components/mics_5524/sensor.py b/esphome/components/mics_5524/sensor.py index 7b80572a115..0440a4829d9 100644 --- a/esphome/components/mics_5524/sensor.py +++ b/esphome/components/mics_5524/sensor.py @@ -434,8 +434,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_warmup_time(config[CONF_WARMUP_TIME])) cg.add(var.set_preference_key(_preference_key(config))) - if (enable := config.get(CONF_ENABLE_PIN)) is not None: - cg.add(var.set_enable_pin(await cg.gpio_pin_expression(enable))) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) if (reference := config.get(CONF_AIR_REFERENCE, config.get(CONF_R0))) is not None: cg.add(var.set_reference(reference)) # A request on a hub without `calibration:` uses the schema defaults, never persisted. diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 6dd96d21bcb..67aad41c00c 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -213,9 +213,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pclk_frequency(config[CONF_PCLK_FREQUENCY] / 1.0e6)) cg.add(var.set_lanes(int(config[CONF_LANES]))) cg.add(var.set_lane_bit_rate(config[CONF_LANE_BIT_RATE] / 1.0e6)) - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] cg.add(var.set_enable_pins(enable)) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 4c87bf19987..a7cca81d8d5 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -315,28 +315,16 @@ async def to_code(config: ConfigType) -> None: data_pin = await cg.gpio_pin_expression(pin) cg.add(var.add_data_pin(data_pin, index)) - if dc_pin := config.get(CONF_DC_PIN): - dc = await cg.gpio_pin_expression(dc_pin) - cg.add(var.set_dc_pin(dc)) - - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_dc_pin, config.get(CONF_DC_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if model.rotation_as_transform(config): config[CONF_ROTATION] = 0 - if de_pin := config.get(CONF_DE_PIN): - pin = await cg.gpio_pin_expression(de_pin) - cg.add(var.set_de_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_PCLK_PIN]) - cg.add(var.set_pclk_pin(pin)) - if hsync_pin := config.get(CONF_HSYNC_PIN): - pin = await cg.gpio_pin_expression(hsync_pin) - cg.add(var.set_hsync_pin(pin)) - if vsync_pin := config.get(CONF_VSYNC_PIN): - pin = await cg.gpio_pin_expression(vsync_pin) - cg.add(var.set_vsync_pin(pin)) + await cg.add_pin(var.set_de_pin, config.get(CONF_DE_PIN)) + await cg.add_pin(var.set_pclk_pin, config[CONF_PCLK_PIN]) + await cg.add_pin(var.set_hsync_pin, config.get(CONF_HSYNC_PIN)) + await cg.add_pin(var.set_vsync_pin, config.get(CONF_VSYNC_PIN)) await display.register_display(var, config) await display.add_writer(var, config) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index ff49979a244..380c3a4dfd6 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -408,13 +408,8 @@ async def to_code(config: ConfigType) -> None: enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] cg.add(var.set_enable_pins(enable)) - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) - - if dc_pin := config.get(CONF_DC_PIN): - dc_pin = await cg.gpio_pin_expression(dc_pin) - cg.add(var.set_dc_pin(dc_pin)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await cg.add_pin(var.set_dc_pin, config.get(CONF_DC_PIN)) if config.get(CONF_INVERT_COLORS): cg.add(var.set_invert_colors(True)) diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index d6a777e7d88..5f793efd918 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -145,8 +145,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_resolution(2, RESOLUTION[config[CONF_Z_AXIS][CONF_RESOLUTION]])) if await sensors(CONF_TEMPERATURE, var.set_t_sensor): cg.add(var.set_t_oversampling(config[CONF_TEMPERATURE][CONF_OVERSAMPLING])) - if CONF_DRDY_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_DRDY_PIN]) - cg.add(var.set_drdy_gpio(pin)) + await cg.add_pin(var.set_drdy_gpio, config.get(CONF_DRDY_PIN)) cg.add_library("functionpointer/arduino-MLX90393", "1.0.2") diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 2c3aba950fe..8c079afbac3 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -11,7 +11,6 @@ from esphome.components.const import CONF_ROLE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj -from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv from esphome.types import ConfigType, TemplateArgsType @@ -307,9 +306,7 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) - if CONF_FLOW_CONTROL_PIN in config: - pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) - cg.add(var.set_flow_control_pin(pin)) + await cg.add_pin(var.set_flow_control_pin, config.get(CONF_FLOW_CONTROL_PIN)) if config[CONF_ROLE] == "client": cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) diff --git a/esphome/components/my9231/__init__.py b/esphome/components/my9231/__init__.py index 6fcb09a3478..5c4fe6af458 100644 --- a/esphome/components/my9231/__init__.py +++ b/esphome/components/my9231/__init__.py @@ -33,10 +33,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - di = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_pin_di(di)) - dcki = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_pin_dcki(dcki)) + await cg.add_pin(var.set_pin_di, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_pin_dcki, config[CONF_CLOCK_PIN]) cg.add(var.set_num_channels(config[CONF_NUM_CHANNELS])) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index b0c375eb53d..0c6ae8eb470 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -529,9 +529,7 @@ async def to_code(config: ConfigType) -> None: async def _dfu_to_code(dfu_config): cg.add_define("USE_NRF52_DFU") var = cg.new_Pvariable(dfu_config[CONF_ID]) - if CONF_RESET_PIN in dfu_config: - pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_reset_pin, dfu_config.get(CONF_RESET_PIN)) # DFU uses cdc rate callback to enter bootloader which was disabled explicitly to save power. zephyr_add_prj_conf("USB_DEVICE_STACK", True) diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 026951be93d..3772034ab63 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -87,11 +87,8 @@ async def to_code(config: dict[str, Any]) -> None: await cg.register_component(var, config) # Set pins - in_pin = await cg.gpio_pin_expression(config[CONF_IN_PIN]) - cg.add(var.set_in_pin(in_pin)) - - out_pin = await cg.gpio_pin_expression(config[CONF_OUT_PIN]) - cg.add(var.set_out_pin(out_pin)) + await cg.add_pin(var.set_in_pin, config[CONF_IN_PIN]) + await cg.add_pin(var.set_out_pin, config[CONF_OUT_PIN]) non_sensors = { CONF_ID, diff --git a/esphome/components/pcd8544/display.py b/esphome/components/pcd8544/display.py index c63d18b3cc4..1143def11c3 100644 --- a/esphome/components/pcd8544/display.py +++ b/esphome/components/pcd8544/display.py @@ -47,10 +47,8 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) + await cg.add_pin(var.set_reset_pin, config[CONF_RESET_PIN]) cg.add(var.set_contrast(config[CONF_CONTRAST])) diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index f1bd6122ff1..058b70ffb65 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -29,10 +29,5 @@ pn71xx.register_is_writing_condition("pn7160.is_writing", PN7160) async def setup_pn7160(var: MockObj, config: ConfigType) -> None: await pn71xx.setup_pn71xx(var, config) - if dwl_req_pin_config := config.get(CONF_DWL_REQ_PIN): - pin = await cg.gpio_pin_expression(dwl_req_pin_config) - cg.add(var.set_dwl_req_pin(pin)) - - if wkup_req_pin_config := config.get(CONF_WKUP_REQ_PIN): - pin = await cg.gpio_pin_expression(wkup_req_pin_config) - cg.add(var.set_wkup_req_pin(pin)) + await cg.add_pin(var.set_dwl_req_pin, config.get(CONF_DWL_REQ_PIN)) + await cg.add_pin(var.set_wkup_req_pin, config.get(CONF_WKUP_REQ_PIN)) diff --git a/esphome/components/pn71xx/__init__.py b/esphome/components/pn71xx/__init__.py index 3b40eb11419..10d806ca0c6 100644 --- a/esphome/components/pn71xx/__init__.py +++ b/esphome/components/pn71xx/__init__.py @@ -127,11 +127,8 @@ _request_ontagremoved_trigger_slot = cg.slot_counter( async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) - cg.add(var.set_irq_pin(pin)) - - pin = await cg.gpio_pin_expression(config[CONF_VEN_PIN]) - cg.add(var.set_ven_pin(pin)) + await cg.add_pin(var.set_irq_pin, config[CONF_IRQ_PIN]) + await cg.add_pin(var.set_ven_pin, config[CONF_VEN_PIN]) if emulation_message_config := config.get(CONF_EMULATION_MESSAGE): cg.add(var.set_tag_emulation_message(emulation_message_config)) diff --git a/esphome/components/power_supply/__init__.py b/esphome/components/power_supply/__init__.py index 4d90642b5f5..5862a945a66 100644 --- a/esphome/components/power_supply/__init__.py +++ b/esphome/components/power_supply/__init__.py @@ -35,8 +35,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) cg.add(var.set_enable_time(config[CONF_ENABLE_TIME])) cg.add(var.set_keep_on_time(config[CONF_KEEP_ON_TIME])) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 46aa87a8e11..33beb96dcf6 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -131,8 +131,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config, use_pcnt) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) count = config[CONF_COUNT_MODE] cg.add(var.set_rising_edge_mode(count[CONF_RISING_EDGE])) cg.add(var.set_falling_edge_mode(count[CONF_FALLING_EDGE])) diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index 05708c65c13..1239d011a2b 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -83,8 +83,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) cg.add(var.set_filter_us(config[CONF_INTERNAL_FILTER])) cg.add(var.set_timeout_us(config[CONF_TIMEOUT])) cg.add(var.set_filter_mode(config[CONF_INTERNAL_FILTER_MODE])) diff --git a/esphome/components/pulse_width/sensor.py b/esphome/components/pulse_width/sensor.py index dd7554c4ebd..01f7988f584 100644 --- a/esphome/components/pulse_width/sensor.py +++ b/esphome/components/pulse_width/sensor.py @@ -32,5 +32,4 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index deba7eab7e0..c1b8d3c7af8 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -153,6 +153,4 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor) await sensors(CONF_HEADING, var.set_heading_sensor) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) - if CONF_DRDY_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_DRDY_PIN]) - cg.add(var.set_drdy_pin(pin)) + await cg.add_pin(var.set_drdy_pin, config.get(CONF_DRDY_PIN)) diff --git a/esphome/components/rc522/__init__.py b/esphome/components/rc522/__init__.py index 69cc713813b..8b2aabfa237 100644 --- a/esphome/components/rc522/__init__.py +++ b/esphome/components/rc522/__init__.py @@ -44,9 +44,7 @@ RC522_SCHEMA = cv.Schema( async def setup_rc522(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 31391d9c6ac..15629c446ab 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -93,16 +93,12 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin_a = await cg.gpio_pin_expression(config[CONF_PIN_A]) - cg.add(var.set_pin_a(pin_a)) - pin_b = await cg.gpio_pin_expression(config[CONF_PIN_B]) - cg.add(var.set_pin_b(pin_b)) + await cg.add_pin(var.set_pin_a, config[CONF_PIN_A]) + await cg.add_pin(var.set_pin_b, config[CONF_PIN_B]) cg.add(var.set_publish_initial_value(config[CONF_PUBLISH_INITIAL_VALUE])) cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) - if CONF_PIN_RESET in config: - pin_i = await cg.gpio_pin_expression(config[CONF_PIN_RESET]) - cg.add(var.set_reset_pin(pin_i)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_PIN_RESET)) cg.add(var.set_resolution(config[CONF_RESOLUTION])) if CONF_MIN_VALUE in config: cg.add(var.set_min_value(config[CONF_MIN_VALUE])) diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index 49b10f7d201..2f1394d121c 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -27,8 +27,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) cg.add(var.set_frequency(config[CONF_FREQUENCY])) diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 35b65f6bae3..27d69c97346 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -117,10 +117,5 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(_add_serial_proxy_count_define) data.count += 1 - if CONF_RTS_PIN in config: - rts_pin = await cg.gpio_pin_expression(config[CONF_RTS_PIN]) - cg.add(var.set_rts_pin(rts_pin)) - - if CONF_DTR_PIN in config: - dtr_pin = await cg.gpio_pin_expression(config[CONF_DTR_PIN]) - cg.add(var.set_dtr_pin(dtr_pin)) + await cg.add_pin(var.set_rts_pin, config.get(CONF_RTS_PIN)) + await cg.add_pin(var.set_dtr_pin, config.get(CONF_DTR_PIN)) diff --git a/esphome/components/sh1122_base/__init__.py b/esphome/components/sh1122_base/__init__.py index e8828685368..4e412f5ebf7 100644 --- a/esphome/components/sh1122_base/__init__.py +++ b/esphome/components/sh1122_base/__init__.py @@ -32,8 +32,6 @@ async def setup_sh1122(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if (reset_pin := config.get(CONF_RESET_PIN)) is not None: - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index c166076e0f8..701d29ffa22 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -250,10 +250,8 @@ async def to_code(config: ConfigType) -> None: await light.register_light(var, config) await uart.register_uart_device(var, config) - nrst_pin = await cg.gpio_pin_expression(config[CONF_NRST_PIN]) - cg.add(var.set_nrst_pin(nrst_pin)) - boot0_pin = await cg.gpio_pin_expression(config[CONF_BOOT0_PIN]) - cg.add(var.set_boot0_pin(boot0_pin)) + await cg.add_pin(var.set_nrst_pin, config[CONF_NRST_PIN]) + await cg.add_pin(var.set_boot0_pin, config[CONF_BOOT0_PIN]) cg.add(var.set_leading_edge(config[CONF_LEADING_EDGE])) cg.add(var.set_warmup_brightness(config[CONF_WARMUP_BRIGHTNESS])) diff --git a/esphome/components/sigma_delta_output/output.py b/esphome/components/sigma_delta_output/output.py index aca5f264572..af6f5948eaf 100644 --- a/esphome/components/sigma_delta_output/output.py +++ b/esphome/components/sigma_delta_output/output.py @@ -43,9 +43,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - if CONF_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config.get(CONF_PIN)) if CONF_STATE_CHANGE_ACTION in config: await automation.build_automation( diff --git a/esphome/components/slow_pwm/output.py b/esphome/components/slow_pwm/output.py index f3f82d7ce5a..488932f1b42 100644 --- a/esphome/components/slow_pwm/output.py +++ b/esphome/components/slow_pwm/output.py @@ -47,9 +47,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) - if CONF_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config.get(CONF_PIN)) if CONF_STATE_CHANGE_ACTION in config: await automation.build_automation( var.get_state_change_trigger(), diff --git a/esphome/components/sm10bit_base/__init__.py b/esphome/components/sm10bit_base/__init__.py index 05ba98562d7..5417f9ea6ce 100644 --- a/esphome/components/sm10bit_base/__init__.py +++ b/esphome/components/sm10bit_base/__init__.py @@ -33,10 +33,8 @@ async def register_sm10bit_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_max_power_color_channels(config[CONF_MAX_POWER_COLOR_CHANNELS])) cg.add(var.set_max_power_white_channels(config[CONF_MAX_POWER_WHITE_CHANNELS])) diff --git a/esphome/components/sm16716/__init__.py b/esphome/components/sm16716/__init__.py index ae3c54ab7c7..8359b53671b 100644 --- a/esphome/components/sm16716/__init__.py +++ b/esphome/components/sm16716/__init__.py @@ -31,10 +31,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_num_channels(config[CONF_NUM_CHANNELS])) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/sm2135/__init__.py b/esphome/components/sm2135/__init__.py index 7c5622c041b..934cf68186f 100644 --- a/esphome/components/sm2135/__init__.py +++ b/esphome/components/sm2135/__init__.py @@ -61,10 +61,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_rgb_current(config[CONF_RGB_CURRENT])) cg.add(var.set_cw_current(config[CONF_CW_CURRENT])) diff --git a/esphome/components/sn74hc165/__init__.py b/esphome/components/sn74hc165/__init__.py index af566cebda9..f7d03a897d9 100644 --- a/esphome/components/sn74hc165/__init__.py +++ b/esphome/components/sn74hc165/__init__.py @@ -44,15 +44,10 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data_pin = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data_pin)) - clock_pin = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock_pin)) - load_pin = await cg.gpio_pin_expression(config[CONF_LOAD_PIN]) - cg.add(var.set_load_pin(load_pin)) - if CONF_CLOCK_INHIBIT_PIN in config: - clock_inhibit_pin = await cg.gpio_pin_expression(config[CONF_CLOCK_INHIBIT_PIN]) - cg.add(var.set_clock_inhibit_pin(clock_inhibit_pin)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) + await cg.add_pin(var.set_load_pin, config[CONF_LOAD_PIN]) + await cg.add_pin(var.set_clock_inhibit_pin, config.get(CONF_CLOCK_INHIBIT_PIN)) cg.add(var.set_sr_count(config[CONF_SR_COUNT])) diff --git a/esphome/components/sn74hc595/__init__.py b/esphome/components/sn74hc595/__init__.py index 78ed703ed9e..8d540011798 100644 --- a/esphome/components/sn74hc595/__init__.py +++ b/esphome/components/sn74hc595/__init__.py @@ -72,18 +72,13 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if config[CONF_TYPE] == TYPE_GPIO: - data_pin = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data_pin)) - clock_pin = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock_pin)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) else: await spi.register_spi_device(var, config) - latch_pin = await cg.gpio_pin_expression(config[CONF_LATCH_PIN]) - cg.add(var.set_latch_pin(latch_pin)) - if oe_pin := config.get(CONF_OE_PIN): - oe_pin = await cg.gpio_pin_expression(oe_pin) - cg.add(var.set_oe_pin(oe_pin)) + await cg.add_pin(var.set_latch_pin, config[CONF_LATCH_PIN]) + await cg.add_pin(var.set_oe_pin, config.get(CONF_OE_PIN)) cg.add(var.set_sr_count(config[CONF_SR_COUNT])) diff --git a/esphome/components/spd2010/touchscreen.py b/esphome/components/spd2010/touchscreen.py index 7ead6a34184..0f3a2e24bf6 100644 --- a/esphome/components/spd2010/touchscreen.py +++ b/esphome/components/spd2010/touchscreen.py @@ -27,7 +27,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 92d5baccf76..151f228651e 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -436,12 +436,9 @@ async def to_code(configs: list[ConfigType]) -> None: for spi in configs: var = cg.new_Pvariable(spi[CONF_ID]) await cg.register_component(var, spi) - clk = await cg.gpio_pin_expression(spi[CONF_CLK_PIN]) - cg.add(var.set_clk(clk)) - if miso := spi.get(CONF_MISO_PIN): - cg.add(var.set_miso(await cg.gpio_pin_expression(miso))) - if mosi := spi.get(CONF_MOSI_PIN): - cg.add(var.set_mosi(await cg.gpio_pin_expression(mosi))) + await cg.add_pin(var.set_clk, spi[CONF_CLK_PIN]) + await cg.add_pin(var.set_miso, spi.get(CONF_MISO_PIN)) + await cg.add_pin(var.set_mosi, spi.get(CONF_MOSI_PIN)) if data_pins := spi.get(CONF_DATA_PINS): cg.add(var.set_data_pins(data_pins)) if (index := spi.get(CONF_INTERFACE_INDEX)) is not None: @@ -491,9 +488,7 @@ async def register_spi_device( cg.add(var.set_spi_parent(parent)) if write_only: cg.add(var.set_write_only(True)) - if cs_pin := config.get(CONF_CS_PIN): - pin = await cg.gpio_pin_expression(cs_pin) - cg.add(var.set_cs_pin(pin)) + await cg.add_pin(var.set_cs_pin, config.get(CONF_CS_PIN)) if data_rate := config.get(CONF_DATA_RATE): cg.add(var.set_data_rate(data_rate)) if spi_mode := config.get(CONF_SPI_MODE): diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index b51fb6f8f22..4f9dda09dd7 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -80,9 +80,7 @@ async def setup_ssd1306(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_CONTRAST in config: diff --git a/esphome/components/ssd1306_spi/display.py b/esphome/components/ssd1306_spi/display.py index 9a415ccfdbd..e7d067eca46 100644 --- a/esphome/components/ssd1306_spi/display.py +++ b/esphome/components/ssd1306_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1306_base.setup_ssd1306(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1322_base/__init__.py b/esphome/components/ssd1322_base/__init__.py index 72f8bd27235..e3a6ac5656e 100644 --- a/esphome/components/ssd1322_base/__init__.py +++ b/esphome/components/ssd1322_base/__init__.py @@ -33,9 +33,7 @@ async def setup_ssd1322(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: diff --git a/esphome/components/ssd1322_spi/display.py b/esphome/components/ssd1322_spi/display.py index 63e0816ae72..e9b036eb7f8 100644 --- a/esphome/components/ssd1322_spi/display.py +++ b/esphome/components/ssd1322_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1322_base.setup_ssd1322(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1325_base/__init__.py b/esphome/components/ssd1325_base/__init__.py index e148808d15f..ee75f2b0b0b 100644 --- a/esphome/components/ssd1325_base/__init__.py +++ b/esphome/components/ssd1325_base/__init__.py @@ -37,9 +37,7 @@ async def setup_ssd1325(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: diff --git a/esphome/components/ssd1325_spi/display.py b/esphome/components/ssd1325_spi/display.py index cca9a3cf8e8..601d874ed3d 100644 --- a/esphome/components/ssd1325_spi/display.py +++ b/esphome/components/ssd1325_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1325_base.setup_ssd1325(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1327_base/__init__.py b/esphome/components/ssd1327_base/__init__.py index 93e4c75d3b1..bd317cf17f4 100644 --- a/esphome/components/ssd1327_base/__init__.py +++ b/esphome/components/ssd1327_base/__init__.py @@ -32,9 +32,7 @@ async def setup_ssd1327(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/ssd1327_spi/display.py b/esphome/components/ssd1327_spi/display.py index 8a5ea40efa2..60a96c5764c 100644 --- a/esphome/components/ssd1327_spi/display.py +++ b/esphome/components/ssd1327_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1327_base.setup_ssd1327(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1331_base/__init__.py b/esphome/components/ssd1331_base/__init__.py index 5f71570b6ae..acde9a1ea46 100644 --- a/esphome/components/ssd1331_base/__init__.py +++ b/esphome/components/ssd1331_base/__init__.py @@ -23,9 +23,7 @@ SSD1331_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( async def setup_ssd1331(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/ssd1331_spi/display.py b/esphome/components/ssd1331_spi/display.py index eeebb48ac6b..f52f14a2273 100644 --- a/esphome/components/ssd1331_spi/display.py +++ b/esphome/components/ssd1331_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1331_base.setup_ssd1331(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1351_base/__init__.py b/esphome/components/ssd1351_base/__init__.py index 2c44cd18403..7078f7d2421 100644 --- a/esphome/components/ssd1351_base/__init__.py +++ b/esphome/components/ssd1351_base/__init__.py @@ -33,9 +33,7 @@ async def setup_ssd1351(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/ssd1351_spi/display.py b/esphome/components/ssd1351_spi/display.py index 10991b8338f..1f70dd14b95 100644 --- a/esphome/components/ssd1351_spi/display.py +++ b/esphome/components/ssd1351_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1351_base.setup_ssd1351(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/st7123/touchscreen/__init__.py b/esphome/components/st7123/touchscreen/__init__.py index 55ea43ca6ab..a1b19c49247 100644 --- a/esphome/components/st7123/touchscreen/__init__.py +++ b/esphome/components/st7123/touchscreen/__init__.py @@ -27,7 +27,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/st7567_base/__init__.py b/esphome/components/st7567_base/__init__.py index a045cf34ccd..67f05157d97 100644 --- a/esphome/components/st7567_base/__init__.py +++ b/esphome/components/st7567_base/__init__.py @@ -39,9 +39,7 @@ ST7567_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( async def setup_st7567(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) cg.add(var.init_invert_colors(config[CONF_INVERT_COLORS])) diff --git a/esphome/components/st7567_spi/display.py b/esphome/components/st7567_spi/display.py index ad04b9f2125..ba4a08eaf45 100644 --- a/esphome/components/st7567_spi/display.py +++ b/esphome/components/st7567_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await st7567_base.setup_st7567(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 79430f14176..915d32b5380 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -209,13 +209,8 @@ async def to_code(config: ConfigType) -> None: data_pin = await cg.gpio_pin_expression(pin) cg.add(var.add_data_pin(data_pin, index)) - if dc_pin := config.get(CONF_DC_PIN): - dc = await cg.gpio_pin_expression(dc_pin) - cg.add(var.set_dc_pin(dc)) - - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_dc_pin, config.get(CONF_DC_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if transform := config.get(CONF_TRANSFORM): cg.add(var.set_mirror_x(transform[CONF_MIRROR_X])) @@ -236,11 +231,7 @@ async def to_code(config: ConfigType) -> None: await display.add_writer(var, config) - pin = await cg.gpio_pin_expression(config[CONF_DE_PIN]) - cg.add(var.set_de_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_PCLK_PIN]) - cg.add(var.set_pclk_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_HSYNC_PIN]) - cg.add(var.set_hsync_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_VSYNC_PIN]) - cg.add(var.set_vsync_pin(pin)) + await cg.add_pin(var.set_de_pin, config[CONF_DE_PIN]) + await cg.add_pin(var.set_pclk_pin, config[CONF_PCLK_PIN]) + await cg.add_pin(var.set_hsync_pin, config[CONF_HSYNC_PIN]) + await cg.add_pin(var.set_vsync_pin, config[CONF_VSYNC_PIN]) diff --git a/esphome/components/status_led/light/__init__.py b/esphome/components/status_led/light/__init__.py index f63cf14178b..a36de55f198 100644 --- a/esphome/components/status_led/light/__init__.py +++ b/esphome/components/status_led/light/__init__.py @@ -27,11 +27,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) - if CONF_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) - if CONF_OUTPUT in config: - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_pin(var.set_pin, config.get(CONF_PIN)) + await cg.add_variable(var.set_output, config.get(CONF_OUTPUT)) await cg.register_component(var, config) await light.register_light(var, config) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 3af042ea72b..2c14ea0bd56 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -249,13 +249,9 @@ async def to_code(config: ConfigType) -> None: ], config[CONF_ON_PACKET], ) - if CONF_DIO1_PIN in config: - dio1_pin = await cg.gpio_pin_expression(config[CONF_DIO1_PIN]) - cg.add(var.set_dio1_pin(dio1_pin)) - rst_pin = await cg.gpio_pin_expression(config[CONF_RST_PIN]) - cg.add(var.set_rst_pin(rst_pin)) - busy_pin = await cg.gpio_pin_expression(config[CONF_BUSY_PIN]) - cg.add(var.set_busy_pin(busy_pin)) + await cg.add_pin(var.set_dio1_pin, config.get(CONF_DIO1_PIN)) + await cg.add_pin(var.set_rst_pin, config[CONF_RST_PIN]) + await cg.add_pin(var.set_busy_pin, config[CONF_BUSY_PIN]) cg.add(var.set_bandwidth(config[CONF_BANDWIDTH])) cg.add(var.set_frequency(config[CONF_FREQUENCY])) cg.add(var.set_hw_version(config[CONF_HW_VERSION])) diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 3bd3b35a2ed..49e68c61844 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -232,11 +232,8 @@ async def to_code(config: ConfigType) -> None: ], config[CONF_ON_PACKET], ) - if CONF_DIO0_PIN in config: - dio0_pin = await cg.gpio_pin_expression(config[CONF_DIO0_PIN]) - cg.add(var.set_dio0_pin(dio0_pin)) - rst_pin = await cg.gpio_pin_expression(config[CONF_RST_PIN]) - cg.add(var.set_rst_pin(rst_pin)) + await cg.add_pin(var.set_dio0_pin, config.get(CONF_DIO0_PIN)) + await cg.add_pin(var.set_rst_pin, config[CONF_RST_PIN]) cg.add(var.set_auto_cal(config[CONF_AUTO_CAL])) cg.add(var.set_bandwidth(config[CONF_BANDWIDTH])) cg.add(var.set_frequency(config[CONF_FREQUENCY])) diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 332d994d5e9..4a23e085ea0 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -175,6 +175,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_mixer_mode(config[CONF_MIXER_MODE])) cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) - if enable_pin_config := config.get(CONF_ENABLE_PIN): - enable_pin = await cg.gpio_pin_expression(enable_pin_config) - cg.add(var.set_enable_pin(enable_pin)) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py index ee9decd6680..2c33df3db91 100644 --- a/esphome/components/tca8418/__init__.py +++ b/esphome/components/tca8418/__init__.py @@ -145,8 +145,7 @@ async def to_code(config: ConfigType) -> None: array = cg.shared_progmem_array("tca8418_keys", cg.uint8, table) cg.add(var.set_keys(array, len(table))) cg.add(var.set_gpi_events(config[CONF_GPI_EVENTS])) - if (interrupt_pin := config.get(CONF_INTERRUPT_PIN)) is not None: - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) for conf in config.get(CONF_ON_KEY, []): await automation.build_callback_automation( diff --git a/esphome/components/tlc5947/__init__.py b/esphome/components/tlc5947/__init__.py index 71247dd13ef..1f1f4ea9c29 100644 --- a/esphome/components/tlc5947/__init__.py +++ b/esphome/components/tlc5947/__init__.py @@ -38,14 +38,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) - lat = await cg.gpio_pin_expression(config[CONF_LAT_PIN]) - cg.add(var.set_lat_pin(lat)) - if CONF_OE_PIN in config: - outenable = await cg.gpio_pin_expression(config[CONF_OE_PIN]) - cg.add(var.set_outenable_pin(outenable)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) + await cg.add_pin(var.set_lat_pin, config[CONF_LAT_PIN]) + await cg.add_pin(var.set_outenable_pin, config.get(CONF_OE_PIN)) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/tlc5971/__init__.py b/esphome/components/tlc5971/__init__.py index 26beec280e1..8e22b080f42 100644 --- a/esphome/components/tlc5971/__init__.py +++ b/esphome/components/tlc5971/__init__.py @@ -28,9 +28,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/tm1621/display.py b/esphome/components/tm1621/display.py index 4a83d363fe4..4765d950cbe 100644 --- a/esphome/components/tm1621/display.py +++ b/esphome/components/tm1621/display.py @@ -30,13 +30,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - cs = await cg.gpio_pin_expression(config[CONF_CS_PIN]) - cg.add(var.set_cs_pin(cs)) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - read = await cg.gpio_pin_expression(config[CONF_READ_PIN]) - cg.add(var.set_read_pin(read)) - write = await cg.gpio_pin_expression(config[CONF_WRITE_PIN]) - cg.add(var.set_write_pin(write)) + await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN]) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_read_pin, config[CONF_READ_PIN]) + await cg.add_pin(var.set_write_pin, config[CONF_WRITE_PIN]) await display.add_writer(var, config, TM1621DisplayRef) diff --git a/esphome/components/tm1637/display.py b/esphome/components/tm1637/display.py index ebdd9449d65..27289dea774 100644 --- a/esphome/components/tm1637/display.py +++ b/esphome/components/tm1637/display.py @@ -36,10 +36,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - clk = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_clk_pin(clk)) - dio = await cg.gpio_pin_expression(config[CONF_DIO_PIN]) - cg.add(var.set_dio_pin(dio)) + await cg.add_pin(var.set_clk_pin, config[CONF_CLK_PIN]) + await cg.add_pin(var.set_dio_pin, config[CONF_DIO_PIN]) cg.add(var.set_intensity(config[CONF_INTENSITY])) cg.add(var.set_inverted(config[CONF_INVERTED])) diff --git a/esphome/components/tm1638/display.py b/esphome/components/tm1638/display.py index 74ee8b3293d..d7e375ac885 100644 --- a/esphome/components/tm1638/display.py +++ b/esphome/components/tm1638/display.py @@ -35,14 +35,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - clk = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_clk_pin(clk)) - - dio = await cg.gpio_pin_expression(config[CONF_DIO_PIN]) - cg.add(var.set_dio_pin(dio)) - - stb = await cg.gpio_pin_expression(config[CONF_STB_PIN]) - cg.add(var.set_stb_pin(stb)) + await cg.add_pin(var.set_clk_pin, config[CONF_CLK_PIN]) + await cg.add_pin(var.set_dio_pin, config[CONF_DIO_PIN]) + await cg.add_pin(var.set_stb_pin, config[CONF_STB_PIN]) cg.add(var.set_intensity(config[CONF_INTENSITY])) diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index 14aa1c8842b..60cf1e542a1 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -43,10 +43,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - clk_pin = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_clk_pin(clk_pin)) - dio_pin = await cg.gpio_pin_expression(config[CONF_DIO_PIN]) - cg.add(var.set_dio_pin(dio_pin)) + await cg.add_pin(var.set_clk_pin, config[CONF_CLK_PIN]) + await cg.add_pin(var.set_dio_pin, config[CONF_DIO_PIN]) validate_brightness = cv.enum(TM1651_BRIGHTNESS_OPTIONS, int=True) diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index 1018443e002..427b3fe83a0 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -176,8 +176,7 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: (CONF_INDEX_PIN, var.set_index_pin), (CONF_DIAG_PIN, var.set_diag_pin), ): - if (pin := config.get(key)) is not None: - cg.add(setter(await cg.gpio_pin_expression(pin))) + await cg.add_pin(setter, config.get(key)) for key, setter in ( (CONF_RSENSE, var.set_rsense), (CONF_VSENSE, var.set_vsense), diff --git a/esphome/components/tt21100/touchscreen/__init__.py b/esphome/components/tt21100/touchscreen/__init__.py index 22489df379e..7ef15f810d4 100644 --- a/esphome/components/tt21100/touchscreen/__init__.py +++ b/esphome/components/tt21100/touchscreen/__init__.py @@ -32,10 +32,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if CONF_INTERRUPT_PIN in config: - interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(interrupt_pin)) - - if CONF_RESET_PIN in config: - rts_pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(rts_pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/ttp229_bsf/__init__.py b/esphome/components/ttp229_bsf/__init__.py index 1b10940570b..274ea25fd7c 100644 --- a/esphome/components/ttp229_bsf/__init__.py +++ b/esphome/components/ttp229_bsf/__init__.py @@ -26,7 +26,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - sdo = await cg.gpio_pin_expression(config[CONF_SDO_PIN]) - cg.add(var.set_sdo_pin(sdo)) - scl = await cg.gpio_pin_expression(config[CONF_SCL_PIN]) - cg.add(var.set_scl_pin(scl)) + await cg.add_pin(var.set_sdo_pin, config[CONF_SDO_PIN]) + await cg.add_pin(var.set_scl_pin, config[CONF_SCL_PIN]) diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index 51db405512c..4a334e3e518 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -89,9 +89,7 @@ async def to_code(config): cg.add(var.set_time_id(time_)) if config[CONF_FORCE_TIME_SYNC]: cg.add(var.set_force_time_sync(True)) - if CONF_STATUS_PIN in config: - status_pin_ = await cg.gpio_pin_expression(config[CONF_STATUS_PIN]) - cg.add(var.set_status_pin(status_pin_)) + await cg.add_pin(var.set_status_pin, config.get(CONF_STATUS_PIN)) if CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS in config: for dp in config[CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS]: cg.add(var.add_ignore_mcu_update_on_datapoints(dp)) diff --git a/esphome/components/tuya/climate/__init__.py b/esphome/components/tuya/climate/__init__.py index cefe5d5a4b4..91ef9a6a68a 100644 --- a/esphome/components/tuya/climate/__init__.py +++ b/esphome/components/tuya/climate/__init__.py @@ -204,12 +204,8 @@ async def to_code(config): if switch_datapoint := config.get(CONF_SWITCH_DATAPOINT): cg.add(var.set_switch_id(switch_datapoint)) - if heating_state_pin_config := config.get(CONF_HEATING_STATE_PIN): - heating_state_pin = await cg.gpio_pin_expression(heating_state_pin_config) - cg.add(var.set_heating_state_pin(heating_state_pin)) - if cooling_state_pin_config := config.get(CONF_COOLING_STATE_PIN): - cooling_state_pin = await cg.gpio_pin_expression(cooling_state_pin_config) - cg.add(var.set_cooling_state_pin(cooling_state_pin)) + await cg.add_pin(var.set_heating_state_pin, config.get(CONF_HEATING_STATE_PIN)) + await cg.add_pin(var.set_cooling_state_pin, config.get(CONF_COOLING_STATE_PIN)) if active_state_config := config.get(CONF_ACTIVE_STATE): cg.add(var.set_active_state_id(active_state_config.get(CONF_DATAPOINT))) if (heating_value := active_state_config.get(CONF_HEATING_VALUE)) is not None: diff --git a/esphome/components/tx20/sensor.py b/esphome/components/tx20/sensor.py index 77418e5284f..fe2d1ba99f6 100644 --- a/esphome/components/tx20/sensor.py +++ b/esphome/components/tx20/sensor.py @@ -48,5 +48,4 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_WIND_SPEED, var.set_wind_speed_sensor) await sensors(CONF_WIND_DIRECTION_DEGREES, var.set_wind_direction_degrees_sensor) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/uln2003/stepper.py b/esphome/components/uln2003/stepper.py index f09b2702c71..bc656a5ff36 100644 --- a/esphome/components/uln2003/stepper.py +++ b/esphome/components/uln2003/stepper.py @@ -42,14 +42,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await stepper.register_stepper(var, config) - pin_a = await cg.gpio_pin_expression(config[CONF_PIN_A]) - cg.add(var.set_pin_a(pin_a)) - pin_b = await cg.gpio_pin_expression(config[CONF_PIN_B]) - cg.add(var.set_pin_b(pin_b)) - pin_c = await cg.gpio_pin_expression(config[CONF_PIN_C]) - cg.add(var.set_pin_c(pin_c)) - pin_d = await cg.gpio_pin_expression(config[CONF_PIN_D]) - cg.add(var.set_pin_d(pin_d)) + await cg.add_pin(var.set_pin_a, config[CONF_PIN_A]) + await cg.add_pin(var.set_pin_b, config[CONF_PIN_B]) + await cg.add_pin(var.set_pin_c, config[CONF_PIN_C]) + await cg.add_pin(var.set_pin_d, config[CONF_PIN_D]) cg.add(var.set_sleep_when_done(config[CONF_SLEEP_WHEN_DONE])) cg.add(var.set_step_mode(config[CONF_STEP_MODE])) diff --git a/esphome/components/ultrasonic/sensor.py b/esphome/components/ultrasonic/sensor.py index 25973447f6f..a978f36b90b 100644 --- a/esphome/components/ultrasonic/sensor.py +++ b/esphome/components/ultrasonic/sensor.py @@ -49,9 +49,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - trigger = await cg.gpio_pin_expression(config[CONF_TRIGGER_PIN]) - cg.add(var.set_trigger_pin(trigger)) - echo = await cg.gpio_pin_expression(config[CONF_ECHO_PIN]) - cg.add(var.set_echo_pin(echo)) + await cg.add_pin(var.set_trigger_pin, config[CONF_TRIGGER_PIN]) + await cg.add_pin(var.set_echo_pin, config[CONF_ECHO_PIN]) cg.add(var.set_timeout_us(config[CONF_TIMEOUT] / (0.000343 / 2))) cg.add(var.set_pulse_time_us(config[CONF_PULSE_TIME])) diff --git a/esphome/components/vl53l0x/sensor.py b/esphome/components/vl53l0x/sensor.py index 3029e0f77b1..8f6a97c384a 100644 --- a/esphome/components/vl53l0x/sensor.py +++ b/esphome/components/vl53l0x/sensor.py @@ -81,9 +81,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_long_range(config[CONF_LONG_RANGE])) cg.add(var.set_timeout_us(config[CONF_TIMEOUT])) - if CONF_ENABLE_PIN in config: - enable = await cg.gpio_pin_expression(config[CONF_ENABLE_PIN]) - cg.add(var.set_enable_pin(enable)) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) if timing_budget := config.get(CONF_TIMING_BUDGET): cg.add(var.set_timing_budget(timing_budget)) diff --git a/esphome/components/wiegand/__init__.py b/esphome/components/wiegand/__init__.py index 93d5e4f0f06..c59bf1c0785 100644 --- a/esphome/components/wiegand/__init__.py +++ b/esphome/components/wiegand/__init__.py @@ -56,10 +56,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_D0]) - cg.add(var.set_d0_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_D1]) - cg.add(var.set_d1_pin(pin)) + await cg.add_pin(var.set_d0_pin, config[CONF_D0]) + await cg.add_pin(var.set_d1_pin, config[CONF_D1]) for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/x9c/output.py b/esphome/components/x9c/output.py index ff3f26e2abf..b927cfa906f 100644 --- a/esphome/components/x9c/output.py +++ b/esphome/components/x9c/output.py @@ -45,12 +45,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - cs_pin = await cg.gpio_pin_expression(config[CONF_CS_PIN]) - cg.add(var.set_cs_pin(cs_pin)) - inc_pin = await cg.gpio_pin_expression(config[CONF_INC_PIN]) - cg.add(var.set_inc_pin(inc_pin)) - ud_pin = await cg.gpio_pin_expression(config[CONF_UD_PIN]) - cg.add(var.set_ud_pin(ud_pin)) + await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN]) + await cg.add_pin(var.set_inc_pin, config[CONF_INC_PIN]) + await cg.add_pin(var.set_ud_pin, config[CONF_UD_PIN]) cg.add(var.set_initial_value(config[CONF_INITIAL_VALUE])) cg.add(var.set_step_delay(config[CONF_STEP_DELAY])) diff --git a/esphome/components/xpt2046/touchscreen/__init__.py b/esphome/components/xpt2046/touchscreen/__init__.py index e38bc23da37..a8bbe9d88fd 100644 --- a/esphome/components/xpt2046/touchscreen/__init__.py +++ b/esphome/components/xpt2046/touchscreen/__init__.py @@ -39,6 +39,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_threshold(config[CONF_THRESHOLD])) - if CONF_INTERRUPT_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_irq_pin(pin)) + await cg.add_pin(var.set_irq_pin, config.get(CONF_INTERRUPT_PIN)) diff --git a/esphome/components/zyaura/sensor.py b/esphome/components/zyaura/sensor.py index cfe980f037c..23fd3bc5bfb 100644 --- a/esphome/components/zyaura/sensor.py +++ b/esphome/components/zyaura/sensor.py @@ -18,7 +18,6 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) -from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType zyaura_ns = cg.esphome_ns.namespace("zyaura") @@ -56,10 +55,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin_clock = await gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_pin_clock(pin_clock)) - pin_data = await gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_pin_data(pin_data)) + await cg.add_pin(var.set_pin_clock, config[CONF_CLOCK_PIN]) + await cg.add_pin(var.set_pin_data, config[CONF_DATA_PIN]) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) From eca95348acb87f129f4fc650aa9078cfe3ba9cb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:16:50 -1000 Subject: [PATCH 115/120] [components] Assemble 24-bit readings with encode_uint24 (#21016) --- esphome/components/am2315c/am2315c.cpp | 2 +- esphome/components/bl0906/bl0906.cpp | 3 ++- esphome/components/bmp085/bmp085.cpp | 3 ++- esphome/components/bmp3xx_base/bmp3xx_base.cpp | 7 ++++--- esphome/components/bmp581_base/bmp581_base.cpp | 3 ++- esphome/components/dps310/dps310.cpp | 10 ++++------ esphome/components/ens210/ens210.cpp | 5 +++-- esphome/components/it8951/it8951.cpp | 2 +- esphome/components/ms5611/ms5611.cpp | 5 +++-- esphome/components/pzem004t/pzem004t.cpp | 3 ++- 10 files changed, 24 insertions(+), 19 deletions(-) diff --git a/esphome/components/am2315c/am2315c.cpp b/esphome/components/am2315c/am2315c.cpp index 9b350560ee9..cd754411577 100644 --- a/esphome/components/am2315c/am2315c.cpp +++ b/esphome/components/am2315c/am2315c.cpp @@ -64,7 +64,7 @@ bool AM2315C::convert_(uint8_t *data, float &humidity, float &temperature) { uint32_t raw; raw = (data[1] << 12) | (data[2] << 4) | (data[3] >> 4); humidity = raw * 9.5367431640625e-5; - raw = ((data[3] & 0x0F) << 16) | (data[4] << 8) | data[5]; + raw = encode_uint24(data[3] & 0x0F, data[4], data[5]); temperature = raw * 1.9073486328125e-4 - 50; return crc8(data, 6, 0xFF, 0x31, true) == data[6]; } diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 35f663ffa0c..6f59da77d34 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -1,13 +1,14 @@ #include "bl0906.h" #include "constants.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bl0906 { ESPHOME_LOG_TAG(TAG, "bl0906"); -constexpr uint32_t to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } +constexpr uint32_t to_uint32_t(ube24_t input) { return encode_uint24(input.h, input.m, input.l); } constexpr int32_t to_int32_t(sbe24_t input) { return static_cast(encode_uint32((uint8_t) input.h, input.m, input.l, 0)) >> 8; diff --git a/esphome/components/bmp085/bmp085.cpp b/esphome/components/bmp085/bmp085.cpp index f9ec710a9b6..a7607d4dbd3 100644 --- a/esphome/components/bmp085/bmp085.cpp +++ b/esphome/components/bmp085/bmp085.cpp @@ -1,4 +1,5 @@ #include "bmp085.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bmp085 { @@ -93,7 +94,7 @@ void BMP085Component::read_pressure_() { return; } - uint32_t value = (uint32_t(buffer[0]) << 16) | (uint32_t(buffer[1]) << 8) | uint32_t(buffer[2]); + uint32_t value = encode_uint24(buffer[0], buffer[1], buffer[2]); if ((value >> 5) == 0) { ESP_LOGW(TAG, "Invalid pressure!"); this->status_set_warning(); diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index f59abc61ec3..f985ebed870 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -7,6 +7,7 @@ #include "bmp3xx_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" #include @@ -222,7 +223,7 @@ bool BMP3XXComponent::get_temperature(float &temperature) { return false; } // Copy the temperature data into the adc variables - int32_t adc_temp = (int32_t) data[2] << 16 | (int32_t) data[1] << 8 | (int32_t) data[0]; + int32_t adc_temp = encode_uint24(data[2], data[1], data[0]); // Temperature compensation (function from BMP388 datasheet) temperature = bmp388_compensate_temperature_((float) adc_temp); return true; @@ -249,8 +250,8 @@ bool BMP3XXComponent::get_measurements(float &temperature, float &pressure) { return false; } // Copy the temperature and pressure data into the adc variables - int32_t adc_pres = (int32_t) data[2] << 16 | (int32_t) data[1] << 8 | (int32_t) data[0]; - int32_t adc_temp = (int32_t) data[5] << 16 | (int32_t) data[4] << 8 | (int32_t) data[3]; + int32_t adc_pres = encode_uint24(data[2], data[1], data[0]); + int32_t adc_temp = encode_uint24(data[5], data[4], data[3]); // Temperature compensation (function from BMP388 datasheet) temperature = bmp388_compensate_temperature_((float) adc_temp); diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 7bc743977b2..9f67f734e6e 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -12,6 +12,7 @@ #include "bmp581_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -410,7 +411,7 @@ bool BMP581Component::read_temperature_and_pressure_(float &temperature, float & temperature = (float) (raw_temp / 65536.0); // convert measurement to degrees Celsius (page 22 of datasheet) // pressure MSB is in data[5], LSB is in data[4], XLSB in data[3] - int32_t raw_press = (int32_t) data[5] << 16 | (int32_t) data[4] << 8 | (int32_t) data[3]; + int32_t raw_press = encode_uint24(data[5], data[4], data[3]); pressure = (float) (raw_press / 64.0); // Divide by 2^6=64 for Pa (page 22 of datasheet) return true; diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index 6ab786e7c71..5b3272b1d5e 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -1,4 +1,5 @@ #include "dps310.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -69,8 +70,7 @@ bool DPS310Component::setup_registers_() { (((uint32_t) coef_data_raw[5] >> 4) & 0x0F); this->c00_ = DPS310Component::twos_complement(c00_, 20); - this->c10_ = - (((uint32_t) coef_data_raw[5] & 0x0F) << 16) | ((uint32_t) coef_data_raw[6] << 8) | (uint32_t) coef_data_raw[7]; + this->c10_ = encode_uint24(coef_data_raw[5] & 0x0F, coef_data_raw[6], coef_data_raw[7]); this->c10_ = DPS310Component::twos_complement(c10_, 20); this->c01_ = int16_t(((uint16_t) coef_data_raw[8] << 8) | (uint16_t) coef_data_raw[9]); @@ -133,8 +133,7 @@ void DPS310Component::read_pressure_() { return; } this->got_pres_ = true; - this->raw_pressure_ = DPS310Component::twos_complement( - int32_t((uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2]))), 24); + this->raw_pressure_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); } void DPS310Component::read_temperature_() { @@ -144,8 +143,7 @@ void DPS310Component::read_temperature_() { return; } this->got_temp_ = true; - this->raw_temperature_ = DPS310Component::twos_complement( - int32_t((uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2]))), 24); + this->raw_temperature_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); } // Calculations are taken from the datasheet which can be found here: diff --git a/esphome/components/ens210/ens210.cpp b/esphome/components/ens210/ens210.cpp index 2251e0a574b..0d31a398177 100644 --- a/esphome/components/ens210/ens210.cpp +++ b/esphome/components/ens210/ens210.cpp @@ -7,6 +7,7 @@ // https://github.com/sciosense/ENS210_driver #include "ens210.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -160,7 +161,7 @@ void ENS210Component::update() { return; } // Pack bytes for humidity - h_val_data = (uint32_t) ((uint32_t) data[5] << 16 | (uint32_t) data[4] << 8 | (uint32_t) data[3]); + h_val_data = encode_uint24(data[5], data[4], data[3]); // Extract humidity data and update the status extract_measurement_(h_val_data, &humidity_data, &humidity_status); @@ -175,7 +176,7 @@ void ENS210Component::update() { return; } // Pack bytes for temperature - t_val_data = (uint32_t) ((uint32_t) data[2] << 16 | (uint32_t) data[1] << 8 | (uint32_t) data[0]); + t_val_data = encode_uint24(data[2], data[1], data[0]); // Extract temperature data and update the status extract_measurement_(t_val_data, &temperature_data, &temperature_status); diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 6b0a4b56094..e0d6f683f0d 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -988,7 +988,7 @@ void HOT IT8951Display::draw_pixels_internal(int x_start, int y_start, int w, in } case COLOR_BITNESS_888: { const size_t i = source_idx * 3; - color_value = ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); + color_value = encode_uint24(ptr[i + 2], ptr[i + 1], ptr[i]); break; } default: diff --git a/esphome/components/ms5611/ms5611.cpp b/esphome/components/ms5611/ms5611.cpp index e3e5194c415..29d483dd228 100644 --- a/esphome/components/ms5611/ms5611.cpp +++ b/esphome/components/ms5611/ms5611.cpp @@ -1,4 +1,5 @@ #include "ms5611.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -52,7 +53,7 @@ void MS5611Component::read_temperature_() { this->status_set_warning(); return; } - const uint32_t raw_temperature = (uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2])); + const uint32_t raw_temperature = encode_uint24(bytes[0], bytes[1], bytes[2]); // request pressure reading if (!this->write_bytes(MS5611_CMD_CONV_D1 + 0x08, nullptr, 0)) { @@ -68,7 +69,7 @@ void MS5611Component::read_pressure_(uint32_t raw_temperature) { this->status_set_warning(); return; } - const uint32_t raw_pressure = (uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2])); + const uint32_t raw_pressure = encode_uint24(bytes[0], bytes[1], bytes[2]); this->calculate_values_(raw_temperature, raw_pressure); } diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index 606a03e0c23..2c5e349bd31 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -1,4 +1,5 @@ #include "pzem004t.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/application.h" #include @@ -74,7 +75,7 @@ void PZEM004T::loop() { } case 0xA3: { // Energy Response - uint32_t energy = (uint32_t(resp[1]) << 16) | (uint32_t(resp[2]) << 8) | (uint32_t(resp[3])); + uint32_t energy = encode_uint24(resp[1], resp[2], resp[3]); if (this->energy_sensor_ != nullptr) this->energy_sensor_->publish_state(energy); ESP_LOGD(TAG, "Got Energy %" PRIu32 " Wh", energy); From 22a36de4aed7ef86840ed0a9e707a4d5c01f0308 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:20:58 -1000 Subject: [PATCH 116/120] [components] Decode little-endian I2C values with the new helpers (#20939) --- esphome/components/apds9930/apds9930.cpp | 7 +++--- esphome/components/as7341/as7341.cpp | 10 ++++---- esphome/components/as7341/as7341.h | 1 - esphome/components/as734x/as734x.cpp | 4 ++-- esphome/components/bh1745/bh1745.cpp | 13 +++++----- .../components/bme280_base/bme280_base.cpp | 4 ++-- esphome/components/bme280_base/bme280_base.h | 2 +- esphome/components/bme280_i2c/bme280_i2c.cpp | 4 ++-- esphome/components/bme280_i2c/bme280_i2c.h | 2 +- esphome/components/bme280_spi/bme280_spi.cpp | 7 +++--- esphome/components/bme280_spi/bme280_spi.h | 2 +- esphome/components/bmi160/bmi160.cpp | 17 ++----------- esphome/components/bmi160/bmi160.h | 3 --- esphome/components/bmi270/bmm150_aux.h | 2 +- .../components/bmp280_base/bmp280_base.cpp | 4 ++-- esphome/components/bmp280_base/bmp280_base.h | 2 +- esphome/components/bmp280_i2c/bmp280_i2c.h | 2 +- esphome/components/bmp280_spi/bmp280_spi.cpp | 7 +++--- esphome/components/bmp280_spi/bmp280_spi.h | 2 +- esphome/components/bq27220/bq27220.cpp | 11 +-------- esphome/components/bq27220/bq27220.h | 3 +-- esphome/components/bq76972/bq76972.cpp | 5 ++-- .../touchscreen/cst9220_touchscreen.cpp | 8 +++---- esphome/components/fusb302b/fusb302b.cpp | 8 +++---- .../gt911/touchscreen/gt911_touchscreen.cpp | 4 ++-- esphome/components/hdc2010/hdc2010.cpp | 5 ++-- esphome/components/hdc2080/hdc2080.cpp | 5 ++-- esphome/components/icm20689/icm20689.cpp | 14 +++++------ esphome/components/lis2dw12/lis2dw12.cpp | 6 ++--- esphome/components/lis3dh/lis3dh.cpp | 16 ++++++------- esphome/components/lps22/lps22.cpp | 3 ++- esphome/components/lsm6ds/lsm6ds.cpp | 18 +++++++------- esphome/components/mlx90614/mlx90614.cpp | 2 +- esphome/components/mpr121/mpr121.cpp | 7 +----- esphome/components/qmi8658/qmi8658.cpp | 14 +++++------ esphome/components/qmp6988/qmp6988.cpp | 20 ++++++++-------- .../spd2010/spd2010_touchscreen.cpp | 6 ++--- esphome/components/stts22h/stts22h.cpp | 3 ++- esphome/components/tca9555/tca9555.cpp | 24 +++++++------------ esphome/components/tcs34725/tcs34725.cpp | 12 +++++----- esphome/components/tfluna/tfluna.cpp | 8 +++---- esphome/components/tsl2561/tsl2561.cpp | 8 +------ esphome/components/veml3235/veml3235.cpp | 6 ++--- esphome/components/veml3235/veml3235.h | 10 +++----- esphome/components/xdb401/xdb401.cpp | 2 +- 45 files changed, 136 insertions(+), 187 deletions(-) diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp index a4b836beaa1..eec741d5938 100644 --- a/esphome/components/apds9930/apds9930.cpp +++ b/esphome/components/apds9930/apds9930.cpp @@ -1,5 +1,6 @@ #include "apds9930.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -111,8 +112,8 @@ void APDS9930Component::update() { const uint8_t status = raw[0]; if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) { - const uint16_t ch0 = encode_uint16(raw[2], raw[1]); - const uint16_t ch1 = encode_uint16(raw[4], raw[3]); + const uint16_t ch0 = encode_value_le(&raw[1]); + const uint16_t ch1 = encode_value_le(&raw[3]); if (ch0 >= ALS_SATURATION || ch1 >= ALS_SATURATION) { // A saturated channel would give a lux value that is too low, not too high ESP_LOGW(TAG, "Light channel saturated, lower ambient_light_gain"); @@ -125,7 +126,7 @@ void APDS9930Component::update() { } if (this->proximity_sensor_ != nullptr && (status & APDS9930_PVALID) != 0) { - this->proximity_sensor_->publish_state(encode_uint16(raw[6], raw[5])); + this->proximity_sensor_->publish_state(encode_value_le(&raw[5])); } } diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index cf02884c5e4..a40b8b507ce 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -95,16 +95,16 @@ uint8_t AS7341Component::get_atime() { } uint16_t AS7341Component::get_astep() { - uint16_t data; - this->read_byte_16(AS7341_ASTEP, &data); - return this->swap_bytes(data); + uint16_t data = 0; + this->read_byte_16_le(AS7341_ASTEP, &data); + return data; } bool AS7341Component::setup_gain(AS7341Gain gain) { return this->write_byte(AS7341_CFG1, gain); } bool AS7341Component::setup_atime(uint8_t atime) { return this->write_byte(AS7341_ATIME, atime); } -bool AS7341Component::setup_astep(uint16_t astep) { return this->write_byte_16(AS7341_ASTEP, swap_bytes(astep)); } +bool AS7341Component::setup_astep(uint16_t astep) { return this->write_byte_16_le(AS7341_ASTEP, astep); } bool AS7341Component::read_channels(uint16_t *data) { this->set_smux_low_channels(true); @@ -253,6 +253,4 @@ bool AS7341Component::clear_register_bit(uint8_t address, uint8_t bit_position) return this->write_byte(address, data); } -uint16_t AS7341Component::swap_bytes(uint16_t data) { return (data >> 8) | (data << 8); } - } // namespace esphome::as7341 diff --git a/esphome/components/as7341/as7341.h b/esphome/components/as7341/as7341.h index 2d72987f1cd..0ecc5dca419 100644 --- a/esphome/components/as7341/as7341.h +++ b/esphome/components/as7341/as7341.h @@ -118,7 +118,6 @@ class AS7341Component final : public PollingComponent, public i2c::I2CDevice { bool write_register_bit(uint8_t address, bool value, uint8_t bit_position); bool set_register_bit(uint8_t address, uint8_t bit_position); bool clear_register_bit(uint8_t address, uint8_t bit_position); - uint16_t swap_bytes(uint16_t data); protected: sensor::Sensor *f1_{nullptr}; diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp index 30412be70f5..5be4f4808ac 100644 --- a/esphome/components/as734x/as734x.cpp +++ b/esphome/components/as734x/as734x.cpp @@ -277,7 +277,7 @@ bool AS734XComponent::update_bits_(uint8_t reg, uint8_t mask, bool value) { 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_16_le(pgm(chip->astep_reg), this->astep_) || !this->write_byte(pgm(chip->cfg1_reg), this->gain_)) { return false; } @@ -459,7 +459,7 @@ void AS734XComponent::read_step_() { 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]); }; + auto slot_value = [&frame](uint8_t slot) { return encode_value_le(&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++) { diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp index ee7b9935f4c..c34c2493310 100644 --- a/esphome/components/bh1745/bh1745.cpp +++ b/esphome/components/bh1745/bh1745.cpp @@ -133,18 +133,17 @@ void BH1745Component::update() { if ((mode_control2 & MODE_CONTROL2_VALID) == 0) return; - uint8_t buffer[BH1745_CHANNELS * 2]; - if (!this->read_bytes(REG_RED_DATA_LSB, buffer, sizeof(buffer))) { + uint16_t buffer[BH1745_CHANNELS]; + if (!this->read_bytes_16_le(REG_RED_DATA_LSB, buffer, BH1745_CHANNELS)) { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->status_clear_warning(); - // Each channel is sent low byte first. - const uint16_t red = encode_uint16(buffer[1], buffer[0]); - const uint16_t green = encode_uint16(buffer[3], buffer[2]); - const uint16_t blue = encode_uint16(buffer[5], buffer[4]); - const uint16_t clear = encode_uint16(buffer[7], buffer[6]); + const uint16_t red = buffer[0]; + const uint16_t green = buffer[1]; + const uint16_t blue = buffer[2]; + const uint16_t clear = buffer[3]; ESP_LOGV(TAG, "Red: %u, Green: %u, Blue: %u, Clear: %u", red, green, blue, clear); if (this->red_counts_sensor_ != nullptr) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index cf61160f77e..f922b1942b4 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -335,8 +335,8 @@ uint8_t BME280Component::read_u8_(uint8_t a_register) { } uint16_t BME280Component::read_u16_le_(uint8_t a_register) { uint16_t data = 0; - this->read_byte_16(a_register, &data); - return (data >> 8) | (data << 8); + this->read_byte_16_le(a_register, &data); + return data; } int16_t BME280Component::read_s16_le_(uint8_t a_register) { return this->read_u16_le_(a_register); } diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index d1a441ccebb..5ddb3cfd456 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -91,7 +91,7 @@ class BME280Component : public PollingComponent { virtual bool read_byte(uint8_t a_register, uint8_t *data) = 0; virtual bool write_byte(uint8_t a_register, uint8_t data) = 0; virtual bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; - virtual bool read_byte_16(uint8_t a_register, uint16_t *data) = 0; + virtual bool read_byte_16_le(uint8_t a_register, uint16_t *data) = 0; BME280CalibrationData calibration_; BME280Oversampling temperature_oversampling_{BME280_OVERSAMPLING_16X}; diff --git a/esphome/components/bme280_i2c/bme280_i2c.cpp b/esphome/components/bme280_i2c/bme280_i2c.cpp index f4380b0d343..40f2384852b 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.cpp +++ b/esphome/components/bme280_i2c/bme280_i2c.cpp @@ -16,8 +16,8 @@ bool BME280I2CComponent::write_byte(uint8_t a_register, uint8_t data) { bool BME280I2CComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t len) { return I2CDevice::read_bytes(a_register, data, len); }; -bool BME280I2CComponent::read_byte_16(uint8_t a_register, uint16_t *data) { - return I2CDevice::read_byte_16(a_register, data); +bool BME280I2CComponent::read_byte_16_le(uint8_t a_register, uint16_t *data) { + return I2CDevice::read_byte_16_le(a_register, data); }; void BME280I2CComponent::dump_config() { diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index 45c3e5b260f..a4817faece0 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -12,7 +12,7 @@ class BME280I2CComponent final : public esphome::bme280_base::BME280Component, p bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; - bool read_byte_16(uint8_t a_register, uint16_t *data) override; + bool read_byte_16_le(uint8_t a_register, uint16_t *data) override; void dump_config() override; }; diff --git a/esphome/components/bme280_spi/bme280_spi.cpp b/esphome/components/bme280_spi/bme280_spi.cpp index a4e7b7d95c3..b9473fb6985 100644 --- a/esphome/components/bme280_spi/bme280_spi.cpp +++ b/esphome/components/bme280_spi/bme280_spi.cpp @@ -3,6 +3,7 @@ #include "bme280_spi.h" #include +#include "esphome/core/helpers.h" namespace esphome::bme280_spi { @@ -51,11 +52,11 @@ bool BME280SPIComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t le return true; } -bool BME280SPIComponent::read_byte_16(uint8_t a_register, uint16_t *data) { +bool BME280SPIComponent::read_byte_16_le(uint8_t a_register, uint16_t *data) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); - ((uint8_t *) data)[1] = this->transfer_byte(0); - ((uint8_t *) data)[0] = this->transfer_byte(0); + const uint8_t lsb = this->transfer_byte(0); + *data = encode_uint16(this->transfer_byte(0), lsb); this->disable(); return true; } diff --git a/esphome/components/bme280_spi/bme280_spi.h b/esphome/components/bme280_spi/bme280_spi.h index 3879151ea13..98f39f44b38 100644 --- a/esphome/components/bme280_spi/bme280_spi.h +++ b/esphome/components/bme280_spi/bme280_spi.h @@ -12,7 +12,7 @@ class BME280SPIComponent final : public esphome::bme280_base::BME280Component, bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; - bool read_byte_16(uint8_t a_register, uint16_t *data) override; + bool read_byte_16_le(uint8_t a_register, uint16_t *data) override; }; } // namespace esphome::bme280_spi diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index b97baae06ae..2a522c7a8ce 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -196,19 +196,6 @@ void BMI160Component::dump_config() { LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); } -i2c::ErrorCode BMI160Component::read_le_int16_(uint8_t reg, int16_t *value, uint8_t len) { - uint8_t raw_data[len * 2]; - // read using read_register because we have little-endian data, and read_bytes_16 will swap it - i2c::ErrorCode err = this->read_register(reg, raw_data, len * 2); - if (err != i2c::ERROR_OK) { - return err; - } - for (int i = 0; i < len; i++) { - value[i] = (int16_t) ((uint16_t) raw_data[i * 2] | ((uint16_t) raw_data[i * 2 + 1] << 8)); - } - return err; -} - void BMI160Component::update() { if (!this->setup_complete_) { return; @@ -216,7 +203,7 @@ void BMI160Component::update() { ESP_LOGV(TAG, " Updating BMI160"); int16_t data[6]; - if (this->read_le_int16_(BMI160_REGISTER_DATA_GYRO_X_LSB, data, 6) != i2c::ERROR_OK) { + if (!this->read_bytes_16_le(BMI160_REGISTER_DATA_GYRO_X_LSB, data, 6)) { this->status_set_warning(); return; } @@ -229,7 +216,7 @@ void BMI160Component::update() { float accel_z = (float) data[5] / (float) INT16_MAX * 16 * GRAVITY_EARTH; int16_t raw_temperature; - if (this->read_le_int16_(BMI160_REGISTER_DATA_TEMP_LSB, &raw_temperature, 1) != i2c::ERROR_OK) { + if (!this->read_bytes_16_le(BMI160_REGISTER_DATA_TEMP_LSB, &raw_temperature, 1)) { this->status_set_warning(); return; } diff --git a/esphome/components/bmi160/bmi160.h b/esphome/components/bmi160/bmi160.h index 53704965cd9..eb0b553cc24 100644 --- a/esphome/components/bmi160/bmi160.h +++ b/esphome/components/bmi160/bmi160.h @@ -34,9 +34,6 @@ class BMI160Component final : public PollingComponent, public i2c::I2CDevice { /// 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 */ - i2c::ErrorCode read_le_int16_(uint8_t reg, int16_t *value, uint8_t len); }; } // namespace esphome::bmi160 diff --git a/esphome/components/bmi270/bmm150_aux.h b/esphome/components/bmi270/bmm150_aux.h index 747a0898fa8..3e1c288b77a 100644 --- a/esphome/components/bmi270/bmm150_aux.h +++ b/esphome/components/bmi270/bmm150_aux.h @@ -61,7 +61,7 @@ struct BMM150Trim { // A little-endian 16-bit trim word, addressed by its register inline uint16_t bmm150_trim_word(const uint8_t *regs, uint8_t reg) { const uint8_t offset = reg - BMM150_REG_TRIM_START; - return encode_uint16(regs[offset + 1], regs[offset]); + return encode_value_le(®s[offset]); } // Decodes the trim registers; `regs` holds the bytes of registers 0x5D onwards (at least 21). diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index d82f80ccd43..10be0585210 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -247,8 +247,8 @@ uint8_t BMP280Component::read_u8_(uint8_t a_register) { } uint16_t BMP280Component::read_u16_le_(uint8_t a_register) { uint16_t data = 0; - this->bmp_read_byte_16(a_register, &data); - return (data >> 8) | (data << 8); + this->bmp_read_byte_16_le(a_register, &data); + return data; } int16_t BMP280Component::read_s16_le_(uint8_t a_register) { return this->read_u16_le_(a_register); } diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 1c3fc9cf6cf..cee8f43c7c2 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -69,7 +69,7 @@ class BMP280Component : public PollingComponent { virtual bool bmp_read_byte(uint8_t a_register, uint8_t *data) = 0; virtual bool bmp_write_byte(uint8_t a_register, uint8_t data) = 0; virtual bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; - virtual bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) = 0; + virtual bool bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) = 0; /// Read the temperature value and store the calculated ambient temperature in t_fine. float read_temperature_(int32_t *t_fine); diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index f2ec93176ef..441d1a4565f 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -16,7 +16,7 @@ class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, p bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override { return read_bytes(a_register, data, len); } - bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) override { return read_byte_16(a_register, data); } + bool bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) override { return read_byte_16_le(a_register, data); } void dump_config() override; }; diff --git a/esphome/components/bmp280_spi/bmp280_spi.cpp b/esphome/components/bmp280_spi/bmp280_spi.cpp index 04f92f9b899..5008d191392 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.cpp +++ b/esphome/components/bmp280_spi/bmp280_spi.cpp @@ -3,6 +3,7 @@ #include "bmp280_spi.h" #include +#include "esphome/core/helpers.h" namespace esphome::bmp280_spi { @@ -51,11 +52,11 @@ bool BMP280SPIComponent::bmp_read_bytes(uint8_t a_register, uint8_t *data, size_ return true; } -bool BMP280SPIComponent::bmp_read_byte_16(uint8_t a_register, uint16_t *data) { +bool BMP280SPIComponent::bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); - ((uint8_t *) data)[1] = this->transfer_byte(0); - ((uint8_t *) data)[0] = this->transfer_byte(0); + const uint8_t lsb = this->transfer_byte(0); + *data = encode_uint16(this->transfer_byte(0), lsb); this->disable(); return true; } diff --git a/esphome/components/bmp280_spi/bmp280_spi.h b/esphome/components/bmp280_spi/bmp280_spi.h index 449167811d3..fafc722c6cc 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.h +++ b/esphome/components/bmp280_spi/bmp280_spi.h @@ -12,7 +12,7 @@ class BMP280SPIComponent final : public esphome::bmp280_base::BMP280Component, bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; bool bmp_write_byte(uint8_t a_register, uint8_t data) override; bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; - bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) override; + bool bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) override; }; } // namespace esphome::bmp280_spi diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp index b7912b930e6..3c78893437d 100644 --- a/esphome/components/bq27220/bq27220.cpp +++ b/esphome/components/bq27220/bq27220.cpp @@ -25,15 +25,6 @@ static constexpr uint8_t REG_MAC_DATA = 0x40; static constexpr uint16_t DEVICE_NUMBER = 0x0220; static constexpr uint16_t TIME_TO_EMPTY_NOT_APPLICABLE = 0xFFFF; -bool BQ27220Component::read_word_(uint8_t reg, uint16_t &value) { - uint8_t data[2]; - if (this->read_register(reg, data, 2) != i2c::ERROR_OK) { - return false; - } - value = encode_uint16(data[1], data[0]); - return true; -} - void BQ27220Component::setup() { const uint8_t device_number_cmd[2] = {0x01, 0x00}; // DEVICE_NUMBER subcommand, little-endian if (this->write_register(REG_CONTROL, device_number_cmd, 2) != i2c::ERROR_OK) { @@ -42,7 +33,7 @@ void BQ27220Component::setup() { } delay(2); // SLUUBD4A §5.3: the subcommand result needs a moment before it can be read uint16_t device_number = 0; - if (!this->read_word_(REG_MAC_DATA, device_number)) { + if (!this->read_byte_16_le(REG_MAC_DATA, &device_number)) { this->mark_failed(LOG_STR("Failed to read device number from BQ27220")); return; } diff --git a/esphome/components/bq27220/bq27220.h b/esphome/components/bq27220/bq27220.h index d88b76a20f0..62cc4b659d0 100644 --- a/esphome/components/bq27220/bq27220.h +++ b/esphome/components/bq27220/bq27220.h @@ -25,14 +25,13 @@ class BQ27220Component final : public PollingComponent, public i2c::I2CDevice { void dump_config() override; protected: - bool read_word_(uint8_t reg, uint16_t &value); /// Read a standard command register and publish it through the sensor, if one is configured. /// A failed read publishes NAN and clears `ok`. template void publish_(sensor::Sensor *sensor, uint8_t reg, bool &ok, F convert) { if (sensor == nullptr) return; uint16_t raw; - if (!this->read_word_(reg, raw)) { + if (!this->read_byte_16_le(reg, &raw)) { ok = false; sensor->publish_state(NAN); return; diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp index 9426fbb2526..59d18878370 100644 --- a/esphome/components/bq76972/bq76972.cpp +++ b/esphome/components/bq76972/bq76972.cpp @@ -80,11 +80,10 @@ bool BQ76972Component::write_block_(uint8_t start_register, const uint8_t *data, } bool BQ76972Component::read_words_(uint8_t start_register, uint16_t *data, size_t num_words) { - std::array bytes; - if (num_words * 2 > bytes.size() || !this->read_block_(start_register, bytes.data(), num_words * 2)) + if (!this->read_block_(start_register, reinterpret_cast(data), num_words * 2)) return false; for (size_t i = 0; i < num_words; i++) - data[i] = encode_uint16(bytes[2 * i + 1], bytes[2 * i]); + data[i] = convert_little_endian(data[i]); return true; } diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp index 366b1846d73..72803804335 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -51,9 +51,9 @@ void CST9220Touchscreen::continue_setup_() { // Read the panel resolution unless the user supplied calibration values. if (this->read_register16(REG_RESOLUTION, buffer, 4) == i2c::ERROR_OK) { if (this->x_raw_max_ == this->x_raw_min_) - this->x_raw_max_ = encode_uint16(buffer[1], buffer[0]); + this->x_raw_max_ = encode_value_le(buffer); if (this->y_raw_max_ == this->y_raw_min_) - this->y_raw_max_ = encode_uint16(buffer[3], buffer[2]); + this->y_raw_max_ = encode_value_le(&buffer[2]); } // Read the chip type and project id and validate the controller. @@ -62,8 +62,8 @@ void CST9220Touchscreen::continue_setup_() { this->mark_failed(); return; } - this->chip_id_ = encode_uint16(buffer[3], buffer[2]); - this->project_id_ = encode_uint16(buffer[1], buffer[0]); + this->chip_id_ = encode_value_le(&buffer[2]); + this->project_id_ = encode_value_le(buffer); if (this->chip_id_ != CST9220_CHIP_ID && this->chip_id_ != CST9217_CHIP_ID) { ESP_LOGE(TAG, "Unknown chip ID: 0x%04X", this->chip_id_); this->status_set_error(LOG_STR("Unknown chip ID")); diff --git a/esphome/components/fusb302b/fusb302b.cpp b/esphome/components/fusb302b/fusb302b.cpp index 479981f8cac..608b356bbac 100644 --- a/esphome/components/fusb302b/fusb302b.cpp +++ b/esphome/components/fusb302b/fusb302b.cpp @@ -343,16 +343,14 @@ bool FUSB302B::read_message_(PdMsg &msg, bool &is_sop) { uint8_t header[2]; if (!this->read_byte(FUSB_FIFOS, &token) || this->read_register(FUSB_FIFOS, header, 2) != i2c::ERROR_OK) return false; - msg.set_header(encode_uint16(header[1], header[0])); + msg.set_header(encode_value_le(header)); // Data objects, followed by the CRC32 that the chip already checked uint8_t buf[PD_MAX_NUM_DATA_OBJECTS * 4 + 4]; if (this->read_register(FUSB_FIFOS, buf, msg.num_of_obj * 4 + 4) != i2c::ERROR_OK) return false; - for (uint8_t i = 0; i < msg.num_of_obj; i++) { - const uint8_t *obj = &buf[i * 4]; - msg.data_objects[i] = encode_uint32(obj[3], obj[2], obj[1], obj[0]); - } + for (uint8_t i = 0; i < msg.num_of_obj; i++) + msg.data_objects[i] = encode_value_le(&buf[i * 4]); is_sop = (token & FUSB_FIFO_RX_TOKEN_BITS) == FUSB_FIFO_RX_SOP; return true; } diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 388b8165fd7..fe251508e27 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -90,8 +90,8 @@ void GT911Touchscreen::setup_internal_() { if (err == i2c::ERROR_OK) { err = this->read(data, sizeof(data)); if (err == i2c::ERROR_OK) { - this->x_raw_max_ = encode_uint16(data[1], data[0]); - this->y_raw_max_ = encode_uint16(data[3], data[2]); + this->x_raw_max_ = encode_value_le(data); + this->y_raw_max_ = encode_value_le(&data[2]); if (this->swap_x_y_) std::swap(this->x_raw_max_, this->y_raw_max_); } diff --git a/esphome/components/hdc2010/hdc2010.cpp b/esphome/components/hdc2010/hdc2010.cpp index 471afe515a1..ecae9909390 100644 --- a/esphome/components/hdc2010/hdc2010.cpp +++ b/esphome/components/hdc2010/hdc2010.cpp @@ -1,4 +1,5 @@ #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "hdc2010.h" // https://github.com/vigsterkr/homebridge-hdc2010/blob/main/src/hdc2010.js // https://github.com/lime-labs/HDC2080-Arduino/blob/master/src/HDC2080.cpp @@ -76,7 +77,7 @@ float HDC2010Component::read_temp() { this->read_register(REG_TEMPERATURE_LOW, &byte[0], 1); this->read_register(REG_TEMPERATURE_HIGH, &byte[1], 1); - uint16_t temp = encode_uint16(byte[1], byte[0]); + uint16_t temp = encode_value_le(byte); return (float) temp * 0.0025177f - 40.0f; } @@ -86,7 +87,7 @@ float HDC2010Component::read_humidity() { this->read_register(REG_HUMIDITY_LOW, &byte[0], 1); this->read_register(REG_HUMIDITY_HIGH, &byte[1], 1); - uint16_t humidity = encode_uint16(byte[1], byte[0]); + uint16_t humidity = encode_value_le(byte); return (float) humidity * 0.001525879f; } diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index beb84f30746..8672dc497f5 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -1,5 +1,6 @@ #include "hdc2080.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::hdc2080 { @@ -55,11 +56,11 @@ void HDC2080Component::update() { } this->status_clear_warning(); if (this->temperature_sensor_ != nullptr) { - float temp = encode_uint16(raw_data[1], raw_data[0]) * (165.0f / 65536.0f) - 40.5f; + float temp = encode_value_le(raw_data) * (165.0f / 65536.0f) - 40.5f; this->temperature_sensor_->publish_state(temp); } if (this->humidity_sensor_ != nullptr) { - float humidity = encode_uint16(raw_data[3], raw_data[2]) * (100.0f / 65536.0f); + float humidity = encode_value_le(&raw_data[2]) * (100.0f / 65536.0f); this->humidity_sensor_->publish_state(humidity); } }); diff --git a/esphome/components/icm20689/icm20689.cpp b/esphome/components/icm20689/icm20689.cpp index 3bca35dc254..69cb69c1f25 100644 --- a/esphome/components/icm20689/icm20689.cpp +++ b/esphome/components/icm20689/icm20689.cpp @@ -101,19 +101,19 @@ bool ICM20689Component::update_data(motion::MotionData &data) { const float accel_factor = accel_scale(accel_range); - data.acceleration[motion::X_AXIS] = static_cast(encode_uint16(raw_data[0], raw_data[1])) * accel_factor; - data.acceleration[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[2], raw_data[3])) * accel_factor; - data.acceleration[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[4], raw_data[5])) * accel_factor; + data.acceleration[motion::X_AXIS] = encode_value(raw_data) * accel_factor; + data.acceleration[motion::Y_AXIS] = encode_value(&raw_data[2]) * accel_factor; + data.acceleration[motion::Z_AXIS] = encode_value(&raw_data[4]) * accel_factor; const float gyro_factor = gyro_scale(gyro_range); - data.angular_rate[motion::X_AXIS] = static_cast(encode_uint16(raw_data[8], raw_data[9])) * gyro_factor; - data.angular_rate[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[10], raw_data[11])) * gyro_factor; - data.angular_rate[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[12], raw_data[13])) * gyro_factor; + data.angular_rate[motion::X_AXIS] = encode_value(&raw_data[8]) * gyro_factor; + data.angular_rate[motion::Y_AXIS] = encode_value(&raw_data[10]) * gyro_factor; + data.angular_rate[motion::Z_AXIS] = encode_value(&raw_data[12]) * gyro_factor; if (!this->temperature_callback_.empty()) { // Datasheet formula: temperature (°C) = raw / 326.8 + 25. - const float temperature = static_cast(encode_uint16(raw_data[6], raw_data[7])) / 326.8f + 25.0f; + const float temperature = encode_value(&raw_data[6]) / 326.8f + 25.0f; this->temperature_callback_.call(temperature); } diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp index 21e1a07676f..7af60be87a4 100644 --- a/esphome/components/lis2dw12/lis2dw12.cpp +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -214,9 +214,9 @@ bool LIS2DW12Component::read_acceleration_data_(float &x, float &y, float &z) { return false; } - int16_t raw_x = static_cast(encode_uint16(accel_data[1], accel_data[0])) >> this->shift_; - int16_t raw_y = static_cast(encode_uint16(accel_data[3], accel_data[2])) >> this->shift_; - int16_t raw_z = static_cast(encode_uint16(accel_data[5], accel_data[4])) >> this->shift_; + int16_t raw_x = encode_value_le(accel_data) >> this->shift_; + int16_t raw_y = encode_value_le(&accel_data[2]) >> this->shift_; + int16_t raw_z = encode_value_le(&accel_data[4]) >> this->shift_; if (this->swap_xy_) { std::swap(raw_x, raw_y); diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp index 64e6da4ef95..f5ade6ab0b3 100644 --- a/esphome/components/lis3dh/lis3dh.cpp +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -144,8 +144,8 @@ bool LIS3DHComponent::update_data(motion::MotionData &data) { if (this->is_failed()) return false; - uint8_t raw[6]; - if (!this->read_bytes(LIS3DH_REG_OUT_X_L | LIS3DH_AUTO_INCREMENT, raw, 6)) { + int16_t raw[3]; + if (!this->read_bytes_16_le(LIS3DH_REG_OUT_X_L | LIS3DH_AUTO_INCREMENT, raw, 3)) { this->status_set_warning(LOG_STR("Failed to read acceleration data")); return false; } @@ -154,9 +154,9 @@ bool LIS3DHComponent::update_data(motion::MotionData &data) { progmem_memcpy(&scale, &SCALE_G[this->range_], sizeof(scale)); // Data is little-endian and left-justified in a signed 16-bit register. - int16_t raw_x = (int16_t) encode_uint16(raw[1], raw[0]); - int16_t raw_y = (int16_t) encode_uint16(raw[3], raw[2]); - int16_t raw_z = (int16_t) encode_uint16(raw[5], raw[4]); + int16_t raw_x = raw[0]; + int16_t raw_y = raw[1]; + int16_t raw_z = raw[2]; ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); data.acceleration[motion::X_AXIS] = raw_x * scale; data.acceleration[motion::Y_AXIS] = raw_y * scale; @@ -168,14 +168,14 @@ bool LIS3DHComponent::update_data(motion::MotionData &data) { } bool LIS3DHComponent::publish_temperature_() { - uint8_t raw[2]; - if (!this->read_bytes(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, raw, 2)) { + int16_t raw; + if (!this->read_bytes_16_le(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, &raw, 1)) { this->status_set_warning(LOG_STR("Failed to read temperature data")); return false; } // Signed, left-justified, relative value with 1 °C per count of the high byte; the // low byte carries the fractional bits in the modes that have them. - float temperature = (int16_t) encode_uint16(raw[1], raw[0]) / 256.0f + LIS3DH_TEMP_REFERENCE_C; + float temperature = raw / 256.0f + LIS3DH_TEMP_REFERENCE_C; this->temperature_callback_.call(temperature); return true; } diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp index 57f801c332b..47b00057c69 100644 --- a/esphome/components/lps22/lps22.cpp +++ b/esphome/components/lps22/lps22.cpp @@ -1,4 +1,5 @@ #include "lps22.h" +#include "esphome/core/helpers.h" namespace esphome::lps22 { @@ -65,7 +66,7 @@ void LPS22Component::try_read_() { if (this->temperature_sensor_ != nullptr) { uint8_t t_buf[2]{0}; this->read_register(TEMP_L, t_buf, 2); - int16_t encoded = static_cast(encode_uint16(t_buf[1], t_buf[0])); + int16_t encoded = encode_value_le(t_buf); float temp = TEMPERATURE_SCALE * static_cast(encoded); this->temperature_sensor_->publish_state(temp); } diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp index e34735a4160..1d284d54ef0 100644 --- a/esphome/components/lsm6ds/lsm6ds.cpp +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -1,6 +1,7 @@ #include "lsm6ds.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/progmem.h" namespace esphome::lsm6ds { @@ -166,9 +167,9 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { }; float gyro_scale = GYRO_SCALE[this->gyro_range_]; - data.angular_rate[motion::X_AXIS] = (int16_t) ((raw[1] << 8) | raw[0]) * gyro_scale; - data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw[3] << 8) | raw[2]) * gyro_scale; - data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw[5] << 8) | raw[4]) * gyro_scale; + data.angular_rate[motion::X_AXIS] = encode_value_le(raw) * gyro_scale; + data.angular_rate[motion::Y_AXIS] = encode_value_le(&raw[2]) * gyro_scale; + data.angular_rate[motion::Z_AXIS] = encode_value_le(&raw[4]) * gyro_scale; // Accelerometer // Sensitivity (mg/LSB) from datasheet Table 3. @@ -182,12 +183,9 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { }; float accel_scale = ACCEL_SCALE[this->accel_range_]; - data.acceleration[motion::X_AXIS] = - (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 1] << 8) | raw[LSM6DS_ACCEL_OFFSET + 0]) * accel_scale; - data.acceleration[motion::Y_AXIS] = - (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 3] << 8) | raw[LSM6DS_ACCEL_OFFSET + 2]) * accel_scale; - data.acceleration[motion::Z_AXIS] = - (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 5] << 8) | raw[LSM6DS_ACCEL_OFFSET + 4]) * accel_scale; + data.acceleration[motion::X_AXIS] = encode_value_le(&raw[LSM6DS_ACCEL_OFFSET]) * accel_scale; + data.acceleration[motion::Y_AXIS] = encode_value_le(&raw[LSM6DS_ACCEL_OFFSET + 2]) * accel_scale; + data.acceleration[motion::Z_AXIS] = encode_value_le(&raw[LSM6DS_ACCEL_OFFSET + 4]) * accel_scale; // 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 @@ -196,7 +194,7 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { 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]); + int16_t temp_raw = encode_value_le(raw_t); float temperature = temp_raw / static_cast(this->temperature_sensitivity_) + 25.0f; this->temperature_callback_.call(temperature); } diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index a67d770cc3d..353057dc34d 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -140,7 +140,7 @@ i2c::ErrorCode MLX90614Component::read_register_(uint8_t reg, uint16_t &data) { return i2c::ERROR_CRC; } - data = encode_uint16(buf[4], buf[3]); + data = encode_value_le(&buf[3]); return i2c::ERROR_OK; } diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index 3d9cded59ff..bbcee223e8c 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -72,12 +72,7 @@ void MPR121Component::dump_config() { } void MPR121Component::loop() { uint16_t val = 0; - this->read_byte_16(MPR121_TOUCHSTATUS_L, &val); - - // Flip order - uint8_t lsb = val >> 8; - uint8_t msb = val; - val = (uint16_t(msb) << 8) | lsb; + this->read_byte_16_le(MPR121_TOUCHSTATUS_L, &val); for (auto *channel : this->channels_) channel->process(val); diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index f03aac9a80c..f6690e0af6b 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -99,18 +99,18 @@ bool QMI8658Component::update_data(motion::MotionData &data) { // Data is little-endian (low byte first). float scale = ACCEL_SCALE[this->accel_range_ >> 4]; - int16_t raw_x = encode_uint16(raw_data[ACC_OFFS + 1], raw_data[ACC_OFFS + 0]); - int16_t raw_y = encode_uint16(raw_data[ACC_OFFS + 3], raw_data[ACC_OFFS + 2]); - int16_t raw_z = encode_uint16(raw_data[ACC_OFFS + 5], raw_data[ACC_OFFS + 4]); + int16_t raw_x = encode_value_le(&raw_data[ACC_OFFS]); + int16_t raw_y = encode_value_le(&raw_data[ACC_OFFS + 2]); + int16_t raw_z = encode_value_le(&raw_data[ACC_OFFS + 4]); ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); data.acceleration[motion::X_AXIS] = raw_x * scale; data.acceleration[motion::Y_AXIS] = raw_y * scale; data.acceleration[motion::Z_AXIS] = raw_z * scale; scale = GYRO_SCALE[this->gyro_range_ >> 4]; - raw_x = encode_uint16(raw_data[GYR_OFFS + 1], raw_data[GYR_OFFS + 0]); - raw_y = encode_uint16(raw_data[GYR_OFFS + 3], raw_data[GYR_OFFS + 2]); - raw_z = encode_uint16(raw_data[GYR_OFFS + 5], raw_data[GYR_OFFS + 4]); + raw_x = encode_value_le(&raw_data[GYR_OFFS]); + raw_y = encode_value_le(&raw_data[GYR_OFFS + 2]); + raw_z = encode_value_le(&raw_data[GYR_OFFS + 4]); ESP_LOGV(TAG, "Read raw gyro data: %d, %d, %d", raw_x, raw_y, raw_z); data.angular_rate[motion::X_AXIS] = raw_x * scale; data.angular_rate[motion::Y_AXIS] = raw_y * scale; @@ -119,7 +119,7 @@ bool QMI8658Component::update_data(motion::MotionData &data) { if (this->temperature_callback_.empty()) return true; // Temperature: signed 16-bit, °C = raw / 256 - int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + int16_t raw_t = encode_value_le(&raw_data[TEMP_OFFS]); this->temperature_callback_.call(raw_t / 256.0f); return true; } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index ea3bf19eb4c..9dce402fe02 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -112,21 +112,21 @@ bool QMP6988Component::get_calibration_data_() { (int32_t) encode_uint32(a_data_uint8_tr[18], a_data_uint8_tr[19], (a_data_uint8_tr[24] & 0x0f) << 4, 0); qmp6988_data_.qmp6988_cali.COE_a0 = qmp6988_data_.qmp6988_cali.COE_a0 >> 12; - qmp6988_data_.qmp6988_cali.COE_a1 = (int16_t) encode_uint16(a_data_uint8_tr[20], a_data_uint8_tr[21]); - qmp6988_data_.qmp6988_cali.COE_a2 = (int16_t) encode_uint16(a_data_uint8_tr[22], a_data_uint8_tr[23]); + qmp6988_data_.qmp6988_cali.COE_a1 = encode_value(&a_data_uint8_tr[20]); + qmp6988_data_.qmp6988_cali.COE_a2 = encode_value(&a_data_uint8_tr[22]); qmp6988_data_.qmp6988_cali.COE_b00 = (int32_t) encode_uint32(a_data_uint8_tr[0], a_data_uint8_tr[1], a_data_uint8_tr[24] & 0xf0, 0); qmp6988_data_.qmp6988_cali.COE_b00 = qmp6988_data_.qmp6988_cali.COE_b00 >> 12; - qmp6988_data_.qmp6988_cali.COE_bt1 = (int16_t) encode_uint16(a_data_uint8_tr[2], a_data_uint8_tr[3]); - qmp6988_data_.qmp6988_cali.COE_bt2 = (int16_t) encode_uint16(a_data_uint8_tr[4], a_data_uint8_tr[5]); - qmp6988_data_.qmp6988_cali.COE_bp1 = (int16_t) encode_uint16(a_data_uint8_tr[6], a_data_uint8_tr[7]); - qmp6988_data_.qmp6988_cali.COE_b11 = (int16_t) encode_uint16(a_data_uint8_tr[8], a_data_uint8_tr[9]); - qmp6988_data_.qmp6988_cali.COE_bp2 = (int16_t) encode_uint16(a_data_uint8_tr[10], a_data_uint8_tr[11]); - qmp6988_data_.qmp6988_cali.COE_b12 = (int16_t) encode_uint16(a_data_uint8_tr[12], a_data_uint8_tr[13]); - qmp6988_data_.qmp6988_cali.COE_b21 = (int16_t) encode_uint16(a_data_uint8_tr[14], a_data_uint8_tr[15]); - qmp6988_data_.qmp6988_cali.COE_bp3 = (int16_t) encode_uint16(a_data_uint8_tr[16], a_data_uint8_tr[17]); + qmp6988_data_.qmp6988_cali.COE_bt1 = encode_value(&a_data_uint8_tr[2]); + qmp6988_data_.qmp6988_cali.COE_bt2 = encode_value(&a_data_uint8_tr[4]); + qmp6988_data_.qmp6988_cali.COE_bp1 = encode_value(&a_data_uint8_tr[6]); + qmp6988_data_.qmp6988_cali.COE_b11 = encode_value(&a_data_uint8_tr[8]); + qmp6988_data_.qmp6988_cali.COE_bp2 = encode_value(&a_data_uint8_tr[10]); + qmp6988_data_.qmp6988_cali.COE_b12 = encode_value(&a_data_uint8_tr[12]); + qmp6988_data_.qmp6988_cali.COE_b21 = encode_value(&a_data_uint8_tr[14]); + qmp6988_data_.qmp6988_cali.COE_bp3 = encode_value(&a_data_uint8_tr[16]); ESP_LOGV(TAG, "Calibration data:\n" diff --git a/esphome/components/spd2010/spd2010_touchscreen.cpp b/esphome/components/spd2010/spd2010_touchscreen.cpp index 94fad87d7d4..55fa177d18e 100644 --- a/esphome/components/spd2010/spd2010_touchscreen.cpp +++ b/esphome/components/spd2010/spd2010_touchscreen.cpp @@ -88,7 +88,7 @@ void SPD2010Touchscreen::initialize_() { this->mark_failed(LOG_STR("Failed to read firmware version")); return; } - ESP_LOGD(TAG, "Firmware version: 0x%04X", encode_uint16(version[5], version[4])); + ESP_LOGD(TAG, "Firmware version: 0x%04X", encode_value_le(&version[4])); if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); @@ -104,7 +104,7 @@ bool SPD2010Touchscreen::finish_report_() { return false; if (status[5] == HDP_STATUS_DONE) return this->clear_interrupt_(); - const uint16_t length = encode_uint16(status[3], status[2]); + const uint16_t length = encode_value_le(&status[2]); if (status[5] != HDP_STATUS_MORE || length == 0 || length > MAX_REPORT_LENGTH) break; uint8_t remaining[MAX_REPORT_LENGTH]; @@ -125,7 +125,7 @@ bool SPD2010Touchscreen::read_data_() { } const uint8_t status_low = status[0]; const uint8_t status_high = status[1]; - const uint16_t length = encode_uint16(status[3], status[2]); + const uint16_t length = encode_value_le(&status[2]); if (status_high & STATUS_BIOS) { this->status_set_warning(LOG_STR("BIOS status; start CPU")); diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index fa96c6a0227..aee4fc9c204 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -1,3 +1,4 @@ +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "stts22h.h" @@ -48,7 +49,7 @@ float STTS22HComponent::read_temperature_() { // Combine the two bytes into a single 16-bit signed integer // The STTS22H temperature data is in two's complement format - int16_t temp_raw_value = static_cast(encode_uint16(temp_reg_value[1], temp_reg_value[0])); + int16_t temp_raw_value = encode_value_le(temp_reg_value); return temp_raw_value * SENSOR_SCALE; // Apply sensor resolution } diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 2b234983a20..15272e614fb 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -64,12 +64,12 @@ void TCA9555Component::loop() { bool TCA9555Component::read_gpio_outputs_() { if (this->is_failed()) return false; - uint8_t data[2]; - if (!this->read_bytes(TCA9555_OUTPUT_PORT_REGISTER_0, data, 2)) { + uint16_t value; + if (!this->read_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, &value)) { this->status_set_warning(LOG_STR("Failed to read output register")); return false; } - this->output_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); + this->output_mask_ = value; this->status_clear_warning(); return true; } @@ -77,13 +77,12 @@ bool TCA9555Component::read_gpio_outputs_() { bool TCA9555Component::read_gpio_modes_() { if (this->is_failed()) return false; - uint8_t data[2]; - bool success = this->read_bytes(TCA9555_CONFIGURATION_PORT_0, data, 2); - if (!success) { + uint16_t value; + if (!this->read_byte_16_le(TCA9555_CONFIGURATION_PORT_0, &value)) { this->status_set_warning(LOG_STR("Failed to read mode register")); return false; } - this->mode_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); + this->mode_mask_ = value; this->status_clear_warning(); return true; @@ -120,10 +119,7 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) { this->output_mask_ &= ~(1 << pin); } - uint8_t data[2]; - data[0] = this->output_mask_; - data[1] = this->output_mask_ >> 8; - if (!this->write_bytes(TCA9555_OUTPUT_PORT_REGISTER_0, data, 2)) { + if (!this->write_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, this->output_mask_)) { this->status_set_warning(LOG_STR("Failed to write output register")); return; } @@ -134,11 +130,7 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) { bool TCA9555Component::write_gpio_modes_() { if (this->is_failed()) return false; - uint8_t data[2]; - - data[0] = this->mode_mask_; - data[1] = this->mode_mask_ >> 8; - if (!this->write_bytes(TCA9555_CONFIGURATION_PORT_0, data, 2)) { + if (!this->write_byte_16_le(TCA9555_CONFIGURATION_PORT_0, this->mode_mask_)) { this->status_set_warning(LOG_STR("Failed to write mode register")); return false; } diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 979137a9729..a8497e7a446 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -177,20 +177,20 @@ void TCS34725Component::calculate_temperature_and_lux_(uint16_t r, uint16_t g, u } void TCS34725Component::update() { - uint8_t data[8]; // Buffer to hold the 8 bytes (2 bytes for each of the 4 channels) + uint16_t data[4]; // Clear, red, green and blue channels // Perform burst - if (this->read_register(TCS34725_REGISTER_CRGBDATAL, data, 8) != i2c::ERROR_OK) { + if (!this->read_bytes_16_le(TCS34725_REGISTER_CRGBDATAL, data, 4)) { this->status_set_warning(); ESP_LOGW(TAG, "Error reading TCS34725 sensor data"); return; } // Extract the data - uint16_t raw_c = encode_uint16(data[1], data[0]); // Clear channel - uint16_t raw_r = encode_uint16(data[3], data[2]); // Red channel - uint16_t raw_g = encode_uint16(data[5], data[4]); // Green channel - uint16_t raw_b = encode_uint16(data[7], data[6]); // Blue channel + uint16_t raw_c = data[0]; // Clear channel + uint16_t raw_r = data[1]; // Red channel + uint16_t raw_g = data[2]; // Green channel + uint16_t raw_b = data[3]; // Blue channel ESP_LOGV(TAG, "Raw values clear=%d red=%d green=%d blue=%d", raw_c, raw_r, raw_g, raw_b); diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp index 706213024d6..ac168327e09 100644 --- a/esphome/components/tfluna/tfluna.cpp +++ b/esphome/components/tfluna/tfluna.cpp @@ -94,10 +94,10 @@ bool TFLuna::read_data_() { // Layout: // buf[0..1] = distance (LE), buf[2..3] = signal (LE), // buf[4..5] = temperature (LE), buf[6..7] = timestamp (LE) - uint16_t distance = encode_uint16(buf[1], buf[0]); - uint16_t signal_strength = encode_uint16(buf[3], buf[2]); - int16_t temperature_raw = encode_uint16(buf[5], buf[4]); - uint16_t timestamp = encode_uint16(buf[7], buf[6]); + uint16_t distance = encode_value_le(buf); + uint16_t signal_strength = encode_value_le(&buf[2]); + int16_t temperature_raw = encode_value_le(&buf[4]); + uint16_t timestamp = encode_value_le(&buf[6]); if (timestamp == this->previous_timestamp_) { return false; diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index f887b7d32ca..0ae96c9fd1c 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -148,13 +148,7 @@ bool TSL2561Sensor::tsl2561_write_byte(uint8_t a_register, uint8_t value) { return this->write_byte(a_register | TSL2561_COMMAND_BIT, value); } bool TSL2561Sensor::tsl2561_read_uint(uint8_t a_register, uint16_t *value) { - uint8_t data[2]; - if (!this->read_bytes(a_register | TSL2561_COMMAND_BIT, data, 2)) - return false; - const uint16_t hi = data[1]; - const uint16_t lo = data[0]; - *value = (hi << 8) | lo; - return true; + return this->read_byte_16_le(a_register | TSL2561_COMMAND_BIT, value); } bool TSL2561Sensor::tsl2561_read_byte(uint8_t a_register, uint8_t *value) { return this->read_byte(a_register | TSL2561_COMMAND_BIT, value); diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 09340f3bdee..ffaaf9eb2a3 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -33,14 +33,14 @@ void VEML3235Sensor::setup() { } bool VEML3235Sensor::refresh_config_reg() { - uint16_t data = 0x1; // mandatory 1 per RM; shutdown bits cleared (device powered on) + uint16_t data = 0x100; // mandatory 1 per RM; shutdown bits cleared (device powered on) data |= (uint16_t(this->integration_time_) << CONFIG_REG_IT_BIT); data |= (uint16_t(this->digital_gain_) << CONFIG_REG_DG_BIT); data |= (uint16_t(this->gain_) << CONFIG_REG_G_BIT); ESP_LOGVV(TAG, "Writing 0x%.4x to register 0x%.2x", data, CONFIG_REG); - return this->write_byte_16(CONFIG_REG, data); + return this->write_byte_16_le(CONFIG_REG, data); } void VEML3235Sensor::update() { @@ -63,7 +63,7 @@ void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { this->status_clear_warning(); - uint16_t als_counts = encode_uint16(als_regs[1], als_regs[0]); + uint16_t als_counts = encode_value_le(als_regs); if (this->auto_gain_ && adjustments_left > 0) { // A sample integrated with the previous settings may still be in the data register after the diff --git a/esphome/components/veml3235/veml3235.h b/esphome/components/veml3235/veml3235.h index c19fc17b65f..fb00a30326d 100644 --- a/esphome/components/veml3235/veml3235.h +++ b/esphome/components/veml3235/veml3235.h @@ -15,13 +15,9 @@ static const uint8_t ID_REG = 0x09; // Bit offsets within CONFIG_REG // -// The device expects the low data byte first while write_byte_16() sends the high byte first, so the 16-bit -// configuration word used here is byte-swapped relative to the datasheet: datasheet low-byte bits are word -// bits 15:8 here and datasheet high-byte bits are word bits 7:0. -// -static const uint8_t CONFIG_REG_IT_BIT = 12; -static const uint8_t CONFIG_REG_DG_BIT = 5; -static const uint8_t CONFIG_REG_G_BIT = 3; +static const uint8_t CONFIG_REG_IT_BIT = 4; +static const uint8_t CONFIG_REG_DG_BIT = 13; +static const uint8_t CONFIG_REG_G_BIT = 11; // Other important constants // diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp index 11a1a569f3f..721300bae74 100644 --- a/esphome/components/xdb401/xdb401.cpp +++ b/esphome/components/xdb401/xdb401.cpp @@ -149,7 +149,7 @@ i2c::ErrorCode XDB401Component::read_temperature_(float &temperature) { ESP_LOGV(TAG, "Got temperature data: %s", temperature_buf); // Temperature is a signed 16-bit big-endian value in 1/256 °C (Q8.8 fixed point). - int16_t raw_temperature = static_cast(encode_uint16(t_data[0], t_data[1])); + int16_t raw_temperature = encode_value(t_data); ESP_LOGD(TAG, "Temperature data raw %i", raw_temperature); temperature = static_cast(raw_temperature) / 256.0f; From a801e414a06e683195d9e0fbc2c8c4b899e25508 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:32:06 -1000 Subject: [PATCH 117/120] [components] Set referenced components with cg.add_variable and register_parented (m-z) (#21023) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/mitsubishi_cn105/__init__.py | 3 +-- esphome/components/modbus/__init__.py | 3 +-- esphome/components/monochromatic/light.py | 3 +-- .../mqtt_subscribe/sensor/__init__.py | 3 +-- .../mqtt_subscribe/text_sensor/__init__.py | 3 +-- esphome/components/my9231/output.py | 3 +-- esphome/components/nextion/base_component.py | 20 +++++++----------- esphome/components/ntc/sensor.py | 3 +-- esphome/components/one_wire/__init__.py | 3 +-- esphome/components/opentherm/__init__.py | 5 +++-- esphome/components/output/__init__.py | 4 +--- esphome/components/output/button/__init__.py | 3 +-- esphome/components/output/lock/__init__.py | 3 +-- esphome/components/output/switch/__init__.py | 3 +-- esphome/components/pid/climate.py | 17 ++++----------- .../pvvx_mithermometer/display/__init__.py | 4 +--- esphome/components/remote_base/__init__.py | 3 +-- .../components/resampler/speaker/__init__.py | 3 +-- esphome/components/resistance/sensor.py | 3 +-- esphome/components/rgb/light.py | 9 +++----- esphome/components/rgbct/light.py | 16 +++++--------- esphome/components/rgbw/light.py | 12 ++++------- esphome/components/rgbww/light.py | 16 +++++--------- esphome/components/router/speaker/__init__.py | 3 +-- .../components/rp2_ble_tracker/__init__.py | 3 +-- esphome/components/rtttl/__init__.py | 7 ++----- .../components/safe_mode/button/__init__.py | 3 +-- .../components/safe_mode/switch/__init__.py | 3 +-- esphome/components/scd4x/sensor.py | 7 ++++--- esphome/components/sen6x/sensor.py | 9 ++++---- esphome/components/sendspin/__init__.py | 3 +-- esphome/components/serial_proxy/__init__.py | 6 ++---- esphome/components/servo/__init__.py | 3 +-- esphome/components/sgp30/sensor.py | 10 +++++---- esphome/components/sgp4x/sensor.py | 10 +++++---- esphome/components/sm16716/output.py | 3 +-- esphome/components/sm2135/output.py | 3 +-- esphome/components/sm2235/output.py | 3 +-- esphome/components/sm2335/output.py | 3 +-- esphome/components/speaker/__init__.py | 4 +--- .../speaker/media_player/__init__.py | 10 +++++---- esphome/components/spi/__init__.py | 3 +-- esphome/components/sprinkler/__init__.py | 6 +----- esphome/components/stcc4/sensor.py | 21 +++++++------------ esphome/components/sun/__init__.py | 3 +-- esphome/components/sun/sensor/__init__.py | 3 +-- .../components/sun/text_sensor/__init__.py | 3 +-- .../switch/binary_sensor/__init__.py | 3 +-- .../sx126x/packet_transport/__init__.py | 3 +-- .../sx127x/packet_transport/__init__.py | 3 +-- esphome/components/thermostat/climate.py | 4 +--- esphome/components/tm1638/output/__init__.py | 3 +-- esphome/components/tm1638/switch/__init__.py | 3 +-- .../components/total_daily_energy/sensor.py | 6 ++---- esphome/components/touchscreen/__init__.py | 3 +-- .../touchscreen/binary_sensor/__init__.py | 3 +-- esphome/components/tuya/__init__.py | 3 +-- .../components/tuya/binary_sensor/__init__.py | 3 +-- esphome/components/tuya/climate/__init__.py | 3 +-- esphome/components/tuya/cover/__init__.py | 3 +-- esphome/components/tuya/light/__init__.py | 3 +-- esphome/components/tuya/number/__init__.py | 3 +-- esphome/components/tuya/select/__init__.py | 3 +-- esphome/components/tuya/sensor/__init__.py | 3 +-- esphome/components/tuya/switch/__init__.py | 3 +-- .../components/tuya/text_sensor/__init__.py | 3 +-- .../components/tuya/water_heater/__init__.py | 3 +-- esphome/components/ufire_ec/sensor.py | 6 +++--- esphome/components/ufire_ise/sensor.py | 6 +++--- esphome/components/uponor_smatrix/__init__.py | 3 +-- esphome/components/uptime/sensor/__init__.py | 3 +-- .../components/voice_assistant/__init__.py | 14 +++---------- esphome/components/wireguard/__init__.py | 2 +- esphome/components/yashima/climate.py | 8 ++----- esphome/components/zigbee/time/__init__.py | 3 +-- 75 files changed, 138 insertions(+), 248 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index ba51bd3a806..4037dd14d1f 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -85,8 +85,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_MITSUBISHI_CN105_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MITSUBISHI_CN105_ID]) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 8c079afbac3..a1f15aab452 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -382,8 +382,7 @@ def final_validate_modbus_device( async def register_modbus_client_device(var: MockObj, config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_MODBUS_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MODBUS_ID]) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/monochromatic/light.py b/esphome/components/monochromatic/light.py index 04336bcde1a..40e793a6058 100644 --- a/esphome/components/monochromatic/light.py +++ b/esphome/components/monochromatic/light.py @@ -21,5 +21,4 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/mqtt_subscribe/sensor/__init__.py b/esphome/components/mqtt_subscribe/sensor/__init__.py index e451d2db8ad..38eb47654ad 100644 --- a/esphome/components/mqtt_subscribe/sensor/__init__.py +++ b/esphome/components/mqtt_subscribe/sensor/__init__.py @@ -33,7 +33,6 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_MQTT_PARENT_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MQTT_PARENT_ID]) cg.add(var.set_topic(config[CONF_TOPIC])) cg.add(var.set_qos(config[CONF_QOS])) diff --git a/esphome/components/mqtt_subscribe/text_sensor/__init__.py b/esphome/components/mqtt_subscribe/text_sensor/__init__.py index d432ef8a931..5b195778c55 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/__init__.py +++ b/esphome/components/mqtt_subscribe/text_sensor/__init__.py @@ -31,7 +31,6 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_MQTT_PARENT_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MQTT_PARENT_ID]) cg.add(var.set_topic(config[CONF_TOPIC])) cg.add(var.set_qos(config[CONF_QOS])) diff --git a/esphome/components/my9231/output.py b/esphome/components/my9231/output.py index c2ec15d411b..1516640e0e7 100644 --- a/esphome/components/my9231/output.py +++ b/esphome/components/my9231/output.py @@ -24,6 +24,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_MY9231_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MY9231_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 17c1b263ed8..442eb979158 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -116,21 +116,17 @@ async def setup_component_core_(var, config, arg): ) ) - if CONF_BACKGROUND_COLOR in config: - color_component = await cg.get_variable(config[CONF_BACKGROUND_COLOR]) - cg.add(var.set_background_color(color_component)) + await cg.add_variable(var.set_background_color, config.get(CONF_BACKGROUND_COLOR)) - if CONF_BACKGROUND_PRESSED_COLOR in config: - color_component = await cg.get_variable(config[CONF_BACKGROUND_PRESSED_COLOR]) - cg.add(var.set_background_pressed_color(color_component)) + await cg.add_variable( + var.set_background_pressed_color, config.get(CONF_BACKGROUND_PRESSED_COLOR) + ) - if CONF_FOREGROUND_COLOR in config: - color_component = await cg.get_variable(config[CONF_FOREGROUND_COLOR]) - cg.add(var.set_foreground_color(color_component)) + await cg.add_variable(var.set_foreground_color, config.get(CONF_FOREGROUND_COLOR)) - if CONF_FOREGROUND_PRESSED_COLOR in config: - color_component = await cg.get_variable(config[CONF_FOREGROUND_PRESSED_COLOR]) - cg.add(var.set_foreground_pressed_color(color_component)) + await cg.add_variable( + var.set_foreground_pressed_color, config.get(CONF_FOREGROUND_PRESSED_COLOR) + ) if CONF_FONT_ID in config: cg.add(var.set_font_id(config[CONF_FONT_ID])) diff --git a/esphome/components/ntc/sensor.py b/esphome/components/ntc/sensor.py index 6c2cb699901..9ac69b595f6 100644 --- a/esphome/components/ntc/sensor.py +++ b/esphome/components/ntc/sensor.py @@ -138,8 +138,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) calib = config[CONF_CALIBRATION] cg.add(var.set_a(calib[CONF_A])) cg.add(var.set_b(calib[CONF_B])) diff --git a/esphome/components/one_wire/__init__.py b/esphome/components/one_wire/__init__.py index 69368a990ad..4a8edc656e2 100644 --- a/esphome/components/one_wire/__init__.py +++ b/esphome/components/one_wire/__init__.py @@ -37,8 +37,7 @@ async def register_one_wire_device(var: MockObj, config: ConfigType) -> None: This is a coroutine, you need to await it with an 'await' expression! """ - parent = await cg.get_variable(config[CONF_ONE_WIRE_ID]) - cg.add(var.set_one_wire_bus(parent)) + await cg.add_variable(var.set_one_wire_bus, config[CONF_ONE_WIRE_ID]) if (address := config.get(CONF_ADDRESS)) is not None: cg.add(var.set_address(address)) if (index := config.get(CONF_INDEX)) is not None: diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 3772034ab63..309d35aac19 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -103,8 +103,9 @@ async def to_code(config: dict[str, Any]) -> None: if key in non_sensors: continue if key in schema.INPUTS: - input_sensor = await cg.get_variable(value) - cg.add(getattr(var, f"set_{key}_{const.INPUT_SENSOR}")(input_sensor)) + await cg.add_variable( + getattr(var, f"set_{key}_{const.INPUT_SENSOR}"), value + ) input_sensors.append(key) elif key in schema.SETTINGS: if value == schema.SETTINGS[key].default_value: diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 1b794037848..ff7f55586da 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -59,9 +59,7 @@ FloatOutputPtr = FloatOutput.operator("ptr") async def setup_output_platform_(obj, config): if CONF_INVERTED in config: cg.add(obj.set_inverted(config[CONF_INVERTED])) - if CONF_POWER_SUPPLY in config: - power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) - cg.add(obj.set_power_supply(power_supply_)) + await cg.add_variable(obj.set_power_supply, config.get(CONF_POWER_SUPPLY)) # The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when # the config matches them. The define stays whenever the key is present because # platforms such as ac_dimmer read the scaling fields directly. diff --git a/esphome/components/output/button/__init__.py b/esphome/components/output/button/__init__.py index 1d14fa5755f..95ad18a56b9 100644 --- a/esphome/components/output/button/__init__.py +++ b/esphome/components/output/button/__init__.py @@ -23,8 +23,7 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_duration(config[CONF_DURATION])) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) await cg.register_component(var, config) await button.register_button(var, config) diff --git a/esphome/components/output/lock/__init__.py b/esphome/components/output/lock/__init__.py index 553114b689c..ffca1a5c47c 100644 --- a/esphome/components/output/lock/__init__.py +++ b/esphome/components/output/lock/__init__.py @@ -22,5 +22,4 @@ async def to_code(config): var = await lock.new_lock(config) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/output/switch/__init__.py b/esphome/components/output/switch/__init__.py index e896b183cad..6d46f8b2d5e 100644 --- a/esphome/components/output/switch/__init__.py +++ b/esphome/components/output/switch/__init__.py @@ -25,5 +25,4 @@ async def to_code(config): var = await switch.new_switch(config) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 1527fa6f354..a6b5641eb70 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -100,19 +100,10 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) - - if CONF_HUMIDITY_SENSOR in config: - sens = await cg.get_variable(config[CONF_HUMIDITY_SENSOR]) - cg.add(var.set_humidity_sensor(sens)) - - if CONF_COOL_OUTPUT in config: - out = await cg.get_variable(config[CONF_COOL_OUTPUT]) - cg.add(var.set_cool_output(out)) - if CONF_HEAT_OUTPUT in config: - out = await cg.get_variable(config[CONF_HEAT_OUTPUT]) - cg.add(var.set_heat_output(out)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) + await cg.add_variable(var.set_cool_output, config.get(CONF_COOL_OUTPUT)) + await cg.add_variable(var.set_heat_output, config.get(CONF_HEAT_OUTPUT)) params = config[CONF_CONTROL_PARAMETERS] cg.add(var.set_kp(params[CONF_KP])) cg.add(var.set_ki(params[CONF_KI])) diff --git a/esphome/components/pvvx_mithermometer/display/__init__.py b/esphome/components/pvvx_mithermometer/display/__init__.py index 1b8c0b5be30..dcbb749d400 100644 --- a/esphome/components/pvvx_mithermometer/display/__init__.py +++ b/esphome/components/pvvx_mithermometer/display/__init__.py @@ -44,8 +44,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auto_clear(config[CONF_AUTO_CLEAR_ENABLED])) cg.add(var.set_validity_period(config[CONF_VALIDITY_PERIOD].total_seconds)) - if CONF_TIME_ID in config: - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.add_variable(var.set_time, config.get(CONF_TIME_ID)) await display.add_writer(var, config, PVVXDisplayRef) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 990d2b87e4d..b01b0f3645e 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -154,8 +154,7 @@ async def attach_receiver( async def register_transmittable(var, config, key: str = CONF_TRANSMITTER_ID): - transmitter_ = await cg.get_variable(config[key]) - cg.add(var.set_transmitter(transmitter_)) + await cg.add_variable(var.set_transmitter, config[key]) # Registry names that share a protocol source file diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 8fa8aeb61c4..5b82f149799 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -86,8 +86,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await speaker.register_speaker(var, config) - output_spkr = await cg.get_variable(config[CONF_OUTPUT_SPEAKER]) - cg.add(var.set_output_speaker(output_spkr)) + await cg.add_variable(var.set_output_speaker, config[CONF_OUTPUT_SPEAKER]) cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) diff --git a/esphome/components/resistance/sensor.py b/esphome/components/resistance/sensor.py index cbf329713df..c76d910df53 100644 --- a/esphome/components/resistance/sensor.py +++ b/esphome/components/resistance/sensor.py @@ -43,8 +43,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) cg.add(var.set_configuration(config[CONF_CONFIGURATION])) cg.add(var.set_resistor(config[CONF_RESISTOR])) cg.add(var.set_reference_voltage(config[CONF_REFERENCE_VOLTAGE])) diff --git a/esphome/components/rgb/light.py b/esphome/components/rgb/light.py index cd553292569..0a1ec2eddef 100644 --- a/esphome/components/rgb/light.py +++ b/esphome/components/rgb/light.py @@ -21,9 +21,6 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) diff --git a/esphome/components/rgbct/light.py b/esphome/components/rgbct/light.py index 00626ec6ba2..43e31a011bf 100644 --- a/esphome/components/rgbct/light.py +++ b/esphome/components/rgbct/light.py @@ -42,17 +42,11 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) - - color_temp = await cg.get_variable(config[CONF_COLOR_TEMPERATURE]) - cg.add(var.set_color_temperature(color_temp)) - white_brightness = await cg.get_variable(config[CONF_WHITE_BRIGHTNESS]) - cg.add(var.set_white_brightness(white_brightness)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) + await cg.add_variable(var.set_color_temperature, config[CONF_COLOR_TEMPERATURE]) + await cg.add_variable(var.set_white_brightness, config[CONF_WHITE_BRIGHTNESS]) cg.add(var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE])) cg.add(var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE])) diff --git a/esphome/components/rgbw/light.py b/esphome/components/rgbw/light.py index 7aedf4affc3..2875eba1978 100644 --- a/esphome/components/rgbw/light.py +++ b/esphome/components/rgbw/light.py @@ -30,12 +30,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) - white = await cg.get_variable(config[CONF_WHITE]) - cg.add(var.set_white(white)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) + await cg.add_variable(var.set_white, config[CONF_WHITE]) cg.add(var.set_color_interlock(config[CONF_COLOR_INTERLOCK])) diff --git a/esphome/components/rgbww/light.py b/esphome/components/rgbww/light.py index 3626a57b8c1..8881af6af45 100644 --- a/esphome/components/rgbww/light.py +++ b/esphome/components/rgbww/light.py @@ -45,22 +45,16 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) - - cwhite = await cg.get_variable(config[CONF_COLD_WHITE]) - cg.add(var.set_cold_white(cwhite)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) + await cg.add_variable(var.set_cold_white, config[CONF_COLD_WHITE]) if CONF_COLD_WHITE_COLOR_TEMPERATURE in config: cg.add( var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE]) ) - wwhite = await cg.get_variable(config[CONF_WARM_WHITE]) - cg.add(var.set_warm_white(wwhite)) + await cg.add_variable(var.set_warm_white, config[CONF_WARM_WHITE]) if CONF_WARM_WHITE_COLOR_TEMPERATURE in config: cg.add( var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE]) diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py index f568aedcbdf..90e9febed5c 100644 --- a/esphome/components/router/speaker/__init__.py +++ b/esphome/components/router/speaker/__init__.py @@ -86,8 +86,7 @@ async def to_code(config: ConfigType) -> None: speakers = config[CONF_OUTPUT_SPEAKERS] cg.add(var.set_output_count(len(speakers))) for spk_id in speakers: - spk = await cg.get_variable(spk_id) - cg.add(var.add_output(spk)) + await cg.add_variable(var.add_output, spk_id) automation.register_apply_action( diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 4a3d3f788bf..fdfc2a1f967 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -95,8 +95,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_RP2040_BLE_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_RP2040_BLE_ID]) # The tracker registers itself as a controller scan listener in setup(); # request the codegen-sized StaticVector slot for it. rp2040_ble.request_scan_listener_slot() diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 8bff8bb58dc..c987e0327c5 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -84,13 +84,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) if CONF_OUTPUT in config: - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) cg.add_define("USE_OUTPUT") - if CONF_SPEAKER in config: - out = await cg.get_variable(config[CONF_SPEAKER]) - cg.add(var.set_speaker(out)) + await cg.add_variable(var.set_speaker, config.get(CONF_SPEAKER)) cg.add(var.set_gain(config[CONF_GAIN])) diff --git a/esphome/components/safe_mode/button/__init__.py b/esphome/components/safe_mode/button/__init__.py index 89e24757996..d0ef080f10e 100644 --- a/esphome/components/safe_mode/button/__init__.py +++ b/esphome/components/safe_mode/button/__init__.py @@ -31,5 +31,4 @@ async def to_code(config: ConfigType) -> None: var = await button.new_button(config) await cg.register_component(var, config) - safe_mode_component = await cg.get_variable(config[CONF_SAFE_MODE]) - cg.add(var.set_safe_mode(safe_mode_component)) + await cg.add_variable(var.set_safe_mode, config[CONF_SAFE_MODE]) diff --git a/esphome/components/safe_mode/switch/__init__.py b/esphome/components/safe_mode/switch/__init__.py index 529b023d689..432d7a76154 100644 --- a/esphome/components/safe_mode/switch/__init__.py +++ b/esphome/components/safe_mode/switch/__init__.py @@ -26,5 +26,4 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - safe_mode_component = await cg.get_variable(config[CONF_SAFE_MODE]) - cg.add(var.set_safe_mode(safe_mode_component)) + await cg.add_variable(var.set_safe_mode, config[CONF_SAFE_MODE]) diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 12a07a3f63a..4b67319efb1 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -112,9 +112,10 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(config[key]) cg.add(getattr(var, funcName)(sens)) - if CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE in config: - sens = await cg.get_variable(config[CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE]) - cg.add(var.set_ambient_pressure_source(sens)) + await cg.add_variable( + var.set_ambient_pressure_source, + config.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), + ) cg.add(var.set_measurement_mode(config[CONF_MEASUREMENT_MODE])) diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index b928f97774e..d961d05bc85 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -326,11 +326,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_altitude_compensation(altitude)) if (pressure := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION)) is not None: cg.add(var.set_ambient_pressure_compensation(pressure)) - if ( - source := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE) - ) is not None: - sens = await cg.get_variable(source) - cg.add(var.set_ambient_pressure_source(sens)) + await cg.add_variable( + var.set_ambient_pressure_source, + co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), + ) SEN6X_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index c557a7960d2..3caad51f919 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -428,8 +428,7 @@ async def to_code(config: ConfigType) -> None: # Service starts disabled and the hub enables it; always advertised where unsupported if mdns.request_service_enable_disable(): - mdns_var = await cg.get_variable(CORE.config[MDNS_DOMAIN][CONF_ID]) - cg.add(var.set_mdns(mdns_var)) + await cg.add_variable(var.set_mdns, CORE.config[MDNS_DOMAIN][CONF_ID]) data = _get_data() diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 27d69c97346..d1b4e889066 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -101,12 +101,10 @@ async def to_code(config: ConfigType) -> None: # port_type names the electrical interface (a USB RS485 adapter is RS485), so every # usb_uart channel reports USB identity whatever port type it declares if is_usb_uart_channel(config[CONF_UART_ID], CORE.config): - channel = await cg.get_variable(config[CONF_UART_ID]) - cg.add(var.set_usb_channel(channel)) + await cg.add_variable(var.set_usb_channel, config[CONF_UART_ID]) 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)) + await cg.add_variable(var.set_host_uart, config[CONF_UART_ID]) cg.add_define("USE_SERIAL_PROXY_HOST_UART") cg.add_define("USE_SERIAL_PROXY") diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index 44ec294765b..fdfb892d3e1 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -43,8 +43,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) cg.add(var.set_min_level(config[CONF_MIN_LEVEL])) cg.add(var.set_idle_level(config[CONF_IDLE_LEVEL])) cg.add(var.set_max_level(config[CONF_MAX_LEVEL])) diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index 5d4c43fccae..87b0d3aa2c0 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -98,7 +98,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_tvoc_baseline(baseline_config[CONF_TVOC_BASELINE])) if compensation_config := config.get(CONF_COMPENSATION): - sens = await cg.get_variable(compensation_config[CONF_HUMIDITY_SOURCE]) - cg.add(var.set_humidity_sensor(sens)) - sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE_SOURCE]) - cg.add(var.set_temperature_sensor(sens)) + await cg.add_variable( + var.set_humidity_sensor, compensation_config[CONF_HUMIDITY_SOURCE] + ) + await cg.add_variable( + var.set_temperature_sensor, compensation_config[CONF_TEMPERATURE_SOURCE] + ) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 866445f2464..78792c80102 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -103,10 +103,12 @@ async def to_code(config: ConfigType) -> None: if CONF_COMPENSATION in config: compensation_config = config[CONF_COMPENSATION] - sens = await cg.get_variable(compensation_config[CONF_HUMIDITY_SOURCE]) - cg.add(var.set_humidity_sensor(sens)) - sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE_SOURCE]) - cg.add(var.set_temperature_sensor(sens)) + await cg.add_variable( + var.set_humidity_sensor, compensation_config[CONF_HUMIDITY_SOURCE] + ) + await cg.add_variable( + var.set_temperature_sensor, compensation_config[CONF_TEMPERATURE_SOURCE] + ) cg.add(var.set_store_baseline(config[CONF_STORE_BASELINE])) diff --git a/esphome/components/sm16716/output.py b/esphome/components/sm16716/output.py index 08cec24fd64..c4f6981c04e 100644 --- a/esphome/components/sm16716/output.py +++ b/esphome/components/sm16716/output.py @@ -24,6 +24,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM16716_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM16716_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/sm2135/output.py b/esphome/components/sm2135/output.py index 80b4cb1a4f2..b7a49374045 100644 --- a/esphome/components/sm2135/output.py +++ b/esphome/components/sm2135/output.py @@ -25,6 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM2135_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM2135_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/sm2235/output.py b/esphome/components/sm2235/output.py index 29940a651e8..be590a3bd95 100644 --- a/esphome/components/sm2235/output.py +++ b/esphome/components/sm2235/output.py @@ -25,6 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM2235_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM2235_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/sm2335/output.py b/esphome/components/sm2335/output.py index 30f653b087a..ce047cbb96e 100644 --- a/esphome/components/sm2335/output.py +++ b/esphome/components/sm2335/output.py @@ -25,6 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM2335_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM2335_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index d34cfc32c0f..e1df66f6841 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -22,9 +22,7 @@ PlayAction = speaker_ns.class_( async def setup_speaker_core_(var, config): - if audio_dac_config := config.get(CONF_AUDIO_DAC): - aud_dac = await cg.get_variable(audio_dac_config) - cg.add(var.set_audio_dac(aud_dac)) + await cg.add_variable(var.set_audio_dac, config.get(CONF_AUDIO_DAC)) async def register_speaker(var, config): diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index b37a288d874..83f406e9e65 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -184,8 +184,9 @@ async def to_code(config): cg.add(var.set_volume_min(config[CONF_VOLUME_MIN])) announcement_pipeline_config = config[CONF_ANNOUNCEMENT_PIPELINE] - spkr = await cg.get_variable(announcement_pipeline_config[CONF_SPEAKER]) - cg.add(var.set_announcement_speaker(spkr)) + await cg.add_variable( + var.set_announcement_speaker, announcement_pipeline_config[CONF_SPEAKER] + ) if announcement_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_announcement_format( @@ -196,8 +197,9 @@ async def to_code(config): ) if media_pipeline_config := config.get(CONF_MEDIA_PIPELINE): - spkr = await cg.get_variable(media_pipeline_config[CONF_SPEAKER]) - cg.add(var.set_media_speaker(spkr)) + await cg.add_variable( + var.set_media_speaker, media_pipeline_config[CONF_SPEAKER] + ) if media_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_media_format( diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 151f228651e..8ff1842a7c1 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -484,8 +484,7 @@ def spi_device_schema( async def register_spi_device( var: cg.MockObj, config: ConfigType, write_only: bool = False ) -> None: - parent = await cg.get_variable(config[CONF_SPI_ID]) - cg.add(var.set_spi_parent(parent)) + await cg.add_variable(var.set_spi_parent, config[CONF_SPI_ID]) if write_only: cg.add(var.set_write_only(True)) await cg.add_pin(var.set_cs_pin, config.get(CONF_CS_PIN)) diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index fe096514d07..557b24505bd 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -733,8 +733,4 @@ async def to_code(config): var = await cg.get_variable(sprinkler_controller[CONF_ID]) for controller_to_add in config: if sprinkler_controller[CONF_ID] != controller_to_add[CONF_ID]: - cg.add( - var.add_controller( - await cg.get_variable(controller_to_add[CONF_ID]) - ) - ) + await cg.add_variable(var.add_controller, controller_to_add[CONF_ID]) diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py index b103aa39534..4ea1bc6ed81 100644 --- a/esphome/components/stcc4/sensor.py +++ b/esphome/components/stcc4/sensor.py @@ -107,13 +107,11 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) await sensors(CONF_HUMIDITY, var.set_humidity_sensor) - if (temperature_source := config.get(CONF_TEMPERATURE_SOURCE)) is not None: - sens = await cg.get_variable(temperature_source) - cg.add(var.set_temperature_source(sens)) + await cg.add_variable( + var.set_temperature_source, config.get(CONF_TEMPERATURE_SOURCE) + ) - if (humidity_source := config.get(CONF_HUMIDITY_SOURCE)) is not None: - sens = await cg.get_variable(humidity_source) - cg.add(var.set_humidity_source(sens)) + await cg.add_variable(var.set_humidity_source, config.get(CONF_HUMIDITY_SOURCE)) if ( ambient_pressure_compensation := config.get(CONF_AMBIENT_PRESSURE_COMPENSATION) @@ -124,12 +122,9 @@ async def to_code(config: ConfigType) -> None: ) ) - if ( - ambient_pressure_compensation_source := config.get( - CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE - ) - ) is not None: - sens = await cg.get_variable(ambient_pressure_compensation_source) - cg.add(var.set_ambient_pressure_source(sens)) + await cg.add_variable( + var.set_ambient_pressure_source, + config.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), + ) cg.add(var.set_measurement_mode(config[CONF_MEASUREMENT_MODE])) diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index 71441e97823..c104316d475 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -118,8 +118,7 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.add_variable(var.set_time, config[CONF_TIME_ID]) cg.add(var.set_latitude(config[CONF_LATITUDE])) cg.add(var.set_longitude(config[CONF_LONGITUDE])) diff --git a/esphome/components/sun/sensor/__init__.py b/esphome/components/sun/sensor/__init__.py index ef4e51d5e5e..b6981422a05 100644 --- a/esphome/components/sun/sensor/__init__.py +++ b/esphome/components/sun/sensor/__init__.py @@ -40,5 +40,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_type(config[CONF_TYPE])) - paren = await cg.get_variable(config[CONF_SUN_ID]) - cg.add(var.set_parent(paren)) + await cg.register_parented(var, config[CONF_SUN_ID]) diff --git a/esphome/components/sun/text_sensor/__init__.py b/esphome/components/sun/text_sensor/__init__.py index 523471bd41e..b7db7cd39d9 100644 --- a/esphome/components/sun/text_sensor/__init__.py +++ b/esphome/components/sun/text_sensor/__init__.py @@ -53,8 +53,7 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_SUN_ID]) - cg.add(var.set_parent(paren)) + await cg.register_parented(var, config[CONF_SUN_ID]) cg.add(var.set_sunrise(SUN_TYPES[config[CONF_TYPE]])) cg.add(var.set_elevation(config[CONF_ELEVATION])) cg.add(var.set_format(config[CONF_FORMAT])) diff --git a/esphome/components/switch/binary_sensor/__init__.py b/esphome/components/switch/binary_sensor/__init__.py index 61ca1a14a24..ece53c0deb3 100644 --- a/esphome/components/switch/binary_sensor/__init__.py +++ b/esphome/components/switch/binary_sensor/__init__.py @@ -27,5 +27,4 @@ async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/sx126x/packet_transport/__init__.py b/esphome/components/sx126x/packet_transport/__init__.py index fc3bbf6df25..0c3ec56fcc0 100644 --- a/esphome/components/sx126x/packet_transport/__init__.py +++ b/esphome/components/sx126x/packet_transport/__init__.py @@ -23,5 +23,4 @@ CONFIG_SCHEMA = transport_schema(SX126xTransport).extend( async def to_code(config: ConfigType) -> None: var, _ = await new_packet_transport(config) - sx126x = await cg.get_variable(config[CONF_SX126X_ID]) - cg.add(var.set_parent(sx126x)) + await cg.register_parented(var, config[CONF_SX126X_ID]) diff --git a/esphome/components/sx127x/packet_transport/__init__.py b/esphome/components/sx127x/packet_transport/__init__.py index 33204a7d83d..0ec1a0f6950 100644 --- a/esphome/components/sx127x/packet_transport/__init__.py +++ b/esphome/components/sx127x/packet_transport/__init__.py @@ -23,5 +23,4 @@ CONFIG_SCHEMA = transport_schema(SX127xTransport).extend( async def to_code(config: ConfigType) -> None: var, _ = await new_packet_transport(config) - sx127x = await cg.get_variable(config[CONF_SX127X_ID]) - cg.add(var.set_parent(sx127x)) + await cg.register_parented(var, config[CONF_SX127X_ID]) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index bebdd7d0660..3e6d63adbb7 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -716,9 +716,7 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_sensor(sens)) - if CONF_HUMIDITY_SENSOR in config: - sens = await cg.get_variable(config[CONF_HUMIDITY_SENSOR]) - cg.add(var.set_humidity_sensor(sens)) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) cg.add(var.set_cool_deadband(config[CONF_COOL_DEADBAND])) cg.add(var.set_cool_overrun(config[CONF_COOL_OVERRUN])) diff --git a/esphome/components/tm1638/output/__init__.py b/esphome/components/tm1638/output/__init__.py index 961abfee476..4b85e73c88b 100644 --- a/esphome/components/tm1638/output/__init__.py +++ b/esphome/components/tm1638/output/__init__.py @@ -23,5 +23,4 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) await cg.register_component(var, config) cg.add(var.set_lednum(config[CONF_LED])) - hub = await cg.get_variable(config[CONF_TM1638_ID]) - cg.add(var.set_tm1638(hub)) + await cg.add_variable(var.set_tm1638, config[CONF_TM1638_ID]) diff --git a/esphome/components/tm1638/switch/__init__.py b/esphome/components/tm1638/switch/__init__.py index f42b835e039..eee55dc118e 100644 --- a/esphome/components/tm1638/switch/__init__.py +++ b/esphome/components/tm1638/switch/__init__.py @@ -25,5 +25,4 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) cg.add(var.set_lednum(config[CONF_LED])) - hub = await cg.get_variable(config[CONF_TM1638_ID]) - cg.add(var.set_tm1638(hub)) + await cg.add_variable(var.set_tm1638, config[CONF_TM1638_ID]) diff --git a/esphome/components/total_daily_energy/sensor.py b/esphome/components/total_daily_energy/sensor.py index af2f5ff561c..304688de07a 100644 --- a/esphome/components/total_daily_energy/sensor.py +++ b/esphome/components/total_daily_energy/sensor.py @@ -83,9 +83,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_POWER_ID]) - cg.add(var.set_parent(sens)) - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.register_parented(var, config[CONF_POWER_ID]) + await cg.add_variable(var.set_time, config[CONF_TIME_ID]) cg.add(var.set_restore(config[CONF_RESTORE])) cg.add(var.set_method(config[CONF_METHOD])) diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index 7a23d140690..36679e828b7 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -155,8 +155,7 @@ TOUCHSCREEN_SCHEMA = touchscreen_schema(cv.UNDEFINED) async def register_touchscreen(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - disp = await cg.get_variable(config[CONF_DISPLAY]) - cg.add(var.set_display(disp)) + await cg.add_variable(var.set_display, config[CONF_DISPLAY]) if CONF_TOUCH_TIMEOUT in config: cg.add(var.set_touch_timeout(config[CONF_TOUCH_TIMEOUT])) diff --git a/esphome/components/touchscreen/binary_sensor/__init__.py b/esphome/components/touchscreen/binary_sensor/__init__.py index 6a66d00ea6c..fccaf26c39a 100644 --- a/esphome/components/touchscreen/binary_sensor/__init__.py +++ b/esphome/components/touchscreen/binary_sensor/__init__.py @@ -83,5 +83,4 @@ async def to_code(config: ConfigType) -> None: ) for page_id in config.get(CONF_PAGES, []): - page = await cg.get_variable(page_id) - cg.add(var.add_page(page)) + await cg.add_variable(var.add_page, page_id) diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index 4a334e3e518..0ec7452e379 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -85,8 +85,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await uart.new_uart_component(config) if (time_id := config.get(CONF_TIME_ID)) is not None: - time_ = await cg.get_variable(time_id) - cg.add(var.set_time_id(time_)) + await cg.add_variable(var.set_time_id, time_id) if config[CONF_FORCE_TIME_SYNC]: cg.add(var.set_force_time_sync(True)) await cg.add_pin(var.set_status_pin, config.get(CONF_STATUS_PIN)) diff --git a/esphome/components/tuya/binary_sensor/__init__.py b/esphome/components/tuya/binary_sensor/__init__.py index 767d1c636e5..049386461af 100644 --- a/esphome/components/tuya/binary_sensor/__init__.py +++ b/esphome/components/tuya/binary_sensor/__init__.py @@ -28,7 +28,6 @@ async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_sensor_id(config[CONF_SENSOR_DATAPOINT])) diff --git a/esphome/components/tuya/climate/__init__.py b/esphome/components/tuya/climate/__init__.py index 91ef9a6a68a..65045e2cd27 100644 --- a/esphome/components/tuya/climate/__init__.py +++ b/esphome/components/tuya/climate/__init__.py @@ -196,8 +196,7 @@ async def to_code(config): var = await climate.new_climate(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) diff --git a/esphome/components/tuya/cover/__init__.py b/esphome/components/tuya/cover/__init__.py index 6e1daef884a..c93fdf55445 100644 --- a/esphome/components/tuya/cover/__init__.py +++ b/esphome/components/tuya/cover/__init__.py @@ -69,5 +69,4 @@ async def to_code(config): cg.add(var.set_invert_position(config[CONF_INVERT_POSITION])) cg.add(var.set_invert_position_report(config[CONF_INVERT_POSITION_REPORT])) cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) diff --git a/esphome/components/tuya/light/__init__.py b/esphome/components/tuya/light/__init__.py index 5ba55b6ccbb..9811995ad0c 100644 --- a/esphome/components/tuya/light/__init__.py +++ b/esphome/components/tuya/light/__init__.py @@ -112,5 +112,4 @@ async def to_code(config): ) cg.add(var.set_color_interlock(config[CONF_COLOR_INTERLOCK])) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) diff --git a/esphome/components/tuya/number/__init__.py b/esphome/components/tuya/number/__init__.py index 7a61e69c5cf..dcc2c15347b 100644 --- a/esphome/components/tuya/number/__init__.py +++ b/esphome/components/tuya/number/__init__.py @@ -85,8 +85,7 @@ async def to_code(config): ) cg.add(var.set_write_multiply(config[CONF_MULTIPLY])) - parent = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(parent)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_number_id(config[CONF_NUMBER_DATAPOINT])) if hidden_config := config.get(CONF_DATAPOINT_HIDDEN): diff --git a/esphome/components/tuya/select/__init__.py b/esphome/components/tuya/select/__init__.py index 9f2b6f1e12a..84819f8f497 100644 --- a/esphome/components/tuya/select/__init__.py +++ b/esphome/components/tuya/select/__init__.py @@ -52,8 +52,7 @@ async def to_code(config): var = await select.new_select(config, options=list(options_map.values())) await cg.register_component(var, config) cg.add(var.set_select_mappings(list(options_map.keys()))) - parent = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(parent)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) if (enum_datapoint := config.get(CONF_ENUM_DATAPOINT, None)) is not None: cg.add(var.set_select_id(enum_datapoint, False)) if (int_datapoint := config.get(CONF_INT_DATAPOINT, None)) is not None: diff --git a/esphome/components/tuya/sensor/__init__.py b/esphome/components/tuya/sensor/__init__.py index 861bbc656aa..191f1095e6d 100644 --- a/esphome/components/tuya/sensor/__init__.py +++ b/esphome/components/tuya/sensor/__init__.py @@ -27,7 +27,6 @@ async def to_code(config): await cg.register_component(var, config) await sensor.register_sensor(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_sensor_id(config[CONF_SENSOR_DATAPOINT])) diff --git a/esphome/components/tuya/switch/__init__.py b/esphome/components/tuya/switch/__init__.py index e9987050cb0..fc43b39b9c0 100644 --- a/esphome/components/tuya/switch/__init__.py +++ b/esphome/components/tuya/switch/__init__.py @@ -26,7 +26,6 @@ async def to_code(config): var = await switch.new_switch(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) diff --git a/esphome/components/tuya/text_sensor/__init__.py b/esphome/components/tuya/text_sensor/__init__.py index 3bae109b93a..af84d32f124 100644 --- a/esphome/components/tuya/text_sensor/__init__.py +++ b/esphome/components/tuya/text_sensor/__init__.py @@ -27,7 +27,6 @@ async def to_code(config): var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_sensor_id(config[CONF_SENSOR_DATAPOINT])) diff --git a/esphome/components/tuya/water_heater/__init__.py b/esphome/components/tuya/water_heater/__init__.py index 7d1af791b4f..96b7136c725 100644 --- a/esphome/components/tuya/water_heater/__init__.py +++ b/esphome/components/tuya/water_heater/__init__.py @@ -89,8 +89,7 @@ async def to_code(config: ConfigType) -> None: var = await water_heater.new_water_heater(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 6a632e05cd5..de68f56046b 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -68,9 +68,9 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) await sensors(CONF_EC, var.set_ec_sensor) - if CONF_TEMPERATURE_SENSOR in config: - sens = await cg.get_variable(config[CONF_TEMPERATURE_SENSOR]) - cg.add(var.set_temperature_sensor_external(sens)) + await cg.add_variable( + var.set_temperature_sensor_external, config.get(CONF_TEMPERATURE_SENSOR) + ) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index 6f205af784d..f9aae747689 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -60,9 +60,9 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) await sensors(CONF_PH, var.set_ph_sensor) - if CONF_TEMPERATURE_SENSOR in config: - sens = await cg.get_variable(config[CONF_TEMPERATURE_SENSOR]) - cg.add(var.set_temperature_sensor_external(sens)) + await cg.add_variable( + var.set_temperature_sensor_external, config.get(CONF_TEMPERATURE_SENSOR) + ) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index e23f4157a7e..d4d03d3e0f5 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -69,8 +69,7 @@ async def to_code(config: ConfigType) -> None: var = await uart.new_uart_component(config) if time_id := config.get(CONF_TIME_ID): - time_ = await cg.get_variable(time_id) - cg.add(var.set_time_id(time_)) + await cg.add_variable(var.set_time_id, time_id) if time_device_address := config.get(CONF_TIME_DEVICE_ADDRESS): cg.add(var.set_time_device_address(time_device_address)) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index 04140ba431d..619d9ca8864 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -61,8 +61,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) if time_id_config := config.get(CONF_TIME_ID): - time_id = await cg.get_variable(time_id_config) - cg.add(var.set_time(time_id)) + await cg.add_variable(var.set_time, time_id_config) cg.add_define("USE_UPTIME_TIMESTAMP") diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 488e16a3839..1b7ad38c1c5 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -207,17 +207,9 @@ async def to_code(config: ConfigType) -> None: mic_source2 = await microphone.microphone_source_to_code(mic_sources[1]) cg.add(var.set_microphone_source2(mic_source2)) - if CONF_MICRO_WAKE_WORD in config: - mww = await cg.get_variable(config[CONF_MICRO_WAKE_WORD]) - cg.add(var.set_micro_wake_word(mww)) - - if CONF_MEDIA_PLAYER in config: - mp = await cg.get_variable(config[CONF_MEDIA_PLAYER]) - cg.add(var.set_media_player(mp)) - - if CONF_SPEAKER in config: - spkr = await cg.get_variable(config[CONF_SPEAKER]) - cg.add(var.set_speaker(spkr)) + await cg.add_variable(var.set_micro_wake_word, config.get(CONF_MICRO_WAKE_WORD)) + await cg.add_variable(var.set_media_player, config.get(CONF_MEDIA_PLAYER)) + await cg.add_variable(var.set_speaker, config.get(CONF_SPEAKER)) cg.add(var.set_use_wake_word(config[CONF_USE_WAKE_WORD])) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index e9641a5957c..07cb154c91c 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -137,7 +137,7 @@ async def to_code(config): ) ) - cg.add(var.set_srctime(await cg.get_variable(config[CONF_TIME_ID]))) + await cg.add_variable(var.set_srctime, config[CONF_TIME_ID]) if config[CONF_REQUIRE_CONNECTION_TO_PROCEED]: cg.add(var.disable_auto_proceed()) diff --git a/esphome/components/yashima/climate.py b/esphome/components/yashima/climate.py index cfc4744d82c..25e06b37586 100644 --- a/esphome/components/yashima/climate.py +++ b/esphome/components/yashima/climate.py @@ -32,9 +32,5 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) - if CONF_SENSOR in config: - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) - - transmitter = await cg.get_variable(config[CONF_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) + await cg.add_variable(var.set_transmitter, config[CONF_TRANSMITTER_ID]) diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 0572e591cfc..dbbb6734920 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -113,5 +113,4 @@ async def _add_time_zephyr(config: ConfigType) -> None: cg.add(var.set_endpoint(slot_index + 1)) cg.add(var.set_cluster_attributes(time_attrs)) - hub = await cg.get_variable(config[CONF_ZIGBEE_ID]) - cg.add(var.set_parent(hub)) + await cg.register_parented(var, config[CONF_ZIGBEE_ID]) From b2d6c7849928b082d0af909b85bf1cb20886f2a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 07:18:47 -1000 Subject: [PATCH 118/120] [light] Add `LightState::set_external_state()` for outputs that mirror a light elsewhere (#20787) --- esphome/components/light/light_state.cpp | 23 +++ esphome/components/light/light_state.h | 5 + .../light/test_light_set_external_state.cpp | 179 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 tests/components/light/test_light_set_external_state.cpp diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 885440187f9..d8293338897 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -181,6 +181,29 @@ void LightState::loop() { } } +bool LightState::set_external_state(const LightColorValues &values) { + // Either would write its own values over the reported ones + if (this->next_write_ || this->transformer_ != nullptr) { + return false; + } + const bool had_effect = this->active_effect_index_ != 0; + this->stop_effect_(); // schedules no write, unlike a call that sets effect 0 + if (!had_effect && values == this->remote_values && values == this->current_values) { + return true; // nothing new to tell + } + this->current_values = values; + this->remote_values = values; + // Reached at once, as after an instant call, and told before the publish like there: an + // automation run by the publish may change the light again, and its own call reports that + if (this->target_state_reached_listeners_) { + for (auto *listener : *this->target_state_reached_listeners_) { + listener->on_light_target_state_reached(); + } + } + this->publish_state(); + return true; +} + void LightState::publish_state() { if (this->remote_values_listeners_) { for (auto *listener : *this->remote_values_listeners_) { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 2a99943e1df..946b4efae58 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -92,6 +92,11 @@ class LightState : public EntityBase, public Component { bool is_transitioning() const { return this->transformer_ != nullptr; } LightCall make_call(); + /// Take over the state of a light mirrored from elsewhere: current and target values at once, + /// published but not written or saved; a running effect stops. False while a write is pending or + /// a transition or flash runs. + bool set_external_state(const LightColorValues &values); + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Load state from preferences diff --git a/tests/components/light/test_light_set_external_state.cpp b/tests/components/light/test_light_set_external_state.cpp new file mode 100644 index 00000000000..624d374519a --- /dev/null +++ b/tests/components/light/test_light_set_external_state.cpp @@ -0,0 +1,179 @@ +#include + +#include "esphome/components/light/light_effect.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +// Counts the writes, like an output that mirrors a light elsewhere and sends each one on +class CountingOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::BRIGHTNESS}); + return traits; + } + void write_state(LightState *state) override { this->writes++; } + int writes{0}; +}; + +class NoopEffect : public LightEffect { + public: + using LightEffect::LightEffect; + void apply() override {} +}; + +class ReachedListener : public LightTargetStateReachedListener { + public: + void on_light_target_state_reached() override { this->reached++; } + int reached{0}; +}; + +class PublishListener : public LightRemoteValuesListener { + public: + void on_light_remote_values_update() override { this->published++; } + int published{0}; +}; + +// Counts the target-reached calls that find the light off, as the on_turn_off trigger does +class OffReachedListener : public LightTargetStateReachedListener { + public: + explicit OffReachedListener(LightState *state) : state_(state) {} + void on_light_target_state_reached() override { this->reached_off += !this->state_->current_values.is_on(); } + int reached_off{0}; + + protected: + LightState *state_; +}; + +// An automation that turns the light off as soon as it is reported on +class TurnOffOnPublish : public LightRemoteValuesListener { + public: + explicit TurnOffOnPublish(LightState *state) : state_(state) {} + void on_light_remote_values_update() override { + if (this->state_->remote_values.is_on()) { + this->state_->make_call().set_state(false).perform(); + } + } + + protected: + LightState *state_; +}; + +LightColorValues on_at(float brightness) { + LightColorValues values; + values.set_color_mode(ColorMode::BRIGHTNESS); + values.set_state(true); + values.set_brightness(brightness); + return values; +} + +} // namespace + +TEST(LightStateExternal, TakesTheValuesWithoutAWrite) { + CountingOutput output; + LightState state(&output); + ReachedListener listener; + state.add_target_state_reached_listener(&listener); + state.loop(); // the initial write of a fresh state + const int writes = output.writes; + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(state.remote_values, on_at(0.5f)); + EXPECT_EQ(state.current_values, on_at(0.5f)); + EXPECT_EQ(listener.reached, 1); + state.loop(); + EXPECT_EQ(output.writes, writes); +} + +TEST(LightStateExternal, RefusesWhileALocalChangeWaitsToBeWritten) { + CountingOutput output; + LightState state(&output); + state.loop(); + + state.make_call().set_state(true).set_brightness(0.8f).perform(); + EXPECT_FALSE(state.set_external_state(on_at(0.5f))); + EXPECT_FLOAT_EQ(state.remote_values.get_brightness(), 0.8f); + state.loop(); // the local change is written + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); +} + +TEST(LightStateExternal, RefusesDuringATransition) { + CountingOutput output; + LightState state(&output); + state.loop(); + + state.make_call().set_state(true).set_brightness(1.0f).set_transition_length(1000).perform(); + state.loop(); + EXPECT_TRUE(state.is_transitioning()); + EXPECT_FALSE(state.set_external_state(on_at(0.5f))); +} + +TEST(LightStateExternal, StopsARunningEffect) { + CountingOutput output; + LightState state(&output); + NoopEffect effect("Noop"); + LightEffect *effects[] = {&effect}; + state.add_effects(effects, 1); + state.loop(); + + state.make_call().set_state(true).set_effect("Noop").perform(); + state.loop(); + ASSERT_EQ(state.get_current_effect_index(), 1u); + const auto writes = output.writes; + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(state.get_current_effect_index(), 0u); + EXPECT_EQ(state.remote_values, on_at(0.5f)); + state.loop(); + EXPECT_EQ(output.writes, writes); // the stopped effect schedules no write either +} + +TEST(LightStateExternal, PublishesWhenOnlyAnEffectStops) { + CountingOutput output; + LightState state(&output); + NoopEffect effect("Noop"); + LightEffect *effects[] = {&effect}; + state.add_effects(effects, 1); + PublishListener listener; + state.add_remote_values_listener(&listener); + state.loop(); + + state.make_call().set_state(true).set_effect("Noop").perform(); + state.loop(); + const int published = listener.published; + // The values stay, but the effect name clients show has to go + EXPECT_TRUE(state.set_external_state(state.remote_values)); + EXPECT_EQ(state.get_current_effect_index(), 0u); + EXPECT_EQ(listener.published, published + 1); +} + +TEST(LightStateExternal, AnAutomationRunByThePublishReportsItsOwnCall) { + CountingOutput output; + LightState state(&output); + OffReachedListener reached(&state); + state.add_target_state_reached_listener(&reached); + TurnOffOnPublish automation(&state); + state.add_remote_values_listener(&automation); + state.loop(); + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_FALSE(state.remote_values.is_on()); + EXPECT_EQ(reached.reached_off, 1); // once from the turn-off call, not again from the report +} + +TEST(LightStateExternal, SaysNothingWhenNothingChanges) { + CountingOutput output; + LightState state(&output); + ReachedListener listener; + state.add_target_state_reached_listener(&listener); + state.loop(); + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(listener.reached, 1); +} + +} // namespace esphome::light::testing From c35d98b622cd04e508e050d8731680444909b1f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 07:19:14 -1000 Subject: [PATCH 119/120] [core] Add `progmem_string_table_find()` (#20790) --- esphome/core/progmem.h | 14 +++++++ .../core/test_progmem_string_table_find.cpp | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/components/core/test_progmem_string_table_find.cpp diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 02ec4778237..983f2e31f85 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -155,4 +155,18 @@ struct LogString; } \ } +/// The index of the string value (len bytes, in RAM) in a PROGMEM_STRING_TABLE, or -1 when it is +/// not one of its strings. One pass over the blob, so a table without offsets costs the same. +template int16_t progmem_string_table_find(const char *value, size_t len) { + const char *entry = Table::BLOB.data(); + for (uint8_t i = 0; i < Table::COUNT; i++) { + const size_t entry_len = ESPHOME_strlen_P(entry); + if (entry_len == len && ESPHOME_strncmp_P(value, entry, len) == 0) { + return i; + } + entry += entry_len + 1; + } + return -1; +} + } // namespace esphome diff --git a/tests/components/core/test_progmem_string_table_find.cpp b/tests/components/core/test_progmem_string_table_find.cpp new file mode 100644 index 00000000000..b344e6d2920 --- /dev/null +++ b/tests/components/core/test_progmem_string_table_find.cpp @@ -0,0 +1,37 @@ +#include + +#include "esphome/core/progmem.h" + +namespace esphome::testing { + +PROGMEM_STRING_TABLE(Names, "light.turn_on", "entity_id", "rgb_color"); + +TEST(ProgmemStringTableFind, FindsWholeStringsOnly) { + EXPECT_EQ(progmem_string_table_find("entity_id", 9), 1); + EXPECT_EQ(progmem_string_table_find("rgb_color", 9), 2); + EXPECT_EQ(progmem_string_table_find("entity", 6), -1); + EXPECT_EQ(progmem_string_table_find("entity_id_x", 11), -1); + EXPECT_EQ(progmem_string_table_find("", 0), -1); +} + +// 130 strings, 520 bytes: over the one-byte offsets, so the blob is scanned +PROGMEM_STRING_TABLE(Big, "s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10", "s11", "s12", "s13", "s14", + "s15", "s16", "s17", "s18", "s19", "s20", "s21", "s22", "s23", "s24", "s25", "s26", "s27", "s28", + "s29", "s30", "s31", "s32", "s33", "s34", "s35", "s36", "s37", "s38", "s39", "s40", "s41", "s42", + "s43", "s44", "s45", "s46", "s47", "s48", "s49", "s50", "s51", "s52", "s53", "s54", "s55", "s56", + "s57", "s58", "s59", "s60", "s61", "s62", "s63", "s64", "s65", "s66", "s67", "s68", "s69", "s70", + "s71", "s72", "s73", "s74", "s75", "s76", "s77", "s78", "s79", "s80", "s81", "s82", "s83", "s84", + "s85", "s86", "s87", "s88", "s89", "s90", "s91", "s92", "s93", "s94", "s95", "s96", "s97", "s98", + "s99", "s100", "s101", "s102", "s103", "s104", "s105", "s106", "s107", "s108", "s109", "s110", + "s111", "s112", "s113", "s114", "s115", "s116", "s117", "s118", "s119", "s120", "s121", "s122", + "s123", "s124", "s125", "s126", "s127", "s128", "s129"); + +TEST(ProgmemStringTableFind, ReachesEveryIndexOfAScannedTable) { + EXPECT_EQ(progmem_string_table_find("s0", 2), 0); + EXPECT_EQ(progmem_string_table_find("s127", 4), 127); + EXPECT_EQ(progmem_string_table_find("s128", 4), 128); + EXPECT_EQ(progmem_string_table_find("s129", 4), 129); + EXPECT_EQ(progmem_string_table_find("s130", 4), -1); +} + +} // namespace esphome::testing From 619728b686c65adfa50a42e2ba7fc3de69b37284 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 07:23:06 -1000 Subject: [PATCH 120/120] [components] Drop stale UART receive bytes with discard_available (#21030) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/bl0906/bl0906.cpp | 4 +--- esphome/components/cm1106/cm1106.cpp | 4 +--- esphome/components/cse7761/cse7761.cpp | 4 +--- .../dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp | 6 +----- esphome/components/dlms_meter/dlms_meter.cpp | 12 +++--------- esphome/components/dlms_meter/dlms_meter.h | 1 - .../components/fingerprint_grow/fingerprint_grow.cpp | 3 +-- esphome/components/hc8/hc8.cpp | 7 ++----- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 11 +++-------- esphome/components/hydreon_rgxx/hydreon_rgxx.cpp | 4 +--- esphome/components/mhz19/mhz19.cpp | 4 +--- esphome/components/pylontech/pylontech.cpp | 6 +----- esphome/components/pzem004t/pzem004t.cpp | 7 ++----- esphome/components/shelly_dimmer/shelly_dimmer.cpp | 5 +---- esphome/components/t6615/t6615.cpp | 7 ++----- esphome/components/tmc22xx/tmc22xx.cpp | 6 ++---- 16 files changed, 23 insertions(+), 68 deletions(-) diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 6f59da77d34..681c7ad15eb 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -134,9 +134,7 @@ void BL0906::handle_actions_() { } } - while (this->available()) { - this->read(); - } + this->discard_available(); this->action_queue_.clear(); } diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index 55b7556cda5..8739e6c0805 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -115,9 +115,7 @@ bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t co bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len) { - // Empty RX Buffer - while (this->available()) - this->read(); + this->discard_available(); this->write_array(command, command_len - 1); this->write_byte(cm1106_checksum(command, command_len)); this->flush(); diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 0c56c741fe6..8ec520b7396 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -116,9 +116,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) { } bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) { - while (this->available()) { - this->read(); - } + this->discard_available(); this->write_(reg, 0); diff --git a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp index ed1c6733abb..08d6024c16b 100644 --- a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp +++ b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp @@ -40,11 +40,7 @@ void DFRobotSEN0465UARTComponent::loop() { bool DFRobotSEN0465UARTComponent::write_frame(const uint8_t *frame) { // Drop anything left over, such as the answer to the mode change or readings sent before passive mode was set - uint8_t byte; - while (this->available() > 0) { - if (!this->read_byte(&byte)) - break; - } + this->discard_available(); this->rx_count_ = 0; this->write_array(frame, FRAME_SIZE); return true; diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index 32d1817e0e7..7c0ad009205 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -73,7 +73,7 @@ DlmsMeterComponent::DlmsMeterComponent(const uint32_t receive_timeout_ms, const } } -void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); } +void DlmsMeterComponent::setup() { this->discard_available(); } void DlmsMeterComponent::dump_config() { ESP_LOGCONFIG(TAG, "DLMS Meter:"); @@ -122,12 +122,6 @@ void DlmsMeterComponent::loop() { } } -void DlmsMeterComponent::flush_rx_buffer_() { - while (this->available()) { - this->read(); - } -} - void DlmsMeterComponent::read_rx_buffer_() { int available = this->available(); if (available == 0) @@ -137,7 +131,7 @@ void DlmsMeterComponent::read_rx_buffer_() { ESP_LOGW(TAG, "RX Buffer overflow. Frame too large! Dropping frame."); this->bytes_accumulated_ = 0; - this->flush_rx_buffer_(); + this->discard_available(); return; } @@ -145,7 +139,7 @@ void DlmsMeterComponent::read_rx_buffer_() { if (!success) { ESP_LOGW(TAG, "UART read failed. Dropping frame."); this->bytes_accumulated_ = 0; - this->flush_rx_buffer_(); + this->discard_available(); return; } diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index 2d7898f4d3e..9ff2cabf2b5 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -104,7 +104,6 @@ class DlmsMeterComponent final : public Component, public uart::UARTDevice { protected: void read_rx_buffer_(); - void flush_rx_buffer_(); void process_frame_(); void on_data_(const dlms_parser::AxdrCapture &capture); diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 99e88af5b74..3abd1862f34 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -362,8 +362,7 @@ void FingerprintGrowComponent::aura_led_control(uint8_t state, uint8_t speed, ui } uint8_t FingerprintGrowComponent::transfer_(std::vector &data_buffer) { - while (this->available()) - this->read(); + this->discard_available(); this->write((uint8_t) (START_CODE >> 8)); this->write((uint8_t) (START_CODE & 0xFF)); this->write(this->address_[0]); diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp index 4fbfbc84d70..2197a086c36 100644 --- a/esphome/components/hc8/hc8.cpp +++ b/esphome/components/hc8/hc8.cpp @@ -18,9 +18,7 @@ void HC8Component::setup() { this->write_array(HC8_COMMAND_GET_PPM); this->flush(); - // ensure the buffer is empty - while (this->available()) - this->read(); + this->discard_available(); } void HC8Component::update() { @@ -36,8 +34,7 @@ void HC8Component::update() { this->status_clear_warning(); } - while (this->available()) - this->read(); + this->discard_available(); this->write_array(HC8_COMMAND_GET_PPM); this->flush(); diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 8674a1de97c..43311f3d968 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -11,9 +11,7 @@ static constexpr uint32_t PAYLOAD_TIMEOUT_MS = 20; void HlkFm22xComponent::setup() { ESP_LOGCONFIG(TAG, "Setting up HLK-FM22X..."); this->set_enrolling_(false); - while (this->available() > 0) { - this->read(); - } + this->discard_available(); this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_STATUS); }); } @@ -85,8 +83,7 @@ void HlkFm22xComponent::send_command_(HlkFm22xCommand command, const uint8_t *da } this->wait_cycles_ = 0; this->active_command_ = command; - while (this->available() > 0) - this->read(); + this->discard_available(); this->write((uint8_t) (START_CODE >> 8)); this->write((uint8_t) (START_CODE & 0xFF)); this->write((uint8_t) command); @@ -141,9 +138,7 @@ void HlkFm22xComponent::recv_command_() { if (millis() - start > PAYLOAD_TIMEOUT_MS) { ESP_LOGE(TAG, "Timeout waiting for payload (%u bytes)", length); // Drain any partial payload bytes to resync the parser - while (this->available() > 0) { - this->read(); - } + this->discard_available(); return; } delay(1); diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index a1d6553073a..4a479874f53 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -38,9 +38,7 @@ void HydreonRGxxComponent::dump_config() { } void HydreonRGxxComponent::setup() { - while (this->available() != 0) { - this->read(); - } + this->discard_available(); this->schedule_reboot_(); } diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index 0f623bfd726..eda4b4d8e78 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -120,9 +120,7 @@ void MHZ19Component::range_set(MHZ19DetectionRange detection_range) { } bool MHZ19Component::mhz19_write_command_(const uint8_t *command, uint8_t *response) { - // Empty RX Buffer - while (this->available()) - this->read(); + this->discard_available(); this->write_array(command, MHZ19_REQUEST_LENGTH); this->write_byte(mhz19_checksum(command)); this->flush(); diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 90cb4a8b855..acc80b0f839 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -99,11 +99,7 @@ void PylontechComponent::dump_config() { LOG_UPDATE_INTERVAL(this); } -void PylontechComponent::setup() { - while (this->available() != 0) { - this->read(); - } -} +void PylontechComponent::setup() { this->discard_available(); } void PylontechComponent::update() { #ifdef PYLONTECH_CELL_BATTERY_COUNT diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index 2c5e349bd31..c27fb07ce08 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -9,9 +9,7 @@ namespace esphome::pzem004t { ESPHOME_LOG_TAG(TAG, "pzem004t"); void PZEM004T::setup() { - // Clear UART buffer - while (this->available()) - this->read(); + this->discard_available(); // Set module address this->write_state_(SET_ADDRESS); } @@ -19,8 +17,7 @@ void PZEM004T::setup() { void PZEM004T::loop() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_read_ > 500 && this->available() < 7) { - while (this->available()) - this->read(); + this->discard_available(); this->last_read_ = now; } diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index eedf7ff7ce2..8c44c8c5a08 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -483,10 +483,7 @@ void ShellyDimmer::reset_(bool boot0) { // Wait 50ms for the STM32 to reset. delay(50); // NOLINT - // Clear receive buffer. - while (this->available()) { - this->read(); - } + this->discard_available(); this->pin_nrst_->digital_write(true); // Wait 50ms for the STM32 to boot. diff --git a/esphome/components/t6615/t6615.cpp b/esphome/components/t6615/t6615.cpp index d3997528890..6fb58345358 100644 --- a/esphome/components/t6615/t6615.cpp +++ b/esphome/components/t6615/t6615.cpp @@ -32,8 +32,7 @@ void T6615Component::loop() { if (this->available() < 5) { if (this->command_ == T6615Command::GET_PPM && millis() - this->command_time_ > T6615_TIMEOUT) { /* command got eaten, clear the buffer and fire another */ - while (this->available()) - this->read(); + this->discard_available(); this->send_ppm_command_(); } return; @@ -48,9 +47,7 @@ void T6615Component::loop() { if (response_buffer[0] != T6615_MAGIC || response_buffer[1] != T6615_ADDR_HOST) { ESP_LOGW(TAG, "Got bad data from T6615! Magic was %02X and address was %02X", response_buffer[0], response_buffer[1]); - /* make sure the buffer is empty */ - while (this->available()) - this->read(); + this->discard_available(); /* try again to read the sensor */ this->send_ppm_command_(); this->status_set_warning(); diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index f68496bd0ca..6ee4c9e3ee6 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -446,8 +446,7 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { 0}; datagram[7] = datagram_crc(datagram, 7); - while (this->available()) - this->read(); + this->discard_available(); this->write_array(datagram, DATAGRAM_SIZE); this->flush(); // On the single wire bus every byte sent is also received, drop that echo @@ -475,8 +474,7 @@ optional TMC22XXStepper::read_register(uint8_t reg) { uint8_t request[READ_REQUEST_SIZE] = {SYNC_BYTE, this->address_, reg, 0}; request[3] = datagram_crc(request, 3); - while (this->available()) - this->read(); + this->discard_available(); this->write_array(request, READ_REQUEST_SIZE); this->flush(); uint8_t reply[DATAGRAM_SIZE];