diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 9d8761ea90..8ae51829e7 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -3,6 +3,8 @@ import logging 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.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -43,6 +45,16 @@ DigitalWriteAction = remote_transmitter_ns.class_( ) +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. + if CORE.is_esp32: + return cv.boolean(value) + if CORE.is_libretiny and get_libretiny_family() == FAMILY_RTL8720C: + return cv.boolean(value) + raise cv.Invalid("non_blocking is only supported on ESP32 and RTL8720C") + + MULTI_CONF = True CONFIG_SCHEMA = ( cv.Schema( @@ -76,7 +88,7 @@ CONFIG_SCHEMA = ( esp32_s2=64, esp32_s3=48, ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), - cv.Optional(CONF_NON_BLOCKING): cv.All(cv.only_on_esp32, cv.boolean), + cv.Optional(CONF_NON_BLOCKING): _validate_non_blocking_platform, cv.Optional(CONF_ON_TRANSMIT): automation.validate_automation(single=True), cv.Optional(CONF_ON_COMPLETE): automation.validate_automation(single=True), } @@ -164,6 +176,8 @@ async def to_code(config: ConfigType) -> None: ) else: var = cg.new_Pvariable(config[CONF_ID], pin) + if (non_blocking := config.get(CONF_NON_BLOCKING)) is not None: + cg.add(var.set_non_blocking(non_blocking)) await cg.register_component(var, config) cg.add(var.set_carrier_duty_percent(config[CONF_CARRIER_DUTY_PERCENT])) diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 94bcb74b09..ef9a80f668 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -56,15 +56,23 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa #if defined(USE_ESP32) && SOC_RMT_SUPPORTED 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) void set_non_blocking(bool non_blocking) { this->non_blocking_ = non_blocking; } #endif +#ifdef USE_LIBRETINY_VARIANT_RTL8720C + void loop() override; + // called from the envelope timer ISR trampoline; not part of the public API + void advance_envelope_isr(); +#endif Trigger<> *get_transmit_trigger() { return &this->transmit_trigger_; } Trigger<> *get_complete_trigger() { return &this->complete_trigger_; } protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C)) || \ + defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) void await_target_time_(); uint32_t target_time_{0}; #endif @@ -81,6 +89,27 @@ 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 + 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); + 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}; + volatile uint32_t isr_wait_remaining_{0}; // remainder of a duration chained across one-shots + volatile bool isr_in_gap_{false}; + volatile bool transmitting_{false}; + bool non_blocking_{false}; + bool complete_pending_{false}; + bool stall_aborted_{false}; // this transmission ended via abort; blocks warning clear +#endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED void configure_rmt_(); diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp index b7078b9d69..9f629168f2 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -5,31 +5,49 @@ // clang-tidy cannot parse the Realtek SDK headers pulled in via ArduinoPrivate.h #if defined(USE_RTL87XX) && !defined(CLANG_TIDY) -// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout etc.) with the core's fixes for +// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout, gtimer) with the core's fixes for // type-name collisions between the two (e.g. PinMode) #include +#ifndef USE_LIBRETINY_VARIANT_RTL8720C #include #include +#endif namespace esphome::remote_transmitter { static const char *const TAG = "remote_transmitter"; -// The carrier is generated by the PWM peripheral instead of bit-banging the pin: software carrier -// generation requires disabling interrupts for the whole frame, but this core's micros() is derived -// from the FreeRTOS tick and freezes while interrupts are off, so the timing loop never advances and -// the watchdog resets the chip. With hardware PWM, software only times the mark/space envelope and -// interrupts can stay enabled. -// -// The PWM is driven through the SDK's pwmout HAL directly rather than the Arduino wiring layer: -// changing the carrier frequency via the wiring requires a GPIO/PWM pin mode round-trip, which -// use-after-frees the core's per-pin state (pinRemoveMode() frees without nulling) and corrupts the -// heap. pwmout_period_us() changes the frequency with no mode transitions. +// PWM peripheral carrier, envelope paced by a gtimer interrupt chain. Bit-banging would need +// interrupts disabled for the whole frame, but this core's micros() derives from the FreeRTOS +// tick and freezes then. The SDK pwmout HAL is driven directly: the Arduino wiring layer's +// GPIO/PWM mode round-trip use-after-frees LibreTiny's per-pin state. + +#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 +// 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) { + reinterpret_cast(arg)->advance_envelope_isr(); +} +#endif // USE_LIBRETINY_VARIANT_RTL8720C void RemoteTransmitterComponent::setup() { - // Deliberately no pin_->setup(): registering the pin as GPIO claims it in the SDK's pin - // management, and the pad is then never handed over to the PWM peripheral -- pwmout_init() - // must own the pin from the start. + // no pin_->setup(): a GPIO claim in the SDK's pin management blocks pwmout_init from + // owning the pad PinInfo *info = pinInfo(this->pin_->get_pin()); if (info == nullptr || !pinSupported(info, PIN_PWM)) { // checked here because the AmebaZ (RTL8710B) SDK does not report PWM init failure @@ -40,7 +58,7 @@ void RemoteTransmitterComponent::setup() { auto *pwm = new pwmout_t(); this->pwm_ = pwm; pwmout_init(pwm, static_cast(info->gpio)); -#if LT_RTL8720C +#ifdef USE_LIBRETINY_VARIANT_RTL8720C // only the AmebaZ2 SDK's pwmout_s reports init success if (!pwm->is_init) { ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin()); @@ -49,9 +67,19 @@ void RemoteTransmitterComponent::setup() { this->mark_failed(); return; } + // Shrink the PWM tick-source pool before the period claim below so GTimer7 stays free + // for the envelope; pwmout_init just registered the full pool. + hal_pwm_comm_tick_source_list(s_pwm_tick_sources); #endif pwmout_period_us(pwm, 26); // placeholder; the real carrier period is set per transmission pwmout_write(pwm, this->pin_->is_inverted() ? 1.0f : 0.0f); +#ifdef USE_LIBRETINY_VARIANT_RTL8720C + if (!s_envelope_timer_ready) { + gtimer_init(&s_envelope_timer, ENVELOPE_TIMER_ID); + s_envelope_timer_ready = true; + } + this->disable_loop(); // loop() is only needed while a non-blocking completion is pending +#endif } void RemoteTransmitterComponent::dump_config() { @@ -59,9 +87,224 @@ void RemoteTransmitterComponent::dump_config() { "Remote Transmitter:\n" " Carrier Duty: %u%%", this->carrier_duty_percent_); +#ifdef USE_LIBRETINY_VARIANT_RTL8720C + ESP_LOGCONFIG(TAG, " Non-blocking: %s", YESNO(this->non_blocking_)); +#endif LOG_PIN(" Pin: ", this->pin_); } +void RemoteTransmitterComponent::digital_write(bool value) { + if (this->pwm_ == nullptr) + return; +#ifdef USE_LIBRETINY_VARIANT_RTL8720C + // serialize behind an in-flight chain, matching the ESP32/RMT non-blocking behavior + this->wait_until_idle_(); +#endif + pwmout_write(static_cast(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f); +} + +#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); +} + +// 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 +} + +// 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; + } + 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) { + 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 + const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency); + pwmout_period_us(static_cast(this->pwm_), period); + 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 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 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_(); +} + +#else // !USE_LIBRETINY_VARIANT_RTL8720C -- AmebaZ (RTL8710B): spin-based envelope, per-frame priority boost + void RemoteTransmitterComponent::await_target_time_() { const uint32_t current_time = micros(); if (this->target_time_ == 0) { @@ -72,15 +315,8 @@ void RemoteTransmitterComponent::await_target_time_() { } } -void RemoteTransmitterComponent::digital_write(bool value) { - if (this->pwm_ == nullptr) - return; - pwmout_write(static_cast(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f); -} - void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { - auto *pwm = static_cast(this->pwm_); - if (pwm == nullptr) { + if (this->pwm_ == nullptr) { ESP_LOGW(TAG, "Cannot send: PWM not initialized"); return; } @@ -94,6 +330,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen mark_duty = 1.0f - mark_duty; space_duty = 1.0f; } + auto *pwm = static_cast(this->pwm_); if (carrier_frequency > 0 && carrier_frequency != this->current_carrier_frequency_) { // round(1000000/freq), clamped like the bit-bang path so a bad lambda can't hand the SDK a zero period const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency); @@ -132,6 +369,8 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen this->complete_trigger_.trigger(); } +#endif // USE_LIBRETINY_VARIANT_RTL8720C + } // namespace esphome::remote_transmitter #endif // USE_RTL87XX && !CLANG_TIDY diff --git a/tests/component_tests/remote_transmitter/__init__.py b/tests/component_tests/remote_transmitter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/remote_transmitter/test_non_blocking_gate.py b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py new file mode 100644 index 0000000000..f843f1e84f --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py @@ -0,0 +1,42 @@ +"""non_blocking is family-gated at config validation; the CI build boards never compile +the ISR paths, so this gate is the only CI-reachable coverage for the platform matrix.""" + +import pytest + +from esphome.components.libretiny.const import ( + FAMILY_RTL8710B, + FAMILY_RTL8720C, + KEY_FAMILY, + KEY_LIBRETINY, +) +from esphome.components.remote_transmitter import _validate_non_blocking_platform +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from esphome.core import CORE + +from ..types import SetCoreConfigCallable + + +@pytest.mark.parametrize( + ("platform_framework", "family", "accepted"), + [ + (PlatformFramework.ESP32_IDF, None, True), + (PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C, True), + (PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8710B, False), + (PlatformFramework.ESP8266_ARDUINO, None, False), + ], +) +def test_non_blocking_platform_gate( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + family: str | None, + accepted: bool, +) -> None: + set_core_config(platform_framework) + if family is not None: + CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family} + if accepted: + assert _validate_non_blocking_platform(True) is True + else: + with pytest.raises(cv.Invalid, match="non_blocking is only supported on"): + _validate_non_blocking_platform(True) diff --git a/tests/components/remote_transmitter/test.rtl87xx-ard.yaml b/tests/components/remote_transmitter/test.rtl87xx-ard.yaml index 769adbdf5c..74caa24cdd 100644 --- a/tests/components/remote_transmitter/test.rtl87xx-ard.yaml +++ b/tests/components/remote_transmitter/test.rtl87xx-ard.yaml @@ -2,6 +2,7 @@ remote_transmitter: id: xmitr pin: GPIO12 carrier_duty_percent: 50% + # non_blocking is rtl8720c-only; the CI board is an RTL8710B packages: buttons: !include common-buttons.yaml