mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
339 lines
14 KiB
C++
339 lines
14 KiB
C++
#pragma once
|
||
#include <array>
|
||
#include <cstdint>
|
||
#include <limits>
|
||
#include <memory>
|
||
#include <span>
|
||
#include <utility>
|
||
|
||
#include "esphome/core/defines.h"
|
||
#ifdef USE_API
|
||
#include "esphome/components/api/api_buffer.h"
|
||
#include "esphome/components/api/api_overflow_buffer.h"
|
||
#include "esphome/components/socket/socket.h"
|
||
#include "esphome/core/application.h"
|
||
#include "esphome/core/log.h"
|
||
#include "proto.h"
|
||
|
||
namespace esphome::api {
|
||
|
||
// uncomment to log raw packets
|
||
//#define HELPER_LOG_PACKETS
|
||
|
||
// Maximum message size limits to prevent OOM on constrained devices
|
||
// Handshake messages are limited to a small size for security
|
||
static constexpr uint16_t MAX_HANDSHAKE_SIZE = 128;
|
||
|
||
// Data message limits vary by platform based on available memory
|
||
#ifdef USE_ESP8266
|
||
static constexpr uint16_t MAX_MESSAGE_SIZE = 8192; // 8 KiB for ESP8266
|
||
#else
|
||
static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and other platforms
|
||
#endif
|
||
|
||
// Extra byte reserved in rx_buf_ beyond the message size so protobuf
|
||
// StringRef fields can be null-terminated in-place after decode.
|
||
static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1;
|
||
|
||
// Maximum number of messages to batch in a single write operation
|
||
// Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there)
|
||
static constexpr size_t MAX_MESSAGES_PER_BATCH = 34;
|
||
|
||
// Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars)
|
||
static constexpr size_t CLIENT_INFO_NAME_MAX_LEN = 32;
|
||
|
||
struct ReadPacketBuffer {
|
||
const uint8_t *data; // Points directly into frame helper's rx_buf_ (valid until next read_packet call)
|
||
uint16_t data_len;
|
||
uint16_t type;
|
||
};
|
||
|
||
// Packed message info structure to minimize memory usage
|
||
// Note: message_type is uint8_t — all current protobuf message types fit in 8 bits.
|
||
// The noise wire format encodes types as 16-bit, but the high byte is always 0.
|
||
// If message types ever exceed 255, this and encrypt_noise_message_ must be updated.
|
||
struct MessageInfo {
|
||
uint16_t offset; // Offset in buffer where message starts
|
||
uint16_t payload_size; // Size of the message payload
|
||
uint8_t message_type; // Message type (0-255)
|
||
uint8_t header_size; // Actual header size used (avoids recomputation in write path)
|
||
|
||
MessageInfo(uint8_t type, uint16_t off, uint16_t size, uint8_t hdr)
|
||
: offset(off), payload_size(size), message_type(type), header_size(hdr) {}
|
||
};
|
||
|
||
enum class APIError : uint16_t {
|
||
OK = 0,
|
||
WOULD_BLOCK = 1001,
|
||
BAD_INDICATOR = 1003,
|
||
BAD_DATA_PACKET = 1004,
|
||
TCP_NODELAY_FAILED = 1005,
|
||
TCP_NONBLOCKING_FAILED = 1006,
|
||
CLOSE_FAILED = 1007,
|
||
SHUTDOWN_FAILED = 1008,
|
||
BAD_STATE = 1009,
|
||
BAD_ARG = 1010,
|
||
SOCKET_READ_FAILED = 1011,
|
||
SOCKET_WRITE_FAILED = 1012,
|
||
OUT_OF_MEMORY = 1018,
|
||
CONNECTION_CLOSED = 1022,
|
||
#ifdef USE_API_NOISE
|
||
BAD_HANDSHAKE_PACKET_LEN = 1002,
|
||
HANDSHAKESTATE_READ_FAILED = 1013,
|
||
HANDSHAKESTATE_WRITE_FAILED = 1014,
|
||
HANDSHAKESTATE_BAD_STATE = 1015,
|
||
CIPHERSTATE_DECRYPT_FAILED = 1016,
|
||
CIPHERSTATE_ENCRYPT_FAILED = 1017,
|
||
HANDSHAKESTATE_SETUP_FAILED = 1019,
|
||
HANDSHAKESTATE_SPLIT_FAILED = 1020,
|
||
BAD_HANDSHAKE_ERROR_BYTE = 1021,
|
||
#endif
|
||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||
// Not an error: an unprovisioned device received a Noise client hello on a
|
||
// plaintext connection; the caller must hand the socket off to a Noise helper.
|
||
PROTOCOL_SWITCH_TO_NOISE = 1023,
|
||
#endif
|
||
};
|
||
|
||
const LogString *api_error_to_logstr(APIError err);
|
||
|
||
class APIFrameHelper {
|
||
public:
|
||
APIFrameHelper() = default;
|
||
explicit APIFrameHelper(std::unique_ptr<socket::Socket> socket) : socket_(std::move(socket)) {}
|
||
|
||
// Get client name (null-terminated)
|
||
const char *get_client_name() const { return this->client_name_; }
|
||
// Get client peername/IP into caller-provided buffer (fetches on-demand from socket)
|
||
// Returns pointer to buf for convenience in printf-style calls
|
||
const char *get_peername_to(std::span<char, socket::SOCKADDR_STR_LEN> buf) const;
|
||
// Set client name from buffer with length (truncates if needed)
|
||
void set_client_name(const char *name, size_t len) {
|
||
size_t copy_len = std::min(len, sizeof(this->client_name_) - 1);
|
||
memcpy(this->client_name_, name, copy_len);
|
||
this->client_name_[copy_len] = '\0';
|
||
}
|
||
virtual ~APIFrameHelper() = default;
|
||
virtual APIError init() = 0;
|
||
virtual APIError loop() = 0;
|
||
virtual APIError read_packet(ReadPacketBuffer *buffer) = 0;
|
||
bool can_write_without_blocking() { return this->state_ == State::DATA && this->overflow_buf_.empty(); }
|
||
int getpeername(struct sockaddr *addr, socklen_t *addrlen) { return socket_->getpeername(addr, addrlen); }
|
||
APIError close() {
|
||
if (state_ == State::CLOSED)
|
||
return APIError::OK; // Already closed
|
||
state_ = State::CLOSED;
|
||
int err = this->socket_->close();
|
||
if (err == -1)
|
||
return APIError::CLOSE_FAILED;
|
||
return APIError::OK;
|
||
}
|
||
APIError shutdown(int how) {
|
||
int err = this->socket_->shutdown(how);
|
||
if (err == -1)
|
||
return APIError::SHUTDOWN_FAILED;
|
||
if (how == SHUT_RDWR) {
|
||
state_ = State::CLOSED;
|
||
}
|
||
return APIError::OK;
|
||
}
|
||
// Manage TCP_NODELAY (Nagle's algorithm) based on message type.
|
||
//
|
||
// For non-log messages (sensor data, state updates): Always disable Nagle
|
||
// (NODELAY on) for immediate delivery - these are time-sensitive.
|
||
//
|
||
// For log messages: Use Nagle to coalesce multiple small log packets into
|
||
// fewer larger packets, reducing WiFi overhead. However, we limit batching
|
||
// to avoid excessive LWIP buffer pressure on memory-constrained devices.
|
||
// LWIP's TCP_OVERSIZE option coalesces the data into shared pbufs, but
|
||
// holding data too long waiting for Nagle's timer causes buffer exhaustion
|
||
// and dropped messages.
|
||
//
|
||
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle
|
||
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
|
||
//
|
||
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
|
||
// Flow (ESP8266): Log 1 (Nagle on) -> Log 2 -> Log 3 (NODELAY, flush all)
|
||
//
|
||
void set_nodelay_for_message(bool is_log_message) {
|
||
if (!is_log_message) {
|
||
if (this->nodelay_counter_) {
|
||
this->set_nodelay_raw_(true);
|
||
this->nodelay_counter_ = 0;
|
||
}
|
||
return;
|
||
}
|
||
// Log message: enable Nagle on first, flush after LOG_NAGLE_COUNT
|
||
if (!this->nodelay_counter_)
|
||
this->set_nodelay_raw_(false);
|
||
if (++this->nodelay_counter_ > LOG_NAGLE_COUNT) {
|
||
this->set_nodelay_raw_(true);
|
||
this->nodelay_counter_ = 0;
|
||
}
|
||
}
|
||
// Write a single protobuf message - the hot path (87-100% of all writes).
|
||
// Caller must ensure state is DATA before calling.
|
||
virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0;
|
||
// Write multiple protobuf messages in a single batched operation.
|
||
// Caller must ensure state is DATA and messages is not empty.
|
||
// messages contains (message_type, offset, length) for each message in the buffer.
|
||
// The buffer contains all messages with appropriate padding before each.
|
||
virtual APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) = 0;
|
||
// Get the maximum frame header padding required by this protocol (worst case)
|
||
uint8_t frame_header_padding() const { return frame_header_padding_; }
|
||
// Get the actual frame header size for a specific message.
|
||
// For noise: always returns frame_header_padding_ (fixed 7-byte header).
|
||
// For plaintext: computes actual size from varint lengths (3-6 bytes).
|
||
// Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC
|
||
// footer, plaintext has footer=0). If a protocol with a plaintext footer is ever
|
||
// added, this should become a virtual method.
|
||
uint8_t frame_header_size(uint16_t payload_size, uint8_t message_type) const {
|
||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||
return this->frame_footer_size_
|
||
? this->frame_header_padding_
|
||
: static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
|
||
#elif defined(USE_API_NOISE)
|
||
return this->frame_header_padding_;
|
||
#else // USE_API_PLAINTEXT only
|
||
return static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
|
||
#endif
|
||
}
|
||
// Get the frame footer size required by this protocol
|
||
uint8_t frame_footer_size() const { return frame_footer_size_; }
|
||
// Check if socket has buffered data ready to read.
|
||
// Contract: callers must read until it would block (EAGAIN/EWOULDBLOCK)
|
||
// or track that they stopped early and retry without this check.
|
||
// See Socket::ready() for details.
|
||
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
|
||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||
// Move the socket out of this helper so a replacement helper can take it
|
||
// over (plaintext to Noise handoff on unprovisioned devices). The drained
|
||
// helper must be destroyed right after.
|
||
std::unique_ptr<socket::Socket> release_socket_for_switch() { return std::move(this->socket_); }
|
||
#endif
|
||
// Release excess memory from internal buffers after initial sync
|
||
void release_buffers() {
|
||
// rx_buf_: Safe to clear only if no partial read in progress.
|
||
// rx_buf_len_ tracks bytes read so far; if non-zero, we're mid-frame
|
||
// and clearing would lose partially received data.
|
||
if (this->rx_buf_len_ == 0) {
|
||
this->rx_buf_.release();
|
||
}
|
||
}
|
||
|
||
protected:
|
||
// Drain backlogged overflow data to the socket and handle errors.
|
||
// Called when overflow_buf_.empty() is false. Out-of-line to keep the
|
||
// fast path (empty check) inline at call sites.
|
||
// Returns OK for transient errors (WOULD_BLOCK), SOCKET_WRITE_FAILED for hard errors.
|
||
APIError drain_overflow_and_handle_errors_();
|
||
|
||
// Sentinel values for the sent parameter in write_raw_ methods
|
||
static constexpr ssize_t WRITE_FAILED = -1; // Fast path: write()/writev() returned -1
|
||
static constexpr ssize_t WRITE_NOT_ATTEMPTED = -2; // Cold path: no write attempted yet
|
||
|
||
// Dispatch to write() or writev() based on iovec count
|
||
inline ssize_t ESPHOME_ALWAYS_INLINE write_iov_to_socket_(const struct iovec *iov, int iovcnt) {
|
||
return (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt);
|
||
}
|
||
|
||
// Inlined write methods — used by hot paths (write_protobuf_packet, write_protobuf_messages)
|
||
// These inline the fast path (overflow empty + full write) and tail-call the out-of-line
|
||
// slow path only on failure/partial write.
|
||
inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_buf_(const void *data, uint16_t len) {
|
||
if (this->overflow_buf_.empty()) [[likely]] {
|
||
ssize_t sent = this->socket_->write(data, len);
|
||
if (sent == static_cast<ssize_t>(len)) [[likely]] {
|
||
#ifdef HELPER_LOG_PACKETS
|
||
this->log_packet_sending_(data, len);
|
||
#endif
|
||
return APIError::OK;
|
||
}
|
||
// sent is -1 (WRITE_FAILED) or partial write count
|
||
return this->write_raw_buf_(data, len, sent);
|
||
}
|
||
return this->write_raw_buf_(data, len, WRITE_NOT_ATTEMPTED);
|
||
}
|
||
// Out-of-line write paths: handle partial writes, errors, overflow buffering
|
||
// sent: WRITE_NOT_ATTEMPTED (cold path), WRITE_FAILED (fast path write returned -1), or bytes sent (partial write)
|
||
APIError write_raw_buf_(const void *data, uint16_t len, ssize_t sent = WRITE_NOT_ATTEMPTED);
|
||
APIError write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len,
|
||
ssize_t sent = WRITE_NOT_ATTEMPTED);
|
||
#ifdef HELPER_LOG_PACKETS
|
||
void log_packet_sending_(const void *data, uint16_t len);
|
||
#endif
|
||
|
||
// Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit)
|
||
std::unique_ptr<socket::Socket> socket_;
|
||
|
||
// Common state enum for all frame helpers
|
||
// Note: Not all states are used by all implementations
|
||
// - INITIALIZE: Used by both Noise and Plaintext
|
||
// - CLIENT_HELLO, SERVER_HELLO, HANDSHAKE: Only used by Noise protocol
|
||
// - DATA: Used by both Noise and Plaintext
|
||
// - CLOSED: Used by both Noise and Plaintext
|
||
// - FAILED: Used by both Noise and Plaintext
|
||
// - EXPLICIT_REJECT: Only used by Noise protocol
|
||
enum class State : uint8_t {
|
||
INITIALIZE = 1,
|
||
CLIENT_HELLO = 2, // Noise only
|
||
SERVER_HELLO = 3, // Noise only
|
||
HANDSHAKE = 4, // Noise only
|
||
DATA = 5,
|
||
CLOSED = 6,
|
||
FAILED = 7,
|
||
EXPLICIT_REJECT = 8, // Noise only
|
||
};
|
||
|
||
// Fast inline state check for read_packet/write_protobuf_messages hot path.
|
||
// Returns OK only in DATA state; maps CLOSED/FAILED to BAD_STATE and any
|
||
// other intermediate state to WOULD_BLOCK.
|
||
inline APIError ESPHOME_ALWAYS_INLINE check_data_state_() const {
|
||
if (this->state_ == State::DATA)
|
||
return APIError::OK;
|
||
if (this->state_ == State::CLOSED || this->state_ == State::FAILED)
|
||
return APIError::BAD_STATE;
|
||
return APIError::WOULD_BLOCK;
|
||
}
|
||
|
||
// Backlog for unsent data when TCP send buffer is full (rarely used in production)
|
||
APIOverflowBuffer overflow_buf_;
|
||
APIBuffer rx_buf_;
|
||
|
||
// Client name buffer - stores name from Hello message or initial peername
|
||
char client_name_[CLIENT_INFO_NAME_MAX_LEN]{};
|
||
|
||
// Group smaller types together
|
||
uint16_t rx_buf_len_ = 0;
|
||
State state_{State::INITIALIZE};
|
||
uint8_t frame_header_padding_{0};
|
||
uint8_t frame_footer_size_{0};
|
||
// Nagle batching counter for log messages. 0 means NODELAY is enabled (immediate send).
|
||
// Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
|
||
// After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0.
|
||
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
|
||
// ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more.
|
||
#ifdef USE_ESP8266
|
||
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
|
||
#else
|
||
static constexpr uint8_t LOG_NAGLE_COUNT = 3;
|
||
#endif
|
||
uint8_t nodelay_counter_{0};
|
||
|
||
// Internal helper to set TCP_NODELAY socket option
|
||
void set_nodelay_raw_(bool enable) {
|
||
int val = enable ? 1 : 0;
|
||
this->socket_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &val, sizeof(int));
|
||
}
|
||
|
||
// Common initialization for both plaintext and noise protocols
|
||
APIError init_common_();
|
||
|
||
// Helper method to handle socket read results
|
||
APIError handle_socket_read_result_(ssize_t received);
|
||
};
|
||
|
||
} // namespace esphome::api
|
||
|
||
#endif // USE_API
|