[remote_transmitter] ISR-driven transmission and non_blocking support on BK7231N/BK7238 (#18660)

This commit is contained in:
Keith Burzinski
2026-08-26 11:30:07 -05:00
committed by GitHub
parent 5c79c92c06
commit 1cbe3a49b2
8 changed files with 497 additions and 207 deletions
@@ -4,7 +4,11 @@ from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import esp32, esp32_rmt, remote_base
from esphome.components.libretiny import get_libretiny_family
from esphome.components.libretiny.const import FAMILY_RTL8720C
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7238,
FAMILY_RTL8720C,
)
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -45,14 +49,19 @@ DigitalWriteAction = remote_transmitter_ns.class_(
)
_NON_BLOCKING_LIBRETINY_FAMILIES = (FAMILY_RTL8720C, FAMILY_BK7231N, FAMILY_BK7238)
def _validate_non_blocking_platform(value: bool) -> bool:
# non_blocking requires hardware transmission: RMT on ESP32, the gtimer
# envelope chain on RTL8720C. Reject everywhere else at config time.
# non_blocking requires hardware transmission: RMT on ESP32, a hardware timer
# envelope chain on the listed LibreTiny families. Reject elsewhere at config time.
if CORE.is_esp32:
return cv.boolean(value)
if CORE.is_libretiny and get_libretiny_family() == FAMILY_RTL8720C:
if CORE.is_libretiny and get_libretiny_family() in _NON_BLOCKING_LIBRETINY_FAMILIES:
return cv.boolean(value)
raise cv.Invalid("non_blocking is only supported on ESP32 and RTL8720C")
raise cv.Invalid(
"non_blocking is only supported on ESP32, RTL8720C, BK7231N and BK7238"
)
MULTI_CONF = True
@@ -202,6 +211,13 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
"remote_transmitter_rtl87xx.cpp": {
PlatformFramework.RTL87XX_ARDUINO,
},
"remote_transmitter_bk72xx.cpp": {
PlatformFramework.BK72XX_ARDUINO,
},
"remote_transmitter_libretiny_isr.cpp": {
PlatformFramework.RTL87XX_ARDUINO,
PlatformFramework.BK72XX_ARDUINO,
},
"remote_transmitter.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
@@ -2,8 +2,8 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_ESP8266) || defined(USE_RP2) || \
(defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \
defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
namespace esphome::remote_transmitter {
@@ -12,6 +12,13 @@
#endif // SOC_RMT_SUPPORTED
#endif // USE_ESP32
// The BK7231N-style PWM block (hardware shadow-load duty updates) enables the ISR-driven
// transmitter on these families; family-level proxy for the SDK's CFG_SOC_NAME gate.
// See remote_transmitter_bk72xx.cpp.
#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7238)
#define REMOTE_TRANSMITTER_BK_PWM
#endif
namespace esphome::remote_transmitter {
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
@@ -57,13 +64,16 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
void set_with_dma(bool with_dma) { this->with_dma_ = with_dma; }
void set_eot_level(bool eot_level) { this->eot_level_ = eot_level; }
#endif
#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C)
#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || \
defined(REMOTE_TRANSMITTER_BK_PWM)
void set_non_blocking(bool non_blocking) { this->non_blocking_ = non_blocking; }
#endif
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
void loop() override;
// called from the envelope timer ISR trampoline; not part of the public API
void advance_envelope_isr();
// same, for trampolines whose SDK callback carries no user argument
static void advance_active_isr();
#endif
Trigger<> *get_transmit_trigger() { return &this->transmit_trigger_; }
@@ -71,12 +81,14 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
protected:
void send_internal(uint32_t send_times, uint32_t send_wait) override;
#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C)) || \
#if defined(USE_ESP8266) || \
(defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \
defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
void await_target_time_();
uint32_t target_time_{0};
#endif
#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_RP2) || \
#if defined(USE_ESP8266) || \
(defined(USE_LIBRETINY) && !defined(USE_RTL87XX) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || defined(USE_RP2) || \
(defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period);
@@ -89,17 +101,22 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
uint32_t current_carrier_frequency_{0};
void *pwm_{nullptr}; // pwmout_t*, opaque here to keep the SDK header out of this shared header
#endif
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
// Envelope chain, shared by every family that paces transmission from a hardware timer
// (remote_transmitter_libretiny_isr.cpp)
void start_isr_item_(size_t index);
void arm_envelope_timer_(uint32_t duration_us);
void abort_stalled_chain_();
void deliver_completion_();
void wait_until_idle_();
void arm_chain_(uint32_t send_times, uint32_t send_wait);
void update_carrier_(uint32_t carrier_frequency);
// Hooks implemented per family: everything the chain needs from the hardware
bool envelope_ready_() const; // PWM claimed successfully in setup()
void prepare_carrier_(uint32_t carrier_frequency); // retune period, stage mark/space levels
void write_envelope_level_(bool mark); // drive carrier (mark) or idle (space)
void arm_one_shot_(uint32_t duration_us); // fire advance_envelope_isr after duration_us
void stop_envelope_timer_();
std::vector<int32_t> isr_data_; // owned copy of the frame; temp_ may be re-encoded mid-flight
float isr_mark_duty_{0.0f};
float isr_space_duty_{0.0f};
volatile size_t isr_index_{0};
volatile uint32_t isr_repeats_left_{0};
uint32_t isr_send_wait_{0};
@@ -110,6 +127,17 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
bool complete_pending_{false};
bool stall_aborted_{false}; // this transmission ended via abort; blocks warning clear
#endif
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
float isr_mark_duty_{0.0f};
float isr_space_duty_{0.0f};
#endif
#ifdef REMOTE_TRANSMITTER_BK_PWM
void write_pwm_t1_(uint32_t t1_counts);
uint32_t isr_mark_t1_{0};
uint32_t isr_space_t1_{0};
uint32_t isr_period_t4_{684}; // 26MHz counts; ~38kHz default until a send sets the real carrier
int8_t pwm_channel_{-1};
#endif
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
void configure_rmt_();
@@ -0,0 +1,187 @@
#include "remote_transmitter.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
// clang-tidy cannot parse the Beken SDK headers pulled in via ArduinoPrivate.h
#if defined(USE_BK72XX) && !defined(CLANG_TIDY)
// ArduinoPrivate.h = Arduino.h + the BDK SDK headers (pwm_pub.h, bk_timer_pub.h, icu_pub.h)
// with the core's fixes for type-name collisions between the two
#include <ArduinoPrivate.h>
// Only the BK7231N-style PWM block (shadow registers with a hardware CFG_UPDATA load bit)
// supports glitch-free per-edge duty updates; older SoCs compile the generic bit-bang
// implementation (remote_transmitter.cpp) instead, and this file compiles to nothing.
// REMOTE_TRANSMITTER_BK_PWM is set per-family in remote_transmitter.h.
namespace esphome::remote_transmitter {
static const char *const TAG = "remote_transmitter";
#ifdef REMOTE_TRANSMITTER_BK_PWM
// PWM peripheral carrier (26MHz block), envelope paced by a BKTIMER1 interrupt chain: each
// interrupt writes the next duty through the shadow registers (T1..T4 + CFG_UPDATA hardware
// load, glitch-free at the next carrier period). Direct register writes beat the driver's
// pwm_update_param() (~19us vs ~26us edge error) and have no shared state to race against.
// BKTIMER1 is the only free channel: TIMER0 = FreeRTOS tick, TIMER2 = SDK cal, TIMER4 = wdt.
static constexpr uint32_t REG_PWM_BASE = 0x00802B00UL;
static constexpr uint32_t REG_PWM_GROUP_STRIDE = 0x40; // one register group per channel pair
static constexpr uint32_t REG_PWM_T_REGS[2] = {0x04, 0x14}; // T1..T4 offsets within a group
static constexpr uint32_t PWM_INT_STATUS_MASK = 3UL << 30; // write-1-clear -- always write as zero
static constexpr uint8_t ENVELOPE_TIMER = BKTIMER1;
// The bk_timer handler receives only the channel number, so the chain resolves the instance
// that owns the timer. No IRAM_ATTR: hal.h makes it a no-op on BK72xx (the SDK masks IRQs
// around flash writes).
static void envelope_timer_isr(UINT8 channel) { RemoteTransmitterComponent::advance_active_isr(); }
// Channel <-> pin comes from the board variant's own PIN_PWMn defines rather than a
// family-wide assumption, so an unusual pinout maps correctly instead of silently
// driving another pad
struct PwmPinChannel {
uint8_t pin;
int8_t channel;
};
static constexpr PwmPinChannel PWM_PIN_CHANNELS[] = {
#ifdef PIN_PWM0
{PIN_PWM0, 0},
#endif
#ifdef PIN_PWM1
{PIN_PWM1, 1},
#endif
#ifdef PIN_PWM2
{PIN_PWM2, 2},
#endif
#ifdef PIN_PWM3
{PIN_PWM3, 3},
#endif
#ifdef PIN_PWM4
{PIN_PWM4, 4},
#endif
#ifdef PIN_PWM5
{PIN_PWM5, 5},
#endif
};
static int8_t pwm_channel_for_pin(uint8_t pin) {
for (const auto &entry : PWM_PIN_CHANNELS) {
if (entry.pin == pin)
return entry.channel;
}
return -1;
}
void RemoteTransmitterComponent::setup() {
// Deliberately no pin_->setup(): the pin must belong to the PWM function, not GPIO
const int8_t channel = pwm_channel_for_pin(this->pin_->get_pin());
if (channel < 0) {
ESP_LOGE(TAG, "Pin %u is not PWM-capable", this->pin_->get_pin());
this->mark_failed();
return;
}
this->pwm_channel_ = channel;
const uint32_t idle_t1 = this->pin_->is_inverted() ? this->isr_period_t4_ : 0;
pwm_param_st param{};
param.chan = channel;
param.t1 = idle_t1;
param.t4 = this->isr_period_t4_;
param.init_level = idle_t1 ? 1 : 0;
if (pwm_init_param(&param) != 0 || pwm_start(channel) != 0) {
ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin());
this->pwm_channel_ = -1;
this->mark_failed();
return;
}
this->disable_loop(); // loop() is only needed while a non-blocking completion is pending
}
void RemoteTransmitterComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"Remote Transmitter:\n"
" Carrier Duty: %u%%\n"
" Non-blocking: %s",
this->carrier_duty_percent_, YESNO(this->non_blocking_));
LOG_PIN(" Pin: ", this->pin_);
}
// Writes the duty compare registers and sets the hardware CFG_UPDATA shadow-load bit;
// the new duty latches glitch-free at the next carrier period. ISR-safe: registers only.
// The group control word is shared with the paired channel, but every SDK write to it runs
// under GLOBAL_INT_DISABLE (bk_pwm), so it cannot be torn by this interrupt.
void RemoteTransmitterComponent::write_pwm_t1_(uint32_t t1_counts) {
const uint32_t group = this->pwm_channel_ / 2;
const uint32_t post = this->pwm_channel_ % 2;
const uint32_t group_base = REG_PWM_BASE + REG_PWM_GROUP_STRIDE * group;
auto *t_regs = (volatile uint32_t *) (group_base + REG_PWM_T_REGS[post]);
auto *ctrl = (volatile uint32_t *) group_base;
const uint32_t init_level_bit = 1UL << (8 * post + 6); // output level while the counter is stopped
const uint32_t cfg_updata_bit = 1UL << (8 * post + 7); // 0->1 latches T1..T4 at the next period
t_regs[0] = t1_counts; // T1: high time
t_regs[1] = 0; // T2
t_regs[2] = 0; // T3
t_regs[3] = this->isr_period_t4_; // T4: period
uint32_t cfg = *ctrl;
cfg &= ~(PWM_INT_STATUS_MASK | init_level_bit | cfg_updata_bit);
if (t1_counts != 0)
cfg |= init_level_bit;
*ctrl = cfg;
*ctrl = cfg | cfg_updata_bit;
}
// --- envelope chain hooks (see remote_transmitter_libretiny_isr.cpp) ---
bool RemoteTransmitterComponent::envelope_ready_() const { return this->pwm_channel_ >= 0; }
// Recomputes the carrier period in 26MHz counts and stages the per-item duties;
// unmodulated protocols drive the pin constantly during marks
void RemoteTransmitterComponent::prepare_carrier_(uint32_t carrier_frequency) {
if (carrier_frequency > 0) {
this->isr_period_t4_ = std::max(uint32_t(2), (26000000UL + carrier_frequency / 2) / carrier_frequency);
}
uint32_t mark_t1 = (carrier_frequency > 0 && this->carrier_duty_percent_ < 100)
? std::max(uint32_t(1), this->isr_period_t4_ * this->carrier_duty_percent_ / 100)
: this->isr_period_t4_;
uint32_t space_t1 = 0;
if (this->pin_->is_inverted()) {
mark_t1 = this->isr_period_t4_ - mark_t1;
space_t1 = this->isr_period_t4_;
}
this->isr_mark_t1_ = mark_t1;
this->isr_space_t1_ = space_t1;
}
void RemoteTransmitterComponent::write_envelope_level_(bool mark) {
this->write_pwm_t1_(mark ? this->isr_mark_t1_ : this->isr_space_t1_);
}
// The driver's microsecond init path is register writes under a nested interrupt guard,
// so it is safe to call from the chain's own interrupt
void RemoteTransmitterComponent::arm_one_shot_(uint32_t duration_us) {
timer_param_t param{};
param.channel = ENVELOPE_TIMER;
param.div = 1;
param.period = duration_us;
param.t_Int_Handler = envelope_timer_isr;
sddev_control((char *) TIMER_DEV_NAME, CMD_TIMER_INIT_PARAM_US, &param);
}
void RemoteTransmitterComponent::stop_envelope_timer_() {
UINT32 channel = ENVELOPE_TIMER;
sddev_control((char *) TIMER_DEV_NAME, CMD_TIMER_UNIT_DISABLE, &channel);
}
void RemoteTransmitterComponent::digital_write(bool value) {
if (this->pwm_channel_ < 0)
return;
// serialize behind an in-flight chain, matching the ESP32/RMT non-blocking behavior
this->wait_until_idle_();
this->write_pwm_t1_((value != this->pin_->is_inverted()) ? this->isr_period_t4_ : 0);
}
#endif // REMOTE_TRANSMITTER_BK_PWM
} // namespace esphome::remote_transmitter
#endif // USE_BK72XX && !CLANG_TIDY
@@ -0,0 +1,224 @@
#include "remote_transmitter.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
// Envelope chain shared by the LibreTiny families that pace transmission from a hardware
// timer interrupt: RTL8720C (gtimer) and the BK7231N-style PWM block (BKTIMER1). Everything
// platform-specific sits behind five hooks implemented in the per-family files -- carrier
// setup, duty writes, one-shot arming and timer stop. Families without a usable timer keep
// the generic bit-bang implementation and compile none of this.
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
namespace esphome::remote_transmitter {
static const char *const TAG = "remote_transmitter";
// Margin past a transmission's expected duration before the chain is declared stalled
static constexpr uint32_t STALL_MARGIN_MS = 1000;
// Longest single one-shot armed; longer durations are chained. Both families need the cap:
// the Beken driver computes period_us * 26 in 32 bits (overflows past ~165s) and the Realtek
// us->tick conversion lives in mask ROM with unverified headroom.
static constexpr uint32_t MAX_ONE_SHOT_US = 50000;
// One hardware timer is shared by all instances (MULTI_CONF), so they serialize on this
// token; the deadline always describes whichever chain currently owns it.
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
static RemoteTransmitterComponent *volatile s_active_transmitter = nullptr;
static uint32_t s_expected_end_ms = 0;
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
// Entry point for trampolines whose SDK callback carries no user argument
void IRAM_ATTR RemoteTransmitterComponent::advance_active_isr() {
auto *transmitter = s_active_transmitter;
if (transmitter != nullptr)
transmitter->advance_envelope_isr();
}
// Arms the envelope timer, chaining durations longer than MAX_ONE_SHOT_US. ISR-safe.
void IRAM_ATTR RemoteTransmitterComponent::arm_envelope_timer_(uint32_t duration_us) {
// clamp to 1us (a zero-length one-shot never fires); the remainder must not underflow
const uint32_t chunk = std::max(uint32_t(1), std::min(duration_us, MAX_ONE_SHOT_US));
this->isr_wait_remaining_ = duration_us > chunk ? duration_us - chunk : 0;
this->arm_one_shot_(chunk);
}
// Writes the level for one envelope item and arms the timer for its duration.
// Runs in ISR context (and once from arm_chain_ to kick the chain): no logging, no allocation.
void IRAM_ATTR RemoteTransmitterComponent::start_isr_item_(size_t index) {
const int32_t item = this->isr_data_[index];
this->write_envelope_level_(item > 0);
this->arm_envelope_timer_(uint32_t(item > 0 ? item : -item));
}
void IRAM_ATTR RemoteTransmitterComponent::advance_envelope_isr() {
if (!this->transmitting_)
return; // chain was aborted; this is a stale one-shot that was already latched
if (this->isr_wait_remaining_ > 0) {
// continue a duration longer than one hardware one-shot
this->arm_envelope_timer_(this->isr_wait_remaining_);
return;
}
if (this->isr_in_gap_) {
// inter-repeat gap elapsed; restart the item chain
this->isr_in_gap_ = false;
this->isr_index_ = 0;
this->start_isr_item_(0);
return;
}
this->isr_index_ = this->isr_index_ + 1;
if (this->isr_index_ < this->isr_data_.size()) {
this->start_isr_item_(this->isr_index_);
return;
}
// end of one repetition
this->write_envelope_level_(false);
if (this->isr_repeats_left_ > 1) {
this->isr_repeats_left_ = this->isr_repeats_left_ - 1;
this->isr_index_ = 0;
if (this->isr_send_wait_ > 0) {
this->isr_in_gap_ = true;
this->arm_envelope_timer_(this->isr_send_wait_);
} else {
this->start_isr_item_(0);
}
return;
}
// required on Beken (its timer reloads); on Realtek this only clears the enable bit of a
// one-shot that has already fired
this->stop_envelope_timer_();
this->transmitting_ = false;
s_active_transmitter = nullptr;
}
// Aborts a chain that stopped advancing: stop the timer, idle the pin, release the token.
// Every step is a no-op if the chain completed meanwhile. Task context only.
void RemoteTransmitterComponent::abort_stalled_chain_() {
// cleared first so a straggler one-shot bails at the ISR entry check
this->transmitting_ = false;
this->stop_envelope_timer_();
this->write_envelope_level_(false);
s_active_transmitter = nullptr;
this->stall_aborted_ = true;
this->status_set_warning("envelope timer stalled");
ESP_LOGE(TAG, "Envelope timer stalled; transmission aborted");
delay(1); // let any already-latched interrupt land while the chain state is safe
}
// Delivers one deferred completion with its status bookkeeping
void RemoteTransmitterComponent::deliver_completion_() {
if (!this->stall_aborted_)
this->status_clear_warning();
this->complete_pending_ = false;
this->complete_trigger_.trigger();
}
// Waits until no chain is in flight, delivering any deferred completions; a completion
// automation may start a new send, so repeat until truly idle. Bounded by the stall deadline.
void RemoteTransmitterComponent::wait_until_idle_() {
while (true) {
while (true) {
// snapshot: the final ISR can clear the volatile pointer between a check and a use
auto *active = s_active_transmitter;
if (active == nullptr)
break;
if ((int32_t) (millis() - s_expected_end_ms) > 0) {
active->abort_stalled_chain_();
break;
}
App.feed_wdt();
delay(1);
}
if (!this->complete_pending_)
break;
this->deliver_completion_();
}
}
// Stages the repeat schedule and stall deadline, then starts the interrupt chain
void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_wait) {
this->isr_repeats_left_ = send_times;
this->isr_send_wait_ = send_wait;
this->isr_index_ = 0;
this->isr_in_gap_ = false;
this->stall_aborted_ = false;
uint64_t frame_us = 0;
for (int32_t item : this->isr_data_)
frame_us += uint32_t(item > 0 ? item : -item);
const uint64_t total_us = frame_us * send_times + uint64_t(send_wait) * (send_times - 1);
s_expected_end_ms = millis() + uint32_t(total_us / 1000) + STALL_MARGIN_MS;
this->transmitting_ = true;
s_active_transmitter = this;
this->start_isr_item_(0);
}
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
if (!this->envelope_ready_()) {
// both triggers still fire, so an on_complete-sequenced automation does not stall
ESP_LOGW(TAG, "Cannot send: PWM not initialized");
this->transmit_trigger_.trigger();
this->deliver_completion_();
return;
}
this->wait_until_idle_();
if (send_times == 0) {
// parity with the loop-based implementations: transmit nothing, but both triggers
// still fire so an on_complete-sequenced automation does not stall
this->transmit_trigger_.trigger();
this->deliver_completion_();
return;
}
ESP_LOGD(TAG, "Sending remote code");
this->prepare_carrier_(this->temp_.get_carrier_frequency());
// own copy: with non_blocking the caller may re-encode temp_ while this frame is in flight
this->isr_data_.assign(this->temp_.get_data().begin(), this->temp_.get_data().end());
if (this->isr_data_.empty()) {
ESP_LOGW(TAG, "Empty data");
this->transmit_trigger_.trigger();
this->deliver_completion_();
return;
}
// trigger first: the deadline computed in arm_chain_ must not be charged for user code
this->transmit_trigger_.trigger();
// the automation may have started a send on another instance; let it finish before
// claiming the shared timer (a same-instance send remains unsupported here)
this->wait_until_idle_();
this->arm_chain_(send_times, send_wait);
if (this->non_blocking_) {
this->complete_pending_ = true;
this->enable_loop();
return;
}
// blocking mode: wait out the chain, bounded by the stall deadline
while (this->transmitting_) {
if ((int32_t) (millis() - s_expected_end_ms) > 0) {
this->abort_stalled_chain_();
break;
}
App.feed_wdt();
delay(1);
}
this->deliver_completion_();
}
void RemoteTransmitterComponent::loop() {
if (!this->complete_pending_) {
this->disable_loop();
return;
}
if (this->transmitting_) {
// non-blocking stall recovery: without this, a dead chain would leave the carrier
// driven and on_complete unfired until the next send happened to abort it
if ((int32_t) (millis() - s_expected_end_ms) <= 0)
return;
this->abort_stalled_chain_();
}
// release the loop before user code runs: the automation may start a new non-blocking
// send, and its enable_loop() must be the last writer or its completion would strand
this->disable_loop();
this->deliver_completion_();
}
} // namespace esphome::remote_transmitter
#endif // USE_LIBRETINY_VARIANT_RTL8720C || REMOTE_TRANSMITTER_BK_PWM
@@ -24,20 +24,13 @@ static const char *const TAG = "remote_transmitter";
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
static constexpr uint32_t ENVELOPE_TIMER_ID = TIMER6; // GTimer7
// Margin past a transmission's expected duration before the chain is declared stalled
static constexpr uint32_t STALL_MARGIN_MS = 1000;
// Longest single one-shot armed; longer durations are chained (ROM us->tick headroom unverified)
static constexpr uint32_t MAX_ONE_SHOT_US = 50000;
// Shared envelope timer: a second gtimer_init on the same id fails silently, so all
// instances serialize on s_active_transmitter
// One envelope timer for all instances: a second gtimer_init on the same id fails silently,
// so the chain serializes them (remote_transmitter_libretiny_isr.cpp)
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
static uint8_t s_pwm_tick_sources[] = {GTimer1, GTimer2, GTimer3, GTimer4, GTimer5, GTimer6, 0xff};
static gtimer_t s_envelope_timer;
static bool s_envelope_timer_ready = false;
static RemoteTransmitterComponent *volatile s_active_transmitter = nullptr;
// Deadline for the in-flight transmission (millis-based); only touched from the main task
static uint32_t s_expected_end_ms = 0;
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
static void IRAM_ATTR envelope_timer_isr(uint32_t arg) {
@@ -104,105 +97,22 @@ void RemoteTransmitterComponent::digital_write(bool value) {
}
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
// Arms the shared envelope timer, chaining durations longer than MAX_ONE_SHOT_US. ISR-safe.
void IRAM_ATTR RemoteTransmitterComponent::arm_envelope_timer_(uint32_t duration_us) {
// clamp to 1us (a zero-length one-shot never fires); the remainder must not underflow
const uint32_t chunk = std::max(uint32_t(1), std::min(duration_us, MAX_ONE_SHOT_US));
this->isr_wait_remaining_ = duration_us > chunk ? duration_us - chunk : 0;
gtimer_start_one_shout(&s_envelope_timer, chunk, (void *) envelope_timer_isr, (uint32_t) this);
}
// --- envelope chain hooks (see remote_transmitter_libretiny_isr.cpp) ---
// Aborts a chain that stopped advancing: stop the timer, idle the pin, release the token.
// Every step is a no-op if the chain completed meanwhile. Task context only.
void RemoteTransmitterComponent::abort_stalled_chain_() {
// cleared first so a straggler one-shot bails at the ISR entry check
this->transmitting_ = false;
gtimer_stop(&s_envelope_timer);
pwmout_write(static_cast<pwmout_t *>(this->pwm_), this->isr_space_duty_);
s_active_transmitter = nullptr;
this->stall_aborted_ = true;
this->status_set_warning("envelope timer stalled");
ESP_LOGE(TAG, "Envelope timer stalled; transmission aborted");
delay(1); // let any already-latched interrupt land while the chain state is safe
}
bool RemoteTransmitterComponent::envelope_ready_() const { return this->pwm_ != nullptr; }
// Delivers one deferred completion with its status bookkeeping
void RemoteTransmitterComponent::deliver_completion_() {
if (!this->stall_aborted_)
this->status_clear_warning();
this->complete_pending_ = false;
this->complete_trigger_.trigger();
}
// Writes the duty for one envelope item and arms the timer for its duration.
// Runs in ISR context (and once from send_internal to kick the chain): no logging, no allocation.
void IRAM_ATTR RemoteTransmitterComponent::start_isr_item_(size_t index) {
const int32_t item = this->isr_data_[index];
pwmout_write(static_cast<pwmout_t *>(this->pwm_), item > 0 ? this->isr_mark_duty_ : this->isr_space_duty_);
this->arm_envelope_timer_(uint32_t(item > 0 ? item : -item));
}
void IRAM_ATTR RemoteTransmitterComponent::advance_envelope_isr() {
if (!this->transmitting_)
return; // chain was aborted; this is a stale one-shot that was already latched
if (this->isr_wait_remaining_ > 0) {
// continue a duration longer than one hardware one-shot
this->arm_envelope_timer_(this->isr_wait_remaining_);
return;
// Retunes the PWM period when the carrier changes and stages the per-item duties;
// unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks
void RemoteTransmitterComponent::prepare_carrier_(uint32_t carrier_frequency) {
float mark_duty =
(carrier_frequency > 0 && this->carrier_duty_percent_ < 100) ? this->carrier_duty_percent_ / 100.0f : 1.0f;
float space_duty = 0.0f;
if (this->pin_->is_inverted()) {
mark_duty = 1.0f - mark_duty;
space_duty = 1.0f;
}
if (this->isr_in_gap_) {
// inter-repeat gap elapsed; restart the item chain
this->isr_in_gap_ = false;
this->isr_index_ = 0;
this->start_isr_item_(0);
return;
}
this->isr_index_++;
if (this->isr_index_ < this->isr_data_.size()) {
this->start_isr_item_(this->isr_index_);
return;
}
// end of one repetition
pwmout_write(static_cast<pwmout_t *>(this->pwm_), this->isr_space_duty_);
if (this->isr_repeats_left_ > 1) {
this->isr_repeats_left_--;
this->isr_index_ = 0;
if (this->isr_send_wait_ > 0) {
this->isr_in_gap_ = true;
this->arm_envelope_timer_(this->isr_send_wait_);
} else {
this->start_isr_item_(0);
}
return;
}
this->transmitting_ = false;
s_active_transmitter = nullptr;
}
// Waits until no chain is in flight, delivering any deferred completions; a completion
// automation may start a new send, so repeat until truly idle. Bounded by the stall deadline.
void RemoteTransmitterComponent::wait_until_idle_() {
while (true) {
while (true) {
// snapshot: the final ISR can clear the volatile pointer between a check and a use
auto *active = s_active_transmitter;
if (active == nullptr)
break;
if ((int32_t) (millis() - s_expected_end_ms) > 0) {
active->abort_stalled_chain_();
break;
}
App.feed_wdt();
delay(1);
}
if (!this->complete_pending_)
break;
this->deliver_completion_();
}
}
// Retunes the PWM period when the carrier changes; the ISR sets duty per item
void RemoteTransmitterComponent::update_carrier_(uint32_t carrier_frequency) {
this->isr_mark_duty_ = mark_duty;
this->isr_space_duty_ = space_duty;
if (carrier_frequency == 0 || carrier_frequency == this->current_carrier_frequency_)
return;
// round(1000000/freq), clamped so a bad lambda can't hand the SDK a zero period
@@ -211,97 +121,15 @@ void RemoteTransmitterComponent::update_carrier_(uint32_t carrier_frequency) {
this->current_carrier_frequency_ = carrier_frequency;
}
// Stages the repeat schedule and stall deadline, then starts the interrupt chain
void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_wait) {
this->isr_repeats_left_ = send_times;
this->isr_send_wait_ = send_wait;
this->isr_index_ = 0;
this->isr_in_gap_ = false;
this->stall_aborted_ = false;
uint64_t frame_us = 0;
for (int32_t item : this->isr_data_)
frame_us += uint32_t(item > 0 ? item : -item);
const uint64_t total_us = frame_us * send_times + uint64_t(send_wait) * (send_times - 1);
s_expected_end_ms = millis() + uint32_t(total_us / 1000) + STALL_MARGIN_MS;
this->transmitting_ = true;
s_active_transmitter = this;
this->start_isr_item_(0);
void IRAM_ATTR RemoteTransmitterComponent::write_envelope_level_(bool mark) {
pwmout_write(static_cast<pwmout_t *>(this->pwm_), mark ? this->isr_mark_duty_ : this->isr_space_duty_);
}
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
if (this->pwm_ == nullptr) {
ESP_LOGW(TAG, "Cannot send: PWM not initialized");
return;
}
this->wait_until_idle_();
if (send_times == 0) {
// parity with the loop-based implementations: transmit nothing, but both triggers
// still fire so an on_complete-sequenced automation does not stall
this->transmit_trigger_.trigger();
this->deliver_completion_();
return;
}
ESP_LOGD(TAG, "Sending remote code");
const uint32_t carrier_frequency = this->temp_.get_carrier_frequency();
// unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks
float mark_duty =
(carrier_frequency > 0 && this->carrier_duty_percent_ < 100) ? this->carrier_duty_percent_ / 100.0f : 1.0f;
float space_duty = 0.0f;
if (this->pin_->is_inverted()) {
mark_duty = 1.0f - mark_duty;
space_duty = 1.0f;
}
this->update_carrier_(carrier_frequency);
// own copy: with non_blocking the caller may re-encode temp_ while this frame is in flight
this->isr_data_.assign(this->temp_.get_data().begin(), this->temp_.get_data().end());
if (this->isr_data_.empty()) {
ESP_LOGW(TAG, "Empty data");
this->transmit_trigger_.trigger();
this->deliver_completion_();
return;
}
this->isr_mark_duty_ = mark_duty;
this->isr_space_duty_ = space_duty;
// trigger first: the deadline computed in arm_chain_ must not be charged for user code
this->transmit_trigger_.trigger();
// the automation may have started a send on another instance; let it finish before
// claiming the shared timer (a same-instance send remains unsupported here)
this->wait_until_idle_();
this->arm_chain_(send_times, send_wait);
if (this->non_blocking_) {
this->complete_pending_ = true;
this->enable_loop();
return;
}
// blocking mode: wait out the chain, bounded by the stall deadline
while (this->transmitting_) {
if ((int32_t) (millis() - s_expected_end_ms) > 0) {
this->abort_stalled_chain_();
break;
}
App.feed_wdt();
delay(1);
}
this->deliver_completion_();
void IRAM_ATTR RemoteTransmitterComponent::arm_one_shot_(uint32_t duration_us) {
gtimer_start_one_shout(&s_envelope_timer, duration_us, (void *) envelope_timer_isr, (uint32_t) this);
}
void RemoteTransmitterComponent::loop() {
if (!this->complete_pending_) {
this->disable_loop();
return;
}
if (this->transmitting_) {
// non-blocking stall recovery: without this, a dead chain would leave the carrier
// driven and on_complete unfired until the next send happened to abort it
if ((int32_t) (millis() - s_expected_end_ms) <= 0)
return;
this->abort_stalled_chain_();
}
// release the loop before user code runs: the automation may start a new non-blocking
// send, and its enable_loop() must be the last writer or its completion would strand
this->disable_loop();
this->deliver_completion_();
}
void IRAM_ATTR RemoteTransmitterComponent::stop_envelope_timer_() { gtimer_stop(&s_envelope_timer); }
#else // !USE_LIBRETINY_VARIANT_RTL8720C -- AmebaZ (RTL8710B): spin-based envelope, per-frame priority boost
@@ -4,6 +4,9 @@ the ISR paths, so this gate is the only CI-reachable coverage for the platform m
import pytest
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_RTL8710B,
FAMILY_RTL8720C,
KEY_FAMILY,
@@ -23,6 +26,9 @@ from ..types import SetCoreConfigCallable
(PlatformFramework.ESP32_IDF, None, True),
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C, True),
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8710B, False),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231N, True),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7238, True),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231T, False),
(PlatformFramework.ESP8266_ARDUINO, None, False),
],
)
@@ -2,6 +2,7 @@ remote_transmitter:
id: xmitr
pin: GPIO26
carrier_duty_percent: 50%
# non_blocking is bk7231n/bk7238-only; the CI board is a BK7252
packages:
buttons: !include common-buttons.yaml