diff --git a/.ai/instructions.md b/.ai/instructions.md index cb08a1e4600..3c24177827f 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -295,7 +295,9 @@ This document provides essential context for AI models interacting with this pro **Why Heap Allocation Matters:** - ESP devices run for months with small heaps shared between Wi-Fi, BLE, LWIP, and application code. Over time, repeated allocations of different sizes fragment the heap. Failures happen when the largest contiguous block shrinks, even if total free heap is still large. We have seen field crashes caused by this. For this reason, ESPHome treats runtime heap allocation in hot paths as a reliability bug, not a performance issue. Helpers that hide allocation (`std::string`, `std::to_string`, string-returning helpers) are being deprecated and replaced with buffer and view based APIs. + ESP devices run for months with small heaps shared between Wi-Fi, BLE, LWIP, and application code. Over time, repeated allocations of different sizes fragment the heap. Failures happen when the largest contiguous block shrinks, even if total free heap is still large. We have seen field crashes caused by this. + + **Heap allocation after `setup()` should be avoided unless absolutely unavoidable.** Every allocation/deallocation cycle contributes to fragmentation. ESPHome treats runtime heap allocation as a long-term reliability bug, not a performance issue. Helpers that hide allocation (`std::string`, `std::to_string`, string-returning helpers) are being deprecated and replaced with buffer and view based APIs. **STL Container Guidelines:** @@ -326,15 +328,15 @@ This document provides essential context for AI models interacting with this pro std::array buffer; ``` - 2. **Compile-time-known fixed sizes with vector-like API:** Use `StaticVector` from `esphome/core/helpers.h` for fixed-size stack allocation with `push_back()` interface. + 2. **Compile-time-known fixed sizes with vector-like API:** Use `StaticVector` from `esphome/core/helpers.h` for compile-time fixed size with `push_back()` interface (no dynamic allocation). ```cpp // Bad - generates STL realloc code (_M_realloc_insert) std::vector services; services.reserve(5); // Still includes reallocation machinery - // Good - compile-time fixed size, stack allocated, no reallocation machinery - StaticVector services; // Allocates all MAX_SERVICES on stack - services.push_back(record1); // Tracks count but all slots allocated + // Good - compile-time fixed size, no dynamic allocation + StaticVector services; + services.push_back(record1); ``` Use `cg.add_define("MAX_SERVICES", count)` to set the size from Python configuration. Like `std::array` but with vector-like API (`push_back()`, `size()`) and no STL reallocation code. @@ -376,22 +378,21 @@ This document provides essential context for AI models interacting with this pro ``` Linear search on small datasets (1-16 elements) is often faster than hashing/tree overhead, but this depends on lookup frequency and access patterns. For frequent lookups in hot code paths, the O(1) vs O(n) complexity difference may still matter even for small datasets. `std::vector` with simple structs is usually fine—it's the heavy containers (`map`, `set`, `unordered_map`) that should be avoided for small datasets unless profiling shows otherwise. - 5. **Detection:** Look for these patterns in compiler output: + 5. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices. + + 6. **Detection:** Look for these patterns in compiler output: - Large code sections with STL symbols (vector, map, set) - `alloc`, `realloc`, `dealloc` in symbol names - `_M_realloc_insert`, `_M_default_append` (vector reallocation) - Red-black tree code (`rb_tree`, `_Rb_tree`) - Hash table infrastructure (`unordered_map`, `hash`) - **When to optimize:** + **Prioritize optimization effort for:** - Core components (API, network, logger) - Widely-used components (mdns, wifi, ble) - Components causing flash size complaints - **When not to optimize:** - - Single-use niche components - - Code where readability matters more than bytes - - Already using appropriate containers + Note: Avoiding heap allocation after `setup()` is always required regardless of component type. The prioritization above is about the effort spent on container optimization (e.g., migrating from `std::vector` to `StaticVector`). * **State Management:** Use `CORE.data` for component state that needs to persist during configuration generation. Avoid module-level mutable globals. diff --git a/CODEOWNERS b/CODEOWNERS index bdcc86ef0c4..48318ee0646 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -249,6 +249,7 @@ esphome/components/ina260/* @mreditor97 esphome/components/ina2xx_base/* @latonita esphome/components/ina2xx_i2c/* @latonita esphome/components/ina2xx_spi/* @latonita +esphome/components/infrared/* @kbx81 esphome/components/inkbird_ibsth1_mini/* @fkirill esphome/components/inkplate/* @jesserockz @JosipKuci esphome/components/integration/* @OttoWinter diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d6384456d55..597da25883a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -66,6 +66,8 @@ service APIConnection { rpc zwave_proxy_frame(ZWaveProxyFrame) returns (void) {} rpc zwave_proxy_request(ZWaveProxyRequest) returns (void) {} + + rpc infrared_rf_transmit_raw_timings(InfraredRFTransmitRawTimingsRequest) returns (void) {} } @@ -2437,3 +2439,49 @@ message ZWaveProxyRequest { ZWaveProxyRequestType type = 1; bytes data = 2; } + +// ==================== INFRARED ==================== +// Note: Feature and capability flag enums are defined in +// esphome/components/infrared/infrared.h + +// Listing of infrared instances +message ListEntitiesInfraredResponse { + option (id) = 135; + option (base_class) = "InfoResponseProtoMessage"; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_INFRARED"; + + string object_id = 1; + fixed32 key = 2; + string name = 3; + string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON"]; + bool disabled_by_default = 5; + EntityCategory entity_category = 6; + uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"]; + uint32 capabilities = 8; // Bitfield of InfraredCapabilityFlags +} + +// Command to transmit infrared/RF data using raw timings +message InfraredRFTransmitRawTimingsRequest { + option (id) = 136; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_IR_RF"; + + uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; + fixed32 key = 2; // Key identifying the transmitter instance + uint32 carrier_frequency = 3; // Carrier frequency in Hz + uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.) + repeated sint32 timings = 5 [packed = true, (packed_buffer) = true]; // Raw timings in microseconds (zigzag-encoded): positive = mark (LED/TX on), negative = space (LED/TX off) +} + +// Event message for received infrared/RF data +message InfraredRFReceiveEvent { + option (id) = 137; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_IR_RF"; + option (no_delay) = true; + + uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; + fixed32 key = 2; // Key identifying the receiver instance + repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d6f0d84550c..65f8c1a8cc6 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -46,6 +46,9 @@ #ifdef USE_WATER_HEATER #include "esphome/components/water_heater/water_heater.h" #endif +#ifdef USE_INFRARED +#include "esphome/components/infrared/infrared.h" +#endif namespace esphome::api { @@ -1438,6 +1441,35 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c } #endif +#ifdef USE_IR_RF +void APIConnection::infrared_rf_transmit_raw_timings(const InfraredRFTransmitRawTimingsRequest &msg) { + // TODO: When RF is implemented, add a field to the message to distinguish IR vs RF + // and dispatch to the appropriate entity type based on that field. +#ifdef USE_INFRARED + ENTITY_COMMAND_MAKE_CALL(infrared::Infrared, infrared, infrared) + call.set_carrier_frequency(msg.carrier_frequency); + call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); + call.set_repeat_count(msg.repeat_count); + call.perform(); +#endif +} + +void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { + this->send_message(msg, InfraredRFReceiveEvent::MESSAGE_TYPE); +} +#endif + +#ifdef USE_INFRARED +uint16_t APIConnection::try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single) { + auto *infrared = static_cast(entity); + ListEntitiesInfraredResponse msg; + msg.capabilities = infrared->get_capability_flags(); + return fill_and_encode_entity_info(infrared, msg, ListEntitiesInfraredResponse::MESSAGE_TYPE, conn, remaining_size, + is_single); +} +#endif + #ifdef USE_UPDATE bool APIConnection::send_update_state(update::UpdateEntity *update) { return this->send_message_smart_(update, &APIConnection::try_send_update_state, UpdateStateResponse::MESSAGE_TYPE, diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 0289b3d2ff5..b3d072ff69c 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -172,6 +172,11 @@ class APIConnection final : public APIServerConnection { void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override; #endif +#ifdef USE_IR_RF + void infrared_rf_transmit_raw_timings(const InfraredRFTransmitRawTimingsRequest &msg) override; + void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg); +#endif + #ifdef USE_EVENT void send_event(event::Event *event, StringRef event_type); #endif @@ -468,6 +473,10 @@ class APIConnection final : public APIServerConnection { static uint16_t try_send_water_heater_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single); #endif +#ifdef USE_INFRARED + static uint16_t try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single); +#endif #ifdef USE_EVENT static uint16_t try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn, uint32_t remaining_size, bool is_single); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 03a6639b5e5..743f51dac77 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3347,5 +3347,98 @@ void ZWaveProxyRequest::calculate_size(ProtoSize &size) const { size.add_length(1, this->data_len); } #endif +#ifdef USE_INFRARED +void ListEntitiesInfraredResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_string(1, this->object_id); + buffer.encode_fixed32(2, this->key); + buffer.encode_string(3, this->name); +#ifdef USE_ENTITY_ICON + buffer.encode_string(4, this->icon); +#endif + buffer.encode_bool(5, this->disabled_by_default); + buffer.encode_uint32(6, static_cast(this->entity_category)); +#ifdef USE_DEVICES + buffer.encode_uint32(7, this->device_id); +#endif + buffer.encode_uint32(8, this->capabilities); +} +void ListEntitiesInfraredResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name.size()); +#ifdef USE_ENTITY_ICON + size.add_length(1, this->icon.size()); +#endif + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); +#ifdef USE_DEVICES + size.add_uint32(1, this->device_id); +#endif + size.add_uint32(1, this->capabilities); +} +#endif +#ifdef USE_IR_RF +bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { +#ifdef USE_DEVICES + case 1: + this->device_id = value.as_uint32(); + break; +#endif + case 3: + this->carrier_frequency = value.as_uint32(); + break; + case 4: + this->repeat_count = value.as_uint32(); + break; + default: + return false; + } + return true; +} +bool InfraredRFTransmitRawTimingsRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 5: { + this->timings_data_ = value.data(); + this->timings_length_ = value.size(); + this->timings_count_ = count_packed_varints(value.data(), value.size()); + break; + } + default: + return false; + } + return true; +} +bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 2: + this->key = value.as_fixed32(); + break; + default: + return false; + } + return true; +} +void InfraredRFReceiveEvent::encode(ProtoWriteBuffer buffer) const { +#ifdef USE_DEVICES + buffer.encode_uint32(1, this->device_id); +#endif + buffer.encode_fixed32(2, this->key); + for (const auto &it : *this->timings) { + buffer.encode_sint32(3, it, true); + } +} +void InfraredRFReceiveEvent::calculate_size(ProtoSize &size) const { +#ifdef USE_DEVICES + size.add_uint32(1, this->device_id); +#endif + size.add_fixed32(1, this->key); + if (!this->timings->empty()) { + for (const auto &it : *this->timings) { + size.add_sint32_force(1, it); + } + } +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index e21b8596ca6..0ab38b8b85f 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3049,5 +3049,70 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif +#ifdef USE_INFRARED +class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 135; + static constexpr uint8_t ESTIMATED_SIZE = 44; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "list_entities_infrared_response"; } +#endif + uint32_t capabilities{0}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: +}; +#endif +#ifdef USE_IR_RF +class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 136; + static constexpr uint8_t ESTIMATED_SIZE = 220; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "infrared_rf_transmit_raw_timings_request"; } +#endif +#ifdef USE_DEVICES + uint32_t device_id{0}; +#endif + uint32_t key{0}; + uint32_t carrier_frequency{0}; + uint32_t repeat_count{0}; + const uint8_t *timings_data_{nullptr}; + uint16_t timings_length_{0}; + uint16_t timings_count_{0}; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class InfraredRFReceiveEvent final : public ProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 137; + static constexpr uint8_t ESTIMATED_SIZE = 17; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "infrared_rf_receive_event"; } +#endif +#ifdef USE_DEVICES + uint32_t device_id{0}; +#endif + uint32_t key{0}; + const std::vector *timings{}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: +}; +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 999107956ad..9550ecbcdd7 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2293,6 +2293,50 @@ void ZWaveProxyRequest::dump_to(std::string &out) const { dump_bytes_field(out, "data", this->data, this->data_len); } #endif +#ifdef USE_INFRARED +void ListEntitiesInfraredResponse::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "ListEntitiesInfraredResponse"); + dump_field(out, "object_id", this->object_id); + dump_field(out, "key", this->key); + dump_field(out, "name", this->name); +#ifdef USE_ENTITY_ICON + dump_field(out, "icon", this->icon); +#endif + dump_field(out, "disabled_by_default", this->disabled_by_default); + dump_field(out, "entity_category", static_cast(this->entity_category)); +#ifdef USE_DEVICES + dump_field(out, "device_id", this->device_id); +#endif + dump_field(out, "capabilities", this->capabilities); +} +#endif +#ifdef USE_IR_RF +void InfraredRFTransmitRawTimingsRequest::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "InfraredRFTransmitRawTimingsRequest"); +#ifdef USE_DEVICES + dump_field(out, "device_id", this->device_id); +#endif + dump_field(out, "key", this->key); + dump_field(out, "carrier_frequency", this->carrier_frequency); + dump_field(out, "repeat_count", this->repeat_count); + out.append(" timings: "); + out.append("packed buffer ["); + out.append(std::to_string(this->timings_count_)); + out.append(" values, "); + out.append(std::to_string(this->timings_length_)); + out.append(" bytes]\n"); +} +void InfraredRFReceiveEvent::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "InfraredRFReceiveEvent"); +#ifdef USE_DEVICES + dump_field(out, "device_id", this->device_id); +#endif + dump_field(out, "key", this->key); + for (const auto &it : *this->timings) { + dump_field(out, "timings", it, 4); + } +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index c9bf638ad74..576b8024430 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -621,6 +621,17 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_water_heater_command_request(msg); break; } +#endif +#ifdef USE_IR_RF + case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: { + InfraredRFTransmitRawTimingsRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "on_infrared_rf_transmit_raw_timings_request: %s", msg.dump().c_str()); +#endif + this->on_infrared_rf_transmit_raw_timings_request(msg); + break; + } #endif default: break; @@ -819,6 +830,11 @@ void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { th #ifdef USE_ZWAVE_PROXY void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { this->zwave_proxy_request(msg); } #endif +#ifdef USE_IR_RF +void APIServerConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) { + this->infrared_rf_transmit_raw_timings(msg); +} +#endif void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { // Check authentication/connection requirements for messages diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index e2a23827dc9..4bd6a7b6a40 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -217,6 +217,11 @@ class APIServerConnectionBase : public ProtoService { #ifdef USE_ZWAVE_PROXY virtual void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif + +#ifdef USE_IR_RF + virtual void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){}; +#endif + protected: void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; @@ -347,6 +352,9 @@ class APIServerConnection : public APIServerConnectionBase { #endif #ifdef USE_ZWAVE_PROXY virtual void zwave_proxy_request(const ZWaveProxyRequest &msg) = 0; +#endif +#ifdef USE_IR_RF + virtual void infrared_rf_transmit_raw_timings(const InfraredRFTransmitRawTimingsRequest &msg) = 0; #endif protected: void on_hello_request(const HelloRequest &msg) override; @@ -473,6 +481,9 @@ class APIServerConnection : public APIServerConnectionBase { #endif #ifdef USE_ZWAVE_PROXY void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override; +#endif +#ifdef USE_IR_RF + void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) override; #endif void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 336672f50b9..949262098ff 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -347,6 +347,21 @@ void APIServer::on_zwave_proxy_request(const esphome::api::ProtoMessage &msg) { } #endif +#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{}; +#ifdef USE_DEVICES + resp.device_id = device_id; +#endif + resp.key = key; + resp.timings = timings; + + for (auto &c : this->clients_) + c->send_infrared_rf_receive_event(resp); +} +#endif + #ifdef USE_ALARM_CONTROL_PANEL API_DISPATCH_UPDATE(alarm_control_panel::AlarmControlPanel, alarm_control_panel) #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index f5b57f994af..93421ef801f 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -185,6 +185,9 @@ class APIServer : public Component, #ifdef USE_ZWAVE_PROXY void on_zwave_proxy_request(const esphome::api::ProtoMessage &msg); #endif +#ifdef USE_IR_RF + void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); +#endif bool is_connected(bool state_subscription_only = false) const; diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index 2470899c93e..fe43a47c3b7 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -76,6 +76,9 @@ LIST_ENTITIES_HANDLER(alarm_control_panel, alarm_control_panel::AlarmControlPane #ifdef USE_WATER_HEATER LIST_ENTITIES_HANDLER(water_heater, water_heater::WaterHeater, ListEntitiesWaterHeaterResponse) #endif +#ifdef USE_INFRARED +LIST_ENTITIES_HANDLER(infrared, infrared::Infrared, ListEntitiesInfraredResponse) +#endif #ifdef USE_EVENT LIST_ENTITIES_HANDLER(event, event::Event, ListEntitiesEventResponse) #endif diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 04e6525eb0a..912aab72b29 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -85,6 +85,9 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_WATER_HEATER bool on_water_heater(water_heater::WaterHeater *entity) override; #endif +#ifdef USE_INFRARED + bool on_infrared(infrared::Infrared *entity) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *entity) override; #endif diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 9230000acef..3c9f33835a5 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -79,6 +79,9 @@ class InitialStateIterator : public ComponentIterator { #ifdef USE_WATER_HEATER bool on_water_heater(water_heater::WaterHeater *entity) override; #endif +#ifdef USE_INFRARED + bool on_infrared(infrared::Infrared *infrared) override { return true; }; +#endif #ifdef USE_EVENT bool on_event(event::Event *event) override { return true; }; #endif diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 833be8e76c0..79c34f627a9 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -26,7 +26,7 @@ namespace deep_sleep { // - ext0: Single pin wakeup using RTC GPIO (esp_sleep_enable_ext0_wakeup) // - ext1: Multiple pin wakeup (esp_sleep_enable_ext1_wakeup) // - Touch: Touch pad wakeup (esp_sleep_enable_touchpad_wakeup) -// - GPIO wakeup: GPIO wakeup for non-RTC pins (esp_deep_sleep_enable_gpio_wakeup) +// - GPIO wakeup: GPIO wakeup for RTC pins (esp_deep_sleep_enable_gpio_wakeup) static const char *const TAG = "deep_sleep"; @@ -127,22 +127,14 @@ void DeepSleepComponent::deep_sleep_() { defined(USE_ESP32_VARIANT_ESP32C61) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); - if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { - gpio_sleep_set_pull_mode(gpio_pin, GPIO_PULLUP_ONLY); - } else if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLDOWN) { - gpio_sleep_set_pull_mode(gpio_pin, GPIO_PULLDOWN_ONLY); - } - gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); - gpio_hold_en(gpio_pin); -#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP - // Some ESP32 variants support holding a single GPIO during deep sleep without this function - // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep - gpio_deep_sleep_hold_en(); -#endif + // Make sure GPIO is in input mode, not all RTC GPIO pins are input by default + gpio_set_direction(gpio_pin, GPIO_MODE_INPUT); bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; } + // Internal pullup/pulldown resistors are enabled automatically, when + // ESP_SLEEP_GPIO_ENABLE_INTERNAL_RESISTORS is set (by default it is) esp_deep_sleep_enable_gpio_wakeup(1 << this->wakeup_pin_->get_pin(), static_cast(level)); } diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 0899a0dc2b1..0768b355071 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -19,6 +19,7 @@ from esphome.components.esp32 import ( import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_MODE, CONF_RX_PIN, CONF_RX_QUEUE_LEN, CONF_TX_PIN, @@ -33,6 +34,13 @@ CONF_TX_ENQUEUE_TIMEOUT = "tx_enqueue_timeout" esp32_can_ns = cg.esphome_ns.namespace("esp32_can") esp32_can = esp32_can_ns.class_("ESP32Can", CanbusComponent) +# Mode options - consistent with MCP2515 component +CanMode = esp32_can_ns.enum("CanMode") +CAN_MODES = { + "NORMAL": CanMode.CAN_MODE_NORMAL, + "LISTENONLY": CanMode.CAN_MODE_LISTEN_ONLY, +} + # Currently the driver only supports a subset of the bit rates defined in canbus # The supported bit rates differ between ESP32 variants. # See ESP-IDF Programming Guide --> API Reference --> Two-Wire Automotive Interface (TWAI) @@ -95,6 +103,7 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( cv.Optional(CONF_BIT_RATE, default="125KBPS"): validate_bit_rate, cv.Required(CONF_RX_PIN): pins.internal_gpio_input_pin_number, cv.Required(CONF_TX_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_MODE, default="NORMAL"): cv.enum(CAN_MODES, upper=True), cv.Optional(CONF_RX_QUEUE_LEN): cv.uint32_t, cv.Optional(CONF_TX_QUEUE_LEN): cv.uint32_t, cv.Optional(CONF_TX_ENQUEUE_TIMEOUT): cv.positive_time_period_milliseconds, @@ -117,6 +126,7 @@ async def to_code(config): cg.add(var.set_rx(config[CONF_RX_PIN])) cg.add(var.set_tx(config[CONF_TX_PIN])) + cg.add(var.set_mode(config[CONF_MODE])) if (rx_queue_len := config.get(CONF_RX_QUEUE_LEN)) is not None: cg.add(var.set_rx_queue_len(rx_queue_len)) if (tx_queue_len := config.get(CONF_TX_QUEUE_LEN)) is not None: diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index d50964187d0..f521b63430f 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -75,8 +75,15 @@ bool ESP32Can::setup_internal() { return false; } + // Select TWAI mode based on configuration + twai_mode_t twai_mode = (this->mode_ == CAN_MODE_LISTEN_ONLY) ? TWAI_MODE_LISTEN_ONLY : TWAI_MODE_NORMAL; + + if (this->mode_ == CAN_MODE_LISTEN_ONLY) { + ESP_LOGI(TAG, "CAN bus configured in LISTEN_ONLY mode (passive, no ACKs)"); + } + twai_general_config_t g_config = - TWAI_GENERAL_CONFIG_DEFAULT((gpio_num_t) this->tx_, (gpio_num_t) this->rx_, TWAI_MODE_NORMAL); + TWAI_GENERAL_CONFIG_DEFAULT((gpio_num_t) this->tx_, (gpio_num_t) this->rx_, twai_mode); g_config.controller_id = next_twai_ctrl_num++; if (this->tx_queue_len_.has_value()) { g_config.tx_queue_len = this->tx_queue_len_.value(); @@ -111,6 +118,12 @@ bool ESP32Can::setup_internal() { } canbus::Error ESP32Can::send_message(struct canbus::CanFrame *frame) { + // In listen-only mode, we cannot transmit + if (this->mode_ == CAN_MODE_LISTEN_ONLY) { + ESP_LOGW(TAG, "Cannot send messages in LISTEN_ONLY mode"); + return canbus::ERROR_FAIL; + } + if (this->twai_handle_ == nullptr) { // not setup yet or setup failed return canbus::ERROR_FAIL; diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index dc44aceb368..c3f200271bb 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -10,10 +10,16 @@ namespace esphome { namespace esp32_can { +enum CanMode : uint8_t { + CAN_MODE_NORMAL = 0, + CAN_MODE_LISTEN_ONLY = 1, +}; + class ESP32Can : public canbus::Canbus { public: void set_rx(int rx) { rx_ = rx; } void set_tx(int tx) { tx_ = tx; } + void set_mode(CanMode mode) { mode_ = mode; } void set_tx_queue_len(uint32_t tx_queue_len) { this->tx_queue_len_ = tx_queue_len; } void set_rx_queue_len(uint32_t rx_queue_len) { this->rx_queue_len_ = rx_queue_len; } void set_tx_enqueue_timeout_ms(uint32_t tx_enqueue_timeout_ms) { @@ -28,6 +34,7 @@ class ESP32Can : public canbus::Canbus { int rx_{-1}; int tx_{-1}; + CanMode mode_{CAN_MODE_NORMAL}; TickType_t tx_enqueue_timeout_ticks_{}; optional tx_queue_len_{}; optional rx_queue_len_{}; diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py new file mode 100644 index 00000000000..5c759d6fd9c --- /dev/null +++ b/esphome/components/infrared/__init__.py @@ -0,0 +1,76 @@ +""" +Infrared component for ESPHome. + +WARNING: This component is EXPERIMENTAL. The API (both Python configuration +and C++ interfaces) 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. +""" + +import esphome.codegen as cg +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 setup_entity +from esphome.coroutine import CoroPriority +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +AUTO_LOAD = ["remote_base"] + +IS_PLATFORM_COMPONENT = True + +infrared_ns = cg.esphome_ns.namespace("infrared") +Infrared = infrared_ns.class_("Infrared", cg.EntityBase, cg.Component) +InfraredCall = infrared_ns.class_("InfraredCall") +InfraredTraits = infrared_ns.class_("InfraredTraits") + +CONF_INFRARED_ID = "infrared_id" +CONF_SUPPORTS_TRANSMITTER = "supports_transmitter" +CONF_SUPPORTS_RECEIVER = "supports_receiver" + + +def infrared_schema(class_: type[cg.MockObjClass]) -> cv.Schema: + """Create a schema for an infrared platform. + + :param class_: The infrared class to use for this schema. + :return: An extended schema for infrared configuration. + """ + entity_schema = cv.ENTITY_BASE_SCHEMA.extend(cv.COMPONENT_SCHEMA) + return entity_schema.extend( + { + cv.GenerateID(): cv.declare_id(class_), + } + ) + + +async def setup_infrared_core_(var: cg.Pvariable, config: ConfigType) -> None: + """Set up core infrared configuration.""" + await setup_entity(var, config, "infrared") + + +async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None: + """Register an infrared device with the core.""" + cg.add_define("USE_IR_RF") + await cg.register_component(var, config) + await setup_infrared_core_(var, config) + cg.add(cg.App.register_infrared(var)) + CORE.register_platform_component("infrared", var) + + +async def new_infrared(config: ConfigType, *args) -> cg.Pvariable: + """Create a new Infrared instance. + + :param config: Configuration dictionary. + :param args: Additional arguments to pass to new_Pvariable. + :return: The created Infrared instance. + """ + var = cg.new_Pvariable(config[CONF_ID], *args) + await register_infrared(var, config) + return var + + +@coroutine_with_priority(CoroPriority.CORE) +async def to_code(config: ConfigType) -> None: + cg.add_global(infrared_ns.using) diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp new file mode 100644 index 00000000000..5f8d63926a9 --- /dev/null +++ b/esphome/components/infrared/infrared.cpp @@ -0,0 +1,138 @@ +#include "infrared.h" +#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; // Clear packed if vector is set + 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; // Clear vector if packed is set + 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()); + + // Register as listener for received IR data + if (this->receiver_ != nullptr) { + this->receiver_->register_listener(this); + } +} + +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())); +} + +InfraredCall Infrared::make_call() { return InfraredCall(this); } + +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 + if (call.get_carrier_frequency().has_value()) { + transmit_data->set_carrier_frequency(call.get_carrier_frequency().value()); + } + + // 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=%u, repeat=%u", call.get_packed_count(), + 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=%u", 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 +} + +} // namespace esphome::infrared diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h new file mode 100644 index 00000000000..3a891301f4d --- /dev/null +++ b/esphome/components/infrared/infrared.h @@ -0,0 +1,130 @@ +#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/components/remote_base/remote_base.h" + +#include + +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 +}; + +/// Forward declarations +class Infrared; + +/// InfraredCall - Builder pattern for transmitting infrared signals +class InfraredCall { + public: + explicit InfraredCall(Infrared *parent) : parent_(parent) {} + + /// Set the carrier frequency in Hz + InfraredCall &set_carrier_frequency(uint32_t frequency); + /// Set the raw timings (positive = mark, negative = space) + /// Note: The timings vector must outlive the InfraredCall (zero-copy reference) + InfraredCall &set_raw_timings(const std::vector &timings); + /// Set the raw timings from packed protobuf sint32 data (zero-copy from wire) + /// Note: The data must outlive the InfraredCall + 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(); + + /// 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, not packed) + const std::vector &get_raw_timings() const { return *this->raw_timings_; } + /// Check if raw timings have been set (either vector or packed) + bool has_raw_timings() const { return this->raw_timings_ != nullptr || this->packed_data_ != nullptr; } + /// Check if using packed data format + bool is_packed() const { return this->packed_data_ != nullptr; } + /// 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_; + // Vector-based timings (for lambdas/automations) + const std::vector *raw_timings_{nullptr}; + // Packed protobuf timings (for API zero-copy) + 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; } + + protected: + bool supports_transmitter_{false}; + bool supports_receiver_{false}; +}; + +/// Infrared - Base class for infrared remote control implementations +class Infrared : public Component, public EntityBase, public remote_base::RemoteReceiverListener { + 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 + 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_; } + const InfraredTraits &get_traits() const { return this->traits_; } + + /// Create a call object for transmitting + InfraredCall make_call(); + + /// 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; + + /// 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}; + + // Traits describing capabilities + InfraredTraits traits_; +}; + +} // namespace esphome::infrared diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 27263d0bffb..0dbcff1bfbb 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -16,7 +16,6 @@ from esphome.const import ( CONF_REPEAT, CONF_TRIGGER_ID, CONF_TYPE, - DEVICE_CLASS_DISTANCE, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, ICON_BRIGHTNESS_6, @@ -169,7 +168,6 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -179,7 +177,6 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_7, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -189,7 +186,6 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_PROXIMITY, accuracy_decimals=0, - device_class=DEVICE_CLASS_DISTANCE, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -198,7 +194,6 @@ CONFIG_SCHEMA = cv.All( sensor.sensor_schema( icon=ICON_GAIN, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index bf90a41df50..5fb8abddfc8 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -8,6 +8,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components.zephyr import ( copy_files as zephyr_copy_files, + zephyr_add_overlay, zephyr_add_pm_static, zephyr_add_prj_conf, zephyr_data, @@ -26,6 +27,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_ID, CONF_RESET_PIN, + CONF_VERSION, CONF_VOLTAGE, KEY_CORE, KEY_FRAMEWORK_VERSION, @@ -59,7 +61,6 @@ def set_core_data(config: ConfigType) -> ConfigType: zephyr_set_core_data(config) CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR - CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(2, 6, 1) if config[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: zephyr_add_pm_static(BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]]) @@ -67,6 +68,12 @@ def set_core_data(config: ConfigType) -> ConfigType: return config +def set_framework(config: ConfigType) -> ConfigType: + version = cv.Version.parse(cv.version_number(config[CONF_FRAMEWORK][CONF_VERSION])) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = version + return config + + BOOTLOADERS = [ BOOTLOADER_ADAFRUIT, BOOTLOADER_ADAFRUIT_NRF52_SD132, @@ -133,8 +140,14 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_UICR_ERASE, default=False): cv.boolean, } ), + cv.Optional(CONF_FRAMEWORK, default={CONF_VERSION: "2.6.1-7"}): cv.Schema( + { + cv.Required(CONF_VERSION): cv.string_strict, + } + ), } ), + set_framework, ) @@ -173,7 +186,7 @@ async def to_code(config: ConfigType) -> None: cg.add_platformio_option( "platform_packages", [ - "platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-7.zip", + f"platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v{CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}.zip", "platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.17.4-0.zip", ], ) @@ -200,7 +213,17 @@ async def to_code(config: ConfigType) -> None: if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) - zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) + framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver < cv.Version(2, 9, 2): + zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) + else: + zephyr_add_overlay( + f""" + ®1 {{ + regulator-initial-mode = <{"NRF5X_REG_MODE_DCDC" if config[CONF_DCDC] else "NRF5X_REG_MODE_LDO"}>; + }}; + """ + ) if reg0_config := config.get(CONF_REG0): value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE]) @@ -209,8 +232,12 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_NRF52_UICR_ERASE") # c++ support - zephyr_add_prj_conf("CPLUSPLUS", True) - zephyr_add_prj_conf("LIB_CPLUSPLUS", True) + if framework_ver < cv.Version(2, 9, 2): + zephyr_add_prj_conf("CPLUSPLUS", True) + zephyr_add_prj_conf("LIB_CPLUSPLUS", True) + else: + zephyr_add_prj_conf("CPP", True) + zephyr_add_prj_conf("REQUIRES_FULL_LIBCPP", True) # watchdog zephyr_add_prj_conf("WATCHDOG", True) zephyr_add_prj_conf("WDT_DISABLE_AT_BOOT", False) @@ -218,7 +245,16 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("UART_CONSOLE", False) zephyr_add_prj_conf("CONSOLE", False) # use NFC pins as GPIO - zephyr_add_prj_conf("NFCT_PINS_AS_GPIOS", True) + if framework_ver < cv.Version(2, 9, 2): + zephyr_add_prj_conf("NFCT_PINS_AS_GPIOS", True) + else: + zephyr_add_overlay( + """ + &uicr { + nfct-pins-as-gpios; + }; + """ + ) @coroutine_with_priority(CoroPriority.DIAGNOSTICS) diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index 4a53ab110b5..cefe9a604e7 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -274,7 +274,7 @@ void PacketTransport::flush_() { void PacketTransport::add_binary_data_(uint8_t key, const char *id, bool data) { auto len = 1 + 1 + 1 + strlen(id); - if (len + this->header_.size() + this->data_.size() > this->get_max_packet_size()) { + if (round4(this->header_.size()) + round4(this->data_.size() + len) > this->get_max_packet_size()) { this->flush_(); this->init_data_(); } @@ -289,7 +289,7 @@ void PacketTransport::add_data_(uint8_t key, const char *id, float data) { void PacketTransport::add_data_(uint8_t key, const char *id, uint32_t data) { auto len = 4 + 1 + 1 + strlen(id); - if (len + this->header_.size() + this->data_.size() > this->get_max_packet_size()) { + if (round4(this->header_.size()) + round4(this->data_.size() + len) > this->get_max_packet_size()) { this->flush_(); this->init_data_(); } diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 68d20eef85a..e2639a22983 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -1011,7 +1011,7 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima value_accuracy_to_buf(value_buf, traits.get_visual_max_temperature(), target_accuracy); climate_value_row_(stream, obj, area, node, friendly_name, max_temp, value_buf); // min temp - std::string min_temp = "mininum_temperature"; + std::string min_temp = "minimum_temperature"; value_accuracy_to_buf(value_buf, traits.get_visual_min_temperature(), target_accuracy); climate_value_row_(stream, obj, area, node, friendly_name, min_temp, value_buf); // now check optional traits diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index 44e479c153f..d9b0b59fe9f 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -21,6 +21,11 @@ static constexpr uint8_t CMD_FRAME_FOOTER[] = {0x04, 0x03, 0x02, 0x01}; static constexpr uint16_t CMD_SINGLE_TARGET = 0x0080; static constexpr uint16_t CMD_MULTI_TARGET = 0x0090; +// Speed sentinel values (cm/s) - radar outputs these when no valid Doppler measurement +// FMCW radars detect motion via Doppler shift; targets with these speeds are likely noise +static constexpr int16_t SPEED_SENTINEL_248 = 248; +static constexpr int16_t SPEED_SENTINEL_256 = 256; + // Decode coordinate/speed value from RD-03D format // Per datasheet: MSB=1 means positive, MSB=0 means negative static constexpr int16_t decode_value(uint8_t low_byte, uint8_t high_byte) { @@ -31,6 +36,13 @@ static constexpr int16_t decode_value(uint8_t low_byte, uint8_t high_byte) { return value; } +// Check if speed value indicates a valid Doppler measurement +// Zero, ±248, or ±256 cm/s are sentinel values from the radar firmware +static constexpr bool is_speed_valid(int16_t speed) { + int16_t abs_speed = speed < 0 ? -speed : speed; + return speed != 0 && abs_speed != SPEED_SENTINEL_248 && abs_speed != SPEED_SENTINEL_256; +} + void RD03DComponent::setup() { ESP_LOGCONFIG(TAG, "Setting up RD-03D..."); this->set_timeout(SETUP_TIMEOUT_MS, [this]() { this->apply_config_(); }); @@ -136,8 +148,12 @@ void RD03DComponent::process_frame_() { int16_t speed = decode_value(speed_low, speed_high); uint16_t resolution = (res_high << 8) | res_low; - // Check if target is present (non-zero coordinates) - bool target_present = (x != 0 || y != 0); + // Check if target is present + // Requires non-zero coordinates AND valid speed (not a sentinel value) + // FMCW radars detect motion via Doppler; sentinel speed indicates no real target + bool has_position = (x != 0 || y != 0); + bool has_valid_speed = is_speed_valid(speed); + bool target_present = has_position && has_valid_speed; if (target_present) { target_count++; } @@ -169,20 +185,21 @@ void RD03DComponent::process_frame_() { #ifdef USE_SENSOR void RD03DComponent::publish_target_(uint8_t target_num, int16_t x, int16_t y, int16_t speed, uint16_t resolution) { TargetSensor &target = this->targets_[target_num]; + bool valid = is_speed_valid(speed); - // Publish X coordinate (mm) + // Publish X coordinate (mm) - NaN if target invalid if (target.x != nullptr) { - target.x->publish_state(x); + target.x->publish_state(valid ? static_cast(x) : NAN); } - // Publish Y coordinate (mm) + // Publish Y coordinate (mm) - NaN if target invalid if (target.y != nullptr) { - target.y->publish_state(y); + target.y->publish_state(valid ? static_cast(y) : NAN); } - // Publish speed (convert from cm/s to mm/s) + // Publish speed (convert from cm/s to mm/s) - NaN if target invalid if (target.speed != nullptr) { - target.speed->publish_state(static_cast(speed) * 10.0f); + target.speed->publish_state(valid ? static_cast(speed) * 10.0f : NAN); } // Publish resolution (mm) @@ -190,20 +207,23 @@ void RD03DComponent::publish_target_(uint8_t target_num, int16_t x, int16_t y, i target.resolution->publish_state(resolution); } - // Calculate and publish distance (mm) + // Calculate and publish distance (mm) - NaN if target invalid if (target.distance != nullptr) { - float distance = std::hypot(static_cast(x), static_cast(y)); - target.distance->publish_state(distance); + if (valid) { + target.distance->publish_state(std::hypot(static_cast(x), static_cast(y))); + } else { + target.distance->publish_state(NAN); + } } - // Calculate and publish angle (degrees) + // Calculate and publish angle (degrees) - NaN if target invalid // Angle is measured from the Y axis (radar forward direction) if (target.angle != nullptr) { - if (x == 0 && y == 0) { - target.angle->publish_state(0); - } else { + if (valid) { float angle = std::atan2(static_cast(x), static_cast(y)) * 180.0f / M_PI; target.angle->publish_state(angle); + } else { + target.angle->publish_state(NAN); } } } diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 576143bcbcb..f20789fb9f2 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -40,13 +40,10 @@ void RemoteTransmitterComponent::await_target_time_() { if (this->target_time_ == 0) { this->target_time_ = current_time; } else if ((int32_t) (this->target_time_ - current_time) > 0) { -#if defined(USE_LIBRETINY) || defined(USE_RP2040) - // busy loop is required for libretiny and rp2040 as interrupts are disabled + // busy loop is required as interrupts are disabled and delayMicroseconds() + // may not work correctly in interrupt-disabled contexts on all platforms while ((int32_t) (this->target_time_ - micros()) > 0) ; -#else - delayMicroseconds(this->target_time_ - current_time); -#endif } } diff --git a/esphome/components/tuya/light/__init__.py b/esphome/components/tuya/light/__init__.py index 4d2ccba8b19..bf2d3daf981 100644 --- a/esphome/components/tuya/light/__init__.py +++ b/esphome/components/tuya/light/__init__.py @@ -26,6 +26,7 @@ CONF_RGB_DATAPOINT = "rgb_datapoint" CONF_HSV_DATAPOINT = "hsv_datapoint" CONF_COLOR_DATAPOINT = "color_datapoint" CONF_COLOR_TYPE = "color_type" +CONF_COLOR_TYPE_LOWERCASE = "color_type_lowercase" TuyaColorType = tuya_ns.enum("TuyaColorType") @@ -47,6 +48,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SWITCH_DATAPOINT): cv.uint8_t, cv.Inclusive(CONF_COLOR_DATAPOINT, "color"): cv.uint8_t, cv.Inclusive(CONF_COLOR_TYPE, "color"): cv.enum(COLOR_TYPES, upper=True), + cv.Optional(CONF_COLOR_TYPE_LOWERCASE, default=False): cv.boolean, cv.Optional(CONF_COLOR_INTERLOCK, default=False): cv.boolean, cv.Inclusive( CONF_COLOR_TEMPERATURE_DATAPOINT, "color_temperature" @@ -91,6 +93,7 @@ async def to_code(config): if CONF_COLOR_DATAPOINT in config: cg.add(var.set_color_id(config[CONF_COLOR_DATAPOINT])) cg.add(var.set_color_type(config[CONF_COLOR_TYPE])) + cg.add(var.set_color_type_lowercase(config[CONF_COLOR_TYPE_LOWERCASE])) if CONF_COLOR_TEMPERATURE_DATAPOINT in config: cg.add(var.set_color_temperature_id(config[CONF_COLOR_TEMPERATURE_DATAPOINT])) cg.add(var.set_color_temperature_invert(config[CONF_COLOR_TEMPERATURE_INVERT])) diff --git a/esphome/components/tuya/light/tuya_light.cpp b/esphome/components/tuya/light/tuya_light.cpp index 815a089d9f5..c487f9f50bf 100644 --- a/esphome/components/tuya/light/tuya_light.cpp +++ b/esphome/components/tuya/light/tuya_light.cpp @@ -190,7 +190,8 @@ void TuyaLight::write_state(light::LightState *state) { switch (*this->color_type_) { case TuyaColorType::RGB: { char buffer[7]; - sprintf(buffer, "%02X%02X%02X", int(red * 255), int(green * 255), int(blue * 255)); + const char *format_str = this->color_type_lowercase_ ? "%02x%02x%02x" : "%02X%02X%02X"; + sprintf(buffer, format_str, int(red * 255), int(green * 255), int(blue * 255)); color_value = buffer; break; } @@ -199,7 +200,8 @@ void TuyaLight::write_state(light::LightState *state) { float saturation, value; rgb_to_hsv(red, green, blue, hue, saturation, value); char buffer[13]; - sprintf(buffer, "%04X%04X%04X", hue, int(saturation * 1000), int(value * 1000)); + const char *format_str = this->color_type_lowercase_ ? "%04x%04x%04x" : "%04X%04X%04X"; + sprintf(buffer, format_str, hue, int(saturation * 1000), int(value * 1000)); color_value = buffer; break; } @@ -208,8 +210,9 @@ void TuyaLight::write_state(light::LightState *state) { float saturation, value; rgb_to_hsv(red, green, blue, hue, saturation, value); char buffer[15]; - sprintf(buffer, "%02X%02X%02X%04X%02X%02X", int(red * 255), int(green * 255), int(blue * 255), hue, - int(saturation * 255), int(value * 255)); + const char *format_str = this->color_type_lowercase_ ? "%02x%02x%02x%04x%02x%02x" : "%02X%02X%02X%04X%02X%02X"; + sprintf(buffer, format_str, int(red * 255), int(green * 255), int(blue * 255), hue, int(saturation * 255), + int(value * 255)); color_value = buffer; break; } diff --git a/esphome/components/tuya/light/tuya_light.h b/esphome/components/tuya/light/tuya_light.h index bd9920f18f3..ded94f390af 100644 --- a/esphome/components/tuya/light/tuya_light.h +++ b/esphome/components/tuya/light/tuya_light.h @@ -7,11 +7,7 @@ namespace esphome { namespace tuya { -enum TuyaColorType { - RGB, - HSV, - RGBHSV, -}; +enum TuyaColorType { RGB, HSV, RGBHSV }; class TuyaLight : public Component, public light::LightOutput { public: @@ -28,6 +24,7 @@ class TuyaLight : public Component, public light::LightOutput { void set_color_temperature_invert(bool color_temperature_invert) { this->color_temperature_invert_ = color_temperature_invert; } + void set_color_type_lowercase(bool color_type_lowercase) { this->color_type_lowercase_ = color_type_lowercase; } void set_tuya_parent(Tuya *parent) { this->parent_ = parent; } void set_min_value(uint32_t min_value) { min_value_ = min_value; } void set_max_value(uint32_t max_value) { max_value_ = max_value; } @@ -63,6 +60,7 @@ class TuyaLight : public Component, public light::LightOutput { float cold_white_temperature_; float warm_white_temperature_; bool color_temperature_invert_{false}; + bool color_type_lowercase_{false}; bool color_interlock_{false}; light::LightState *state_{nullptr}; }; diff --git a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp index 0b3e2026d89..36b6d630ae3 100644 --- a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp +++ b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp @@ -1,4 +1,5 @@ #include "tuya_text_sensor.h" +#include "esphome/core/entity_base.h" #include "esphome/core/log.h" namespace esphome { @@ -14,9 +15,9 @@ void TuyaTextSensor::setup() { this->publish_state(datapoint.value_string); break; case TuyaDatapointType::RAW: { - // Text sensor state is limited to 255 bytes, use 256 byte buffer - char hex_buf[256]; - const char *formatted = format_hex_pretty_to(hex_buf, sizeof(hex_buf), datapoint.value_raw); + char hex_buf[MAX_STATE_LEN + 1]; + const char *formatted = + format_hex_pretty_to(hex_buf, sizeof(hex_buf), datapoint.value_raw.data(), datapoint.value_raw.size()); ESP_LOGD(TAG, "MCU reported text sensor %u is: %s", datapoint.id, formatted); this->publish_state(formatted); break; diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 1e852f6a961..0af95213261 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -141,6 +141,13 @@ bool ListEntitiesIterator::on_water_heater(water_heater::WaterHeater *obj) { } #endif +#ifdef USE_INFRARED +bool ListEntitiesIterator::on_infrared(infrared::Infrared *obj) { + // Infrared web_server support not yet implemented - this stub acknowledges the entity + return true; +} +#endif + #ifdef USE_EVENT bool ListEntitiesIterator::on_event(event::Event *obj) { // Null event type, since we are just iterating over entities diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index 56fd91a8c62..d0a4fa27256 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -82,6 +82,9 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_WATER_HEATER bool on_water_heater(water_heater::WaterHeater *obj) override; #endif +#ifdef USE_INFRARED + bool on_infrared(infrared::Infrared *obj) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *obj) override; #endif diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index c3fe6f67804..ae4bbfa557b 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -202,7 +202,7 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { stream.print(""); for (auto const &option : select->traits.get_options()) { stream.print(""); } stream.print(""); diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index a91d976e6b3..8e3ae86bbe0 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -3,7 +3,8 @@ import textwrap from typing import TypedDict import esphome.codegen as cg -from esphome.const import CONF_BOARD +import esphome.config_validation as cv +from esphome.const import CONF_BOARD, KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE from esphome.helpers import copy_file_if_changed, write_file_if_changed @@ -150,6 +151,9 @@ def _format_prj_conf_val(value: PrjConfValueType) -> str: def zephyr_add_cdc_acm(config, id): + framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if CORE.is_nrf52 and framework_ver >= cv.Version(3, 2, 0): + zephyr_add_prj_conf("CONFIG_USB_DEVICE_STACK_NEXT", False) zephyr_add_prj_conf("USB_DEVICE_STACK", True) zephyr_add_prj_conf("USB_CDC_ACM", True) # prevent device to go to susspend, without this communication stop working in python @@ -159,12 +163,12 @@ def zephyr_add_cdc_acm(config, id): zephyr_add_prj_conf("USB_CDC_ACM_LOG_LEVEL_WRN", True) zephyr_add_overlay( f""" -&zephyr_udc0 {{ - cdc_acm_uart{id}: cdc_acm_uart{id} {{ - compatible = "zephyr,cdc-acm-uart"; - }}; -}}; -""" + &zephyr_udc0 {{ + cdc_acm_uart{id}: cdc_acm_uart{id} {{ + compatible = "zephyr,cdc-acm-uart"; + }}; + }}; + """ ) @@ -184,11 +188,12 @@ def copy_files(): if user: zephyr_add_overlay( f""" -/ {{ - zephyr,user {{ - {[f"{key} = {', '.join(value)};" for key, value in user.items()][0]} -}}; -}};""" + / {{ + zephyr,user {{ + {[f"{key} = {', '.join(value)};" for key, value in user.items()][0]} + }}; + }}; + """ ) want_opts = zephyr_data()[KEY_PRJ_CONF] diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index cf37e890c46..e3631649974 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -1,3 +1,4 @@ +import logging from typing import Any from esphome import automation, core @@ -6,7 +7,7 @@ from esphome.components.nrf52.boards import BOOTLOADER_CONFIG, Section from esphome.components.zephyr import zephyr_add_pm_static, zephyr_data from esphome.components.zephyr.const import KEY_BOOTLOADER import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERNAL +from esphome.const import CONF_ID, CONF_INTERNAL, CONF_NAME from esphome.core import CORE from esphome.types import ConfigType @@ -24,6 +25,8 @@ from .const_zephyr import ( ) from .zigbee_zephyr import zephyr_binary_sensor, zephyr_sensor +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@tomaszduda23"] @@ -107,6 +110,12 @@ async def setup_sensor(entity: cg.MockObj, config: ConfigType) -> None: def consume_endpoint(config: ConfigType) -> ConfigType: if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): return config + if " " in config[CONF_NAME]: + _LOGGER.warning( + "Spaces in '%s' work with ZHA but not Zigbee2MQTT. For Zigbee2MQTT use '%s'", + config[CONF_NAME], + config[CONF_NAME].replace(" ", "_"), + ) data: dict[str, Any] = CORE.data.setdefault(KEY_ZIGBEE, {}) slots: list[str] = data.setdefault(KEY_EP_NUMBER, []) slots.extend([""]) diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index d8a2716603c..71ea0da6a7b 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -212,6 +212,8 @@ def zigbee_assign(target: cg.MockObj, expression: cg.RawExpression | int) -> str def zigbee_set_string(target: cg.MockObj, value: str) -> str: """Set a ZCL string value and return the target name (arrays decay to pointers).""" + # Zigbee supports only ASCII + value = value.encode("ascii", "ignore").decode() cg.add( cg.RawExpression( f"ZB_ZCL_SET_STRING_VAL({target}, {cg.safe_exp(value)}, ZB_ZCL_STRING_CONST_SIZE({cg.safe_exp(value)}))" diff --git a/esphome/core/application.h b/esphome/core/application.h index 13461b3ebd6..592bf809f1d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -91,6 +91,9 @@ #ifdef USE_WATER_HEATER #include "esphome/components/water_heater/water_heater.h" #endif +#ifdef USE_INFRARED +#include "esphome/components/infrared/infrared.h" +#endif #ifdef USE_EVENT #include "esphome/components/event/event.h" #endif @@ -223,6 +226,10 @@ class Application { void register_water_heater(water_heater::WaterHeater *water_heater) { this->water_heaters_.push_back(water_heater); } #endif +#ifdef USE_INFRARED + void register_infrared(infrared::Infrared *infrared) { this->infrareds_.push_back(infrared); } +#endif + #ifdef USE_EVENT void register_event(event::Event *event) { this->events_.push_back(event); } #endif @@ -457,6 +464,11 @@ class Application { GET_ENTITY_METHOD(water_heater::WaterHeater, water_heater, water_heaters) #endif +#ifdef USE_INFRARED + auto &get_infrareds() const { return this->infrareds_; } + GET_ENTITY_METHOD(infrared::Infrared, infrared, infrareds) +#endif + #ifdef USE_EVENT auto &get_events() const { return this->events_; } GET_ENTITY_METHOD(event::Event, event, events) @@ -656,6 +668,9 @@ class Application { #ifdef USE_WATER_HEATER StaticVector water_heaters_{}; #endif +#ifdef USE_INFRARED + StaticVector infrareds_{}; +#endif #ifdef USE_UPDATE StaticVector updates_{}; #endif diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 4015d8ec604..ff76b2b81bf 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -169,6 +169,12 @@ void ComponentIterator::advance() { break; #endif +#ifdef USE_INFRARED + case IteratorState::INFRARED: + this->process_platform_item_(App.get_infrareds(), &ComponentIterator::on_infrared); + break; +#endif + #ifdef USE_EVENT case IteratorState::EVENT: this->process_platform_item_(App.get_events(), &ComponentIterator::on_event); diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 37d19606015..e13d81a8e4f 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -16,6 +16,12 @@ class UserServiceDescriptor; } // namespace api #endif +#ifdef USE_INFRARED +namespace infrared { +class Infrared; +} // namespace infrared +#endif + class ComponentIterator { public: void begin(bool include_internal = false); @@ -87,6 +93,9 @@ class ComponentIterator { #ifdef USE_WATER_HEATER virtual bool on_water_heater(water_heater::WaterHeater *water_heater) = 0; #endif +#ifdef USE_INFRARED + virtual bool on_infrared(infrared::Infrared *infrared) = 0; +#endif #ifdef USE_EVENT virtual bool on_event(event::Event *event) = 0; #endif @@ -167,6 +176,9 @@ class ComponentIterator { #ifdef USE_WATER_HEATER WATER_HEATER, #endif +#ifdef USE_INFRARED + INFRARED, +#endif #ifdef USE_EVENT EVENT, #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ed5f152e9f2..633b0c6c5e4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -49,6 +49,8 @@ #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_IMAGE #define USE_IMPROV_SERIAL_NEXT_URL +#define USE_INFRARED +#define USE_IR_RF #define USE_JSON #define USE_LIGHT #define USE_LOCK @@ -321,9 +323,9 @@ // Default counts for static analysis #define CONTROLLER_REGISTRY_MAX 2 +#define ESPHOME_AREA_COUNT 10 #define ESPHOME_COMPONENT_COUNT 50 #define ESPHOME_DEVICE_COUNT 10 -#define ESPHOME_AREA_COUNT 10 #define ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT 1 #define ESPHOME_ENTITY_BINARY_SENSOR_COUNT 1 #define ESPHOME_ENTITY_BUTTON_COUNT 1 @@ -333,6 +335,7 @@ #define ESPHOME_ENTITY_DATETIME_COUNT 1 #define ESPHOME_ENTITY_EVENT_COUNT 1 #define ESPHOME_ENTITY_FAN_COUNT 1 +#define ESPHOME_ENTITY_INFRARED_COUNT 1 #define ESPHOME_ENTITY_LIGHT_COUNT 1 #define ESPHOME_ENTITY_LOCK_COUNT 1 #define ESPHOME_ENTITY_MEDIA_PLAYER_COUNT 1 diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 5f75872a0f7..f91bd9b20c6 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -26,6 +26,9 @@ static constexpr size_t ESPHOME_DOMAIN_MAX_LEN = 20; // Maximum size for object_id buffer (friendly_name + null + margin) static constexpr size_t OBJECT_ID_MAX_LEN = 128; +// Maximum state length that Home Assistant will accept without raising ValueError +static constexpr size_t MAX_STATE_LEN = 255; + enum EntityCategory : uint8_t { ENTITY_CATEGORY_NONE = 0, ENTITY_CATEGORY_CONFIG = 1, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 49e57bd1467..ad0647d61ee 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -532,8 +532,7 @@ bool str_startswith(const std::string &str, const std::string &start); bool str_endswith(const std::string &str, const std::string &end); /// Truncate a string to a specific length. -/// @deprecated Allocates heap memory and is unused. Removed in 2026.7.0. -ESPDEPRECATED("Allocates heap memory and is unused. Removed in 2026.7.0.", "2026.1.0") +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. std::string str_truncate(const std::string &str, size_t length); /// Extract the part of the string until either the first occurrence of the specified character, or the end @@ -545,15 +544,13 @@ std::string str_until(const std::string &str, char ch); /// Convert the string to lower case. std::string str_lower_case(const std::string &str); /// Convert the string to upper case. -/// @deprecated Allocates heap memory and is unused. Removed in 2026.7.0. -ESPDEPRECATED("Allocates heap memory and is unused. Removed in 2026.7.0.", "2026.1.0") +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. std::string str_upper_case(const std::string &str); /// Convert a single char to snake_case: lowercase and space to underscore. constexpr char to_snake_case_char(char c) { return (c == ' ') ? '_' : (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c; } /// Convert the string to snake case (lowercase with underscores). -/// @deprecated Allocates heap memory and is unused in C++. Removed in 2026.7.0. -ESPDEPRECATED("Allocates heap memory and is unused in C++. Removed in 2026.7.0.", "2026.1.0") +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. std::string str_snake_case(const std::string &str); /// Sanitize a single char: keep alphanumerics, dashes, underscores; replace others with underscore. @@ -776,6 +773,16 @@ inline char *format_hex_to(char (&buffer)[N], T val) { return format_hex_to(buffer, reinterpret_cast(&val), sizeof(T)); } +/// Format std::vector as lowercase hex to buffer. +template inline char *format_hex_to(char (&buffer)[N], const std::vector &data) { + return format_hex_to(buffer, data.data(), data.size()); +} + +/// Format std::array as lowercase hex to buffer. +template inline char *format_hex_to(char (&buffer)[N], const std::array &data) { + return format_hex_to(buffer, data.data(), data.size()); +} + /// Calculate buffer size needed for format_hex_to: "XXXXXXXX...\0" = bytes * 2 + 1 constexpr size_t format_hex_size(size_t byte_count) { return byte_count * 2 + 1; } @@ -825,6 +832,18 @@ inline char *format_hex_pretty_to(char (&buffer)[N], const uint8_t *data, size_t return format_hex_pretty_to(buffer, N, data, length, separator); } +/// Format std::vector as uppercase hex with separator to buffer. +template +inline char *format_hex_pretty_to(char (&buffer)[N], const std::vector &data, char separator = ':') { + return format_hex_pretty_to(buffer, data.data(), data.size(), separator); +} + +/// Format std::array as uppercase hex with separator to buffer. +template +inline char *format_hex_pretty_to(char (&buffer)[N], const std::array &data, char separator = ':') { + return format_hex_pretty_to(buffer, data.data(), data.size(), separator); +} + /// Calculate buffer size needed for format_hex_pretty_to with uint16_t data: "XXXX:XXXX:...:XXXX\0" constexpr size_t format_hex_pretty_uint16_size(size_t count) { return count * 5; } @@ -858,8 +877,8 @@ static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = format_hex_pretty_size( static constexpr size_t MAC_ADDRESS_BUFFER_SIZE = MAC_ADDRESS_SIZE * 2 + 1; /// Format MAC address as XX:XX:XX:XX:XX:XX (uppercase, colon separators) -inline void format_mac_addr_upper(const uint8_t *mac, char *output) { - format_hex_pretty_to(output, MAC_ADDRESS_PRETTY_BUFFER_SIZE, mac, MAC_ADDRESS_SIZE, ':'); +inline char *format_mac_addr_upper(const uint8_t *mac, char *output) { + return format_hex_pretty_to(output, MAC_ADDRESS_PRETTY_BUFFER_SIZE, mac, MAC_ADDRESS_SIZE, ':'); } /// Format MAC address as xxxxxxxxxxxxxx (lowercase, no separators) @@ -868,34 +887,31 @@ inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { } /// Format the six-byte array \p mac into a MAC address. -/// @deprecated Allocates heap memory. Use format_mac_addr_upper() with a stack buffer instead. Removed in 2026.7.0. -ESPDEPRECATED("Allocates heap memory. Use format_mac_addr_upper() with stack buffer. Removed in 2026.7.0.", "2026.1.0") +/// @warning Allocates heap memory. Use format_mac_addr_upper() with a stack buffer instead. +/// Causes heap fragmentation on long-running devices. std::string format_mac_address_pretty(const uint8_t mac[6]); /// Format the byte array \p data of length \p len in lowercased hex. -/// @deprecated Allocates heap memory. Use format_hex_to() with a stack buffer instead. Removed in 2026.7.0. -ESPDEPRECATED("Allocates heap memory. Use format_hex_to() with stack buffer. Removed in 2026.7.0.", "2026.1.0") +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +/// Causes heap fragmentation on long-running devices. std::string format_hex(const uint8_t *data, size_t length); /// Format the vector \p data in lowercased hex. -/// @deprecated Allocates heap memory. Use format_hex_to() with a stack buffer instead. Removed in 2026.7.0. -ESPDEPRECATED("Allocates heap memory. Use format_hex_to() with stack buffer. Removed in 2026.7.0.", "2026.1.0") +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +/// Causes heap fragmentation on long-running devices. std::string format_hex(const std::vector &data); /// Format an unsigned integer in lowercased hex, starting with the most significant byte. -/// @deprecated Allocates heap memory. Use format_hex_to() with a stack buffer instead. Removed in 2026.7.0. -template::value, int> = 0> -ESPDEPRECATED("Allocates heap memory. Use format_hex_to() with stack buffer. Removed in 2026.7.0.", "2026.1.0") -std::string format_hex(T val) { +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +/// Causes heap fragmentation on long-running devices. +template::value, int> = 0> std::string format_hex(T val) { val = convert_big_endian(val); #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" return format_hex(reinterpret_cast(&val), sizeof(T)); #pragma GCC diagnostic pop } -/// @deprecated Allocates heap memory. Use format_hex_to() with a stack buffer instead. Removed in 2026.7.0. -template -ESPDEPRECATED("Allocates heap memory. Use format_hex_to() with stack buffer. Removed in 2026.7.0.", "2026.1.0") -std::string format_hex(const std::array &data) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +/// Format the std::array \p data in lowercased hex. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +/// Causes heap fragmentation on long-running devices. +template std::string format_hex(const std::array &data) { return format_hex(data.data(), data.size()); #pragma GCC diagnostic pop } @@ -1342,14 +1358,13 @@ class HighFrequencyLoopRequester { void get_mac_address_raw(uint8_t *mac); // NOLINT(readability-non-const-parameter) /// Get the device MAC address as a string, in lowercase hex notation. -/// @deprecated Allocates heap memory. Use get_mac_address_into_buffer() instead. Removed in 2026.7.0. -ESPDEPRECATED("Allocates heap memory. Use get_mac_address_into_buffer() instead. Removed in 2026.7.0.", "2026.1.0") +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +/// Use get_mac_address_into_buffer() instead. std::string get_mac_address(); /// Get the device MAC address as a string, in colon-separated uppercase hex notation. -/// @deprecated Allocates heap memory. Use get_mac_address_pretty_into_buffer() instead. Removed in 2026.7.0. -ESPDEPRECATED("Allocates heap memory. Use get_mac_address_pretty_into_buffer() instead. Removed in 2026.7.0.", - "2026.1.0") +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +/// Use get_mac_address_pretty_into_buffer() instead. std::string get_mac_address_pretty(); /// Get the device MAC address into the given buffer, in lowercase hex notation. diff --git a/requirements.txt b/requirements.txt index d3bb5b5dc50..9994148cf6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20260110.0 -aioesphomeapi==43.12.0 +aioesphomeapi==43.13.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import diff --git a/script/ci-custom.py b/script/ci-custom.py index 77d2ab287d0..1e2d07885e3 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -679,6 +679,52 @@ def lint_trailing_whitespace(fname, match): return "Trailing whitespace detected" +# Heap-allocating helpers that cause fragmentation on long-running embedded devices. +# These return std::string and should be replaced with stack-based alternatives. +HEAP_ALLOCATING_HELPERS = { + "format_hex": "format_hex_to() with a stack buffer", + "format_mac_address_pretty": "format_mac_addr_upper() with a stack buffer", + "get_mac_address": "get_mac_address_into_buffer() with a stack buffer", + "get_mac_address_pretty": "get_mac_address_pretty_into_buffer() with a stack buffer", + "str_truncate": "removal (function is unused)", + "str_upper_case": "removal (function is unused)", + "str_snake_case": "removal (function is unused)", +} + + +@lint_re_check( + # Use negative lookahead to exclude _to/_into_buffer variants + # format_hex(?!_) ensures we don't match format_hex_to, format_hex_pretty_to, etc. + # get_mac_address(?!_) ensures we don't match get_mac_address_into_buffer, etc. + r"[^\w](" + r"format_hex(?!_)|" + r"format_mac_address_pretty|" + r"get_mac_address_pretty(?!_)|" + r"get_mac_address(?!_)|" + r"str_truncate|" + r"str_upper_case|" + r"str_snake_case" + r")\s*\(", + include=cpp_include, + exclude=[ + # The definitions themselves + "esphome/core/helpers.h", + "esphome/core/helpers.cpp", + ], +) +def lint_no_heap_allocating_helpers(fname, match): + func = match.group(1) + replacement = HEAP_ALLOCATING_HELPERS.get(func, "a stack-based alternative") + return ( + f"{highlight(func + '()')} allocates heap memory. On long-running embedded devices, " + f"repeated heap allocations fragment memory over time. Even infrequent allocations " + f"become time bombs - the heap eventually cannot satisfy requests even with free " + f"memory available.\n" + f"Please use {replacement} instead.\n" + f"(If strictly necessary, add `// NOLINT` to the end of the line)" + ) + + @lint_content_find_check( "ESP_LOG", include=["*.h", "*.tcc"], diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index 4349c470f30..3b9b33c048e 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -18,6 +18,7 @@ canbus: tx_pin: ${tx_pin} can_id: 4 bit_rate: 50kbps + mode: NORMAL on_frame: - can_id: 500 then: diff --git a/tests/components/esp32_can/test.esp32-c6-idf.yaml b/tests/components/esp32_can/test.esp32-c6-idf.yaml index 6ef730c3786..ac978482fcd 100644 --- a/tests/components/esp32_can/test.esp32-c6-idf.yaml +++ b/tests/components/esp32_can/test.esp32-c6-idf.yaml @@ -12,17 +12,7 @@ esphome: canbus_id: esp32_internal_can can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] - - canbus.send: - # Extended ID explicit - canbus_id: esp32_internal_can_2 - use_extended_id: true - can_id: 0x100 - data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] - - canbus.send: - # Standard ID by default - canbus_id: esp32_internal_can_2 - can_id: 0x100 - data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] + # Note: esp32_internal_can_2 uses LISTENONLY mode, so no send actions canbus: - platform: esp32_can @@ -31,6 +21,7 @@ canbus: tx_pin: GPIO7 can_id: 4 bit_rate: 50kbps + mode: NORMAL on_frame: - can_id: 500 then: @@ -62,6 +53,7 @@ canbus: tx_pin: GPIO9 can_id: 4 bit_rate: 50kbps + mode: LISTENONLY on_frame: - can_id: 500 then: diff --git a/tests/components/nrf52/test.nrf52-adafruit.yaml b/tests/components/nrf52/test.nrf52-adafruit.yaml index 5fa0d6e88fb..0ad31993aed 100644 --- a/tests/components/nrf52/test.nrf52-adafruit.yaml +++ b/tests/components/nrf52/test.nrf52-adafruit.yaml @@ -19,3 +19,5 @@ nrf52: reg0: voltage: 2.1V uicr_erase: true + framework: + version: "2.6.1-7" diff --git a/tests/components/tuya/common.yaml b/tests/components/tuya/common.yaml index e177b7d056f..9986d398f1d 100644 --- a/tests/components/tuya/common.yaml +++ b/tests/components/tuya/common.yaml @@ -38,11 +38,14 @@ light: dimmer_datapoint: 2 min_value_datapoint: 3 color_temperature_datapoint: 4 + color_datapoint: 5 min_value: 1 max_value: 100 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds gamma_correct: 1 + color_type: RGB + color_type_lowercase: true number: - platform: tuya diff --git a/tests/components/web_server/common.yaml b/tests/components/web_server/common.yaml index 82307c189c7..35a605484c1 100644 --- a/tests/components/web_server/common.yaml +++ b/tests/components/web_server/common.yaml @@ -37,3 +37,4 @@ datetime: event: update: water_heater: +infrared: