Merge branch 'integration' into memory_api

This commit is contained in:
J. Nick Koston
2025-07-15 09:39:01 -10:00
17 changed files with 331 additions and 124 deletions
+1 -1
View File
@@ -1 +1 @@
a3cdfc378d28b53b416a1d5bf0ab9077ee18867f0d39436ea8013cf5a4ead87a
07f621354fe1350ba51953c80273cd44a04aa44f15cc30bd7b8fe2a641427b7a
+92
View File
@@ -0,0 +1,92 @@
name: Report an issue with ESPHome
description: Report an issue with ESPHome.
body:
- type: markdown
attributes:
value: |
This issue form is for reporting bugs only!
If you have a feature request or enhancement, please [request them here instead][fr].
[fr]: https://github.com/orgs/esphome/discussions
- type: textarea
validations:
required: true
id: problem
attributes:
label: The problem
description: >-
Describe the issue you are experiencing here to communicate to the
maintainers. Tell us what you were trying to do and what happened.
Provide a clear and concise description of what the problem is.
- type: markdown
attributes:
value: |
## Environment
- type: input
id: version
validations:
required: true
attributes:
label: Which version of ESPHome has the issue?
description: >
ESPHome version like 1.19, 2025.6.0 or 2025.XX.X-dev.
- type: dropdown
validations:
required: true
id: installation
attributes:
label: What type of installation are you using?
options:
- Home Assistant Add-on
- Docker
- pip
- type: dropdown
validations:
required: true
id: platform
attributes:
label: What platform are you using?
options:
- ESP8266
- ESP32
- RP2040
- BK72XX
- RTL87XX
- LN882X
- Host
- Other
- type: input
id: component_name
attributes:
label: Component causing the issue
description: >
The name of the component or platform. For example, api/i2c or ultrasonic.
- type: markdown
attributes:
value: |
# Details
- type: textarea
id: config
attributes:
label: YAML Config
description: |
Include a complete YAML configuration file demonstrating the problem here. Preferably post the *entire* file - don't make assumptions about what is unimportant. However, if it's a large or complicated config then you will need to reduce it to the smallest possible file *that still demonstrates the problem*. If you don't provide enough information to *easily* reproduce the problem, it's unlikely your bug report will get any attention. Logs do not belong here, attach them below.
render: yaml
- type: textarea
id: logs
attributes:
label: Anything in the logs that might be useful for us?
description: For example, error message, or stack traces. Serial or USB logs are much more useful than WiFi logs.
render: txt
- type: textarea
id: additional
attributes:
label: Additional information
description: >
If you have any additional information for us, use the field below.
Please note, you can attach screenshots or screen recordings here, by
dragging and dropping files in the field below.
+16 -10
View File
@@ -1,15 +1,21 @@
---
blank_issues_enabled: false
contact_links:
- name: Issue Tracker
url: https://github.com/esphome/issues
about: Please create bug reports in the dedicated issue tracker.
- name: Feature Request Tracker
url: https://github.com/esphome/feature-requests
about: |
Please create feature requests in the dedicated feature request tracker.
- name: Report an issue with the ESPHome documentation
url: https://github.com/esphome/esphome-docs/issues/new/choose
about: Report an issue with the ESPHome documentation.
- name: Report an issue with the ESPHome web server
url: https://github.com/esphome/esphome-webserver/issues/new/choose
about: Report an issue with the ESPHome web server.
- name: Report an issue with the ESPHome Builder / Dashboard
url: https://github.com/esphome/dashboard/issues/new/choose
about: Report an issue with the ESPHome Builder / Dashboard.
- name: Report an issue with the ESPHome API client
url: https://github.com/esphome/aioesphomeapi/issues/new/choose
about: Report an issue with the ESPHome API client.
- name: Make a Feature Request
url: https://github.com/orgs/esphome/discussions
about: Please create feature requests in the dedicated feature request tracker.
- name: Frequently Asked Question
url: https://esphome.io/guides/faq.html
about: |
Please view the FAQ for common questions and what
to include in a bug report.
about: Please view the FAQ for common questions and what to include in a bug report.
+4 -5
View File
@@ -31,7 +31,6 @@ APIServer::APIServer() {
}
void APIServer::setup() {
ESP_LOGCONFIG(TAG, "Running setup");
this->setup_controller();
#ifdef USE_API_NOISE
@@ -205,16 +204,16 @@ void APIServer::loop() {
void APIServer::dump_config() {
ESP_LOGCONFIG(TAG,
"API Server:\n"
"Server:\n"
" Address: %s:%u",
network::get_use_address().c_str(), this->port_);
#ifdef USE_API_NOISE
ESP_LOGCONFIG(TAG, " Using noise encryption: %s", YESNO(this->noise_ctx_->has_psk()));
ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_->has_psk()));
if (!this->noise_ctx_->has_psk()) {
ESP_LOGCONFIG(TAG, " Supports noise encryption: YES");
ESP_LOGCONFIG(TAG, " Supports encryption: YES");
}
#else
ESP_LOGCONFIG(TAG, " Using noise encryption: NO");
ESP_LOGCONFIG(TAG, " Noise encryption: NO");
#endif
}
@@ -389,10 +389,12 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
#ifdef USE_WEBSERVER_SORTING
for (auto &group : ws->sorting_groups_) {
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
message = json::build_json([group](JsonObject root) {
root["name"] = group.second.name;
root["sorting_weight"] = group.second.weight;
});
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
// a (very) large number of these should be able to be queued initially without defer
// since the only thing in the send buffer at this point is the initial ping/config
+4 -4
View File
@@ -71,7 +71,7 @@ void Application::setup() {
do {
uint8_t new_app_state = STATUS_LED_WARNING;
this->scheduler.call();
this->scheduler.call(millis());
this->feed_wdt();
for (uint32_t j = 0; j <= i; j++) {
// Update loop_component_start_time_ right before calling each component
@@ -97,11 +97,11 @@ void Application::setup() {
void Application::loop() {
uint8_t new_app_state = 0;
this->scheduler.call();
// Get the initial loop time at the start
uint32_t last_op_end_time = millis();
this->scheduler.call(last_op_end_time);
// Feed WDT with time
this->feed_wdt(last_op_end_time);
@@ -160,7 +160,7 @@ void Application::loop() {
this->yield_with_select_(0);
} else {
uint32_t delay_time = this->loop_interval_ - elapsed;
uint32_t next_schedule = this->scheduler.next_schedule_in().value_or(delay_time);
uint32_t next_schedule = this->scheduler.next_schedule_in(last_op_end_time).value_or(delay_time);
// next_schedule is max 0.5*delay_time
// otherwise interval=0 schedules result in constant looping with almost no sleep
next_schedule = std::max(next_schedule, delay_time / 2);
+1
View File
@@ -267,6 +267,7 @@ void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std:
bool Component::is_failed() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED; }
bool Component::is_ready() const {
return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP ||
(this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE ||
(this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_SETUP;
}
bool Component::can_proceed() { return true; }
+23 -27
View File
@@ -91,7 +91,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
}
#endif
const auto now = this->millis_();
const auto now = this->millis_64_(millis());
// Type-specific setup
if (type == SchedulerItem::INTERVAL) {
@@ -193,9 +193,7 @@ void HOT Scheduler::set_retry(Component *component, const std::string &name, uin
name.c_str(), initial_wait_time, max_attempts, backoff_increase_factor);
if (backoff_increase_factor < 0.0001) {
ESP_LOGE(TAG,
"set_retry(name='%s'): backoff_factor cannot be close to zero nor negative (%0.1f). Using 1.0 instead",
name.c_str(), backoff_increase_factor);
ESP_LOGE(TAG, "backoff_factor %0.1f too small, using 1.0", backoff_increase_factor);
backoff_increase_factor = 1;
}
@@ -215,19 +213,19 @@ bool HOT Scheduler::cancel_retry(Component *component, const std::string &name)
return this->cancel_timeout(component, "retry$" + name);
}
optional<uint32_t> HOT Scheduler::next_schedule_in() {
optional<uint32_t> HOT Scheduler::next_schedule_in(uint32_t now) {
// IMPORTANT: This method should only be called from the main thread (loop task).
// It calls empty_() and accesses items_[0] without holding a lock, which is only
// safe when called from the main thread. Other threads must not call this method.
if (this->empty_())
return {};
auto &item = this->items_[0];
const auto now = this->millis_();
if (item->next_execution_ < now)
const auto now_64 = this->millis_64_(now);
if (item->next_execution_ < now_64)
return 0;
return item->next_execution_ - now;
return item->next_execution_ - now_64;
}
void HOT Scheduler::call() {
void HOT Scheduler::call(uint32_t now) {
#if !defined(USE_ESP8266) && !defined(USE_RP2040)
// Process defer queue first to guarantee FIFO execution order for deferred items.
// Previously, defer() used the heap which gave undefined order for equal timestamps,
@@ -256,22 +254,22 @@ void HOT Scheduler::call() {
// Execute callback without holding lock to prevent deadlocks
// if the callback tries to call defer() again
if (!this->should_skip_item_(item.get())) {
this->execute_item_(item.get());
this->execute_item_(item.get(), now);
}
}
#endif
const auto now = this->millis_();
const auto now_64 = this->millis_64_(now);
this->process_to_add();
#ifdef ESPHOME_DEBUG_SCHEDULER
static uint64_t last_print = 0;
if (now - last_print > 2000) {
last_print = now;
if (now_64 - last_print > 2000) {
last_print = now_64;
std::vector<std::unique_ptr<SchedulerItem>> old_items;
ESP_LOGD(TAG, "Items: count=%zu, now=%" PRIu64 " (%u, %" PRIu32 ")", this->items_.size(), now, this->millis_major_,
this->last_millis_);
ESP_LOGD(TAG, "Items: count=%zu, now=%" PRIu64 " (%u, %" PRIu32 ")", this->items_.size(), now_64,
this->millis_major_, this->last_millis_);
while (!this->empty_()) {
std::unique_ptr<SchedulerItem> item;
{
@@ -283,7 +281,7 @@ void HOT Scheduler::call() {
const char *name = item->get_name();
ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64,
item->get_type_str(), item->get_source(), name ? name : "(null)", item->interval,
item->next_execution_ - now, item->next_execution_);
item->next_execution_ - now_64, item->next_execution_);
old_items.push_back(std::move(item));
}
@@ -328,7 +326,7 @@ void HOT Scheduler::call() {
{
// Don't copy-by value yet
auto &item = this->items_[0];
if (item->next_execution_ > now) {
if (item->next_execution_ > now_64) {
// Not reached timeout yet, done for this call
break;
}
@@ -342,13 +340,13 @@ void HOT Scheduler::call() {
const char *item_name = item->get_name();
ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")",
item->get_type_str(), item->get_source(), item_name ? item_name : "(null)", item->interval,
item->next_execution_, now);
item->next_execution_, now_64);
#endif
// Warning: During callback(), a lot of stuff can happen, including:
// - timeouts/intervals get added, potentially invalidating vector pointers
// - timeouts/intervals get cancelled
this->execute_item_(item.get());
this->execute_item_(item.get(), now);
}
{
@@ -367,7 +365,7 @@ void HOT Scheduler::call() {
}
if (item->type == SchedulerItem::INTERVAL) {
item->next_execution_ = now + item->interval;
item->next_execution_ = now_64 + item->interval;
// Add new item directly to to_add_
// since we have the lock held
this->to_add_.push_back(std::move(item));
@@ -423,11 +421,9 @@ void HOT Scheduler::pop_raw_() {
}
// Helper to execute a scheduler item
void HOT Scheduler::execute_item_(SchedulerItem *item) {
void HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) {
App.set_current_component(item->component);
uint32_t now_ms = millis();
WarnIfComponentBlockingGuard guard{item->component, now_ms};
WarnIfComponentBlockingGuard guard{item->component, now};
item->callback();
guard.finish();
}
@@ -486,15 +482,15 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c
return total_cancelled > 0;
}
uint64_t Scheduler::millis_() {
// Get the current 32-bit millis value
const uint32_t now = millis();
uint64_t Scheduler::millis_64_(uint32_t now) {
// Check for rollover by comparing with last value
if (now < this->last_millis_) {
// Detected rollover (happens every ~49.7 days)
this->millis_major_++;
#ifdef ESPHOME_DEBUG_SCHEDULER
ESP_LOGD(TAG, "Incrementing scheduler major at %" PRIu64 "ms",
now + (static_cast<uint64_t>(this->millis_major_) << 32));
#endif
}
this->last_millis_ = now;
// Combine major (high 32 bits) and now (low 32 bits) into 64-bit time
+4 -4
View File
@@ -52,9 +52,9 @@ class Scheduler {
std::function<RetryResult(uint8_t)> func, float backoff_increase_factor = 1.0f);
bool cancel_retry(Component *component, const std::string &name);
optional<uint32_t> next_schedule_in();
optional<uint32_t> next_schedule_in(uint32_t now);
void call();
void call(uint32_t now);
void process_to_add();
@@ -137,7 +137,7 @@ class Scheduler {
void set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, const void *name_ptr,
uint32_t delay, std::function<void()> func);
uint64_t millis_();
uint64_t millis_64_(uint32_t now);
void cleanup_();
void pop_raw_();
@@ -175,7 +175,7 @@ class Scheduler {
}
// Helper to execute a scheduler item
void execute_item_(SchedulerItem *item);
void execute_item_(SchedulerItem *item, uint32_t now);
// Helper to check if item should be skipped
bool should_skip_item_(const SchedulerItem *item) const {
+10 -6
View File
@@ -163,7 +163,7 @@ def get_ini_content():
CORE.add_platformio_option("build_unflags", sorted(CORE.build_unflags))
# Add extra script for C++ flags
CORE.add_platformio_option("extra_scripts", ["pre:cxx_flags.py"])
CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"])
content = "[platformio]\n"
content += f"description = ESPHome {__version__}\n"
@@ -402,14 +402,18 @@ def write_gitignore():
f.write(GITIGNORE_CONTENT)
CXX_FLAGS_SCRIPT = """# Auto-generated ESPHome script for C++ specific compiler flags
CXX_FLAGS_FILE_NAME = "cxx_flags.py"
CXX_FLAGS_FILE_CONTENTS = """# Auto-generated ESPHome script for C++ specific compiler flags
Import("env")
# Add C++ specific warning flags
env.Append(CXXFLAGS=["-Wno-volatile"])
# Add C++ specific flags
"""
def write_cxx_flags_script() -> None:
path = CORE.relative_build_path("cxx_flags.py")
write_file_if_changed(path, CXX_FLAGS_SCRIPT)
path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME)
contents = CXX_FLAGS_FILE_CONTENTS
if not CORE.is_host:
contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])'
contents += "\n"
write_file_if_changed(path, contents)
+4 -23
View File
@@ -62,26 +62,6 @@ def get_clang_tidy_version_from_requirements() -> str:
return "clang-tidy version not found"
def extract_platformio_flags() -> str:
"""Extract clang-tidy related flags from platformio.ini"""
flags: list[str] = []
in_clangtidy_section = False
platformio_path = Path(__file__).parent.parent / "platformio.ini"
lines = read_file_lines(platformio_path)
for line in lines:
line = line.strip()
if line.startswith("[flags:clangtidy]"):
in_clangtidy_section = True
continue
elif line.startswith("[") and in_clangtidy_section:
break
elif in_clangtidy_section and line and not line.startswith("#"):
flags.append(line)
return "\n".join(sorted(flags))
def read_file_bytes(path: Path) -> bytes:
"""Read bytes from a file."""
with open(path, "rb") as f:
@@ -101,9 +81,10 @@ def calculate_clang_tidy_hash() -> str:
version = get_clang_tidy_version_from_requirements()
hasher.update(version.encode())
# Hash relevant platformio.ini sections
pio_flags = extract_platformio_flags()
hasher.update(pio_flags.encode())
# Hash the entire platformio.ini file
platformio_path = Path(__file__).parent.parent / "platformio.ini"
platformio_content = read_file_bytes(platformio_path)
hasher.update(platformio_content)
return hasher.hexdigest()
@@ -1,7 +1,7 @@
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_COMPONENTS, CONF_ID, CONF_NAME
from esphome.const import CONF_COMPONENTS, CONF_ID, CONF_NAME, CONF_UPDATE_INTERVAL
CODEOWNERS = ["@esphome/tests"]
@@ -10,10 +10,15 @@ LoopTestComponent = loop_test_component_ns.class_("LoopTestComponent", cg.Compon
LoopTestISRComponent = loop_test_component_ns.class_(
"LoopTestISRComponent", cg.Component
)
LoopTestUpdateComponent = loop_test_component_ns.class_(
"LoopTestUpdateComponent", cg.PollingComponent
)
CONF_DISABLE_AFTER = "disable_after"
CONF_TEST_REDUNDANT_OPERATIONS = "test_redundant_operations"
CONF_ISR_COMPONENTS = "isr_components"
CONF_UPDATE_COMPONENTS = "update_components"
CONF_DISABLE_LOOP_AFTER = "disable_loop_after"
COMPONENT_CONFIG_SCHEMA = cv.Schema(
{
@@ -31,11 +36,23 @@ ISR_COMPONENT_CONFIG_SCHEMA = cv.Schema(
}
)
UPDATE_COMPONENT_CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LoopTestUpdateComponent),
cv.Required(CONF_NAME): cv.string,
cv.Optional(CONF_DISABLE_LOOP_AFTER, default=0): cv.int_,
cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval,
}
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LoopTestComponent),
cv.Required(CONF_COMPONENTS): cv.ensure_list(COMPONENT_CONFIG_SCHEMA),
cv.Optional(CONF_ISR_COMPONENTS): cv.ensure_list(ISR_COMPONENT_CONFIG_SCHEMA),
cv.Optional(CONF_UPDATE_COMPONENTS): cv.ensure_list(
UPDATE_COMPONENT_CONFIG_SCHEMA
),
}
).extend(cv.COMPONENT_SCHEMA)
@@ -94,3 +111,12 @@ async def to_code(config):
var = cg.new_Pvariable(isr_config[CONF_ID])
await cg.register_component(var, isr_config)
cg.add(var.set_name(isr_config[CONF_NAME]))
# Create update test components
for update_config in config.get(CONF_UPDATE_COMPONENTS, []):
var = cg.new_Pvariable(update_config[CONF_ID])
await cg.register_component(var, update_config)
cg.add(var.set_name(update_config[CONF_NAME]))
cg.add(var.set_disable_loop_after(update_config[CONF_DISABLE_LOOP_AFTER]))
cg.add(var.set_update_interval(update_config[CONF_UPDATE_INTERVAL]))
@@ -39,5 +39,29 @@ void LoopTestComponent::service_disable() {
this->disable_loop();
}
// LoopTestUpdateComponent implementation
void LoopTestUpdateComponent::setup() {
ESP_LOGI(TAG, "[%s] LoopTestUpdateComponent setup called", this->name_.c_str());
}
void LoopTestUpdateComponent::loop() {
this->loop_count_++;
ESP_LOGI(TAG, "[%s] LoopTestUpdateComponent loop count: %d", this->name_.c_str(), this->loop_count_);
// Disable loop after specified count to test component.update when loop is disabled
if (this->disable_loop_after_ > 0 && this->loop_count_ == this->disable_loop_after_) {
ESP_LOGI(TAG, "[%s] Disabling loop after %d iterations", this->name_.c_str(), this->disable_loop_after_);
this->disable_loop();
}
}
void LoopTestUpdateComponent::update() {
this->update_count_++;
// Check if loop is disabled by testing component state
bool loop_disabled = this->component_state_ == COMPONENT_STATE_LOOP_DONE;
ESP_LOGI(TAG, "[%s] LoopTestUpdateComponent update() called, count: %d, loop_disabled: %s", this->name_.c_str(),
this->update_count_, loop_disabled ? "YES" : "NO");
}
} // namespace loop_test_component
} // namespace esphome
@@ -4,6 +4,7 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
namespace esphome {
namespace loop_test_component {
@@ -54,5 +55,29 @@ template<typename... Ts> class DisableAction : public Action<Ts...> {
LoopTestComponent *parent_;
};
// Component with update() method to test component.update action
class LoopTestUpdateComponent : public PollingComponent {
public:
LoopTestUpdateComponent() : PollingComponent(1000) {} // Default 1s update interval
void set_name(const std::string &name) { this->name_ = name; }
void set_disable_loop_after(int count) { this->disable_loop_after_ = count; }
void setup() override;
void loop() override;
void update() override;
int get_update_count() const { return this->update_count_; }
int get_loop_count() const { return this->loop_count_; }
float get_setup_priority() const override { return setup_priority::DATA; }
protected:
std::string name_;
int loop_count_{0};
int update_count_{0};
int disable_loop_after_{0};
};
} // namespace loop_test_component
} // namespace esphome
@@ -40,6 +40,13 @@ loop_test_component:
- id: isr_test
name: "isr_test"
# Update test component to test component.update when loop is disabled
update_components:
- id: update_test_component
name: "update_test"
disable_loop_after: 3 # Disable loop after 3 iterations
update_interval: 0.1s # Fast update interval for testing
# Interval to re-enable the self_disable_10 component after some time
interval:
- interval: 0.5s
@@ -51,3 +58,28 @@ interval:
- logger.log: "Re-enabling self_disable_10 via service"
- loop_test_component.enable:
id: self_disable_10
# Test component.update on a component with disabled loop
- interval: 0.1s
then:
- lambda: |-
static bool manual_update_done = false;
if (!manual_update_done &&
id(update_test_component).get_loop_count() == 3 &&
id(update_test_component).get_update_count() >= 3) {
ESP_LOGI("main", "Manually calling component.update on update_test_component with disabled loop");
manual_update_done = true;
}
- if:
condition:
lambda: |-
static bool manual_update_triggered = false;
if (!manual_update_triggered &&
id(update_test_component).get_loop_count() == 3 &&
id(update_test_component).get_update_count() >= 3) {
manual_update_triggered = true;
return true;
}
return false;
then:
- component.update: update_test_component
@@ -45,11 +45,18 @@ async def test_loop_disable_enable(
isr_component_disabled = asyncio.Event()
isr_component_re_enabled = asyncio.Event()
isr_component_pure_re_enabled = asyncio.Event()
# Events for update component testing
update_component_loop_disabled = asyncio.Event()
update_component_manual_update_called = asyncio.Event()
# Track loop counts for components
self_disable_10_counts: list[int] = []
normal_component_counts: list[int] = []
isr_component_counts: list[int] = []
# Track update component behavior
update_component_loop_count = 0
update_component_update_count = 0
update_component_manual_update_count = 0
def on_log_line(line: str) -> None:
"""Process each log line from the process output."""
@@ -59,6 +66,7 @@ async def test_loop_disable_enable(
if (
"loop_test_component" not in clean_line
and "loop_test_isr_component" not in clean_line
and "Manually calling component.update" not in clean_line
):
return
@@ -112,6 +120,23 @@ async def test_loop_disable_enable(
elif "Running after pure ISR re-enable!" in clean_line:
isr_component_pure_re_enabled.set()
# Update component events
elif "[update_test]" in clean_line:
if "LoopTestUpdateComponent loop count:" in clean_line:
nonlocal update_component_loop_count
update_component_loop_count = int(
clean_line.split("LoopTestUpdateComponent loop count: ")[1]
)
elif "LoopTestUpdateComponent update() called" in clean_line:
nonlocal update_component_update_count
update_component_update_count += 1
if "Manually calling component.update" in " ".join(log_messages[-5:]):
nonlocal update_component_manual_update_count
update_component_manual_update_count += 1
update_component_manual_update_called.set()
elif "Disabling loop after" in clean_line:
update_component_loop_disabled.set()
# Write, compile and run the ESPHome device with log callback
async with (
run_compiled(yaml_config, line_callback=on_log_line),
@@ -205,3 +230,28 @@ async def test_loop_disable_enable(
assert final_count > 10, (
f"Component didn't run after pure ISR enable: got {final_count} counts total"
)
# Test component.update functionality when loop is disabled
# Wait for update component to disable its loop
try:
await asyncio.wait_for(update_component_loop_disabled.wait(), timeout=3.0)
except asyncio.TimeoutError:
pytest.fail("Update component did not disable its loop within 3 seconds")
# Verify it ran exactly 3 loops before disabling
assert update_component_loop_count == 3, (
f"Expected 3 loop iterations before disable, got {update_component_loop_count}"
)
# Wait for manual component.update to be called
try:
await asyncio.wait_for(
update_component_manual_update_called.wait(), timeout=5.0
)
except asyncio.TimeoutError:
pytest.fail("Manual component.update was not called within 5 seconds")
# The key test: verify that manual component.update worked after loop was disabled
assert update_component_manual_update_count >= 1, (
"component.update did not fire after loop was disabled"
)
+12 -43
View File
@@ -44,67 +44,36 @@ def test_get_clang_tidy_version_from_requirements(
assert result == expected
@pytest.mark.parametrize(
("platformio_content", "expected_flags"),
[
(
"[env:esp32]\n"
"platform = espressif32\n"
"\n"
"[flags:clangtidy]\n"
"build_flags = -Wall\n"
"extra_flags = -Wextra\n"
"\n"
"[env:esp8266]\n",
"build_flags = -Wall\nextra_flags = -Wextra",
),
(
"[flags:clangtidy]\n# Comment line\nbuild_flags = -O2\n\n[next_section]\n",
"build_flags = -O2",
),
(
"[flags:clangtidy]\nflag_c = -std=c99\nflag_b = -Wall\nflag_a = -O2\n",
"flag_a = -O2\nflag_b = -Wall\nflag_c = -std=c99", # Sorted
),
(
"[env:esp32]\nplatform = espressif32\n", # No clangtidy section
"",
),
],
)
def test_extract_platformio_flags(platformio_content: str, expected_flags: str) -> None:
"""Test extracting clang-tidy flags from platformio.ini."""
# Mock read_file_lines to return our test content
with patch("clang_tidy_hash.read_file_lines") as mock_read:
mock_read.return_value = platformio_content.splitlines(keepends=True)
result = clang_tidy_hash.extract_platformio_flags()
assert result == expected_flags
def test_calculate_clang_tidy_hash() -> None:
"""Test calculating hash from all configuration sources."""
clang_tidy_content = b"Checks: '-*,readability-*'\n"
requirements_version = "clang-tidy==18.1.5"
pio_flags = "build_flags = -Wall"
platformio_content = b"[env:esp32]\nplatform = espressif32\n"
# Expected hash calculation
expected_hasher = hashlib.sha256()
expected_hasher.update(clang_tidy_content)
expected_hasher.update(requirements_version.encode())
expected_hasher.update(pio_flags.encode())
expected_hasher.update(platformio_content)
expected_hash = expected_hasher.hexdigest()
# Mock the dependencies
with (
patch("clang_tidy_hash.read_file_bytes", return_value=clang_tidy_content),
patch("clang_tidy_hash.read_file_bytes") as mock_read_bytes,
patch(
"clang_tidy_hash.get_clang_tidy_version_from_requirements",
return_value=requirements_version,
),
patch("clang_tidy_hash.extract_platformio_flags", return_value=pio_flags),
):
# Set up mock to return different content based on the file being read
def read_file_mock(path: Path) -> bytes:
if ".clang-tidy" in str(path):
return clang_tidy_content
elif "platformio.ini" in str(path):
return platformio_content
return b""
mock_read_bytes.side_effect = read_file_mock
result = clang_tidy_hash.calculate_clang_tidy_hash()
assert result == expected_hash