mirror of
https://github.com/esphome/esphome.git
synced 2025-11-05 09:31:54 +00:00
Compare commits
27 Commits
select_fix
...
light-addr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
902680a2e0 | ||
|
|
e1c851cab8 | ||
|
|
146b067d62 | ||
|
|
5b15827009 | ||
|
|
0de79ba291 | ||
|
|
e3aaf6a144 | ||
|
|
78ffeb30fb | ||
|
|
1b3cbb9f60 | ||
|
|
e3ecbf6d65 | ||
|
|
603e3d94c7 | ||
|
|
98f691913f | ||
|
|
a89a35bff3 | ||
|
|
2c1927fd12 | ||
|
|
c6ae1a5909 | ||
|
|
e9e306501a | ||
|
|
9c712744be | ||
|
|
ae50a09b4e | ||
|
|
1ea80594c6 | ||
|
|
8500323d39 | ||
|
|
6f7db2f5f7 | ||
|
|
9922c65912 | ||
|
|
f2469077d9 | ||
|
|
742eca92d8 | ||
|
|
548913b471 | ||
|
|
a05c5ea240 | ||
|
|
2aa3bceed8 | ||
|
|
bdfa84ed87 |
@@ -1 +1 @@
|
||||
d7693a1e996cacd4a3d1c9a16336799c2a8cc3db02e4e74084151ce964581248
|
||||
3d46b63015d761c85ca9cb77ab79a389509e5776701fb22aed16e7b79d432c0c
|
||||
|
||||
7
.github/workflows/auto-label-pr.yml
vendored
7
.github/workflows/auto-label-pr.yml
vendored
@@ -53,6 +53,7 @@ jobs:
|
||||
'new-target-platform',
|
||||
'merging-to-release',
|
||||
'merging-to-beta',
|
||||
'chained-pr',
|
||||
'core',
|
||||
'small-pr',
|
||||
'dashboard',
|
||||
@@ -140,6 +141,8 @@ jobs:
|
||||
labels.add('merging-to-release');
|
||||
} else if (baseRef === 'beta') {
|
||||
labels.add('merging-to-beta');
|
||||
} else if (baseRef !== 'dev') {
|
||||
labels.add('chained-pr');
|
||||
}
|
||||
|
||||
return labels;
|
||||
@@ -528,8 +531,8 @@ jobs:
|
||||
const apiData = await fetchApiData();
|
||||
const baseRef = context.payload.pull_request.base.ref;
|
||||
|
||||
// Early exit for non-dev branches
|
||||
if (baseRef !== 'dev') {
|
||||
// Early exit for release and beta branches only
|
||||
if (baseRef === 'release' || baseRef === 'beta') {
|
||||
const branchLabels = await detectMergeBranch();
|
||||
const finalLabels = Array.from(branchLabels);
|
||||
|
||||
|
||||
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@@ -178,6 +178,8 @@ jobs:
|
||||
component-test-count: ${{ steps.determine.outputs.component-test-count }}
|
||||
changed-cpp-file-count: ${{ steps.determine.outputs.changed-cpp-file-count }}
|
||||
memory_impact: ${{ steps.determine.outputs.memory-impact }}
|
||||
cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }}
|
||||
cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
@@ -210,6 +212,8 @@ jobs:
|
||||
echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT
|
||||
echo "changed-cpp-file-count=$(echo "$output" | jq -r '.changed_cpp_file_count')" >> $GITHUB_OUTPUT
|
||||
echo "memory-impact=$(echo "$output" | jq -c '.memory_impact')" >> $GITHUB_OUTPUT
|
||||
echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT
|
||||
echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT
|
||||
|
||||
integration-tests:
|
||||
name: Run integration tests
|
||||
@@ -247,6 +251,33 @@ jobs:
|
||||
. venv/bin/activate
|
||||
pytest -vv --no-cov --tb=native -n auto tests/integration/
|
||||
|
||||
cpp-unit-tests:
|
||||
name: Run C++ unit tests
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
- name: Run cpp_unit_test.py
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then
|
||||
script/cpp_unit_test.py --all
|
||||
else
|
||||
ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs)
|
||||
script/cpp_unit_test.py $ARGS
|
||||
fi
|
||||
|
||||
clang-tidy-single:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
1
.github/workflows/status-check-labels.yml
vendored
1
.github/workflows/status-check-labels.yml
vendored
@@ -14,6 +14,7 @@ jobs:
|
||||
label:
|
||||
- needs-docs
|
||||
- merge-after-release
|
||||
- chained-pr
|
||||
steps:
|
||||
- name: Check for ${{ matrix.label }} label
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -550,6 +550,32 @@ CONF_ENABLE_LWIP_BRIDGE_INTERFACE = "enable_lwip_bridge_interface"
|
||||
CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING = "enable_lwip_tcpip_core_locking"
|
||||
CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY = "enable_lwip_check_thread_safety"
|
||||
CONF_DISABLE_LIBC_LOCKS_IN_IRAM = "disable_libc_locks_in_iram"
|
||||
CONF_DISABLE_VFS_SUPPORT_TERMIOS = "disable_vfs_support_termios"
|
||||
CONF_DISABLE_VFS_SUPPORT_SELECT = "disable_vfs_support_select"
|
||||
CONF_DISABLE_VFS_SUPPORT_DIR = "disable_vfs_support_dir"
|
||||
|
||||
# VFS requirement tracking
|
||||
# Components that need VFS features can call require_vfs_select() or require_vfs_dir()
|
||||
KEY_VFS_SELECT_REQUIRED = "vfs_select_required"
|
||||
KEY_VFS_DIR_REQUIRED = "vfs_dir_required"
|
||||
|
||||
|
||||
def require_vfs_select() -> None:
|
||||
"""Mark that VFS select support is required by a component.
|
||||
|
||||
Call this from components that use esp_vfs_eventfd or other VFS select features.
|
||||
This prevents CONFIG_VFS_SUPPORT_SELECT from being disabled.
|
||||
"""
|
||||
CORE.data[KEY_VFS_SELECT_REQUIRED] = True
|
||||
|
||||
|
||||
def require_vfs_dir() -> None:
|
||||
"""Mark that VFS directory support is required by a component.
|
||||
|
||||
Call this from components that use directory functions (opendir, readdir, mkdir, etc.).
|
||||
This prevents CONFIG_VFS_SUPPORT_DIR from being disabled.
|
||||
"""
|
||||
CORE.data[KEY_VFS_DIR_REQUIRED] = True
|
||||
|
||||
|
||||
def _validate_idf_component(config: ConfigType) -> ConfigType:
|
||||
@@ -615,6 +641,13 @@ FRAMEWORK_SCHEMA = cv.All(
|
||||
cv.Optional(
|
||||
CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True
|
||||
): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True
|
||||
): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_DISABLE_VFS_SUPPORT_SELECT, default=True
|
||||
): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean,
|
||||
cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean,
|
||||
}
|
||||
),
|
||||
@@ -962,6 +995,43 @@ async def to_code(config):
|
||||
if advanced.get(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, True):
|
||||
add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False)
|
||||
|
||||
# Disable VFS support for termios (terminal I/O functions)
|
||||
# ESPHome doesn't use termios functions on ESP32 (only used in host UART driver).
|
||||
# Saves approximately 1.8KB of flash when disabled (default).
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_VFS_SUPPORT_TERMIOS",
|
||||
not advanced.get(CONF_DISABLE_VFS_SUPPORT_TERMIOS, True),
|
||||
)
|
||||
|
||||
# Disable VFS support for select() with file descriptors
|
||||
# ESPHome only uses select() with sockets via lwip_select(), which still works.
|
||||
# VFS select is only needed for UART/eventfd file descriptors.
|
||||
# Components that need it (e.g., openthread) call require_vfs_select().
|
||||
# Saves approximately 2.7KB of flash when disabled (default).
|
||||
if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False):
|
||||
# Component requires VFS select - force enable regardless of user setting
|
||||
add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_SELECT", True)
|
||||
else:
|
||||
# No component needs it - allow user to control (default: disabled)
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_VFS_SUPPORT_SELECT",
|
||||
not advanced.get(CONF_DISABLE_VFS_SUPPORT_SELECT, True),
|
||||
)
|
||||
|
||||
# Disable VFS support for directory functions (opendir, readdir, mkdir, etc.)
|
||||
# ESPHome doesn't use directory functions on ESP32.
|
||||
# Components that need it (e.g., storage components) call require_vfs_dir().
|
||||
# Saves approximately 0.5KB+ of flash when disabled (default).
|
||||
if CORE.data.get(KEY_VFS_DIR_REQUIRED, False):
|
||||
# Component requires VFS directory support - force enable regardless of user setting
|
||||
add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_DIR", True)
|
||||
else:
|
||||
# No component needs it - allow user to control (default: disabled)
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_VFS_SUPPORT_DIR",
|
||||
not advanced.get(CONF_DISABLE_VFS_SUPPORT_DIR, True),
|
||||
)
|
||||
|
||||
cg.add_platformio_option("board_build.partitions", "partitions.csv")
|
||||
if CONF_PARTITIONS in config:
|
||||
add_extra_build_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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,7 +67,7 @@ void GPIOSwitch::write_state(bool state) {
|
||||
this->pin_->digital_write(state);
|
||||
this->publish_state(state);
|
||||
}
|
||||
void GPIOSwitch::set_interlock(const std::vector<Switch *> &interlock) { this->interlock_ = interlock; }
|
||||
void GPIOSwitch::set_interlock(const std::initializer_list<Switch *> &interlock) { this->interlock_ = interlock; }
|
||||
|
||||
} // namespace gpio
|
||||
} // namespace esphome
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/switch/switch.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace esphome {
|
||||
namespace gpio {
|
||||
|
||||
@@ -19,14 +18,14 @@ class GPIOSwitch : public switch_::Switch, public Component {
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void set_interlock(const std::vector<Switch *> &interlock);
|
||||
void set_interlock(const std::initializer_list<Switch *> &interlock);
|
||||
void set_interlock_wait_time(uint32_t interlock_wait_time) { interlock_wait_time_ = interlock_wait_time; }
|
||||
|
||||
protected:
|
||||
void write_state(bool state) override;
|
||||
|
||||
GPIOPin *pin_;
|
||||
std::vector<Switch *> interlock_;
|
||||
FixedVector<Switch *> interlock_;
|
||||
uint32_t interlock_wait_time_{0};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
@@ -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_{};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 ¤t_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_{};
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
@@ -17,19 +17,19 @@ class ESPColorCorrection {
|
||||
this->color_correct_blue(color.blue), this->color_correct_white(color.white));
|
||||
}
|
||||
inline uint8_t color_correct_red(uint8_t red) const ESPHOME_ALWAYS_INLINE {
|
||||
uint8_t res = esp_scale8(esp_scale8(red, this->max_brightness_.red), this->local_brightness_);
|
||||
uint8_t res = esp_scale8_twice(red, this->max_brightness_.red, this->local_brightness_);
|
||||
return this->gamma_table_[res];
|
||||
}
|
||||
inline uint8_t color_correct_green(uint8_t green) const ESPHOME_ALWAYS_INLINE {
|
||||
uint8_t res = esp_scale8(esp_scale8(green, this->max_brightness_.green), this->local_brightness_);
|
||||
uint8_t res = esp_scale8_twice(green, this->max_brightness_.green, this->local_brightness_);
|
||||
return this->gamma_table_[res];
|
||||
}
|
||||
inline uint8_t color_correct_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE {
|
||||
uint8_t res = esp_scale8(esp_scale8(blue, this->max_brightness_.blue), this->local_brightness_);
|
||||
uint8_t res = esp_scale8_twice(blue, this->max_brightness_.blue, this->local_brightness_);
|
||||
return this->gamma_table_[res];
|
||||
}
|
||||
inline uint8_t color_correct_white(uint8_t white) const ESPHOME_ALWAYS_INLINE {
|
||||
uint8_t res = esp_scale8(esp_scale8(white, this->max_brightness_.white), this->local_brightness_);
|
||||
uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_);
|
||||
return this->gamma_table_[res];
|
||||
}
|
||||
inline Color color_uncorrect(Color color) const ESPHOME_ALWAYS_INLINE {
|
||||
|
||||
@@ -4,6 +4,7 @@ from esphome.components.esp32 import (
|
||||
VARIANT_ESP32H2,
|
||||
add_idf_sdkconfig_option,
|
||||
only_on_variant,
|
||||
require_vfs_select,
|
||||
)
|
||||
from esphome.components.mdns import MDNSComponent, enable_mdns_storage
|
||||
import esphome.config_validation as cv
|
||||
@@ -106,6 +107,14 @@ _CONNECTION_SCHEMA = cv.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _require_vfs_select(config):
|
||||
"""Register VFS select requirement during config validation."""
|
||||
# OpenThread uses esp_vfs_eventfd which requires VFS select support
|
||||
require_vfs_select()
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -122,6 +131,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV),
|
||||
cv.only_with_esp_idf,
|
||||
only_on_variant(supported=[VARIANT_ESP32C6, VARIANT_ESP32H2]),
|
||||
_require_vfs_select,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,13 @@ uint32_t ESP8266UartComponent::get_config() {
|
||||
}
|
||||
|
||||
void ESP8266UartComponent::setup() {
|
||||
if (this->rx_pin_) {
|
||||
this->rx_pin_->setup();
|
||||
}
|
||||
if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) {
|
||||
this->tx_pin_->setup();
|
||||
}
|
||||
|
||||
// Use Arduino HardwareSerial UARTs if all used pins match the ones
|
||||
// preconfigured by the platform. For example if RX disabled but TX pin
|
||||
// is 1 we still want to use Serial.
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/gpio.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "soc/gpio_num.h"
|
||||
|
||||
#ifdef USE_LOGGER
|
||||
#include "esphome/components/logger/logger.h"
|
||||
@@ -104,6 +107,13 @@ void IDFUARTComponent::load_settings(bool dump_config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->rx_pin_) {
|
||||
this->rx_pin_->setup();
|
||||
}
|
||||
if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) {
|
||||
this->tx_pin_->setup();
|
||||
}
|
||||
|
||||
int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1;
|
||||
int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1;
|
||||
int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1;
|
||||
|
||||
@@ -46,6 +46,13 @@ uint16_t LibreTinyUARTComponent::get_config() {
|
||||
}
|
||||
|
||||
void LibreTinyUARTComponent::setup() {
|
||||
if (this->rx_pin_) {
|
||||
this->rx_pin_->setup();
|
||||
}
|
||||
if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) {
|
||||
this->tx_pin_->setup();
|
||||
}
|
||||
|
||||
int8_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin();
|
||||
int8_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin();
|
||||
bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted();
|
||||
|
||||
@@ -52,6 +52,13 @@ uint16_t RP2040UartComponent::get_config() {
|
||||
}
|
||||
|
||||
void RP2040UartComponent::setup() {
|
||||
if (this->rx_pin_) {
|
||||
this->rx_pin_->setup();
|
||||
}
|
||||
if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) {
|
||||
this->tx_pin_->setup();
|
||||
}
|
||||
|
||||
uint16_t config = get_config();
|
||||
|
||||
constexpr uint32_t valid_tx_uart_0 = __bitset({0, 12, 16, 28});
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -14,6 +14,15 @@ inline static constexpr uint8_t esp_scale8(uint8_t i, uint8_t scale) {
|
||||
return (uint16_t(i) * (1 + uint16_t(scale))) / 256;
|
||||
}
|
||||
|
||||
/// Scale an 8-bit value by two 8-bit scale factors with improved precision.
|
||||
/// This is more accurate than calling esp_scale8() twice because it delays
|
||||
/// truncation until after both multiplications, preserving intermediate precision.
|
||||
/// For example: esp_scale8_twice(value, max_brightness, local_brightness)
|
||||
/// gives better results than esp_scale8(esp_scale8(value, max_brightness), local_brightness)
|
||||
inline static constexpr uint8_t esp_scale8_twice(uint8_t i, uint8_t scale1, uint8_t scale2) {
|
||||
return (uint32_t(i) * (1 + uint32_t(scale1)) * (1 + uint32_t(scale2))) >> 16;
|
||||
}
|
||||
|
||||
struct Color {
|
||||
union {
|
||||
struct {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -46,6 +46,10 @@ lib_deps =
|
||||
; This is using the repository until a new release is published to PlatformIO
|
||||
https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library
|
||||
lvgl/lvgl@8.4.0 ; lvgl
|
||||
; This dependency is used only in unit tests.
|
||||
; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py
|
||||
; See scripts/cpp_unit_test.py and tests/components/README.md
|
||||
google/googletest@^1.15.2
|
||||
build_flags =
|
||||
-DESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE
|
||||
-std=gnu++20
|
||||
|
||||
172
script/cpp_unit_test.py
Executable file
172
script/cpp_unit_test.py
Executable file
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from helpers import get_all_components, get_all_dependencies, root_path
|
||||
|
||||
from esphome.__main__ import command_compile, parse_args
|
||||
from esphome.config import validate_config
|
||||
from esphome.core import CORE
|
||||
from esphome.platformio_api import get_idedata
|
||||
|
||||
# This must coincide with the version in /platformio.ini
|
||||
PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2"
|
||||
|
||||
# Path to /tests/components
|
||||
COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components"
|
||||
|
||||
|
||||
def hash_components(components: list[str]) -> str:
|
||||
key = ",".join(components)
|
||||
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def filter_components_without_tests(components: list[str]) -> list[str]:
|
||||
"""Filter out components that do not have a corresponding test file.
|
||||
|
||||
This is done by checking if the component's directory contains at
|
||||
least a .cpp file.
|
||||
"""
|
||||
filtered_components: list[str] = []
|
||||
for component in components:
|
||||
test_dir = COMPONENTS_TESTS_DIR / component
|
||||
if test_dir.is_dir() and any(test_dir.glob("*.cpp")):
|
||||
filtered_components.append(component)
|
||||
else:
|
||||
print(
|
||||
f"WARNING: No tests found for component '{component}', skipping.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return filtered_components
|
||||
|
||||
|
||||
def create_test_config(config_name: str, includes: list[str]) -> dict:
|
||||
"""Create ESPHome test configuration for C++ unit tests.
|
||||
|
||||
Args:
|
||||
config_name: Unique name for this test configuration
|
||||
includes: List of include folders for the test build
|
||||
|
||||
Returns:
|
||||
Configuration dict for ESPHome
|
||||
"""
|
||||
return {
|
||||
"esphome": {
|
||||
"name": config_name,
|
||||
"friendly_name": "CPP Unit Tests",
|
||||
"libraries": PLATFORMIO_GOOGLE_TEST_LIB,
|
||||
"platformio_options": {
|
||||
"build_type": "debug",
|
||||
"build_unflags": [
|
||||
"-Os", # remove size-opt flag
|
||||
],
|
||||
"build_flags": [
|
||||
"-Og", # optimize for debug
|
||||
],
|
||||
"debug_build_flags": [ # only for debug builds
|
||||
"-g3", # max debug info
|
||||
"-ggdb3",
|
||||
],
|
||||
},
|
||||
"includes": includes,
|
||||
},
|
||||
"host": {},
|
||||
"logger": {"level": "DEBUG"},
|
||||
}
|
||||
|
||||
|
||||
def run_tests(selected_components: list[str]) -> int:
|
||||
# Skip tests on Windows
|
||||
if os.name == "nt":
|
||||
print("Skipping esphome tests on Windows", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Remove components that do not have tests
|
||||
components = filter_components_without_tests(selected_components)
|
||||
|
||||
if len(components) == 0:
|
||||
print(
|
||||
"No components specified or no tests found for the specified components.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
components = sorted(components)
|
||||
|
||||
# Obtain possible dependencies for the requested components:
|
||||
components_with_dependencies = sorted(get_all_dependencies(set(components)))
|
||||
|
||||
# Build a list of include folders, one folder per component containing tests.
|
||||
# A special replacement main.cpp is located in /tests/components/main.cpp
|
||||
includes: list[str] = ["main.cpp"] + components
|
||||
|
||||
# Create a unique name for this config based on the actual components being tested
|
||||
# to maximize cache during testing
|
||||
config_name: str = "cpptests-" + hash_components(components)
|
||||
|
||||
config = create_test_config(config_name, includes)
|
||||
|
||||
CORE.config_path = COMPONENTS_TESTS_DIR / "dummy.yaml"
|
||||
CORE.dashboard = None
|
||||
|
||||
# Validate config will expand the above with defaults:
|
||||
config = validate_config(config, {})
|
||||
|
||||
# Add all components and dependencies to the base configuration after validation, so their files
|
||||
# are added to the build.
|
||||
config.update({key: {} for key in components_with_dependencies})
|
||||
|
||||
print(f"Testing components: {', '.join(components)}")
|
||||
CORE.config = config
|
||||
args = parse_args(["program", "compile", str(CORE.config_path)])
|
||||
try:
|
||||
exit_code: int = command_compile(args, config)
|
||||
|
||||
if exit_code != 0:
|
||||
print(f"Error compiling unit tests for {', '.join(components)}")
|
||||
return exit_code
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error compiling unit tests for {', '.join(components)}. Check path. : {e}"
|
||||
)
|
||||
return 2
|
||||
|
||||
# After a successful compilation, locate the executable and run it:
|
||||
idedata = get_idedata(config)
|
||||
if idedata is None:
|
||||
print("Cannot find executable")
|
||||
return 1
|
||||
|
||||
program_path: str = idedata.raw["prog_path"]
|
||||
run_cmd: list[str] = [program_path]
|
||||
run_proc = subprocess.run(run_cmd, check=False)
|
||||
return run_proc.returncode
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run C++ unit tests for ESPHome components."
|
||||
)
|
||||
parser.add_argument(
|
||||
"components",
|
||||
nargs="*",
|
||||
help="List of components to test. Use --all to test all known components.",
|
||||
)
|
||||
parser.add_argument("--all", action="store_true", help="Test all known components.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
components: list[str] = get_all_components()
|
||||
else:
|
||||
components: list[str] = args.components
|
||||
|
||||
sys.exit(run_tests(components))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -52,13 +52,16 @@ from helpers import (
|
||||
CPP_FILE_EXTENSIONS,
|
||||
PYTHON_FILE_EXTENSIONS,
|
||||
changed_files,
|
||||
filter_component_files,
|
||||
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,
|
||||
git_ls_files,
|
||||
parse_test_filename,
|
||||
root_path,
|
||||
@@ -143,10 +146,9 @@ def should_run_integration_tests(branch: str | None = None) -> bool:
|
||||
"""
|
||||
files = changed_files(branch)
|
||||
|
||||
# Check if any core files changed (esphome/core/*)
|
||||
for file in files:
|
||||
if file.startswith("esphome/core/"):
|
||||
return True
|
||||
if core_changed(files):
|
||||
# If any core files changed, run integration tests
|
||||
return True
|
||||
|
||||
# Check if any integration test files changed
|
||||
if any("tests/integration" in file for file in files):
|
||||
@@ -283,6 +285,40 @@ def should_run_python_linters(branch: str | None = None) -> bool:
|
||||
return _any_changed_file_endswith(branch, PYTHON_FILE_EXTENSIONS)
|
||||
|
||||
|
||||
def determine_cpp_unit_tests(
|
||||
branch: str | None = None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Determine if C++ unit tests should run based on changed files.
|
||||
|
||||
This function is used by the CI workflow to skip C++ unit tests when
|
||||
no relevant files have changed, saving CI time and resources.
|
||||
|
||||
C++ unit tests will run when any of the following conditions are met:
|
||||
|
||||
1. Any C++ core source files changed (esphome/core/*), in which case
|
||||
all cpp unit tests run.
|
||||
2. A test file for a component changed, which triggers tests for that
|
||||
component.
|
||||
3. The code for a component changed, which triggers tests for that
|
||||
component and all components that depend on it.
|
||||
|
||||
Args:
|
||||
branch: Branch to compare against. If None, uses default.
|
||||
|
||||
Returns:
|
||||
Tuple of (run_all, components) where:
|
||||
- run_all: True if all tests should run, False otherwise
|
||||
- components: List of specific components to test (empty if run_all)
|
||||
"""
|
||||
files = changed_files(branch)
|
||||
if core_changed(files):
|
||||
return (True, [])
|
||||
|
||||
# Filter to only C++ files
|
||||
cpp_files = list(filter(filter_component_and_test_cpp_files, files))
|
||||
return (False, get_cpp_changed_components(cpp_files))
|
||||
|
||||
|
||||
def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool:
|
||||
"""Check if a changed file ends with any of the specified extensions."""
|
||||
return any(file.endswith(extensions) for file in changed_files(branch))
|
||||
@@ -570,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_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
|
||||
@@ -646,6 +684,9 @@ def main() -> None:
|
||||
files_to_check_count = 0
|
||||
|
||||
# Build output
|
||||
# Determine which C++ unit tests to run
|
||||
cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch)
|
||||
|
||||
output: dict[str, Any] = {
|
||||
"integration_tests": run_integration,
|
||||
"clang_tidy": run_clang_tidy,
|
||||
@@ -661,6 +702,8 @@ def main() -> None:
|
||||
"dependency_only_count": len(dependency_only_components),
|
||||
"changed_cpp_file_count": changed_cpp_file_count,
|
||||
"memory_impact": memory_impact,
|
||||
"cpp_unit_tests_run_all": cpp_run_all,
|
||||
"cpp_unit_tests_components": cpp_components,
|
||||
}
|
||||
|
||||
# Output as JSON
|
||||
|
||||
@@ -2,19 +2,14 @@
|
||||
|
||||
import json
|
||||
|
||||
from helpers import git_ls_files
|
||||
from helpers import get_all_component_files, get_components_with_dependencies
|
||||
|
||||
from esphome.automation import ACTION_REGISTRY, CONDITION_REGISTRY
|
||||
from esphome.pins import PIN_SCHEMA_REGISTRY
|
||||
|
||||
list_components = __import__("list-components")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
files = git_ls_files()
|
||||
files = filter(list_components.filter_component_files, files)
|
||||
|
||||
components = list_components.get_components(files, True)
|
||||
files = get_all_component_files()
|
||||
components = get_components_with_dependencies(files, True)
|
||||
|
||||
dump = {
|
||||
"actions": sorted(list(ACTION_REGISTRY.keys())),
|
||||
|
||||
@@ -25,12 +25,21 @@ CPP_FILE_EXTENSIONS = (".cpp", ".h", ".hpp", ".cc", ".cxx", ".c", ".tcc")
|
||||
# Python file extensions
|
||||
PYTHON_FILE_EXTENSIONS = (".py", ".pyi")
|
||||
|
||||
# Combined C++ and Python file extensions for convenience
|
||||
CPP_AND_PYTHON_FILE_EXTENSIONS = (*CPP_FILE_EXTENSIONS, *PYTHON_FILE_EXTENSIONS)
|
||||
|
||||
# YAML file extensions
|
||||
YAML_FILE_EXTENSIONS = (".yaml", ".yml")
|
||||
|
||||
# Component path prefix
|
||||
ESPHOME_COMPONENTS_PATH = "esphome/components/"
|
||||
|
||||
# Test components path prefix
|
||||
ESPHOME_TESTS_COMPONENTS_PATH = "tests/components/"
|
||||
|
||||
# Tuple of component and test paths for efficient startswith checks
|
||||
COMPONENT_AND_TESTS_PATHS = (ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH)
|
||||
|
||||
# Base bus components - these ARE the bus implementations and should not
|
||||
# be flagged as needing migration since they are the platform/base components
|
||||
BASE_BUS_COMPONENTS = {
|
||||
@@ -658,17 +667,32 @@ def get_components_from_integration_fixtures() -> set[str]:
|
||||
return components
|
||||
|
||||
|
||||
def filter_component_files(file_path: str) -> bool:
|
||||
"""Check if a file path is a component file.
|
||||
def filter_component_and_test_files(file_path: str) -> bool:
|
||||
"""Check if a file path is a component or test file.
|
||||
|
||||
Args:
|
||||
file_path: Path to check
|
||||
|
||||
Returns:
|
||||
True if the file is in a component directory
|
||||
True if the file is in a component or test directory
|
||||
"""
|
||||
return file_path.startswith("esphome/components/") or file_path.startswith(
|
||||
"tests/components/"
|
||||
return file_path.startswith(COMPONENT_AND_TESTS_PATHS) or (
|
||||
file_path.startswith(ESPHOME_TESTS_COMPONENTS_PATH)
|
||||
and file_path.endswith(YAML_FILE_EXTENSIONS)
|
||||
)
|
||||
|
||||
|
||||
def filter_component_and_test_cpp_files(file_path: str) -> bool:
|
||||
"""Check if a file is a C++ source file in component or test directories.
|
||||
|
||||
Args:
|
||||
file_path: Path to check
|
||||
|
||||
Returns:
|
||||
True if the file is a C++ source/header file in component or test directories
|
||||
"""
|
||||
return file_path.endswith(CPP_FILE_EXTENSIONS) and file_path.startswith(
|
||||
COMPONENT_AND_TESTS_PATHS
|
||||
)
|
||||
|
||||
|
||||
@@ -740,7 +764,7 @@ def create_components_graph() -> dict[str, list[str]]:
|
||||
|
||||
# The root directory of the repo
|
||||
root = Path(__file__).parent.parent
|
||||
components_dir = root / "esphome" / "components"
|
||||
components_dir = root / ESPHOME_COMPONENTS_PATH
|
||||
# Fake some directory so that get_component works
|
||||
CORE.config_path = root
|
||||
# Various configuration to capture different outcomes used by `AUTO_LOAD` function.
|
||||
@@ -873,3 +897,81 @@ def get_components_with_dependencies(
|
||||
return sorted(all_changed_components)
|
||||
|
||||
return sorted(components)
|
||||
|
||||
|
||||
def get_all_component_files() -> list[str]:
|
||||
"""Get all component and test files from git.
|
||||
|
||||
Returns:
|
||||
List of all component and test file paths
|
||||
"""
|
||||
files = git_ls_files()
|
||||
return list(filter(filter_component_and_test_files, files))
|
||||
|
||||
|
||||
def get_all_components() -> list[str]:
|
||||
"""Get all component names.
|
||||
|
||||
This function uses git to find all component files and extracts the component names.
|
||||
It returns the same list as calling list-components.py without arguments.
|
||||
|
||||
Returns:
|
||||
List of all component names
|
||||
"""
|
||||
return get_components_with_dependencies(get_all_component_files(), False)
|
||||
|
||||
|
||||
def core_changed(files: list[str]) -> bool:
|
||||
"""Check if any core C++ or Python files have changed.
|
||||
|
||||
Args:
|
||||
files: List of file paths to check
|
||||
|
||||
Returns:
|
||||
True if any core C++ or Python files have changed
|
||||
"""
|
||||
return any(
|
||||
f.startswith("esphome/core/") and f.endswith(CPP_AND_PYTHON_FILE_EXTENSIONS)
|
||||
for f in files
|
||||
)
|
||||
|
||||
|
||||
def get_cpp_changed_components(files: list[str]) -> list[str]:
|
||||
"""Get components that have changed C++ files or tests.
|
||||
|
||||
This function analyzes a list of changed files and determines which components
|
||||
are affected. It handles two scenarios:
|
||||
|
||||
1. Test files changed (tests/components/<component>/*.cpp):
|
||||
- Adds the component to the affected list
|
||||
- Only that component needs to be tested
|
||||
|
||||
2. Component C++ files changed (esphome/components/<component>/*):
|
||||
- Adds the component to the affected list
|
||||
- Also adds all components that depend on this component (recursively)
|
||||
- This ensures that changes propagate to dependent components
|
||||
|
||||
Args:
|
||||
files: List of file paths to analyze (should be C++ files)
|
||||
|
||||
Returns:
|
||||
Sorted list of component names that need C++ unit tests run
|
||||
"""
|
||||
components_graph = create_components_graph()
|
||||
affected: set[str] = set()
|
||||
for file in files:
|
||||
if not file.endswith(CPP_FILE_EXTENSIONS):
|
||||
continue
|
||||
if file.startswith(ESPHOME_TESTS_COMPONENTS_PATH):
|
||||
parts = file.split("/")
|
||||
if len(parts) >= 4:
|
||||
component_dir = Path(ESPHOME_TESTS_COMPONENTS_PATH) / parts[2]
|
||||
if component_dir.is_dir():
|
||||
affected.add(parts[2])
|
||||
elif file.startswith(ESPHOME_COMPONENTS_PATH):
|
||||
parts = file.split("/")
|
||||
if len(parts) >= 4:
|
||||
component = parts[2]
|
||||
affected.update(find_children_of_component(components_graph, component))
|
||||
affected.add(component)
|
||||
return sorted(affected)
|
||||
|
||||
@@ -3,18 +3,14 @@ import argparse
|
||||
|
||||
from helpers import (
|
||||
changed_files,
|
||||
filter_component_files,
|
||||
filter_component_and_test_cpp_files,
|
||||
filter_component_and_test_files,
|
||||
get_all_component_files,
|
||||
get_components_with_dependencies,
|
||||
git_ls_files,
|
||||
get_cpp_changed_components,
|
||||
)
|
||||
|
||||
|
||||
def get_all_component_files() -> list[str]:
|
||||
"""Get all component files from git."""
|
||||
files = git_ls_files()
|
||||
return list(filter(filter_component_files, files))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
@@ -39,16 +35,29 @@ def main():
|
||||
parser.add_argument(
|
||||
"-b", "--branch", help="Branch to compare changed files against"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cpp-changed",
|
||||
action="store_true",
|
||||
help="List components with changed C++ files",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.branch and not (
|
||||
args.changed or args.changed_direct or args.changed_with_deps
|
||||
args.changed
|
||||
or args.changed_direct
|
||||
or args.changed_with_deps
|
||||
or args.cpp_changed
|
||||
):
|
||||
parser.error(
|
||||
"--branch requires --changed, --changed-direct, or --changed-with-deps"
|
||||
"--branch requires --changed, --changed-direct, --changed-with-deps, or --cpp-changed"
|
||||
)
|
||||
|
||||
if args.changed or args.changed_direct or args.changed_with_deps:
|
||||
if (
|
||||
args.changed
|
||||
or args.changed_direct
|
||||
or args.changed_with_deps
|
||||
or args.cpp_changed
|
||||
):
|
||||
# When --changed* is passed, only get the changed files
|
||||
changed = changed_files(args.branch)
|
||||
|
||||
@@ -68,6 +77,11 @@ def main():
|
||||
# - --changed-with-deps: Used by CI test determination (script/determine-jobs.py)
|
||||
# Returns: Components with code changes + their dependencies (not infrastructure)
|
||||
# Reason: CI needs to test changed components and their dependents
|
||||
#
|
||||
# - --cpp-changed: Used by CI to determine if any C++ files changed (script/determine-jobs.py)
|
||||
# Returns: Only components with changed C++ files
|
||||
# Reason: Only components with C++ changes need C++ testing
|
||||
|
||||
base_test_changed = any(
|
||||
"tests/test_build_components" in file for file in changed
|
||||
)
|
||||
@@ -80,7 +94,7 @@ def main():
|
||||
# Only look at changed component files (ignore infrastructure changes)
|
||||
# For --changed-direct: only actual component code changes matter (for isolation)
|
||||
# For --changed-with-deps: only actual component code changes matter (for testing)
|
||||
files = [f for f in changed if filter_component_files(f)]
|
||||
files = [f for f in changed if filter_component_and_test_files(f)]
|
||||
else:
|
||||
# Get all component files
|
||||
files = get_all_component_files()
|
||||
@@ -100,6 +114,11 @@ def main():
|
||||
# Return only directly changed components (without dependencies)
|
||||
for c in get_components_with_dependencies(files, False):
|
||||
print(c)
|
||||
elif args.cpp_changed:
|
||||
# Only look at changed cpp files
|
||||
files = list(filter(filter_component_and_test_cpp_files, changed))
|
||||
for c in get_cpp_changed_components(files):
|
||||
print(c)
|
||||
else:
|
||||
# Return all changed components (with dependencies) - default behavior
|
||||
for c in get_components_with_dependencies(files, args.changed):
|
||||
|
||||
5
tests/components/.gitignore
vendored
Normal file
5
tests/components/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Gitignore settings for ESPHome
|
||||
# This is an example and may include too much for your use-case.
|
||||
# You can modify this file to suit your needs.
|
||||
/.esphome/
|
||||
/secrets.yaml
|
||||
32
tests/components/README.md
Normal file
32
tests/components/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# How to write C++ ESPHome unit tests
|
||||
|
||||
1. Locate the folder with your component or create a new one with the same name as the component.
|
||||
2. Write the tests. You can add as many `.cpp` and `.h` files as you need to organize your tests.
|
||||
|
||||
**IMPORTANT**: wrap all your testing code in a unique namespace to avoid linker collisions when compiling
|
||||
testing binaries that combine many components. By convention, this unique namespace is `esphome::component::testing`
|
||||
(where "component" is the component under test), for example: `esphome::uart::testing`.
|
||||
|
||||
|
||||
## Running component unit tests
|
||||
|
||||
(from the repository root)
|
||||
```bash
|
||||
./script/cpp_unit_test.py component1 component2 ...
|
||||
```
|
||||
|
||||
The above will compile and run the provided components and their tests.
|
||||
|
||||
To run all tests, you can invoke `cpp_unit_test.py` with the special `--all` flag:
|
||||
|
||||
```bash
|
||||
./script/cpp_unit_test.py --all
|
||||
```
|
||||
|
||||
To run a specific test suite, you can provide a Google Test filter:
|
||||
|
||||
```bash
|
||||
GTEST_FILTER='UART*' ./script/cpp_unit_test.py uart modbus
|
||||
```
|
||||
|
||||
The process will return `0` for success or nonzero for failure. In case of failure, the errors will be printed out to the console.
|
||||
@@ -70,3 +70,69 @@ binary_sensor:
|
||||
- delay: 10s
|
||||
time_off: 200ms
|
||||
time_on: 800ms
|
||||
|
||||
# Test on_multi_click with single click
|
||||
- platform: template
|
||||
id: multi_click_single
|
||||
name: "Multi Click Single"
|
||||
on_multi_click:
|
||||
- timing:
|
||||
- state: true
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
then:
|
||||
- logger.log: "Single click detected"
|
||||
|
||||
# Test on_multi_click with double click
|
||||
- platform: template
|
||||
id: multi_click_double
|
||||
name: "Multi Click Double"
|
||||
on_multi_click:
|
||||
- timing:
|
||||
- state: true
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
- state: false
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
- state: true
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
then:
|
||||
- logger.log: "Double click detected"
|
||||
|
||||
# Test on_multi_click with complex pattern (5 events)
|
||||
- platform: template
|
||||
id: multi_click_complex
|
||||
name: "Multi Click Complex"
|
||||
on_multi_click:
|
||||
- timing:
|
||||
- state: true
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
- state: false
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
- state: true
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
- state: false
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
- state: true
|
||||
min_length: 50ms
|
||||
then:
|
||||
- logger.log: "Complex pattern detected"
|
||||
|
||||
# Test on_multi_click with custom invalid_cooldown
|
||||
- platform: template
|
||||
id: multi_click_cooldown
|
||||
name: "Multi Click Cooldown"
|
||||
on_multi_click:
|
||||
- timing:
|
||||
- state: true
|
||||
min_length: 100ms
|
||||
max_length: 500ms
|
||||
invalid_cooldown: 2s
|
||||
then:
|
||||
- logger.log: "Click with custom cooldown"
|
||||
|
||||
@@ -12,3 +12,20 @@ switch:
|
||||
- platform: gpio
|
||||
pin: ${switch_pin}
|
||||
id: gpio_switch
|
||||
|
||||
- platform: gpio
|
||||
pin: ${switch_pin_2}
|
||||
id: gpio_switch_interlock_1
|
||||
interlock: [gpio_switch_interlock_2, gpio_switch_interlock_3]
|
||||
interlock_wait_time: 100ms
|
||||
|
||||
- platform: gpio
|
||||
pin: ${switch_pin_3}
|
||||
id: gpio_switch_interlock_2
|
||||
interlock: [gpio_switch_interlock_1, gpio_switch_interlock_3]
|
||||
|
||||
- platform: gpio
|
||||
pin: ${switch_pin_4}
|
||||
id: gpio_switch_interlock_3
|
||||
interlock: [gpio_switch_interlock_1, gpio_switch_interlock_2]
|
||||
interlock_wait_time: 50ms
|
||||
|
||||
@@ -2,5 +2,8 @@ substitutions:
|
||||
binary_sensor_pin: GPIO2
|
||||
output_pin: GPIO3
|
||||
switch_pin: GPIO4
|
||||
switch_pin_2: GPIO5
|
||||
switch_pin_3: GPIO6
|
||||
switch_pin_4: GPIO7
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -2,5 +2,8 @@ substitutions:
|
||||
binary_sensor_pin: GPIO12
|
||||
output_pin: GPIO13
|
||||
switch_pin: GPIO14
|
||||
switch_pin_2: GPIO15
|
||||
switch_pin_3: GPIO16
|
||||
switch_pin_4: GPIO17
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -2,5 +2,8 @@ substitutions:
|
||||
binary_sensor_pin: GPIO0
|
||||
output_pin: GPIO2
|
||||
switch_pin: GPIO15
|
||||
switch_pin_2: GPIO12
|
||||
switch_pin_3: GPIO13
|
||||
switch_pin_4: GPIO14
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -12,3 +12,20 @@ switch:
|
||||
- platform: gpio
|
||||
pin: P1.2
|
||||
id: gpio_switch
|
||||
|
||||
- platform: gpio
|
||||
pin: P1.3
|
||||
id: gpio_switch_interlock_1
|
||||
interlock: [gpio_switch_interlock_2, gpio_switch_interlock_3]
|
||||
interlock_wait_time: 100ms
|
||||
|
||||
- platform: gpio
|
||||
pin: P1.4
|
||||
id: gpio_switch_interlock_2
|
||||
interlock: [gpio_switch_interlock_1, gpio_switch_interlock_3]
|
||||
|
||||
- platform: gpio
|
||||
pin: P1.5
|
||||
id: gpio_switch_interlock_3
|
||||
interlock: [gpio_switch_interlock_1, gpio_switch_interlock_2]
|
||||
interlock_wait_time: 50ms
|
||||
|
||||
@@ -12,3 +12,20 @@ switch:
|
||||
- platform: gpio
|
||||
pin: P1.2
|
||||
id: gpio_switch
|
||||
|
||||
- platform: gpio
|
||||
pin: P1.3
|
||||
id: gpio_switch_interlock_1
|
||||
interlock: [gpio_switch_interlock_2, gpio_switch_interlock_3]
|
||||
interlock_wait_time: 100ms
|
||||
|
||||
- platform: gpio
|
||||
pin: P1.4
|
||||
id: gpio_switch_interlock_2
|
||||
interlock: [gpio_switch_interlock_1, gpio_switch_interlock_3]
|
||||
|
||||
- platform: gpio
|
||||
pin: P1.5
|
||||
id: gpio_switch_interlock_3
|
||||
interlock: [gpio_switch_interlock_1, gpio_switch_interlock_2]
|
||||
interlock_wait_time: 50ms
|
||||
|
||||
@@ -2,5 +2,8 @@ substitutions:
|
||||
binary_sensor_pin: GPIO2
|
||||
output_pin: GPIO3
|
||||
switch_pin: GPIO4
|
||||
switch_pin_2: GPIO5
|
||||
switch_pin_3: GPIO6
|
||||
switch_pin_4: GPIO7
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -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
|
||||
@@ -123,3 +137,49 @@ light:
|
||||
red: 100%
|
||||
green: 50%
|
||||
blue: 50%
|
||||
# Test StrobeLightEffect with multiple colors
|
||||
- platform: monochromatic
|
||||
id: test_strobe_multiple
|
||||
name: Strobe Multiple Colors
|
||||
output: test_ledc_1
|
||||
effects:
|
||||
- strobe:
|
||||
name: Strobe Multi
|
||||
colors:
|
||||
- state: true
|
||||
brightness: 100%
|
||||
duration: 500ms
|
||||
- state: false
|
||||
duration: 250ms
|
||||
- state: true
|
||||
brightness: 50%
|
||||
duration: 500ms
|
||||
# Test StrobeLightEffect with transition
|
||||
- platform: rgb
|
||||
id: test_strobe_transition
|
||||
name: Strobe With Transition
|
||||
red: test_ledc_1
|
||||
green: test_ledc_2
|
||||
blue: test_ledc_3
|
||||
effects:
|
||||
- strobe:
|
||||
name: Strobe Transition
|
||||
colors:
|
||||
- state: true
|
||||
red: 100%
|
||||
green: 0%
|
||||
blue: 0%
|
||||
duration: 1s
|
||||
transition_length: 500ms
|
||||
- state: true
|
||||
red: 0%
|
||||
green: 100%
|
||||
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
|
||||
|
||||
26
tests/components/main.cpp
Normal file
26
tests/components/main.cpp
Normal file
@@ -0,0 +1,26 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
/*
|
||||
This special main.cpp replaces the default one.
|
||||
It will run all the Google Tests found in all compiled cpp files and then exit with the result
|
||||
See README.md for more information
|
||||
*/
|
||||
|
||||
// Auto generated code by esphome
|
||||
// ========== AUTO GENERATED INCLUDE BLOCK BEGIN ===========
|
||||
// ========== AUTO GENERATED INCLUDE BLOCK END ==========="
|
||||
|
||||
void original_setup() {
|
||||
// This function won't be run.
|
||||
|
||||
// ========== AUTO GENERATED CODE BEGIN ===========
|
||||
// =========== AUTO GENERATED CODE END ============
|
||||
}
|
||||
|
||||
void setup() {
|
||||
::testing::InitGoogleTest();
|
||||
int exit_code = RUN_ALL_TESTS();
|
||||
exit(exit_code);
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
37
tests/components/uart/common.h
Normal file
37
tests/components/uart/common.h
Normal file
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
|
||||
namespace esphome::uart::testing {
|
||||
|
||||
using ::testing::_;
|
||||
using ::testing::Return;
|
||||
using ::testing::SaveArg;
|
||||
using ::testing::DoAll;
|
||||
using ::testing::Invoke;
|
||||
using ::testing::SetArgPointee;
|
||||
|
||||
// Derive a mock from UARTComponent to test the wrapper implementations.
|
||||
class MockUARTComponent : public UARTComponent {
|
||||
public:
|
||||
using UARTComponent::write_array;
|
||||
using UARTComponent::write_byte;
|
||||
|
||||
// NOTE: std::vector is used here for test convenience. For production code,
|
||||
// consider using StaticVector or FixedVector from esphome/core/helpers.h instead.
|
||||
std::vector<uint8_t> written_data;
|
||||
|
||||
void write_array(const uint8_t *data, size_t len) override { written_data.assign(data, data + len); }
|
||||
|
||||
MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override));
|
||||
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
|
||||
MOCK_METHOD(int, available, (), (override));
|
||||
MOCK_METHOD(void, flush, (), (override));
|
||||
MOCK_METHOD(void, check_logger_conflict, (), (override));
|
||||
};
|
||||
|
||||
} // namespace esphome::uart::testing
|
||||
73
tests/components/uart/uart_component.cpp
Normal file
73
tests/components/uart/uart_component.cpp
Normal file
@@ -0,0 +1,73 @@
|
||||
#include "common.h"
|
||||
|
||||
namespace esphome::uart::testing {
|
||||
|
||||
TEST(UARTComponentTest, SetGetBaudRate) {
|
||||
MockUARTComponent mock;
|
||||
mock.set_baud_rate(38400);
|
||||
EXPECT_EQ(mock.get_baud_rate(), 38400);
|
||||
}
|
||||
|
||||
TEST(UARTComponentTest, SetGetStopBits) {
|
||||
MockUARTComponent mock;
|
||||
mock.set_stop_bits(2);
|
||||
EXPECT_EQ(mock.get_stop_bits(), 2);
|
||||
}
|
||||
|
||||
TEST(UARTComponentTest, SetGetDataBits) {
|
||||
MockUARTComponent mock;
|
||||
mock.set_data_bits(7);
|
||||
EXPECT_EQ(mock.get_data_bits(), 7);
|
||||
}
|
||||
|
||||
TEST(UARTComponentTest, SetGetParity) {
|
||||
MockUARTComponent mock;
|
||||
mock.set_parity(UARTParityOptions::UART_CONFIG_PARITY_EVEN);
|
||||
EXPECT_EQ(mock.get_parity(), UARTParityOptions::UART_CONFIG_PARITY_EVEN);
|
||||
}
|
||||
|
||||
TEST(UARTComponentTest, SetGetRxBufferSize) {
|
||||
MockUARTComponent mock;
|
||||
mock.set_rx_buffer_size(128);
|
||||
EXPECT_EQ(mock.get_rx_buffer_size(), 128);
|
||||
}
|
||||
|
||||
TEST(UARTComponentTest, WriteArrayVector) {
|
||||
MockUARTComponent mock;
|
||||
std::vector<uint8_t> data = {10, 20, 30};
|
||||
mock.write_array(data);
|
||||
EXPECT_EQ(mock.written_data, data);
|
||||
}
|
||||
TEST(UARTComponentTest, WriteByte) {
|
||||
MockUARTComponent mock;
|
||||
uint8_t byte = 0x79;
|
||||
mock.write_byte(byte);
|
||||
EXPECT_EQ(mock.written_data.size(), 1);
|
||||
EXPECT_EQ(mock.written_data[0], byte);
|
||||
}
|
||||
|
||||
TEST(UARTComponentTest, WriteStr) {
|
||||
MockUARTComponent mock;
|
||||
const char *str = "Hello";
|
||||
std::vector<uint8_t> captured;
|
||||
mock.write_str(str);
|
||||
EXPECT_EQ(mock.written_data.size(), strlen(str));
|
||||
EXPECT_EQ(0, strncmp(str, (const char *) mock.written_data.data(), mock.written_data.size()));
|
||||
}
|
||||
|
||||
// Tests for wrapper methods forwarding to pure virtual read_array
|
||||
TEST(UARTComponentTest, ReadByteSuccess) {
|
||||
MockUARTComponent mock;
|
||||
uint8_t value = 0;
|
||||
EXPECT_CALL(mock, read_array(&value, 1)).WillOnce(Return(true));
|
||||
EXPECT_TRUE(mock.read_byte(&value));
|
||||
}
|
||||
|
||||
TEST(UARTComponentTest, ReadByteFailure) {
|
||||
MockUARTComponent mock;
|
||||
uint8_t value = 0xFF;
|
||||
EXPECT_CALL(mock, read_array(&value, 1)).WillOnce(Return(false));
|
||||
EXPECT_FALSE(mock.read_byte(&value));
|
||||
}
|
||||
|
||||
} // namespace esphome::uart::testing
|
||||
108
tests/components/uart/uart_device.cpp
Normal file
108
tests/components/uart/uart_device.cpp
Normal file
@@ -0,0 +1,108 @@
|
||||
#include "common.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
|
||||
namespace esphome::uart::testing {
|
||||
|
||||
TEST(UARTDeviceTest, ReadByteSuccess) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
uint8_t value = 0;
|
||||
EXPECT_CALL(mock, read_array(_, 1)).WillOnce(DoAll(SetArgPointee<0>(0x5A), Return(true)));
|
||||
bool result = dev.read_byte(&value);
|
||||
EXPECT_TRUE(result);
|
||||
EXPECT_EQ(value, 0x5A);
|
||||
}
|
||||
|
||||
TEST(UARTDeviceTest, ReadByteFailure) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
uint8_t value = 0xFF;
|
||||
EXPECT_CALL(mock, read_array(_, 1)).WillOnce(Return(false));
|
||||
bool result = dev.read_byte(&value);
|
||||
EXPECT_FALSE(result);
|
||||
}
|
||||
|
||||
TEST(UARTDeviceTest, PeekByteSuccess) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
uint8_t value = 0;
|
||||
EXPECT_CALL(mock, peek_byte(_)).WillOnce(DoAll(SetArgPointee<0>(0xA5), Return(true)));
|
||||
bool result = dev.peek_byte(&value);
|
||||
EXPECT_TRUE(result);
|
||||
EXPECT_EQ(value, 0xA5);
|
||||
}
|
||||
|
||||
TEST(UARTDeviceTest, PeekByteFailure) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
uint8_t value = 0;
|
||||
EXPECT_CALL(mock, peek_byte(_)).WillOnce(Return(false));
|
||||
bool result = dev.peek_byte(&value);
|
||||
EXPECT_FALSE(result);
|
||||
}
|
||||
|
||||
TEST(UARTDeviceTest, Available) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
EXPECT_CALL(mock, available()).WillOnce(Return(5));
|
||||
EXPECT_EQ(dev.available(), 5);
|
||||
}
|
||||
|
||||
TEST(UARTDeviceTest, FlushCallsParent) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
EXPECT_CALL(mock, flush()).Times(1);
|
||||
dev.flush();
|
||||
}
|
||||
|
||||
TEST(UARTDeviceTest, WriteByteForwardsToWriteArray) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
dev.write_byte(0xAB);
|
||||
EXPECT_EQ(mock.written_data.size(), 1);
|
||||
EXPECT_EQ(mock.written_data[0], 0xAB);
|
||||
}
|
||||
TEST(UARTDeviceTest, WriteArrayPointer) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
uint8_t data[3] = {1, 2, 3};
|
||||
dev.write_array(data, 3);
|
||||
EXPECT_EQ(mock.written_data.size(), 3);
|
||||
EXPECT_EQ(mock.written_data, std::vector(data, data + 3));
|
||||
}
|
||||
|
||||
TEST(UARTDeviceTest, WriteArrayVector) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
std::vector<uint8_t> data = {4, 5, 6};
|
||||
dev.write_array(data);
|
||||
EXPECT_EQ(mock.written_data, data);
|
||||
}
|
||||
|
||||
TEST(UARTDeviceTest, WriteArrayStdArray) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
std::array<uint8_t, 4> data = {7, 8, 9, 10};
|
||||
dev.write_array(data);
|
||||
EXPECT_EQ(mock.written_data.size(), data.size());
|
||||
EXPECT_EQ(mock.written_data, std::vector(data.begin(), data.end()));
|
||||
}
|
||||
|
||||
TEST(UARTDeviceTest, WriteStrForwardsToWriteArray) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
const char *str = "ESPHome";
|
||||
dev.write_str(str);
|
||||
EXPECT_EQ(mock.written_data.size(), strlen(str));
|
||||
EXPECT_EQ(0, strncmp(str, (const char *) mock.written_data.data(), mock.written_data.size()));
|
||||
}
|
||||
|
||||
TEST(UARTDeviceTest, WriteStrEmptyString) {
|
||||
MockUARTComponent mock;
|
||||
UARTDevice dev(&mock);
|
||||
const char *str = "";
|
||||
dev.write_str(str);
|
||||
EXPECT_EQ(mock.written_data.size(), 0);
|
||||
}
|
||||
|
||||
} // namespace esphome::uart::testing
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: noise-key-test
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: "zX9/JHxMKwpP0jUGsF0iESCm1wRvNgR6NkKVOhn7kSs="
|
||||
|
||||
logger:
|
||||
@@ -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()
|
||||
|
||||
@@ -5,7 +5,6 @@ import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import Mock, call, patch
|
||||
|
||||
@@ -56,9 +55,9 @@ def mock_should_run_python_linters() -> Generator[Mock, None, None]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_subprocess_run() -> Generator[Mock, None, None]:
|
||||
"""Mock subprocess.run for list-components.py calls."""
|
||||
with patch.object(determine_jobs.subprocess, "run") as mock:
|
||||
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
|
||||
"""Mock determine_cpp_unit_tests from helpers."""
|
||||
with patch.object(determine_jobs, "determine_cpp_unit_tests") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@@ -82,8 +81,8 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_subprocess_run: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -95,6 +94,7 @@ def test_main_all_tests_should_run(
|
||||
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 = (False, ["wifi", "api", "sensor"])
|
||||
|
||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
||||
# Memory impact only runs when component C++ files change
|
||||
@@ -114,15 +114,15 @@ def test_main_all_tests_should_run(
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"filter_component_files",
|
||||
"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: ["wifi", "api"]
|
||||
if not deps
|
||||
else ["wifi", "api", "sensor"],
|
||||
side_effect=lambda files, deps: (
|
||||
["wifi", "api"] if not deps else ["wifi", "api", "sensor"]
|
||||
),
|
||||
),
|
||||
):
|
||||
determine_jobs.main()
|
||||
@@ -150,6 +150,8 @@ def test_main_all_tests_should_run(
|
||||
# memory_impact should be false (no component C++ files changed)
|
||||
assert "memory_impact" in output
|
||||
assert output["memory_impact"]["should_run"] == "false"
|
||||
assert output["cpp_unit_tests_run_all"] is False
|
||||
assert output["cpp_unit_tests_components"] == ["wifi", "api", "sensor"]
|
||||
|
||||
|
||||
def test_main_no_tests_should_run(
|
||||
@@ -157,8 +159,8 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_subprocess_run: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -170,6 +172,7 @@ def test_main_no_tests_should_run(
|
||||
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_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# Mock changed_files to return no component files
|
||||
mock_changed_files.return_value = []
|
||||
@@ -178,7 +181,9 @@ def test_main_no_tests_should_run(
|
||||
with (
|
||||
patch("sys.argv", ["determine-jobs.py"]),
|
||||
patch.object(determine_jobs, "get_changed_components", return_value=[]),
|
||||
patch.object(determine_jobs, "filter_component_files", return_value=False),
|
||||
patch.object(
|
||||
determine_jobs, "filter_component_and_test_files", return_value=False
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs, "get_components_with_dependencies", return_value=[]
|
||||
),
|
||||
@@ -202,31 +207,8 @@ def test_main_no_tests_should_run(
|
||||
# memory_impact should be present
|
||||
assert "memory_impact" in output
|
||||
assert output["memory_impact"]["should_run"] == "false"
|
||||
|
||||
|
||||
def test_main_list_components_fails(
|
||||
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_subprocess_run: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Test when list-components.py fails."""
|
||||
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 list-components.py failure
|
||||
mock_subprocess_run.side_effect = subprocess.CalledProcessError(1, "cmd")
|
||||
|
||||
# Run main function with mocked argv - should raise
|
||||
with (
|
||||
patch("sys.argv", ["determine-jobs.py"]),
|
||||
pytest.raises(subprocess.CalledProcessError),
|
||||
):
|
||||
determine_jobs.main()
|
||||
assert output["cpp_unit_tests_run_all"] is False
|
||||
assert output["cpp_unit_tests_components"] == []
|
||||
|
||||
|
||||
def test_main_with_branch_argument(
|
||||
@@ -234,8 +216,8 @@ def test_main_with_branch_argument(
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_subprocess_run: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -247,6 +229,7 @@ def test_main_with_branch_argument(
|
||||
mock_should_run_clang_tidy.return_value = True
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = True
|
||||
mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"])
|
||||
|
||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
||||
# Memory impact only runs when component C++ files change
|
||||
@@ -258,7 +241,7 @@ def test_main_with_branch_argument(
|
||||
patch.object(determine_jobs, "get_changed_components", return_value=["mqtt"]),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"filter_component_files",
|
||||
"filter_component_and_test_files",
|
||||
side_effect=lambda f: f.startswith("esphome/components/"),
|
||||
),
|
||||
patch.object(
|
||||
@@ -296,6 +279,8 @@ def test_main_with_branch_argument(
|
||||
# memory_impact should be false (no component C++ files changed)
|
||||
assert "memory_impact" in output
|
||||
assert output["memory_impact"]["should_run"] == "false"
|
||||
assert output["cpp_unit_tests_run_all"] is False
|
||||
assert output["cpp_unit_tests_components"] == ["mqtt"]
|
||||
|
||||
|
||||
def test_should_run_integration_tests(
|
||||
@@ -506,7 +491,6 @@ def test_main_filters_components_without_tests(
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_subprocess_run: Mock,
|
||||
mock_changed_files: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
tmp_path: Path,
|
||||
@@ -556,16 +540,17 @@ def test_main_filters_components_without_tests(
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"filter_component_files",
|
||||
"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: ["wifi", "sensor"]
|
||||
if not deps
|
||||
else ["wifi", "sensor", "airthings_ble"],
|
||||
side_effect=lambda files, deps: (
|
||||
["wifi", "sensor"] if not deps else ["wifi", "sensor", "airthings_ble"]
|
||||
),
|
||||
),
|
||||
patch.object(determine_jobs, "changed_files", return_value=[]),
|
||||
):
|
||||
# Clear the cache since we're mocking root_path
|
||||
determine_jobs._component_has_tests.cache_clear()
|
||||
@@ -808,7 +793,6 @@ def test_clang_tidy_mode_full_scan(
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_subprocess_run: Mock,
|
||||
mock_changed_files: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -829,7 +813,9 @@ def test_clang_tidy_mode_full_scan(
|
||||
patch("sys.argv", ["determine-jobs.py"]),
|
||||
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=True),
|
||||
patch.object(determine_jobs, "get_changed_components", return_value=[]),
|
||||
patch.object(determine_jobs, "filter_component_files", return_value=False),
|
||||
patch.object(
|
||||
determine_jobs, "filter_component_and_test_files", return_value=False
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs, "get_components_with_dependencies", return_value=[]
|
||||
),
|
||||
@@ -873,7 +859,6 @@ def test_clang_tidy_mode_targeted_scan(
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_subprocess_run: Mock,
|
||||
mock_changed_files: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -912,7 +897,7 @@ def test_clang_tidy_mode_targeted_scan(
|
||||
patch.object(determine_jobs, "get_changed_components", return_value=components),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"filter_component_files",
|
||||
"filter_component_and_test_files",
|
||||
side_effect=lambda f: f.startswith("esphome/components/"),
|
||||
),
|
||||
patch.object(
|
||||
@@ -925,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
|
||||
|
||||
Reference in New Issue
Block a user