diff --git a/CODEOWNERS b/CODEOWNERS index 944c5f2809..b5138c0612 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -287,6 +287,7 @@ esphome/components/inkplate/* @jesserockz @JosipKuci esphome/components/integration/* @OttoWinter esphome/components/internal_temperature/* @Mat931 esphome/components/interval/* @esphome/core +esphome/components/ir_rf_base/* @bdraco @kbx81 esphome/components/ir_rf_proxy/* @kbx81 esphome/components/it8951/* @koosoli @limengdu @Passific esphome/components/jsn_sr04t/* @Mafus1 diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index ed7b8e515c..5f4246b574 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2692,7 +2692,7 @@ message ListEntitiesInfraredResponse { message InfraredRFTransmitRawTimingsRequest { option (id) = 136; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; + option (ifdef) = "USE_IR_RF"; uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance @@ -2706,7 +2706,7 @@ message InfraredRFTransmitRawTimingsRequest { message InfraredRFReceiveEvent { option (id) = 137; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; + option (ifdef) = "USE_IR_RF"; option (no_delay) = true; option (speed_optimized) = true; @@ -2715,6 +2715,28 @@ 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 was refused before reaching the transmitter (unknown key, no transmitter, +// no or invalid timings) or the transmitter could not send it (not set up, hardware error). +// success=false also answers a request superseded by a newer request on the same entity, from +// any client, and a transmit that reported nothing within 30 s past its expected air time, so a +// client should not retry on it blindly. The device serializes transmits per transmitter and +// several entities may share one, so a client should keep at most one transmit outstanding per +// device, not per entity. A reply for an unknown key or a refused request is sent once and can be lost +// when the device's send buffer is full, so a client should also stop waiting on its own after the +// expected air time plus a margin. 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"; + 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 request was refused, not sent, superseded, or never reported +} + // ==================== 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 0a2fe13e6a..06e5e7c342 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -198,6 +198,17 @@ APIConnection::~APIConnection() { proxy->serial_proxy_request(this, enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE); } } +#endif + // entities holding a transmit reply for this client must not answer into a freed connection +#ifdef USE_INFRARED + for (auto *infrared : App.get_infrareds()) { + infrared->on_api_connection_closed(this); + } +#endif +#ifdef USE_RADIO_FREQUENCY + for (auto *radio_frequency : App.get_radio_frequencies()) { + radio_frequency->on_api_connection_closed(this); + } #endif } @@ -1517,8 +1528,10 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) { + // Clients on API 1.18+ are told when the frame has left the transmitter; the entity owns that reply + const bool want_reply = this->client_supports_api_version(1, 18); // 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 @@ -1528,6 +1541,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.set_api_connection(want_reply ? this : nullptr); call.perform(); return; } @@ -1540,13 +1554,38 @@ 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.set_api_connection(want_reply ? this : nullptr); call.perform(); + return; } #endif + ESP_LOGW(TAG, "IR/RF transmit for unknown key %" PRIu32, msg.key); + if (want_reply) { + // nothing will ever report for an unknown key, so answer as not started right away +#ifdef USE_DEVICES + const uint32_t device_id = msg.device_id; +#else + const uint32_t device_id = 0; +#endif + if (!this->send_infrared_rf_transmit_complete(device_id, msg.key, false)) { + API_LOG_MSG_DROPPED(TAG, "IR/RF reply"); + } + } +} + +bool APIConnection::send_infrared_rf_transmit_complete([[maybe_unused]] uint32_t device_id, uint32_t key, + bool success) { + InfraredRFTransmitCompleteResponse resp{}; +#ifdef USE_DEVICES + resp.device_id = device_id; +#endif + resp.key = key; + resp.success = success; + return this->send_message(resp); } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { if (!this->send_message(msg)) { // V: fires per decoded frame with no subscription gate, so a warning @@ -1554,6 +1593,7 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full"); } } + #endif #ifdef USE_SERIAL_PROXY @@ -1816,7 +1856,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 dacca57369..db364d1830 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -233,9 +233,12 @@ class APIConnection final : public APIServerConnectionBase { void on_water_heater_command_request(const WaterHeaterCommandRequest &msg); #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg); void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg); + // Reply to an InfraredRFTransmitRawTimingsRequest (API 1.18+); false when the TCP buffer is + // full, the entity that owns the reply retries it then + [[nodiscard]] bool send_infrared_rf_transmit_complete(uint32_t device_id, uint32_t key, bool success); #endif #ifdef USE_SERIAL_PROXY diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index fe8992337d..2f0d8e7cc7 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3934,7 +3934,7 @@ uint32_t ListEntitiesInfraredResponse::calc_size_msg(const void *self) { return size; } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void InfraredRFTransmitRawTimingsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { auto &msg = *static_cast(self); @@ -3994,6 +3994,27 @@ InfraredRFReceiveEvent::calc_size_msg(const void *self) { } return size; } +uint8_t *InfraredRFTransmitCompleteResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + uint8_t *__restrict__ pos = buffer.get_pos(); +#ifdef USE_DEVICES + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); +#endif + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.success); + return pos; +} +uint32_t InfraredRFTransmitCompleteResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; +#ifdef USE_DEVICES + size += ProtoSize::calc_uint32(1, msg.device_id); +#endif + size += 5; + size += ProtoSize::calc_bool(1, msg.success); + return size; +} #endif #ifdef USE_RADIO_FREQUENCY uint8_t *ListEntitiesRadioFrequencyResponse::encode_msg(const void *self, @@ -4329,7 +4350,7 @@ static_assert(!std::is_polymorphic_v, "decodable messages static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); #endif #ifdef USE_SERIAL_PROXY diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d94cd709d8..0429bc78dd 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3681,7 +3681,7 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { protected: }; #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { public: static constexpr uint16_t MESSAGE_TYPE = 136; @@ -3733,6 +3733,30 @@ 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}; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#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 b884507618..6c7a1ac9ac 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2720,7 +2720,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest")); #ifdef USE_DEVICES @@ -2749,6 +2749,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_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 172062be63..b8ac99f14f 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -628,7 +628,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: { InfraredRFTransmitRawTimingsRequest msg; msg.decode(msg_data, msg_size); diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index a4dfd6a366..b77fc82da7 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -213,7 +213,7 @@ class APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){}; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 600ff2a2fe..904bb33e53 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -504,7 +504,7 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key, const std::vector *timings) { InfraredRFReceiveEvent resp{}; @@ -517,6 +517,7 @@ 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); } + #endif #ifdef USE_ALARM_CONTROL_PANEL diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index fc1ca7df13..df05ca3a43 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -203,7 +203,7 @@ class APIServer final : public Component #ifdef USE_ZWAVE_PROXY void on_zwave_proxy_request(const ZWaveProxyRequest &msg); #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index d04c82ea96..47ca5bf07f 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -9,20 +9,21 @@ Once the API is considered stable, this warning will be removed. """ import esphome.codegen as cg +from esphome.components import ir_rf_base import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import queue_entity_register, setup_entity +from esphome.core import coroutine_with_priority +from esphome.core.entity_helpers import setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@kbx81"] -AUTO_LOAD = ["remote_base"] +AUTO_LOAD = ["ir_rf_base"] IS_PLATFORM_COMPONENT = True infrared_ns = cg.esphome_ns.namespace("infrared") -Infrared = infrared_ns.class_("Infrared", cg.EntityBase, cg.Component) +Infrared = infrared_ns.class_("Infrared", ir_rf_base.IrRfEntity) InfraredCall = infrared_ns.class_("InfraredCall") InfraredTraits = infrared_ns.class_("InfraredTraits") @@ -52,11 +53,8 @@ async def setup_infrared_core_(var: cg.MockObj, config: ConfigType) -> None: async def register_infrared(var: cg.MockObj, config: ConfigType) -> None: """Register an infrared device with the core.""" - cg.add_define("USE_IR_RF") - await cg.register_component(var, config) - queue_entity_register("infrared", config) + await ir_rf_base.register_ir_rf_entity(var, config, "infrared") await setup_infrared_core_(var, config) - CORE.register_platform_component("infrared", var) async def new_infrared(config: ConfigType, *args: SafeExpType) -> cg.MockObj: diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 83039a5a9b..67a40fa349 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -1,161 +1,18 @@ #include "infrared.h" -#include - #include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/api_server.h" -#endif - namespace esphome::infrared { static const char *const TAG = "infrared"; -// ========== InfraredCall ========== - -InfraredCall &InfraredCall::set_carrier_frequency(uint32_t frequency) { - this->carrier_frequency_ = frequency; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings(const std::vector &timings) { - this->raw_timings_ = &timings; - this->packed_data_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings_base64url(const std::string &base64url) { - this->base64url_ptr_ = &base64url; - this->raw_timings_ = nullptr; - this->packed_data_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { - this->packed_data_ = data; - this->packed_length_ = length; - this->packed_count_ = count; - this->raw_timings_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_repeat_count(uint32_t count) { - this->repeat_count_ = count; - return *this; -} - -void InfraredCall::perform() { - if (this->parent_ != nullptr) { - this->parent_->control(*this); - } -} - -// ========== Infrared ========== - -void Infrared::setup() { - // Set up traits based on configuration - this->traits_.set_supports_transmitter(this->has_transmitter()); - this->traits_.set_supports_receiver(this->has_receiver()); -} - void Infrared::dump_config() { ESP_LOGCONFIG(TAG, "Infrared '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); -} - -void Infrared::control(const InfraredCall &call) { - if (this->transmitter_ == nullptr) { - ESP_LOGW(TAG, "No transmitter configured"); - return; - } - - if (!call.has_raw_timings()) { - ESP_LOGE(TAG, "No raw timings provided"); - return; - } - - // Create transmit data object - auto transmit_call = this->transmitter_->transmit(); - auto *transmit_data = transmit_call.get_data(); - - // Set carrier frequency - auto freq = call.get_carrier_frequency(); - if (freq.has_value()) { - transmit_data->set_carrier_frequency(*freq); - } - - // Set timings based on format - if (call.is_packed()) { - // Zero-copy from packed protobuf data - transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), - call.get_packed_count()); - ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), - call.get_repeat_count()); - } else if (call.is_base64url()) { - // 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; - } - // Sanity check: validate timing values are within reasonable bounds - constexpr int32_t max_timing_us = 500000; // 500ms absolute max - for (int32_t timing : transmit_data->get_data()) { - int32_t abs_timing = timing < 0 ? -timing : timing; - if (abs_timing > max_timing_us) { - ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); - return; - } - } - ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), - call.get_repeat_count()); - } else { - // From vector (lambdas/automations) - transmit_data->set_data(call.get_raw_timings()); - ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), - call.get_repeat_count()); - } - - // Set repeat count - if (call.get_repeat_count() > 0) { - transmit_call.set_send_times(call.get_repeat_count()); - } - - // Perform transmission - transmit_call.perform(); -} - -uint32_t Infrared::get_capability_flags() const { - uint32_t flags = 0; - - // Add transmit/receive capability based on traits - if (this->traits_.get_supports_transmitter()) - flags |= InfraredCapability::CAPABILITY_TRANSMITTER; - if (this->traits_.get_supports_receiver()) - flags |= InfraredCapability::CAPABILITY_RECEIVER; - - return flags; -} - -bool Infrared::on_receive(remote_base::RemoteReceiveData data) { - // Forward received IR data to API server -#if defined(USE_API) && defined(USE_IR_RF) - if (api::global_api_server != nullptr) { -#ifdef USE_DEVICES - uint32_t device_id = this->get_device_id(); -#else - uint32_t device_id = 0; -#endif - api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); - } -#endif - return false; // Don't consume the event, allow other listeners to process it + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); } } // namespace esphome::infrared diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index afbde57be2..d81e3e2e38 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -4,131 +4,48 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. -#include "esphome/core/component.h" -#include "esphome/core/entity_base.h" -#include "esphome/components/remote_base/remote_base.h" - -#include +#include "esphome/components/ir_rf_base/ir_rf_base.h" namespace esphome::infrared { -/// Capability flags for individual infrared instances -enum InfraredCapability : uint32_t { - CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals - CAPABILITY_RECEIVER = 1 << 1, // Can receive signals -}; +using ir_rf_base::CAPABILITY_RECEIVER; +using ir_rf_base::CAPABILITY_TRANSMITTER; -/// Forward declarations class Infrared; /// InfraredCall - Builder pattern for transmitting infrared signals -class InfraredCall { +class InfraredCall : public ir_rf_base::IrRfCall { public: - explicit InfraredCall(Infrared *parent) : parent_(parent) {} + explicit InfraredCall(Infrared *parent) : IrRfCall(parent) {} /// Set the carrier frequency in Hz - InfraredCall &set_carrier_frequency(uint32_t frequency); - - // ===== Raw Timings Methods ===== - // All set_raw_timings_* methods store pointers/references to external data. - // The referenced data must remain valid until perform() completes. - // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous - // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! - - /// Set the raw timings from a vector (positive = mark, negative = space) - /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). - /// @note Usage: Primarily for lambdas/automations where the vector is in scope. - InfraredCall &set_raw_timings(const std::vector &timings); - - /// Set the raw timings from base64url-encoded little-endian int32 data - /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). - /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). - /// @note Decoding happens at perform() time, directly into the transmit buffer. - InfraredCall &set_raw_timings_base64url(const std::string &base64url); - - /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) - /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). - /// @note Usage: For API component where data comes directly from the protobuf message. - InfraredCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count); - - /// 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(); - + InfraredCall &set_carrier_frequency(uint32_t frequency) { + this->carrier_frequency_ = frequency; + return *this; + } /// Get the carrier frequency const optional &get_carrier_frequency() const { return this->carrier_frequency_; } - /// Get the raw timings (only valid if set via set_raw_timings) - const std::vector &get_raw_timings() const { return *this->raw_timings_; } - /// Check if raw timings have been set (any format) - bool has_raw_timings() const { - return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; - } - /// Check if using packed data format - bool is_packed() const { return this->packed_data_ != nullptr; } - /// Check if using base64url data format - bool is_base64url() const { return this->base64url_ptr_ != nullptr; } - /// Get the base64url data string - const std::string &get_base64url_data() const { return *this->base64url_ptr_; } - /// Get packed data (only valid if set via set_raw_timings_packed) - const uint8_t *get_packed_data() const { return this->packed_data_; } - uint16_t get_packed_length() const { return this->packed_length_; } - uint16_t get_packed_count() const { return this->packed_count_; } - /// Get the repeat count - uint32_t get_repeat_count() const { return this->repeat_count_; } protected: - uint32_t repeat_count_{1}; - Infrared *parent_; optional carrier_frequency_; - // Pointer to vector-based timings (caller-owned, must outlive perform()) - const std::vector *raw_timings_{nullptr}; - // Pointer to base64url-encoded string (caller-owned, must outlive perform()) - const std::string *base64url_ptr_{nullptr}; - // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) - const uint8_t *packed_data_{nullptr}; - uint16_t packed_length_{0}; - uint16_t packed_count_{0}; }; /// InfraredTraits - Describes the capabilities of an infrared implementation class InfraredTraits { public: - bool get_supports_transmitter() const { return this->supports_transmitter_; } - void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } - - bool get_supports_receiver() const { return this->supports_receiver_; } - void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } - uint32_t get_receiver_frequency_hz() const { return this->receiver_frequency_hz_; } void set_receiver_frequency_hz(uint32_t freq) { this->receiver_frequency_hz_ = freq; } protected: - bool supports_transmitter_{false}; - bool supports_receiver_{false}; uint32_t receiver_frequency_hz_{0}; // Demodulation frequency of the IR receiver in Hz (0 = unspecified) }; /// Infrared - Base class for infrared remote control implementations -class Infrared : public Component, public EntityBase, public remote_base::RemoteReceiverListener { +class Infrared : public ir_rf_base::IrRfEntity { public: Infrared() = default; - void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } - - /// Set the remote receiver component; the listener registration happens from codegen, see - /// remote_base.attach_receiver - void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } - /// Set the remote transmitter component - void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - - /// Check if this infrared has a transmitter configured - bool has_transmitter() const { return this->transmitter_ != nullptr; } - /// Check if this infrared has a receiver configured - bool has_receiver() const { return this->receiver_ != nullptr; } /// Get the traits for this infrared implementation InfraredTraits &get_traits() { return this->traits_; } @@ -137,21 +54,16 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote /// Create a call object for transmitting InfraredCall make_call() { return InfraredCall(this); } - /// Get capability flags for this infrared instance - uint32_t get_capability_flags() const; - - /// Called when IR data is received (from RemoteReceiverListener) - bool on_receive(remote_base::RemoteReceiveData data) override; - protected: - friend class InfraredCall; + friend class ir_rf_base::IrRfCall; - /// Perform the actual transmission (called by InfraredCall) - virtual void control(const InfraredCall &call); - - // Underlying hardware components - remote_base::RemoteReceiverBase *receiver_{nullptr}; - remote_base::RemoteTransmitterBase *transmitter_{nullptr}; + void on_call_(const InfraredCall &) {} + /// Perform the actual transmission (called by InfraredCall); false only when no frame was handed + /// to the transmitter, in which case no completion follows + /// Without a remote_base transmitter, call api_transmit_done_() once the frame is out + virtual bool control(const InfraredCall &call) { + return this->transmit_raw_(call, call.get_carrier_frequency().value_or(0)); + } // Traits describing capabilities InfraredTraits traits_; diff --git a/esphome/components/ir_rf_base/__init__.py b/esphome/components/ir_rf_base/__init__.py new file mode 100644 index 0000000000..55536c131b --- /dev/null +++ b/esphome/components/ir_rf_base/__init__.py @@ -0,0 +1,38 @@ +"""Shared base for the infrared and radio_frequency entity components.""" + +import esphome.codegen as cg +from esphome.components import remote_base +from esphome.const import CONF_API +from esphome.core import CORE +from esphome.core.entity_helpers import queue_entity_register +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81", "@bdraco"] +AUTO_LOAD = ["remote_base"] + +ir_rf_base_ns = cg.esphome_ns.namespace("ir_rf_base") +IrRfEntity = ir_rf_base_ns.class_("IrRfEntity", cg.EntityBase, cg.Component) + + +async def attach_transmitter(var: cg.MockObj, config: ConfigType, key: str) -> None: + """Link the configured transmitter to an entity. + + With the API configured this also compiles in the transmit completion + tracking that answers API transmit requests; the transmitter platform has + to report completion through notify_complete_(), as remote_transmitter + does. Without this call anywhere in the build a request is answered as + soon as the frame is handed over. + """ + await remote_base.register_transmittable(var, config, key) + if CONF_API in CORE.config: + cg.add_define("USE_IR_RF_TRANSMIT_COMPLETE") + + +async def register_ir_rf_entity( + var: cg.MockObj, config: ConfigType, domain: str +) -> None: + """Register an infrared or radio_frequency entity; USE_IR_RF covers both kinds.""" + cg.add_define("USE_IR_RF") + await cg.register_component(var, config) + queue_entity_register(domain, config) + CORE.register_platform_component(domain, var) diff --git a/esphome/components/ir_rf_base/ir_rf_base.cpp b/esphome/components/ir_rf_base/ir_rf_base.cpp new file mode 100644 index 0000000000..9aa845f269 --- /dev/null +++ b/esphome/components/ir_rf_base/ir_rf_base.cpp @@ -0,0 +1,237 @@ +#include "ir_rf_base.h" + +#include +#include + +#include "esphome/core/log.h" + +#ifdef USE_API +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_server.h" +#include "esphome/core/application.h" +#endif + +namespace esphome::ir_rf_base { + +static const char *const TAG = "ir_rf"; + +#if defined(USE_API) && defined(USE_IR_RF) +// A missing completion is answered as failed this long after the frame should have left the +// wire, and an owed reply the client never reads is dropped this long after the first refusal +static constexpr uint32_t API_REPLY_TIMEOUT_MS = 30000; +#endif +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +// Longest air time added to the deadline, in 16 ms ticks (8 min); with the 30 s above the +// deadline stays within the signed 16 bit tick window loop() compares against +static constexpr uint16_t API_REPLY_MAX_AIR_TICKS = 30000; +#endif + +#if defined(USE_API) && defined(USE_IR_RF) +static uint16_t api_reply_ticks_from_now() { + return static_cast((App.get_loop_component_start_time() >> 4) + (API_REPLY_TIMEOUT_MS >> 4)); +} +#endif + +void IrRfEntity::setup() { + // merged, not assigned: a platform may have set a flag for its own hardware before setup() + if (this->has_transmitter()) + this->supports_transmitter_ = true; + if (this->has_receiver()) + this->supports_receiver_ = true; +} + +bool IrRfEntity::transmit_raw_(const IrRfCallData &call, uint32_t carrier_frequency_hz) { + if (this->transmitter_ == nullptr) { + ESP_LOGW(TAG, "No transmitter configured"); + return false; + } + + if (!call.has_raw_timings()) { + ESP_LOGE(TAG, "No raw timings provided"); + return false; + } + + auto transmit_call = this->transmitter_->transmit(); + auto *transmit_data = transmit_call.get_data(); + transmit_data->set_carrier_frequency(carrier_frequency_hz); + + if (call.is_packed()) { + // Zero-copy from packed protobuf data + transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), + call.get_packed_count()); + ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), + call.get_repeat_count()); + } else if (call.is_base64url()) { + // 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 false; + } + constexpr int32_t max_timing_us = 500000; // 500ms absolute max + 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 false; + } + } + ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), + call.get_repeat_count()); + } else { + // From vector (lambdas/automations) + transmit_data->set_data(call.get_raw_timings()); + ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), + call.get_repeat_count()); + } + + // one answer for every backend: a frame that decoded to nothing is refused here + if (transmit_data->get_data().empty()) { + ESP_LOGE(TAG, "No raw timings provided"); + return false; + } + + if (call.get_repeat_count() > 0) { + transmit_call.set_send_times(call.get_repeat_count()); + } + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + // only the API frame expect_api_reply_() armed claims the seq and extends its deadline + if (call.wants_api_reply() && this->api_reply_ == ApiReply::API_REPLY_WAITING) { + this->inflight_seq_ = transmit_call.get_seq(); + // a long frame must not be answered as failed while still on the wire: the 30 s safety net + // starts after this frame's own air time (capped so the tick comparison cannot wrap) + uint64_t frame_us = 0; + for (const int64_t timing : transmit_data->get_data()) + frame_us += timing < 0 ? -timing : timing; + const uint64_t air_ticks = (frame_us * std::max(call.get_repeat_count(), 1) / 1000) >> 4; + this->api_reply_deadline_ += static_cast(std::min(air_ticks, API_REPLY_MAX_AIR_TICKS)); + } +#endif + transmit_call.perform(); + return true; +} + +bool IrRfEntity::on_receive(remote_base::RemoteReceiveData data) { +#if defined(USE_API) && defined(USE_IR_RF) + if (api::global_api_server != nullptr) { +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); + } +#endif + return false; // Don't consume the event, allow other listeners to process it +} + +#if defined(USE_API) && defined(USE_IR_RF) + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +void IrRfEntity::on_transmit_complete(remote_base::RemoteTransmitterBase *transmitter, uint16_t seq, bool sent) { + // only the frame this entity submitted; YAML automations and other entities share the transmitter + if (transmitter == this->transmitter_ && seq == this->inflight_seq_ && + this->api_reply_ == ApiReply::API_REPLY_WAITING) + this->finish_api_reply_(sent); +} +#endif + +bool IrRfEntity::expect_api_reply_(api::APIConnection *conn) { + // only an unpaced client gets here: its earlier request is answered as not started, and a reply + // the buffer still owes gets one last try; if that fails too the slot stays with it and the + // new request is refused rather than answered to nobody + if (this->api_reply_ == ApiReply::API_REPLY_WAITING) + this->finish_api_reply_(false); + if (this->api_reply_ != ApiReply::API_REPLY_NONE && !this->send_api_reply_()) { + ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("refused, reply still owed")); + this->refuse_api_call_(conn); + return false; + } + this->api_reply_connection_ = conn; + this->api_reply_deadline_ = api_reply_ticks_from_now(); + this->api_reply_ = ApiReply::API_REPLY_WAITING; + return true; +} + +void IrRfEntity::refuse_api_call_(api::APIConnection *conn) { + uint32_t device_id = 0; +#ifdef USE_DEVICES + device_id = this->get_device_id(); +#endif + // no slot to retry from; a drop here is covered by the client's own timeout + if (!conn->send_infrared_rf_transmit_complete(device_id, this->get_object_id_hash(), false)) { + API_LOG_MSG_DROPPED(TAG, "IR/RF reply"); + } +} + +void IrRfEntity::finish_api_reply_(bool success) { + this->api_reply_ = success ? ApiReply::API_REPLY_OWED_OK : ApiReply::API_REPLY_OWED_FAILED; + if (!this->send_api_reply_()) { + // the retry gets its own window + this->api_reply_deadline_ = api_reply_ticks_from_now(); + } +} + +bool IrRfEntity::send_api_reply_() { + uint32_t device_id = 0; +#ifdef USE_DEVICES + device_id = this->get_device_id(); +#endif + // Refused by a full TCP buffer: the reply stays owed and loop() retries it, since a lost + // reply would stall the client's pacing for good (same shape as bluetooth_proxy) + if (!this->api_reply_connection_->send_infrared_rf_transmit_complete(device_id, this->get_object_id_hash(), + this->api_reply_ == ApiReply::API_REPLY_OWED_OK)) + return false; + this->clear_api_reply_(); + return true; +} + +// Only runs while an API reply is pending: retries an owed one until it goes out or the client is +// gone, and expires a transmit that never reported (a platform overriding control() without wiring +// its transmitter's completion) +void IrRfEntity::loop() { + if (this->api_reply_ == ApiReply::API_REPLY_NONE) { + this->disable_loop(); + return; + } + const auto remaining = static_cast(this->api_reply_deadline_ - (App.get_loop_component_start_time() >> 4)); + if (this->api_reply_ != ApiReply::API_REPLY_WAITING) { + if (this->send_api_reply_() || remaining > 0) + return; + ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("reply dropped, client not reading")); + this->clear_api_reply_(); + return; + } + if (remaining > 0) + return; + ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("never reported completion")); + this->finish_api_reply_(false); +} + +void IrRfEntity::on_api_connection_closed(api::APIConnection *conn) { + if (this->api_reply_connection_ == conn) { + this->clear_api_reply_(); + } +} +#endif + +} // namespace esphome::ir_rf_base + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +namespace esphome::remote_base { + +void ir_rf_transmit_complete(RemoteTransmitterBase *transmitter, uint16_t seq, bool sent) { +#ifdef USE_INFRARED + for (auto *entity : App.get_infrareds()) { + entity->on_transmit_complete(transmitter, seq, sent); + } +#endif +#ifdef USE_RADIO_FREQUENCY + for (auto *entity : App.get_radio_frequencies()) { + entity->on_transmit_complete(transmitter, seq, sent); + } +#endif +} + +} // namespace esphome::remote_base +#endif diff --git a/esphome/components/ir_rf_base/ir_rf_base.h b/esphome/components/ir_rf_base/ir_rf_base.h new file mode 100644 index 0000000000..c6dc877696 --- /dev/null +++ b/esphome/components/ir_rf_base/ir_rf_base.h @@ -0,0 +1,273 @@ +#pragma once + +// WARNING: This component is EXPERIMENTAL. The API may change at any time +// without following the normal breaking changes policy. Use at your own risk. +// Once the API is considered stable, this warning will be removed. + +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" +#include "esphome/core/helpers.h" +#include "esphome/components/remote_base/remote_base.h" + +#include +#include + +#if defined(USE_API) && defined(USE_IR_RF) +namespace esphome::api { +class APIConnection; +} // namespace esphome::api +#endif + +namespace esphome::ir_rf_base { + +/// Capability flags reported by infrared and radio frequency entities +enum IrRfCapability : uint32_t { + CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals + CAPABILITY_RECEIVER = 1 << 1, // Can receive signals +}; + +/// Raw timings of a transmit call, in one of three caller-owned forms +class IrRfCallData { + public: + /// Get the raw timings (only valid if set via set_raw_timings) + const std::vector &get_raw_timings() const { return *this->raw_timings_; } + /// Check if raw timings have been set (any format) + bool has_raw_timings() const { + return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; + } + /// Check if using packed data format + bool is_packed() const { return this->packed_data_ != nullptr; } + /// Check if using base64url data format + bool is_base64url() const { return this->base64url_ptr_ != nullptr; } + /// Get the base64url data string + const std::string &get_base64url_data() const { return *this->base64url_ptr_; } + /// Get packed data (only valid if set via set_raw_timings_packed) + const uint8_t *get_packed_data() const { return this->packed_data_; } + uint16_t get_packed_length() const { return this->packed_length_; } + uint16_t get_packed_count() const { return this->packed_count_; } + /// Get the repeat count + uint32_t get_repeat_count() const { return this->repeat_count_; } + +#if defined(USE_API) && defined(USE_IR_RF) + /// True for a frame an API client is waiting on + bool wants_api_reply() const { return this->api_connection_ != nullptr; } +#endif + + protected: + uint32_t repeat_count_{1}; +#if defined(USE_API) && defined(USE_IR_RF) + api::APIConnection *api_connection_{nullptr}; +#endif + // Pointer to vector-based timings (caller-owned, must outlive perform()) + const std::vector *raw_timings_{nullptr}; + // Pointer to base64url-encoded string (caller-owned, must outlive perform()) + const std::string *base64url_ptr_{nullptr}; + // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) + const uint8_t *packed_data_{nullptr}; + uint16_t packed_length_{0}; + uint16_t packed_count_{0}; +}; + +template class IrRfCall; + +/// Everything an infrared or radio frequency entity does that does not depend on the medium: +/// the remote_base transport, forwarding received frames to the API, and answering the API +/// once a transmit it started has left the transmitter. +class IrRfEntity : public Component, public EntityBase, public remote_base::RemoteReceiverListener { + public: + /// Reports the configured transports, listens on the receiver and hooks the transmitter's + /// completion; a platform with its own setup() calls it first + void setup() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + /// Set the remote receiver component; the listener registration happens from codegen, see + /// remote_base.attach_receiver + void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } + /// Set the remote transmitter component + void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } + bool has_transmitter() const { return this->transmitter_ != nullptr; } + bool has_receiver() const { return this->receiver_ != nullptr; } + + /// What the entity can do; platforms with their own hardware set these from their own + /// setup(), remote_base ones get them from IrRfEntity::setup() + bool get_supports_transmitter() const { return this->supports_transmitter_; } + void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } + bool get_supports_receiver() const { return this->supports_receiver_; } + void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } + /// Capability flags as reported to the API and web server + uint32_t get_capability_flags() const { + uint32_t flags = 0; + if (this->supports_transmitter_) + flags |= CAPABILITY_TRANSMITTER; + if (this->supports_receiver_) + flags |= CAPABILITY_RECEIVER; + return flags; + } + + /// Forwards a received frame to the API; never consumes it, so other listeners still run + bool on_receive(remote_base::RemoteReceiveData data) override; +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + /// Called for every finished frame on every transmitter; answers the API request when the + /// frame is the one this entity submitted for it, with sent as the outcome + void on_transmit_complete(remote_base::RemoteTransmitterBase *transmitter, uint16_t seq, bool sent); +#endif + +#if defined(USE_API) && defined(USE_IR_RF) + void loop() override; + /// The API server calls this when a client disconnects, so no reply goes to a stale pointer + void on_api_connection_closed(api::APIConnection *conn); +#endif + + protected: + template friend class IrRfCall; + + /// Hands the call's timings to the transmitter; the default transmit path of both entity types + bool transmit_raw_(const IrRfCallData &call, uint32_t carrier_frequency_hz); +#if defined(USE_API) && defined(USE_IR_RF) + // One reply slot: a pacing client has at most one transmit outstanding, and a second request + // from an unpaced client displaces the first. Retried and expired from loop(), which only + // runs while a reply is pending; polling the expiry there costs nothing while idle, where a + // scheduler timeout would allocate per frame. + enum class ApiReply : uint8_t { + API_REPLY_NONE, + API_REPLY_WAITING, // frame handed to the transmitter, completion not reported yet + API_REPLY_OWED_OK, // reply refused by a full TCP buffer; loop() retries it + API_REPLY_OWED_FAILED // same, for a transmit that did not start + }; + /// Claims the reply slot for conn; false when it still owes a reply that cannot be sent + bool expect_api_reply_(api::APIConnection *conn); + void refuse_api_call_(api::APIConnection *conn); + /// After control(): a false start is answered now, and the loop only runs once something is + /// pending, since a blocking transmitter has already answered inside control() + void settle_api_reply_(bool started) { +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + if (!started) + this->finish_api_reply_(false); +#else + // no transmitter in this build reports completion, so the hand-over is the answer + this->finish_api_reply_(started); +#endif + if (this->api_reply_ != ApiReply::API_REPLY_NONE) + this->enable_loop(); + } + void finish_api_reply_(bool success); + /// Answers the API request for a control() override that transmits without a remote_base transmitter + void api_transmit_done_(bool sent) { + if (this->api_reply_ == ApiReply::API_REPLY_WAITING) + this->finish_api_reply_(sent); + } + bool send_api_reply_(); + void clear_api_reply_() { + this->api_reply_ = ApiReply::API_REPLY_NONE; + this->api_reply_connection_ = nullptr; + this->disable_loop(); + } + api::APIConnection *api_reply_connection_{nullptr}; +#endif + + remote_base::RemoteReceiverBase *receiver_{nullptr}; + remote_base::RemoteTransmitterBase *transmitter_{nullptr}; +#if defined(USE_API) && defined(USE_IR_RF) + // 16 ms ticks: 30 s for completion (plus capped air time) or for delivering an owed reply + uint16_t api_reply_deadline_{0}; +#endif +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + uint16_t inflight_seq_{0}; // seq of the API frame this entity submitted last +#endif + // short members last, so the derived traits start on the next word without a gap + bool supports_transmitter_{false}; + bool supports_receiver_{false}; +#if defined(USE_API) && defined(USE_IR_RF) + ApiReply api_reply_{ApiReply::API_REPLY_NONE}; +#endif +}; + +/// Builder for a transmit; Call is the concrete call type and Entity its entity, so the fluent +/// setters return the concrete type and control() sees the medium specific fields +template class IrRfCall : public IrRfCallData { + // only the concrete call may construct the CRTP base + friend Call; + explicit IrRfCall(Entity *parent) : parent_(parent) {} + + public: + // ===== Raw Timings Methods ===== + // All set_raw_timings_* methods store pointers/references to external data. + // The referenced data must remain valid until perform() completes. + // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous + // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! + + /// Set the raw timings from a vector (positive = mark, negative = space) + /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). + /// @note Usage: Primarily for lambdas/automations where the vector is in scope. + Call &set_raw_timings(const std::vector &timings) { + this->raw_timings_ = &timings; + this->packed_data_ = nullptr; + this->base64url_ptr_ = nullptr; + return this->self_(); + } + + /// Set the raw timings from base64url-encoded little-endian int32 data + /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). + /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). + /// @note Decoding happens at perform() time, directly into the transmit buffer. + Call &set_raw_timings_base64url(const std::string &base64url) { + this->base64url_ptr_ = &base64url; + this->raw_timings_ = nullptr; + this->packed_data_ = nullptr; + return this->self_(); + } + + /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) + /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). + /// @note Usage: For API component where data comes directly from the protobuf message. + Call &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { + this->packed_data_ = data; + this->packed_length_ = length; + this->packed_count_ = count; + this->raw_timings_ = nullptr; + this->base64url_ptr_ = nullptr; + return this->self_(); + } + + /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) + Call &set_repeat_count(uint32_t count) { + this->repeat_count_ = count; + return this->self_(); + } + +#if defined(USE_API) && defined(USE_IR_RF) + /// Reply to this API client once the frame has left the transmitter (API 1.18+) + Call &set_api_connection(api::APIConnection *conn) { + this->api_connection_ = conn; + return this->self_(); + } +#endif + + /// Perform the transmission; returns true if a frame was handed to the transmitter + bool perform() { + // make_call() always sets the parent + Entity *parent = this->parent_; + if (parent == nullptr) + return false; + parent->on_call_(this->self_()); +#if defined(USE_API) && defined(USE_IR_RF) + // Before control(): blocking transmitters report completion from inside it, and the + // non-blocking RMT path flushes the previous frame's completion there + if (this->api_connection_ != nullptr && !parent->expect_api_reply_(this->api_connection_)) + return false; +#endif + const bool started = parent->control(this->self_()); +#if defined(USE_API) && defined(USE_IR_RF) + if (this->api_connection_ != nullptr) + parent->settle_api_reply_(started); +#endif + return started; + } + + protected: + Call &self_() { return static_cast(*this); } + + Entity *parent_; +}; + +} // namespace esphome::ir_rf_base diff --git a/esphome/components/ir_rf_proxy/infrared.py b/esphome/components/ir_rf_proxy/infrared.py index 288bd91673..548ba2b132 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -3,12 +3,7 @@ from typing import Any import esphome.codegen as cg -from esphome.components import ( - infrared, - remote_base, - remote_receiver, - remote_transmitter, -) +from esphome.components import infrared, ir_rf_base, remote_base, remote_receiver from esphome.components.const import CONF_RECEIVER_FREQUENCY import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY @@ -30,7 +25,7 @@ CONFIG_SCHEMA = cv.All( remote_receiver.RemoteReceiverComponent ), cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( - remote_transmitter.RemoteTransmitterComponent + remote_base.RemoteTransmitterBase ), } ), @@ -82,8 +77,7 @@ async def to_code(config: dict[str, Any]) -> None: # Link transmitter if specified if CONF_REMOTE_TRANSMITTER_ID in config: - transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) + await ir_rf_base.attach_transmitter(var, config, CONF_REMOTE_TRANSMITTER_ID) # Link receiver if specified if CONF_REMOTE_RECEIVER_ID in config: diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index ceb4c9a67c..88b1560608 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -8,70 +8,17 @@ namespace esphome::ir_rf_proxy { static const char *const TAG = "ir_rf_proxy"; -// ========== Shared transmit helper ========== -// Static template: all instantiations occur in this translation unit. - -template -static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency, - const CallT &call) { - if (transmitter == nullptr) { - ESP_LOGW(TAG, "No transmitter configured"); - return; - } - - if (!call.has_raw_timings()) { - ESP_LOGE(TAG, "No raw timings provided"); - return; - } - - auto transmit_call = transmitter->transmit(); - auto *transmit_data = transmit_call.get_data(); - transmit_data->set_carrier_frequency(carrier_frequency); - - if (call.is_packed()) { - transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), - call.get_packed_count()); - ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), - call.get_repeat_count()); - } else if (call.is_base64url()) { - if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { - ESP_LOGE(TAG, "Invalid base64url data"); - return; - } - constexpr int32_t max_timing_us = 500000; - for (int32_t timing : transmit_data->get_data()) { - int32_t abs_timing = timing < 0 ? -timing : timing; - if (abs_timing > max_timing_us) { - ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); - return; - } - } - ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), - call.get_repeat_count()); - } else { - transmit_data->set_data(call.get_raw_timings()); - ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), - call.get_repeat_count()); - } - - if (call.get_repeat_count() > 0) { - transmit_call.set_send_times(call.get_repeat_count()); - } - - transmit_call.perform(); -} - // ========== IrRfProxy (Infrared platform) ========== -#ifdef USE_IR_RF +#ifdef USE_INFRARED void IrRfProxy::dump_config() { ESP_LOGCONFIG(TAG, "IR Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); if (this->is_rf()) { ESP_LOGCONFIG(TAG, " Hardware Type: RF (%.3f MHz)", this->frequency_khz_ / 1e3f); @@ -80,21 +27,14 @@ void IrRfProxy::dump_config() { } } -void IrRfProxy::control(const infrared::InfraredCall &call) { - uint32_t carrier = call.get_carrier_frequency().value_or(0); - transmit_raw_timings(this->transmitter_, carrier, call); -} - -#endif // USE_IR_RF +#endif // USE_INFRARED // ========== RfProxy (Radio Frequency platform) ========== #ifdef USE_RADIO_FREQUENCY void RfProxy::setup() { - this->traits_.set_supports_transmitter(this->transmitter_ != nullptr); - this->traits_.set_supports_receiver(this->receiver_ != nullptr); - + ir_rf_base::IrRfEntity::setup(); // remote_transmitter/receiver always uses OOK (on-off keying) this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); } @@ -104,8 +44,8 @@ void RfProxy::dump_config() { "RF Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); const auto &traits = this->traits_; if (traits.get_frequency_min_hz() > 0) { @@ -118,11 +58,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 this->transmit_raw_(call, 0); } #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 1aa4394fe8..a45b0ac472 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -6,7 +6,7 @@ #include "esphome/components/remote_base/remote_base.h" -#ifdef USE_IR_RF +#ifdef USE_INFRARED #include "esphome/components/infrared/infrared.h" #endif @@ -16,7 +16,7 @@ namespace esphome::ir_rf_proxy { -#ifdef USE_IR_RF +#ifdef USE_INFRARED /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend class IrRfProxy final : public infrared::Infrared { public: @@ -35,12 +35,10 @@ 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; - // RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF uint32_t frequency_khz_{0}; }; -#endif // USE_IR_RF +#endif // USE_INFRARED #ifdef USE_RADIO_FREQUENCY /// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend. @@ -54,20 +52,11 @@ class RfProxy final : public radio_frequency::RadioFrequency { void setup() override; void dump_config() override; - /// Set the remote transmitter component - void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - /// Set the remote receiver component; the listener registration happens from codegen, see - /// remote_base.attach_receiver - void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } - /// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware) void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); } protected: - void control(const radio_frequency::RadioFrequencyCall &call) override; - - remote_base::RemoteTransmitterBase *transmitter_{nullptr}; - remote_base::RemoteReceiverBase *receiver_{nullptr}; + bool control(const radio_frequency::RadioFrequencyCall &call) override; }; #endif // USE_RADIO_FREQUENCY diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py index 28b8fd5953..1519fe9908 100644 --- a/esphome/components/ir_rf_proxy/radio_frequency.py +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -1,12 +1,7 @@ """Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" import esphome.codegen as cg -from esphome.components import ( - radio_frequency, - remote_base, - remote_receiver, - remote_transmitter, -) +from esphome.components import ir_rf_base, radio_frequency, remote_base, remote_receiver import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY import esphome.final_validate as fv @@ -27,7 +22,7 @@ CONFIG_SCHEMA = cv.All( remote_receiver.RemoteReceiverComponent ), cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( - remote_transmitter.RemoteTransmitterComponent + remote_base.RemoteTransmitterBase ), } ), @@ -67,8 +62,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY]))) if CONF_REMOTE_TRANSMITTER_ID in config: - transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) + await ir_rf_base.attach_transmitter(var, config, CONF_REMOTE_TRANSMITTER_ID) if CONF_REMOTE_RECEIVER_ID in config: await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py index f303a14297..ebba97d1a3 100644 --- a/esphome/components/radio_frequency/__init__.py +++ b/esphome/components/radio_frequency/__init__.py @@ -10,22 +10,21 @@ Once the API is considered stable, this warning will be removed. from esphome import automation import esphome.codegen as cg +from esphome.components import ir_rf_base import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_CONTROL -from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import queue_entity_register, setup_entity +from esphome.core import coroutine_with_priority +from esphome.core.entity_helpers import setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@kbx81"] -AUTO_LOAD = ["remote_base"] +AUTO_LOAD = ["ir_rf_base"] IS_PLATFORM_COMPONENT = True radio_frequency_ns = cg.esphome_ns.namespace("radio_frequency") -RadioFrequency = radio_frequency_ns.class_( - "RadioFrequency", cg.EntityBase, cg.Component -) +RadioFrequency = radio_frequency_ns.class_("RadioFrequency", ir_rf_base.IrRfEntity) RadioFrequencyCall = radio_frequency_ns.class_("RadioFrequencyCall") RadioFrequencyTraits = radio_frequency_ns.class_("RadioFrequencyTraits") RadioFrequencyModulation = radio_frequency_ns.enum("RadioFrequencyModulation") @@ -55,11 +54,8 @@ async def setup_radio_frequency_core_(var: cg.MockObj, config: ConfigType) -> No async def register_radio_frequency(var: cg.MockObj, config: ConfigType) -> None: """Register a radio frequency device with the core.""" - cg.add_define("USE_RADIO_FREQUENCY") - await cg.register_component(var, config) - queue_entity_register("radio_frequency", config) + await ir_rf_base.register_ir_rf_entity(var, config, "radio_frequency") await setup_radio_frequency_core_(var, config) - CORE.register_platform_component("radio_frequency", var) for conf in config.get(CONF_ON_CONTROL, []): await automation.build_callback_automation( diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index 61e7feb9af..a238603217 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -4,73 +4,17 @@ #include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/api_server.h" -#endif - namespace esphome::radio_frequency { static const char *const TAG = "radio_frequency"; -// ========== RadioFrequencyCall ========== - -RadioFrequencyCall &RadioFrequencyCall::set_frequency(uint32_t frequency_hz) { - this->frequency_hz_ = frequency_hz; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_modulation(RadioFrequencyModulation modulation) { - this->modulation_ = modulation; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_raw_timings(const std::vector &timings) { - this->raw_timings_ = &timings; - this->packed_data_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_base64url(const std::string &base64url) { - this->base64url_ptr_ = &base64url; - this->raw_timings_ = nullptr; - this->packed_data_ = nullptr; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { - this->packed_data_ = data; - this->packed_length_ = length; - this->packed_count_ = count; - this->raw_timings_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) { - this->repeat_count_ = 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); - } -} - -// ========== RadioFrequency ========== - void RadioFrequency::dump_config() { ESP_LOGCONFIG(TAG, "Radio Frequency '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); if (this->traits_.get_frequency_min_hz() > 0) { if (this->traits_.get_frequency_min_hz() == this->traits_.get_frequency_max_hz()) { ESP_LOGCONFIG(TAG, " Frequency: %" PRIu32 " Hz (fixed)", this->traits_.get_frequency_min_hz()); @@ -81,31 +25,9 @@ void RadioFrequency::dump_config() { } } -uint32_t RadioFrequency::get_capability_flags() const { - uint32_t flags = 0; - if (this->traits_.get_supports_transmitter()) - flags |= RadioFrequencyCapability::CAPABILITY_TRANSMITTER; - if (this->traits_.get_supports_receiver()) - flags |= RadioFrequencyCapability::CAPABILITY_RECEIVER; - return flags; -} - bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { - // Invoke local callbacks this->receive_callback_.call(data); - - // Forward received RF data to API server -#if defined(USE_API) && defined(USE_RADIO_FREQUENCY) - if (api::global_api_server != nullptr) { -#ifdef USE_DEVICES - uint32_t device_id = this->get_device_id(); -#else - uint32_t device_id = 0; -#endif - api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); - } -#endif - return false; // Don't consume the event, allow other listeners to process it + return IrRfEntity::on_receive(data); } } // namespace esphome::radio_frequency diff --git a/esphome/components/radio_frequency/radio_frequency.h b/esphome/components/radio_frequency/radio_frequency.h index 8782c255f0..d084a6c016 100644 --- a/esphome/components/radio_frequency/radio_frequency.h +++ b/esphome/components/radio_frequency/radio_frequency.h @@ -4,20 +4,12 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. -#include "esphome/core/component.h" -#include "esphome/core/entity_base.h" -#include "esphome/core/helpers.h" -#include "esphome/components/remote_base/remote_base.h" - -#include +#include "esphome/components/ir_rf_base/ir_rf_base.h" namespace esphome::radio_frequency { -/// Capability flags for individual radio frequency instances -enum RadioFrequencyCapability : uint32_t { - CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals - CAPABILITY_RECEIVER = 1 << 1, // Can receive signals -}; +using ir_rf_base::CAPABILITY_RECEIVER; +using ir_rf_base::CAPABILITY_TRANSMITTER; /// Modulation types supported by radio frequency implementations enum RadioFrequencyModulation : uint8_t { @@ -25,95 +17,36 @@ enum RadioFrequencyModulation : uint8_t { // Future: RADIO_FREQUENCY_MODULATION_FSK, RADIO_FREQUENCY_MODULATION_GFSK, etc. }; -/// Forward declarations class RadioFrequency; /// RadioFrequencyCall - Builder pattern for transmitting radio frequency signals -class RadioFrequencyCall { +class RadioFrequencyCall : public ir_rf_base::IrRfCall { public: - explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {} + explicit RadioFrequencyCall(RadioFrequency *parent) : IrRfCall(parent) {} /// Set the carrier frequency in Hz (e.g. 433920000 for 433.92 MHz) - RadioFrequencyCall &set_frequency(uint32_t frequency_hz); - + RadioFrequencyCall &set_frequency(uint32_t frequency_hz) { + this->frequency_hz_ = frequency_hz; + return *this; + } /// Set the modulation type (defaults to OOK) - RadioFrequencyCall &set_modulation(RadioFrequencyModulation modulation); - - // ===== Raw Timings Methods ===== - // All set_raw_timings_* methods store pointers/references to external data. - // The referenced data must remain valid until perform() completes. - // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous - // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! - - /// Set the raw timings from a vector (positive = mark, negative = space) - /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). - /// @note Usage: Primarily for lambdas/automations where the vector is in scope. - RadioFrequencyCall &set_raw_timings(const std::vector &timings); - - /// Set the raw timings from base64url-encoded little-endian int32 data - /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). - /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). - /// @note Decoding happens at perform() time, directly into the transmit buffer. - RadioFrequencyCall &set_raw_timings_base64url(const std::string &base64url); - - /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) - /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). - /// @note Usage: For API component where data comes directly from the protobuf message. - RadioFrequencyCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count); - - /// 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(); - + RadioFrequencyCall &set_modulation(RadioFrequencyModulation modulation) { + this->modulation_ = modulation; + return *this; + } /// Get the frequency in Hz const optional &get_frequency() const { return this->frequency_hz_; } /// Get the modulation type RadioFrequencyModulation get_modulation() const { return this->modulation_; } - /// Get the raw timings (only valid if set via set_raw_timings) - const std::vector &get_raw_timings() const { return *this->raw_timings_; } - /// Check if raw timings have been set (any format) - bool has_raw_timings() const { - return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; - } - /// Check if using packed data format - bool is_packed() const { return this->packed_data_ != nullptr; } - /// Check if using base64url data format - bool is_base64url() const { return this->base64url_ptr_ != nullptr; } - /// Get the base64url data string - const std::string &get_base64url_data() const { return *this->base64url_ptr_; } - /// Get packed data (only valid if set via set_raw_timings_packed) - const uint8_t *get_packed_data() const { return this->packed_data_; } - uint16_t get_packed_length() const { return this->packed_length_; } - uint16_t get_packed_count() const { return this->packed_count_; } - /// Get the repeat count - uint32_t get_repeat_count() const { return this->repeat_count_; } protected: optional frequency_hz_{}; - uint32_t repeat_count_{1}; - RadioFrequency *parent_; - // Pointer to vector-based timings (caller-owned, must outlive perform()) - const std::vector *raw_timings_{nullptr}; - // Pointer to base64url-encoded string (caller-owned, must outlive perform()) - const std::string *base64url_ptr_{nullptr}; - // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) - const uint8_t *packed_data_{nullptr}; - uint16_t packed_length_{0}; - uint16_t packed_count_{0}; RadioFrequencyModulation modulation_{RADIO_FREQUENCY_MODULATION_OOK}; }; /// RadioFrequencyTraits - Describes the capabilities of a radio frequency implementation class RadioFrequencyTraits { public: - bool get_supports_transmitter() const { return this->supports_transmitter_; } - void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } - - bool get_supports_receiver() const { return this->supports_receiver_; } - void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } - /// Hardware-supported tunable frequency range in Hz. /// If min == max (and both non-zero): fixed-frequency hardware. /// If both 0: range unspecified. @@ -140,17 +73,14 @@ class RadioFrequencyTraits { uint32_t frequency_min_hz_{0}; // Minimum tunable frequency in Hz (0 = unspecified) uint32_t frequency_max_hz_{0}; // Maximum tunable frequency in Hz (0 = unspecified) uint32_t supported_modulations_{0}; // Bitmask of supported RadioFrequencyModulation values - bool supports_transmitter_{false}; - bool supports_receiver_{false}; }; /// RadioFrequency - Base class for radio frequency implementations -class RadioFrequency : public Component, public EntityBase, public remote_base::RemoteReceiverListener { +class RadioFrequency : public ir_rf_base::IrRfEntity { public: RadioFrequency() = default; void dump_config() override; - float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } /// Get the traits for this radio frequency implementation RadioFrequencyTraits &get_traits() { return this->traits_; } @@ -159,9 +89,6 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: /// Create a call object for transmitting RadioFrequencyCall make_call() { return RadioFrequencyCall(this); } - /// Get capability flags for this radio frequency instance - uint32_t get_capability_flags() const; - /// Called when RF data is received (from RemoteReceiverListener) bool on_receive(remote_base::RemoteReceiveData data) override; @@ -180,11 +107,16 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: } protected: - friend class RadioFrequencyCall; + friend class ir_rf_base::IrRfCall; + /// Fires the on_control hooks before the platform-specific control() runs + void on_call_(const RadioFrequencyCall &call) { this->control_callback_.call(call); } /// 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 only when no frame was handed to the transmitter, in which case no + /// completion follows. + /// Without a remote_base transmitter, call api_transmit_done_() once the frame is out + virtual bool control(const RadioFrequencyCall &call) = 0; // Traits describing capabilities RadioFrequencyTraits traits_; diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 5d615f0edf..e9708d0a9f 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -136,8 +136,8 @@ async def attach_receiver( add_listener(receiver, var) -async def register_transmittable(var, config): - transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) +async def register_transmittable(var, config, key: str = CONF_TRANSMITTER_ID): + transmitter_ = await cg.get_variable(config[key]) cg.add(var.set_transmitter(transmitter_)) diff --git a/esphome/components/remote_base/dish_protocol.cpp b/esphome/components/remote_base/dish_protocol.cpp index 9a6420afd5..5a89c91a50 100644 --- a/esphome/components/remote_base/dish_protocol.cpp +++ b/esphome/components/remote_base/dish_protocol.cpp @@ -20,7 +20,7 @@ void DishProtocol::encode(RemoteTransmitData *dst, const DishData &data) { // Typically a DISH device needs to get a command a total of // at least 4 times to accept it. - for (uint i = 0; i < 4; i++) { + for (uint32_t i = 0; i < 4; i++) { // COMMAND (function, in MSB) for (uint8_t mask = 1UL << 5; mask; mask >>= 1) { if (data.command & mask) { @@ -39,7 +39,7 @@ void DishProtocol::encode(RemoteTransmitData *dst, const DishData &data) { } } // PADDING - for (uint j = 0; j < 6; j++) + for (uint32_t j = 0; j < 6; j++) dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); // FOOTER @@ -73,7 +73,7 @@ optional DishProtocol::decode(RemoteReceiveData src) { return {}; } } - for (uint j = 0; j < 6; j++) { + for (uint32_t j = 0; j < 6; j++) { if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { return {}; } diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 5d1bba16b6..a685b733b4 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -182,7 +182,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, [[maybe_unused]] uint16_t seq) { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE const auto &vec = this->temp_.get_data(); char buffer[256]; @@ -213,6 +213,10 @@ void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) { if (pos != 0) { ESP_LOGVV(TAG, "%s", buffer); } +#endif + this->flush_pending_completion(); +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + this->current_seq_ = seq; #endif this->send_internal(send_times, send_wait); } diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 67e5799bca..36d2a44c50 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -143,6 +143,14 @@ class RemoteRMTChannel { #endif // SOC_RMT_SUPPORTED #endif // USE_ESP32 +class RemoteTransmitterBase; + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +/// Defined by ir_rf_base: answers the API request waiting on the entity that submitted seq; +/// sent is false when the platform never put the frame on the wire. +/// One function for the whole build instead of a callback list on every transmitter. +void ir_rf_transmit_complete(RemoteTransmitterBase *transmitter, uint16_t seq, bool sent); +#endif // Protocol shapes, checked where a protocol is used so a missing method fails at the use site // instead of deep inside a template body. Receive-only protocols such as RCSwitchBase decode // without encoding. @@ -164,21 +172,24 @@ class RemoteTransmitterBase : public RemoteComponentBase { RemoteTransmitterBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} class TransmitCall { public: - explicit TransmitCall(RemoteTransmitterBase *parent) : parent_(parent) {} + TransmitCall(RemoteTransmitterBase *parent, uint16_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 hook + uint16_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}; + uint16_t seq_; }; TransmitCall transmit() { this->temp_.reset(); - return TransmitCall(this); + return TransmitCall(this, this->take_seq_()); } template void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { @@ -190,9 +201,25 @@ class RemoteTransmitterBase : public RemoteComponentBase { } protected: - void send_(uint32_t send_times, uint32_t send_wait); + void send_(uint32_t send_times, uint32_t send_wait, uint16_t seq); virtual void send_internal(uint32_t send_times, uint32_t send_wait) = 0; - void send_single_() { this->send_(1, 0); } + /// Platforms that report completion later wait out the previous frame here, before send_() + /// assigns the next seq, so that completion carries the seq it belongs to + virtual void flush_pending_completion() {} + void send_single_() { this->send_(1, 0, this->take_seq_()); } +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + /// Reports the frame handed to the platform last, after its final repeat and before the + /// on_complete trigger; a seq only has to be unique within the 30 s reply window + void notify_complete_(bool sent) { ir_rf_transmit_complete(this, this->current_seq_, sent); } + uint16_t take_seq_() { return ++this->next_seq_; } + + uint16_t next_seq_{0}; + uint16_t current_seq_{0}; +#else + // seq tracking only exists for the API completion reply + void notify_complete_(bool /*sent*/) {} + static uint16_t take_seq_() { return 0; } +#endif /// 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..29ef9bc2cc 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -114,7 +114,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } } } - this->complete_trigger_.trigger(); + this->fire_complete_(send_times != 0); // same answer as the ISR backend for a frame sent zero times } } // namespace esphome::remote_transmitter diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 99e1ce9504..fad273a118 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -82,6 +82,16 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; + // the API reply is answered before the user's on_complete automation; sent is false on a + // bail-out that never put the frame on the wire, on_complete fires either way + void fire_complete_(bool sent = true) { + this->notify_complete_(sent); + this->complete_trigger_.trigger(); + } +#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || \ + defined(REMOTE_TRANSMITTER_BK_PWM) + void flush_pending_completion() override; +#endif #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..26e9a1baa3 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_(!this->stall_aborted_); } // Waits until no chain is in flight, delivering any deferred completions; a completion @@ -152,20 +152,21 @@ void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_w this->start_isr_item_(0); } +void RemoteTransmitterComponent::flush_pending_completion() { this->wait_until_idle_(); } + 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_(); + this->fire_complete_(false); 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_(); + this->fire_complete_(false); return; } ESP_LOGD(TAG, "Sending remote code"); @@ -175,7 +176,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if (this->isr_data_.empty()) { ESP_LOGW(TAG, "Empty data"); this->transmit_trigger_.trigger(); - this->deliver_completion_(); + this->fire_complete_(false); return; } // trigger first: the deadline computed in arm_chain_ must not be charged for user code diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 6d27be8d47..91bd339f21 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -200,25 +200,35 @@ void RemoteTransmitterComponent::wait_for_rmt_() { this->status_set_warning(); } - this->complete_trigger_.trigger(); + this->fire_complete_(error == ESP_OK); } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) +void RemoteTransmitterComponent::flush_pending_completion() { + // a frame still on the wire is waited out, and its completion reported, before the next one + if (this->non_blocking_ && this->cancel_timeout("complete")) { + this->wait_for_rmt_(); + } +} + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { uint64_t total_duration = 0; if (this->is_failed()) { + // both triggers still fire, so a paced API client or on_complete automation is not left waiting + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } - // if the timeout was cancelled, block until the tx is complete - if (this->non_blocking_ && this->cancel_timeout("complete")) { - this->wait_for_rmt_(); - } - if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); this->configure_rmt_(); + if (this->is_failed()) { // the carrier change failed, there is no channel to send on + this->transmit_trigger_.trigger(); + this->fire_complete_(false); + return; + } } this->rmt_temp_.clear(); @@ -258,6 +268,8 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.size() <= offset) { ESP_LOGE(TAG, "Empty data"); + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } @@ -273,9 +285,11 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if (error != ESP_OK) { ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); this->status_set_warning(); - } else { - this->status_clear_warning(); + // nothing was queued, so there is no frame to wait for + this->fire_complete_(false); + return; } + this->status_clear_warning(); if (this->non_blocking_) { this->set_timeout("complete", total_duration / 1000, [this]() { this->wait_for_rmt_(); }); @@ -284,13 +298,23 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } } #else +void RemoteTransmitterComponent::flush_pending_completion() {} + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { - if (this->is_failed()) + if (this->is_failed()) { + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; + } if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); this->configure_rmt_(); + if (this->is_failed()) { // the carrier change failed, there is no channel to send on + this->transmit_trigger_.trigger(); + this->fire_complete_(false); + return; + } } this->rmt_temp_.clear(); @@ -328,9 +352,12 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.empty()) { ESP_LOGE(TAG, "Empty data"); + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } this->transmit_trigger_.trigger(); + bool sent = send_times != 0; // same answer as the ISR backend for a frame sent zero times for (uint32_t i = 0; i < send_times; i++) { rmt_transmit_config_t config; memset(&config, 0, sizeof(config)); @@ -340,18 +367,21 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if (error != ESP_OK) { ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); this->status_set_warning(); - } else { - this->status_clear_warning(); + sent = false; } error = rmt_tx_wait_all_done(this->channel_, -1); if (error != ESP_OK) { ESP_LOGW(TAG, "rmt_tx_wait_all_done failed: %s", esp_err_to_name(error)); this->status_set_warning(); + sent = false; } if (i + 1 < send_times) delayMicroseconds(send_wait); } - this->complete_trigger_.trigger(); + // a later repeat must not clear the warning a failed one raised + if (sent) + this->status_clear_warning(); + this->fire_complete_(sent); } #endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp index 6db9faac36..799f46401c 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -146,6 +146,8 @@ void RemoteTransmitterComponent::await_target_time_() { void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { if (this->pwm_ == nullptr) { ESP_LOGW(TAG, "Cannot send: PWM not initialized"); + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } ESP_LOGD(TAG, "Sending remote code"); @@ -194,7 +196,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/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 90c6b167f6..fde86502d4 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2080,10 +2080,8 @@ void WebServer::infrared_json_(infrared::Infrared *obj, JsonDetail start_config, set_json_icon_state_value(root, obj, "infrared", "", 0, start_config); - auto traits = obj->get_traits(); - - root[ESPHOME_F("supports_transmitter")] = traits.get_supports_transmitter(); - root[ESPHOME_F("supports_receiver")] = traits.get_supports_receiver(); + root[ESPHOME_F("supports_transmitter")] = obj->get_supports_transmitter(); + root[ESPHOME_F("supports_receiver")] = obj->get_supports_receiver(); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8b3319190d..7c35ae2714 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -86,6 +86,7 @@ #define USE_IMPROV_BLE_STATE_CALLBACK #define USE_INFRARED #define USE_IR_RF +#define USE_IR_RF_TRANSMIT_COMPLETE #define USE_JSON #define USE_JSON_ARENA #define USE_RADIO_FREQUENCY diff --git a/tests/benchmarks/components/api/bench_proto_proxy.cpp b/tests/benchmarks/components/api/bench_proto_proxy.cpp index e45966999b..c8b56044de 100644 --- a/tests/benchmarks/components/api/bench_proto_proxy.cpp +++ b/tests/benchmarks/components/api/bench_proto_proxy.cpp @@ -149,7 +149,7 @@ BENCHMARK(Decode_SerialProxyWriteRequest); // --- InfraredRFReceiveEvent encode (100 sint32 timings) + // InfraredRFTransmitRawTimingsRequest decode (hand-built wire bytes) --- -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF // Mark/space pairs simulating a typical RC-5 / NEC capture (100 timings). static std::vector make_ir_timings_100() { @@ -275,6 +275,6 @@ static void Decode_InfraredRFTransmitRawTimingsRequest(benchmark::State &state) } BENCHMARK(Decode_InfraredRFTransmitRawTimingsRequest); -#endif // USE_IR_RF || USE_RADIO_FREQUENCY +#endif // USE_IR_RF } // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/stubs/esphome/components/infrared/infrared.h b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h index 874e7a270b..c6ed7fe203 100644 --- a/tests/benchmarks/stubs/esphome/components/infrared/infrared.h +++ b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h @@ -19,7 +19,8 @@ class InfraredCall { return *this; } InfraredCall &set_repeat_count(uint32_t /*count*/) { return *this; } - void perform() {} + template InfraredCall &set_api_connection(T * /*conn*/) { return *this; } + bool perform() { return false; } protected: Infrared *parent_; @@ -37,6 +38,7 @@ class Infrared : public Component, public EntityBase { const InfraredTraits &get_traits() const { return this->traits_; } InfraredCall make_call() { return InfraredCall(this); } uint32_t get_capability_flags() const { return 0; } + template void on_api_connection_closed(T * /*conn*/) {} protected: InfraredTraits traits_; 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..b1cdd53c5b 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,8 @@ class RadioFrequencyCall { RadioFrequencyCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) { return *this; } - void perform() {} + template RadioFrequencyCall &set_api_connection(T * /*conn*/) { return *this; } + bool perform() { return false; } protected: RadioFrequency *parent_; @@ -43,6 +44,7 @@ class RadioFrequency : public Component, public EntityBase { const RadioFrequencyTraits &get_traits() const { return this->traits_; } RadioFrequencyCall make_call() { return RadioFrequencyCall(this); } uint32_t get_capability_flags() const { return 0; } + template void on_api_connection_closed(T * /*conn*/) {} protected: RadioFrequencyTraits traits_; diff --git a/tests/integration/fixtures/external_components/remote_transmitter_mock/__init__.py b/tests/integration/fixtures/external_components/remote_transmitter_mock/__init__.py new file mode 100644 index 0000000000..b58eb41c59 --- /dev/null +++ b/tests/integration/fixtures/external_components/remote_transmitter_mock/__init__.py @@ -0,0 +1,25 @@ +"""Host-only stand-in for remote_transmitter used by the IR/RF integration tests.""" + +import esphome.codegen as cg +from esphome.components import remote_base +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/tests"] +MULTI_CONF = True +AUTO_LOAD = ["remote_base"] + +remote_transmitter_mock_ns = cg.esphome_ns.namespace("remote_transmitter_mock") +MockRemoteTransmitter = remote_transmitter_mock_ns.class_( + "MockRemoteTransmitter", remote_base.RemoteTransmitterBase, cg.Component +) + +CONFIG_SCHEMA = cv.Schema( + {cv.GenerateID(): cv.declare_id(MockRemoteTransmitter)} +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.cpp b/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.cpp new file mode 100644 index 0000000000..cfc03f38db --- /dev/null +++ b/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.cpp @@ -0,0 +1,40 @@ +#include "remote_transmitter_mock.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::remote_transmitter_mock { + +static const char *const TAG = "remote_transmitter_mock"; + +void MockRemoteTransmitter::dump_config() { ESP_LOGCONFIG(TAG, "Mock Remote Transmitter"); } + +void MockRemoteTransmitter::flush_pending_completion() { + if (!this->busy_) + return; + // The RMT backend blocks here until the hardware is idle; the mock only reports the overlap + ESP_LOGW(TAG, "Overlap: a frame was requested while seq=%" PRIu16 " was in flight", this->current_seq_); + this->cancel_timeout("complete"); + this->finish_(); +} + +void MockRemoteTransmitter::send_internal(uint32_t send_times, uint32_t send_wait) { + uint64_t total_us = static_cast(send_wait) * (send_times - 1); + for (int32_t value : this->temp_.get_data()) { + total_us += static_cast(value < 0 ? -value : value) * send_times; + } + const auto duration_ms = static_cast(total_us / 1000); + + this->busy_ = true; + ESP_LOGI(TAG, "TX seq=%" PRIu16 " timings=%zu repeat=%" PRIu32 " duration=%" PRIu32 "ms", this->current_seq_, + this->temp_.get_data().size(), send_times, duration_ms); + this->set_timeout("complete", duration_ms, [this]() { this->finish_(); }); +} + +void MockRemoteTransmitter::finish_() { + this->busy_ = false; + ESP_LOGI(TAG, "Complete seq=%" PRIu16, this->current_seq_); + this->notify_complete_(true); +} + +} // namespace esphome::remote_transmitter_mock diff --git a/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.h b/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.h new file mode 100644 index 0000000000..5b753d9638 --- /dev/null +++ b/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.h @@ -0,0 +1,31 @@ +#pragma once + +// ============================================================================ +// HOST-ONLY TEST COMPONENT — DO NOT COPY TO PRODUCTION CODE +// +// Emulates a non-blocking remote transmitter: a frame "occupies" the +// transmitter for its real duration and the completion callback fires from a +// scheduler timeout, the same way the ESP32 RMT backend reports completion. +// A frame submitted while another is in flight is logged as an overlap; the +// real backends block the main loop in that case. +// ============================================================================ + +#include "esphome/core/component.h" +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::remote_transmitter_mock { + +class MockRemoteTransmitter : public remote_base::RemoteTransmitterBase, public Component { + public: + MockRemoteTransmitter() : remote_base::RemoteTransmitterBase(nullptr) {} + void dump_config() override; + + protected: + void flush_pending_completion() override; + void send_internal(uint32_t send_times, uint32_t send_wait) override; + void finish_(); + + bool busy_{false}; +}; + +} // namespace esphome::remote_transmitter_mock diff --git a/tests/integration/fixtures/ir_rf_transmit_complete.yaml b/tests/integration/fixtures/ir_rf_transmit_complete.yaml new file mode 100644 index 0000000000..16bc09b1ad --- /dev/null +++ b/tests/integration/fixtures/ir_rf_transmit_complete.yaml @@ -0,0 +1,30 @@ +esphome: + name: ir-rf-transmit-complete-test + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +remote_transmitter_mock: + - id: rf_tx + - id: ir_tx + +# two entities on one transmitter: each must get only its own completion +radio_frequency: + - platform: ir_rf_proxy + name: RF Transmitter A + remote_transmitter_id: rf_tx + - platform: ir_rf_proxy + name: RF Transmitter B + remote_transmitter_id: rf_tx + +infrared: + - platform: ir_rf_proxy + name: IR Transmitter + remote_transmitter_id: ir_tx diff --git a/tests/integration/test_ir_rf_transmit_complete.py b/tests/integration/test_ir_rf_transmit_complete.py new file mode 100644 index 0000000000..d74712271f --- /dev/null +++ b/tests/integration/test_ir_rf_transmit_complete.py @@ -0,0 +1,131 @@ +"""IR/RF transmit completion replies (API 1.18) and the client pacing built on them. + +The transmitter is a host-only mock that takes a frame's real duration to +"send" it and reports completion from a scheduler timeout, like the ESP32 RMT +backend. The client is expected to hold the next frame until the device +replies, so the mock never sees an overlapping frame. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import InfraredInfo, RadioFrequencyInfo +from aioesphomeapi.api_pb2 import InfraredRFTransmitCompleteResponse +import pytest + +from .state_utils import find_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +FRAME_COUNT = 5 +# 10 marks and 10 spaces of 500 us, sent twice: 20 ms per frame +TIMINGS = [500, -500] * 10 +REPEAT = 2 +MOCK_EVENT = re.compile( + r"remote_transmitter_mock[^\]]*\]: (TX|Complete|Overlap)\b.*?seq=(\d+)" +) + + +@pytest.mark.shared_yaml("ir_rf_transmit_complete") +@pytest.mark.asyncio +async def test_ir_rf_transmit_complete_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The host build with the mock transmitters boots and lists all entities on any client.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + assert find_entity(entities, "rf_transmitter_a", RadioFrequencyInfo) is not None + assert find_entity(entities, "rf_transmitter_b", RadioFrequencyInfo) is not None + assert find_entity(entities, "ir_transmitter", InfraredInfo) is not None + + +@pytest.mark.shared_yaml("ir_rf_transmit_complete") +@pytest.mark.asyncio +async def test_ir_rf_transmit_complete( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Frames are answered once they leave the transmitter, never overlap, and a refused + request is answered at once. Two RF entities share a transmitter and each gets its own + reply; the infrared entity on its own transmitter is answered the same way.""" + loop = asyncio.get_running_loop() + events: list[tuple[str, int]] = [] + all_sent = loop.create_future() + all_logged = loop.create_future() + + def line_callback(line: str) -> None: + if (match := MOCK_EVENT.search(line)) is None: + return + events.append((match.group(1), int(match.group(2)))) + if ( + match.group(1) == "Complete" + and not all_sent.done() + and sum(kind == "Complete" for kind, _ in events) == FRAME_COUNT + ): + all_sent.set_result(None) + # the log reader stops with the device, so wait for the last line before asserting on it + if len(events) == 2 * (FRAME_COUNT + 1) and not all_logged.done(): + all_logged.set_result(None) + + completions: list[InfraredRFTransmitCompleteResponse] = [] + all_replied = loop.create_future() + ir_replied = loop.create_future() + refused = loop.create_future() + + def on_complete(msg: InfraredRFTransmitCompleteResponse) -> None: + if not msg.success: + if not refused.done(): + refused.set_result(msg) + return + completions.append(msg) + if len(completions) == FRAME_COUNT and not all_replied.done(): + all_replied.set_result(None) + if len(completions) == FRAME_COUNT + 1 and not ir_replied.done(): + ir_replied.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + rf = find_entity(entities, "rf_transmitter_a", RadioFrequencyInfo) + rf_b = find_entity(entities, "rf_transmitter_b", RadioFrequencyInfo) + ir = find_entity(entities, "ir_transmitter", InfraredInfo) + assert rf is not None and rf_b is not None, "RF transmitter entities not found" + assert ir is not None, "IR transmitter entity not found" + + client._connection.add_message_callback( + on_complete, (InfraredRFTransmitCompleteResponse,) + ) + # alternate between the two entities sharing the transmitter + keys = [rf.key if i % 2 == 0 else rf_b.key for i in range(FRAME_COUNT)] + for key in keys: + client.radio_frequency_transmit_raw_timings( + key, 433920000, TIMINGS, repeat_count=REPEAT + ) + + await asyncio.wait_for(all_replied, timeout=10) + await asyncio.wait_for(all_sent, timeout=10) + + # the infrared entity goes through the same path on its own transmitter + client.infrared_rf_transmit_raw_timings(ir.key, 38000, TIMINGS) + await asyncio.wait_for(ir_replied, timeout=10) + await asyncio.wait_for(all_logged, timeout=10) + + # A request the entity refuses is answered right away with success false; + # no timings, so the proxy rejects it before it reaches the transmitter + client.radio_frequency_transmit_raw_timings(rf.key, 433920000, []) + refused_msg = await asyncio.wait_for(refused, timeout=10) + + assert [msg.key for msg in completions] == [*keys, ir.key] + assert refused_msg.key == rf.key + + # The mocks saw one frame at a time: every transmit follows the previous completion + kinds = [kind for kind, _ in events] + assert kinds == ["TX", "Complete"] * (FRAME_COUNT + 1), events + seqs = [seq for _, seq in events] + assert seqs[::2] == seqs[1::2], events