Merge remote-tracking branch 'origin/git_shallow_fetch' into integration

This commit is contained in:
J. Nick Koston
2026-04-26 04:13:13 -05:00
15 changed files with 524 additions and 15 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))
+15 -5
View File
@@ -128,7 +128,10 @@ def clone_or_update(
# We need to fetch the PR branch first, otherwise git will complain
# about missing objects
_LOGGER.info("Fetching %s", ref)
run_git_command(["git", "fetch", "--", "origin", ref], git_dir=repo_dir)
run_git_command(
["git", "fetch", "--depth=1", "--", "origin", ref],
git_dir=repo_dir,
)
run_git_command(
["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir
)
@@ -138,7 +141,8 @@ def clone_or_update(
"Initializing submodules (%s) for %s", ", ".join(submodules), key
)
run_git_command(
["git", "submodule", "update", "--init"] + submodules,
["git", "submodule", "update", "--init", "--depth=1", "--"]
+ submodules,
git_dir=repo_dir,
)
except GitException:
@@ -181,8 +185,13 @@ def clone_or_update(
git_dir=repo_dir,
)
# Fetch remote ref
cmd = ["git", "fetch", "--", "origin"]
# Fetch from the remote. --depth=1 keeps the clone shallow
# while still picking up new commits when the remote tip
# moves: a shallow fetch retrieves the current tip being
# fetched, whether that's an explicit ref or the remote's
# default branch, then reset --hard FETCH_HEAD updates the
# working tree to it.
cmd = ["git", "fetch", "--depth=1", "--", "origin"]
if ref is not None:
cmd.append(ref)
run_git_command(cmd, git_dir=repo_dir)
@@ -231,7 +240,8 @@ def clone_or_update(
"Updating submodules (%s) for %s", ", ".join(submodules), key
)
run_git_command(
["git", "submodule", "update", "--init"] + submodules,
["git", "submodule", "update", "--init", "--depth=1", "--"]
+ submodules,
git_dir=repo_dir,
)
+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
@@ -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
+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 [
+190
View File
@@ -782,3 +782,193 @@ def test_clone_or_update_stale_clone_is_retried_after_cleanup(
assert repo_dir.exists()
assert call_count["clone"] == 2
assert call_count["fetch"] == 2
def test_clone_with_ref_uses_shallow_fetch(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Clone with a ref should use --depth=1 on both clone and fetch."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "pull/123/head"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
if _get_git_command_type(cmd) == "clone":
repo_dir.mkdir(parents=True, exist_ok=True)
(repo_dir / ".git").mkdir(exist_ok=True)
return ""
mock_run_git_command.side_effect = git_command_side_effect
git.clone_or_update(url=url, ref=ref, refresh=None, domain=domain)
call_list = mock_run_git_command.call_args_list
clone_calls = [c for c in call_list if "clone" in c[0][0]]
assert len(clone_calls) == 1
assert "--depth=1" in clone_calls[0][0][0]
fetch_calls = [c for c in call_list if "fetch" in c[0][0]]
assert len(fetch_calls) == 1
assert "--depth=1" in fetch_calls[0][0][0]
# Ref must still be passed so the requested commit/branch is fetched.
assert ref in fetch_calls[0][0][0]
def test_clone_with_submodules_uses_shallow_submodule_update(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Submodule init on a fresh clone should use --depth=1."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
domain = "test"
repo_dir = _compute_repo_dir(url, None, domain)
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
if _get_git_command_type(cmd) == "clone":
repo_dir.mkdir(parents=True, exist_ok=True)
(repo_dir / ".git").mkdir(exist_ok=True)
return ""
mock_run_git_command.side_effect = git_command_side_effect
git.clone_or_update(
url=url,
ref=None,
refresh=None,
domain=domain,
submodules=["components/foo"],
)
submodule_calls = [
c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0]
]
assert len(submodule_calls) == 1
cmd = submodule_calls[0][0][0]
assert "--depth=1" in cmd
assert "components/foo" in cmd
# The `--` terminator must precede the submodule paths so a path
# beginning with `-` cannot be parsed as an option.
assert cmd.index("--") < cmd.index("components/foo")
def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> None:
"""The refresh-path fetch should use --depth=1."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "main"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
_setup_old_repo(repo_dir)
mock_run_git_command.return_value = "abc123"
git.clone_or_update(
url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain
)
fetch_calls = [c for c in mock_run_git_command.call_args_list if "fetch" in c[0][0]]
assert len(fetch_calls) == 1
cmd = fetch_calls[0][0][0]
assert "--depth=1" in cmd
# Ref must still be in the refresh fetch so the right tip is updated.
assert cmd[-1] == ref
def test_refresh_submodule_update_is_shallow(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""The refresh-path submodule update should use --depth=1."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
domain = "test"
repo_dir = _compute_repo_dir(url, None, domain)
_setup_old_repo(repo_dir)
mock_run_git_command.return_value = "abc123"
git.clone_or_update(
url=url,
ref=None,
refresh=TimePeriodSeconds(days=1),
domain=domain,
submodules=["components/foo"],
)
submodule_calls = [
c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0]
]
assert len(submodule_calls) == 1
cmd = submodule_calls[0][0][0]
assert "--depth=1" in cmd
assert "components/foo" in cmd
assert cmd.index("--") < cmd.index("components/foo")
def test_refresh_picks_up_new_remote_commits(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Shallow fetch must still pull new commits when the remote tip moves.
Simulates a stale local repo at SHA "old" while the remote has advanced
to SHA "new". The refresh path must run fetch (with --depth=1) followed
by reset --hard FETCH_HEAD so the working tree advances to the new tip.
"""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "main"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
_setup_old_repo(repo_dir)
# rev-parse is called once before fetch to record the pre-update SHA.
rev_parse_calls = {"count": 0}
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type == "rev-parse":
rev_parse_calls["count"] += 1
return "old_sha"
return ""
mock_run_git_command.side_effect = git_command_side_effect
_, revert = git.clone_or_update(
url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain
)
# Verify the refresh sequence: rev-parse -> stash -> fetch (depth=1) -> reset
call_list = mock_run_git_command.call_args_list
cmd_sequence = [_get_git_command_type(c[0][0]) for c in call_list]
assert cmd_sequence == ["rev-parse", "stash", "fetch", "reset"]
fetch_cmd = call_list[2][0][0]
assert "--depth=1" in fetch_cmd
assert fetch_cmd[-1] == ref
reset_cmd = call_list[3][0][0]
assert reset_cmd[-1] == "FETCH_HEAD"
# revert callback should reset back to the recorded pre-update SHA.
assert revert is not None
revert()
assert mock_run_git_command.call_args_list[-1][0][0] == [
"git",
"reset",
"--hard",
"old_sha",
]