1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-31 15:12:06 +00:00
This commit is contained in:
J. Nick Koston
2025-10-22 08:48:25 -10:00
7 changed files with 232 additions and 79 deletions

View File

@@ -877,6 +877,11 @@ async def to_code(config):
for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"): for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"):
os.environ.pop(clean_var, None) os.environ.pop(clean_var, None)
# Set the location of the IDF component manager cache
os.environ["IDF_COMPONENT_CACHE_PATH"] = str(
CORE.relative_internal_path(".espressif")
)
add_extra_script( add_extra_script(
"post", "post",
"post_build.py", "post_build.py",

View File

@@ -61,6 +61,10 @@ void AddressableLightTransformer::start() {
this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state()); this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state());
} }
inline constexpr uint8_t subtract_scaled_difference(uint8_t a, uint8_t b, int32_t scale) {
return uint8_t(int32_t(a) - (((int32_t(a) - int32_t(b)) * scale) / 256));
}
optional<LightColorValues> AddressableLightTransformer::apply() { optional<LightColorValues> AddressableLightTransformer::apply() {
float smoothed_progress = LightTransformer::smoothed_progress(this->get_progress_()); float smoothed_progress = LightTransformer::smoothed_progress(this->get_progress_());
@@ -74,38 +78,37 @@ optional<LightColorValues> AddressableLightTransformer::apply() {
// all LEDs, we use the current state of each LED as the start. // all LEDs, we use the current state of each LED as the start.
// We can't use a direct lerp smoothing here though - that would require creating a copy of the original // We can't use a direct lerp smoothing here though - that would require creating a copy of the original
// state of each LED at the start of the transition. // state of each LED at the start of the transition. Instead, we "fake" the look of lerp by calculating
// Instead, we "fake" the look of the LERP by using an exponential average over time and using // the delta between the current state and the target state, assuming that the delta represents the rest
// dynamically-calculated alpha values to match the look. // of the transition that was to be applied as of the previous transition step, and scaling the delta for
// what should be left after the current transition step. In this manner, the delta decays to zero as the
// transition progresses.
//
// Here's an example of how the algorithm progresses in discrete steps:
//
// At time = 0.00, 0% complete, 100% remaining, 100% will remain after this step, so the scale is 100% / 100% = 100%.
// At time = 0.10, 0% complete, 100% remaining, 90% will remain after this step, so the scale is 90% / 100% = 90%.
// At time = 0.20, 10% complete, 90% remaining, 80% will remain after this step, so the scale is 80% / 90% = 88.9%.
// At time = 0.50, 20% complete, 80% remaining, 50% will remain after this step, so the scale is 50% / 80% = 62.5%.
// At time = 0.90, 50% complete, 50% remaining, 10% will remain after this step, so the scale is 10% / 50% = 20%.
// At time = 0.91, 90% complete, 10% remaining, 9% will remain after this step, so the scale is 9% / 10% = 90%.
// At time = 1.00, 91% complete, 9% remaining, 0% will remain after this step, so the scale is 0% / 9% = 0%.
//
// Because the color values are quantized to 8 bit resolution after each step, the transition may appear
// non-linear when applying small deltas.
float denom = (1.0f - smoothed_progress); if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) {
float alpha = denom == 0.0f ? 1.0f : (smoothed_progress - this->last_transition_progress_) / denom; int32_t scale = int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f));
for (auto led : this->light_) {
// We need to use a low-resolution alpha here which makes the transition set in only after ~half of the length led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
// We solve this by accumulating the fractional part of the alpha over time. subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
float alpha255 = alpha * 255.0f; subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
float alpha255int = floorf(alpha255); subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
float alpha255remainder = alpha255 - alpha255int; }
this->last_transition_progress_ = smoothed_progress;
this->accumulated_alpha_ += alpha255remainder; this->light_.schedule_show();
float alpha_add = floorf(this->accumulated_alpha_);
this->accumulated_alpha_ -= alpha_add;
alpha255 += alpha_add;
alpha255 = clamp(alpha255, 0.0f, 255.0f);
auto alpha8 = static_cast<uint8_t>(alpha255);
if (alpha8 != 0) {
uint8_t inv_alpha8 = 255 - alpha8;
Color add = this->target_color_ * alpha8;
for (auto led : this->light_)
led.set(add + led.get() * inv_alpha8);
} }
this->last_transition_progress_ = smoothed_progress;
this->light_.schedule_show();
return {}; return {};
} }

View File

@@ -113,7 +113,6 @@ class AddressableLightTransformer : public LightTransformer {
protected: protected:
AddressableLight &light_; AddressableLight &light_;
float last_transition_progress_{0.0f}; float last_transition_progress_{0.0f};
float accumulated_alpha_{0.0f};
Color target_color_{}; Color target_color_{};
}; };

View File

@@ -8,34 +8,35 @@
namespace esphome { namespace esphome {
/// Generic bitmask for storing a set of enum values efficiently. /// Generic bitmask for storing a finite set of discrete values efficiently.
/// Replaces std::set<EnumType> to eliminate red-black tree overhead (~586 bytes per instantiation). /// Replaces std::set<ValueType> to eliminate red-black tree overhead (~586 bytes per instantiation).
/// ///
/// Template parameters: /// Template parameters:
/// EnumType: The enum type to store (must be uint8_t-based) /// ValueType: The type to store (typically enum, but can be any discrete bounded type)
/// MaxBits: Maximum number of bits needed (auto-selects uint8_t/uint16_t/uint32_t) /// MaxBits: Maximum number of bits needed (auto-selects uint8_t/uint16_t/uint32_t)
/// ///
/// Requirements: /// Requirements:
/// - EnumType must be an enum with sequential values starting from 0 /// - ValueType must have a bounded discrete range that maps to bit positions
/// - Specialization must provide enum_to_bit() and bit_to_enum() static methods /// - Specialization must provide value_to_bit() and bit_to_value() static methods
/// - MaxBits must be sufficient to hold all enum values /// - MaxBits must be sufficient to hold all possible values
/// ///
/// Example usage: /// Example usage:
/// using ClimateModeMask = EnumBitmask<ClimateMode, 8>; /// using ClimateModeMask = FiniteSetMask<ClimateMode, 8>;
/// ClimateModeMask modes({CLIMATE_MODE_HEAT, CLIMATE_MODE_COOL}); /// ClimateModeMask modes({CLIMATE_MODE_HEAT, CLIMATE_MODE_COOL});
/// if (modes.count(CLIMATE_MODE_HEAT)) { ... } /// if (modes.count(CLIMATE_MODE_HEAT)) { ... }
/// for (auto mode : modes) { ... } // Iterate over set bits /// for (auto mode : modes) { ... } // Iterate over set bits
/// ///
/// For complete usage examples with template specializations, see: /// For complete usage examples with template specializations, see:
/// - esphome/components/light/color_mode.h (ColorMode example) /// - esphome/components/light/color_mode.h (ColorMode enum example)
/// ///
/// Design notes: /// Design notes:
/// - Uses compile-time type selection for optimal size (uint8_t/uint16_t/uint32_t) /// - Uses compile-time type selection for optimal size (uint8_t/uint16_t/uint32_t)
/// - Iterator converts bit positions to actual enum values during traversal /// - Iterator converts bit positions to actual values during traversal
/// - All operations are constexpr-compatible for compile-time initialization /// - All operations are constexpr-compatible for compile-time initialization
/// - Drop-in replacement for std::set<EnumType> with simpler API /// - Drop-in replacement for std::set<ValueType> with simpler API
/// - Despite the name, works with any discrete bounded type, not just enums
/// ///
template<typename EnumType, int MaxBits = 16> class EnumBitmask { template<typename ValueType, int MaxBits = 16> class FiniteSetMask {
public: public:
// Automatic bitmask type selection based on MaxBits // Automatic bitmask type selection based on MaxBits
// ≤8 bits: uint8_t, ≤16 bits: uint16_t, otherwise: uint32_t // ≤8 bits: uint8_t, ≤16 bits: uint16_t, otherwise: uint32_t
@@ -43,38 +44,38 @@ template<typename EnumType, int MaxBits = 16> class EnumBitmask {
typename std::conditional<(MaxBits <= 8), uint8_t, typename std::conditional<(MaxBits <= 8), uint8_t,
typename std::conditional<(MaxBits <= 16), uint16_t, uint32_t>::type>::type; typename std::conditional<(MaxBits <= 16), uint16_t, uint32_t>::type>::type;
constexpr EnumBitmask() = default; constexpr FiniteSetMask() = default;
/// Construct from initializer list: {VALUE1, VALUE2, ...} /// Construct from initializer list: {VALUE1, VALUE2, ...}
constexpr EnumBitmask(std::initializer_list<EnumType> values) { constexpr FiniteSetMask(std::initializer_list<ValueType> values) {
for (auto value : values) { for (auto value : values) {
this->insert(value); this->insert(value);
} }
} }
/// Add a single enum value to the set (std::set compatibility) /// Add a single value to the set (std::set compatibility)
constexpr void insert(EnumType value) { this->mask_ |= (static_cast<bitmask_t>(1) << enum_to_bit(value)); } constexpr void insert(ValueType value) { this->mask_ |= (static_cast<bitmask_t>(1) << value_to_bit(value)); }
/// Add multiple enum values from initializer list /// Add multiple values from initializer list
constexpr void insert(std::initializer_list<EnumType> values) { constexpr void insert(std::initializer_list<ValueType> values) {
for (auto value : values) { for (auto value : values) {
this->insert(value); this->insert(value);
} }
} }
/// Remove an enum value from the set (std::set compatibility) /// Remove a value from the set (std::set compatibility)
constexpr void erase(EnumType value) { this->mask_ &= ~(static_cast<bitmask_t>(1) << enum_to_bit(value)); } constexpr void erase(ValueType value) { this->mask_ &= ~(static_cast<bitmask_t>(1) << value_to_bit(value)); }
/// Clear all values from the set /// Clear all values from the set
constexpr void clear() { this->mask_ = 0; } constexpr void clear() { this->mask_ = 0; }
/// Check if the set contains a specific enum value (std::set compatibility) /// Check if the set contains a specific value (std::set compatibility)
/// Returns 1 if present, 0 if not (same as std::set for unique elements) /// Returns 1 if present, 0 if not (same as std::set for unique elements)
constexpr size_t count(EnumType value) const { constexpr size_t count(ValueType value) const {
return (this->mask_ & (static_cast<bitmask_t>(1) << enum_to_bit(value))) != 0 ? 1 : 0; return (this->mask_ & (static_cast<bitmask_t>(1) << value_to_bit(value))) != 0 ? 1 : 0;
} }
/// Count the number of enum values in the set /// Count the number of values in the set
constexpr size_t size() const { constexpr size_t size() const {
// Brian Kernighan's algorithm - efficient for sparse bitmasks // Brian Kernighan's algorithm - efficient for sparse bitmasks
// Typical case: 2-4 modes out of 10 possible // Typical case: 2-4 modes out of 10 possible
@@ -91,51 +92,52 @@ template<typename EnumType, int MaxBits = 16> class EnumBitmask {
constexpr bool empty() const { return this->mask_ == 0; } constexpr bool empty() const { return this->mask_ == 0; }
/// Iterator support for range-based for loops and API encoding /// Iterator support for range-based for loops and API encoding
/// Iterates over set bits and converts bit positions to enum values /// Iterates over set bits and converts bit positions to values
/// Optimization: removes bits from mask as we iterate
class Iterator { class Iterator {
public: public:
using iterator_category = std::forward_iterator_tag; using iterator_category = std::forward_iterator_tag;
using value_type = EnumType; using value_type = ValueType;
using difference_type = std::ptrdiff_t; using difference_type = std::ptrdiff_t;
using pointer = const EnumType *; using pointer = const ValueType *;
using reference = EnumType; using reference = ValueType;
constexpr Iterator(bitmask_t mask, int bit) : mask_(mask), bit_(bit) { advance_to_next_set_bit_(); } constexpr explicit Iterator(bitmask_t mask) : mask_(mask) {}
constexpr EnumType operator*() const { return bit_to_enum(bit_); } constexpr ValueType operator*() const {
// Return value for the first set bit
return bit_to_value(find_next_set_bit(mask_, 0));
}
constexpr Iterator &operator++() { constexpr Iterator &operator++() {
++bit_; // Clear the lowest set bit (Brian Kernighan's algorithm)
advance_to_next_set_bit_(); mask_ &= mask_ - 1;
return *this; return *this;
} }
constexpr bool operator==(const Iterator &other) const { return bit_ == other.bit_; } constexpr bool operator==(const Iterator &other) const { return mask_ == other.mask_; }
constexpr bool operator!=(const Iterator &other) const { return !(*this == other); } constexpr bool operator!=(const Iterator &other) const { return !(*this == other); }
private: private:
constexpr void advance_to_next_set_bit_() { bit_ = find_next_set_bit(mask_, bit_); }
bitmask_t mask_; bitmask_t mask_;
int bit_;
}; };
constexpr Iterator begin() const { return Iterator(mask_, 0); } constexpr Iterator begin() const { return Iterator(mask_); }
constexpr Iterator end() const { return Iterator(mask_, MaxBits); } constexpr Iterator end() const { return Iterator(0); }
/// Get the raw bitmask value for optimized operations /// Get the raw bitmask value for optimized operations
constexpr bitmask_t get_mask() const { return this->mask_; } constexpr bitmask_t get_mask() const { return this->mask_; }
/// Check if a specific enum value is present in a raw bitmask /// Check if a specific value is present in a raw bitmask
/// Useful for checking intersection results without creating temporary objects /// Useful for checking intersection results without creating temporary objects
static constexpr bool mask_contains(bitmask_t mask, EnumType value) { static constexpr bool mask_contains(bitmask_t mask, ValueType value) {
return (mask & (static_cast<bitmask_t>(1) << enum_to_bit(value))) != 0; return (mask & (static_cast<bitmask_t>(1) << value_to_bit(value))) != 0;
} }
/// Get the first enum value from a raw bitmask /// Get the first value from a raw bitmask
/// Used for optimizing intersection logic (e.g., "pick first suitable mode") /// Used for optimizing intersection logic (e.g., "pick first suitable mode")
static constexpr EnumType first_value_from_mask(bitmask_t mask) { return bit_to_enum(find_next_set_bit(mask, 0)); } static constexpr ValueType first_value_from_mask(bitmask_t mask) { return bit_to_value(find_next_set_bit(mask, 0)); }
/// Find the next set bit in a bitmask starting from a given position /// Find the next set bit in a bitmask starting from a given position
/// Returns the bit position, or MaxBits if no more bits are set /// Returns the bit position, or MaxBits if no more bits are set
@@ -149,9 +151,9 @@ template<typename EnumType, int MaxBits = 16> class EnumBitmask {
protected: protected:
// Must be provided by template specialization // Must be provided by template specialization
// These convert between enum values and bit positions (0, 1, 2, ...) // These convert between values and bit positions (0, 1, 2, ...)
static constexpr int enum_to_bit(EnumType value); static constexpr int value_to_bit(ValueType value);
static EnumType bit_to_enum(int bit); // Not constexpr due to static array limitation in C++20 static ValueType bit_to_value(int bit); // Not constexpr: array indexing with runtime bounds checking
bitmask_t mask_{0}; bitmask_t mask_{0};
}; };

View File

@@ -336,7 +336,7 @@ def _component_has_tests(component: str) -> bool:
Returns: Returns:
True if the component has test YAML files True if the component has test YAML files
""" """
return bool(get_component_test_files(component)) return bool(get_component_test_files(component, all_variants=True))
def _select_platform_by_preference( def _select_platform_by_preference(
@@ -496,7 +496,7 @@ def detect_memory_impact_config(
for component in sorted(changed_component_set): for component in sorted(changed_component_set):
# Look for test files on preferred platforms # Look for test files on preferred platforms
test_files = get_component_test_files(component) test_files = get_component_test_files(component, all_variants=True)
if not test_files: if not test_files:
continue continue

View File

@@ -49,9 +49,9 @@ def has_test_files(component_name: str, tests_dir: Path) -> bool:
tests_dir: Path to tests/components directory (unused, kept for compatibility) tests_dir: Path to tests/components directory (unused, kept for compatibility)
Returns: Returns:
True if the component has test.*.yaml files True if the component has test.*.yaml or test-*.yaml files
""" """
return bool(get_component_test_files(component_name)) return bool(get_component_test_files(component_name, all_variants=True))
def create_intelligent_batches( def create_intelligent_batches(

View File

@@ -574,6 +574,105 @@ def test_main_filters_components_without_tests(
assert output["memory_impact"]["should_run"] == "false" assert output["memory_impact"]["should_run"] == "false"
def test_main_detects_components_with_variant_tests(
mock_should_run_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_changed_files: Mock,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test that components with only variant test files (test-*.yaml) are detected.
This test verifies the fix for components like improv_serial, ethernet, mdns,
improv_base, and safe_mode which only have variant test files (test-*.yaml)
instead of base test files (test.*.yaml).
"""
# 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_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False
# Mock changed_files to return component files
mock_changed_files.return_value = [
"esphome/components/improv_serial/improv_serial.cpp",
"esphome/components/ethernet/ethernet.cpp",
"esphome/components/no_tests/component.cpp",
]
# Create test directory structure
tests_dir = tmp_path / "tests" / "components"
# improv_serial has only variant tests (like the real component)
improv_serial_dir = tests_dir / "improv_serial"
improv_serial_dir.mkdir(parents=True)
(improv_serial_dir / "test-uart0.esp32-idf.yaml").write_text("test: config")
(improv_serial_dir / "test-uart0.esp8266-ard.yaml").write_text("test: config")
(improv_serial_dir / "test-usb_cdc.esp32-s2-idf.yaml").write_text("test: config")
# ethernet also has only variant tests
ethernet_dir = tests_dir / "ethernet"
ethernet_dir.mkdir(parents=True)
(ethernet_dir / "test-manual_ip.esp32-idf.yaml").write_text("test: config")
(ethernet_dir / "test-dhcp.esp32-idf.yaml").write_text("test: config")
# no_tests component has no test files at all
no_tests_dir = tests_dir / "no_tests"
no_tests_dir.mkdir(parents=True)
# Mock root_path to use tmp_path (need to patch both determine_jobs and helpers)
with (
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(helpers, "root_path", str(tmp_path)),
patch("sys.argv", ["determine-jobs.py"]),
patch.object(
determine_jobs,
"get_changed_components",
return_value=["improv_serial", "ethernet", "no_tests"],
),
patch.object(
determine_jobs,
"filter_component_and_test_files",
side_effect=lambda f: f.startswith("esphome/components/"),
),
patch.object(
determine_jobs,
"get_components_with_dependencies",
side_effect=lambda files, deps: (
["improv_serial", "ethernet"]
if not deps
else ["improv_serial", "ethernet", "no_tests"]
),
),
patch.object(determine_jobs, "changed_files", return_value=[]),
):
# Clear the cache since we're mocking root_path
determine_jobs._component_has_tests.cache_clear()
determine_jobs.main()
# Check output
captured = capsys.readouterr()
output = json.loads(captured.out)
# changed_components should have all components
assert set(output["changed_components"]) == {
"improv_serial",
"ethernet",
"no_tests",
}
# changed_components_with_tests should include components with variant tests
assert set(output["changed_components_with_tests"]) == {"improv_serial", "ethernet"}
# component_test_count should be 2 (improv_serial and ethernet)
assert output["component_test_count"] == 2
# no_tests should be excluded since it has no test files
assert "no_tests" not in output["changed_components_with_tests"]
# Tests for detect_memory_impact_config function # Tests for detect_memory_impact_config function
@@ -785,6 +884,51 @@ def test_detect_memory_impact_config_skips_base_bus_components(tmp_path: Path) -
assert "i2c" not in result["components"] assert "i2c" not in result["components"]
def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None:
"""Test memory impact detection for components with only variant test files.
This verifies that memory impact analysis works correctly for components like
improv_serial, ethernet, mdns, etc. which only have variant test files
(test-*.yaml) instead of base test files (test.*.yaml).
"""
# Create test directory structure
tests_dir = tmp_path / "tests" / "components"
# improv_serial with only variant tests
improv_serial_dir = tests_dir / "improv_serial"
improv_serial_dir.mkdir(parents=True)
(improv_serial_dir / "test-uart0.esp32-idf.yaml").write_text("test: improv")
(improv_serial_dir / "test-uart0.esp8266-ard.yaml").write_text("test: improv")
(improv_serial_dir / "test-usb_cdc.esp32-s2-idf.yaml").write_text("test: improv")
# ethernet with only variant tests
ethernet_dir = tests_dir / "ethernet"
ethernet_dir.mkdir(parents=True)
(ethernet_dir / "test-manual_ip.esp32-idf.yaml").write_text("test: ethernet")
(ethernet_dir / "test-dhcp.esp32-c3-idf.yaml").write_text("test: ethernet")
# Mock changed_files to return both components
with (
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(helpers, "root_path", str(tmp_path)),
patch.object(determine_jobs, "changed_files") as mock_changed_files,
):
mock_changed_files.return_value = [
"esphome/components/improv_serial/improv_serial.cpp",
"esphome/components/ethernet/ethernet.cpp",
]
determine_jobs._component_has_tests.cache_clear()
result = determine_jobs.detect_memory_impact_config()
# Should detect both components even though they only have variant tests
assert result["should_run"] == "true"
assert set(result["components"]) == {"improv_serial", "ethernet"}
# Both components support esp32-idf
assert result["platform"] == "esp32-idf"
assert result["use_merged_config"] == "true"
# Tests for clang-tidy split mode logic # Tests for clang-tidy split mode logic