From 1cbe3a49b2bf9375f0c778f9d44084c12ec3eaea Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 26 Aug 2026 11:30:07 -0500 Subject: [PATCH] [remote_transmitter] ISR-driven transmission and non_blocking support on BK7231N/BK7238 (#18660) --- .../components/remote_transmitter/__init__.py | 26 +- .../remote_transmitter/remote_transmitter.cpp | 4 +- .../remote_transmitter/remote_transmitter.h | 44 +++- .../remote_transmitter_bk72xx.cpp | 187 +++++++++++++++ .../remote_transmitter_libretiny_isr.cpp | 224 ++++++++++++++++++ .../remote_transmitter_rtl87xx.cpp | 212 ++--------------- .../test_non_blocking_gate.py | 6 + .../remote_transmitter/test.bk72xx-ard.yaml | 1 + 8 files changed, 497 insertions(+), 207 deletions(-) create mode 100644 esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp create mode 100644 esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 8ae51829e7..cb2aebec91 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -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, diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 67341e936f..5e82213a48 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -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 { diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index ef9a80f668..313b26364d 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -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 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_(); diff --git a/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp new file mode 100644 index 0000000000..0081ae47b3 --- /dev/null +++ b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp @@ -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 + +// 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(¶m) != 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, ¶m); +} + +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 diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp new file mode 100644 index 0000000000..003cdfa986 --- /dev/null +++ b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp @@ -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 diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp index 9f629168f2..6db9faac36 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -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(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(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(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(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 diff --git a/tests/component_tests/remote_transmitter/test_non_blocking_gate.py b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py index f843f1e84f..ee2769e177 100644 --- a/tests/component_tests/remote_transmitter/test_non_blocking_gate.py +++ b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py @@ -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), ], ) diff --git a/tests/components/remote_transmitter/test.bk72xx-ard.yaml b/tests/components/remote_transmitter/test.bk72xx-ard.yaml index 2a5cceddec..ea2feafda9 100644 --- a/tests/components/remote_transmitter/test.bk72xx-ard.yaml +++ b/tests/components/remote_transmitter/test.bk72xx-ard.yaml @@ -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