From a9bf807f4a28d5e2a576f8a763862d23a81925f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 10 Sep 2026 19:26:39 -0500 Subject: [PATCH] [api] Reply when an infrared/RF raw timings transmit has completed --- esphome/components/api/api.proto | 15 ++++ esphome/components/api/api_connection.cpp | 29 ++++++- esphome/components/api/api_connection.h | 1 + esphome/components/api/api_pb2.cpp | 18 +++++ esphome/components/api/api_pb2.h | 20 +++++ esphome/components/api/api_pb2_dump.cpp | 9 +++ esphome/components/api/api_server.cpp | 76 +++++++++++++++++++ esphome/components/api/api_server.h | 20 +++++ esphome/components/infrared/infrared.cpp | 34 ++++++--- esphome/components/infrared/infrared.h | 11 ++- .../components/ir_rf_proxy/ir_rf_proxy.cpp | 31 +++++--- esphome/components/ir_rf_proxy/ir_rf_proxy.h | 4 +- .../radio_frequency/radio_frequency.cpp | 22 ++++-- .../radio_frequency/radio_frequency.h | 10 ++- .../components/remote_base/remote_base.cpp | 3 +- esphome/components/remote_base/remote_base.h | 34 +++++++-- .../remote_transmitter/remote_transmitter.cpp | 3 +- .../remote_transmitter/remote_transmitter.h | 6 ++ .../remote_transmitter_libretiny_isr.cpp | 3 +- .../remote_transmitter_rmt.cpp | 6 +- .../remote_transmitter_rtl87xx.cpp | 3 +- .../esphome/components/infrared/infrared.h | 2 +- .../radio_frequency/radio_frequency.h | 2 +- 23 files changed, 309 insertions(+), 53 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 21972decad..951c59683f 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2697,6 +2697,21 @@ message InfraredRFReceiveEvent { repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods } +// Sent only to the client that issued an InfraredRFTransmitRawTimingsRequest, once the +// transmitter reports that the transmission (all repeats) has finished, or immediately with +// success=false if it could not be started (unknown key, no transmitter, no or invalid timings). +// Lets clients pace requests instead of estimating durations (since API 1.18) +message InfraredRFTransmitCompleteResponse { + option (id) = 153; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; + option (no_delay) = true; + + uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; + fixed32 key = 2 [(force) = true]; // Key of the transmitter entity from the request + bool success = 3; // false if the transmit never started +} + // ==================== RADIO FREQUENCY ==================== // Lists available radio frequency entity instances diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d910f6fc67..93d1140ac2 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1516,6 +1516,18 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) { +#ifdef USE_DEVICES + const uint32_t device_id = msg.device_id; +#else + const uint32_t device_id = 0; +#endif + // Register before perform(): blocking transmitters report completion from inside it, and the + // non-blocking RMT path flushes the previous frame's completion there + const bool want_reply = this->client_supports_api_version(1, 18); + if (want_reply) { + this->parent_->register_pending_ir_rf_transmit(device_id, msg.key, this); + } + bool started = false; // Dispatch by key: infrared entities are checked first, then radio frequency entities. // The key is unique across all entity instances on a device, so at most one lookup will succeed. #ifdef USE_INFRARED @@ -1525,8 +1537,7 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF call.set_carrier_frequency(msg.carrier_frequency); call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); call.set_repeat_count(msg.repeat_count); - call.perform(); - return; + started = call.perform(); } #endif #ifdef USE_RADIO_FREQUENCY @@ -1537,9 +1548,12 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF call.set_modulation(static_cast(msg.modulation)); call.set_repeat_count(msg.repeat_count); call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); - call.perform(); + started = call.perform(); } #endif + if (want_reply && !started) { + this->parent_->fail_pending_ir_rf_transmit(device_id, msg.key, this); + } } #endif @@ -1551,6 +1565,13 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full"); } } + +void APIConnection::send_infrared_rf_transmit_complete_response(const InfraredRFTransmitCompleteResponse &msg) { + if (!this->send_message(msg)) { + // a lost reply stalls the client's pacing until the server side expiry + API_LOG_MSG_DROPPED(TAG, "IR/RF transmit complete"); + } +} #endif #ifdef USE_SERIAL_PROXY @@ -1813,7 +1834,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 17; + resp.api_version_minor = 18; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index c19a33ca9b..80a56e9e77 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -236,6 +236,7 @@ class APIConnection final : public APIServerConnectionBase { #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg); void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg); + void send_infrared_rf_transmit_complete_response(const InfraredRFTransmitCompleteResponse &msg); #endif #ifdef USE_SERIAL_PROXY diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 7f162d9c15..30189bdceb 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4082,6 +4082,24 @@ InfraredRFReceiveEvent::calculate_size() const { } return size; } +uint8_t *InfraredRFTransmitCompleteResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); +#ifdef USE_DEVICES + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); +#endif + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->success); + return pos; +} +uint32_t InfraredRFTransmitCompleteResponse::calculate_size() const { + uint32_t size = 0; +#ifdef USE_DEVICES + size += ProtoSize::calc_uint32(1, this->device_id); +#endif + size += 5; + size += ProtoSize::calc_bool(1, this->success); + return size; +} #endif #ifdef USE_RADIO_FREQUENCY uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 799aaa27b5..e4682aff86 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3241,6 +3241,26 @@ class InfraredRFReceiveEvent final : public ProtoMessage { protected: }; +class InfraredRFTransmitCompleteResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 153; + static constexpr uint8_t ESTIMATED_SIZE = 11; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_complete_response"); } +#endif +#ifdef USE_DEVICES + uint32_t device_id{0}; +#endif + uint32_t key{0}; + bool success{false}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; #endif #ifdef USE_RADIO_FREQUENCY class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index bb244973a1..debcd7ddec 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2740,6 +2740,15 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const { } return out.c_str(); } +const char *InfraredRFTransmitCompleteResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitCompleteResponse")); +#ifdef USE_DEVICES + dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); +#endif + dump_field(out, ESPHOME_PSTR("key"), this->key); + dump_field(out, ESPHOME_PSTR("success"), this->success); + return out.c_str(); +} #endif #ifdef USE_RADIO_FREQUENCY const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 78ebe5c38e..b541b921c1 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -38,6 +38,9 @@ void APIServer::socket_failed_(const LogString *msg) { } void APIServer::setup() { +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) + this->pending_ir_rf_transmits_.reserve(this->clients_.size()); +#endif ControllerRegistry::register_controller(this); #ifdef USE_API_NOISE @@ -193,6 +196,9 @@ void APIServer::remove_client_(uint8_t client_index) { #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES this->unregister_active_action_calls_for_connection(client.get()); +#endif +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) + this->unregister_pending_ir_rf_transmits_for_connection_(client.get()); #endif ESP_LOGV(TAG, "Remove connection %s", client->get_name()); @@ -417,6 +423,76 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ for (auto &c : this->active_clients()) c->send_infrared_rf_receive_event(resp); } + +// Safety net for transmitters that never report completion (for example a failed component) +static constexpr uint32_t IR_RF_TRANSMIT_TIMEOUT_MS = 30000; +static constexpr char IR_RF_TRANSMIT_TIMEOUT[] = "ir_rf_tx"; + +void APIServer::register_pending_ir_rf_transmit(uint32_t device_id, uint32_t key, APIConnection *conn) { + this->pending_ir_rf_transmits_.push_back({device_id, key, App.get_loop_component_start_time(), conn}); + if (this->pending_ir_rf_transmits_.size() == 1) { + this->set_timeout(IR_RF_TRANSMIT_TIMEOUT, IR_RF_TRANSMIT_TIMEOUT_MS, + [this]() { this->expire_pending_ir_rf_transmits_(); }); + } +} + +void APIServer::fail_pending_ir_rf_transmit(uint32_t device_id, uint32_t key, APIConnection *conn) { + auto &pending = this->pending_ir_rf_transmits_; + for (size_t i = pending.size(); i-- > 0;) { + if (pending[i].connection == conn && pending[i].key == key && pending[i].device_id == device_id) { + this->complete_pending_ir_rf_transmit_(i, false); + return; + } + } +} + +void APIServer::send_infrared_rf_transmit_complete(const EntityBase &entity) { + uint32_t device_id = 0; +#ifdef USE_DEVICES + device_id = entity.get_device_id(); +#endif + const uint32_t key = entity.get_object_id_hash(); + auto &pending = this->pending_ir_rf_transmits_; + for (size_t i = 0; i < pending.size(); i++) { + if (pending[i].key == key && pending[i].device_id == device_id) { + this->complete_pending_ir_rf_transmit_(i, true); + return; + } + } +} + +void APIServer::complete_pending_ir_rf_transmit_(size_t index, bool success) { + auto &pending = this->pending_ir_rf_transmits_; + InfraredRFTransmitCompleteResponse resp{}; +#ifdef USE_DEVICES + resp.device_id = pending[index].device_id; +#endif + resp.key = pending[index].key; + resp.success = success; + pending[index].connection->send_infrared_rf_transmit_complete_response(resp); + pending.erase(pending.begin() + index); +} + +void APIServer::unregister_pending_ir_rf_transmits_for_connection_(APIConnection *conn) { + auto &pending = this->pending_ir_rf_transmits_; + pending.erase(std::remove_if(pending.begin(), pending.end(), + [conn](const PendingIrRfTransmit &entry) { return entry.connection == conn; }), + pending.end()); +} + +// A timer firing on an empty list is harmless, so nothing cancels it +void APIServer::expire_pending_ir_rf_transmits_() { + const uint32_t now = App.get_loop_component_start_time(); + auto &pending = this->pending_ir_rf_transmits_; + while (!pending.empty() && now - pending.front().registered_ms >= IR_RF_TRANSMIT_TIMEOUT_MS) { + ESP_LOGW(TAG, "IR/RF transmit %" PRIu32 " never reported completion", pending.front().key); + this->complete_pending_ir_rf_transmit_(0, false); + } + if (!pending.empty()) { + this->set_timeout(IR_RF_TRANSMIT_TIMEOUT, IR_RF_TRANSMIT_TIMEOUT_MS - (now - pending.front().registered_ms), + [this]() { this->expire_pending_ir_rf_transmits_(); }); + } +} #endif #ifdef USE_ALARM_CONTROL_PANEL diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 618ea4eb11..6a9397c491 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -196,6 +196,10 @@ class APIServer final : public Component, #endif #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); + // Completion replies for InfraredRFTransmitRawTimingsRequest (API 1.18+); register before perform() + void register_pending_ir_rf_transmit(uint32_t device_id, uint32_t key, APIConnection *conn); + void fail_pending_ir_rf_transmit(uint32_t device_id, uint32_t key, APIConnection *conn); + void send_infrared_rf_transmit_complete(const EntityBase &entity); #endif bool is_connected() const { return this->api_connection_count_ != 0; } @@ -342,6 +346,22 @@ class APIServer final : public Component, uint32_t next_action_call_id_{1}; // Counter for generating unique action_call_ids #endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) + struct PendingIrRfTransmit { + uint32_t device_id; + uint32_t key; + uint32_t registered_ms; + APIConnection *connection; + }; + // FIFO per entity: entities forward only completions of frames they submitted, and each + // completion pops the oldest match, so entries are never reordered. + // Unlike action calls, transmits share one named expiry timer: they are frequent and + // a scheduler item per entry would churn the heap. + std::vector pending_ir_rf_transmits_; + void complete_pending_ir_rf_transmit_(size_t index, bool success); + void unregister_pending_ir_rf_transmits_for_connection_(APIConnection *conn); + void expire_pending_ir_rf_transmits_(); +#endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES struct PendingActionResponse { uint32_t call_id; diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 5a909738c6..57a46ee58b 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -47,11 +47,7 @@ InfraredCall &InfraredCall::set_repeat_count(uint32_t count) { return *this; } -void InfraredCall::perform() { - if (this->parent_ != nullptr) { - this->parent_->control(*this); - } -} +bool InfraredCall::perform() { return this->parent_ != nullptr && this->parent_->control(*this); } // ========== Infrared ========== @@ -64,6 +60,15 @@ void Infrared::setup() { if (this->receiver_ != nullptr) { this->receiver_->register_listener(this); } +#if defined(USE_API) && defined(USE_IR_RF) + if (this->transmitter_ != nullptr) { + // only frames this entity submitted; YAML automations share the transmitter + this->transmitter_->set_on_complete_callback([this](uint32_t seq) { + if (seq == this->inflight_seq_) + this->notify_transmit_complete_(); + }); + } +#endif } void Infrared::dump_config() { @@ -75,15 +80,15 @@ void Infrared::dump_config() { YESNO(this->traits_.get_supports_receiver())); } -void Infrared::control(const InfraredCall &call) { +bool Infrared::control(const InfraredCall &call) { if (this->transmitter_ == nullptr) { ESP_LOGW(TAG, "No transmitter configured"); - return; + return false; } if (!call.has_raw_timings()) { ESP_LOGE(TAG, "No raw timings provided"); - return; + return false; } // Create transmit data object @@ -107,7 +112,7 @@ void Infrared::control(const InfraredCall &call) { // Decode base64url (URL-safe) into transmit buffer if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { ESP_LOGE(TAG, "Invalid base64url data"); - return; + return false; } // Sanity check: validate timing values are within reasonable bounds constexpr int32_t max_timing_us = 500000; // 500ms absolute max @@ -115,7 +120,7 @@ void Infrared::control(const InfraredCall &call) { int32_t abs_timing = timing < 0 ? -timing : timing; if (abs_timing > max_timing_us) { ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); - return; + return false; } } ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), @@ -133,7 +138,16 @@ void Infrared::control(const InfraredCall &call) { } // Perform transmission + this->inflight_seq_ = transmit_call.get_seq(); transmit_call.perform(); + return true; +} + +void Infrared::notify_transmit_complete_() { +#if defined(USE_API) && defined(USE_IR_RF) + if (api::global_api_server != nullptr) + api::global_api_server->send_infrared_rf_transmit_complete(*this); +#endif } uint32_t Infrared::get_capability_flags() const { diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index b6863e37ce..867ffba8cd 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -54,8 +54,8 @@ class InfraredCall { /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) InfraredCall &set_repeat_count(uint32_t count); - /// Perform the transmission - void perform(); + /// Perform the transmission; returns true if a frame was handed to the transmitter + bool perform(); /// Get the carrier frequency const optional &get_carrier_frequency() const { return this->carrier_frequency_; } @@ -145,8 +145,11 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote protected: friend class InfraredCall; - /// Perform the actual transmission (called by InfraredCall) - virtual void control(const InfraredCall &call); + /// Perform the actual transmission (called by InfraredCall); false if nothing was transmitted + virtual bool control(const InfraredCall &call); + /// Forwards the transmitter's completion to the API server + void notify_transmit_complete_(); + uint32_t inflight_seq_{0}; // seq of the frame this entity submitted last // Underlying hardware components remote_base::RemoteReceiverBase *receiver_{nullptr}; diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index c13c6198cb..4ebcc844fb 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -12,16 +12,16 @@ static const char *const TAG = "ir_rf_proxy"; // Static template: all instantiations occur in this translation unit. template -static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency, - const CallT &call) { +static bool transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency, + const CallT &call, uint32_t &inflight_seq) { if (transmitter == nullptr) { ESP_LOGW(TAG, "No transmitter configured"); - return; + return false; } if (!call.has_raw_timings()) { ESP_LOGE(TAG, "No raw timings provided"); - return; + return false; } auto transmit_call = transmitter->transmit(); @@ -36,14 +36,14 @@ static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter } else if (call.is_base64url()) { if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { ESP_LOGE(TAG, "Invalid base64url data"); - return; + return false; } constexpr int32_t max_timing_us = 500000; for (int32_t timing : transmit_data->get_data()) { int32_t abs_timing = timing < 0 ? -timing : timing; if (abs_timing > max_timing_us) { ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); - return; + return false; } } ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), @@ -58,7 +58,9 @@ static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter transmit_call.set_send_times(call.get_repeat_count()); } + inflight_seq = transmit_call.get_seq(); transmit_call.perform(); + return true; } // ========== IrRfProxy (Infrared platform) ========== @@ -80,9 +82,9 @@ void IrRfProxy::dump_config() { } } -void IrRfProxy::control(const infrared::InfraredCall &call) { +bool IrRfProxy::control(const infrared::InfraredCall &call) { uint32_t carrier = call.get_carrier_frequency().value_or(0); - transmit_raw_timings(this->transmitter_, carrier, call); + return transmit_raw_timings(this->transmitter_, carrier, call, this->inflight_seq_); } #endif // USE_IR_RF @@ -101,6 +103,15 @@ void RfProxy::setup() { if (this->receiver_ != nullptr) { this->receiver_->register_listener(this); } +#if defined(USE_API) && defined(USE_RADIO_FREQUENCY) + if (this->transmitter_ != nullptr) { + // only frames this entity submitted; YAML automations share the transmitter + this->transmitter_->set_on_complete_callback([this](uint32_t seq) { + if (seq == this->inflight_seq_) + this->notify_transmit_complete_(); + }); + } +#endif } void RfProxy::dump_config() { @@ -122,11 +133,11 @@ void RfProxy::dump_config() { } } -void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { +bool RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { // RF: no IR carrier modulation. Any RF front-end coordination (state turnaround, retuning) // happens via the radio_frequency entity's on_control trigger and remote_transmitter's // on_transmit/on_complete triggers — wired up in user YAML. - transmit_raw_timings(this->transmitter_, 0, call); + return transmit_raw_timings(this->transmitter_, 0, call, this->inflight_seq_); } #endif // USE_RADIO_FREQUENCY diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 5fc683354b..cd2cac0529 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -35,7 +35,7 @@ class IrRfProxy final : public infrared::Infrared { void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); } protected: - void control(const infrared::InfraredCall &call) override; + bool control(const infrared::InfraredCall &call) override; // RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF uint32_t frequency_khz_{0}; @@ -63,7 +63,7 @@ class RfProxy final : public radio_frequency::RadioFrequency { void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); } protected: - void control(const radio_frequency::RadioFrequencyCall &call) override; + bool control(const radio_frequency::RadioFrequencyCall &call) override; remote_base::RemoteTransmitterBase *transmitter_{nullptr}; remote_base::RemoteReceiverBase *receiver_{nullptr}; diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index 61e7feb9af..2bca3da23e 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -52,14 +52,15 @@ RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) { return *this; } -void RadioFrequencyCall::perform() { - if (this->parent_ != nullptr) { - // Fire any on_control hooks (user-wired automations) before handing off to - // the platform-specific control() — gives users a chance to react to call - // parameters (e.g. retune an external RF front-end based on call.get_frequency()). - this->parent_->control_callback_.call(*this); - this->parent_->control(*this); +bool RadioFrequencyCall::perform() { + if (this->parent_ == nullptr) { + return false; } + // Fire any on_control hooks (user-wired automations) before handing off to + // the platform-specific control() — gives users a chance to react to call + // parameters (e.g. retune an external RF front-end based on call.get_frequency()). + this->parent_->control_callback_.call(*this); + return this->parent_->control(*this); } // ========== RadioFrequency ========== @@ -108,4 +109,11 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { return false; // Don't consume the event, allow other listeners to process it } +void RadioFrequency::notify_transmit_complete_() { +#if defined(USE_API) && defined(USE_RADIO_FREQUENCY) + if (api::global_api_server != nullptr) + api::global_api_server->send_infrared_rf_transmit_complete(*this); +#endif +} + } // namespace esphome::radio_frequency diff --git a/esphome/components/radio_frequency/radio_frequency.h b/esphome/components/radio_frequency/radio_frequency.h index 8782c255f0..29e04ed603 100644 --- a/esphome/components/radio_frequency/radio_frequency.h +++ b/esphome/components/radio_frequency/radio_frequency.h @@ -64,8 +64,8 @@ class RadioFrequencyCall { /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) RadioFrequencyCall &set_repeat_count(uint32_t count); - /// Perform the transmission - void perform(); + /// Perform the transmission; returns true if a frame was handed to the transmitter + bool perform(); /// Get the frequency in Hz const optional &get_frequency() const { return this->frequency_hz_; } @@ -184,7 +184,11 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: /// Perform the actual transmission (called by RadioFrequencyCall::perform()) /// Platforms must override this to implement hardware-specific transmission. - virtual void control(const RadioFrequencyCall &call) = 0; + /// Returns false if nothing was transmitted. + virtual bool control(const RadioFrequencyCall &call) = 0; + /// Forwards the transmitter's completion to the API server; platforms hook their transmitter to it + void notify_transmit_complete_(); + uint32_t inflight_seq_{0}; // seq of the frame this entity submitted last // Traits describing capabilities RadioFrequencyTraits traits_; diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 4d9bc55f21..e5b33d25e7 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -163,7 +163,7 @@ bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) { /* RemoteTransmitterBase */ -void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) { +void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait, uint32_t seq) { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE const auto &vec = this->temp_.get_data(); char buffer[256]; @@ -195,6 +195,7 @@ void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) { ESP_LOGVV(TAG, "%s", buffer); } #endif + this->current_seq_ = seq; this->send_internal(send_times, send_wait); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 4e2ed4b71c..3a29fb360c 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -146,21 +146,24 @@ class RemoteTransmitterBase : public RemoteComponentBase { RemoteTransmitterBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} class TransmitCall { public: - explicit TransmitCall(RemoteTransmitterBase *parent) : parent_(parent) {} + TransmitCall(RemoteTransmitterBase *parent, uint32_t seq) : parent_(parent), seq_(seq) {} RemoteTransmitData *get_data() { return &this->parent_->temp_; } void set_send_times(uint32_t send_times) { send_times_ = send_times; } void set_send_wait(uint32_t send_wait) { send_wait_ = send_wait; } - void perform() { this->parent_->send_(this->send_times_, this->send_wait_); } + /// Identifies this transmission in the completion callback + uint32_t get_seq() const { return this->seq_; } + void perform() { this->parent_->send_(this->send_times_, this->send_wait_, this->seq_); } protected: RemoteTransmitterBase *parent_; uint32_t send_times_{1}; uint32_t send_wait_{0}; + uint32_t seq_; }; TransmitCall transmit() { this->temp_.reset(); - return TransmitCall(this); + return TransmitCall(this, ++this->next_seq_); } template void transmit(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { @@ -170,11 +173,32 @@ class RemoteTransmitterBase : public RemoteComponentBase { call.set_send_wait(send_wait); call.perform(); } +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) + /// Called with the TransmitCall's seq once that transmission has finished, after the last + /// repeat and before the on_complete trigger. One slot: a transmitter is driven by a single + /// infrared or radio_frequency entity. + template void set_on_complete_callback(F &&callback) { + this->complete_callback_ = Callback::create(std::forward(callback)); + } +#endif protected: - void send_(uint32_t send_times, uint32_t send_wait); + void send_(uint32_t send_times, uint32_t send_wait, uint32_t seq); virtual void send_internal(uint32_t send_times, uint32_t send_wait) = 0; - void send_single_() { this->send_(1, 0); } + void send_single_() { this->send_(1, 0, ++this->next_seq_); } +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) + void notify_complete_(uint32_t seq) { + if (this->complete_callback_.fn_ != nullptr) + this->complete_callback_.call(seq); + } + + Callback complete_callback_{}; +#else + void notify_complete_(uint32_t seq) {} +#endif + // seq handed to the platform by send_(); platforms copy it when they accept the frame + uint32_t next_seq_{0}; + uint32_t current_seq_{0}; /// Use same vector for all transmits, avoids many allocations RemoteTransmitData temp_; diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 5e82213a48..8011bc5c14 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -80,6 +80,7 @@ void RemoteTransmitterComponent::digital_write(bool value) { this->pin_->digital void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { ESP_LOGD(TAG, "Sending remote code"); + this->inflight_seq_ = this->current_seq_; uint32_t on_time, off_time; this->calculate_on_off_time_(this->temp_.get_carrier_frequency(), &on_time, &off_time); this->transmit_trigger_.trigger(); @@ -114,7 +115,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } } } - this->complete_trigger_.trigger(); + this->fire_complete_(); } } // namespace esphome::remote_transmitter diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 4db4e80a60..ee68723e8c 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -82,6 +82,12 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; + // completion callbacks run before the user's on_complete automation + void fire_complete_() { + this->notify_complete_(this->inflight_seq_); + this->complete_trigger_.trigger(); + } + uint32_t inflight_seq_{0}; // seq of the frame whose completion is still to be reported #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) diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp index fad91f593f..42ef8f825a 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp @@ -110,7 +110,7 @@ void RemoteTransmitterComponent::deliver_completion_() { if (!this->stall_aborted_) this->status_clear_warning(); this->complete_pending_ = false; - this->complete_trigger_.trigger(); + this->fire_complete_(); } // Waits until no chain is in flight, delivering any deferred completions; a completion @@ -137,6 +137,7 @@ void RemoteTransmitterComponent::wait_until_idle_() { // 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->inflight_seq_ = this->current_seq_; this->isr_repeats_left_ = send_times; this->isr_send_wait_ = send_wait; this->isr_index_ = 0; diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 3c9a12d472..44fddca09a 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -213,7 +213,7 @@ void RemoteTransmitterComponent::wait_for_rmt_() { this->status_set_warning(); } - this->complete_trigger_.trigger(); + this->fire_complete_(); } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) @@ -228,6 +228,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if (this->non_blocking_ && this->cancel_timeout("complete")) { this->wait_for_rmt_(); } + this->inflight_seq_ = this->current_seq_; if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); @@ -300,6 +301,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { if (this->is_failed()) return; + this->inflight_seq_ = this->current_seq_; if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); @@ -364,7 +366,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if (i + 1 < send_times) delayMicroseconds(send_wait); } - this->complete_trigger_.trigger(); + this->fire_complete_(); } #endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp index 6db9faac36..a3811cea75 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -149,6 +149,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen return; } ESP_LOGD(TAG, "Sending remote code"); + this->inflight_seq_ = this->current_seq_; 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 = @@ -194,7 +195,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } } } - this->complete_trigger_.trigger(); + this->fire_complete_(); } #endif // USE_LIBRETINY_VARIANT_RTL8720C diff --git a/tests/benchmarks/stubs/esphome/components/infrared/infrared.h b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h index 874e7a270b..c565d6e4b3 100644 --- a/tests/benchmarks/stubs/esphome/components/infrared/infrared.h +++ b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h @@ -19,7 +19,7 @@ class InfraredCall { return *this; } InfraredCall &set_repeat_count(uint32_t /*count*/) { return *this; } - void perform() {} + bool perform() { return false; } protected: Infrared *parent_; diff --git a/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h b/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h index 72fc08034b..ff9cc791d5 100644 --- a/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h +++ b/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h @@ -23,7 +23,7 @@ class RadioFrequencyCall { RadioFrequencyCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) { return *this; } - void perform() {} + bool perform() { return false; } protected: RadioFrequency *parent_;