mirror of
https://github.com/esphome/esphome.git
synced 2026-09-01 02:26:01 +00:00
Merge branch 'dev' into codex/pr15983-idf-reproducible
This commit is contained in:
@@ -8,7 +8,6 @@
|
||||
#include <csignal>
|
||||
#include <sched.h>
|
||||
#include <time.h>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace {
|
||||
@@ -22,9 +21,7 @@ void HOT yield() { ::sched_yield(); }
|
||||
uint32_t IRAM_ATTR HOT millis() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
time_t seconds = spec.tv_sec;
|
||||
uint32_t ms = round(spec.tv_nsec / 1e6);
|
||||
return ((uint32_t) seconds) * 1000U + ms;
|
||||
return static_cast<uint32_t>(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000);
|
||||
}
|
||||
uint64_t millis_64() {
|
||||
struct timespec spec;
|
||||
@@ -43,9 +40,7 @@ void HOT delay(uint32_t ms) {
|
||||
uint32_t IRAM_ATTR HOT micros() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
time_t seconds = spec.tv_sec;
|
||||
uint32_t us = round(spec.tv_nsec / 1e3);
|
||||
return ((uint32_t) seconds) * 1000000U + us;
|
||||
return static_cast<uint32_t>(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000);
|
||||
}
|
||||
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) {
|
||||
struct timespec ts;
|
||||
|
||||
@@ -1,13 +1,73 @@
|
||||
#include "ir_rf_proxy.h"
|
||||
|
||||
#include <cinttypes>
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ir_rf_proxy {
|
||||
|
||||
static const char *const TAG = "ir_rf_proxy";
|
||||
|
||||
// ========== Shared transmit helper ==========
|
||||
// Static template: all instantiations occur in this translation unit.
|
||||
|
||||
template<typename CallT>
|
||||
static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency,
|
||||
const CallT &call) {
|
||||
if (transmitter == nullptr) {
|
||||
ESP_LOGW(TAG, "No transmitter configured");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!call.has_raw_timings()) {
|
||||
ESP_LOGE(TAG, "No raw timings provided");
|
||||
return;
|
||||
}
|
||||
|
||||
auto transmit_call = transmitter->transmit();
|
||||
auto *transmit_data = transmit_call.get_data();
|
||||
transmit_data->set_carrier_frequency(carrier_frequency);
|
||||
|
||||
if (call.is_packed()) {
|
||||
transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(),
|
||||
call.get_packed_count());
|
||||
ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(),
|
||||
call.get_repeat_count());
|
||||
} else if (call.is_base64url()) {
|
||||
if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) {
|
||||
ESP_LOGE(TAG, "Invalid base64url data");
|
||||
return;
|
||||
}
|
||||
constexpr int32_t max_timing_us = 500000;
|
||||
for (int32_t timing : transmit_data->get_data()) {
|
||||
int32_t abs_timing = timing < 0 ? -timing : timing;
|
||||
if (abs_timing > max_timing_us) {
|
||||
ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us);
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(),
|
||||
call.get_repeat_count());
|
||||
} else {
|
||||
transmit_data->set_data(call.get_raw_timings());
|
||||
ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(),
|
||||
call.get_repeat_count());
|
||||
}
|
||||
|
||||
if (call.get_repeat_count() > 0) {
|
||||
transmit_call.set_send_times(call.get_repeat_count());
|
||||
}
|
||||
|
||||
transmit_call.perform();
|
||||
}
|
||||
|
||||
// ========== IrRfProxy (Infrared platform) ==========
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
|
||||
void IrRfProxy::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"IR/RF Proxy '%s'\n"
|
||||
"IR Proxy '%s'\n"
|
||||
" Supports Transmitter: %s\n"
|
||||
" Supports Receiver: %s",
|
||||
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
|
||||
@@ -20,4 +80,54 @@ void IrRfProxy::dump_config() {
|
||||
}
|
||||
}
|
||||
|
||||
void IrRfProxy::control(const infrared::InfraredCall &call) {
|
||||
uint32_t carrier = call.get_carrier_frequency().value_or(0);
|
||||
transmit_raw_timings(this->transmitter_, carrier, call);
|
||||
}
|
||||
|
||||
#endif // USE_IR_RF
|
||||
|
||||
// ========== RfProxy (Radio Frequency platform) ==========
|
||||
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
|
||||
void RfProxy::setup() {
|
||||
this->traits_.set_supports_transmitter(this->transmitter_ != nullptr);
|
||||
this->traits_.set_supports_receiver(this->receiver_ != nullptr);
|
||||
|
||||
// remote_transmitter/receiver always uses OOK (on-off keying)
|
||||
this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK);
|
||||
|
||||
if (this->receiver_ != nullptr) {
|
||||
this->receiver_->register_listener(this);
|
||||
}
|
||||
}
|
||||
|
||||
void RfProxy::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"RF Proxy '%s'\n"
|
||||
" Backend: remote_transmitter/receiver\n"
|
||||
" Supports Transmitter: %s\n"
|
||||
" Supports Receiver: %s",
|
||||
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
|
||||
YESNO(this->traits_.get_supports_receiver()));
|
||||
|
||||
const auto &traits = this->traits_;
|
||||
if (traits.get_frequency_min_hz() > 0) {
|
||||
if (traits.get_frequency_min_hz() == traits.get_frequency_max_hz()) {
|
||||
ESP_LOGCONFIG(TAG, " Frequency: %.3f MHz (fixed)", traits.get_frequency_min_hz() / 1e6f);
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Frequency Range: %.3f - %.3f MHz", traits.get_frequency_min_hz() / 1e6f,
|
||||
traits.get_frequency_max_hz() / 1e6f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) {
|
||||
// RF: no IR carrier modulation
|
||||
transmit_raw_timings(this->transmitter_, 0, call);
|
||||
}
|
||||
|
||||
#endif // USE_RADIO_FREQUENCY
|
||||
|
||||
} // namespace esphome::ir_rf_proxy
|
||||
|
||||
@@ -4,10 +4,19 @@
|
||||
// without following the normal breaking changes policy. Use at your own risk.
|
||||
// Once the API is considered stable, this warning will be removed.
|
||||
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
#include "esphome/components/infrared/infrared.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
#include "esphome/components/radio_frequency/radio_frequency.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::ir_rf_proxy {
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
/// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend
|
||||
class IrRfProxy : public infrared::Infrared {
|
||||
public:
|
||||
@@ -26,8 +35,36 @@ class IrRfProxy : public infrared::Infrared {
|
||||
void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); }
|
||||
|
||||
protected:
|
||||
void control(const infrared::InfraredCall &call) override;
|
||||
|
||||
// RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF
|
||||
uint32_t frequency_khz_{0};
|
||||
};
|
||||
#endif // USE_IR_RF
|
||||
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend
|
||||
class RfProxy : public radio_frequency::RadioFrequency {
|
||||
public:
|
||||
RfProxy() = default;
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// Set the remote transmitter component
|
||||
void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; }
|
||||
/// Set the remote receiver component
|
||||
void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; }
|
||||
|
||||
/// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware)
|
||||
void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); }
|
||||
|
||||
protected:
|
||||
void control(const radio_frequency::RadioFrequencyCall &call) override;
|
||||
|
||||
remote_base::RemoteTransmitterBase *transmitter_{nullptr};
|
||||
remote_base::RemoteReceiverBase *receiver_{nullptr};
|
||||
};
|
||||
#endif // USE_RADIO_FREQUENCY
|
||||
|
||||
} // namespace esphome::ir_rf_proxy
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Radio Frequency platform implementation using remote_base (remote_transmitter/receiver)."""
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import radio_frequency, remote_receiver, remote_transmitter
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID, ir_rf_proxy_ns
|
||||
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
DEPENDENCIES = ["radio_frequency"]
|
||||
|
||||
RfProxy = ir_rf_proxy_ns.class_("RfProxy", radio_frequency.RadioFrequency)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
radio_frequency.radio_frequency_schema(RfProxy).extend(
|
||||
{
|
||||
cv.Optional(CONF_FREQUENCY): cv.frequency,
|
||||
cv.Optional(CONF_REMOTE_RECEIVER_ID): cv.use_id(
|
||||
remote_receiver.RemoteReceiverComponent
|
||||
),
|
||||
cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id(
|
||||
remote_transmitter.RemoteTransmitterComponent
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_exactly_one_key(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID),
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
"""Validate that RF transmitters have carrier duty set to 100%."""
|
||||
if CONF_REMOTE_TRANSMITTER_ID not in config:
|
||||
return
|
||||
|
||||
transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID]
|
||||
full_config = fv.full_config.get()
|
||||
transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1]
|
||||
transmitter_config = full_config.get_config_for_path(transmitter_path)
|
||||
|
||||
duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT)
|
||||
if duty_percent is not None and duty_percent != 100:
|
||||
raise cv.Invalid(
|
||||
f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' "
|
||||
"set to 100% for RF transmission. Dedicated RF hardware handles modulation; "
|
||||
"applying a carrier duty cycle would corrupt the signal"
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
"""Code generation for remote_base radio frequency platform."""
|
||||
var = await radio_frequency.new_radio_frequency(config)
|
||||
|
||||
if CONF_FREQUENCY in config:
|
||||
cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY])))
|
||||
|
||||
if CONF_REMOTE_TRANSMITTER_ID in config:
|
||||
transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID])
|
||||
cg.add(var.set_transmitter(transmitter))
|
||||
|
||||
if CONF_REMOTE_RECEIVER_ID in config:
|
||||
receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID])
|
||||
cg.add(var.set_receiver(receiver))
|
||||
@@ -378,9 +378,8 @@ def _substitute_package_definition(
|
||||
Local package contents are left untouched — they will be substituted
|
||||
later during the main substitution pass.
|
||||
"""
|
||||
if isinstance(package_config, str) or (
|
||||
isinstance(package_config, dict) and is_remote_package(package_config)
|
||||
):
|
||||
|
||||
def do_substitute(package_config: dict | str) -> dict | str:
|
||||
# Collect undefined-variable errors (rather than raising strict) so the
|
||||
# path walked through a remote-package dict is preserved and the user
|
||||
# sees which field (url / path / ref / ...) referenced the undefined
|
||||
@@ -394,6 +393,22 @@ def _substitute_package_definition(
|
||||
errors=errors,
|
||||
)
|
||||
raise_first_undefined(errors, "package definition")
|
||||
return package_config
|
||||
|
||||
if isinstance(package_config, str):
|
||||
return do_substitute(package_config)
|
||||
|
||||
if isinstance(package_config, dict) and is_remote_package(package_config):
|
||||
# Mark vars as literal to avoid substituting variables in the vars block itself, since they are meant to be
|
||||
# passed as-is to the package YAML and may contain their own substitution expressions that should not
|
||||
# be prematurely evaluated here.
|
||||
if CONF_FILES in package_config:
|
||||
for file_def in package_config[CONF_FILES]:
|
||||
if isinstance(file_def, dict) and CONF_VARS in file_def:
|
||||
file_def[CONF_VARS] = yaml_util.make_literal(file_def[CONF_VARS])
|
||||
|
||||
package_config = do_substitute(package_config)
|
||||
|
||||
return package_config
|
||||
|
||||
|
||||
|
||||
@@ -93,7 +93,9 @@ async def to_code(config):
|
||||
|
||||
cg.add(var.set_gain(config[CONF_GAIN]))
|
||||
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
if config.get(CONF_ON_FINISHED_PLAYBACK):
|
||||
cg.add_define("USE_RTTTL_FINISHED_PLAYBACK_CALLBACK")
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
|
||||
@@ -424,7 +424,9 @@ void Rtttl::set_state_(State state) {
|
||||
// Clear loop_done when transitioning from `State::STOPPED` to any other state
|
||||
if (state == State::STOPPED) {
|
||||
this->disable_loop();
|
||||
#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
|
||||
this->on_finished_playback_callback_.call();
|
||||
#endif
|
||||
ESP_LOGD(TAG, "Playback finished");
|
||||
} else if (old_state == State::STOPPED) {
|
||||
this->enable_loop();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_OUTPUT
|
||||
#include "esphome/components/output/float_output.h"
|
||||
@@ -45,9 +47,11 @@ class Rtttl : public Component {
|
||||
|
||||
bool is_playing() { return this->state_ != State::STOPPED; }
|
||||
|
||||
#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
|
||||
template<typename F> void add_on_finished_playback_callback(F &&callback) {
|
||||
this->on_finished_playback_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
inline uint16_t get_integer_() {
|
||||
@@ -106,8 +110,10 @@ class Rtttl : public Component {
|
||||
uint32_t samples_gap_{0};
|
||||
#endif // USE_SPEAKER
|
||||
|
||||
#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
|
||||
/// The callback to call when playback is finished.
|
||||
CallbackManager<void()> on_finished_playback_callback_;
|
||||
#endif
|
||||
};
|
||||
|
||||
template<typename... Ts> class PlayAction : public Action<Ts...> {
|
||||
|
||||
@@ -76,8 +76,9 @@ async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if config.get(CONF_ON_SAFE_MODE):
|
||||
if on_safe_mode := config.get(CONF_ON_SAFE_MODE):
|
||||
cg.add_define("USE_SAFE_MODE_CALLBACK")
|
||||
cg.add_define("ESPHOME_SAFE_MODE_CALLBACK_COUNT", len(on_safe_mode))
|
||||
await automation.build_callback_automations(
|
||||
var, config, _CALLBACK_AUTOMATIONS
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ class SafeModeComponent final : public Component {
|
||||
// Larger objects at the end
|
||||
ESPPreferenceObject rtc_;
|
||||
#ifdef USE_SAFE_MODE_CALLBACK
|
||||
CallbackManager<void()> safe_mode_callback_{};
|
||||
StaticCallbackManager<ESPHOME_SAFE_MODE_CALLBACK_COUNT, void()> safe_mode_callback_{};
|
||||
#endif
|
||||
|
||||
static const uint32_t ENTER_SAFE_MODE_MAGIC =
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
#define USE_PREFERENCES_SYNC_EVERY_LOOP
|
||||
#define USE_QR_CODE
|
||||
#define USE_SAFE_MODE_CALLBACK
|
||||
#define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1
|
||||
#define USE_SELECT
|
||||
#define USE_SENSOR
|
||||
#define USE_SENSOR_FILTER
|
||||
@@ -185,6 +186,7 @@
|
||||
#define USE_MQTT
|
||||
#define USE_MQTT_COVER_JSON
|
||||
#define USE_NETWORK
|
||||
#define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
|
||||
#define USE_RUNTIME_IMAGE_BMP
|
||||
#define USE_RUNTIME_IMAGE_PNG
|
||||
#define USE_RUNTIME_IMAGE_JPEG
|
||||
|
||||
@@ -437,7 +437,11 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
|
||||
class EsphomeLogsHandler(EsphomePortCommandWebSocket):
|
||||
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
|
||||
"""Build the command to run."""
|
||||
return await self.build_device_command(["logs"], json_message)
|
||||
cmd = await self.build_device_command(["logs"], json_message)
|
||||
if json_message.get("no_states"):
|
||||
cmd.append("--no-states")
|
||||
_LOGGER.debug("Built command: %s", cmd)
|
||||
return cmd
|
||||
|
||||
|
||||
class EsphomeRenameHandler(EsphomeCommandWebSocket):
|
||||
|
||||
+10
-1
@@ -113,6 +113,15 @@ def make_data_base(
|
||||
return value
|
||||
|
||||
|
||||
def make_literal(value: Any) -> ESPLiteralValue | Any:
|
||||
"""Wrap a value in an ESPLiteralValue object."""
|
||||
try:
|
||||
return add_class_to_obj(value, ESPLiteralValue)
|
||||
except TypeError:
|
||||
# Adding class failed, ignore error
|
||||
return value
|
||||
|
||||
|
||||
def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any:
|
||||
"""Tags a list/string/dict value with context vars that must be applied to it and its children
|
||||
during the substitution pass. If no vars are given, no tagging is done.
|
||||
@@ -525,7 +534,7 @@ class ESPHomeLoaderMixin:
|
||||
obj = self.construct_sequence(node)
|
||||
elif isinstance(node, yaml.MappingNode):
|
||||
obj = self.construct_mapping(node)
|
||||
return add_class_to_obj(obj, ESPLiteralValue)
|
||||
return make_literal(obj)
|
||||
|
||||
@_add_data_ref
|
||||
def construct_extend(self, node: yaml.Node) -> Extend:
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
cryptography==46.0.7
|
||||
cryptography==47.0.0
|
||||
voluptuous==0.16.0
|
||||
PyYAML==6.0.3
|
||||
paho-mqtt==1.6.1
|
||||
@@ -11,8 +11,8 @@ pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.2.0
|
||||
click==8.3.3
|
||||
esphome-dashboard==20260408.1
|
||||
aioesphomeapi==44.21.0
|
||||
esphome-dashboard==20260425.0
|
||||
aioesphomeapi==44.22.0
|
||||
zeroconf==0.148.0
|
||||
puremagic==1.30
|
||||
ruamel.yaml==0.19.1 # dashboard_import
|
||||
|
||||
@@ -402,8 +402,11 @@ def should_run_benchmarks(branch: str | None = None) -> bool:
|
||||
Benchmarks run when any of the following conditions are met:
|
||||
|
||||
1. Core C++ files changed (esphome/core/*)
|
||||
2. A directly changed component has benchmark files (no dependency expansion)
|
||||
3. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py,
|
||||
2. The host platform changed (esphome/components/host/*) — benchmarks
|
||||
are built and run on the host platform, so its implementations of
|
||||
``millis()``/``micros()``/etc. affect every benchmark
|
||||
3. A directly changed component has benchmark files (no dependency expansion)
|
||||
4. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py,
|
||||
script/build_helpers.py, script/setup_codspeed_lib.py)
|
||||
|
||||
Unlike unit tests, benchmarks do NOT expand to dependent components.
|
||||
@@ -420,6 +423,10 @@ def should_run_benchmarks(branch: str | None = None) -> bool:
|
||||
if core_changed(files):
|
||||
return True
|
||||
|
||||
# Host platform supplies the runtime that benchmarks execute on
|
||||
if any(f.startswith("esphome/components/host/") for f in files):
|
||||
return True
|
||||
|
||||
# Check if benchmark infrastructure changed
|
||||
if any(
|
||||
f.startswith("tests/benchmarks/") or f in BENCHMARK_INFRASTRUCTURE_FILES
|
||||
|
||||
@@ -1491,3 +1491,133 @@ def test_substitute_package_definition_includes_source_location(tmp_path: Path)
|
||||
line, col = int(match.group(1)), int(match.group(2))
|
||||
assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})"
|
||||
assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})"
|
||||
|
||||
|
||||
def test_substitute_package_definition_vars_preserved_literally() -> None:
|
||||
"""``vars:`` blocks in remote-package files are not substituted prematurely.
|
||||
|
||||
Variable references inside ``vars:`` may resolve to substitutions
|
||||
contributed by sibling packages that have not yet been loaded, so they
|
||||
must be passed through untouched and resolved later by the package YAML.
|
||||
"""
|
||||
pkg = {
|
||||
CONF_URL: "https://github.com/esphome/non-existant-repo",
|
||||
CONF_REF: "main",
|
||||
CONF_FILES: [
|
||||
{
|
||||
CONF_PATH: "common/somefile.yaml",
|
||||
CONF_VARS: {"pin": "${PIN}"},
|
||||
},
|
||||
],
|
||||
}
|
||||
# Note: PIN is intentionally NOT in the context — it is meant to
|
||||
# be resolved later, when the package YAML is processed.
|
||||
result = _substitute_package_definition(pkg, ContextVars())
|
||||
|
||||
assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"}
|
||||
|
||||
|
||||
def test_substitute_package_definition_other_fields_still_substituted() -> None:
|
||||
"""Marking ``vars:`` literal does not stop substitution of url/ref/path."""
|
||||
ctx = ContextVars({"branch": "release", "org": "esphome"})
|
||||
pkg = {
|
||||
CONF_URL: "https://github.com/${org}/firmware",
|
||||
CONF_REF: "${branch}",
|
||||
CONF_FILES: [
|
||||
{
|
||||
CONF_PATH: "common/sensor.yaml",
|
||||
CONF_VARS: {"pin": "${PIN}"},
|
||||
},
|
||||
],
|
||||
}
|
||||
result = _substitute_package_definition(pkg, ctx)
|
||||
|
||||
assert result[CONF_URL] == "https://github.com/esphome/firmware"
|
||||
assert result[CONF_REF] == "release"
|
||||
# vars passed through unchanged
|
||||
assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"}
|
||||
|
||||
|
||||
def test_substitute_package_definition_without_vars_unaffected() -> None:
|
||||
"""Files entries without a ``vars:`` block continue to work."""
|
||||
ctx = ContextVars({"branch": "main"})
|
||||
pkg = {
|
||||
CONF_URL: "https://github.com/esphome/firmware",
|
||||
CONF_REF: "${branch}",
|
||||
CONF_FILES: [
|
||||
{CONF_PATH: "file1.yaml"},
|
||||
"file2.yaml",
|
||||
],
|
||||
}
|
||||
result = _substitute_package_definition(pkg, ctx)
|
||||
|
||||
assert result[CONF_REF] == "main"
|
||||
assert result[CONF_FILES][0] == {CONF_PATH: "file1.yaml"}
|
||||
assert result[CONF_FILES][1] == "file2.yaml"
|
||||
|
||||
|
||||
@patch("esphome.yaml_util.load_yaml")
|
||||
@patch("pathlib.Path.is_file")
|
||||
@patch("esphome.git.clone_or_update")
|
||||
def test_remote_package_vars_resolved_against_sibling_package_substitutions(
|
||||
mock_clone_or_update, mock_is_file, mock_load_yaml
|
||||
) -> None:
|
||||
"""A ``vars:`` reference in one remote package can resolve to a
|
||||
substitution defined in a sibling remote package.
|
||||
|
||||
A higher-priority package declares ``substitutions:`` (e.g. ``SENSOR_PIN: 5``) and a
|
||||
lower-priority package's ``files: -> vars:`` references that substitution.
|
||||
Because packages are processed highest-priority first and ``vars:`` is now
|
||||
preserved literally during package-definition processing, the substitution
|
||||
is resolved correctly when the package YAML itself is loaded.
|
||||
"""
|
||||
mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock())
|
||||
mock_is_file.return_value = True
|
||||
|
||||
# Two YAML files mocked from the "remote" repo:
|
||||
# - platform.yaml exports a substitution ``SENSOR_PIN``
|
||||
# - sensor.yaml uses ``${pin}`` (which is bound from ``vars:`` to
|
||||
# ``${SENSOR_PIN}`` and resolved against the merged substitutions).
|
||||
mock_load_yaml.side_effect = [
|
||||
# Order matches reverse-priority traversal (highest priority first).
|
||||
OrderedDict(
|
||||
{
|
||||
CONF_SUBSTITUTIONS: {"SENSOR_PIN": "GPIO5"},
|
||||
}
|
||||
),
|
||||
OrderedDict(
|
||||
{
|
||||
CONF_SENSOR: [
|
||||
{
|
||||
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
|
||||
CONF_NAME: TEST_SENSOR_NAME_1,
|
||||
"pin": "${pin}",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
config = {
|
||||
CONF_PACKAGES: {
|
||||
"special_sensor": {
|
||||
CONF_URL: "https://github.com/esphome/non-existant-repo",
|
||||
CONF_FILES: [
|
||||
{
|
||||
CONF_PATH: "sensor.yaml",
|
||||
CONF_VARS: {"pin": "${SENSOR_PIN}"},
|
||||
},
|
||||
],
|
||||
CONF_REFRESH: "1d",
|
||||
},
|
||||
"platform": {
|
||||
CONF_URL: "https://github.com/esphome/non-existant-repo",
|
||||
CONF_FILES: ["platform.yaml"],
|
||||
CONF_REFRESH: "1d",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
actual = packages_pass(config)
|
||||
|
||||
assert actual[CONF_SENSOR][0]["pin"] == "GPIO5"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
remote_receiver:
|
||||
id: rf_receiver
|
||||
pin: ${rx_pin}
|
||||
|
||||
# Test radio_frequency platform with receiver
|
||||
radio_frequency:
|
||||
# RF 900MHz receiver
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_900_rx
|
||||
name: "RF 900 Receiver"
|
||||
frequency: 900 MHz
|
||||
remote_receiver_id: rf_receiver
|
||||
|
||||
# RF receiver (no frequency specified)
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_rx
|
||||
name: "RF Receiver"
|
||||
remote_receiver_id: rf_receiver
|
||||
@@ -0,0 +1,19 @@
|
||||
remote_transmitter:
|
||||
id: rf_transmitter
|
||||
pin: ${tx_pin}
|
||||
carrier_duty_percent: 100%
|
||||
|
||||
# Test radio_frequency platform with transmitter
|
||||
radio_frequency:
|
||||
# RF 433MHz transmitter
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_433_tx
|
||||
name: "RF 433 Transmitter"
|
||||
frequency: 433 MHz
|
||||
remote_transmitter_id: rf_transmitter
|
||||
|
||||
# RF transmitter (no frequency specified)
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_tx
|
||||
name: "RF Transmitter"
|
||||
remote_transmitter_id: rf_transmitter
|
||||
@@ -0,0 +1,7 @@
|
||||
network:
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rx: !include common-rx.yaml
|
||||
tx: !include common-tx.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rx: !include common-rx.yaml
|
||||
tx: !include common-tx.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rx: !include common-rx.yaml
|
||||
tx: !include common-tx.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rx: !include common-rx.yaml
|
||||
tx: !include common-tx.yaml
|
||||
@@ -29,3 +29,8 @@ output:
|
||||
|
||||
rtttl:
|
||||
output: rtttl_output
|
||||
on_finished_playback:
|
||||
- then:
|
||||
- logger.log: "Playback finished 1"
|
||||
- then:
|
||||
- logger.log: "Playback finished 2"
|
||||
|
||||
@@ -1744,6 +1744,64 @@ def test_proc_on_exit_skips_when_already_closed() -> None:
|
||||
handler.close.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_esphome_logs_handler_appends_no_states_when_set() -> None:
|
||||
"""Test --no-states is appended when no_states is truthy in the message."""
|
||||
handler = Mock(spec=web_server.EsphomeLogsHandler)
|
||||
handler.build_device_command = AsyncMock(
|
||||
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
|
||||
)
|
||||
|
||||
json_message = {
|
||||
"configuration": "device.yaml",
|
||||
"port": "OTA",
|
||||
"no_states": True,
|
||||
}
|
||||
cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message)
|
||||
|
||||
assert cmd == [
|
||||
"esphome",
|
||||
"logs",
|
||||
"device.yaml",
|
||||
"--device",
|
||||
"OTA",
|
||||
"--no-states",
|
||||
]
|
||||
handler.build_device_command.assert_awaited_once_with(["logs"], json_message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_esphome_logs_handler_omits_no_states_when_missing() -> None:
|
||||
"""Test --no-states is not added when no_states is absent from the message."""
|
||||
handler = Mock(spec=web_server.EsphomeLogsHandler)
|
||||
handler.build_device_command = AsyncMock(
|
||||
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
|
||||
)
|
||||
|
||||
cmd = await web_server.EsphomeLogsHandler.build_command(
|
||||
handler, {"configuration": "device.yaml", "port": "OTA"}
|
||||
)
|
||||
|
||||
assert "--no-states" not in cmd
|
||||
assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_esphome_logs_handler_omits_no_states_when_false() -> None:
|
||||
"""Test --no-states is not added when no_states is explicitly False."""
|
||||
handler = Mock(spec=web_server.EsphomeLogsHandler)
|
||||
handler.build_device_command = AsyncMock(
|
||||
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
|
||||
)
|
||||
|
||||
cmd = await web_server.EsphomeLogsHandler.build_command(
|
||||
handler,
|
||||
{"configuration": "device.yaml", "port": "OTA", "no_states": False},
|
||||
)
|
||||
|
||||
assert "--no-states" not in cmd
|
||||
|
||||
|
||||
def _make_auth_handler(auth_header: str | None = None) -> Mock:
|
||||
"""Create a mock handler with the given Authorization header."""
|
||||
handler = Mock()
|
||||
|
||||
@@ -1842,6 +1842,22 @@ def test_should_run_benchmarks_core_header_change() -> None:
|
||||
assert determine_jobs.should_run_benchmarks() is True
|
||||
|
||||
|
||||
def test_should_run_benchmarks_host_platform_change() -> None:
|
||||
"""Test benchmarks trigger on host platform changes.
|
||||
|
||||
Benchmarks build and run on the host platform, so changes to its
|
||||
millis()/micros()/etc. implementations affect every benchmark.
|
||||
"""
|
||||
for host_file in [
|
||||
"esphome/components/host/core.cpp",
|
||||
"esphome/components/host/__init__.py",
|
||||
]:
|
||||
with patch.object(determine_jobs, "changed_files", return_value=[host_file]):
|
||||
assert determine_jobs.should_run_benchmarks() is True, (
|
||||
f"Expected benchmarks to run for {host_file}"
|
||||
)
|
||||
|
||||
|
||||
def test_should_run_benchmarks_benchmark_infra_change() -> None:
|
||||
"""Test benchmarks trigger on benchmark infrastructure changes."""
|
||||
for infra_file in [
|
||||
|
||||
@@ -11,7 +11,13 @@ from esphome.config_helpers import Extend, Remove
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import DocumentLocation, DocumentRange, EsphomeError
|
||||
from esphome.util import OrderedDict
|
||||
from esphome.yaml_util import ESPHomeDataBase, format_path, make_data_base
|
||||
from esphome.yaml_util import (
|
||||
ESPHomeDataBase,
|
||||
ESPLiteralValue,
|
||||
format_path,
|
||||
make_data_base,
|
||||
make_literal,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -891,3 +897,57 @@ def test_format_path_empty_path_with_located_current_obj():
|
||||
obj = _located("${var}", "main.yaml", 0, 0)
|
||||
result = format_path([], obj)
|
||||
assert result == "In: in main.yaml 1:1"
|
||||
|
||||
|
||||
def test_make_literal_wraps_dict() -> None:
|
||||
"""A dict is wrapped so it becomes an ESPLiteralValue instance."""
|
||||
value = {"key": "${var}"}
|
||||
result = make_literal(value)
|
||||
assert isinstance(result, ESPLiteralValue)
|
||||
assert isinstance(result, dict)
|
||||
assert result == {"key": "${var}"}
|
||||
|
||||
|
||||
def test_make_literal_wraps_list() -> None:
|
||||
"""A list is wrapped so it becomes an ESPLiteralValue instance."""
|
||||
value = ["${var}", "plain"]
|
||||
result = make_literal(value)
|
||||
assert isinstance(result, ESPLiteralValue)
|
||||
assert isinstance(result, list)
|
||||
assert result == ["${var}", "plain"]
|
||||
|
||||
|
||||
def test_make_literal_wraps_string() -> None:
|
||||
"""A string is wrapped so it becomes an ESPLiteralValue instance."""
|
||||
result = make_literal("${var}")
|
||||
assert isinstance(result, ESPLiteralValue)
|
||||
assert result == "${var}"
|
||||
|
||||
|
||||
def test_make_literal_returns_already_wrapped_value_unchanged() -> None:
|
||||
"""Wrapping a value that is already an ESPLiteralValue returns it as-is."""
|
||||
value = make_literal({"key": "value"})
|
||||
assert isinstance(value, ESPLiteralValue)
|
||||
result = make_literal(value)
|
||||
assert result is value
|
||||
|
||||
|
||||
def test_make_literal_returns_none_unchanged() -> None:
|
||||
"""Values whose class cannot be augmented (e.g. ``None``) are returned as-is."""
|
||||
result = make_literal(None)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_make_literal_blocks_substitution() -> None:
|
||||
"""A value wrapped with make_literal is skipped by the substitution pass."""
|
||||
value = make_literal({"pin": "${PIN}"})
|
||||
result = substitutions.substitute(
|
||||
value,
|
||||
path=[],
|
||||
parent_context=substitutions.ContextVars(),
|
||||
strict_undefined=False,
|
||||
)
|
||||
# The literal block must remain untouched, even though the variable is
|
||||
# undefined in the context.
|
||||
assert result == {"pin": "${PIN}"}
|
||||
assert isinstance(result, ESPLiteralValue)
|
||||
|
||||
Reference in New Issue
Block a user