1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-14 13:55:45 +00:00

Compare commits

...

18 Commits

Author SHA1 Message Date
J. Nick Koston
902680a2e0 Merge remote-tracking branch 'upstream/dev' into light-addr 2025-10-21 19:42:19 -10:00
J. Nick Koston
e1c851cab8 [wifi] Optimize WiFi network storage with FixedVector (#11458)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2025-10-22 05:23:10 +00:00
J. Nick Koston
146b067d62 [light] Add compile test for addressable lights (#11465) 2025-10-22 16:59:39 +13:00
J. Nick Koston
5b15827009 [CI] Fix component detection when core files change in determine-jobs (#11461) 2025-10-22 16:58:40 +13:00
J. Nick Koston
0de79ba291 [event] Replace std::set with FixedVector for event type storage (#11463) 2025-10-22 16:57:18 +13:00
J. Nick Koston
e3aaf6a144 [wifi] Test multiple stas in wifi compile tests (#11460) 2025-10-22 16:55:46 +13:00
J. Nick Koston
78ffeb30fb [binary_sensor] Optimize MultiClickTrigger with FixedVector (#11453)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2025-10-22 16:55:13 +13:00
J. Nick Koston
1b3cbb9f60 Merge branch 'addressable_light_tests' into light-addr 2025-10-21 15:57:21 -10:00
J. Nick Koston
e3ecbf6d65 a wild merge appears 2025-10-21 15:57:00 -10:00
J. Nick Koston
603e3d94c7 Merge branch 'addressable_light_tests' into light-addr 2025-10-21 15:52:17 -10:00
J. Nick Koston
98f691913f [light] Add compile test for addressable lights 2025-10-21 15:51:31 -10:00
J. Nick Koston
a89a35bff3 test 2025-10-21 15:50:03 -10:00
Jesse Hills
2c1927fd12 [api] Allow clearing noise psk if dynamically set (#11429) 2025-10-22 14:24:56 +13:00
Jesse Hills
c6ae1a5909 [core] Stop clang-format "fixing" a single line (#11462) 2025-10-22 01:00:27 +00:00
J. Nick Koston
e9e306501a Merge branch 'dev' into light-addr 2025-10-21 14:19:51 -10:00
J. Nick Koston
9c712744be [light] Replace std::vector with FixedVector in strobe and color_wipe effects (#11455) 2025-10-22 11:40:19 +13:00
J. Nick Koston
2aa3bceed8 Merge branch 'dev' into light-addr 2025-10-19 16:42:21 -10:00
Jeff Brown
bdfa84ed87 [light] Eliminate dimming undershoot during addressable light transition 2025-10-09 18:39:12 -07:00
21 changed files with 287 additions and 96 deletions

View File

@@ -1572,7 +1572,13 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption
resp.success = false;
psk_t psk{};
if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) {
if (msg.key.empty()) {
if (this->parent_->clear_noise_psk(true)) {
resp.success = true;
} else {
ESP_LOGW(TAG, "Failed to clear encryption key");
}
} else if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) {
ESP_LOGW(TAG, "Invalid encryption key length");
} else if (!this->parent_->save_noise_psk(psk, true)) {
ESP_LOGW(TAG, "Failed to save encryption key");

View File

@@ -468,6 +468,31 @@ uint16_t APIServer::get_port() const { return this->port_; }
void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
#ifdef USE_API_NOISE
bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg,
const LogString *fail_log_msg, const psk_t &active_psk, bool make_active) {
if (!this->noise_pref_.save(&new_psk)) {
ESP_LOGW(TAG, "%s", LOG_STR_ARG(fail_log_msg));
return false;
}
// ensure it's written immediately
if (!global_preferences->sync()) {
ESP_LOGW(TAG, "Failed to sync preferences");
return false;
}
ESP_LOGD(TAG, "%s", LOG_STR_ARG(save_log_msg));
if (make_active) {
this->set_timeout(100, [this, active_psk]() {
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
this->set_noise_psk(active_psk);
for (auto &c : this->clients_) {
DisconnectRequest req;
c->send_message(req, DisconnectRequest::MESSAGE_TYPE);
}
});
}
return true;
}
bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
#ifdef USE_API_NOISE_PSK_FROM_YAML
// When PSK is set from YAML, this function should never be called
@@ -482,27 +507,21 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
}
SavedNoisePsk new_saved_psk{psk};
if (!this->noise_pref_.save(&new_saved_psk)) {
ESP_LOGW(TAG, "Failed to save Noise PSK");
return false;
}
// ensure it's written immediately
if (!global_preferences->sync()) {
ESP_LOGW(TAG, "Failed to sync preferences");
return false;
}
ESP_LOGD(TAG, "Noise PSK saved");
if (make_active) {
this->set_timeout(100, [this, psk]() {
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
this->set_noise_psk(psk);
for (auto &c : this->clients_) {
DisconnectRequest req;
c->send_message(req, DisconnectRequest::MESSAGE_TYPE);
}
});
}
return true;
return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"), psk,
make_active);
#endif
}
bool APIServer::clear_noise_psk(bool make_active) {
#ifdef USE_API_NOISE_PSK_FROM_YAML
// When PSK is set from YAML, this function should never be called
// but if it is, reject the change
ESP_LOGW(TAG, "Key set in YAML");
return false;
#else
SavedNoisePsk empty_psk{};
psk_t empty{};
return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), empty,
make_active);
#endif
}
#endif

View File

@@ -53,6 +53,7 @@ class APIServer : public Component, public Controller {
#ifdef USE_API_NOISE
bool save_noise_psk(psk_t psk, bool make_active = true);
bool clear_noise_psk(bool make_active = true);
void set_noise_psk(psk_t psk) { noise_ctx_->set_psk(psk); }
std::shared_ptr<APINoiseContext> get_noise_ctx() { return noise_ctx_; }
#endif // USE_API_NOISE
@@ -174,6 +175,10 @@ class APIServer : public Component, public Controller {
protected:
void schedule_reboot_timeout_();
#ifdef USE_API_NOISE
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
const psk_t &active_psk, bool make_active);
#endif // USE_API_NOISE
// Pointers and pointer-like types first (4 bytes each)
std::unique_ptr<socket::Socket> socket_ = nullptr;
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER

View File

@@ -2,11 +2,11 @@
#include <cinttypes>
#include <utility>
#include <vector>
#include "esphome/core/component.h"
#include "esphome/core/automation.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
namespace esphome {
@@ -92,8 +92,8 @@ class DoubleClickTrigger : public Trigger<> {
class MultiClickTrigger : public Trigger<>, public Component {
public:
explicit MultiClickTrigger(BinarySensor *parent, std::vector<MultiClickTriggerEvent> timing)
: parent_(parent), timing_(std::move(timing)) {}
explicit MultiClickTrigger(BinarySensor *parent, std::initializer_list<MultiClickTriggerEvent> timing)
: parent_(parent), timing_(timing) {}
void setup() override {
this->last_state_ = this->parent_->get_state_default(false);
@@ -115,7 +115,7 @@ class MultiClickTrigger : public Trigger<>, public Component {
void trigger_();
BinarySensor *parent_;
std::vector<MultiClickTriggerEvent> timing_;
FixedVector<MultiClickTriggerEvent> timing_;
uint32_t invalid_cooldown_{1000};
optional<size_t> at_index_{};
bool last_state_{false};

View File

@@ -8,12 +8,19 @@ namespace event {
static const char *const TAG = "event";
void Event::trigger(const std::string &event_type) {
auto found = types_.find(event_type);
if (found == types_.end()) {
// Linear search - faster than std::set for small datasets (1-5 items typical)
const std::string *found = nullptr;
for (const auto &type : this->types_) {
if (type == event_type) {
found = &type;
break;
}
}
if (found == nullptr) {
ESP_LOGE(TAG, "'%s': invalid event type for trigger(): %s", this->get_name().c_str(), event_type.c_str());
return;
}
last_event_type = &(*found);
last_event_type = found;
ESP_LOGD(TAG, "'%s' Triggered event '%s'", this->get_name().c_str(), last_event_type->c_str());
this->event_callback_.call(event_type);
}

View File

@@ -1,6 +1,5 @@
#pragma once
#include <set>
#include <string>
#include "esphome/core/component.h"
@@ -26,13 +25,13 @@ class Event : public EntityBase, public EntityBase_DeviceClass {
const std::string *last_event_type;
void trigger(const std::string &event_type);
void set_event_types(const std::set<std::string> &event_types) { this->types_ = event_types; }
std::set<std::string> get_event_types() const { return this->types_; }
void set_event_types(const std::initializer_list<std::string> &event_types) { this->types_ = event_types; }
const FixedVector<std::string> &get_event_types() const { return this->types_; }
void add_on_event_callback(std::function<void(const std::string &event_type)> &&callback);
protected:
CallbackManager<void(const std::string &event_type)> event_callback_;
std::set<std::string> types_;
FixedVector<std::string> types_;
};
} // namespace event

View File

@@ -61,6 +61,10 @@ void AddressableLightTransformer::start() {
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() {
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.
// 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.
// Instead, we "fake" the look of the LERP by using an exponential average over time and using
// dynamically-calculated alpha values to match the look.
// state of each LED at the start of the transition. Instead, we "fake" the look of lerp by calculating
// the delta between the current state and the target state, assuming that the delta represents the rest
// 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);
float alpha = denom == 0.0f ? 1.0f : (smoothed_progress - this->last_transition_progress_) / denom;
// We need to use a low-resolution alpha here which makes the transition set in only after ~half of the length
// We solve this by accumulating the fractional part of the alpha over time.
float alpha255 = alpha * 255.0f;
float alpha255int = floorf(alpha255);
float alpha255remainder = alpha255 - alpha255int;
this->accumulated_alpha_ += alpha255remainder;
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);
if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) {
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_) {
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
}
this->last_transition_progress_ = smoothed_progress;
this->light_.schedule_show();
}
this->last_transition_progress_ = smoothed_progress;
this->light_.schedule_show();
return {};
}

View File

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

View File

@@ -1,9 +1,9 @@
#pragma once
#include <utility>
#include <vector>
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/light/light_state.h"
#include "esphome/components/light/addressable_light.h"
@@ -113,7 +113,7 @@ struct AddressableColorWipeEffectColor {
class AddressableColorWipeEffect : public AddressableLightEffect {
public:
explicit AddressableColorWipeEffect(const std::string &name) : AddressableLightEffect(name) {}
void set_colors(const std::vector<AddressableColorWipeEffectColor> &colors) { this->colors_ = colors; }
void set_colors(const std::initializer_list<AddressableColorWipeEffectColor> &colors) { this->colors_ = colors; }
void set_add_led_interval(uint32_t add_led_interval) { this->add_led_interval_ = add_led_interval; }
void set_reverse(bool reverse) { this->reverse_ = reverse; }
void apply(AddressableLight &it, const Color &current_color) override {
@@ -155,7 +155,7 @@ class AddressableColorWipeEffect : public AddressableLightEffect {
}
protected:
std::vector<AddressableColorWipeEffectColor> colors_;
FixedVector<AddressableColorWipeEffectColor> colors_;
size_t at_color_{0};
uint32_t last_add_{0};
uint32_t add_led_interval_{};

View File

@@ -1,9 +1,9 @@
#pragma once
#include <utility>
#include <vector>
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include "light_effect.h"
namespace esphome {
@@ -188,10 +188,10 @@ class StrobeLightEffect : public LightEffect {
this->last_switch_ = now;
}
void set_colors(const std::vector<StrobeLightEffectColor> &colors) { this->colors_ = colors; }
void set_colors(const std::initializer_list<StrobeLightEffectColor> &colors) { this->colors_ = colors; }
protected:
std::vector<StrobeLightEffectColor> colors_;
FixedVector<StrobeLightEffectColor> colors_;
uint32_t last_switch_{0};
size_t at_color_{0};
};

View File

@@ -378,14 +378,19 @@ async def to_code(config):
# Track if any network uses Enterprise authentication
has_eap = False
def add_sta(ap, network):
ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP))
cg.add(var.add_sta(wifi_network(network, ap, ip_config)))
# Initialize FixedVector with the count of networks
networks = config.get(CONF_NETWORKS, [])
if networks:
cg.add(var.init_sta(len(networks)))
for network in config.get(CONF_NETWORKS, []):
if CONF_EAP in network:
has_eap = True
cg.with_local_variable(network[CONF_ID], WiFiAP(), add_sta, network)
def add_sta(ap: cg.MockObj, network: dict) -> None:
ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP))
cg.add(var.add_sta(wifi_network(network, ap, ip_config)))
for network in networks:
if CONF_EAP in network:
has_eap = True
cg.with_local_variable(network[CONF_ID], WiFiAP(), add_sta, network)
if CONF_AP in config:
conf = config[CONF_AP]

View File

@@ -330,9 +330,11 @@ float WiFiComponent::get_loop_priority() const {
return 10.0f; // before other loop components
}
void WiFiComponent::init_sta(size_t count) { this->sta_.init(count); }
void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); }
void WiFiComponent::set_sta(const WiFiAP &ap) {
this->clear_sta();
this->init_sta(1);
this->add_sta(ap);
}
void WiFiComponent::clear_sta() { this->sta_.clear(); }

View File

@@ -219,6 +219,7 @@ class WiFiComponent : public Component {
void set_sta(const WiFiAP &ap);
WiFiAP get_sta() { return this->selected_ap_; }
void init_sta(size_t count);
void add_sta(const WiFiAP &ap);
void clear_sta();
@@ -393,7 +394,7 @@ class WiFiComponent : public Component {
#endif
std::string use_address_;
std::vector<WiFiAP> sta_;
FixedVector<WiFiAP> sta_;
std::vector<WiFiSTAPriority> sta_priorities_;
wifi_scan_vector_t<WiFiScanResult> scan_result_;
WiFiAP selected_ap_;

View File

@@ -243,8 +243,10 @@
// Dummy firmware payload for shelly_dimmer
#define USE_SHD_FIRMWARE_MAJOR_VERSION 56
#define USE_SHD_FIRMWARE_MINOR_VERSION 5
// clang-format off
#define USE_SHD_FIRMWARE_DATA \
{}
// clang-format on
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH

View File

@@ -194,12 +194,8 @@ template<typename T> class FixedVector {
size_ = 0;
}
public:
FixedVector() = default;
/// Constructor from initializer list - allocates exact size needed
/// This enables brace initialization: FixedVector<int> v = {1, 2, 3};
FixedVector(std::initializer_list<T> init_list) {
// Helper to assign from initializer list (shared by constructor and assignment operator)
void assign_from_initializer_list_(std::initializer_list<T> init_list) {
init(init_list.size());
size_t idx = 0;
for (const auto &item : init_list) {
@@ -209,6 +205,13 @@ template<typename T> class FixedVector {
size_ = init_list.size();
}
public:
FixedVector() = default;
/// Constructor from initializer list - allocates exact size needed
/// This enables brace initialization: FixedVector<int> v = {1, 2, 3};
FixedVector(std::initializer_list<T> init_list) { assign_from_initializer_list_(init_list); }
~FixedVector() { cleanup_(); }
// Disable copy operations (avoid accidental expensive copies)
@@ -234,6 +237,15 @@ template<typename T> class FixedVector {
return *this;
}
/// Assignment from initializer list - avoids temporary and move overhead
/// This enables: FixedVector<int> v; v = {1, 2, 3};
FixedVector &operator=(std::initializer_list<T> init_list) {
cleanup_();
reset_();
assign_from_initializer_list_(init_list);
return *this;
}
// Allocate capacity - can be called multiple times to reinit
void init(size_t n) {
cleanup_();

View File

@@ -606,21 +606,23 @@ def main() -> None:
# [list]: Changed components (already includes dependencies)
changed_components_result = get_changed_components()
# Always analyze component files, even if core files changed
# This is needed for component testing and memory impact analysis
changed = changed_files(args.branch)
component_files = [f for f in changed if filter_component_and_test_files(f)]
directly_changed_components = get_components_with_dependencies(
component_files, False
)
if changed_components_result is None:
# Core files changed - will trigger full clang-tidy scan
# No specific components to test
changed_components = []
directly_changed_components = []
# But we still need to track changed components for testing and memory analysis
changed_components = get_components_with_dependencies(component_files, True)
is_core_change = True
else:
# Get both directly changed and all changed (with dependencies)
changed = changed_files(args.branch)
component_files = [f for f in changed if filter_component_and_test_files(f)]
directly_changed_components = get_components_with_dependencies(
component_files, False
)
changed_components = get_components_with_dependencies(component_files, True)
# Use the result from get_changed_components() which includes dependencies
changed_components = changed_components_result
is_core_change = False
# Filter to only components that have test files

View File

@@ -17,6 +17,20 @@ esphome:
relative_brightness: 5%
brightness_limits:
max_brightness: 90%
- light.turn_on:
id: test_addressable_transition
brightness: 50%
red: 100%
green: 0%
blue: 0%
transition_length: 500ms
- light.turn_on:
id: test_addressable_transition
brightness: 100%
red: 0%
green: 100%
blue: 0%
transition_length: 1s
light:
- platform: binary
@@ -163,3 +177,9 @@ light:
blue: 0%
duration: 1s
transition_length: 500ms
- platform: partition
id: test_addressable_transition
name: Addressable Transition Test
default_transition_length: 1s
segments:
- single_light_id: test_rgb_light

View File

@@ -12,5 +12,8 @@ esphome:
- logger.log: "Failed to connect to WiFi!"
wifi:
ssid: MySSID
password: password1
networks:
- ssid: MySSID
password: password1
- ssid: MySSID2
password: password2

View File

@@ -0,0 +1,10 @@
esphome:
name: noise-key-test
host:
api:
encryption:
key: "zX9/JHxMKwpP0jUGsF0iESCm1wRvNgR6NkKVOhn7kSs="
logger:

View File

@@ -49,3 +49,42 @@ async def test_noise_encryption_key_protection(
with pytest.raises(InvalidEncryptionKeyAPIError):
async with api_client_connected(noise_psk=wrong_key) as client:
await client.device_info()
@pytest.mark.asyncio
async def test_noise_encryption_key_clear_protection(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that noise encryption key set in YAML cannot be changed via API."""
# The key that's set in the YAML fixture
noise_psk = "zX9/JHxMKwpP0jUGsF0iESCm1wRvNgR6NkKVOhn7kSs="
# Keep ESPHome process running throughout all tests
async with run_compiled(yaml_config):
# First connection - test key change attempt
async with api_client_connected(noise_psk=noise_psk) as client:
# Verify connection is established
device_info = await client.device_info()
assert device_info is not None
# Try to set a new encryption key via API
new_key = b"" # Empty key to attempt to clear
# This should fail since key was set in YAML
success = await client.noise_encryption_set_key(new_key)
assert success is False
# Reconnect with the original key to verify it still works
async with api_client_connected(noise_psk=noise_psk) as client:
# Verify connection is still successful with original key
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "noise-key-test"
# Verify that connecting with a wrong key fails
wrong_key = base64.b64encode(b"y" * 32).decode() # Different key
with pytest.raises(InvalidEncryptionKeyAPIError):
async with api_client_connected(noise_psk=wrong_key) as client:
await client.device_info()

View File

@@ -910,3 +910,60 @@ def test_clang_tidy_mode_targeted_scan(
output = json.loads(captured.out)
assert output["clang_tidy_mode"] == expected_mode
def test_main_core_files_changed_still_detects_components(
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,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""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_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = True
mock_should_run_python_linters.return_value = True
mock_determine_cpp_unit_tests.return_value = (True, [])
mock_changed_files.return_value = [
"esphome/core/helpers.h",
"esphome/components/select/select_traits.h",
"esphome/components/select/select_traits.cpp",
"esphome/components/api/api.proto",
]
with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
patch.object(determine_jobs, "get_changed_components", return_value=None),
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: (
["select", "api"]
if not deps
else ["select", "api", "bluetooth_proxy", "logger"]
),
),
):
determine_jobs.main()
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] == "split"
assert "select" in output["changed_components"]
assert "api" in output["changed_components"]
assert len(output["changed_components"]) > 0