mirror of
https://github.com/esphome/esphome.git
synced 2026-09-03 19:46:02 +00:00
Merge remote-tracking branch 'upstream/dev' into inline-mutex-single-threaded
This commit is contained in:
@@ -106,6 +106,7 @@ jobs:
|
||||
script/build_codeowners.py --check
|
||||
script/build_language_schema.py --check
|
||||
script/generate-esp32-boards.py --check
|
||||
script/generate-rp2040-boards.py --check
|
||||
|
||||
pytest:
|
||||
name: Run pytest
|
||||
@@ -170,6 +171,8 @@ jobs:
|
||||
- common
|
||||
outputs:
|
||||
integration-tests: ${{ steps.determine.outputs.integration-tests }}
|
||||
integration-tests-run-all: ${{ steps.determine.outputs.integration-tests-run-all }}
|
||||
integration-test-files: ${{ steps.determine.outputs.integration-test-files }}
|
||||
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
|
||||
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
|
||||
python-linters: ${{ steps.determine.outputs.python-linters }}
|
||||
@@ -210,6 +213,8 @@ jobs:
|
||||
|
||||
# Extract individual fields
|
||||
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
|
||||
echo "integration-tests-run-all=$(echo "$output" | jq -r '.integration_tests_run_all')" >> $GITHUB_OUTPUT
|
||||
echo "integration-test-files=$(echo "$output" | jq -c '.integration_test_files')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
|
||||
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
|
||||
@@ -261,9 +266,20 @@ jobs:
|
||||
- name: Register matcher
|
||||
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
||||
- name: Run integration tests
|
||||
env:
|
||||
INTEGRATION_TEST_FILES: ${{ needs.determine-jobs.outputs.integration-test-files }}
|
||||
INTEGRATION_TESTS_RUN_ALL: ${{ needs.determine-jobs.outputs.integration-tests-run-all }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pytest -vv --no-cov --tb=native -n auto tests/integration/
|
||||
if [[ "$INTEGRATION_TESTS_RUN_ALL" == "true" ]]; then
|
||||
echo "Running all integration tests"
|
||||
pytest -vv --no-cov --tb=native -n auto tests/integration/
|
||||
else
|
||||
# Parse JSON array into bash array to avoid shell expansion issues
|
||||
mapfile -t test_files < <(echo "$INTEGRATION_TEST_FILES" | jq -r '.[]')
|
||||
echo "Running ${#test_files[@]} specific integration tests"
|
||||
pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
|
||||
fi
|
||||
|
||||
cpp-unit-tests:
|
||||
name: Run C++ unit tests
|
||||
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.5
|
||||
rev: v0.15.6
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Memory usage analyzer for ESPHome compiled binaries."""
|
||||
|
||||
from collections import defaultdict
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -40,6 +40,15 @@ _READELF_SECTION_PATTERN = re.compile(
|
||||
r"\s*\[\s*\d+\]\s+([\.\w]+)\s+\w+\s+[\da-fA-F]+\s+[\da-fA-F]+\s+([\da-fA-F]+)"
|
||||
)
|
||||
|
||||
# Regex for extracting call targets from objdump disassembly
|
||||
# Matches direct call instructions across architectures:
|
||||
# Xtensa: call0/call4/call8/call12/callx0/callx4/callx8/callx12 <addr> <symbol>
|
||||
# ARM: bl/blx <addr> <symbol>
|
||||
# Captures the mangled symbol name inside angle brackets.
|
||||
_CALL_TARGET_PATTERN = re.compile(
|
||||
r"\t(?:call(?:0|4|8|12)|callx(?:0|4|8|12)|blx?)\s+[\da-fA-F]+ <([^>]+)>"
|
||||
)
|
||||
|
||||
# Component category prefixes
|
||||
_COMPONENT_PREFIX_ESPHOME = "[esphome]"
|
||||
_COMPONENT_PREFIX_EXTERNAL = "[external]"
|
||||
@@ -197,6 +206,8 @@ class MemoryAnalyzer:
|
||||
self._lib_hash_to_name: dict[str, str] = {}
|
||||
# Heuristic category to library redirect: "mdns_lib" -> "[lib]mdns"
|
||||
self._heuristic_to_lib: dict[str, str] = {}
|
||||
# Function call counts: mangled_name -> call_count
|
||||
self._function_call_counts: Counter[str] = Counter()
|
||||
|
||||
def analyze(self) -> dict[str, ComponentMemory]:
|
||||
"""Analyze the ELF file and return component memory usage."""
|
||||
@@ -206,6 +217,7 @@ class MemoryAnalyzer:
|
||||
self._categorize_symbols()
|
||||
self._analyze_cswtch_symbols()
|
||||
self._analyze_sdk_libraries()
|
||||
self._analyze_function_calls()
|
||||
return dict(self.components)
|
||||
|
||||
def _parse_sections(self) -> None:
|
||||
@@ -384,8 +396,9 @@ class MemoryAnalyzer:
|
||||
return
|
||||
|
||||
_LOGGER.info("Demangling %d symbols", len(symbols))
|
||||
self._demangle_cache = batch_demangle(symbols, objdump_path=self.objdump_path)
|
||||
_LOGGER.info("Successfully demangled %d symbols", len(self._demangle_cache))
|
||||
demangled = batch_demangle(symbols, objdump_path=self.objdump_path)
|
||||
self._demangle_cache.update(demangled)
|
||||
_LOGGER.info("Successfully demangled %d symbols", len(demangled))
|
||||
|
||||
def _demangle_symbol(self, symbol: str) -> str:
|
||||
"""Get demangled C++ symbol name from cache."""
|
||||
@@ -1011,6 +1024,43 @@ class MemoryAnalyzer:
|
||||
total_size,
|
||||
)
|
||||
|
||||
def _analyze_function_calls(self) -> None:
|
||||
"""Count function call sites by parsing disassembly output.
|
||||
|
||||
Parses direct call instructions (call0/call8/bl/blx) from objdump -d
|
||||
to count how many times each function is called. This helps identify
|
||||
inlining candidates — frequently called small functions benefit most
|
||||
from inlining.
|
||||
"""
|
||||
result = run_tool(
|
||||
[self.objdump_path, "-d", str(self.elf_path)],
|
||||
timeout=60,
|
||||
)
|
||||
if result is None or result.returncode != 0:
|
||||
_LOGGER.debug("Failed to disassemble ELF for function call analysis")
|
||||
return
|
||||
|
||||
self._function_call_counts = Counter(
|
||||
match.group(1)
|
||||
for line in result.stdout.splitlines()
|
||||
if (match := _CALL_TARGET_PATTERN.search(line))
|
||||
)
|
||||
|
||||
# Demangle any call targets not already in the cache
|
||||
missing = [
|
||||
name
|
||||
for name in self._function_call_counts
|
||||
if name not in self._demangle_cache
|
||||
]
|
||||
if missing:
|
||||
self._batch_demangle_symbols(missing)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Function call analysis: %d unique targets, %d total calls",
|
||||
len(self._function_call_counts),
|
||||
sum(self._function_call_counts.values()),
|
||||
)
|
||||
|
||||
def get_unattributed_ram(self) -> tuple[int, int, int]:
|
||||
"""Get unattributed RAM sizes (SDK/framework overhead).
|
||||
|
||||
|
||||
@@ -231,6 +231,110 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
lines.append(f" {size:>6,} B {sym_name}")
|
||||
lines.append("")
|
||||
|
||||
# Number of top called functions to show
|
||||
TOP_CALLS_LIMIT: int = 50
|
||||
# Number of inlining candidates to show
|
||||
INLINE_CANDIDATES_LIMIT: int = 25
|
||||
# Maximum function size in bytes to consider for inlining
|
||||
INLINE_SIZE_THRESHOLD: int = 16
|
||||
|
||||
def _build_symbol_sizes(self) -> dict[str, int]:
|
||||
"""Build a size lookup from all component symbols: mangled_name -> size."""
|
||||
return {
|
||||
symbol: size
|
||||
for symbols in self._component_symbols.values()
|
||||
for symbol, _, size, _ in symbols
|
||||
}
|
||||
|
||||
def _format_call_row(
|
||||
self, index: int, mangled: str, count: int, symbol_sizes: dict[str, int]
|
||||
) -> str:
|
||||
"""Format a single row for call frequency tables."""
|
||||
demangled = self._demangle_cache.get(mangled, mangled)
|
||||
if len(demangled) > 80:
|
||||
demangled = f"{demangled[:77]}..."
|
||||
size = symbol_sizes.get(mangled)
|
||||
size_str = f"{size:>5,} B" if size is not None else " ?"
|
||||
return f"{index:>3} {count:>5} {size_str} {demangled}"
|
||||
|
||||
def _add_call_table_header(self, lines: list[str]) -> None:
|
||||
"""Add the header row for call frequency tables."""
|
||||
lines.append(f"{'#':>3} {'Calls':>5} {'Size':>7} Function")
|
||||
lines.append(f"{'---':>3} {'-----':>5} {'-------':>7} {'-' * 60}")
|
||||
|
||||
def _add_function_call_analysis(self, lines: list[str]) -> None:
|
||||
"""Add function call frequency analysis section.
|
||||
|
||||
Shows the most frequently called functions by call site count.
|
||||
"""
|
||||
self._add_section_header(lines, "Top Called Functions")
|
||||
|
||||
symbol_sizes = self._build_symbol_sizes()
|
||||
|
||||
# Sort by call count descending
|
||||
sorted_calls = sorted(
|
||||
self._function_call_counts.items(), key=lambda x: x[1], reverse=True
|
||||
)
|
||||
|
||||
self._add_call_table_header(lines)
|
||||
|
||||
for i, (mangled, count) in enumerate(sorted_calls[: self.TOP_CALLS_LIMIT]):
|
||||
lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes))
|
||||
|
||||
total_calls = sum(self._function_call_counts.values())
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Total: {len(self._function_call_counts)} unique targets, "
|
||||
f"{total_calls:,} call sites"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
def _add_inline_candidates(self, lines: list[str]) -> None:
|
||||
"""Add inlining candidates section.
|
||||
|
||||
Shows frequently called functions that are small enough to benefit
|
||||
from inlining (< 16 bytes). These are the best candidates for
|
||||
reducing call overhead.
|
||||
"""
|
||||
self._add_section_header(
|
||||
lines,
|
||||
f"Inlining Candidates (<{self.INLINE_SIZE_THRESHOLD} B, by call count)",
|
||||
)
|
||||
|
||||
symbol_sizes = self._build_symbol_sizes()
|
||||
|
||||
# Filter to small functions with known size, sort by call count
|
||||
candidates = sorted(
|
||||
(
|
||||
(mangled, count)
|
||||
for mangled, count in self._function_call_counts.items()
|
||||
if mangled in symbol_sizes
|
||||
and symbol_sizes[mangled] < self.INLINE_SIZE_THRESHOLD
|
||||
),
|
||||
key=lambda x: x[1],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
lines.append("No candidates found.")
|
||||
lines.append("")
|
||||
return
|
||||
|
||||
self._add_call_table_header(lines)
|
||||
|
||||
for i, (mangled, count) in enumerate(
|
||||
candidates[: self.INLINE_CANDIDATES_LIMIT]
|
||||
):
|
||||
lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes))
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Showing top {min(len(candidates), self.INLINE_CANDIDATES_LIMIT)} "
|
||||
f"of {len(candidates)} functions under "
|
||||
f"{self.INLINE_SIZE_THRESHOLD} B"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
def generate_report(self, detailed: bool = False) -> str:
|
||||
"""Generate a formatted memory report."""
|
||||
components = sorted(
|
||||
@@ -533,6 +637,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
if self._cswtch_symbols:
|
||||
self._add_cswtch_analysis(lines)
|
||||
|
||||
# Function call frequency analysis
|
||||
if self._function_call_counts:
|
||||
self._add_function_call_analysis(lines)
|
||||
self._add_inline_candidates(lines)
|
||||
|
||||
lines.append(
|
||||
"Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included."
|
||||
)
|
||||
|
||||
@@ -22,7 +22,8 @@ namespace adc {
|
||||
|
||||
#ifdef USE_ESP32
|
||||
// clang-format off
|
||||
#if (ESP_IDF_VERSION_MAJOR == 5 && \
|
||||
#if ESP_IDF_VERSION_MAJOR >= 6 || \
|
||||
(ESP_IDF_VERSION_MAJOR == 5 && \
|
||||
((ESP_IDF_VERSION_MINOR == 0 && ESP_IDF_VERSION_PATCH >= 5) || \
|
||||
(ESP_IDF_VERSION_MINOR == 1 && ESP_IDF_VERSION_PATCH >= 3) || \
|
||||
(ESP_IDF_VERSION_MINOR >= 2)) \
|
||||
|
||||
@@ -134,12 +134,16 @@ class APIFrameHelper {
|
||||
//
|
||||
// For log messages: Use Nagle to coalesce multiple small log packets into
|
||||
// fewer larger packets, reducing WiFi overhead. However, we limit batching
|
||||
// to 3 messages to avoid excessive LWIP buffer pressure on memory-constrained
|
||||
// devices like ESP8266. LWIP's TCP_OVERSIZE option coalesces the data into
|
||||
// shared pbufs, but holding data too long waiting for Nagle's timer causes
|
||||
// buffer exhaustion and dropped messages.
|
||||
// to avoid excessive LWIP buffer pressure on memory-constrained devices.
|
||||
// LWIP's TCP_OVERSIZE option coalesces the data into shared pbufs, but
|
||||
// holding data too long waiting for Nagle's timer causes buffer exhaustion
|
||||
// and dropped messages.
|
||||
//
|
||||
// Flow: Log 1 (Nagle on) -> Log 2 (Nagle on) -> Log 3 (NODELAY, flush all)
|
||||
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
|
||||
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
|
||||
//
|
||||
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
|
||||
// Flow (ESP8266): Log 1 (Nagle on) -> Log 2 -> Log 3 (NODELAY, flush all)
|
||||
//
|
||||
void set_nodelay_for_message(bool is_log_message) {
|
||||
if (!is_log_message) {
|
||||
@@ -150,7 +154,7 @@ class APIFrameHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log messages 1-3: state transitions -1 -> 1 -> 2 -> -1 (flush on 3rd)
|
||||
// Log messages: state transitions -1 -> 1 -> ... -> LOG_NAGLE_COUNT -> -1 (flush)
|
||||
if (this->nodelay_state_ == NODELAY_ON) {
|
||||
this->set_nodelay_raw_(false);
|
||||
this->nodelay_state_ = 1;
|
||||
@@ -255,10 +259,16 @@ class APIFrameHelper {
|
||||
uint8_t tx_buf_tail_{0};
|
||||
uint8_t tx_buf_count_{0};
|
||||
// Nagle batching state for log messages. NODELAY_ON (-1) means NODELAY is enabled
|
||||
// (immediate send). Values 1-2 count log messages in the current Nagle batch.
|
||||
// (immediate send). Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
|
||||
// After LOG_NAGLE_COUNT logs, we switch to NODELAY to flush and reset.
|
||||
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
|
||||
// ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
|
||||
static constexpr int8_t NODELAY_ON = -1;
|
||||
#ifdef USE_ESP8266
|
||||
static constexpr int8_t LOG_NAGLE_COUNT = 2;
|
||||
#else
|
||||
static constexpr int8_t LOG_NAGLE_COUNT = 3;
|
||||
#endif
|
||||
int8_t nodelay_state_{NODELAY_ON};
|
||||
|
||||
// Internal helper to set TCP_NODELAY socket option
|
||||
|
||||
@@ -602,7 +602,7 @@ class ProtoSize {
|
||||
static constexpr uint32_t calc_sint32(uint32_t field_id_size, int32_t value) {
|
||||
return value ? field_id_size + varint(encode_zigzag32(value)) : 0;
|
||||
}
|
||||
static constexpr uint32_t calc_sint32_force(uint32_t field_id_size, int32_t value) {
|
||||
static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_sint32_force(uint32_t field_id_size, int32_t value) {
|
||||
return field_id_size + varint(encode_zigzag32(value));
|
||||
}
|
||||
static constexpr uint32_t calc_int64(uint32_t field_id_size, int64_t value) {
|
||||
@@ -614,13 +614,13 @@ class ProtoSize {
|
||||
static constexpr uint32_t calc_uint64(uint32_t field_id_size, uint64_t value) {
|
||||
return value ? field_id_size + varint(value) : 0;
|
||||
}
|
||||
static constexpr uint32_t calc_uint64_force(uint32_t field_id_size, uint64_t value) {
|
||||
static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_force(uint32_t field_id_size, uint64_t value) {
|
||||
return field_id_size + varint(value);
|
||||
}
|
||||
static constexpr uint32_t calc_length(uint32_t field_id_size, size_t len) {
|
||||
return len ? field_id_size + varint(static_cast<uint32_t>(len)) + static_cast<uint32_t>(len) : 0;
|
||||
}
|
||||
static constexpr uint32_t calc_length_force(uint32_t field_id_size, size_t len) {
|
||||
static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_length_force(uint32_t field_id_size, size_t len) {
|
||||
return field_id_size + varint(static_cast<uint32_t>(len)) + static_cast<uint32_t>(len);
|
||||
}
|
||||
static constexpr uint32_t calc_sint64(uint32_t field_id_size, int64_t value) {
|
||||
@@ -638,7 +638,8 @@ class ProtoSize {
|
||||
static constexpr uint32_t calc_message(uint32_t field_id_size, uint32_t nested_size) {
|
||||
return nested_size ? field_id_size + varint(nested_size) + nested_size : 0;
|
||||
}
|
||||
static constexpr uint32_t calc_message_force(uint32_t field_id_size, uint32_t nested_size) {
|
||||
static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_message_force(uint32_t field_id_size,
|
||||
uint32_t nested_size) {
|
||||
return field_id_size + varint(nested_size) + nested_size;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
|
||||
CONF_BYTE_ORDER = "byte_order"
|
||||
CONF_CLIMATE_ID = "climate_id"
|
||||
BYTE_ORDER_LITTLE = "little_endian"
|
||||
BYTE_ORDER_BIG = "big_endian"
|
||||
|
||||
|
||||
@@ -99,8 +99,6 @@ void ESP32RMTLEDStripLightOutput::setup() {
|
||||
channel.gpio_num = gpio_num_t(this->pin_);
|
||||
channel.mem_block_symbols = this->rmt_symbols_;
|
||||
channel.trans_queue_depth = 1;
|
||||
channel.flags.io_loop_back = 0;
|
||||
channel.flags.io_od_mode = 0;
|
||||
channel.flags.invert_out = this->invert_out_;
|
||||
channel.flags.with_dma = this->use_dma_;
|
||||
channel.intr_priority = 0;
|
||||
|
||||
@@ -54,6 +54,17 @@ void MIPI_DSI::setup() {
|
||||
this->smark_failed(LOG_STR("new_panel_io_dbi failed"), err);
|
||||
return;
|
||||
}
|
||||
// clang-format off
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
auto color_format = LCD_COLOR_FMT_RGB565;
|
||||
if (this->color_depth_ == display::COLOR_BITNESS_888) {
|
||||
color_format = LCD_COLOR_FMT_RGB888;
|
||||
}
|
||||
esp_lcd_dpi_panel_config_t dpi_config = {.virtual_channel = 0,
|
||||
.dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT,
|
||||
.dpi_clock_freq_mhz = this->pclk_frequency_,
|
||||
.in_color_format = color_format,
|
||||
#else
|
||||
auto pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB565;
|
||||
if (this->color_depth_ == display::COLOR_BITNESS_888) {
|
||||
pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB888;
|
||||
@@ -62,6 +73,7 @@ void MIPI_DSI::setup() {
|
||||
.dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT,
|
||||
.dpi_clock_freq_mhz = this->pclk_frequency_,
|
||||
.pixel_format = pixel_format,
|
||||
#endif
|
||||
.num_fbs = 1, // number of frame buffers to allocate
|
||||
.video_timing =
|
||||
{
|
||||
@@ -77,6 +89,7 @@ void MIPI_DSI::setup() {
|
||||
.flags = {
|
||||
.use_dma2d = true,
|
||||
}};
|
||||
// clang-format on
|
||||
err = esp_lcd_new_panel_dpi(this->bus_handle_, &dpi_config, &this->handle_);
|
||||
if (err != ESP_OK) {
|
||||
this->smark_failed(LOG_STR("esp_lcd_new_panel_dpi failed"), err);
|
||||
|
||||
@@ -57,7 +57,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_KD_MULTIPLIER, default=0.0): cv.float_,
|
||||
cv.Optional(
|
||||
CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES, default=1
|
||||
): cv.int_,
|
||||
): cv.positive_not_null_int,
|
||||
}
|
||||
),
|
||||
cv.Required(CONF_CONTROL_PARAMETERS): cv.Schema(
|
||||
@@ -68,8 +68,12 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_STARTING_INTEGRAL_TERM, default=0.0): cv.float_,
|
||||
cv.Optional(CONF_MIN_INTEGRAL, default=-1): cv.float_,
|
||||
cv.Optional(CONF_MAX_INTEGRAL, default=1): cv.float_,
|
||||
cv.Optional(CONF_DERIVATIVE_AVERAGING_SAMPLES, default=1): cv.int_,
|
||||
cv.Optional(CONF_OUTPUT_AVERAGING_SAMPLES, default=1): cv.int_,
|
||||
cv.Optional(
|
||||
CONF_DERIVATIVE_AVERAGING_SAMPLES, default=1
|
||||
): cv.positive_not_null_int,
|
||||
cv.Optional(
|
||||
CONF_OUTPUT_AVERAGING_SAMPLES, default=1
|
||||
): cv.positive_not_null_int,
|
||||
}
|
||||
),
|
||||
}
|
||||
@@ -102,13 +106,15 @@ async def to_code(config):
|
||||
cg.add(var.set_starting_integral_term(params[CONF_STARTING_INTEGRAL_TERM]))
|
||||
cg.add(var.set_derivative_samples(params[CONF_DERIVATIVE_AVERAGING_SAMPLES]))
|
||||
|
||||
cg.add(var.set_output_samples(params[CONF_OUTPUT_AVERAGING_SAMPLES]))
|
||||
output_samples = params[CONF_OUTPUT_AVERAGING_SAMPLES]
|
||||
cg.add(var.set_output_samples(output_samples))
|
||||
|
||||
if CONF_MIN_INTEGRAL in params:
|
||||
cg.add(var.set_min_integral(params[CONF_MIN_INTEGRAL]))
|
||||
if CONF_MAX_INTEGRAL in params:
|
||||
cg.add(var.set_max_integral(params[CONF_MAX_INTEGRAL]))
|
||||
|
||||
deadband_output_samples = 1
|
||||
if CONF_DEADBAND_PARAMETERS in config:
|
||||
params = config[CONF_DEADBAND_PARAMETERS]
|
||||
cg.add(var.set_threshold_low(params[CONF_THRESHOLD_LOW]))
|
||||
@@ -116,11 +122,11 @@ async def to_code(config):
|
||||
cg.add(var.set_kp_multiplier(params[CONF_KP_MULTIPLIER]))
|
||||
cg.add(var.set_ki_multiplier(params[CONF_KI_MULTIPLIER]))
|
||||
cg.add(var.set_kd_multiplier(params[CONF_KD_MULTIPLIER]))
|
||||
cg.add(
|
||||
var.set_deadband_output_samples(
|
||||
params[CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES]
|
||||
)
|
||||
)
|
||||
deadband_output_samples = params[CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES]
|
||||
cg.add(var.set_deadband_output_samples(deadband_output_samples))
|
||||
|
||||
# Single shared output buffer sized to max of both modes
|
||||
cg.add(var.init_output_buffer(max(output_samples, deadband_output_samples)))
|
||||
|
||||
cg.add(var.set_default_target_temperature(config[CONF_DEFAULT_TARGET_TEMPERATURE]))
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ class PIDClimate : public climate::Climate, public Component {
|
||||
void set_min_integral(float min_integral) { controller_.min_integral_ = min_integral; }
|
||||
void set_max_integral(float max_integral) { controller_.max_integral_ = max_integral; }
|
||||
void set_output_samples(int in) { controller_.output_samples_ = in; }
|
||||
void set_derivative_samples(int in) { controller_.derivative_samples_ = in; }
|
||||
void set_derivative_samples(int in) {
|
||||
controller_.derivative_samples_ = in;
|
||||
if (in > 1) // No allocation needed when samples=1 (ring_buffer_average_ short-circuits)
|
||||
controller_.derivative_window_.init(in);
|
||||
}
|
||||
|
||||
void set_threshold_low(float in) { controller_.threshold_low_ = in; }
|
||||
void set_threshold_high(float in) { controller_.threshold_high_ = in; }
|
||||
@@ -38,6 +42,10 @@ class PIDClimate : public climate::Climate, public Component {
|
||||
void set_starting_integral_term(float in) { controller_.set_starting_integral_term(in); }
|
||||
|
||||
void set_deadband_output_samples(int in) { controller_.deadband_output_samples_ = in; }
|
||||
void init_output_buffer(int size) {
|
||||
if (size > 1) // No allocation needed when samples=1 (ring_buffer_average_ short-circuits)
|
||||
controller_.output_window_.init(size);
|
||||
}
|
||||
|
||||
float get_output_value() const { return output_value_; }
|
||||
float get_error_value() const { return controller_.error_; }
|
||||
|
||||
@@ -21,9 +21,9 @@ float PIDController::update(float setpoint, float process_value) {
|
||||
// u(t) := p(t) + i(t) + d(t)
|
||||
float output = proportional_term_ + integral_term_ + derivative_term_;
|
||||
|
||||
// smooth/sample the output
|
||||
// smooth/sample the output using shared buffer with mode-appropriate sample count
|
||||
int samples = in_deadband() ? deadband_output_samples_ : output_samples_;
|
||||
return weighted_average_(output_list_, output, samples);
|
||||
return ring_buffer_average_(output_window_, output, samples);
|
||||
}
|
||||
|
||||
bool PIDController::in_deadband() {
|
||||
@@ -83,7 +83,7 @@ void PIDController::calculate_derivative_term_(float setpoint) {
|
||||
previous_setpoint_ = setpoint;
|
||||
|
||||
// smooth the derivative samples
|
||||
derivative = weighted_average_(derivative_list_, derivative, derivative_samples_);
|
||||
derivative = ring_buffer_average_(derivative_window_, derivative, derivative_samples_);
|
||||
|
||||
derivative_term_ = kd_ * derivative;
|
||||
|
||||
@@ -93,25 +93,23 @@ void PIDController::calculate_derivative_term_(float setpoint) {
|
||||
}
|
||||
}
|
||||
|
||||
float PIDController::weighted_average_(std::deque<float> &list, float new_value, int samples) {
|
||||
// if only 1 sample needed, clear the list and return
|
||||
if (samples == 1) {
|
||||
list.clear();
|
||||
float PIDController::ring_buffer_average_(FixedRingBuffer<float> &buf, float new_value, int max_samples) {
|
||||
// if only 1 sample needed (or invalid), clear the buffer and return
|
||||
if (max_samples <= 1) {
|
||||
buf.clear();
|
||||
return new_value;
|
||||
}
|
||||
|
||||
// add the new item to the list
|
||||
list.push_front(new_value);
|
||||
// Trim oldest entries to make room (handles mode-switching where buffer
|
||||
// may have more entries than the current mode needs)
|
||||
while (buf.size() >= static_cast<size_t>(max_samples))
|
||||
buf.pop();
|
||||
buf.push(new_value);
|
||||
|
||||
// keep only 'samples' readings, by popping off the back of the list
|
||||
while (samples > 0 && list.size() > static_cast<size_t>(samples))
|
||||
list.pop_back();
|
||||
|
||||
// calculate and return the average of all values in the list
|
||||
float sum = 0;
|
||||
for (auto &elem : list)
|
||||
sum += elem;
|
||||
return sum / list.size();
|
||||
for (auto val : buf)
|
||||
sum += val;
|
||||
return sum / buf.size();
|
||||
}
|
||||
|
||||
float PIDController::calculate_relative_time_() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include <deque>
|
||||
#include "esphome/core/helpers.h"
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome {
|
||||
@@ -24,10 +25,10 @@ struct PIDController {
|
||||
/// Differential gain K_d.
|
||||
float kd_ = 0;
|
||||
|
||||
// smooth the derivative value using a weighted average over X samples
|
||||
int derivative_samples_ = 8;
|
||||
// smooth the derivative value using an average over X samples
|
||||
int derivative_samples_ = 1;
|
||||
|
||||
/// smooth the output value using a weighted average over X values
|
||||
/// smooth the output value using an average over X values
|
||||
int output_samples_ = 1;
|
||||
|
||||
float threshold_low_ = 0.0f;
|
||||
@@ -50,7 +51,10 @@ struct PIDController {
|
||||
void calculate_proportional_term_();
|
||||
void calculate_integral_term_();
|
||||
void calculate_derivative_term_(float setpoint);
|
||||
float weighted_average_(std::deque<float> &list, float new_value, int samples);
|
||||
|
||||
/// Ring buffer smoothing using FixedRingBuffer (single allocation at setup)
|
||||
float ring_buffer_average_(FixedRingBuffer<float> &buf, float new_value, int max_samples);
|
||||
|
||||
float calculate_relative_time_();
|
||||
|
||||
/// Error from previous update used for derivative term
|
||||
@@ -60,12 +64,12 @@ struct PIDController {
|
||||
float accumulated_integral_ = 0;
|
||||
uint32_t last_time_ = 0;
|
||||
|
||||
// this is a list of derivative values for smoothing.
|
||||
std::deque<float> derivative_list_;
|
||||
// Ring buffer for derivative smoothing
|
||||
FixedRingBuffer<float> derivative_window_;
|
||||
|
||||
// this is a list of output values for smoothing.
|
||||
std::deque<float> output_list_;
|
||||
// Ring buffer for output smoothing (shared between normal and deadband modes)
|
||||
FixedRingBuffer<float> output_window_;
|
||||
|
||||
}; // Struct PID Controller
|
||||
}; // Struct PIDController
|
||||
} // namespace pid
|
||||
} // namespace esphome
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
from esphome.components.const import CONF_CLIMATE_ID
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_TYPE, ICON_GAUGE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT
|
||||
|
||||
@@ -21,7 +22,6 @@ PID_CLIMATE_SENSOR_TYPES = {
|
||||
"KD": PIDClimateSensorType.PID_SENSOR_TYPE_KD,
|
||||
}
|
||||
|
||||
CONF_CLIMATE_ID = "climate_id"
|
||||
CONFIG_SCHEMA = (
|
||||
sensor.sensor_schema(
|
||||
PIDClimateSensor,
|
||||
|
||||
@@ -44,7 +44,6 @@ void RemoteReceiverComponent::setup() {
|
||||
channel.intr_priority = 0;
|
||||
channel.flags.invert_in = 0;
|
||||
channel.flags.with_dma = this->with_dma_;
|
||||
channel.flags.io_loop_back = 0;
|
||||
esp_err_t error = rmt_new_rx_channel(&channel, &this->channel_);
|
||||
if (error != ESP_OK) {
|
||||
this->error_code_ = error;
|
||||
|
||||
@@ -120,11 +120,13 @@ void RemoteTransmitterComponent::configure_rmt_() {
|
||||
channel.gpio_num = gpio_num_t(this->pin_->get_pin());
|
||||
channel.mem_block_symbols = this->rmt_symbols_;
|
||||
channel.trans_queue_depth = 1;
|
||||
channel.flags.io_loop_back = open_drain;
|
||||
channel.flags.io_od_mode = open_drain;
|
||||
channel.flags.invert_out = 0;
|
||||
channel.flags.with_dma = this->with_dma_;
|
||||
channel.intr_priority = 0;
|
||||
#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
channel.flags.io_loop_back = open_drain;
|
||||
channel.flags.io_od_mode = open_drain;
|
||||
#endif
|
||||
error = rmt_new_tx_channel(&channel, &this->channel_);
|
||||
if (error != ESP_OK) {
|
||||
this->error_code_ = error;
|
||||
@@ -136,6 +138,13 @@ void RemoteTransmitterComponent::configure_rmt_() {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
if (open_drain) {
|
||||
gpio_num_t gpio = gpio_num_t(this->pin_->get_pin());
|
||||
gpio_od_enable(gpio);
|
||||
gpio_input_enable(gpio);
|
||||
}
|
||||
#endif
|
||||
if (this->pin_->get_flags() & gpio::FLAG_PULLUP) {
|
||||
gpio_pullup_en(gpio_num_t(this->pin_->get_pin()));
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ Usage: python esphome/components/rp2040/generate_boards.py <arduino-pico-path>
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
@@ -157,7 +158,7 @@ def generate(arduino_pico_path: Path) -> str:
|
||||
board_pins, boards = load_boards(arduino_pico_path)
|
||||
|
||||
template = _jinja_env.get_template("boards.jinja2")
|
||||
return template.render(
|
||||
content = template.render(
|
||||
cyw43_gpio_offset=CYW43_GPIO_OFFSET,
|
||||
cyw43_max_gpio=CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1,
|
||||
default_max_pin=DEFAULT_MAX_PIN,
|
||||
@@ -165,6 +166,15 @@ def generate(arduino_pico_path: Path) -> str:
|
||||
boards=sorted(boards.items()),
|
||||
)
|
||||
|
||||
# Format output to match pre-commit ruff formatting
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "ruff", "format", "--stdin-filename", "boards.py"],
|
||||
input=content.encode(),
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.decode()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
|
||||
@@ -26,6 +26,10 @@ class BmpDecoder : public ImageDecoder {
|
||||
int HOT decode(uint8_t *buffer, size_t size) override;
|
||||
|
||||
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<size_t>(this->width_ * this->height_);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ SelectCall &SelectCall::with_index(size_t index) {
|
||||
this->operation_ = SELECT_OP_SET;
|
||||
if (index >= this->parent_->size()) {
|
||||
ESP_LOGW(TAG, "'%s' - Index value %zu out of bounds", this->parent_->get_name().c_str(), index);
|
||||
this->index_ = {}; // Store nullopt for invalid index
|
||||
this->index_ = nullopt; // Store nullopt for invalid index
|
||||
} else {
|
||||
this->index_ = index;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ optional<size_t> SelectCall::calculate_target_index_(const char *name) {
|
||||
const auto &options = this->parent_->traits.get_options();
|
||||
if (options.empty()) {
|
||||
ESP_LOGW(TAG, "'%s' - Select has no options", name);
|
||||
return {};
|
||||
return nullopt;
|
||||
}
|
||||
|
||||
if (this->operation_ == SELECT_OP_FIRST) {
|
||||
@@ -67,7 +67,7 @@ optional<size_t> SelectCall::calculate_target_index_(const char *name) {
|
||||
ESP_LOGD(TAG, "'%s' - Setting", name);
|
||||
if (!this->index_.has_value()) {
|
||||
ESP_LOGW(TAG, "'%s' - No option set", name);
|
||||
return {};
|
||||
return nullopt;
|
||||
}
|
||||
return this->index_;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ optional<size_t> SelectCall::calculate_target_index_(const char *name) {
|
||||
return active_index + 1;
|
||||
}
|
||||
|
||||
return {}; // Can't navigate further without cycling
|
||||
return nullopt; // Can't navigate further without cycling
|
||||
}
|
||||
|
||||
void SelectCall::perform() {
|
||||
|
||||
@@ -403,9 +403,9 @@ async def filter_out_filter_to_code(config, filter_id):
|
||||
QUANTILE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_QUANTILE, default=0.9): cv.zero_to_one_float,
|
||||
}
|
||||
),
|
||||
@@ -427,9 +427,9 @@ async def quantile_filter_to_code(config, filter_id):
|
||||
MEDIAN_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535),
|
||||
}
|
||||
),
|
||||
validate_send_first_at,
|
||||
@@ -449,9 +449,9 @@ async def median_filter_to_code(config, filter_id):
|
||||
MIN_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535),
|
||||
}
|
||||
),
|
||||
validate_send_first_at,
|
||||
@@ -483,9 +483,9 @@ async def min_filter_to_code(config, filter_id):
|
||||
MAX_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535),
|
||||
}
|
||||
),
|
||||
validate_send_first_at,
|
||||
@@ -509,9 +509,9 @@ async def max_filter_to_code(config, filter_id):
|
||||
SLIDING_AVERAGE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WINDOW_SIZE, default=15): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_EVERY, default=15): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_WINDOW_SIZE, default=15): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_SEND_EVERY, default=15): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535),
|
||||
}
|
||||
),
|
||||
validate_send_first_at,
|
||||
@@ -540,8 +540,8 @@ EXPONENTIAL_AVERAGE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ALPHA, default=0.1): cv.positive_float,
|
||||
cv.Optional(CONF_SEND_EVERY, default=15): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_SEND_EVERY, default=15): cv.int_range(min=1, max=65535),
|
||||
cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535),
|
||||
}
|
||||
),
|
||||
validate_send_first_at,
|
||||
|
||||
@@ -41,26 +41,14 @@ void Filter::initialize(Sensor *parent, Filter *next) {
|
||||
}
|
||||
|
||||
// SlidingWindowFilter
|
||||
SlidingWindowFilter::SlidingWindowFilter(size_t window_size, size_t send_every, size_t send_first_at)
|
||||
: window_size_(window_size), send_every_(send_every), send_at_(send_every - send_first_at) {
|
||||
// Allocate ring buffer once at initialization
|
||||
SlidingWindowFilter::SlidingWindowFilter(uint16_t window_size, uint16_t send_every, uint16_t send_first_at)
|
||||
: send_every_(send_every), send_at_(send_every - send_first_at) {
|
||||
this->window_.init(window_size);
|
||||
}
|
||||
|
||||
optional<float> SlidingWindowFilter::new_value(float value) {
|
||||
// Add value to ring buffer
|
||||
if (this->window_count_ < this->window_size_) {
|
||||
// Buffer not yet full - just append
|
||||
this->window_.push_back(value);
|
||||
this->window_count_++;
|
||||
} else {
|
||||
// Buffer full - overwrite oldest value (ring buffer)
|
||||
this->window_[this->window_head_] = value;
|
||||
this->window_head_++;
|
||||
if (this->window_head_ >= this->window_size_) {
|
||||
this->window_head_ = 0;
|
||||
}
|
||||
}
|
||||
// Add value to ring buffer (overwrites oldest when full)
|
||||
this->window_.push_overwrite(value);
|
||||
|
||||
// Check if we should send a result
|
||||
if (++this->send_at_ >= this->send_every_) {
|
||||
@@ -77,9 +65,8 @@ FixedVector<float> SortedWindowFilter::get_window_values_() {
|
||||
// Copy window without NaN values using FixedVector (no heap allocation)
|
||||
// Returns unsorted values - caller will use std::nth_element for partial sorting as needed
|
||||
FixedVector<float> values;
|
||||
values.init(this->window_count_);
|
||||
for (size_t i = 0; i < this->window_count_; i++) {
|
||||
float v = this->window_[i];
|
||||
values.init(this->window_.size());
|
||||
for (float v : this->window_) {
|
||||
if (!std::isnan(v)) {
|
||||
values.push_back(v);
|
||||
}
|
||||
@@ -150,8 +137,7 @@ float MaxFilter::compute_result() { return this->find_extremum_<std::greater<flo
|
||||
float SlidingWindowMovingAverageFilter::compute_result() {
|
||||
float sum = 0;
|
||||
size_t valid_count = 0;
|
||||
for (size_t i = 0; i < this->window_count_; i++) {
|
||||
float v = this->window_[i];
|
||||
for (float v : this->window_) {
|
||||
if (!std::isnan(v)) {
|
||||
sum += v;
|
||||
valid_count++;
|
||||
@@ -161,7 +147,7 @@ float SlidingWindowMovingAverageFilter::compute_result() {
|
||||
}
|
||||
|
||||
// ExponentialMovingAverageFilter
|
||||
ExponentialMovingAverageFilter::ExponentialMovingAverageFilter(float alpha, size_t send_every, size_t send_first_at)
|
||||
ExponentialMovingAverageFilter::ExponentialMovingAverageFilter(float alpha, uint16_t send_every, uint16_t send_first_at)
|
||||
: alpha_(alpha), send_every_(send_every), send_at_(send_every - send_first_at) {}
|
||||
optional<float> ExponentialMovingAverageFilter::new_value(float value) {
|
||||
if (!std::isnan(value)) {
|
||||
@@ -183,7 +169,7 @@ optional<float> ExponentialMovingAverageFilter::new_value(float value) {
|
||||
}
|
||||
return {};
|
||||
}
|
||||
void ExponentialMovingAverageFilter::set_send_every(size_t send_every) { this->send_every_ = send_every; }
|
||||
void ExponentialMovingAverageFilter::set_send_every(uint16_t send_every) { this->send_every_ = send_every; }
|
||||
void ExponentialMovingAverageFilter::set_alpha(float alpha) { this->alpha_ = alpha; }
|
||||
|
||||
// ThrottleAverageFilter
|
||||
@@ -511,7 +497,7 @@ optional<float> ToNTCTemperatureFilter::new_value(float value) {
|
||||
}
|
||||
|
||||
// StreamingFilter (base class)
|
||||
StreamingFilter::StreamingFilter(size_t window_size, size_t send_first_at)
|
||||
StreamingFilter::StreamingFilter(uint16_t window_size, uint16_t send_first_at)
|
||||
: window_size_(window_size), send_first_at_(send_first_at) {}
|
||||
|
||||
optional<float> StreamingFilter::new_value(float value) {
|
||||
|
||||
@@ -52,7 +52,7 @@ class Filter {
|
||||
*/
|
||||
class SlidingWindowFilter : public Filter {
|
||||
public:
|
||||
SlidingWindowFilter(size_t window_size, size_t send_every, size_t send_first_at);
|
||||
SlidingWindowFilter(uint16_t window_size, uint16_t send_every, uint16_t send_first_at);
|
||||
|
||||
optional<float> new_value(float value) final;
|
||||
|
||||
@@ -60,14 +60,10 @@ class SlidingWindowFilter : public Filter {
|
||||
/// Called by new_value() to compute the filtered result from the current window
|
||||
virtual float compute_result() = 0;
|
||||
|
||||
/// Access the sliding window values (ring buffer implementation)
|
||||
/// Use: for (size_t i = 0; i < window_count_; i++) { float val = window_[i]; }
|
||||
FixedVector<float> window_;
|
||||
size_t window_head_{0}; ///< Index where next value will be written
|
||||
size_t window_count_{0}; ///< Number of valid values in window (0 to window_size_)
|
||||
size_t window_size_; ///< Maximum window size
|
||||
size_t send_every_; ///< Send result every N values
|
||||
size_t send_at_; ///< Counter for send_every
|
||||
/// Sliding window ring buffer - automatically overwrites oldest values when full
|
||||
FixedRingBuffer<float> window_;
|
||||
uint16_t send_every_; ///< Send result every N values
|
||||
uint16_t send_at_; ///< Counter for send_every
|
||||
};
|
||||
|
||||
/** Base class for Min/Max filters.
|
||||
@@ -84,8 +80,7 @@ class MinMaxFilter : public SlidingWindowFilter {
|
||||
template<typename Compare> float find_extremum_() {
|
||||
float result = NAN;
|
||||
Compare comp;
|
||||
for (size_t i = 0; i < this->window_count_; i++) {
|
||||
float v = this->window_[i];
|
||||
for (float v : this->window_) {
|
||||
if (!std::isnan(v)) {
|
||||
result = std::isnan(result) ? v : (comp(v, result) ? v : result);
|
||||
}
|
||||
@@ -239,18 +234,18 @@ class SlidingWindowMovingAverageFilter : public SlidingWindowFilter {
|
||||
*/
|
||||
class ExponentialMovingAverageFilter : public Filter {
|
||||
public:
|
||||
ExponentialMovingAverageFilter(float alpha, size_t send_every, size_t send_first_at);
|
||||
ExponentialMovingAverageFilter(float alpha, uint16_t send_every, uint16_t send_first_at);
|
||||
|
||||
optional<float> new_value(float value) override;
|
||||
|
||||
void set_send_every(size_t send_every);
|
||||
void set_send_every(uint16_t send_every);
|
||||
void set_alpha(float alpha);
|
||||
|
||||
protected:
|
||||
float accumulator_{NAN};
|
||||
float alpha_;
|
||||
size_t send_every_;
|
||||
size_t send_at_;
|
||||
uint16_t send_every_;
|
||||
uint16_t send_at_;
|
||||
bool first_value_{true};
|
||||
};
|
||||
|
||||
@@ -570,7 +565,7 @@ class ToNTCTemperatureFilter : public Filter {
|
||||
*/
|
||||
class StreamingFilter : public Filter {
|
||||
public:
|
||||
StreamingFilter(size_t window_size, size_t send_first_at);
|
||||
StreamingFilter(uint16_t window_size, uint16_t send_first_at);
|
||||
|
||||
optional<float> new_value(float value) final;
|
||||
|
||||
@@ -584,9 +579,9 @@ class StreamingFilter : public Filter {
|
||||
/// Called by new_value() to reset internal state after sending a result
|
||||
virtual void reset_batch() = 0;
|
||||
|
||||
size_t window_size_;
|
||||
size_t count_{0};
|
||||
size_t send_first_at_;
|
||||
uint16_t window_size_;
|
||||
uint16_t count_{0};
|
||||
uint16_t send_first_at_;
|
||||
bool first_send_{true};
|
||||
};
|
||||
|
||||
|
||||
@@ -24,23 +24,23 @@ class TemplateTextSaverBase {
|
||||
template<uint8_t SZ> class TextSaver : public TemplateTextSaverBase {
|
||||
public:
|
||||
bool save(const std::string &value) override {
|
||||
int diff = value.compare(this->prev_);
|
||||
if (diff != 0) {
|
||||
// If string is bigger than the allocation, do not save it.
|
||||
// We don't need to waste ram setting prev_value either.
|
||||
int size = value.size();
|
||||
if (size <= SZ) {
|
||||
// Make it into a length prefixed thing
|
||||
unsigned char temp[SZ + 1];
|
||||
memcpy(temp + 1, value.c_str(), size);
|
||||
// SZ should be pre checked at the schema level, it can't go past the char range.
|
||||
temp[0] = ((unsigned char) size);
|
||||
this->pref_.save(&temp);
|
||||
this->prev_.assign(value);
|
||||
return true;
|
||||
}
|
||||
if (value == this->prev_) {
|
||||
return true; // No change, nothing to save
|
||||
}
|
||||
return false;
|
||||
// If string is bigger than the allocation, do not save it.
|
||||
// We don't need to waste ram setting prev_value either.
|
||||
int size = value.size();
|
||||
if (size > SZ) {
|
||||
return false;
|
||||
}
|
||||
// Make it into a length prefixed thing
|
||||
unsigned char temp[SZ + 1];
|
||||
memcpy(temp + 1, value.c_str(), size);
|
||||
// SZ should be pre checked at the schema level, it can't go past the char range.
|
||||
temp[0] = ((unsigned char) size);
|
||||
this->pref_.save(&temp);
|
||||
this->prev_.assign(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Make the preference object. Fill the provided location with the saved data
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#include <type_traits>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
|
||||
#include <esp_eap_client.h>
|
||||
#else
|
||||
#include <esp_wpa2.h>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
#endif
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP)
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
|
||||
#include <esp_eap_client.h>
|
||||
#else
|
||||
#include <esp_wpa2.h>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
|
||||
#include <esp_eap_client.h>
|
||||
#else
|
||||
#include <esp_wpa2.h>
|
||||
@@ -75,7 +75,11 @@ struct IDFWiFiEvent {
|
||||
#if USE_NETWORK_IPV6
|
||||
ip_event_got_ip6_t ip_got_ip6;
|
||||
#endif /* USE_NETWORK_IPV6 */
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
ip_event_assigned_ip_to_client_t ip_assigned_ip_to_client;
|
||||
#else
|
||||
ip_event_ap_staipassigned_t ip_ap_staipassigned;
|
||||
#endif
|
||||
} data;
|
||||
};
|
||||
|
||||
@@ -116,8 +120,13 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi
|
||||
memcpy(&event.data.ap_staconnected, event_data, sizeof(wifi_event_ap_staconnected_t));
|
||||
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STADISCONNECTED) {
|
||||
memcpy(&event.data.ap_stadisconnected, event_data, sizeof(wifi_event_ap_stadisconnected_t));
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
} else if (event_base == IP_EVENT && event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) {
|
||||
memcpy(&event.data.ip_assigned_ip_to_client, event_data, sizeof(ip_event_assigned_ip_to_client_t));
|
||||
#else
|
||||
} else if (event_base == IP_EVENT && event_id == IP_EVENT_AP_STAIPASSIGNED) {
|
||||
memcpy(&event.data.ip_ap_staipassigned, event_data, sizeof(ip_event_ap_staipassigned_t));
|
||||
#endif
|
||||
} else {
|
||||
// did not match any event, don't send anything
|
||||
return;
|
||||
@@ -407,7 +416,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
|
||||
if (eap_opt.has_value()) {
|
||||
// note: all certificates and keys have to be null terminated. Lengths are appended by +1 to include \0.
|
||||
EAPAuth eap = *eap_opt;
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
|
||||
err = esp_eap_client_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length());
|
||||
#else
|
||||
err = esp_wifi_sta_wpa2_ent_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length());
|
||||
@@ -419,7 +428,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
|
||||
int client_cert_len = strlen(eap.client_cert);
|
||||
int client_key_len = strlen(eap.client_key);
|
||||
if (ca_cert_len) {
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
|
||||
err = esp_eap_client_set_ca_cert((uint8_t *) eap.ca_cert, ca_cert_len + 1);
|
||||
#else
|
||||
err = esp_wifi_sta_wpa2_ent_set_ca_cert((uint8_t *) eap.ca_cert, ca_cert_len + 1);
|
||||
@@ -432,7 +441,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
|
||||
// validation is not required as the config tool has already validated it
|
||||
if (client_cert_len && client_key_len) {
|
||||
// if we have certs, this must be EAP-TLS
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
|
||||
err = esp_eap_client_set_certificate_and_key((uint8_t *) eap.client_cert, client_cert_len + 1,
|
||||
(uint8_t *) eap.client_key, client_key_len + 1,
|
||||
(uint8_t *) eap.password.c_str(), eap.password.length());
|
||||
@@ -446,7 +455,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
|
||||
}
|
||||
} else {
|
||||
// in the absence of certs, assume this is username/password based
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
|
||||
err = esp_eap_client_set_username((uint8_t *) eap.username.c_str(), eap.username.length());
|
||||
#else
|
||||
err = esp_wifi_sta_wpa2_ent_set_username((uint8_t *) eap.username.c_str(), eap.username.length());
|
||||
@@ -454,7 +463,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGV(TAG, "set_username failed %d", err);
|
||||
}
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
|
||||
err = esp_eap_client_set_password((uint8_t *) eap.password.c_str(), eap.password.length());
|
||||
#else
|
||||
err = esp_wifi_sta_wpa2_ent_set_password((uint8_t *) eap.password.c_str(), eap.password.length());
|
||||
@@ -463,7 +472,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
|
||||
ESP_LOGV(TAG, "set_password failed %d", err);
|
||||
}
|
||||
// set TTLS Phase 2, defaults to MSCHAPV2
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
|
||||
err = esp_eap_client_set_ttls_phase2_method(eap.ttls_phase_2);
|
||||
#else
|
||||
err = esp_wifi_sta_wpa2_ent_set_ttls_phase2_method(eap.ttls_phase_2);
|
||||
@@ -472,7 +481,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
|
||||
ESP_LOGV(TAG, "set_ttls_phase2_method failed %d", err);
|
||||
}
|
||||
}
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
|
||||
err = esp_wifi_sta_enterprise_enable();
|
||||
#else
|
||||
err = esp_wifi_sta_wpa2_ent_enable();
|
||||
@@ -628,14 +637,26 @@ const char *get_disconnect_reason_str(uint8_t reason) {
|
||||
return "Auth Expired";
|
||||
case WIFI_REASON_AUTH_LEAVE:
|
||||
return "Auth Leave";
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
case WIFI_REASON_DISASSOC_DUE_TO_INACTIVITY:
|
||||
return "Disassociated Due to Inactivity";
|
||||
#else
|
||||
case WIFI_REASON_ASSOC_EXPIRE:
|
||||
return "Association Expired";
|
||||
#endif
|
||||
case WIFI_REASON_ASSOC_TOOMANY:
|
||||
return "Too Many Associations";
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
case WIFI_REASON_CLASS2_FRAME_FROM_NONAUTH_STA:
|
||||
return "Class 2 Frame from Non-Authenticated STA";
|
||||
case WIFI_REASON_CLASS3_FRAME_FROM_NONASSOC_STA:
|
||||
return "Class 3 Frame from Non-Associated STA";
|
||||
#else
|
||||
case WIFI_REASON_NOT_AUTHED:
|
||||
return "Not Authenticated";
|
||||
case WIFI_REASON_NOT_ASSOCED:
|
||||
return "Not Associated";
|
||||
#endif
|
||||
case WIFI_REASON_ASSOC_LEAVE:
|
||||
return "Association Leave";
|
||||
case WIFI_REASON_ASSOC_NOT_AUTHED:
|
||||
@@ -688,7 +709,7 @@ const char *get_disconnect_reason_str(uint8_t reason) {
|
||||
return "Association comeback time too long";
|
||||
case WIFI_REASON_SA_QUERY_TIMEOUT:
|
||||
return "SA query timeout";
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 2)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 2, 0)
|
||||
case WIFI_REASON_NO_AP_FOUND_W_COMPATIBLE_SECURITY:
|
||||
return "No AP found with compatible security";
|
||||
case WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD:
|
||||
@@ -917,8 +938,13 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
|
||||
ESP_LOGV(TAG, "AP client disconnected MAC=%s", mac_buf);
|
||||
#endif
|
||||
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
} else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) {
|
||||
const auto &it = data->data.ip_assigned_ip_to_client;
|
||||
#else
|
||||
} else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_AP_STAIPASSIGNED) {
|
||||
const auto &it = data->data.ip_ap_staipassigned;
|
||||
#endif
|
||||
ESP_LOGV(TAG, "AP client assigned IP " IPSTR, IP2STR(&it.ip));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,7 +589,10 @@ async def _add_looping_components() -> None:
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_global(cg.global_ns.namespace("esphome").using)
|
||||
# These can be used by user lambdas, put them to default scope
|
||||
# picolibc (IDF 6.0+) declares isnan in global scope, conflicting with using std::isnan
|
||||
cg.add_global(cg.RawStatement("#ifndef __PICOLIBC__"))
|
||||
cg.add_global(cg.RawExpression("using std::isnan"))
|
||||
cg.add_global(cg.RawStatement("#endif"))
|
||||
cg.add_global(cg.RawExpression("using std::min"))
|
||||
cg.add_global(cg.RawExpression("using std::max"))
|
||||
|
||||
|
||||
+130
-1
@@ -301,7 +301,7 @@ template<typename T, size_t N> class StaticVector {
|
||||
/// Not thread-safe. All access (push/pop/iteration) must occur from a single
|
||||
/// context, or the caller must provide external synchronization.
|
||||
template<typename T, size_t N> class StaticRingBuffer {
|
||||
using index_type = std::conditional_t<(N <= 255), uint8_t, uint16_t>;
|
||||
using index_type = std::conditional_t<(N <= std::numeric_limits<uint8_t>::max()), uint8_t, uint16_t>;
|
||||
|
||||
public:
|
||||
class Iterator {
|
||||
@@ -356,6 +356,13 @@ template<typename T, size_t N> class StaticRingBuffer {
|
||||
index_type size() const { return this->count_; }
|
||||
bool empty() const { return this->count_ == 0; }
|
||||
|
||||
/// Clear all elements (reset to empty)
|
||||
void clear() {
|
||||
this->head_ = 0;
|
||||
this->tail_ = 0;
|
||||
this->count_ = 0;
|
||||
}
|
||||
|
||||
Iterator begin() { return Iterator(this, 0); }
|
||||
Iterator end() { return Iterator(this, this->count_); }
|
||||
ConstIterator begin() const { return ConstIterator(this, 0); }
|
||||
@@ -368,6 +375,128 @@ template<typename T, size_t N> class StaticRingBuffer {
|
||||
index_type count_{0};
|
||||
};
|
||||
|
||||
/// Fixed-capacity circular buffer - allocates once at runtime, never reallocates.
|
||||
/// Runtime-sized equivalent of StaticRingBuffer - use when capacity is only known at initialization.
|
||||
/// Supports FIFO push/pop and iteration over queued elements.
|
||||
/// Not thread-safe.
|
||||
template<typename T, size_t MAX_CAPACITY = std::numeric_limits<uint16_t>::max()> class FixedRingBuffer {
|
||||
using index_type = std::conditional_t<
|
||||
(MAX_CAPACITY <= std::numeric_limits<uint8_t>::max()), uint8_t,
|
||||
std::conditional_t<(MAX_CAPACITY <= std::numeric_limits<uint16_t>::max()), uint16_t, uint32_t>>;
|
||||
|
||||
public:
|
||||
class Iterator {
|
||||
public:
|
||||
Iterator(FixedRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {}
|
||||
T &operator*() { return buf_->data_[(buf_->head_ + pos_) % buf_->capacity_]; }
|
||||
Iterator &operator++() {
|
||||
++pos_;
|
||||
return *this;
|
||||
}
|
||||
bool operator!=(const Iterator &other) const { return pos_ != other.pos_; }
|
||||
|
||||
private:
|
||||
FixedRingBuffer *buf_;
|
||||
index_type pos_;
|
||||
};
|
||||
|
||||
class ConstIterator {
|
||||
public:
|
||||
ConstIterator(const FixedRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {}
|
||||
const T &operator*() const { return buf_->data_[(buf_->head_ + pos_) % buf_->capacity_]; }
|
||||
ConstIterator &operator++() {
|
||||
++pos_;
|
||||
return *this;
|
||||
}
|
||||
bool operator!=(const ConstIterator &other) const { return pos_ != other.pos_; }
|
||||
|
||||
private:
|
||||
const FixedRingBuffer *buf_;
|
||||
index_type pos_;
|
||||
};
|
||||
|
||||
FixedRingBuffer() = default;
|
||||
~FixedRingBuffer() {
|
||||
if constexpr (std::is_trivial<T>::value) {
|
||||
::operator delete(this->data_);
|
||||
} else {
|
||||
delete[] this->data_;
|
||||
}
|
||||
}
|
||||
|
||||
// Disable copy
|
||||
FixedRingBuffer(const FixedRingBuffer &) = delete;
|
||||
FixedRingBuffer &operator=(const FixedRingBuffer &) = delete;
|
||||
|
||||
/// Allocate capacity - can only be called once
|
||||
void init(index_type capacity) {
|
||||
if constexpr (std::is_trivial<T>::value) {
|
||||
// Raw allocation without initialization (elements are written before read)
|
||||
// NOLINTNEXTLINE(bugprone-sizeof-expression)
|
||||
this->data_ = static_cast<T *>(::operator new(capacity * sizeof(T)));
|
||||
} else {
|
||||
this->data_ = new T[capacity];
|
||||
}
|
||||
this->capacity_ = capacity;
|
||||
}
|
||||
|
||||
/// Push a value. Returns false if full.
|
||||
bool push(const T &value) {
|
||||
if (this->count_ >= this->capacity_)
|
||||
return false;
|
||||
this->data_[this->tail_] = value;
|
||||
this->tail_ = (this->tail_ + 1) % this->capacity_;
|
||||
++this->count_;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Push a value, overwriting the oldest if full.
|
||||
void push_overwrite(const T &value) {
|
||||
this->data_[this->tail_] = value;
|
||||
this->tail_ = (this->tail_ + 1) % this->capacity_;
|
||||
if (this->count_ >= this->capacity_) {
|
||||
// Buffer full - advance head to drop oldest, count stays at capacity
|
||||
this->head_ = this->tail_;
|
||||
} else {
|
||||
++this->count_;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the oldest element.
|
||||
void pop() {
|
||||
if (this->count_ > 0) {
|
||||
this->head_ = (this->head_ + 1) % this->capacity_;
|
||||
--this->count_;
|
||||
}
|
||||
}
|
||||
|
||||
T &front() { return this->data_[this->head_]; }
|
||||
const T &front() const { return this->data_[this->head_]; }
|
||||
index_type size() const { return this->count_; }
|
||||
bool empty() const { return this->count_ == 0; }
|
||||
index_type capacity() const { return this->capacity_; }
|
||||
bool full() const { return this->count_ == this->capacity_; }
|
||||
|
||||
/// Clear all elements (reset to empty, keep capacity)
|
||||
void clear() {
|
||||
this->head_ = 0;
|
||||
this->tail_ = 0;
|
||||
this->count_ = 0;
|
||||
}
|
||||
|
||||
Iterator begin() { return Iterator(this, 0); }
|
||||
Iterator end() { return Iterator(this, this->count_); }
|
||||
ConstIterator begin() const { return ConstIterator(this, 0); }
|
||||
ConstIterator end() const { return ConstIterator(this, this->count_); }
|
||||
|
||||
protected:
|
||||
T *data_{nullptr};
|
||||
index_type head_{0};
|
||||
index_type tail_{0};
|
||||
index_type count_{0};
|
||||
index_type capacity_{0};
|
||||
};
|
||||
|
||||
/// Fixed-capacity vector - allocates once at runtime, never reallocates
|
||||
/// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append)
|
||||
/// when size is known at initialization but not at compile time
|
||||
|
||||
@@ -105,10 +105,11 @@ static void validate_static_string(const char *name) {
|
||||
// avoid the main thread modifying the list while it is being accessed.
|
||||
|
||||
// Calculate random offset for interval timers
|
||||
// Extracted from set_timer_common_ to reduce code size - float math + random_float()
|
||||
// only needed for intervals, not timeouts
|
||||
// Extracted from set_timer_common_ to reduce code size - only needed for intervals, not timeouts
|
||||
uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) {
|
||||
return static_cast<uint32_t>(std::min(delay / 2, MAX_INTERVAL_DELAY) * random_float());
|
||||
uint32_t max_offset = std::min(delay / 2, MAX_INTERVAL_DELAY);
|
||||
// Multiply-and-shift: uniform random in [0, max_offset) without floating point
|
||||
return static_cast<uint32_t>((static_cast<uint64_t>(random_uint32()) * max_offset) >> 32);
|
||||
}
|
||||
|
||||
// Check if a retry was already cancelled in items_ or to_add_
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pylint==4.0.5
|
||||
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.15.5 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.15.6 # also change in .pre-commit-config.yaml when updating
|
||||
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
|
||||
pre-commit
|
||||
|
||||
|
||||
+67
-37
@@ -6,6 +6,8 @@ what files have changed. It outputs JSON with the following structure:
|
||||
|
||||
{
|
||||
"integration_tests": true/false,
|
||||
"integration_tests_run_all": true/false,
|
||||
"integration_test_files": ["tests/integration/test_foo.py", ...],
|
||||
"clang_tidy": true/false,
|
||||
"clang_format": true/false,
|
||||
"python_linters": true/false,
|
||||
@@ -56,13 +58,13 @@ from helpers import (
|
||||
core_changed,
|
||||
filter_component_and_test_cpp_files,
|
||||
filter_component_and_test_files,
|
||||
get_all_dependencies,
|
||||
get_changed_components,
|
||||
get_component_from_path,
|
||||
get_component_test_files,
|
||||
get_components_from_integration_fixtures,
|
||||
get_components_with_dependencies,
|
||||
get_cpp_changed_components,
|
||||
get_fixture_to_test_files,
|
||||
get_integration_test_files_for_components,
|
||||
get_target_branch,
|
||||
git_ls_files,
|
||||
parse_test_filename,
|
||||
@@ -143,65 +145,88 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [
|
||||
]
|
||||
|
||||
|
||||
def should_run_integration_tests(branch: str | None = None) -> bool:
|
||||
"""Determine if integration tests should run based on changed files.
|
||||
def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[str]]:
|
||||
"""Determine which integration tests should run based on changed files.
|
||||
|
||||
This function is used by the CI workflow to intelligently skip integration tests when they're
|
||||
not needed, saving significant CI time and resources.
|
||||
This function is used by the CI workflow to intelligently skip or filter
|
||||
integration tests, saving significant CI time and resources.
|
||||
|
||||
Integration tests will run when ANY of the following conditions are met:
|
||||
Returns (run_all=True, []) when ANY of the following conditions are met:
|
||||
|
||||
1. Core C++ files changed (esphome/core/*)
|
||||
- Any .cpp, .h, .tcc files in the core directory
|
||||
- These files contain fundamental functionality used throughout ESPHome
|
||||
- Examples: esphome/core/component.cpp, esphome/core/application.h
|
||||
|
||||
2. Core Python files changed (esphome/core/*.py)
|
||||
- Only .py files in the esphome/core/ directory
|
||||
- These are core Python files that affect the entire system
|
||||
- Examples: esphome/core/config.py, esphome/core/__init__.py
|
||||
- NOT included: esphome/*.py, esphome/dashboard/*.py, esphome/components/*/*.py
|
||||
|
||||
3. Integration test files changed
|
||||
- Any file in tests/integration/ directory
|
||||
- This includes test files themselves and fixture YAML files
|
||||
- Examples: tests/integration/test_api.py, tests/integration/fixtures/api.yaml
|
||||
3. Integration test infrastructure files changed
|
||||
- conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc.
|
||||
|
||||
4. Components used by integration tests (or their dependencies) changed
|
||||
- The function parses all YAML files in tests/integration/fixtures/
|
||||
- Extracts which components are used in integration tests
|
||||
- Recursively finds all dependencies of those components
|
||||
- If any of these components have changes, tests must run
|
||||
- Example: If api.yaml uses 'sensor' and 'api' components, and 'api' depends on 'socket',
|
||||
then changes to sensor/, api/, or socket/ components trigger tests
|
||||
Returns (run_all=False, [test_files...]) when:
|
||||
|
||||
4. Specific integration test files changed
|
||||
- Only those specific test files are returned
|
||||
|
||||
5. Components used by integration tests (or their dependencies) changed
|
||||
- Only test files whose fixtures use the changed components are returned
|
||||
|
||||
Args:
|
||||
branch: Branch to compare against. If None, uses default.
|
||||
|
||||
Returns:
|
||||
True if integration tests should run, False otherwise.
|
||||
Tuple of (run_all, test_files) where:
|
||||
- run_all: True if all integration tests should run
|
||||
- test_files: List of specific test file paths to run (empty if run_all
|
||||
is True, or if no tests need to run)
|
||||
"""
|
||||
files = changed_files(branch)
|
||||
|
||||
if core_changed(files):
|
||||
# If any core files changed, run integration tests
|
||||
return True
|
||||
# If any core files changed, run all integration tests
|
||||
return (True, [])
|
||||
|
||||
# Check if any integration test files changed
|
||||
if any("tests/integration" in file for file in files):
|
||||
return True
|
||||
# If infrastructure Python files changed (conftest, utils, etc.), run all tests
|
||||
# Excludes test files (test_*.py), fixtures, and non-Python files (README.md)
|
||||
if any(
|
||||
f.startswith("tests/integration/")
|
||||
and f.endswith(".py")
|
||||
and not f.startswith("tests/integration/test_")
|
||||
and "/fixtures/" not in f
|
||||
for f in files
|
||||
):
|
||||
return (True, [])
|
||||
|
||||
# Get all components used in integration tests and their dependencies
|
||||
fixture_components = get_components_from_integration_fixtures()
|
||||
all_required_components = get_all_dependencies(fixture_components)
|
||||
# Collect specific test files that need to run
|
||||
test_files: set[str] = set()
|
||||
fixture_to_test_files = get_fixture_to_test_files()
|
||||
|
||||
# Check if any required components changed
|
||||
for file in files:
|
||||
component = get_component_from_path(file)
|
||||
if component and component in all_required_components:
|
||||
return True
|
||||
for f in files:
|
||||
if f.startswith("tests/integration/test_") and f.endswith(".py"):
|
||||
test_files.add(f)
|
||||
elif f.startswith("tests/integration/fixtures/"):
|
||||
if f.endswith(".yaml"):
|
||||
# Fixture YAML changed - add corresponding test file(s)
|
||||
test_files.update(fixture_to_test_files.get(Path(f).stem, ()))
|
||||
else:
|
||||
# Non-YAML fixture file changed (e.g., external_components/)
|
||||
# Run all tests since we can't determine which tests are affected
|
||||
return (True, [])
|
||||
|
||||
return False
|
||||
# Find test files whose fixtures use any of the changed components
|
||||
changed_component_set = {
|
||||
component for file in files if (component := get_component_from_path(file))
|
||||
}
|
||||
if changed_component_set:
|
||||
test_files.update(
|
||||
get_integration_test_files_for_components(changed_component_set)
|
||||
)
|
||||
|
||||
if test_files:
|
||||
return (False, sorted(test_files))
|
||||
|
||||
return (False, [])
|
||||
|
||||
|
||||
@cache
|
||||
@@ -682,7 +707,10 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine what should run
|
||||
run_integration = should_run_integration_tests(args.branch)
|
||||
integration_run_all, integration_test_files = determine_integration_tests(
|
||||
args.branch
|
||||
)
|
||||
run_integration = integration_run_all or bool(integration_test_files)
|
||||
run_clang_tidy = should_run_clang_tidy(args.branch)
|
||||
run_clang_format = should_run_clang_format(args.branch)
|
||||
run_python_linters = should_run_python_linters(args.branch)
|
||||
@@ -810,6 +838,8 @@ def main() -> None:
|
||||
|
||||
output: dict[str, Any] = {
|
||||
"integration_tests": run_integration,
|
||||
"integration_tests_run_all": integration_run_all,
|
||||
"integration_test_files": integration_test_files,
|
||||
"clang_tidy": run_clang_tidy,
|
||||
"clang_tidy_mode": clang_tidy_mode,
|
||||
"clang_format": run_clang_format,
|
||||
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from esphome.components.rp2040 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION
|
||||
from esphome.components.rp2040.generate_boards import generate
|
||||
from esphome.helpers import write_file_if_changed
|
||||
|
||||
ver = RECOMMENDED_ARDUINO_FRAMEWORK_VERSION
|
||||
version_tag: str = f"{ver.major}.{ver.minor}.{ver.patch}"
|
||||
root: Path = Path(__file__).parent.parent
|
||||
boards_file_path: Path = root / "esphome" / "components" / "rp2040" / "boards.py"
|
||||
|
||||
|
||||
def main(check: bool) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
"-q",
|
||||
"-c",
|
||||
"advice.detachedHead=false",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
version_tag,
|
||||
"https://github.com/earlephilhower/arduino-pico",
|
||||
tempdir,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
content: str = generate(Path(tempdir))
|
||||
|
||||
if check:
|
||||
existing_content: str = boards_file_path.read_text(encoding="utf-8")
|
||||
if existing_content != content:
|
||||
print("esphome/components/rp2040/boards.py is not up to date.")
|
||||
print("Please run `script/generate-rp2040-boards.py`")
|
||||
sys.exit(1)
|
||||
print("esphome/components/rp2040/boards.py is up to date")
|
||||
elif write_file_if_changed(boards_file_path, content):
|
||||
print("RP2040 boards updated successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
help="Check if the boards.py file is up to date.",
|
||||
action="store_true",
|
||||
)
|
||||
args: argparse.Namespace = parser.parse_args()
|
||||
main(args.check)
|
||||
+118
-14
@@ -700,37 +700,141 @@ def get_all_dependencies(
|
||||
return all_components
|
||||
|
||||
|
||||
def _extract_components_from_yaml(config: dict) -> set[str]:
|
||||
"""Extract component names from a parsed YAML config.
|
||||
|
||||
Args:
|
||||
config: Parsed YAML configuration dictionary
|
||||
|
||||
Returns:
|
||||
Set of component names found in the config
|
||||
"""
|
||||
components: set[str] = set()
|
||||
|
||||
# Add all top-level component keys (skip YAML anchor keys starting with '.')
|
||||
components.update(k for k in config if isinstance(k, str) and not k.startswith("."))
|
||||
|
||||
# Add platform values from list entries (e.g., sensor -> platform: template adds "template")
|
||||
for value in config.values():
|
||||
if isinstance(value, list):
|
||||
components.update(
|
||||
item["platform"]
|
||||
for item in value
|
||||
if isinstance(item, dict) and "platform" in item
|
||||
)
|
||||
|
||||
return components
|
||||
|
||||
|
||||
def get_components_from_integration_fixtures() -> set[str]:
|
||||
"""Extract all components used in integration test fixtures.
|
||||
|
||||
Returns:
|
||||
Set of component names used in integration test fixtures
|
||||
"""
|
||||
return {
|
||||
comp
|
||||
for components in get_components_per_integration_fixture().values()
|
||||
for comp in components
|
||||
}
|
||||
|
||||
|
||||
@cache
|
||||
def get_components_per_integration_fixture() -> dict[str, set[str]]:
|
||||
"""Extract components used in each integration test fixture.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping fixture name (stem) to set of component names
|
||||
"""
|
||||
from esphome import yaml_util
|
||||
|
||||
components: set[str] = set()
|
||||
result: dict[str, set[str]] = {}
|
||||
fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures"
|
||||
|
||||
for yaml_file in fixtures_dir.glob("*.yaml"):
|
||||
config: dict[str, any] | None = yaml_util.load_yaml(yaml_file)
|
||||
config: dict[str, Any] | None = yaml_util.load_yaml(yaml_file)
|
||||
if not config:
|
||||
continue
|
||||
|
||||
# Add all top-level component keys (skip YAML anchor keys starting with '.')
|
||||
components.update(
|
||||
k for k in config if isinstance(k, str) and not k.startswith(".")
|
||||
)
|
||||
result[yaml_file.stem] = _extract_components_from_yaml(config)
|
||||
|
||||
# Add platform components (e.g., output.template)
|
||||
for value in config.values():
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
return result
|
||||
|
||||
for item in value:
|
||||
if isinstance(item, dict) and "platform" in item:
|
||||
components.add(item["platform"])
|
||||
|
||||
return components
|
||||
_TEST_FUNC_RE = re.compile(r"async def (test_\w+)")
|
||||
|
||||
|
||||
@cache
|
||||
def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
|
||||
"""Map integration test fixture names to the test files that use them.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping fixture name to frozenset of test file paths
|
||||
(relative to repo root)
|
||||
"""
|
||||
integration_dir = Path(__file__).parent.parent / "tests" / "integration"
|
||||
result: dict[str, set[str]] = {}
|
||||
|
||||
for test_file in integration_dir.glob("test_*.py"):
|
||||
content = test_file.read_text(encoding="utf-8")
|
||||
rel_path = test_file.relative_to(Path(__file__).parent.parent).as_posix()
|
||||
for func in _TEST_FUNC_RE.findall(content):
|
||||
base_name = func.replace("test_", "").partition("[")[0]
|
||||
result.setdefault(base_name, set()).add(rel_path)
|
||||
|
||||
return {k: frozenset(v) for k, v in result.items()}
|
||||
|
||||
|
||||
@cache
|
||||
def _get_component_to_integration_test_files() -> dict[str, frozenset[str]]:
|
||||
"""Build index mapping each component to the test files that depend on it.
|
||||
|
||||
Resolves full dependency trees once per fixture, then inverts the mapping
|
||||
so lookups are O(1) per component.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping component name to frozenset of test file paths
|
||||
"""
|
||||
fixture_components = get_components_per_integration_fixture()
|
||||
fixture_to_test_files = get_fixture_to_test_files()
|
||||
|
||||
result: dict[str, set[str]] = {}
|
||||
for fixture_name, components in fixture_components.items():
|
||||
test_files = fixture_to_test_files.get(fixture_name)
|
||||
if not test_files:
|
||||
continue
|
||||
# Get full dependency tree for this fixture's components
|
||||
all_deps = get_all_dependencies(components)
|
||||
for dep in all_deps:
|
||||
result.setdefault(dep, set()).update(test_files)
|
||||
|
||||
return {k: frozenset(v) for k, v in result.items()}
|
||||
|
||||
|
||||
def get_integration_test_files_for_components(
|
||||
changed_components: set[str],
|
||||
) -> list[str]:
|
||||
"""Get integration test file paths that use any of the given components.
|
||||
|
||||
Uses a precomputed component → test files index for O(C) lookup
|
||||
where C is the number of changed components.
|
||||
|
||||
Args:
|
||||
changed_components: Set of component names that have changed
|
||||
|
||||
Returns:
|
||||
Sorted list of test file paths relative to repo root
|
||||
(e.g., ["tests/integration/test_api.py", ...])
|
||||
"""
|
||||
component_to_tests = _get_component_to_integration_test_files()
|
||||
|
||||
return sorted(
|
||||
{
|
||||
test_file
|
||||
for component in changed_components
|
||||
for test_file in component_to_tests.get(component, ())
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def filter_component_and_test_files(file_path: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
esphome:
|
||||
name: online-image-bmp
|
||||
|
||||
host:
|
||||
|
||||
http_request:
|
||||
|
||||
display:
|
||||
|
||||
online_image:
|
||||
- url: http://127.0.0.1:HTTP_PORT/foo.bmp
|
||||
id: myimg
|
||||
format: BMP
|
||||
type: RGB
|
||||
on_download_finished:
|
||||
logger.log:
|
||||
format: "download finished. cache hit: %u"
|
||||
args: [cached]
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: fetch_image
|
||||
then:
|
||||
- component.update: myimg
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: host-template-text-save-test
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
batch_delay: 0ms
|
||||
|
||||
logger:
|
||||
|
||||
preferences:
|
||||
flash_write_interval: 0s
|
||||
|
||||
text:
|
||||
- platform: template
|
||||
name: "Test Text Restore"
|
||||
id: test_text_restore
|
||||
optimistic: true
|
||||
min_length: 0
|
||||
max_length: 10
|
||||
mode: text
|
||||
initial_value: "hello"
|
||||
restore_value: true
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
# black 8x8 RGB BMP, generated with
|
||||
# from PIL import Image
|
||||
# from io import BytesIO
|
||||
# b = BytesIO()
|
||||
# img = Image.new("RGB", (8, 8))
|
||||
# img.save(b, format="BMP")
|
||||
# b.getvalue()
|
||||
BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
LEN_BMP_IMAGE = len(BMP_IMAGE)
|
||||
|
||||
|
||||
def handle_http(http_request_future):
|
||||
async def handler(reader, writer):
|
||||
try:
|
||||
async with asyncio.timeout(1.0):
|
||||
data = await reader.readuntil(b"\r\n")
|
||||
|
||||
# ensure our request matches the expectation
|
||||
expected_request = b"GET /foo.bmp HTTP/1.1\r\n"
|
||||
assert data[: len(expected_request)] == expected_request
|
||||
|
||||
# consume rest of request
|
||||
async with asyncio.timeout(1.0):
|
||||
data = await reader.readuntil(b"\r\n\r\n")
|
||||
|
||||
http_request_future.set_result(True)
|
||||
|
||||
http_response = [
|
||||
b"HTTP/1.1 200 OK",
|
||||
b"Content-Length: %d" % LEN_BMP_IMAGE,
|
||||
b"Content-Type: text/plain",
|
||||
b"Connection: close",
|
||||
b"",
|
||||
b"",
|
||||
]
|
||||
writer.write(b"\r\n".join(http_response))
|
||||
await writer.drain()
|
||||
|
||||
writer.write(BMP_IMAGE)
|
||||
|
||||
await writer.drain()
|
||||
except Exception as exc:
|
||||
if not http_request_future.done():
|
||||
http_request_future.set_exception(exc)
|
||||
raise
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_online_image_bmp(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Esphome shouldn't block the main loop when a http response is slow"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Track http request
|
||||
http_request_future = loop.create_future()
|
||||
download_finished_future = loop.create_future()
|
||||
downloaded_bytes_future = loop.create_future()
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
"""Check log output for expected messages."""
|
||||
|
||||
if match := re.search(r"Image fully downloaded, (\d+) bytes", line):
|
||||
downloaded_bytes_future.set_result(int(match.group(1)))
|
||||
|
||||
if "download finished" in line:
|
||||
download_finished_future.set_result(True)
|
||||
|
||||
server = await asyncio.start_server(
|
||||
handle_http(http_request_future), "127.0.0.1", 0
|
||||
)
|
||||
http_server_port = server.sockets[0].getsockname()[1]
|
||||
|
||||
config = yaml_config.replace("HTTP_PORT", str(http_server_port))
|
||||
|
||||
# Run with log monitoring
|
||||
async with (
|
||||
server,
|
||||
run_compiled(config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify device info
|
||||
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "online-image-bmp"
|
||||
|
||||
# List services to find our test service
|
||||
_, services = await client.list_entities_services()
|
||||
|
||||
# Find test service
|
||||
request_service = next((s for s in services if s.name == "fetch_image"), None)
|
||||
|
||||
assert request_service is not None, "fetch_image service not found"
|
||||
|
||||
await client.execute_service(request_service, {})
|
||||
|
||||
async with asyncio.timeout(0.1):
|
||||
await http_request_future
|
||||
|
||||
async with asyncio.timeout(0.5):
|
||||
numbytes = await downloaded_bytes_future
|
||||
assert numbytes == LEN_BMP_IMAGE
|
||||
await download_finished_future
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Integration test for template text restore_value persistence.
|
||||
|
||||
Tests that:
|
||||
1. A template text with restore_value saves its value to preferences
|
||||
2. The saved value persists across restarts (binary re-run)
|
||||
3. Setting the same value again does not produce a spurious "too long" warning
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import TextInfo, TextState
|
||||
import pytest
|
||||
|
||||
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import CompileFunction, ConfigWriter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_text_save(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> None:
|
||||
"""Test template text save/restore persistence and duplicate-save behavior."""
|
||||
port, port_socket = reserved_tcp_port
|
||||
|
||||
# Clean up any stale preference file from previous runs
|
||||
prefs_file = (
|
||||
Path.home() / ".esphome" / "prefs" / "host-template-text-save-test.prefs"
|
||||
)
|
||||
if prefs_file.exists():
|
||||
prefs_file.unlink()
|
||||
|
||||
# Write and compile once
|
||||
config_path = await write_yaml_config(yaml_config)
|
||||
binary_path = await compile_esphome(config_path)
|
||||
|
||||
# Release the reserved port so the binary can bind to it
|
||||
port_socket.close()
|
||||
|
||||
# --- First run: set a value and verify no spurious warnings ---
|
||||
warning_lines: list[str] = []
|
||||
|
||||
def capture_warnings(line: str) -> None:
|
||||
if "too long to save" in line.lower():
|
||||
warning_lines.append(line)
|
||||
|
||||
async with (
|
||||
run_binary_and_wait_for_port(
|
||||
binary_path, "127.0.0.1", port, line_callback=capture_warnings
|
||||
),
|
||||
wait_and_connect_api_client(port=port) as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info.name == "host-template-text-save-test"
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
text_entity = require_entity(
|
||||
entities, "test_text_restore", TextInfo, "Test Text Restore"
|
||||
)
|
||||
|
||||
# Set up state tracking
|
||||
loop = asyncio.get_running_loop()
|
||||
state_futures: dict[int, asyncio.Future[Any]] = {}
|
||||
|
||||
def on_state(state: Any) -> None:
|
||||
if state.key in state_futures and not state_futures[state.key].done():
|
||||
state_futures[state.key].set_result(state)
|
||||
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Verify initial value from config
|
||||
initial = initial_state_helper.initial_states[text_entity.key]
|
||||
assert isinstance(initial, TextState)
|
||||
assert initial.state == "hello"
|
||||
|
||||
async def wait_for_state(key: int, timeout: float = 2.0) -> Any:
|
||||
state_futures[key] = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(state_futures[key], timeout)
|
||||
finally:
|
||||
state_futures.pop(key, None)
|
||||
|
||||
# Set a new value that fits within max_length
|
||||
client.text_command(key=text_entity.key, state="world")
|
||||
state = await wait_for_state(text_entity.key)
|
||||
assert state.state == "world"
|
||||
|
||||
# Set the same value again - should NOT produce "too long" warning
|
||||
client.text_command(key=text_entity.key, state="world")
|
||||
# Give time for the warning to appear (if any)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# No warnings should have appeared
|
||||
assert warning_lines == [], (
|
||||
f"Unexpected 'too long to save' warning(s): {warning_lines}"
|
||||
)
|
||||
|
||||
# --- Second run: verify the value was restored from preferences ---
|
||||
async with (
|
||||
run_binary_and_wait_for_port(binary_path, "127.0.0.1", port),
|
||||
wait_and_connect_api_client(port=port) as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
text_entity = require_entity(
|
||||
entities, "test_text_restore", TextInfo, "Test Text Restore"
|
||||
)
|
||||
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(lambda s: None))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# The value should be "world" - restored from preferences
|
||||
restored = initial_state_helper.initial_states[text_entity.key]
|
||||
assert isinstance(restored, TextState)
|
||||
assert restored.state == "world", (
|
||||
f"Expected restored value 'world', got '{restored.state}'"
|
||||
)
|
||||
|
||||
# Clean up preference file
|
||||
if prefs_file.exists():
|
||||
prefs_file.unlink()
|
||||
@@ -29,9 +29,9 @@ spec.loader.exec_module(determine_jobs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_should_run_integration_tests() -> Generator[Mock, None, None]:
|
||||
"""Mock should_run_integration_tests from helpers."""
|
||||
with patch.object(determine_jobs, "should_run_integration_tests") as mock:
|
||||
def mock_determine_integration_tests() -> Generator[Mock, None, None]:
|
||||
"""Mock determine_integration_tests."""
|
||||
with patch.object(determine_jobs, "determine_integration_tests") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ def clear_determine_jobs_caches() -> None:
|
||||
|
||||
|
||||
def test_main_all_tests_should_run(
|
||||
mock_should_run_integration_tests: Mock,
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
@@ -100,7 +100,7 @@ def test_main_all_tests_should_run(
|
||||
# Ensure we're not in GITHUB_ACTIONS mode for this test
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_should_run_integration_tests.return_value = True
|
||||
mock_determine_integration_tests.return_value = (True, [])
|
||||
mock_should_run_clang_tidy.return_value = True
|
||||
mock_should_run_clang_format.return_value = True
|
||||
mock_should_run_python_linters.return_value = True
|
||||
@@ -152,6 +152,8 @@ def test_main_all_tests_should_run(
|
||||
output = json.loads(captured.out)
|
||||
|
||||
assert output["integration_tests"] is True
|
||||
assert output["integration_tests_run_all"] is True
|
||||
assert output["integration_test_files"] == []
|
||||
assert output["clang_tidy"] is True
|
||||
assert output["clang_tidy_mode"] in ["nosplit", "split"]
|
||||
assert output["clang_format"] is True
|
||||
@@ -183,7 +185,7 @@ def test_main_all_tests_should_run(
|
||||
|
||||
|
||||
def test_main_no_tests_should_run(
|
||||
mock_should_run_integration_tests: Mock,
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
@@ -196,7 +198,7 @@ def test_main_no_tests_should_run(
|
||||
# Ensure we're not in GITHUB_ACTIONS mode for this test
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_should_run_integration_tests.return_value = False
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
@@ -233,6 +235,8 @@ def test_main_no_tests_should_run(
|
||||
output = json.loads(captured.out)
|
||||
|
||||
assert output["integration_tests"] is False
|
||||
assert output["integration_tests_run_all"] is False
|
||||
assert output["integration_test_files"] == []
|
||||
assert output["clang_tidy"] is False
|
||||
assert output["clang_tidy_mode"] == "disabled"
|
||||
assert output["clang_format"] is False
|
||||
@@ -253,7 +257,7 @@ def test_main_no_tests_should_run(
|
||||
|
||||
|
||||
def test_main_with_branch_argument(
|
||||
mock_should_run_integration_tests: Mock,
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
@@ -266,7 +270,7 @@ def test_main_with_branch_argument(
|
||||
# Ensure we're not in GITHUB_ACTIONS mode for this test
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_should_run_integration_tests.return_value = False
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = True
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = True
|
||||
@@ -302,7 +306,7 @@ def test_main_with_branch_argument(
|
||||
determine_jobs.main()
|
||||
|
||||
# Check that functions were called with branch
|
||||
mock_should_run_integration_tests.assert_called_once_with("main")
|
||||
mock_determine_integration_tests.assert_called_once_with("main")
|
||||
mock_should_run_clang_tidy.assert_called_once_with("main")
|
||||
mock_should_run_clang_format.assert_called_once_with("main")
|
||||
mock_should_run_python_linters.assert_called_once_with("main")
|
||||
@@ -312,6 +316,8 @@ def test_main_with_branch_argument(
|
||||
output = json.loads(captured.out)
|
||||
|
||||
assert output["integration_tests"] is False
|
||||
assert output["integration_tests_run_all"] is False
|
||||
assert output["integration_test_files"] == []
|
||||
assert output["clang_tidy"] is True
|
||||
assert output["clang_tidy_mode"] in ["nosplit", "split"]
|
||||
assert output["clang_format"] is False
|
||||
@@ -334,30 +340,33 @@ def test_main_with_branch_argument(
|
||||
assert output["cpp_unit_tests_components"] == ["mqtt"]
|
||||
|
||||
|
||||
def test_should_run_integration_tests(
|
||||
def test_determine_integration_tests(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test should_run_integration_tests function."""
|
||||
# Core C++ files trigger tests
|
||||
"""Test determine_integration_tests function."""
|
||||
# Core C++ files trigger run_all
|
||||
with patch.object(
|
||||
determine_jobs, "changed_files", return_value=["esphome/core/component.cpp"]
|
||||
):
|
||||
result = determine_jobs.should_run_integration_tests()
|
||||
assert result is True
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is True
|
||||
assert test_files == []
|
||||
|
||||
# Core Python files trigger tests
|
||||
# Core Python files trigger run_all
|
||||
with patch.object(
|
||||
determine_jobs, "changed_files", return_value=["esphome/core/config.py"]
|
||||
):
|
||||
result = determine_jobs.should_run_integration_tests()
|
||||
assert result is True
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is True
|
||||
assert test_files == []
|
||||
|
||||
# Python files directly in esphome/ do NOT trigger tests
|
||||
with patch.object(
|
||||
determine_jobs, "changed_files", return_value=["esphome/config.py"]
|
||||
):
|
||||
result = determine_jobs.should_run_integration_tests()
|
||||
assert result is False
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is False
|
||||
assert test_files == []
|
||||
|
||||
# Python files in subdirectories (not core) do NOT trigger tests
|
||||
with patch.object(
|
||||
@@ -365,35 +374,151 @@ def test_should_run_integration_tests(
|
||||
"changed_files",
|
||||
return_value=["esphome/dashboard/web_server.py"],
|
||||
):
|
||||
result = determine_jobs.should_run_integration_tests()
|
||||
assert result is False
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is False
|
||||
assert test_files == []
|
||||
|
||||
|
||||
def test_should_run_integration_tests_with_branch() -> None:
|
||||
"""Test should_run_integration_tests with branch argument."""
|
||||
def test_determine_integration_tests_with_branch() -> None:
|
||||
"""Test determine_integration_tests with branch argument."""
|
||||
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
||||
mock_changed.return_value = []
|
||||
determine_jobs.should_run_integration_tests("release")
|
||||
run_all, test_files = determine_jobs.determine_integration_tests("release")
|
||||
mock_changed.assert_called_once_with("release")
|
||||
assert run_all is False
|
||||
assert test_files == []
|
||||
|
||||
|
||||
def test_should_run_integration_tests_component_dependency() -> None:
|
||||
"""Test that integration tests run when components used in fixtures change."""
|
||||
def test_determine_integration_tests_component_dependency() -> None:
|
||||
"""Test that integration tests return specific test files when components used in fixtures change."""
|
||||
with (
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=["esphome/components/api/api.cpp"],
|
||||
),
|
||||
patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map,
|
||||
patch.object(
|
||||
determine_jobs, "get_components_from_integration_fixtures"
|
||||
) as mock_fixtures,
|
||||
determine_jobs, "get_integration_test_files_for_components"
|
||||
) as mock_test_files,
|
||||
):
|
||||
mock_fixtures.return_value = {"api", "sensor"}
|
||||
with patch.object(determine_jobs, "get_all_dependencies") as mock_deps:
|
||||
mock_deps.return_value = {"api", "sensor", "network"}
|
||||
result = determine_jobs.should_run_integration_tests()
|
||||
assert result is True
|
||||
mock_fixture_map.return_value = {}
|
||||
mock_test_files.return_value = [
|
||||
"tests/integration/test_api.py",
|
||||
"tests/integration/test_sensor.py",
|
||||
]
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is False
|
||||
assert test_files == [
|
||||
"tests/integration/test_api.py",
|
||||
"tests/integration/test_sensor.py",
|
||||
]
|
||||
|
||||
|
||||
def test_determine_integration_tests_component_only_affected_tests() -> None:
|
||||
"""Test that only tests using the changed component are returned."""
|
||||
with (
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=["esphome/components/modbus/modbus.cpp"],
|
||||
),
|
||||
patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}),
|
||||
patch.object(
|
||||
determine_jobs, "get_integration_test_files_for_components"
|
||||
) as mock_test_files,
|
||||
):
|
||||
mock_test_files.return_value = [
|
||||
"tests/integration/test_uart_mock_modbus.py",
|
||||
]
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is False
|
||||
assert test_files == ["tests/integration/test_uart_mock_modbus.py"]
|
||||
# Verify it was called with the right component
|
||||
mock_test_files.assert_called_once_with({"modbus"})
|
||||
|
||||
|
||||
def test_determine_integration_tests_infra_file_runs_all() -> None:
|
||||
"""Test that changing infrastructure files (conftest.py, etc.) runs all tests."""
|
||||
with patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=["tests/integration/conftest.py"],
|
||||
):
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is True
|
||||
assert test_files == []
|
||||
|
||||
|
||||
def test_determine_integration_tests_readme_does_not_run_all() -> None:
|
||||
"""Test that changing README.md does not trigger integration tests."""
|
||||
with patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=["tests/integration/README.md"],
|
||||
):
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is False
|
||||
assert test_files == []
|
||||
|
||||
|
||||
def test_determine_integration_tests_changed_test_file() -> None:
|
||||
"""Test that changing a specific test file only runs that test."""
|
||||
with (
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=["tests/integration/test_syslog.py"],
|
||||
),
|
||||
patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_integration_test_files_for_components",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is False
|
||||
assert test_files == ["tests/integration/test_syslog.py"]
|
||||
|
||||
|
||||
def test_determine_integration_tests_changed_fixture_yaml() -> None:
|
||||
"""Test that changing a fixture YAML runs the corresponding test file."""
|
||||
with (
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=["tests/integration/fixtures/uart_mock_modbus.yaml"],
|
||||
),
|
||||
patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map,
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_integration_test_files_for_components",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
mock_fixture_map.return_value = {
|
||||
"uart_mock_modbus": frozenset(
|
||||
{"tests/integration/test_uart_mock_modbus.py"}
|
||||
),
|
||||
}
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is False
|
||||
assert test_files == ["tests/integration/test_uart_mock_modbus.py"]
|
||||
|
||||
|
||||
def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None:
|
||||
"""Test that non-YAML changes under fixtures/ (e.g., external_components) run all tests."""
|
||||
with patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=[
|
||||
"tests/integration/fixtures/external_components/test_component/__init__.py"
|
||||
],
|
||||
):
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is True
|
||||
assert test_files == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -538,7 +663,7 @@ def test_count_changed_cpp_files_with_branch() -> None:
|
||||
|
||||
|
||||
def test_main_filters_components_without_tests(
|
||||
mock_should_run_integration_tests: Mock,
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
@@ -551,7 +676,7 @@ def test_main_filters_components_without_tests(
|
||||
# Ensure we're not in GITHUB_ACTIONS mode for this test
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_should_run_integration_tests.return_value = False
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
@@ -631,7 +756,7 @@ def test_main_filters_components_without_tests(
|
||||
|
||||
|
||||
def test_main_detects_components_with_variant_tests(
|
||||
mock_should_run_integration_tests: Mock,
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
@@ -649,7 +774,7 @@ def test_main_detects_components_with_variant_tests(
|
||||
# Ensure we're not in GITHUB_ACTIONS mode for this test
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_should_run_integration_tests.return_value = False
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
@@ -999,7 +1124,7 @@ def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_clang_tidy_mode_full_scan(
|
||||
mock_should_run_integration_tests: Mock,
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
@@ -1010,7 +1135,7 @@ def test_clang_tidy_mode_full_scan(
|
||||
"""Test that full scan (hash changed) always uses split mode."""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_should_run_integration_tests.return_value = False
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = True
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
@@ -1065,7 +1190,7 @@ def test_clang_tidy_mode_targeted_scan(
|
||||
component_count: int,
|
||||
files_per_component: int,
|
||||
expected_mode: str,
|
||||
mock_should_run_integration_tests: Mock,
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
@@ -1076,7 +1201,7 @@ def test_clang_tidy_mode_targeted_scan(
|
||||
"""Test clang-tidy mode selection based on files_to_check count."""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_should_run_integration_tests.return_value = False
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = True
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
@@ -1123,7 +1248,7 @@ def test_clang_tidy_mode_targeted_scan(
|
||||
|
||||
|
||||
def test_main_core_files_changed_still_detects_components(
|
||||
mock_should_run_integration_tests: Mock,
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
@@ -1135,7 +1260,7 @@ def test_main_core_files_changed_still_detects_components(
|
||||
"""Test that component changes are detected even when core files change."""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_should_run_integration_tests.return_value = True
|
||||
mock_determine_integration_tests.return_value = (True, [])
|
||||
mock_should_run_clang_tidy.return_value = True
|
||||
mock_should_run_clang_format.return_value = True
|
||||
mock_should_run_python_linters.return_value = True
|
||||
@@ -1604,7 +1729,7 @@ def test_detect_platform_hint_from_filename_case_insensitive(
|
||||
|
||||
def test_component_batching_beta_branch_40_per_batch(
|
||||
tmp_path: Path,
|
||||
mock_should_run_integration_tests: Mock,
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
@@ -1628,7 +1753,7 @@ def test_component_batching_beta_branch_40_per_batch(
|
||||
(comp_dir / "test.esp32-idf.yaml").write_text(f"# Test for {comp}")
|
||||
|
||||
# Setup mocks
|
||||
mock_should_run_integration_tests.return_value = False
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
|
||||
@@ -36,6 +36,7 @@ def clear_helpers_cache() -> None:
|
||||
"""Clear cached functions before each test."""
|
||||
helpers._get_github_event_data.cache_clear()
|
||||
helpers._get_changed_files_github_actions.cache_clear()
|
||||
helpers.get_components_per_integration_fixture.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -1111,7 +1112,7 @@ def test_get_components_from_integration_fixtures() -> None:
|
||||
"gpio",
|
||||
}
|
||||
|
||||
mock_yaml_file = Mock()
|
||||
mock_yaml_file = Mock(stem="test_fixture")
|
||||
|
||||
with (
|
||||
patch("pathlib.Path.glob") as mock_glob,
|
||||
@@ -1133,7 +1134,7 @@ def test_get_components_from_integration_fixtures_skips_yaml_anchors() -> None:
|
||||
".binary_filters": {"filters": [{"settle": "50ms"}]},
|
||||
}
|
||||
|
||||
mock_yaml_file = Mock()
|
||||
mock_yaml_file = Mock(stem="test_fixture")
|
||||
|
||||
with (
|
||||
patch("pathlib.Path.glob") as mock_glob,
|
||||
@@ -1148,6 +1149,31 @@ def test_get_components_from_integration_fixtures_skips_yaml_anchors() -> None:
|
||||
assert components == {"sensor", "esphome", "template"}
|
||||
|
||||
|
||||
def test_get_integration_test_files_for_components_real_fixtures() -> None:
|
||||
"""Test that component changes map to the correct real integration test files.
|
||||
|
||||
This test uses real fixtures to verify the mapping stays correct
|
||||
as new tests are added.
|
||||
"""
|
||||
# modbus should include at least the modbus test
|
||||
modbus_tests = helpers.get_integration_test_files_for_components({"modbus"})
|
||||
assert "tests/integration/test_uart_mock_modbus.py" in modbus_tests
|
||||
|
||||
# ld2410 should include at least the ld2410 test
|
||||
ld2410_tests = helpers.get_integration_test_files_for_components({"ld2410"})
|
||||
assert "tests/integration/test_uart_mock_ld2410.py" in ld2410_tests
|
||||
|
||||
# syslog should include at least the syslog test
|
||||
syslog_tests = helpers.get_integration_test_files_for_components({"syslog"})
|
||||
assert "tests/integration/test_syslog.py" in syslog_tests
|
||||
|
||||
# A component not used by any fixture should return nothing
|
||||
fake_tests = helpers.get_integration_test_files_for_components(
|
||||
{"nonexistent_component_xyz"}
|
||||
)
|
||||
assert fake_tests == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"output,expected",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user