Merge branch 'dev' into codex/pr15983-idf-reproducible

This commit is contained in:
J. Nick Koston
2026-04-26 04:01:08 -05:00
committed by GitHub
27 changed files with 625 additions and 22 deletions
+2 -7
View File
@@ -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;
+111 -1
View File
@@ -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))
+18 -3
View File
@@ -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
+3 -1
View File
@@ -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(
+2
View File
@@ -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();
+6
View File
@@ -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...> {
+2 -1
View File
@@ -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
)
+1 -1
View File
@@ -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 =
+2
View File
@@ -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
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+9 -2
View File
@@ -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
+5
View File
@@ -29,3 +29,8 @@ output:
rtttl:
output: rtttl_output
on_finished_playback:
- then:
- logger.log: "Playback finished 1"
- then:
- logger.log: "Playback finished 2"
+58
View File
@@ -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()
+16
View File
@@ -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 [
+61 -1
View File
@@ -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)