Compare commits

..
Author SHA1 Message Date
J. Nick Koston 7f9812cef6 Terminate the loaded target before logging it, validate udp addresses on the raw lwip branch too, and resolve the socket default in the lwip_tcp test 2026-09-05 13:07:37 +02:00
J. Nick Koston a684e7e67d Decide the listener failure policy in setup, enforce the slot limit once, and reject lwip_tcp through the resolved socket implementation only 2026-09-05 11:23:59 +02:00
J. Nick Koston afc93a4dc8 Warn when any UDP address is dropped and print the boot delay with PRIu32 2026-09-04 21:21:50 +02:00
J. Nick Koston 0a74cb4e89 Apply the dial delay the same way at boot and after a disconnect, tell socket create and setblocking failures apart, and cap the delay so the wait always elapses 2026-09-04 12:42:07 +02:00
J. Nick Koston 9c4a08aaad Capture errno before it can be clobbered, warn when no UDP address is valid, and give the reboot test timing headroom 2026-09-04 07:25:50 +02:00
J. Nick Koston 68d5968405 Skip the dial delay when no inbound path waits, keep dial-out alive when the listener fails, check the socket results this PR made meaningful, and cover the clean reboot branch 2026-09-04 06:14:00 +02:00
J. Nick Koston 30da0e81fa Adopt the shared connect poll in async_tcp and name the refused implementation 2026-09-03 18:54:23 +02:00
J. Nick Koston 900638463a Move the connect poll into the socket component and dedupe target persistence 2026-09-03 17:41:48 +02:00
J. Nick Koston 1665d509d4 Bound the delay against uint32 wrap and print the effective boot delay 2026-09-03 12:16:42 +02:00
J. Nick Koston 29d8f9c7c6 Name the reboot cause and log the loaded target at setup 2026-09-03 11:28:32 +02:00
J. Nick Koston 18b5277254 Accept a bare outgoing_connection block and log the bound enforcement 2026-09-02 12:26:19 +02:00
J. Nick Koston b110cee973 Keep the platform level error message alongside the socket implementation check 2026-09-02 12:18:32 +02:00
J. Nick Koston 1171fbf892 Extract a reusable connect poll helper and enforce bounds at the write 2026-09-02 11:58:51 +02:00
J. Nick Koston ae549b25bb Report address parse failures from set_sockaddr and drop duplicated policy 2026-09-02 11:36:57 +02:00
J. Nick Koston 35ab455c10 Use the portable INADDR_NONE, self heal a bad remembered target, retry failed saves 2026-09-02 11:18:37 +02:00
J. Nick Koston dae9a5d1a6 Harden dial target validation and keep a fresh address on flash failure 2026-08-31 21:31:06 -04:00
J. Nick Koston bf380037a6 Refresh the watchdog on every authenticated removal 2026-08-31 21:01:03 -04:00
J. Nick Koston 493c7265ab Fix the IPv6 target guard so it actually compiles in 2026-08-31 20:44:29 -04:00
J. Nick Koston 238b6bb0b7 Simplify dial bookkeeping and settle the watchdog policy 2026-08-31 20:20:50 -04:00
J. Nick Koston 0211bdf2c0 Address review round: backoff fairness, watchdog integrity, silent failures 2026-08-31 19:26:01 -04:00
J. Nick Koston a95bfe0bcb Tighten comments and member ordering 2026-08-31 18:42:28 -04:00
J. Nick Koston 3a0bbc4c17 Merge remote-tracking branch 'origin/dev' into api-reverse-connection 2026-08-31 18:40:27 -04:00
J. Nick Koston cc9dc95cab Only dial immediately at boot for deep sleep devices 2026-08-31 18:24:40 -04:00
J. Nick Koston 79325fe59a Dial immediately when no client has ever connected and polish review nits 2026-08-31 17:36:57 -04:00
pre-commit-ci-lite[bot] 5fbe09b68c apply automatic formatting fixes 2026-08-31 21:28:10 +00:00
J. Nick Koston f41ff5aaeb Merge remote-tracking branch 'origin/dev' into api-reverse-connection 2026-08-31 17:26:02 -04:00
J. Nick Koston 7b57fdd35c Gate the dial-back flag on this connection's transport and tune redial policy 2026-08-31 17:19:17 -04:00
J. Nick Koston fc671d38e5 Address review findings and advertise support in device info 2026-08-31 16:44:49 -04:00
J. Nick Koston 4e0cda0287 Select the dial-back target with an explicit hello flag instead of guessing from client info 2026-08-31 15:40:15 -04:00
J. Nick Koston 676eac7686 [api] Add outgoing connections so the device can dial Home Assistant 2026-08-31 14:06:05 -05:00
97 changed files with 1461 additions and 1675 deletions
+1 -2
View File
@@ -162,9 +162,8 @@ void Alpha3::send_request_(uint8_t *request, size_t len) {
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->geni_handle_, len,
request, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
if (status) {
if (status)
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
}
}
void Alpha3::update() {
+96 -2
View File
@@ -5,7 +5,7 @@ from typing import Any
from esphome import automation
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.const import CONF_DESCRIPTION
from esphome.components.const import CONF_DESCRIPTION, CONF_HOST
from esphome.components.logger import request_log_listener
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
@@ -24,6 +24,8 @@ from esphome.const import (
CONF_CAPTURE_RESPONSE,
CONF_DATA,
CONF_DATA_TEMPLATE,
CONF_DELAY,
CONF_ENABLE_IPV6,
CONF_ENCRYPTION,
CONF_EVENT,
CONF_ID,
@@ -47,6 +49,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.cpp_generator import MockObj, TemplateArgsType
import esphome.final_validate as fv
from esphome.helpers import fnv1_hash
from esphome.types import ConfigFragmentType, ConfigType
@@ -133,6 +136,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
CONF_LISTEN_BACKLOG = "listen_backlog"
CONF_MAX_SEND_QUEUE = "max_send_queue"
CONF_OUTGOING_CONNECTION = "outgoing_connection"
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
@@ -284,9 +288,44 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
# (not max_connections, which is the upper limit rarely reached)
socket.consume_sockets(3, "api")(config)
socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config)
if CONF_OUTGOING_CONNECTION in config:
socket.consume_sockets(1, "api_outgoing_connection")(config)
return config
def _validate_outgoing_connection(config: ConfigType) -> ConfigType:
if CONF_OUTGOING_CONNECTION not in config:
return config
if CONF_ENCRYPTION not in config:
raise cv.Invalid(
"outgoing_connection requires 'encryption' so the peer is verified by key",
path=[CONF_OUTGOING_CONNECTION],
)
return config
_OUTGOING_CONNECTION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_HOST): cv.ipaddress,
cv.Optional(CONF_PORT, default=6054): cv.port,
# Bounded to half the device's uint32 millisecond range so the wait
# always elapses under a wrapping clock
cv.Optional(CONF_DELAY, default="60s"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(milliseconds=2147483647)),
),
}
)
def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType:
# A bare `outgoing_connection:` block is valid; without a host the device
# dials the remembered last dial-back client
if config is None:
config = {}
return _OUTGOING_CONNECTION_SCHEMA(config)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -311,6 +350,7 @@ CONFIG_SCHEMA = cv.All(
): ACTIONS_SCHEMA,
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
cv.Optional(CONF_ENCRYPTION): encryption_schema,
cv.Optional(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema,
cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
@@ -367,6 +407,7 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_validate_outgoing_connection,
_consume_api_sockets,
_register_provisioning_source,
)
@@ -423,7 +464,52 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
return config
FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings
def _validate_outgoing_socket_implementation(config: ConfigType) -> ConfigType:
"""Reject the raw lwip_tcp socket, the only option on ESP8266 and RP2040.
Checked against the resolved implementation so an explicit selection on
another platform is caught the same way as the platform default.
"""
if CONF_OUTGOING_CONNECTION not in config:
return config
from esphome.components import socket
socket_conf = fv.full_config.get().get("socket") or {}
if (
impl := socket_conf.get(socket.CONF_IMPLEMENTATION)
) in socket.IMPLEMENTATIONS_WITHOUT_CONNECT:
raise cv.Invalid(
f"outgoing_connection is not supported with the {impl} socket "
"implementation (the only one on ESP8266 and RP2040) because it "
"cannot make outgoing connections",
path=[CONF_OUTGOING_CONNECTION],
)
return config
def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
"""An IPv6 host can never be parsed, so never dialed, without IPv6."""
if (
(outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None
or (host := outgoing.get(CONF_HOST)) is None
or host.version != 6
):
return config
network_conf = fv.full_config.get().get("network") or {}
if not network_conf.get(CONF_ENABLE_IPV6):
raise cv.Invalid(
"outgoing_connection host is an IPv6 address but IPv6 is not "
"enabled; set 'network: enable_ipv6: true'",
path=[CONF_OUTGOING_CONNECTION, CONF_HOST],
)
return config
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_esp8266_action_strings,
_validate_outgoing_socket_implementation,
_validate_outgoing_host_ipv6,
)
def _add_action_strings(
@@ -606,6 +692,13 @@ async def to_code(config: ConfigType) -> None:
else:
cg.add_define("USE_API_PLAINTEXT")
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is not None:
cg.add_define("USE_API_OUTGOING_CONNECTION")
if (host := outgoing.get(CONF_HOST)) is not None:
cg.add_define("API_OUTGOING_CONNECTION_HOST", str(host))
cg.add_define("API_OUTGOING_CONNECTION_PORT", outgoing[CONF_PORT])
cg.add_define("API_OUTGOING_CONNECTION_DELAY", outgoing[CONF_DELAY])
cg.add_define("USE_API")
cg.add_global(api_ns.using)
@@ -992,6 +1085,7 @@ _define_filter = filter_source_files_from_defines(
"user_services.cpp": "USE_API_USER_DEFINED_ACTIONS",
"api_frame_helper_noise.cpp": "USE_API_NOISE",
"api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT",
"api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION",
}
)
+9
View File
@@ -112,6 +112,11 @@ message HelloRequest {
string client_info = 1;
uint32 api_version_major = 2;
uint32 api_version_minor = 3;
// Set by clients that can accept connections the device opens to them
// (see api: outgoing_connection:). The device remembers this client's
// address as the target to dial when no such client is connected.
bool outgoing_connection_target = 4 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
}
// Confirmation of successful connection request.
@@ -331,6 +336,10 @@ message DeviceInfoResponse {
// all-zeros PSK, so the api encryption key can be provisioned without being
// sent in plaintext (protects against passive sniffing, not active MITM)
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
// Device is built with the api outgoing_connection option and can open
// the TCP connection to a dial-back target itself
bool api_outgoing_connection_supported = 27 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
}
// ==================== DEVICE CAPABILITIES ====================
+17 -2
View File
@@ -1822,6 +1822,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
this->complete_authentication_();
#ifdef USE_API_OUTGOING_CONNECTION
// With a PSK set only key-verified transports reach hello: plaintext and
// zero-PSK are rejected, and pre-activation sessions are force-closed
if (msg.outgoing_connection_target && !this->flags_.outgoing_connection_target) {
if (this->parent_->get_noise_ctx().has_psk()) {
this->flags_.outgoing_connection_target = true;
this->parent_->on_outgoing_target_client(this);
} else {
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Dial-back target refused; no key active"));
}
}
#endif
return this->send_message(resp);
}
@@ -1944,6 +1957,9 @@ bool APIConnection::send_device_info_response_() {
// one) so this advertisement survives the plaintext removal in 2027.2.0.
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
#endif
#ifdef USE_API_OUTGOING_CONNECTION
resp.api_outgoing_connection_supported = true;
#endif
#endif
#ifdef USE_DEVICES
size_t device_index = 0;
@@ -2391,9 +2407,8 @@ void APIConnection::process_batch_() {
} else if (payload_size == 0) {
// payload_size == 0 with remove set means encoding hit OOM and the
// connection is being dropped; warn only for a genuinely oversized message
if (!this->flags_.remove) {
if (!this->flags_.remove)
ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
}
this->clear_batch_();
}
return;
+18
View File
@@ -375,6 +375,21 @@ class APIConnection final : public APIServerConnectionBase {
return this->helper_->get_peername_to(buf);
}
#ifdef USE_API_OUTGOING_CONNECTION
/// Outgoing connection: send our server hello immediately so the peer can
/// pick the matching key. Outgoing connections are only dialed when a PSK
/// is set, so the helper is always the noise helper. Call after start().
void mark_outgoing() {
if (this->flags_.remove) {
return; // start() failed; the connection is already being torn down
}
APIError err = static_cast<APINoiseFrameHelper *>(this->helper_.get())->send_server_hello_first();
if (err != APIError::OK) {
this->fatal_error_with_log_(LOG_STR("Server hello failed"), err);
}
}
#endif
protected:
bool try_to_clear_buffer_slow_(bool log_out_of_space);
@@ -745,6 +760,9 @@ class APIConnection final : public APIServerConnectionBase {
uint8_t batch_first_message : 1; // For batch buffer allocation
uint8_t should_try_send_immediately : 1; // True after initial states are sent
uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check
#ifdef USE_API_OUTGOING_CONNECTION
uint8_t outgoing_connection_target : 1; // Client declared itself a dial-back target in its hello
#endif
#ifdef HAS_PROTO_MESSAGE_DUMP
uint8_t log_only_mode : 1;
#endif
+2 -1
View File
@@ -282,7 +282,8 @@ class APIFrameHelper {
DATA = 5,
CLOSED = 6,
FAILED = 7,
EXPLICIT_REJECT = 8, // Noise only
EXPLICIT_REJECT = 8, // Noise only
CLIENT_HELLO_OUTGOING = 9, // Noise only: like CLIENT_HELLO but the server hello already went out (outgoing conn)
};
// Fast inline state check for read_packet/write_protobuf_messages hot path.
@@ -81,6 +81,13 @@ APIError APINoiseFrameHelper::init() {
state_ = State::CLIENT_HELLO;
return APIError::OK;
}
#ifdef USE_API_OUTGOING_CONNECTION
APIError APINoiseFrameHelper::send_server_hello_first() {
// The peer needs our name and MAC to pick the key before its first message
this->state_ = State::CLIENT_HELLO_OUTGOING;
return this->send_server_hello_frame_();
}
#endif
#ifdef USE_API_PLAINTEXT
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
APIError err = this->init();
@@ -253,6 +260,9 @@ APIError APINoiseFrameHelper::state_action_() {
HELPER_LOG("Bad state for method: %d", (int) this->state_);
return APIError::BAD_STATE;
case State::CLIENT_HELLO:
#ifdef USE_API_OUTGOING_CONNECTION
case State::CLIENT_HELLO_OUTGOING:
#endif
return this->state_action_client_hello_();
case State::SERVER_HELLO:
return this->state_action_server_hello_();
@@ -285,11 +295,16 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size);
}
#ifdef USE_API_OUTGOING_CONNECTION
if (this->state_ == State::CLIENT_HELLO_OUTGOING) {
// Server hello already went out at handoff
return this->start_handshake_();
}
#endif
state_ = State::SERVER_HELLO;
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_server_hello_() {
// send server hello
APIError APINoiseFrameHelper::send_server_hello_frame_() {
const auto &name = App.get_name();
char mac[MAC_ADDRESS_BUFFER_SIZE];
get_mac_address_into_buffer(mac);
@@ -313,15 +328,18 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
// node mac, terminated by null byte
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
APIError aerr = write_frame_(msg, total_size);
return write_frame_(msg, total_size);
}
APIError APINoiseFrameHelper::state_action_server_hello_() {
APIError aerr = this->send_server_hello_frame_();
if (aerr != APIError::OK)
return aerr;
// start handshake
aerr = init_handshake_();
return this->start_handshake_();
}
APIError APINoiseFrameHelper::start_handshake_() {
APIError aerr = init_handshake_();
if (aerr != APIError::OK)
return aerr;
state_ = State::HANDSHAKE;
return APIError::OK;
}
@@ -28,6 +28,12 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Seeds the already-read header bytes and pumps the handshake state machine
// until it would block.
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
#endif
#ifdef USE_API_OUTGOING_CONNECTION
// Send the server hello immediately so the peer can pick the key before
// its PSK-mixed message. Call after init(); the mode is tracked in state_
// so the helper does not grow.
APIError send_server_hello_first();
#endif
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
@@ -39,6 +45,8 @@ class APINoiseFrameHelper final : public APIFrameHelper {
APIError state_action_();
APIError state_action_client_hello_();
APIError state_action_server_hello_();
APIError send_server_hello_frame_();
APIError start_handshake_();
APIError state_action_handshake_();
APIError state_action_handshake_read_();
APIError state_action_handshake_write_();
@@ -0,0 +1,236 @@
#include "api_outgoing_connection.h"
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
#include "api_connection.h"
#include "api_server.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cerrno>
#include <cinttypes>
#include <cstring>
namespace esphome::api {
static const char *const TAG = "api.outgoing";
void OutgoingConnectionManager::setup() {
#ifndef API_OUTGOING_CONNECTION_HOST
this->target_pref_ = global_preferences->make_preference<SavedOutgoingTarget>(629847102UL, true);
if (this->target_pref_.load(&this->saved_)) {
// Defend against a corrupt or truncated blob before the first read
this->saved_.host[sizeof(this->saved_.host) - 1] = '\0';
this->host_persisted_ = true;
ESP_LOGD(TAG, "Loaded target %s", this->saved_.host);
} else {
// Never saved, or the blob failed its size/CRC check
ESP_LOGD(TAG, "No saved target");
this->saved_ = {};
}
#endif
}
void OutgoingConnectionManager::loop(APIServer *server) {
if (server->has_outgoing_target_client_()) {
return; // on_target_client() already reset the dial state
}
if (this->dialed_conn_ != nullptr) {
// A live dialed session (flagged or not, e.g. a host: peer) is the
// target; a silent one dies on the handshake timeout
return;
}
const uint32_t now = App.get_loop_component_start_time();
switch (this->state_) {
case DialState::DIAL_STATE_IDLE:
#ifdef USE_DEEP_SLEEP
// A deep sleep wake window is too short to spend on the delay
this->schedule_wait_(now, BACKOFF_MIN_MS);
#else
// Target went away; give it the configured delay to reconnect first
this->schedule_wait_(now, API_OUTGOING_CONNECTION_DELAY);
#endif
break;
case DialState::DIAL_STATE_WAITING:
if (now - this->state_ts_ >= this->wait_) {
this->try_dial_(server, now);
}
break;
case DialState::DIAL_STATE_CONNECTING:
this->poll_connect_(server, now);
break;
}
}
void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) {
if (!network::is_connected()) {
// Flips within seconds of boot; recheck fast so a deep sleep wake
// window is not spent waiting
this->schedule_wait_(now, NETWORK_RETRY_MS);
return;
}
const char *host = this->target_host_();
if (host == nullptr) {
// The steady state until a dial-back client has ever connected
ESP_LOGV(TAG, "Not dialing: no target");
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
return;
}
const bool at_limit = server->at_client_limit_();
if (at_limit || !server->noise_ctx_.has_psk()) {
ESP_LOGD(TAG, "Not dialing: %s", at_limit ? "max connections" : "no key");
// Not a dial failure; retry without escalating the backoff
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
return;
}
struct sockaddr_storage addr;
socklen_t addr_len =
socket::set_sockaddr((struct sockaddr *) &addr, sizeof(addr), host, API_OUTGOING_CONNECTION_PORT);
if (addr_len == 0) {
ESP_LOGW(TAG, "Invalid target %s", host);
#ifndef API_OUTGOING_CONNECTION_HOST
// A corrupt remembered value can never become dialable; forget it
// (covers an IPv6 literal left by an earlier enable_ipv6 build too)
this->saved_ = {};
if (!this->persist_target_()) {
ESP_LOGW(TAG, "Failed to clear target");
}
#endif
this->schedule_retry_(now);
return;
}
this->dial_socket_ = socket::socket_loop_monitored(((struct sockaddr *) &addr)->sa_family, SOCK_STREAM, IPPROTO_TCP);
if (!this->dial_socket_ || this->dial_socket_->setblocking(false) != 0) {
ESP_LOGW(TAG, "Socket %s failed: errno %d", this->dial_socket_ ? "setblocking" : "create", errno);
this->schedule_retry_(now);
return;
}
ESP_LOGD(TAG, "Dialing %s:%u", host, API_OUTGOING_CONNECTION_PORT);
int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len);
if (err == 0) {
// Immediate success (possible for localhost)
this->handoff_(server, now);
return;
}
if (errno != EINPROGRESS) {
ESP_LOGW(TAG, "Connect failed: errno %d", errno);
this->schedule_retry_(now);
return;
}
this->state_ = DialState::DIAL_STATE_CONNECTING;
this->state_ts_ = now;
this->last_poll_ = now;
}
void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
if (now - this->state_ts_ >= CONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "Connect timeout");
this->schedule_retry_(now);
return;
}
if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) {
return;
}
this->last_poll_ = now;
int err = 0;
switch (socket::poll_connect(*this->dial_socket_, err)) {
case socket::ConnectPollResult::CONNECT_POLL_PENDING:
break;
case socket::ConnectPollResult::CONNECT_POLL_CONNECTED:
this->handoff_(server, now);
break;
case socket::ConnectPollResult::CONNECT_POLL_ERROR:
ESP_LOGW(TAG, "Connect failed: %d", err);
this->schedule_retry_(now);
break;
}
}
void OutgoingConnectionManager::handoff_(APIServer *server, uint32_t now) {
this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_));
if (this->dialed_conn_ == nullptr) {
// Only preconditions (slot limit, key cleared) refuse the handoff; the
// peer is reachable, so do not escalate the backoff
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
return;
}
// Connected; dialed_conn_ gates further dialing until the session settles
this->state_ = DialState::DIAL_STATE_IDLE;
}
void OutgoingConnectionManager::schedule_wait_(uint32_t now, uint32_t wait) {
this->dial_socket_.reset(); // no-op when the socket was handed off
this->state_ = DialState::DIAL_STATE_WAITING;
this->state_ts_ = now;
this->wait_ = wait;
}
void OutgoingConnectionManager::schedule_retry_(uint32_t now) {
// +/-20% jitter so a fleet of devices does not retry one server in lockstep
const uint32_t jitter_span = this->backoff_ / 5;
this->schedule_wait_(now, this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1)));
this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS);
}
void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_authenticated) {
if (conn != this->dialed_conn_) {
return;
}
this->dialed_conn_ = nullptr;
if (was_authenticated) {
// A working peer (e.g. a host: target that never sends the flag)
// disconnected normally; state is IDLE, so loop() applies the delay
this->backoff_ = BACKOFF_MIN_MS;
} else {
this->schedule_retry_(App.get_loop_component_start_time());
}
}
void OutgoingConnectionManager::on_target_client(APIConnection *conn) {
// The target is connected; stop any dial in flight and reset the backoff.
// A dialed connection stays tracked unless it is this one: an inbound
// target must not orphan a still-open dial.
this->dial_socket_.reset();
if (conn == this->dialed_conn_) {
this->dialed_conn_ = nullptr;
}
this->state_ = DialState::DIAL_STATE_IDLE;
this->backoff_ = BACKOFF_MIN_MS;
#ifndef API_OUTGOING_CONNECTION_HOST
SavedOutgoingTarget target{};
conn->get_peername_to(target.host);
if (target.host[0] == '\0') {
ESP_LOGW(TAG, "Could not read peer address; not remembering target");
return;
}
if (this->host_persisted_ && strcmp(target.host, this->saved_.host) == 0) {
return; // unchanged and already on flash; avoid flash wear
}
// Use the fresh address this boot even if the flash write fails; a failed
// write is retried on the next flagged hello via host_persisted_
this->saved_ = target;
if (!this->persist_target_()) {
ESP_LOGW(TAG, "Failed to save target");
return;
}
ESP_LOGD(TAG, "Saved %s as outgoing connection target", this->saved_.host);
#endif
}
void OutgoingConnectionManager::dump_config() const {
const char *host = this->target_host_();
if (host == nullptr) {
host = "none remembered yet";
}
// The boot delay differs from delay: on deep sleep builds, so print the
// value that actually applies
ESP_LOGCONFIG(TAG,
" Outgoing connection port: %u\n"
" Outgoing connection host: %s\n"
" Outgoing connection boot delay: %" PRIu32 "ms",
API_OUTGOING_CONNECTION_PORT, host, BOOT_WAIT_MS);
}
} // namespace esphome::api
#endif // USE_API && USE_API_OUTGOING_CONNECTION
@@ -0,0 +1,117 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
#ifdef USE_SOCKET_IMPL_LWIP_TCP
#error "api outgoing_connection needs a socket implementation that can make outgoing connections"
#endif
#ifndef USE_API_NOISE
#error "api outgoing_connection needs noise encryption so the peer is verified by key"
#endif
#include "esphome/components/socket/socket.h"
#include "esphome/core/preferences.h"
#include <memory>
namespace esphome::api {
class APIServer;
class APIConnection;
// Follows the build's address family (ifdef'd in socket/headers.h): toggling
// enable_ipv6 changes the blob size, load() rejects the old blob, and the
// target is simply relearned
static constexpr size_t SAVED_TARGET_HOST_LEN = socket::SOCKADDR_STR_LEN;
struct SavedOutgoingTarget {
// IP as text so the socket component's v4-mapped-IPv6 normalization is
// reused on both ends; empty = none remembered
char host[SAVED_TARGET_HOST_LEN];
} PACKED; // NOLINT
/// Dials out when no dial-back target client is connected. Only the TCP
/// direction flips: the device stays the Noise responder, so both sides
/// still verify by key. Targets the YAML host or the last remembered client.
class OutgoingConnectionManager {
public:
void setup();
void loop(APIServer *server);
/// A key-verified client declared itself a dial-back target; last one wins
void on_target_client(APIConnection *conn);
/// Clears the dialed-connection gate; dying unauthenticated escalates the backoff
void on_client_removed(APIConnection *conn, bool was_authenticated);
void on_shutdown() { this->dial_socket_.reset(); }
void dump_config() const;
protected:
enum class DialState : uint8_t {
DIAL_STATE_IDLE,
DIAL_STATE_WAITING,
DIAL_STATE_CONNECTING,
};
static constexpr uint32_t BACKOFF_MIN_MS = 5000;
static constexpr uint32_t BACKOFF_MAX_MS = 300000;
static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000;
static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250;
static constexpr uint32_t NETWORK_RETRY_MS = 500;
static constexpr uint32_t PRECONDITION_RETRY_MS = 5000;
// Boot waits for the client to connect in first; a deep sleep wake window
// is short, so connecting out immediately is the wake state
#ifdef USE_DEEP_SLEEP
static constexpr uint32_t BOOT_WAIT_MS = 0;
#else
static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
#endif
void try_dial_(APIServer *server, uint32_t now);
void poll_connect_(APIServer *server, uint32_t now);
// Hand the connected socket to the server and gate on the new connection
void handoff_(APIServer *server, uint32_t now);
// Close any half-open dial and wait a jittered backoff before retrying
void schedule_retry_(uint32_t now);
// Wait without escalating the backoff (used for unmet preconditions)
void schedule_wait_(uint32_t now, uint32_t wait);
#ifndef API_OUTGOING_CONNECTION_HOST
// Write saved_ to flash, tracking success in host_persisted_
bool persist_target_() {
this->host_persisted_ = this->target_pref_.save(&this->saved_) && global_preferences->sync();
return this->host_persisted_;
}
#endif
const char *target_host_() const {
#ifdef API_OUTGOING_CONNECTION_HOST
return API_OUTGOING_CONNECTION_HOST;
#else
return this->saved_.host[0] != '\0' ? this->saved_.host : nullptr;
#endif
}
// Pointers first (4 bytes each on 32-bit)
std::unique_ptr<socket::Socket> dial_socket_;
// Compared only, never dereferenced
APIConnection *dialed_conn_{nullptr};
#ifndef API_OUTGOING_CONNECTION_HOST
ESPPreferenceObject target_pref_;
#endif
// 4-byte types
uint32_t backoff_{BACKOFF_MIN_MS};
uint32_t wait_{BOOT_WAIT_MS};
uint32_t state_ts_{0};
uint32_t last_poll_{0};
// Byte-aligned types last
#ifndef API_OUTGOING_CONNECTION_HOST
SavedOutgoingTarget saved_{};
// False while saved_ holds a value the flash write failed for; retried on
// the next flagged hello
bool host_persisted_{false};
#endif
DialState state_{DialState::DIAL_STATE_WAITING};
};
} // namespace esphome::api
#endif // USE_API && USE_API_OUTGOING_CONNECTION
+11
View File
@@ -15,6 +15,11 @@ bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value)
case 3:
this->api_version_minor = value;
break;
#ifdef USE_API_OUTGOING_CONNECTION
case 4:
this->outgoing_connection_target = value != 0;
break;
#endif
default:
return false;
}
@@ -175,6 +180,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
#endif
#ifdef USE_API_NOISE
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
#endif
#ifdef USE_API_OUTGOING_CONNECTION
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 27, this->api_outgoing_connection_supported);
#endif
return pos;
}
@@ -240,6 +248,9 @@ uint32_t DeviceInfoResponse::calculate_size() const {
#endif
#ifdef USE_API_NOISE
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
#endif
#ifdef USE_API_OUTGOING_CONNECTION
size += ProtoSize::calc_bool(2, this->api_outgoing_connection_supported);
#endif
return size;
}
+8 -2
View File
@@ -412,13 +412,16 @@ class CommandProtoMessage : public ProtoDecodableMessage {
class HelloRequest final : public ProtoDecodableMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 1;
static constexpr uint8_t ESTIMATED_SIZE = 17;
static constexpr uint8_t ESTIMATED_SIZE = 19;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("hello_request"); }
#endif
StringRef client_info{};
uint32_t api_version_major{0};
uint32_t api_version_minor{0};
#ifdef USE_API_OUTGOING_CONNECTION
bool outgoing_connection_target{false};
#endif
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
@@ -549,7 +552,7 @@ class SerialProxyInfo final : public ProtoMessage {
class DeviceInfoResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 10;
static constexpr uint16_t ESTIMATED_SIZE = 312;
static constexpr uint16_t ESTIMATED_SIZE = 315;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
#endif
@@ -607,6 +610,9 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif
#ifdef USE_API_NOISE
bool api_encryption_provisionable{false};
#endif
#ifdef USE_API_OUTGOING_CONNECTION
bool api_outgoing_connection_supported{false};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
+6
View File
@@ -885,6 +885,9 @@ const char *HelloRequest::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("client_info"), this->client_info);
dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major);
dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor);
#ifdef USE_API_OUTGOING_CONNECTION
dump_field(out, ESPHOME_PSTR("outgoing_connection_target"), this->outgoing_connection_target);
#endif
return out.c_str();
}
const char *HelloResponse::dump_to(DumpBuffer &out) const {
@@ -1008,6 +1011,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#endif
#ifdef USE_API_NOISE
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
#endif
#ifdef USE_API_OUTGOING_CONNECTION
dump_field(out, ESPHOME_PSTR("api_outgoing_connection_supported"), this->api_outgoing_connection_supported);
#endif
return out.c_str();
}
+135 -47
View File
@@ -34,7 +34,46 @@ APIServer::APIServer() { global_api_server = this; }
void APIServer::socket_failed_(const LogString *msg) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
this->destroy_socket_();
this->mark_failed();
}
bool APIServer::create_listen_socket_() {
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
if (this->socket_ == nullptr) {
this->socket_failed_(LOG_STR("creation"));
return false;
}
int enable = 1;
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
if (err != 0) {
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
// we can still continue
}
err = this->socket_->setblocking(false);
if (err != 0) {
this->socket_failed_(LOG_STR("nonblocking"));
return false;
}
struct sockaddr_storage server;
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
if (sl == 0) {
this->socket_failed_(LOG_STR("set sockaddr"));
return false;
}
err = this->socket_->bind((struct sockaddr *) &server, sl);
if (err != 0) {
this->socket_failed_(LOG_STR("bind"));
return false;
}
err = this->socket_->listen(this->listen_backlog_);
if (err != 0) {
this->socket_failed_(LOG_STR("listen"));
return false;
}
return true;
}
void APIServer::setup() {
@@ -53,41 +92,14 @@ void APIServer::setup() {
#endif
#endif
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
if (this->socket_ == nullptr) {
this->socket_failed_(LOG_STR("creation"));
return;
}
int enable = 1;
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
if (err != 0) {
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
// we can still continue
}
err = this->socket_->setblocking(false);
if (err != 0) {
this->socket_failed_(LOG_STR("nonblocking"));
return;
}
struct sockaddr_storage server;
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
if (sl == 0) {
this->socket_failed_(LOG_STR("set sockaddr"));
return;
}
err = this->socket_->bind((struct sockaddr *) &server, sl);
if (err != 0) {
this->socket_failed_(LOG_STR("bind"));
return;
}
err = this->socket_->listen(this->listen_backlog_);
if (err != 0) {
this->socket_failed_(LOG_STR("listen"));
if (!this->create_listen_socket_()) {
#ifdef USE_API_OUTGOING_CONNECTION
// Dial-out needs no listener; degrade instead of stopping the component
this->status_set_error(LOG_STR("listen socket failed"));
#else
this->mark_failed();
return;
#endif
}
#ifdef USE_LOGGER
@@ -135,6 +147,9 @@ void APIServer::setup() {
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
}
#ifdef USE_API_OUTGOING_CONNECTION
this->outgoing_conn_.setup();
#endif
}
void APIServer::loop() {
@@ -143,6 +158,12 @@ void APIServer::loop() {
this->accept_new_connections_();
}
#ifdef USE_API_OUTGOING_CONNECTION
if (!this->shutting_down_) {
this->outgoing_conn_.loop(this);
}
#endif
if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
// (cancelled scheduler items sit in heap memory until their scheduled time).
@@ -151,7 +172,12 @@ void APIServer::loop() {
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_connected_ > this->reboot_timeout_) {
ESP_LOGE(TAG, "No clients; rebooting");
// Distinguish a wrong-key peer from nothing connecting at all
if (this->saw_unauthenticated_client_) {
ESP_LOGE(TAG, "Clients connected but none authenticated; rebooting");
} else {
ESP_LOGE(TAG, "No clients; rebooting");
}
App.reboot();
}
}
@@ -203,6 +229,15 @@ void APIServer::remove_client_(uint8_t client_index) {
std::string client_peername(client->get_peername_to(peername_buf));
#endif
// Read before the swap-and-reset below destroys the connection
const bool was_authenticated = client->is_authenticated();
#ifdef USE_API_OUTGOING_CONNECTION
if (client->flags_.outgoing_connection_target) {
this->outgoing_target_count_--;
}
this->outgoing_conn_.on_client_removed(client.get(), was_authenticated);
#endif
// Close socket now (was deferred from on_fatal_error to allow getpeername)
client->helper_->close();
@@ -221,9 +256,18 @@ void APIServer::remove_client_(uint8_t client_index) {
// Last client disconnected - set warning and start tracking for reboot timeout
// (suppressed while provisioning is pending - see loop()).
// Refresh on every authenticated removal, not just the last one, so an
// unauthenticated straggler removed later (e.g. a port scan, or a dial to
// a host that accepts TCP but never speaks the API) cannot discard a
// healthy session's timestamp and trigger a spurious reboot
if (was_authenticated) {
this->last_connected_ = App.get_loop_component_start_time();
this->saw_unauthenticated_client_ = false;
} else {
this->saw_unauthenticated_client_ = true;
}
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
this->last_connected_ = App.get_loop_component_start_time();
}
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
@@ -245,7 +289,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
sock->getpeername_to(peername);
// Check if we're at the connection limit
if (this->api_connection_count_ >= MAX_API_CONNECTIONS) {
if (this->at_client_limit_()) {
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
// Immediately close - socket destructor will handle cleanup
sock.reset();
@@ -254,18 +298,54 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
ESP_LOGD(TAG, "Accept %s", peername);
auto *conn = new APIConnection(std::move(sock), this);
this->clients_[this->api_connection_count_++].reset(conn);
conn->start();
// First client connected - clear warning and update timestamp
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
this->add_client_(new APIConnection(std::move(sock), this));
}
}
bool APIServer::add_client_(APIConnection *conn) {
if (this->at_client_limit_()) {
// The accept path checks first to skip the allocation; the outgoing
// handoff relies on this check
ESP_LOGW(TAG, "Max connections (%d), dropping client", MAX_API_CONNECTIONS);
delete conn;
return false;
}
this->clients_[this->api_connection_count_++].reset(conn);
conn->start();
// First client connected - clear warning. The reboot watchdog timestamp is
// refreshed when an authenticated client is removed (see remove_client_),
// never on bare TCP connects.
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_clear_warning();
}
return true;
}
#ifdef USE_API_OUTGOING_CONNECTION
APIConnection *APIServer::add_outgoing_client_(std::unique_ptr<socket::Socket> sock) {
// Re-check at the handoff: the PSK may have been cleared since the dial
// started (mark_outgoing() needs the noise helper); add_client_ re-checks
// the slot limit
if (!this->noise_ctx_.has_psk()) {
ESP_LOGW(TAG, "Dropping outgoing connection (no key)");
return nullptr;
}
auto *conn = new APIConnection(std::move(sock), this);
if (!this->add_client_(conn)) {
return nullptr;
}
// After start(): sends our server hello first so the peer can pick the key
conn->mark_outgoing();
return conn;
}
void APIServer::on_outgoing_target_client(APIConnection *conn) {
this->outgoing_target_count_++;
this->outgoing_conn_.on_target_client(conn);
}
#endif
void APIServer::dump_config() {
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
@@ -282,6 +362,9 @@ void APIServer::dump_config() {
#else
ESP_LOGCONFIG(TAG, " Noise encryption: NO");
#endif
#ifdef USE_API_OUTGOING_CONNECTION
this->outgoing_conn_.dump_config();
#endif
}
void APIServer::handle_disconnect(APIConnection *conn) {}
@@ -576,6 +659,8 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
if (!c->send_message(req)) {
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
}
// Force it: a session from before the key was active must not survive
c->flags_.next_close = true;
}
});
}
@@ -687,6 +772,9 @@ void APIServer::on_shutdown() {
// Close the listening socket to prevent new connections
this->destroy_socket_();
#ifdef USE_API_OUTGOING_CONNECTION
this->outgoing_conn_.on_shutdown();
#endif
// Change batch delay to 5ms for quick flushing during shutdown
this->batch_delay_ = 5;
+28 -1
View File
@@ -11,6 +11,7 @@
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "api_outgoing_connection.h"
#include "esphome/components/socket/socket.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
@@ -81,6 +82,10 @@ class APIServer final : public Component,
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
#endif // USE_API_NOISE
#ifdef USE_API_OUTGOING_CONNECTION
// Called by APIConnection when a client declares itself a dial-back target in its hello
void on_outgoing_target_client(APIConnection *conn);
#endif
void handle_disconnect(APIConnection *conn);
#ifdef USE_BINARY_SENSOR
@@ -258,6 +263,16 @@ class APIServer final : public Component,
protected:
// Accept incoming socket connections. Only called when socket has pending connections.
void __attribute__((noinline)) accept_new_connections_();
// Insert a constructed connection into the client slots and start it.
// Takes ownership; deletes the connection and returns false at the limit
bool add_client_(APIConnection *conn);
bool at_client_limit_() const { return this->api_connection_count_ >= MAX_API_CONNECTIONS; }
#ifdef USE_API_OUTGOING_CONNECTION
// Returns the new connection, or nullptr (socket dropped) when at the limit
APIConnection *add_outgoing_client_(std::unique_ptr<socket::Socket> sock);
bool has_outgoing_target_client_() const { return this->outgoing_target_count_ != 0; }
friend class OutgoingConnectionManager;
#endif
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
void __attribute__((noinline)) remove_client_(uint8_t client_index);
@@ -297,6 +312,7 @@ class APIServer final : public Component,
this->socket_ = nullptr;
}
void socket_failed_(const LogString *msg);
bool create_listen_socket_();
// Pointers and pointer-like types first (4 bytes each)
socket::ListenSocket *socket_{nullptr};
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
@@ -349,8 +365,16 @@ class APIServer final : public Component,
// Connection limits - these defaults will be overridden by config values
// from cv.SplitDefault in __init__.py which sets platform-specific defaults.
uint8_t listen_backlog_{4};
bool shutting_down_ = false;
// Bit-packed so the two flags share one byte
bool shutting_down_ : 1 = false;
// For the reboot log: whether any removal since the last watchdog refresh
// was an unauthenticated session (e.g. a wrong-key peer)
bool saw_unauthenticated_client_ : 1 = false;
uint8_t api_connection_count_{0};
#ifdef USE_API_OUTGOING_CONNECTION
// Connected clients whose hello declared them a dial-back target
uint8_t outgoing_target_count_{0};
#endif
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
// Index assigned by the provisioning manager for reporting this transport's state.
uint8_t provisioning_source_{0};
@@ -360,6 +384,9 @@ class APIServer final : public Component,
noise::NoiseContext noise_ctx_;
ESPPreferenceObject noise_pref_;
#endif // USE_API_NOISE
#ifdef USE_API_OUTGOING_CONNECTION
OutgoingConnectionManager outgoing_conn_;
#endif
};
extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -42,7 +42,16 @@ bool AsyncClient::connect(const char *host, uint16_t port) {
return false;
}
socket_->setblocking(false);
if (socket_->setblocking(false) != 0) {
// Capture before the log and reset() below can clobber errno; a blocking
// connect()/read() would otherwise stall the whole loop
const int saved_errno = errno;
ESP_LOGE(TAG, "Failed to set nonblocking: errno %d", saved_errno);
socket_.reset();
if (error_cb_)
error_cb_(error_arg_, this, saved_errno);
return false;
}
int err = socket_->connect((struct sockaddr *) &addr, addrlen);
if (err == 0) {
@@ -97,45 +106,22 @@ void AsyncClient::loop() {
return;
if (connecting_) {
// For connecting, we need to check writability, not readability
// The Application's select() only monitors read FDs, so we do our own check here
// For ESP platforms lwip_select() might be faster, but this code isn't used
// on those platforms anyway. If it was, we'd fix the Application select()
// to report writability instead of doing it this way.
int fd = socket_->get_fd();
if (fd < 0) {
ESP_LOGW(TAG, "Invalid socket fd");
close();
return;
}
fd_set writefds;
FD_ZERO(&writefds);
FD_SET(fd, &writefds);
struct timeval tv = {0, 0};
int ret = select(fd + 1, nullptr, &writefds, nullptr, &tv);
if (ret > 0 && FD_ISSET(fd, &writefds)) {
int error = 0;
socklen_t len = sizeof(error);
if (socket_->getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) == 0 && error == 0) {
int err = 0;
switch (socket::poll_connect(*socket_, err)) {
case socket::ConnectPollResult::CONNECT_POLL_PENDING:
break;
case socket::ConnectPollResult::CONNECT_POLL_CONNECTED:
connecting_ = false;
connected_ = true;
if (connect_cb_)
connect_cb_(connect_arg_, this);
} else {
ESP_LOGW(TAG, "Connection failed: %d", error);
break;
case socket::ConnectPollResult::CONNECT_POLL_ERROR:
ESP_LOGW(TAG, "Connection failed: %d", err);
close();
if (error_cb_)
error_cb_(error_arg_, this, error);
}
} else if (ret < 0) {
const int err = errno;
ESP_LOGE(TAG, "Select error: %d", err);
close();
if (error_cb_)
error_cb_(error_arg_, this, err);
error_cb_(error_arg_, this, err);
break;
}
} else if (connected_) {
// For connected sockets, use the Application's select() results
+1 -2
View File
@@ -62,9 +62,8 @@ BdkActivityState bdk_scan_state(uint8_t activity_idx) {
uint8_t bdk_scan_acquire_activity() {
uint8_t idx = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
if (idx == INVALID_ACTIVITY_IDX) {
if (idx == INVALID_ACTIVITY_IDX)
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
}
return idx;
}
+5 -10
View File
@@ -181,9 +181,8 @@ void BK72xxBLE::enable() {
break;
}
}
if (!bdaddr_live) {
if (!bdaddr_live)
ESP_LOGW(TAG, "Controller address still unset after init; BLE stack may not have started");
}
#endif
this->state_ = BLEComponentState::ACTIVE;
@@ -211,9 +210,8 @@ void BK72xxBLE::loop() {
// Re-check a settled scan; scan_start() refills the bring-up budget.
// WARN: the only report of a drop that recovers inside its budget.
if (this->scan_start(this->requested_.interval, this->requested_.window, this->requested_.active) !=
ScanOpResult::SETTLED) {
ScanOpResult::SETTLED)
ESP_LOGW(TAG, "Controller dropped the scan; restarting");
}
}
// Drain the lock-free ring filled by the BLE task; all per-report work runs
@@ -232,9 +230,8 @@ void BK72xxBLE::loop() {
// Log dropped reports — only reachable when reports were processed; drops can
// only occur while the queue is full, and only this loop drains it.
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
if (dropped > 0) {
if (dropped > 0)
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped);
}
}
void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
@@ -452,9 +449,8 @@ ScanOpResult BK72xxBLE::advance_stop_(BdkActivityState state, bool ready) {
if (!ready) {
// Acting mid-operation could delete an activity whose start lands
// afterwards, leaking the slot with the radio on; wait.
if (this->last_result_ == ScanOpResult::SETTLED) {
if (this->last_result_ == ScanOpResult::SETTLED)
ESP_LOGD(TAG, "Scan stop deferred (controller busy)");
}
return ScanOpResult::PENDING;
}
// Settled, so CREATED unambiguously means "never started".
@@ -478,9 +474,8 @@ ScanOpResult BK72xxBLE::advance_start_(BdkActivityState state, bool ready) {
return ScanOpResult::PENDING;
}
if (!ready) {
if (this->last_result_ == ScanOpResult::SETTLED) {
if (this->last_result_ == ScanOpResult::SETTLED)
ESP_LOGD(TAG, "Scan start deferred (controller busy)");
}
return ScanOpResult::PENDING;
}
if (state == BdkActivityState::CREATED) {
@@ -69,9 +69,8 @@ void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress,
this->stop_scan();
// The transfer starves the loop; a deferred stop would leave the radio
// scanning for the whole update, so drain it here, bounded.
if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS)) {
if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS))
ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update");
}
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
// On success the device reboots, so restore only on a failed/aborted update;
// loop() restarts the scan on its next iteration (continuous idle branch).
@@ -80,9 +80,8 @@ void BLEBinaryOutput::write_state(bool state) {
esp_err_t err =
esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_,
sizeof(state_as_uint), &state_as_uint, this->write_type_, ESP_GATT_AUTH_REQ_NONE);
if (err != ESP_GATT_OK) {
if (err != ESP_GATT_OK)
ESP_LOGW(TAG, "[%s] Write error, err=%d", this->char_uuid_.to_str(char_buf), err);
}
}
} // namespace esphome::ble_client
+2 -4
View File
@@ -327,12 +327,10 @@ void BME680Component::read_data_() {
ESP_LOGD(TAG, "Got temperature=%.1f°C pressure=%.1fhPa humidity=%.1f%% gas_resistance=%.1fΩ", temperature, pressure,
humidity, gas_resistance);
if (!gas_valid) {
if (!gas_valid)
ESP_LOGW(TAG, "Gas measurement unsuccessful, reading invalid!");
}
if (!heat_stable) {
if (!heat_stable)
ESP_LOGW(TAG, "Heater unstable, reading invalid! (Normal for a few readings after a power cycle)");
}
if (this->temperature_sensor_ != nullptr)
this->temperature_sensor_->publish_state(temperature);
+6 -12
View File
@@ -749,39 +749,33 @@ void Climate::dump_traits_(const char *tag) {
}
if (!traits.get_supported_modes().empty()) {
ESP_LOGCONFIG(tag, " Supported modes:");
for (ClimateMode m : traits.get_supported_modes()) {
for (ClimateMode m : traits.get_supported_modes())
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_mode_to_string(m)));
}
}
if (!traits.get_supported_fan_modes().empty()) {
ESP_LOGCONFIG(tag, " Supported fan modes:");
for (ClimateFanMode m : traits.get_supported_fan_modes()) {
for (ClimateFanMode m : traits.get_supported_fan_modes())
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_fan_mode_to_string(m)));
}
}
if (!traits.get_supported_custom_fan_modes().empty()) {
ESP_LOGCONFIG(tag, " Supported custom fan modes:");
for (const char *s : traits.get_supported_custom_fan_modes()) {
for (const char *s : traits.get_supported_custom_fan_modes())
ESP_LOGCONFIG(tag, " - %s", s);
}
}
if (!traits.get_supported_presets().empty()) {
ESP_LOGCONFIG(tag, " Supported presets:");
for (ClimatePreset p : traits.get_supported_presets()) {
for (ClimatePreset p : traits.get_supported_presets())
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_preset_to_string(p)));
}
}
if (!traits.get_supported_custom_presets().empty()) {
ESP_LOGCONFIG(tag, " Supported custom presets:");
for (const char *s : traits.get_supported_custom_presets()) {
for (const char *s : traits.get_supported_custom_presets())
ESP_LOGCONFIG(tag, " - %s", s);
}
}
if (!traits.get_supported_swing_modes().empty()) {
ESP_LOGCONFIG(tag, " Supported swing modes:");
for (ClimateSwingMode m : traits.get_supported_swing_modes()) {
for (ClimateSwingMode m : traits.get_supported_swing_modes())
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_swing_mode_to_string(m)));
}
}
}
+1
View File
@@ -22,6 +22,7 @@ CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection"
CONF_ENABLED = "enabled"
CONF_GYROSCOPE_ODR = "gyroscope_odr"
CONF_GYROSCOPE_RANGE = "gyroscope_range"
CONF_HOST = "host"
CONF_IAQ = "iaq"
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
CONF_IS_WRGB = "is_wrgb"
+1 -2
View File
@@ -154,9 +154,8 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r
}
}
if (error_code != 0) {
if (report_errors) {
if (report_errors)
ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL);
}
return false;
}
@@ -1,139 +0,0 @@
#include "epaper_spi_uc8179.h"
#include <algorithm>
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.uc8179";
bool EPaperUC8179::initialise(bool partial) {
EPaperBase::initialise(partial); // send the model init sequence
this->partial_ = partial;
ESP_LOGV(TAG, "Power on");
// POWER ON must precede the waveform/mode registers and the data transfer
// (the original driver powers on and busy-waits before writing them).
// The state machine busy-waits before entering TRANSFER_DATA.
this->command(0x04);
// Give the busy line time to assert before the state machine polls it
this->next_delay_ = 100;
return true;
}
// Set up the refresh mode. Must be called after power-on has completed.
void EPaperUC8179::set_refresh_mode_() {
if (!this->is_using_partial_update_()) {
return; // plain full refresh uses the mode set by the init sequence
}
// Fast and partial refresh use flipped data polarity and a floating border
this->cmd_data(0x50, {0xA9, 0x07});
// Force the waveform via the temperature registers: 0x5A selects the fast
// full-refresh waveform, 0x6E the partial-refresh waveform
this->cmd_data(0xE0, {0x02});
if (this->partial_) {
this->cmd_data(0xE5, {0x6E});
this->command(0x91); // enter partial mode
// Set the partial window to the full screen
const uint16_t x_end = this->width_ - 1;
const uint16_t y_end = this->height_ - 1;
this->cmd_data(0x90, {0x00, 0x00, static_cast<uint8_t>(x_end >> 8), static_cast<uint8_t>(x_end & 0xFF), 0x00, 0x00,
static_cast<uint8_t>(y_end >> 8), static_cast<uint8_t>(y_end & 0xFF), 0x01});
} else {
this->cmd_data(0xE5, {0x5A});
this->command(0x92); // exit partial mode
}
}
bool HOT EPaperUC8179::transfer_data() {
const uint32_t start_time = millis();
const size_t buffer_length = this->buffer_length_;
if (this->current_data_index_ == 0) {
this->set_refresh_mode_();
}
// Fast full refresh sends the previous-image plane as well, so that every pixel transitions
const bool two_pass = this->is_using_partial_update_() && !this->partial_;
// Plain full refresh sends inverted data (buffer is 1=white, the wire wants 0=white);
// in fast/partial mode the data polarity is flipped via the VCOM/data-interval
// register instead, so the new-image plane is sent unmodified
const bool invert_new_data = !this->is_using_partial_update_();
uint8_t bytes_to_send[MAX_TRANSFER_SIZE];
// Phase 1 (fast full refresh only): previous image via 0x10 (DTM1), inverse of the new image
if (two_pass && this->current_data_index_ < buffer_length) {
if (this->current_data_index_ == 0) {
this->command(0x10); // DATA START TRANSMISSION 1 (previous image)
}
this->start_data_();
while (this->current_data_index_ < buffer_length) {
const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, buffer_length - this->current_data_index_);
for (size_t i = 0; i < bytes_to_copy; i++) {
bytes_to_send[i] = ~this->buffer_[this->current_data_index_ + i];
}
this->write_array(bytes_to_send, bytes_to_copy);
this->current_data_index_ += bytes_to_copy;
if (millis() - start_time > MAX_TRANSFER_TIME) {
this->disable();
return false;
}
}
this->disable();
}
// Phase 2: new image via 0x13 (DTM2)
const size_t offset = two_pass ? buffer_length : 0;
const size_t total = offset + buffer_length;
if (this->current_data_index_ < total) {
if (this->current_data_index_ == offset) {
this->command(0x13); // DATA START TRANSMISSION 2 (new image)
}
this->start_data_();
while (this->current_data_index_ < total) {
const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, total - this->current_data_index_);
const size_t data_idx = this->current_data_index_ - offset;
for (size_t i = 0; i < bytes_to_copy; i++) {
const uint8_t byte = this->buffer_[data_idx + i];
bytes_to_send[i] = invert_new_data ? ~byte : byte;
}
this->write_array(bytes_to_send, bytes_to_copy);
this->current_data_index_ += bytes_to_copy;
if (millis() - start_time > MAX_TRANSFER_TIME) {
this->disable();
return false;
}
}
this->disable();
}
this->current_data_index_ = 0;
return true;
}
void EPaperUC8179::power_on() {
// Power-on is sent at the end of initialise() instead, because the
// waveform/mode registers and the data transfer must follow it
}
void EPaperUC8179::refresh_screen(bool /*partial*/) {
ESP_LOGV(TAG, "Refresh");
this->command(0x12); // DISPLAY REFRESH
// Delay the next busy poll: the busy line takes a short time to assert after
// the refresh command, and polling too early would read it as already idle
this->next_delay_ = 100;
}
void EPaperUC8179::power_off() {
ESP_LOGV(TAG, "Power off");
this->command(0x02); // POWER OFF
}
void EPaperUC8179::deep_sleep() {
// Deep sleep loses the previous-image RAM that partial refresh compares against
if (!this->is_using_partial_update_()) {
ESP_LOGV(TAG, "Deep sleep");
this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code
}
}
} // namespace esphome::epaper_spi
@@ -1,52 +0,0 @@
#pragma once
#include "epaper_spi.h"
namespace esphome::epaper_spi {
/**
* Monochrome e-paper displays using the UC8179 controller.
* Supports: 7.5" V2 (EPD_7in5_V2), 800x480 pixels, as used by the
* Waveshare 7.5" V2 HAT and the Seeed reTerminal E1001.
*
* Buffer layout: 1 bit per pixel, 1=white, 0=black (the base class default).
*
* The INITIALISE state sends the panel configuration followed by power-on
* (0x04); the state machine busy-waits for power-on to complete before
* TRANSFER_DATA, which first writes the waveform/mode registers (these are
* only accepted while powered) and then the image data. The state machine
* busy-waits again before triggering REFRESH_SCREEN (0x12).
*
* Three refresh modes are used, following the Waveshare EPD_7in5_V2 examples:
* - full_update_every == 1: plain full refresh. The new image is sent
* inverted to DTM2 (0x13) and the controller uses its normal waveform.
* - full_update_every > 1, full update: fast full refresh. The data polarity
* is flipped via the VCOM/data-interval register, a fast waveform is forced
* via the temperature registers, and the image is sent to both DTM1 (0x10,
* inverted) and DTM2 (0x13) so that every pixel transitions.
* - full_update_every > 1, partial update: partial refresh. A partial-update
* waveform is forced, partial mode is entered with a full-screen window and
* only DTM2 is sent; the controller compares against its previous-image RAM.
*/
class EPaperUC8179 final : public EPaperBase {
public:
EPaperUC8179(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {
this->buffer_length_ = this->row_width_ * height;
}
protected:
bool initialise(bool partial) override;
bool transfer_data() override;
void refresh_screen(bool partial) override;
void power_on() override;
void power_off() override;
void deep_sleep() override;
void set_refresh_mode_();
// Set by initialise() so transfer_data() knows which planes to send
bool partial_{};
};
} // namespace esphome::epaper_spi
@@ -1,93 +0,0 @@
"""Monochrome e-paper displays using the UC8179 controller.
Supported models:
- waveshare-7.5in-v2: 7.5" mono display, 800x480 pixels (EPD_7in5_V2)
- seeed-reterminal-e1001: Seeed reTerminal E1001, which uses the same
7.5" 800x480 panel on an integrated ESP32-S3 board
Panel configuration and power-on (0x04) are both sent during the INITIALISE
state; the state machine's built-in busy wait then covers the power-on delay
before the waveform/mode registers and image data are transferred.
These displays support fast full and partial refresh: set ``full_update_every``
greater than 1 to enable it. Every ``full_update_every``-th update is a fast
full refresh, with partial refreshes in between.
"""
from typing import Any
from esphome.const import CONF_DATA_RATE
from . import EpaperModel
class UC8179(EpaperModel):
"""EpaperModel class for monochrome displays using the UC8179 controller."""
def __init__(
self,
name: str,
class_name: str = "EPaperUC8179",
data_rate: str = "10MHz",
**defaults: Any,
) -> None:
defaults.setdefault(CONF_DATA_RATE, data_rate)
super().__init__(name, class_name, **defaults)
def get_init_sequence(self, config: dict) -> tuple:
"""Generate the initialization sequence for UC8179 mono displays.
Panel configuration only the driver appends power-on (0x04) at the
end of the INITIALISE state, and the state machine busy-waits for it
to complete before the data transfer starts.
"""
width, height = self.get_dimensions(config)
return (
# POWER SETTING
(0x01, 0x07, 0x07, 0x3F, 0x3F),
# BOOSTER SOFT START
(0x06, 0x17, 0x17, 0x28, 0x17),
# PANEL SETTING (black/white mode, LUT from OTP)
(0x00, 0x1F),
# RESOLUTION SETTING (width x height)
(
0x61,
(width >> 8) & 0xFF,
width & 0xFF,
(height >> 8) & 0xFF,
height & 0xFF,
),
# DUAL SPI MODE (disabled)
(0x15, 0x00),
# VCOM AND DATA INTERVAL SETTING
(0x50, 0x10, 0x07),
# TCON SETTING
(0x60, 0x22),
)
uc8179 = UC8179("uc8179")
# Waveshare 7.5" V2 mono (EPD_7in5_V2) — 800x480, UC8179 controller
waveshare_7_5_v2 = uc8179.extend(
"waveshare-7.5in-v2",
width=800,
height=480,
)
# Seeed reTerminal E1001 — 7.5" mono e-paper (800x480), same panel as the
# Waveshare 7.5" V2, driven by an integrated ESP32-S3 board
waveshare_7_5_v2.extend(
"seeed-reterminal-e1001",
cs_pin=10,
dc_pin=11,
reset_pin=12,
busy_pin={
"number": 13,
"inverted": True,
"mode": {
"input": True,
"pullup": True,
},
},
)
+1 -6
View File
@@ -3342,12 +3342,7 @@ def _write_idf_component_yml():
# Don't process arduino libraries
if name not in ARDUINO_DISABLED_LIBRARIES
]
# A library that is also declared as a managed component must not be
# converted as well, or IDF sees the same requirement from two
# components and refuses to build. Converted components still link
# against it via ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
for component in generate_idf_components(libraries, managed=managed):
for component in generate_idf_components(libraries):
dependencies[component.get_sanitized_name()] = {
"override_path": str(component.path)
}
@@ -210,9 +210,8 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) {
if (!image) {
// A shutdown is not a lost frame: wait_for_image_() returns empty as soon
// as running_ clears, and the loop condition below ends the stream anyway.
if (this->running_) {
if (this->running_)
ESP_LOGW(TAG, "STREAM: failed to acquire frame");
}
res = ESP_FAIL;
}
if (res == ESP_OK) {
@@ -358,7 +358,10 @@ void ESPHomeOTAComponent::handle_data_() {
tv.tv_usec = 0;
this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
this->client_->setblocking(true);
if (this->client_->setblocking(true) != 0) {
this->log_socket_error_(LOG_STR("blocking"));
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
// Acknowledge auth OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_AUTH_OK);
+1 -2
View File
@@ -334,9 +334,8 @@ void Fan::dump_traits_(const char *tag, const char *prefix) {
}
if (traits.supports_preset_modes()) {
ESP_LOGCONFIG(tag, "%s Supported presets:", prefix);
for (const char *s : traits.supported_preset_modes()) {
for (const char *s : traits.supported_preset_modes())
ESP_LOGCONFIG(tag, "%s - %s", prefix, s);
}
}
}
@@ -29,9 +29,8 @@ void HBridgeSwitch::dump_config() {
LOG_PIN(" On Pin: ", this->on_pin_);
LOG_PIN(" Off Pin: ", this->off_pin_);
ESP_LOGCONFIG(TAG, " Pulse length: %" PRId32 " ms", this->pulse_length_);
if (this->wait_time_) {
if (this->wait_time_)
ESP_LOGCONFIG(TAG, " Wait time %" PRId32 " ms", this->wait_time_);
}
}
void HBridgeSwitch::write_state(bool state) {
+2 -4
View File
@@ -44,9 +44,8 @@ void HE60rCover::dump_config() {
" Close Duration: %.1fs",
this->open_duration_ / 1e3f, this->close_duration_ / 1e3f);
auto restore = this->restore_state_();
if (restore.has_value()) {
if (restore.has_value())
ESP_LOGCONFIG(TAG, " Saved position %d%%", (int) (restore->position * 100.f));
}
}
void HE60rCover::endstop_reached_(CoverOperation operation) {
@@ -78,9 +77,8 @@ void HE60rCover::process_rx_(uint8_t data) {
ESP_LOGV(TAG, "Process RX data %X", data);
if (!this->query_seen_) {
this->query_seen_ = data == QUERY_BYTE;
if (!this->query_seen_) {
if (!this->query_seen_)
ESP_LOGD(TAG, "RX Byte %02X", data);
}
return;
}
switch (data) {
@@ -257,9 +257,8 @@ void HoermannHcp::on_state_reg_(uint16_t value) {
}
}
// The low byte can change on its own, so only report a state we cannot decode once.
if (state != (previous >> 8)) {
if (state != (previous >> 8))
ESP_LOGW(TAG, "Unknown door state 0x%02X", state);
}
}
// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records
@@ -16,33 +16,26 @@ void KeyCollector::loop() {
void KeyCollector::dump_config() {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG
ESP_LOGCONFIG(TAG, "Key Collector:");
if (this->min_length_ > 0) {
if (this->min_length_ > 0)
ESP_LOGCONFIG(TAG, " min length: %d", this->min_length_);
}
if (this->max_length_ > 0) {
if (this->max_length_ > 0)
ESP_LOGCONFIG(TAG, " max length: %d", this->max_length_);
}
if (!this->back_keys_.empty()) {
if (!this->back_keys_.empty())
ESP_LOGCONFIG(TAG, " erase keys '%s'", this->back_keys_.c_str());
}
if (!this->clear_keys_.empty()) {
if (!this->clear_keys_.empty())
ESP_LOGCONFIG(TAG, " clear keys '%s'", this->clear_keys_.c_str());
}
if (!this->start_keys_.empty()) {
if (!this->start_keys_.empty())
ESP_LOGCONFIG(TAG, " start keys '%s'", this->start_keys_.c_str());
}
if (!this->end_keys_.empty()) {
ESP_LOGCONFIG(TAG,
" end keys '%s'\n"
" end key is required: %s",
this->end_keys_.c_str(), ONOFF(this->end_key_required_));
}
if (!this->allowed_keys_.empty()) {
if (!this->allowed_keys_.empty())
ESP_LOGCONFIG(TAG, " allowed keys '%s'", this->allowed_keys_.c_str());
}
if (this->timeout_ > 0) {
if (this->timeout_ > 0)
ESP_LOGCONFIG(TAG, " entry timeout: %0.1f", this->timeout_ / 1000.0);
}
#endif
}
+1 -2
View File
@@ -333,9 +333,8 @@ void LN882HBLE::loop() {
// the queue empty — from the very first report on. Checking here keeps that
// failure visible instead of producing a scanner that is silently dead.
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
if (dropped > 0) {
if (dropped > 0)
ESP_LOGW(TAG, "Dropped %u scan reports (queue full or out of memory for a report slot)", dropped);
}
// Drain the lock-free ring filled by the rw task; all per-report work runs
// here on the main task, then the report returns to the pool.
BLEScanReport *report = this->report_queue_.pop();
+1 -2
View File
@@ -1059,9 +1059,8 @@ static void *lv_alloc_draw_buf(size_t size, bool internal) {
void *buffer;
size = LV_ROUND_UP(size, LV_DRAW_BUF_ALIGN);
buffer = heap_caps_aligned_alloc(LV_DRAW_BUF_ALIGN, size, internal ? MALLOC_CAP_8BIT : cap_bits); // NOLINT
if (buffer == nullptr) {
if (buffer == nullptr)
ESP_LOGW(esphome::lvgl::TAG, "Failed to allocate %zu bytes for %sdraw buffer", size, internal ? "internal " : "");
}
return buffer;
}
+1 -2
View File
@@ -237,9 +237,8 @@ void MipiDsi::write_to_display_(int x_start, int y_start, int w, int h, const ui
xSemaphoreTake(this->io_lock_, portMAX_DELAY);
}
}
if (err != ESP_OK) {
if (err != ESP_OK)
ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err));
}
}
bool MipiDsi::check_buffer_() {
+1 -2
View File
@@ -243,9 +243,8 @@ void MipiRgb::write_to_display_(int x_start, int y_start, int w, int h, const ui
ptr += stride; // next line
}
}
if (err != ESP_OK) {
if (err != ESP_OK)
ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err));
}
}
bool MipiRgb::check_buffer_() {
+3 -6
View File
@@ -31,15 +31,12 @@ void internal_dump_config(const char *model, int width, int height, int offset_w
LOG_PIN(" CS Pin: ", cs);
LOG_PIN(" Reset Pin: ", reset);
LOG_PIN(" DC Pin: ", dc);
if (offset_width != 0) {
if (offset_width != 0)
ESP_LOGCONFIG(TAG, " Offset width: %d", offset_width);
}
if (offset_height != 0) {
if (offset_height != 0)
ESP_LOGCONFIG(TAG, " Offset height: %d", offset_height);
}
if (brightness.has_value()) {
if (brightness.has_value())
ESP_LOGCONFIG(TAG, " Brightness: %u", brightness.value());
}
}
} // namespace esphome::mipi_spi
+2 -4
View File
@@ -1199,17 +1199,15 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() {
ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1,
this->deferred_payload_len_ - 1);
if (!this->send_frame_(frame)) {
if (!this->send_frame_(frame))
ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked");
}
});
return;
}
ModbusFrame frame(payload[0], payload + 1, len - 1);
if (!this->send_frame_(frame)) {
if (!this->send_frame_(frame))
ESP_LOGE(TAG, "Server reply dropped: a frame arrived during the send delay");
}
}
void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) {
+2 -4
View File
@@ -39,12 +39,10 @@ inline char *append_char(char *p, char c) {
// Function implementation of LOG_MQTT_COMPONENT macro to reduce code size
void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic) {
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
if (state_topic) {
if (state_topic)
ESP_LOGCONFIG(tag, " State Topic: '%s'", obj->get_state_topic_to_(buf).c_str());
}
if (command_topic) {
if (command_topic)
ESP_LOGCONFIG(tag, " Command Topic: '%s'", obj->get_command_topic_to_(buf).c_str());
}
}
void MQTTComponent::set_qos(uint8_t qos) { this->qos_ = qos; }
+6 -31
View File
@@ -5,18 +5,12 @@ from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_KEY
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
noise_ns = cg.esphome_ns.namespace("noise")
# Keep in sync with platformio.ini and esphome/idf_component.yml.
# LIBSODIUM_VERSION must match the version noise-c pins in its manifests.
NOISE_C_VERSION = "0.1.21"
LIBSODIUM_VERSION = "1.10021.4"
CONFIG_SCHEMA = cv.Schema({})
@@ -69,31 +63,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE")
# Both libraries build themselves as ESP-IDF components, so on ESP32 they
# are pulled straight from the component registry instead of going through
# ESPHome's PlatformIO-library converter. Deliberately not conditional on
# the toolchain: wireguard splits on the same condition, and if the two
# disagree one of them converts a second libsodium next to the managed one.
#
# Not on the Arduino framework though: arduino-esp32 depends on
# espressif/libsodium of its own (on IDF < 6.0), so the component manager
# would see two managed components whose names match once the namespace is
# stripped, and refuse to pick between them.
#
# libsodium is declared alongside noise-c rather than left to noise-c's own
# manifest either way: it lets the library manager see the full set up front
# instead of discovering libsodium only after noise-c has downloaded, and it
# keeps other components that depend on it (wireguard) from converting a
# second copy next to the managed one. The version must match the one
# noise-c pins.
if CORE.is_esp32 and not CORE.using_arduino:
from esphome.components.esp32 import add_idf_component
add_idf_component(name="esphome/noise-c", ref=NOISE_C_VERSION)
add_idf_component(name="esphome/libsodium", ref=LIBSODIUM_VERSION)
else:
cg.add_library("esphome/noise-c", NOISE_C_VERSION)
cg.add_library("esphome/libsodium", LIBSODIUM_VERSION)
cg.add_library("esphome/noise-c", "0.1.21")
# noise-c depends on libsodium, but declaring it here too lets the
# library manager see the full set up front instead of discovering
# libsodium only after noise-c has downloaded, so the two can download
# in parallel. The version must match noise-c's library.json.
cg.add_library("esphome/libsodium", "1.10021.4")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
+1 -2
View File
@@ -18,9 +18,8 @@ const std::vector<uint64_t> &OneWireBus::get_devices() { return this->devices_;
bool OneWireBus::reset_() {
int res = this->reset_int();
if (res == -1) {
if (res == -1)
ESP_LOGE(TAG, "1-wire bus is held low");
}
return res == 1;
}
@@ -551,14 +551,12 @@ void PacketTransport::dump_config() {
" Ping-pong: %s",
this->platform_name_, YESNO(this->is_encrypted_()), YESNO(this->ping_pong_enable_));
#ifdef USE_SENSOR
for (const auto &sensor : this->sensors_) {
for (const auto &sensor : this->sensors_)
ESP_LOGCONFIG(TAG, " Sensor: %s", sensor.id);
}
#endif
#ifdef USE_BINARY_SENSOR
for (const auto &sensor : this->binary_sensors_) {
for (const auto &sensor : this->binary_sensors_)
ESP_LOGCONFIG(TAG, " Binary Sensor: %s", sensor.id);
}
#endif
for (const auto &host : this->providers_) {
ESP_LOGCONFIG(TAG, " Remote host: %s", host.first.c_str());
@@ -566,17 +564,15 @@ void PacketTransport::dump_config() {
#ifdef USE_SENSOR
auto rs = this->remote_sensors_.find(host.first.c_str());
if (rs != this->remote_sensors_.end()) {
for (const auto &key : rs->second | std::views::keys) {
for (const auto &key : rs->second | std::views::keys)
ESP_LOGCONFIG(TAG, " Sensor: %s", key.c_str());
}
}
#endif
#ifdef USE_BINARY_SENSOR
auto rbs = this->remote_binary_sensors_.find(host.first.c_str());
if (rbs != this->remote_binary_sensors_.end()) {
for (const auto &key : rbs->second | std::views::keys) {
for (const auto &key : rbs->second | std::views::keys)
ESP_LOGCONFIG(TAG, " Binary Sensor: %s", key.c_str());
}
}
#endif
}
+1 -2
View File
@@ -124,9 +124,8 @@ void QwiicPIRComponent::dump_config() {
void QwiicPIRComponent::clear_events_() {
// Clear event status register
if (!this->write_byte(QWIIC_PIR_EVENT_STATUS, 0x00)) {
if (!this->write_byte(QWIIC_PIR_EVENT_STATUS, 0x00))
ESP_LOGW(TAG, "Failed to clear events");
}
}
} // namespace esphome::qwiic_pir
@@ -75,9 +75,8 @@ void RpiDpiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uin
break;
}
}
if (err != ESP_OK) {
if (err != ESP_OK)
ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err));
}
}
int RpiDpiRgb::get_width() {
@@ -629,9 +629,8 @@ stm32_unique_ptr stm32_init(uart::UARTDevice *stream, const uint8_t flags, const
stm->pid = (buf[1] << 8) | buf[2];
if (returned > 2) {
ESP_LOGD(TAG, "This bootloader returns %d extra bytes in PID:", returned);
for (auto i = 2; i <= returned; i++) {
for (auto i = 2; i <= returned; i++)
ESP_LOGD(TAG, " %02x", buf[i]);
}
}
if (stm32_get_ack(stm) != STM32_ERR_OK) {
return make_stm32_with_deletor(nullptr);
+2
View File
@@ -17,6 +17,8 @@ CONF_IMPLEMENTATION = "implementation"
IMPLEMENTATION_LWIP_TCP = "lwip_tcp"
IMPLEMENTATION_LWIP_SOCKETS = "lwip_sockets"
IMPLEMENTATION_BSD_SOCKETS = "bsd_sockets"
# Implementations whose sockets cannot make outgoing connections
IMPLEMENTATIONS_WITHOUT_CONNECT = frozenset({IMPLEMENTATION_LWIP_TCP})
# Socket tracking infrastructure
# Components register their socket needs and platforms read this to configure appropriately
@@ -59,13 +59,15 @@ int BSDSocketImpl::close() {
int BSDSocketImpl::setblocking(bool blocking) {
int fl = ::fcntl(this->fd_, F_GETFL, 0);
if (fl < 0) {
return fl;
}
if (blocking) {
fl &= ~O_NONBLOCK;
} else {
fl |= O_NONBLOCK;
}
::fcntl(this->fd_, F_SETFL, fl);
return 0;
return ::fcntl(this->fd_, F_SETFL, fl);
}
size_t BSDSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
@@ -49,13 +49,15 @@ int LwIPSocketImpl::close() {
int LwIPSocketImpl::setblocking(bool blocking) {
int fl = lwip_fcntl(this->fd_, F_GETFL, 0);
if (fl < 0) {
return fl;
}
if (blocking) {
fl &= ~O_NONBLOCK;
} else {
fl |= O_NONBLOCK;
}
lwip_fcntl(this->fd_, F_SETFL, fl);
return 0;
return lwip_fcntl(this->fd_, F_SETFL, fl);
}
size_t LwIPSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
+54 -2
View File
@@ -2,6 +2,9 @@
#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)
#include <cerrno>
#include <cstring>
#ifdef USE_SOCKET_IMPL_BSD_SOCKETS
#include <sys/select.h>
#endif
#include <string>
#include "esphome/core/log.h"
#include "esphome/core/application.h"
@@ -165,7 +168,10 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
#else
// Use LWIP-specific functions
ip6_addr_t ip6;
inet6_aton(ip_address, &ip6);
if (inet6_aton(ip_address, &ip6) == 0) {
errno = EINVAL;
return 0;
}
memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr));
#endif
return sizeof(sockaddr_in6);
@@ -185,12 +191,58 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
return 0;
}
#else
server->sin_addr.s_addr = inet_addr(ip_address);
// Unlike inet_addr(), inet_aton() can signal failure while still
// accepting the broadcast address 255.255.255.255
if (inet_aton(ip_address, &server->sin_addr) == 0) {
errno = EINVAL;
return 0;
}
#endif
server->sin_port = htons(port);
return sizeof(sockaddr_in);
}
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
ConnectPollResult poll_connect(Socket &sock, int &err_out) {
int fd = sock.get_fd();
if (fd < 0 || fd >= FD_SETSIZE) {
// FD_SET on either is undefined behavior
err_out = EBADF;
return ConnectPollResult::CONNECT_POLL_ERROR;
}
// Connect completion is a write event; the main loop only selects on reads
fd_set writefds;
FD_ZERO(&writefds);
FD_SET(fd, &writefds);
struct timeval tv = {0, 0};
#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS
// LWIP_COMPAT_SOCKETS may be off (LibreTiny), so use the lwip symbol directly
int ret = lwip_select(fd + 1, nullptr, &writefds, nullptr, &tv);
#else
// Global-scope select: the entity namespace esphome::select shadows it here
int ret = ::select(fd + 1, nullptr, &writefds, nullptr, &tv);
#endif
if (ret < 0) {
err_out = errno;
return ConnectPollResult::CONNECT_POLL_ERROR;
}
if (ret == 0 || !FD_ISSET(fd, &writefds)) {
return ConnectPollResult::CONNECT_POLL_PENDING;
}
int error = 0;
socklen_t len = sizeof(error);
if (sock.getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) != 0) {
err_out = errno;
return ConnectPollResult::CONNECT_POLL_ERROR;
}
if (error != 0) {
err_out = error;
return ConnectPollResult::CONNECT_POLL_ERROR;
}
return ConnectPollResult::CONNECT_POLL_CONNECTED;
}
#endif
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port) {
#if USE_NETWORK_IPV6
if (addrlen < sizeof(sockaddr_in6)) {
+13
View File
@@ -145,6 +145,19 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st
/// Set a sockaddr to the any address and specified port for the IP version used by socket_ip().
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port);
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
enum class ConnectPollResult : uint8_t {
CONNECT_POLL_PENDING,
CONNECT_POLL_CONNECTED,
CONNECT_POLL_ERROR,
};
/// Check a non-blocking connect() for completion without blocking. On
/// CONNECT_POLL_ERROR, err_out holds the socket's SO_ERROR, or errno when the
/// poll itself failed.
ConnectPollResult poll_connect(Socket &sock, int &err_out);
#endif
/// Format sockaddr into caller-provided buffer, returns length written (excluding null)
size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span<char, SOCKADDR_STR_LEN> buf);
+1 -2
View File
@@ -406,9 +406,8 @@ class SPIClient {
this->release_device_, this->write_only_);
#ifdef USE_SPI_PSRAM_DMA
this->delegate_->set_psram_dma(this->psram_dma_);
if (this->psram_dma_) {
if (this->psram_dma_)
esph_log_config("spi_device", "PSRAM DMA: enabled");
}
#endif
}
+3 -6
View File
@@ -42,9 +42,8 @@ class SPIDelegateHw : public SPIDelegate {
if (this->release_device_)
this->add_device_();
if (this->is_ready()) {
if (spi_device_acquire_bus(this->handle_, portMAX_DELAY) != ESP_OK) {
if (spi_device_acquire_bus(this->handle_, portMAX_DELAY) != ESP_OK)
ESP_LOGE(TAG, "Failed to acquire SPI bus");
}
SPIDelegate::begin_transaction();
} else {
ESP_LOGW(TAG, "SPI device not ready, cannot begin transaction");
@@ -64,9 +63,8 @@ class SPIDelegateHw : public SPIDelegate {
~SPIDelegateHw() override {
esp_err_t const err = spi_bus_remove_device(this->handle_);
if (err != ESP_OK) {
if (err != ESP_OK)
ESP_LOGE(TAG, "Remove device failed - err %X", err);
}
}
// do a transfer. either txbuf or rxbuf (but not both) may be null.
@@ -286,9 +284,8 @@ class SPIBusHw : public SPIBus {
}
buscfg.max_transfer_sz = MAX_TRANSFER_SIZE;
auto err = spi_bus_initialize(channel, &buscfg, SPI_DMA_CH_AUTO);
if (err != ESP_OK) {
if (err != ESP_OK)
ESP_LOGE(TAG, "Bus init failed - err %X", err);
}
}
SPIDelegate *get_delegate(uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin,
+1 -2
View File
@@ -78,9 +78,8 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8
break;
}
}
if (err != ESP_OK) {
if (err != ESP_OK)
esph_log_e(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err));
}
}
void ST7701S::draw_pixel_at(int x, int y, Color color) {
+1 -1
View File
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import binary_sensor, sensor
from esphome.components.const import CONF_HOST
import esphome.config_validation as cv
from esphome.const import (
CONF_BINARY_SENSORS,
@@ -14,7 +15,6 @@ AUTO_LOAD = ["socket"]
CODEOWNERS = ["@Links2004"]
DEPENDENCIES = ["network"]
CONF_HOST = "host"
CONF_PREFIX = "prefix"
statsd_component_ns = cg.esphome_ns.namespace("statsd")
@@ -177,18 +177,14 @@ water_heater::WaterHeaterMode TuyaWaterHeater::default_on_mode_() const {
void TuyaWaterHeater::dump_config() {
LOG_WATER_HEATER("", "Tuya Water Heater", this);
if (this->switch_id_.has_value()) {
if (this->switch_id_.has_value())
ESP_LOGCONFIG(TAG, " Switch has datapoint ID %u", *this->switch_id_);
}
if (this->mode_id_.has_value()) {
if (this->mode_id_.has_value())
ESP_LOGCONFIG(TAG, " Mode has datapoint ID %u", *this->mode_id_);
}
if (this->target_temperature_id_.has_value()) {
if (this->target_temperature_id_.has_value())
ESP_LOGCONFIG(TAG, " Target Temperature has datapoint ID %u", *this->target_temperature_id_);
}
if (this->current_temperature_id_.has_value()) {
if (this->current_temperature_id_.has_value())
ESP_LOGCONFIG(TAG, " Current Temperature has datapoint ID %u", *this->current_temperature_id_);
}
}
} // namespace esphome::tuya
+18 -8
View File
@@ -13,9 +13,16 @@ void UDPComponent::setup() {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
for (const auto &address : this->addresses_) {
struct sockaddr saddr {};
socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_);
if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) {
ESP_LOGW(TAG, "Invalid address %s", address);
continue;
}
this->sockaddrs_.push_back(saddr);
}
if (this->sockaddrs_.size() != this->addresses_.size()) {
// A dropped address silently receives nothing; surface the misconfiguration
this->status_set_warning(LOG_STR("invalid address"));
}
// set up broadcast socket
if (this->should_broadcast_) {
this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
@@ -94,9 +101,15 @@ void UDPComponent::setup() {
// 8266 and RP2040 `Duino
for (const auto &address : this->addresses_) {
auto ipaddr = IPAddress();
ipaddr.fromString(address);
if (!ipaddr.fromString(address)) {
ESP_LOGW(TAG, "Invalid address %s", address);
continue;
}
this->ipaddrs_.push_back(ipaddr);
}
if (this->ipaddrs_.size() != this->addresses_.size()) {
this->status_set_warning(LOG_STR("invalid address"));
}
if (this->should_listen_)
this->udp_client_.begin(this->listen_port_);
#endif
@@ -129,9 +142,8 @@ void UDPComponent::dump_config() {
" Listen Port: %u\n"
" Broadcast Port: %u",
this->listen_port_, this->broadcast_port_);
for (const char *address : this->addresses_) {
for (const char *address : this->addresses_)
ESP_LOGCONFIG(TAG, " Address: %s", address);
}
if (this->listen_address_.has_value()) {
char addr_buf[network::IP_ADDRESS_BUFFER_SIZE];
ESP_LOGCONFIG(TAG, " Listen address: %s", this->listen_address_.value().str_to(addr_buf));
@@ -146,9 +158,8 @@ void UDPComponent::send_packet(const uint8_t *data, size_t size) {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
for (const auto &saddr : this->sockaddrs_) {
auto result = this->broadcast_socket_->sendto(data, size, 0, &saddr, sizeof(saddr));
if (result < 0) {
if (result < 0)
ESP_LOGW(TAG, "sendto() error %d", errno);
}
}
#endif
#ifdef USE_SOCKET_IMPL_LWIP_TCP
@@ -157,9 +168,8 @@ void UDPComponent::send_packet(const uint8_t *data, size_t size) {
if (this->udp_client_.beginPacketMulticast(saddr, this->broadcast_port_, iface, 128) != 0) {
this->udp_client_.write(data, size);
auto result = this->udp_client_.endPacket();
if (result == 0) {
if (result == 0)
ESP_LOGW(TAG, "udp.write() error");
}
}
}
#endif
@@ -110,9 +110,8 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
// Handle packet
size_t data_len = (packet_len - 6) / 3;
if (data_len == 0) {
if (packet[4] == UPONOR_ID_REQUEST) {
if (packet[4] == UPONOR_ID_REQUEST)
ESP_LOGVV(TAG, "Ignoring request packet for device 0x%08" PRIX32 "", device_address);
}
return true;
}
+1 -2
View File
@@ -194,9 +194,8 @@ std::vector<CdcEps> USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev
}
}
if (cdc_devs.empty()) {
if (cdc_devs.empty())
ESP_LOGE(TAG, "PL2303: failed to find bulk IN+OUT endpoints");
}
return cdc_devs;
}
@@ -34,15 +34,18 @@ void WakeOnLanButton::press_action() {
struct sockaddr_storage saddr {};
auto addr_len =
socket::set_sockaddr(reinterpret_cast<sockaddr *>(&saddr), sizeof(saddr), "255.255.255.255", this->port_);
if (addr_len == 0) {
ESP_LOGW(TAG, "Invalid broadcast address");
return;
}
uint8_t buffer[6 + sizeof this->macaddr_ * 16];
memcpy(buffer, PREFIX, sizeof(PREFIX));
for (size_t i = 0; i != 16; i++) {
memcpy(buffer + i * sizeof(this->macaddr_) + sizeof(PREFIX), this->macaddr_, sizeof(this->macaddr_));
}
if (this->broadcast_socket_->sendto(buffer, sizeof(buffer), 0, reinterpret_cast<const sockaddr *>(&saddr),
addr_len) <= 0) {
addr_len) <= 0)
ESP_LOGW(TAG, "sendto() error %d", errno);
}
#else
IPAddress broadcast = IPAddress(255, 255, 255, 255);
for (auto ip : esphome::network::get_ip_addresses()) {
+5 -10
View File
@@ -348,18 +348,14 @@ size_t WeikaiChannel::rx_in_fifo_() {
uint8_t const fsr = this->reg(WKREG_FSR);
if (fsr & (FSR_RFOE | FSR_RFLB | FSR_RFFE | FSR_RFPE)) {
char bin_buf[9];
if (fsr & FSR_RFOE) {
if (fsr & FSR_RFOE)
ESP_LOGE(TAG, "Receive data overflow FSR=%s", format_bin_to(bin_buf, fsr));
}
if (fsr & FSR_RFLB) {
if (fsr & FSR_RFLB)
ESP_LOGE(TAG, "Receive line break FSR=%s", format_bin_to(bin_buf, fsr));
}
if (fsr & FSR_RFFE) {
if (fsr & FSR_RFFE)
ESP_LOGE(TAG, "Receive frame error FSR=%s", format_bin_to(bin_buf, fsr));
}
if (fsr & FSR_RFPE) {
if (fsr & FSR_RFPE)
ESP_LOGE(TAG, "Receive parity error FSR=%s", format_bin_to(bin_buf, fsr));
}
}
if ((available == 0) && (fsr & FSR_RFDAT)) {
// here we should be very careful because we can have something like this:
@@ -499,9 +495,8 @@ void print_buffer(std::vector<uint8_t> buffer) {
hex_buffer[(3 * 32) + 1] = 0;
for (size_t i = 0; i < buffer.size(); i++) {
snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", buffer[i]);
if (i % 32 == 31) {
if (i % 32 == 31)
ESP_LOGI(TAG, " %s", hex_buffer);
}
}
if (buffer.size() % 32) {
// null terminate if incomplete line
+5
View File
@@ -214,6 +214,11 @@
#define USE_API_HOMEASSISTANT_SERVICES
#define USE_API_HOMEASSISTANT_STATES
#define USE_API_NOISE
#if !defined(USE_ESP8266) && !defined(USE_RP2) // raw-lwip sockets cannot make outgoing connections
#define USE_API_OUTGOING_CONNECTION
#define API_OUTGOING_CONNECTION_PORT 6054
#define API_OUTGOING_CONNECTION_DELAY 60000
#endif
#define USE_API_VARINT64
#define USE_API_PLAINTEXT
#define USE_API_USER_DEFINED_ACTIONS
+8 -21
View File
@@ -238,17 +238,6 @@ def _parse_lib_deps(platformio_ini: Path, framework: str):
return libs
def _esphome_manifest_deps() -> set[str]:
"""Names of the managed components declared in ``esphome/idf_component.yml``."""
import yaml
esphome_dir = Path(__file__).resolve().parent.parent
manifest = yaml.safe_load(
(esphome_dir / "idf_component.yml").read_text(encoding="utf-8")
)
return set(manifest.get("dependencies") or {})
def _convert_pio_libs(
platformio_ini: Path, framework: str
) -> dict[str, dict[str, str]]:
@@ -261,20 +250,12 @@ def _convert_pio_libs(
The whole library set is resolved as a single batch so a shared transitive
dependency (e.g. esphome/libsodium pulled by both noise-c and esp_wireguard)
is deduplicated to one component instead of clashing override_path entries.
Libraries ESPHome's own manifest already provides as managed components
(noise-c, libsodium, ...) are skipped, mirroring what the real esp32 build
does -- converting them too would make IDF see the same requirement twice.
On Arduino those entries are rule-disabled in the manifest (arduino-esp32
brings its own libsodium), so nothing provides them there and they have to
go through the converter as before.
"""
from esphome.espidf.component import generate_idf_components
libraries = _parse_lib_deps(platformio_ini, framework)
managed = set() if framework == "arduino" else _esphome_manifest_deps()
deps: dict[str, dict[str, str]] = {}
for component in generate_idf_components(libraries, managed=managed):
for component in generate_idf_components(libraries):
deps[component.get_sanitized_name()] = {"override_path": str(component.path)}
return deps
@@ -292,13 +273,19 @@ def _arduino_excluded_stubs(work_dir: Path) -> dict[str, dict]:
ethernet) are NOT stubbed -- those are real deps we need, and arduino-esp32
resolves to the same component rather than conflicting.
"""
import yaml
from esphome.components.esp32 import (
ARDUINO_EXCLUDED_IDF_COMPONENTS,
_idf_component_dep_name,
_idf_component_stub_name,
)
esphome_deps = _esphome_manifest_deps()
esphome_dir = Path(__file__).resolve().parent.parent
base_manifest = yaml.safe_load(
(esphome_dir / "idf_component.yml").read_text(encoding="utf-8")
)
esphome_deps = set(base_manifest.get("dependencies") or {})
stubs_dir = work_dir / "component_stubs"
stubs_dir.mkdir(parents=True, exist_ok=True)
+3 -13
View File
@@ -287,22 +287,12 @@ def _emit_idf_component(component: IDFComponent) -> None:
)
def generate_idf_components(
libraries: list[Library], managed: set[str] | None = None
) -> list[IDFComponent]:
"""Resolve and convert a batch of PlatformIO libraries to IDF components.
``managed`` names the registry components already declared in the project
manifest (via ``add_idf_component``). Those are skipped by the converter --
a library must not be both converted and managed, or IDF fails component
discovery with "Requirement <owner>__<name> and requirement <name> are both
added as project_managed_components". Converted components pick the managed
one up through ``${ESPHOME_PROJECT_MANAGED_COMPONENTS}`` in their REQUIRES.
"""
def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
"""Resolve and convert a batch of PlatformIO libraries to IDF components."""
backend = LibraryBackend(
platform=ESP32_PLATFORM,
framework=_idf_framework(),
emit=_emit_idf_component,
cache_key="idf",
)
return convert_libraries(libraries, backend, provided=managed)
return convert_libraries(libraries, backend)
-9
View File
@@ -457,15 +457,6 @@ def _clone_complete_marker_path(repo_dir: Path) -> Path:
return repo_dir / ".git" / _CLONE_COMPLETE_MARKER
def has_complete_clone(
url: str, ref: str | None, domain: str, subpath: Path | None = None
) -> bool:
"""Lock-free probe for a complete clone; can go stale immediately, so
best-effort decisions only, never a substitute for ``clone_or_update``."""
repo_dir = _repo_entry_dir(_cache_key(url, ref), domain, subpath)
return _clone_complete_marker_path(repo_dir).is_file()
def _clear_clone_complete_marker(repo_dir: Path) -> None:
"""Best-effort removal of the completion marker.
-13
View File
@@ -106,16 +106,3 @@ dependencies:
version: d44c800a9e876a8394caefc2ce4915dd96dac77b
rules:
- if: "$ESPHOME_ARDUINO_COMPONENT == 1"
# api. Not on Arduino: arduino-esp32 pulls espressif/libsodium, and IDF
# refuses to build two managed components whose names differ only by
# namespace. The Arduino envs get noise-c as a PlatformIO library instead.
esphome/noise-c:
version: 0.1.21
rules:
- if: "$ESPHOME_ARDUINO_COMPONENT == 0"
# Declared even though noise-c depends on it, so that the PlatformIO-library
# converter knows to skip the copy esp_wireguard would otherwise pull in.
esphome/libsodium:
version: 1.10021.4
rules:
- if: "$ESPHOME_ARDUINO_COMPONENT == 0"
+30 -93
View File
@@ -13,7 +13,7 @@ regardless of which toolchain consumes the result.
"""
from collections import deque
from collections.abc import Callable, Hashable, Iterable
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from functools import partial
import glob
@@ -99,17 +99,6 @@ class Source:
) -> Path:
raise NotImplementedError
def prefetch_key(self, dir_suffix: str) -> Hashable | None:
"""Prefetch dedup identity; None = not prefetchable. Sources that
could write one cache dir must return equal keys (workers must never
share a dir); a coarser key only skips a prefetch."""
return None
def is_cached(self, dir_suffix: str, salt: str = "", namespace: str = "") -> bool:
"""Whether a completed fetch exists; only consulted when
``prefetch_key()`` is not None, True is the safe default."""
return True
def source_root(self, build_path: Path) -> Path:
"""Directory holding the library's own files (manifest + sources).
@@ -138,9 +127,6 @@ class URLSource(Source):
h.update(salt.encode())
return base_dir / h.hexdigest()[:8] / dir_suffix
def prefetch_key(self, dir_suffix: str) -> Hashable | None:
return self.url if self.size else None
def is_cached(self, dir_suffix: str, salt: str = "", namespace: str = "") -> bool:
"""Whether a completed extraction already exists for this source."""
return (
@@ -191,29 +177,14 @@ class GitSource(Source):
self.url = url
self.ref = ref
@staticmethod
def _domain(salt: str, namespace: str) -> str:
def download(
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = ""
) -> Path:
domain = DOMAIN
if namespace:
domain = f"{domain}/{namespace}"
if salt:
domain = f"{domain}/{salt}"
return domain
def prefetch_key(self, dir_suffix: str) -> Hashable | None:
# The clone target dir is hash(url@ref)/<dir_suffix>
return (self.url, self.ref, dir_suffix)
def is_cached(self, dir_suffix: str, salt: str = "", namespace: str = "") -> bool:
"""Whether a completed clone already exists for this source."""
return git.has_complete_clone(
self.url, self.ref, self._domain(salt, namespace), Path(dir_suffix)
)
def download(
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = ""
) -> Path:
domain = self._domain(salt, namespace)
path, _ = git.clone_or_update(
url=self.url,
ref=self.ref,
@@ -1017,78 +988,56 @@ def _fetch_source(
)
def _clone_source(
component: ConvertedLibrary,
salt: str,
namespace: str,
tracker: Callable[[int], None],
) -> None:
# No byte progress from git; one tick so a cancelled batch stops here
tracker(0)
component.source.download(
component.get_sanitized_name(), salt=salt, namespace=namespace
)
def _prefetch_wave(
wave: list[tuple[str, ConvertedLibrary]], salt: str, namespace: str
) -> None:
"""Best-effort parallel fetch of a wave's registry archives and git clones.
"""Best-effort parallel download of a wave's registry archives.
The walk's own ``download()`` stays authoritative; duplicate sources
The walk's own ``download()`` stays authoritative; duplicate URLs
prefetch once so two threads never share a cache directory. Archives
whose size the registry did not report are left to the sequential
loop, whose per-file bars don't interleave. A node a sibling in the
same wave supersedes has its source fetched in vain (knowing better
same wave supersedes has its archive fetched in vain (knowing better
would need the manifests being downloaded).
"""
try:
archives: list[ConvertedLibrary] = []
clones: list[ConvertedLibrary] = []
seen: set[Hashable] = set()
components: list[ConvertedLibrary] = []
seen: set[str] = set()
for _key, component in wave:
source = component.source
name = component.get_sanitized_name()
dedup_key = source.prefetch_key(name)
if dedup_key is None or dedup_key in seen:
if not isinstance(source, URLSource) or not source.size:
continue
seen.add(dedup_key)
if source.url in seen:
continue
seen.add(source.url)
try:
cached = source.is_cached(name, salt=salt, namespace=namespace)
cached = source.is_cached(
component.get_sanitized_name(), salt=salt, namespace=namespace
)
except OSError as err:
# Best-effort, but visibly: a systematic probe failure makes
# every warm build re-fetch every source
# every warm build re-download every archive
_LOGGER.warning("Cache probe for %s failed: %s", component.name, err)
cached = False
if cached:
# A warm build must stay silent
continue
(archives if isinstance(source, URLSource) else clones).append(component)
if not archives and not clones:
components.append(component)
if not components:
return
# Single-item waves (a dependency chain discovers one archive per
# wave) go through the same runner: one download method, one bar
if archives:
_LOGGER.info(
"Downloading %d library archive(s): %s",
len(archives),
", ".join(c.name for c in archives),
)
if clones:
_LOGGER.info(
"Cloning %d library repo(s): %s",
len(clones),
", ".join(c.name for c in clones),
)
_LOGGER.info(
"Downloading %d library archive(s): %s",
len(components),
", ".join(c.name for c in components),
)
failures = run_batch_downloads(
"Downloading libraries",
[
(c.name, c.source.size, partial(_fetch_source, c, salt, namespace))
for c in archives
]
# Size 0: clones share the worker pool without skewing the
# byte bar, whose total stays the archive sum
+ [(c.name, 0, partial(_clone_source, c, salt, namespace)) for c in clones],
for c in components
],
)
# The sequential call below retries and raises the real error
warn_prefetch_failures(
@@ -1102,9 +1051,7 @@ def _prefetch_wave(
def convert_libraries(
libraries: list[Library],
backend: LibraryBackend,
provided: set[str] | None = None,
libraries: list[Library], backend: LibraryBackend
) -> list[ConvertedLibrary]:
"""Resolve and convert a batch of PlatformIO libraries for ``backend``.
@@ -1125,24 +1072,14 @@ def convert_libraries(
``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by
short name (part after the ``/``), matched against both the top-level
libraries and every dependency discovered during the graph walk.
``provided`` names libraries the toolchain already supplies by other means
(for ESP-IDF: registry-managed components declared via
``add_idf_component``). They are excluded exactly like ``lib_ignore``, so a
library is never both converted and managed -- ESP-IDF refuses to build when
two components claim the same requirement.
"""
nodes: dict[str, _LibNode] = {}
# Libraries the toolchain supplies by other means are excluded exactly like
# lib_ignore, so every is_lib_ignored() call site honors both.
lib_ignore = lib_ignore_set() | {
name.split("/")[-1].lower() for name in provided or ()
}
lib_ignore = lib_ignore_set()
# The generated build files inside the shared cache bake in the dependency
# wiring, which the exclusion set changes; salt the cache path so configs
# with different exclusions don't fight over (and constantly rewrite) the
# wiring, which lib_ignore changes; salt the cache path so configs with
# different lib_ignore values don't fight over (and constantly rewrite) the
# same converted component files.
salt = (
hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8]
+9 -9
View File
@@ -832,16 +832,16 @@ def _prefetch(build_dir: Path, env: str) -> None:
for name, opts in p.packages.items()
if not opts.get("optional")
]
# PIO's build engine installs tool-scons by its own registry spec at build
# start; a platform URL copy has no owner to match it, so prefetch that spec
specs = [s for s in specs if s.name != "tool-scons"]
specs.append(
PackageSpec(
owner="platformio",
name="tool-scons",
requirements=get_core_dependencies()["tool-scons"],
# PIO's build engine installs outside the platform package list;
# skipped when the platform lists it itself
if not any(s.name == "tool-scons" for s in specs):
specs.append(
PackageSpec(
owner="platformio",
name="tool-scons",
requirements=get_core_dependencies()["tool-scons"],
)
)
)
lib_deps = config.get(f"env:{env}", "lib_deps", [])
# pio run's storage dir for this env, with its compatibility
# qualifiers: an unqualified library install could land a different
+1 -3
View File
@@ -45,6 +45,7 @@ lib_deps_base =
lib_deps =
${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
esphome/noise-c@0.1.21 ; noise (api, ota)
improv/Improv@1.2.7 ; improv_serial / esp32_improv
kikuchan98/pngle@1.1.0 ; online_image
; Using the repository directly, otherwise ESP-IDF can't use the library
@@ -76,9 +77,6 @@ lib_compat_mode = strict
extends = common
lib_deps =
${common.lib_deps}
; api -- on the ESP-IDF framework this comes from the component registry
; instead (see esphome/idf_component.yml), so it is not in [common].
esphome/noise-c@0.1.21 ; api
SPI ; spi (Arduino built-in)
Wire ; i2c (Arduino built-int)
heman/AsyncMqttClient-esphome@1.0.0 ; mqtt
-148
View File
@@ -319,154 +319,6 @@ def lint_no_long_delays(fname, match):
)
# An if/else/for/while whose only body is an unbraced ESP_LOG*() call. When the build's compile-time
# log level drops that macro, the body expands to nothing and the compiler warns (-Wempty-body).
# clang-tidy's brace check does not catch these (ShortStatementLines allows short unbraced bodies), so
# this fills that gap. Matched against comment/string-masked content, so commented-out or quoted code
# is ignored. Both spellings are covered: core/log.h defines the uppercase ESP_LOG*() macros and
# the lowercase esph_log_*() ones, and both expand to nothing below their log level.
# 'for' allows ';' inside its parentheses (the classic C-style header); 'if'/'while' do not, so their
# condition cannot run past the statement it guards. The 'for' header permits one level of nested
# parens so it stays bounded to its own statement: without that, it can run past the loop body and
# latch onto a later ')', mis-reporting the line and skipping the '#' preprocessor check below.
ESP_LOG_NEEDS_BRACES_RE = re.compile(
r"(?:\bif\s*\([^{};]*\)|\bwhile\s*\([^{};]*\)|\bfor\s*\((?:[^{}()]|\([^{}()]*\))*\)|\belse\b)"
r"[ \t]*\n?[ \t]*(?:ESP_LOG[A-Z]*|esph_log_[a-z]+)\s*\(",
re.MULTILINE,
)
def _mask_cpp_comments_strings(s):
"""Return s with // and /* */ comments and string/char/raw-string literals blanked to spaces
(length and newlines preserved) so a regex only matches real code. Parentheses in real code are
kept, so callers can still balance them on the masked text."""
out = list(s)
i = 0
n = len(s)
while i < n:
c = s[i]
# Raw string literal: an optional encoding prefix, then R"delim( ... )delim". The body may
# contain quotes, //, /* and unbalanced parens, so it must be consumed as one unit.
if c == "R" and i + 1 < n and s[i + 1] == '"':
j = i + 2
delim = ""
while j < n and s[j] not in "( \t\r\n\\" and len(delim) < 16:
delim += s[j]
j += 1
if j < n and s[j] == "(":
closing = ")" + delim + '"'
end = s.find(closing, j + 1)
end = n if end == -1 else end + len(closing)
for k in range(i, end):
if s[k] != "\n":
out[k] = " "
i = end
continue
i += 1
elif c == "/" and i + 1 < n and s[i + 1] == "/":
while i < n and s[i] != "\n":
out[i] = " "
i += 1
elif c == "/" and i + 1 < n and s[i + 1] == "*":
out[i] = out[i + 1] = " "
i += 2
while i < n and not (s[i] == "*" and i + 1 < n and s[i + 1] == "/"):
if s[i] != "\n":
out[i] = " "
i += 1
if i < n:
out[i] = " "
if i + 1 < n:
out[i + 1] = " "
i += 2
# A "'" after an alphanumeric or '_' is a C++ digit separator (1'000), not a literal opener.
elif c == '"' or (
c == "'" and not (i and (s[i - 1].isalnum() or s[i - 1] == "_"))
):
quote = c
out[i] = " "
i += 1
while i < n:
if s[i] == "\\":
out[i] = " "
if i + 1 < n:
out[i + 1] = " "
i += 2
continue
if s[i] == quote:
out[i] = " "
i += 1
break
if s[i] != "\n":
out[i] = " "
i += 1
else:
i += 1
return "".join(out)
def _log_statement_end(masked, open_paren):
"""Index of the ';' ending the ESP_LOG call whose '(' is at open_paren, or None. Balanced on the
masked text so quotes/comments inside the arguments do not confuse the paren count."""
depth = 0
i = open_paren
n = len(masked)
while i < n:
ch = masked[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
j = i + 1
while j < n and masked[j] != ";":
if not masked[j].isspace():
return None
j += 1
return j if j < n else None
i += 1
return None
@lint_content_check(include=cpp_include)
def lint_esp_log_needs_braces(fname, content):
# Cheap bailout: no log call means nothing to flag, and skips masking the file entirely.
if "ESP_LOG" not in content and "esph_log_" not in content:
return []
masked = _mask_cpp_comments_strings(content)
errors = []
for match in ESP_LOG_NEEDS_BRACES_RE.finditer(masked):
pos = match.start()
line_start = content.rfind("\n", 0, pos) + 1
# Skip preprocessor conditionals (#if/#else/#elif): not C++ control statements.
if content[line_start:pos].lstrip().startswith("#"):
continue
# A '// NOLINT' may sit at the end of the log line (where the message says to put it) or on the
# control-statement line, so scan the whole statement rather than only up to the ESP_LOG token.
stmt_end = _log_statement_end(masked, match.end() - 1)
nolint_end = (
content.find("\n", stmt_end) if stmt_end is not None else match.end()
)
if nolint_end == -1:
nolint_end = len(content)
if "NOLINT" in content[pos:nolint_end]:
continue
snippet = content[pos : match.end()].replace("\n", " ").strip()
errors.append(
(
content.count("\n", 0, pos) + 1,
pos - line_start + 1,
(
f"{highlight(snippet)} - an if/else/for/while body that is a single log "
"call must be wrapped in braces. When the log level compiles the macro out, the "
"body becomes empty and the compiler warns (-Wempty-body). Add { } around the "
"log call (or a '// NOLINT' comment if this is genuinely intended)."
),
)
)
return errors
@lint_content_check(
include=[
"esphome/const.py",
@@ -0,0 +1,148 @@
"""Tests for the api outgoing_connection option."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components import socket
from esphome.components.api import (
CONFIG_SCHEMA,
_validate_outgoing_host_ipv6,
_validate_outgoing_socket_implementation,
)
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
import esphome.config_validation as cv
from esphome.const import PlatformFramework
from esphome.core import CORE
import esphome.final_validate as fv
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
KEY = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="
ESP32_PLATFORM_DATA = {KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}
def _api_config(outgoing: ConfigType, *, encryption: bool = True) -> ConfigType:
config: ConfigType = {"outgoing_connection": outgoing}
if encryption:
config["encryption"] = {"key": KEY}
return config
def test_outgoing_connection_generates_defines(
generate_main: Callable[[str | Path], str],
) -> None:
"""A valid config emits the compile-time defines with defaults applied."""
generate_main("tests/component_tests/api/test_outgoing_connection.yaml")
defines = {define.name: define.value for define in CORE.defines}
assert "USE_API_OUTGOING_CONNECTION" in defines
assert str(defines["API_OUTGOING_CONNECTION_HOST"]) == '"192.168.1.2"'
assert str(defines["API_OUTGOING_CONNECTION_PORT"]) == "6054"
assert str(defines["API_OUTGOING_CONNECTION_DELAY"]) == "60000"
def test_outgoing_connection_defaults(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}))
outgoing = config["outgoing_connection"]
assert outgoing["port"] == 6054
assert outgoing["delay"].total_milliseconds == 60000
def test_outgoing_connection_bare_block(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A bare outgoing_connection: block is valid; the device dials the
remembered last dial-back client."""
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
config = CONFIG_SCHEMA(_api_config(None))
outgoing = config["outgoing_connection"]
assert "host" not in outgoing
assert outgoing["port"] == 6054
def test_outgoing_connection_delay_bounded(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A delay past half the uint32 millisecond range is rejected, not wrapped."""
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
with pytest.raises(cv.Invalid, match="value must be at most"):
CONFIG_SCHEMA(_api_config({"delay": "60d"}))
def test_outgoing_connection_requires_encryption(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
with pytest.raises(cv.Invalid, match="requires 'encryption'"):
CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}, encryption=False))
@pytest.mark.parametrize(
("platform_framework", "platform_data", "socket_conf"),
[
# The platform default on these two, resolved like AUTO_LOAD does
(PlatformFramework.ESP8266_ARDUINO, None, None),
(PlatformFramework.RP2040_ARDUINO, None, None),
# An explicit selection elsewhere
(
PlatformFramework.ESP32_IDF,
ESP32_PLATFORM_DATA,
{"implementation": "lwip_tcp"},
),
],
)
def test_outgoing_connection_rejects_lwip_tcp(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
platform_data: ConfigType | None,
socket_conf: ConfigType | None,
) -> None:
"""The resolved lwip_tcp socket is rejected at final validate."""
set_core_config(platform_framework, platform_data=platform_data)
fv.full_config.set({"socket": socket_conf or socket.CONFIG_SCHEMA({})})
config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}))
with pytest.raises(cv.Invalid, match="lwip_tcp"):
_validate_outgoing_socket_implementation(config)
def test_outgoing_connection_rejects_hostnames(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
with pytest.raises(cv.Invalid, match="not a valid IP address"):
CONFIG_SCHEMA(_api_config({"host": "homeassistant.local"}))
def test_outgoing_connection_ipv6_host_requires_ipv6(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
config = CONFIG_SCHEMA(_api_config({"host": "fd00::1"}))
with pytest.raises(cv.Invalid, match="IPv6 is not"):
_validate_outgoing_host_ipv6(config)
def test_outgoing_connection_ipv6_host_passes_with_ipv6_enabled(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data=ESP32_PLATFORM_DATA,
full_config={"network": {"enable_ipv6": True}},
)
config = CONFIG_SCHEMA(_api_config({"host": "fd00::1"}))
assert _validate_outgoing_host_ipv6(config) is config
def test_outgoing_connection_ipv6_host_with_ipv6(
generate_main: Callable[[str | Path], str],
) -> None:
generate_main("tests/component_tests/api/test_outgoing_connection_ipv6.yaml")
defines = {define.name: define.value for define in CORE.defines}
assert str(defines["API_OUTGOING_CONNECTION_HOST"]) == '"fd00::1"'
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: SomeNetwork
password: SomePassword
logger:
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
host: 192.168.1.2
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: SomeNetwork
password: SomePassword
network:
enable_ipv6: true
logger:
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
host: fd00::1
@@ -1,15 +0,0 @@
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
spi:
clk_pin: GPIO7
mosi_pin: GPIO9
display:
- platform: epaper_spi
id: epaper_display
model: seeed-reterminal-e1001
@@ -439,23 +439,6 @@ def test_enable_pin_multiple(
assert all(pin["mode"]["output"] is True for pin in enable_pins)
def test_uc8179_e1001_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that the reTerminal E1001 model generates the UC8179 driver and init sequence."""
main_cpp = generate_main(component_config_path("uc8179_e1001_test.yaml"))
# The model must instantiate the UC8179 driver class with the panel dimensions
assert "epaper_spi::EPaperUC8179" in main_cpp
assert re.search(r'"SEEED-RETERMINAL-E1001",\s*800,\s*480', main_cpp)
# The generated init sequence must contain the UC8179 resolution setting
# for 800x480: command 0x61, 4 data bytes 0x03 0x20 0x01 0xE0
# (rendered as decimal in the generated array)
assert "97, 4, 3, 32, 1, 224" in main_cpp
def test_enable_pin_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -0,0 +1,13 @@
packages:
common: !include common-base.yaml
wifi:
ssid: MySSID
password: password1
# Outgoing connection on the lwip_sockets implementation used by LibreTiny
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
host: 192.168.1.2
@@ -0,0 +1,16 @@
packages:
common: !include common-base.yaml
wifi:
ssid: MySSID
password: password1
# Outgoing connection: the device dials out when no dial-back client is
# connected. Requires encryption so the peer is verified by key.
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
host: 192.168.1.2
port: 6054
delay: 60s
@@ -0,0 +1,11 @@
packages:
common: !include common-base.yaml
network:
# No host set: the device dials the last remembered Home Assistant address
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
delay: 30s
@@ -255,45 +255,3 @@ display:
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 60, Color(255, 0, 0));
# Waveshare 7.5" V2 mono (800x480, UC8179 controller, EPD_7in5_V2)
# full_update_every > 1 exercises the fast/partial refresh paths
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-7.5in-v2
full_update_every: 4
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
inverted: true
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK);
# Seeed reTerminal E1001 - 7.5" mono e-paper (800x480, UC8179)
# Pins overridden to avoid conflicts with the E1002 defaults above
- platform: epaper_spi
spi_id: spi_bus
model: seeed-reterminal-e1001
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
inverted: true
+8
View File
@@ -702,3 +702,11 @@ async def run_compiled(
)
yield _run_compiled
@pytest.fixture
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Point host preferences at a per-test dir so every run starts clean
(host preferences otherwise persist to ~/.esphome/prefs, keyed only by
device name)."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
@@ -0,0 +1,14 @@
esphome:
name: outgoing-conn-test
host:
logger:
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
host: 127.0.0.1
port: OUTGOING_PORT
delay: 1s
@@ -0,0 +1,13 @@
esphome:
name: outgoing-conn-test
host:
logger:
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
port: OUTGOING_PORT
delay: 1s
@@ -0,0 +1,7 @@
esphome:
name: api-reboot-test
host:
api:
reboot_timeout: 2s # Headroom to connect and authenticate a client first
logger:
level: DEBUG
@@ -0,0 +1,179 @@
"""Integration tests for the api outgoing_connection option.
The device dials out to the test's listener when no dial-back target client is
connected. The listener plays the Home Assistant side over the accepted socket
using aioesphomeapi's sans-IO Noise handshake: the device sends its server
hello first so the listener could pick the right key, and the NNpsk0 handshake
then verifies both sides. Protocol roles stay unchanged, so the client speaks
exactly the same frames as over a normal connection. A client becomes the
remembered dial-back target by setting the outgoing_connection_target flag in
its hello.
"""
from __future__ import annotations
import asyncio
import socket
from typing import Any
from aioesphomeapi import api_pb2
import pytest
from .raw_api_client import MESSAGE_TYPE_OF
from .types import RunCompiledFunction
KEY = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="
DEVICE_NAME = "outgoing-conn-test"
HA_CLIENT_INFO = "Home Assistant 2026.8.0"
# HelloRequest field 4 (outgoing_connection_target) as raw protobuf bytes; the
# installed aioesphomeapi's api_pb2 predates the field, so append it manually.
HELLO_TARGET_FLAG = b"\x20\x01"
# Every run must start with no saved peer
pytestmark = pytest.mark.usefixtures("isolated_preferences")
def _frame(payload: bytes) -> bytes:
return bytes((0x01, len(payload) >> 8, len(payload) & 0xFF)) + payload
async def _read_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> bytes:
header = await asyncio.wait_for(reader.readexactly(3), timeout)
assert header[0] == 0x01, f"Bad frame indicator: {header[0]}"
return await asyncio.wait_for(
reader.readexactly((header[1] << 8) | header[2]), timeout
)
def _check_server_hello(server_hello: bytes) -> None:
assert server_hello[0] == 0x01, "Bad chosen proto in server hello"
name, mac, _rest = server_hello[1:].split(b"\x00", 2)
assert name.decode() == DEVICE_NAME
assert len(mac) == 12, f"Expected bare MAC, got {mac!r}"
async def _run_ha_session(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
*,
device_dialed_out: bool,
) -> None:
"""Handshake and exchange the usual first messages as Home Assistant would."""
# Lazy import per the module's own contract (pulls in the noise stack)
from aioesphomeapi.noise import NoiseHandshake
if device_dialed_out:
# On an outgoing connection the device announces itself first so the
# peer can pick the matching key before its PSK-mixed first message.
_check_server_hello(await _read_frame(reader))
handshake = NoiseHandshake(KEY, b"NoiseAPIInit\x00\x00")
writer.write(b"\x01\x00\x00" + _frame(b"\x00" + handshake.write_message()))
await writer.drain()
if not device_dialed_out:
_check_server_hello(await _read_frame(reader))
reply = await _read_frame(reader)
assert reply[0] == 0, f"Handshake rejected: {reply[1:].decode(errors='replace')}"
handshake.read_message(reply[1:])
encrypt_cipher, decrypt_cipher = handshake.get_ciphers()
async def transact(msg: Any, response_cls: Any, extra_payload: bytes = b"") -> Any:
msg_type = MESSAGE_TYPE_OF[type(msg)]
payload = msg.SerializeToString() + extra_payload
plaintext = (
bytes(
(msg_type >> 8, msg_type & 0xFF, len(payload) >> 8, len(payload) & 0xFF)
)
+ payload
)
writer.write(_frame(encrypt_cipher.encrypt(plaintext)))
await writer.drain()
want = MESSAGE_TYPE_OF[response_cls]
while True:
plain = decrypt_cipher.decrypt(await _read_frame(reader))
if ((plain[0] << 8) | plain[1]) == want:
response = response_cls()
response.ParseFromString(bytes(plain[4:]))
return response
# Declare this client a dial-back target in the hello
await transact(
api_pb2.HelloRequest(client_info=HA_CLIENT_INFO),
api_pb2.HelloResponse,
extra_payload=HELLO_TARGET_FLAG,
)
device_info = await transact(
api_pb2.DeviceInfoRequest(), api_pb2.DeviceInfoResponse
)
assert device_info.name == DEVICE_NAME
async def _serve_home_assistant(listener: socket.socket) -> None:
"""Accept one dial-in from the device and run the client side over it."""
loop = asyncio.get_running_loop()
conn, _ = await asyncio.wait_for(loop.sock_accept(listener), timeout=30)
reader, writer = await asyncio.open_connection(sock=conn)
try:
await _run_ha_session(reader, writer, device_dialed_out=True)
finally:
writer.close()
@pytest.mark.asyncio
async def test_api_outgoing_connection(
yaml_config: str,
run_compiled: RunCompiledFunction,
) -> None:
"""With a configured host the device dials out and speaks the normal API."""
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(2)
listener.setblocking(False)
port = listener.getsockname()[1]
try:
yaml = yaml_config.replace("OUTGOING_PORT", str(port))
async with run_compiled(yaml):
await _serve_home_assistant(listener)
finally:
listener.close()
@pytest.mark.asyncio
async def test_api_outgoing_connection_remembered(
yaml_config: str,
run_compiled: RunCompiledFunction,
unused_tcp_port: int,
) -> None:
"""No host configured: the device remembers the client whose hello carried
the dial-back flag and dials that address after a restart."""
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Bound but not yet listening so first-phase dials cannot queue stale
# connections; whether the device attempts any dial before the restart
# is timing dependent and not asserted here.
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
try:
yaml = yaml_config.replace("OUTGOING_PORT", str(port))
async with run_compiled(yaml):
# Connect inbound with the dial-back flag; the device persists the
# peer address during the hello.
reader, writer = await asyncio.open_connection("127.0.0.1", unused_tcp_port)
try:
await _run_ha_session(reader, writer, device_dialed_out=False)
finally:
writer.close()
# Restart with the same preferences: the device now dials the
# remembered address on its own.
listener.listen(2)
listener.setblocking(False)
async with run_compiled(yaml):
await _serve_home_assistant(listener)
finally:
listener.close()
+31 -2
View File
@@ -5,7 +5,7 @@ import re
import pytest
from .types import RunCompiledFunction
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
@@ -16,7 +16,9 @@ async def test_api_reboot_timeout(
"""Test that the device reboots when no API clients connect within the timeout."""
loop = asyncio.get_running_loop()
reboot_future = loop.create_future()
reboot_pattern = re.compile(r"No clients; rebooting")
# The harness port probe always connects without authenticating, so the
# reboot deterministically reports the unauthenticated form
reboot_pattern = re.compile(r"none authenticated; rebooting")
def check_output(line: str) -> None:
"""Check output for reboot message."""
@@ -33,3 +35,30 @@ async def test_api_reboot_timeout(
pytest.fail("Device did not reboot within expected timeout")
# Test passes if we get here - reboot was detected
@pytest.mark.asyncio
async def test_api_reboot_timeout_after_authenticated_disconnect(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""An authenticated disconnect resets the flag; the clean branch reboots."""
loop = asyncio.get_running_loop()
reboot_future = loop.create_future()
reboot_pattern = re.compile(r"No clients; rebooting")
def check_output(line: str) -> None:
"""Check output for reboot message."""
if not reboot_future.done() and reboot_pattern.search(line):
reboot_future.set_result(True)
async with run_compiled(yaml_config, line_callback=check_output):
# An authenticated session refreshes the watchdog and clears the
# unauthenticated flag the harness probe set
async with api_client_connected() as client:
await client.device_info()
try:
await asyncio.wait_for(reboot_future, timeout=5.0)
except TimeoutError:
pytest.fail("Device did not reboot within expected timeout")
@@ -24,10 +24,8 @@ NEW_KEY = base64.b64encode(b"n" * 32)
KEY_ACTIVATION_DELAY = 0.5
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""Keep host preferences per-test so every run starts unprovisioned."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
# Every run must start unprovisioned
pytestmark = pytest.mark.usefixtures("isolated_preferences")
@pytest.mark.asyncio
@@ -10,13 +10,8 @@ import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left
behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs,
keyed only by device name)."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
# RESTORE_AND_ON must never load a stale value left behind by a previous run
pytestmark = pytest.mark.usefixtures("isolated_preferences")
@pytest.mark.asyncio
-147
View File
@@ -1,147 +0,0 @@
"""Unit tests for the ESP_LOG-needs-braces lint rule in script/ci-custom.py.
The rule flags an if/else/for/while whose only body is an unbraced ESP_LOG*() call (which becomes an
empty statement -- and a -Wempty-body warning -- once the log level compiles the macro out). These
tests pin the comment/string/raw-string masker, the accepted control-statement shapes, and the
NOLINT escape hatch at both placements a contributor would try.
"""
import importlib.util
from pathlib import Path
import sys
SCRIPT_DIR = (Path(__file__).parent / ".." / ".." / "script").resolve()
sys.path.insert(0, str(SCRIPT_DIR))
_spec = importlib.util.spec_from_file_location("ci_custom", SCRIPT_DIR / "ci-custom.py")
ci_custom = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(ci_custom)
mask = ci_custom._mask_cpp_comments_strings
def _lint(content: str) -> list:
return ci_custom.lint_esp_log_needs_braces("test.cpp", content)
# --- masker ---
def test_mask_preserves_length_newlines_and_real_parens() -> None:
src = 'foo("bar") + baz();\nqux();\n'
masked = mask(src)
assert len(masked) == len(src)
assert masked.count("\n") == src.count("\n")
assert masked.count("(") == src.count("(") # real parens survive for balancing
def test_mask_blanks_line_and_block_comments() -> None:
assert "ESP_LOGD" not in mask("a; // if (x) ESP_LOGD(t);\n")
assert "ESP_LOGD" not in mask("a; /* if (x) ESP_LOGD(t); */ b;\n")
def test_mask_blanks_string_literals() -> None:
assert "if" not in mask('x = "if (y) ESP_LOGD";\n')
def test_mask_handles_raw_string_without_desync() -> None:
# A raw string full of quotes/parens must be consumed as one unit; code after it stays intact.
src = 's.print(R"(<a href="x">)");\nreturn;\n'
masked = mask(src)
assert "href" not in masked
assert "return;" in masked # not swallowed by a desynced string scan
# --- rule: flags real violations ---
def test_flags_unbraced_if_next_line() -> None:
assert _lint("if (x)\n ESP_LOGD(t);\n")
def test_flags_unbraced_same_line() -> None:
assert _lint("if (x) ESP_LOGW(t);\n")
def test_flags_c_style_for() -> None:
assert _lint("for (int i = 0; i < n; i++)\n ESP_LOGD(t, i);\n")
def test_flags_range_for_and_else() -> None:
assert _lint("for (auto &x : v)\n ESP_LOGCONFIG(t);\n")
assert _lint("else\n ESP_LOGE(t);\n")
def test_flags_for_header_with_nested_call() -> None:
assert _lint("for (auto it = v.begin(); it != v.end(); ++it)\n ESP_LOGD(t);\n")
def test_for_header_does_not_reach_into_a_later_statement() -> None:
# The 'for' header is bounded to its own statement, so it cannot swallow the loop body and latch
# onto a later ')'. Without that, the '#if' line below is reported as an unbraced body even though
# the '#' preprocessor check should skip it.
assert not _lint(
"for (int i = 0; i < n; i++)\n arr[i] = 0;\n#if defined(USE_X)\n ESP_LOGD(t);\n#endif\n"
)
def test_violation_after_a_for_loop_is_reported_at_its_own_line() -> None:
errors = _lint(
"for (int i = 0; i < n; i++)\n sum += a[i];\nif (verbose)\n ESP_LOGD(t, sum);\n"
)
lines = [line for line, _col, _msg in errors]
assert lines == [3] # the 'if', not the 'for' on line 1
def test_flags_lowercase_esph_log_family() -> None:
# core/log.h defines esph_log_*() alongside ESP_LOG*(); both expand to nothing below their level.
assert _lint('if (x)\n esph_log_config(t, "m");\n')
assert _lint('if (err != ESP_OK)\n esph_log_e(t, "m");\n')
def test_digit_separator_does_not_disable_the_rest_of_the_file() -> None:
# A "'" digit separator must not be read as a char-literal opener, which blanked everything after.
assert _lint("uint32_t x = 1'000;\nif (y)\n ESP_LOGD(t);\n")
def test_mask_still_blanks_real_char_literals() -> None:
assert "ESP_LOGD" not in mask("char c = '\"'; // if (x) ESP_LOGD(t);\n")
assert not _lint("char sep = ';';\nif (x) {\n ESP_LOGD(t);\n}\n")
def test_flags_multiline_log_body() -> None:
assert _lint('if (x)\n ESP_LOGD(t, "%d %d",\n a, b);\n')
def test_raw_string_before_violation_still_caught() -> None:
# Regression for the masker desyncing on a raw string and disabling the check for the rest.
assert _lint('s.print(R"(<a href="x">)");\nif (y)\n ESP_LOGD(t);\n')
# --- rule: ignores non-violations ---
def test_ignores_braced_body() -> None:
assert not _lint("if (x) {\n ESP_LOGD(t);\n}\n")
def test_ignores_commented_out_code() -> None:
assert not _lint("// if (x) ESP_LOGD(t);\n")
def test_ignores_preprocessor_else() -> None:
assert not _lint("#else\n ESP_LOGCONFIG(t);\n#endif\n")
def test_ignores_non_log_body() -> None:
assert not _lint("if (x)\n return false;\n")
# --- NOLINT escape hatch, both placements ---
def test_nolint_at_end_of_log_line_suppresses() -> None:
assert not _lint("if (x)\n ESP_LOGD(t); // NOLINT\n")
def test_nolint_on_control_line_suppresses() -> None:
assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n")
@@ -1,131 +0,0 @@
"""Tests for the noise-c/libsodium library wiring in the noise component.
On ESP32 (but not the Arduino framework) both libraries build themselves as
native ESP-IDF managed components, so they are declared via add_idf_component()
instead of going through ESPHome's PlatformIO-library converter, on either
toolchain. Elsewhere they still go through that converter via cg.add_library():
on the Arduino framework because arduino-esp32 depends on espressif/libsodium
of its own, and off ESP32 because there are no IDF components at all. This
drives the real to_code() coroutine so every branch of that decision is
exercised end to end, not just mocked.
"""
from __future__ import annotations
import asyncio
import pytest
import esphome.codegen as cg
from esphome.components import esp32, noise
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Framework,
Platform,
Toolchain,
)
from esphome.core import CORE
def _setup_core(platform: Platform, framework: Framework, toolchain: Toolchain) -> None:
CORE.reset()
CORE.toolchain = toolchain
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: str(platform),
KEY_TARGET_FRAMEWORK: str(framework),
}
if platform == Platform.ESP32:
CORE.data[esp32.KEY_ESP32] = {esp32.KEY_VARIANT: "ESP32"}
def _record_calls(
monkeypatch: pytest.MonkeyPatch,
) -> tuple[list[dict], list[tuple]]:
"""Capture both wiring paths so each test can assert one ran and one did not."""
idf_calls: list[dict] = []
lib_calls: list[tuple] = []
monkeypatch.setattr(
esp32, "add_idf_component", lambda **kwargs: idf_calls.append(kwargs)
)
monkeypatch.setattr(
cg,
"add_library",
lambda name, version, repository=None: lib_calls.append((name, version)),
)
return idf_calls, lib_calls
@pytest.mark.parametrize("toolchain", [Toolchain.ESP_IDF, Toolchain.PLATFORMIO])
def test_to_code_esp32_idf_uses_managed_idf_components(
toolchain: Toolchain,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""On ESP32 + ESP-IDF both libraries are declared as managed IDF components
rather than converted PlatformIO libraries. The choice is deliberately the
same on either toolchain, because wireguard splits on the same condition."""
_setup_core(Platform.ESP32, Framework.ESP_IDF, toolchain)
idf_calls, lib_calls = _record_calls(monkeypatch)
asyncio.run(noise.to_code({}))
assert idf_calls == [
{"name": "esphome/noise-c", "ref": noise.NOISE_C_VERSION},
{"name": "esphome/libsodium", "ref": noise.LIBSODIUM_VERSION},
]
assert lib_calls == []
def test_to_code_esp32_arduino_uses_add_library(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""On the Arduino framework arduino-esp32 depends on espressif/libsodium of
its own, so declaring esphome/libsodium as a managed component too would
leave the component manager unable to pick between them."""
_setup_core(Platform.ESP32, Framework.ARDUINO, Toolchain.ESP_IDF)
idf_calls, lib_calls = _record_calls(monkeypatch)
asyncio.run(noise.to_code({}))
assert lib_calls == [
("esphome/noise-c", noise.NOISE_C_VERSION),
("esphome/libsodium", noise.LIBSODIUM_VERSION),
]
assert idf_calls == []
def test_to_code_non_esp32_uses_add_library(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Off ESP32 entirely (e.g. host) there are no IDF components at all."""
_setup_core(Platform.HOST, Framework.NATIVE, Toolchain.PLATFORMIO)
idf_calls, lib_calls = _record_calls(monkeypatch)
asyncio.run(noise.to_code({}))
assert lib_calls == [
("esphome/noise-c", noise.NOISE_C_VERSION),
("esphome/libsodium", noise.LIBSODIUM_VERSION),
]
assert idf_calls == []
def test_versions_match_the_repo_manifests() -> None:
"""The pins are duplicated in platformio.ini and esphome/idf_component.yml;
a bump that misses one would ship two different libsodium versions."""
from pathlib import Path
import yaml
repo_root = Path(__file__).resolve().parents[4]
manifest = yaml.safe_load(
(repo_root / "esphome" / "idf_component.yml").read_text(encoding="utf-8")
)
deps = manifest["dependencies"]
assert deps["esphome/noise-c"]["version"] == noise.NOISE_C_VERSION
assert deps["esphome/libsodium"]["version"] == noise.LIBSODIUM_VERSION
assert f"esphome/noise-c@{noise.NOISE_C_VERSION}" in (
repo_root / "platformio.ini"
).read_text(encoding="utf-8")
@@ -1,107 +0,0 @@
"""Tests for esp32's _write_idf_component_yml() managed-component wiring.
A library that is already declared as a managed IDF component (via
add_idf_component(), e.g. api's noise-c/libsodium) must not also be converted
from a PlatformIO library, or ESP-IDF sees the same requirement declared by
two components and refuses to build. _write_idf_component_yml() passes the
set of already-managed component names to generate_idf_components() so the
converter excludes them.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from esphome.components import esp32
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Framework,
Platform,
Toolchain,
)
from esphome.core import CORE
def _setup_core(tmp_path: Path) -> None:
CORE.reset()
CORE.name = "testdevice"
CORE.build_path = tmp_path
CORE.toolchain = Toolchain.ESP_IDF
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: str(Platform.ESP32),
KEY_TARGET_FRAMEWORK: str(Framework.ESP_IDF),
}
def test_write_idf_component_yml_passes_managed_components(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The names already registered via add_idf_component (e.g. noise-c from
api's encryption config) are passed through as ``managed`` so the
PlatformIO-library converter skips them."""
_setup_core(tmp_path)
CORE.data[esp32.KEY_ESP32] = {
esp32.KEY_COMPONENTS: {
"esphome/noise-c": {
esp32.KEY_REPO: None,
esp32.KEY_REF: "0.1.15",
esp32.KEY_PATH: None,
},
},
}
captured: dict[str, set[str] | None] = {}
# A converted (non-managed) library the batch still resolves, so the loop
# wiring its override_path into the manifest is exercised for real too.
converted = MagicMock()
converted.get_sanitized_name.return_value = "esphome/other-lib"
converted.path = tmp_path / "pio_components" / "other-lib"
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return [converted]
monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components)
esp32._write_idf_component_yml()
assert captured["managed"] == {"esphome/noise-c"}
# The managed component itself is still written into the manifest deps
# directly (from KEY_COMPONENTS), just not converted a second time.
yml_path = tmp_path / "src" / "idf_component.yml"
assert yml_path.is_file()
contents = yml_path.read_text(encoding="utf-8")
assert "esphome/noise-c" in contents
assert "0.1.15" in contents
# The converted library the batch DID return is still wired in.
assert "esphome/other-lib" in contents
assert str(converted.path) in contents
def test_write_idf_component_yml_empty_managed_when_no_components(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No managed components registered yet (no add_idf_component calls) ->
an empty managed set, matching the pre-existing (unfiltered) behavior."""
_setup_core(tmp_path)
CORE.data[esp32.KEY_ESP32] = {esp32.KEY_COMPONENTS: {}}
captured: dict[str, set[str] | None] = {}
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return []
monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components)
esp32._write_idf_component_yml()
assert captured["managed"] == set()
+1 -113
View File
@@ -3,22 +3,12 @@
import json
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import yaml
from esphome.espidf import clang_tidy
from esphome.espidf.clang_tidy import (
_arduino_excluded_stubs,
_convert_pio_libs,
_esphome_manifest_deps,
_Settings,
_setup_core,
_write_tidy_project,
)
import esphome.espidf.component as espidf_component
from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -79,108 +69,6 @@ def test_setup_core_sets_arduino_env(
assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected
def test_esphome_manifest_deps_reads_repo_manifest() -> None:
"""Returns the top-level dependency names from esphome/idf_component.yml,
independent of any per-dependency framework rules."""
manifest = yaml.safe_load(
(REPO_ROOT / "esphome" / "idf_component.yml").read_text(encoding="utf-8")
)
deps = _esphome_manifest_deps()
assert isinstance(deps, set)
assert "esphome/noise-c" in deps
assert "esphome/libsodium" in deps
# Cross-check against a fresh parse instead of hardcoding the manifest's
# whole key list, so this doesn't need updating whenever a dependency is
# added or removed.
assert deps == set(manifest["dependencies"])
def test_convert_pio_libs_arduino_framework_passes_empty_managed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""On Arduino, ESPHome's manifest entries for noise-c/libsodium are
rule-gated off (arduino-esp32 brings its own libsodium), so nothing
provides them there -- managed must be empty and they go through the
PlatformIO-library converter as before."""
monkeypatch.setattr(clang_tidy, "_parse_lib_deps", lambda ini, framework: [])
captured: dict[str, set[str] | None] = {}
# A converted library the batch resolves, so the loop wiring its
# override_path into the returned deps mapping is exercised for real too.
converted = SimpleNamespace(
get_sanitized_name=lambda: "esphome/other-lib",
path=tmp_path / "other-lib",
)
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return [converted]
monkeypatch.setattr(
espidf_component, "generate_idf_components", fake_generate_idf_components
)
result = _convert_pio_libs(tmp_path / "platformio.ini", "arduino")
assert captured["managed"] == set()
assert result == {
"esphome/other-lib": {"override_path": str(tmp_path / "other-lib")}
}
def test_convert_pio_libs_espidf_framework_passes_manifest_deps(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""On ESP-IDF, libraries ESPHome's own manifest already provides as
managed components (noise-c, libsodium, ...) must be passed through as
``managed`` so the converter skips them -- converting them too would make
IDF see the same requirement twice."""
monkeypatch.setattr(clang_tidy, "_parse_lib_deps", lambda ini, framework: [])
captured: dict[str, set[str] | None] = {}
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return []
monkeypatch.setattr(
espidf_component, "generate_idf_components", fake_generate_idf_components
)
result = _convert_pio_libs(tmp_path / "platformio.ini", "espidf")
assert captured["managed"] == _esphome_manifest_deps()
assert "esphome/noise-c" in captured["managed"]
assert result == {}
def test_arduino_excluded_stubs_skips_components_esphome_manifest_provides(
tmp_path: Path,
) -> None:
"""A component ESPHome's own idf_component.yml declares for real (e.g.
espressif/lan867x for ethernet) must not be stubbed away -- stubbing it
would silently disable ethernet on Arduino. A component that is only ever
bundled by arduino-esp32 (never in ESPHome's own manifest) still gets a
stub so the arduino-bundled copy doesn't clash with noise-c's libsodium."""
deps = _arduino_excluded_stubs(tmp_path)
# lan867x is a real ESPHome dependency (esphome/idf_component.yml), so it
# must be excluded from the stub set.
assert "espressif/lan867x" not in deps
# espressif/libsodium (arduino-esp32's bundled copy) is a different
# package from ESPHome's own esphome/libsodium, so it's still stubbed.
assert "espressif/libsodium" in deps
stub_info = deps["espressif/libsodium"]
assert stub_info["version"] == "*"
stub_path = Path(stub_info["override_path"])
assert (stub_path / "CMakeLists.txt").is_file()
def test_idedata_from_tidy_project(tmp_path) -> None:
"""The tidy TU's compile entry is assembled into consumer-shaped idedata."""
compile_commands = tmp_path / "compile_commands.json"
-106
View File
@@ -803,112 +803,6 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies(
assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]]
def test_generate_idf_components_managed_filters_top_level_and_dependencies(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
esp32_idf_core: None,
) -> None:
# managed (e.g. noise-c/libsodium already declared via add_idf_component)
# must drop B at the top level and C when discovered as a dependency of A,
# exactly like lib_ignore -- neither may be resolved, downloaded, or wired
# into a manifest.
manifests = {
"esphome/A": {
"name": "A",
"dependencies": [
{"owner": "esphome", "name": "C", "version": "==1.10021.0"}
],
},
"esphome/B": {"name": "B"},
}
download_salts: list[str] = []
def fake_download(self, force=False, salt="", namespace=""):
download_salts.append(salt)
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(IDFComponent, "download", fake_download)
resolve_calls: list[str] = []
def fake_resolve(owner, pkgname, requirements):
resolve_calls.append(pkgname)
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz", None
monkeypatch.setattr(
esphome.platformio.library, "_resolve_registry_version", fake_resolve
)
top = generate_idf_components(
[Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)],
managed={"esphome/B", "esphome/C"},
)
assert [c.name for c in top] == ["esphome/A"]
# Managed libraries were never resolved (and therefore never downloaded).
assert resolve_calls == ["A"]
# The managed dependency is not wired into A's manifest.
assert top[0].dependencies == []
# managed changes the generated wiring just like lib_ignore, so the cache
# path is salted the same way.
assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]]
def test_generate_idf_components_lib_ignore_and_managed_combine_into_salt(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
esp32_idf_core: None,
) -> None:
# lib_ignore and managed both contribute to the same exclusion set, so a
# config using both gets a salt reflecting the union of the two sources
# rather than either alone.
manifests = {
"esphome/A": {"name": "A"},
"esphome/D": {"name": "D"},
"esphome/E": {"name": "E"},
}
download_salts: list[str] = []
def fake_download(self, force=False, salt="", namespace=""):
download_salts.append(salt)
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(IDFComponent, "download", fake_download)
resolve_calls: list[str] = []
def fake_resolve(owner, pkgname, requirements):
resolve_calls.append(pkgname)
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz", None
monkeypatch.setattr(
esphome.platformio.library, "_resolve_registry_version", fake_resolve
)
monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["D"]})
top = generate_idf_components(
[
Library("esphome/A", "1.0.0", None),
Library("esphome/D", "1.0.0", None),
Library("esphome/E", "1.0.0", None),
],
managed={"esphome/E"},
)
assert [c.name for c in top] == ["esphome/A"]
assert resolve_calls == ["A"]
# The salt reflects BOTH lib_ignore's "D" and managed's "E" together.
assert download_salts == [hashlib.sha256(b"d,e").hexdigest()[:8]]
def test_generate_idf_components_handles_dependency_cycle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
-19
View File
@@ -714,25 +714,6 @@ def test_run_git_command_without_git_dir_raises_error(
git.run_git_command(["git", "clone", "https://invalid.url/repo.git"])
def test_has_complete_clone(tmp_path: Path) -> None:
"""The lock-free probe tracks the completion marker, subpath included."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
subpath = Path("lib")
assert not git.has_complete_clone(url, "v1", "test_domain", subpath)
repo_dir = _compute_repo_dir(url, "v1", "test_domain") / subpath
(repo_dir / ".git").mkdir(parents=True)
# A directory without the marker is an incomplete clone
assert not git.has_complete_clone(url, "v1", "test_domain", subpath)
_mark_clone_complete(repo_dir)
assert git.has_complete_clone(url, "v1", "test_domain", subpath)
# The ref is part of the cache key
assert not git.has_complete_clone(url, "v2", "test_domain", subpath)
def test_clone_or_update_with_never_refresh(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
+2 -79
View File
@@ -638,7 +638,7 @@ def test_prefetch_wave_downloads_registry_archives_in_parallel(
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Registry archives in one wave download concurrently, deduped by URL;
local sources and failures are left to the sequential call."""
git/local sources and failures are left to the sequential call."""
calls: list[str] = []
def fake_download(
@@ -658,7 +658,7 @@ def test_prefetch_wave_downloads_registry_archives_in_parallel(
# into the same cache directory)
("b2", ConvertedLibrary("b2", "1.0", URLSource("https://x/b.tar.gz", 1))),
("c", ConvertedLibrary("c", "1.0", URLSource("https://x/boom.tar.gz", 1))),
("l", ConvertedLibrary("l", "*", LocalSource("/some/lib"))),
("g", ConvertedLibrary("g", "*", lib.GitSource("https://x/g.git", None))),
]
lib._prefetch_wave(wave, "", "idf")
assert sorted(calls) == [
@@ -670,83 +670,6 @@ def test_prefetch_wave_downloads_registry_archives_in_parallel(
assert "Prefetch of c failed (retrying sequentially)" in caplog.text
def test_prefetch_wave_clones_git_sources_in_parallel(
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Git sources join the same prefetch batch as the archives, deduped by
clone target; a clone failure warns and is left to the sequential call."""
caplog.set_level("INFO")
calls: list[str] = []
def fake_clone(self, dir_suffix, force=False, salt="", namespace=""):
calls.append(f"{self}/{dir_suffix}")
if "boom" in self.url:
raise RuntimeError("boom")
monkeypatch.setattr(GitSource, "download", fake_clone)
wave = [
("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1))),
("g", ConvertedLibrary("g", "*", GitSource("https://x/g.git", "v1"))),
# Same url@ref and target dir must clone once
("g2", ConvertedLibrary("g", "*", GitSource("https://x/g.git", "v1"))),
("h", ConvertedLibrary("h", "*", GitSource("https://x/boom.git", None))),
]
monkeypatch.setattr(
URLSource, "download", lambda self, dir_suffix, progress=None, **kw: None
)
lib._prefetch_wave(wave, "", "idf")
assert sorted(calls) == ["https://x/boom.git/h", "https://x/g.git#v1/g"]
assert "Cloning 2 library repo(s): g, h" in caplog.text
assert "Prefetch of h failed (retrying sequentially)" in caplog.text
def test_source_base_prefetch_defaults() -> None:
"""The base Source is not prefetchable and reports cached (nothing to do)."""
source = Source()
assert source.prefetch_key("x") is None
assert source.is_cached("x") is True
def test_prefetch_wave_single_clone_uses_the_batch(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A wave with only git sources still clones through the batch runner."""
caplog.set_level("INFO")
calls: list[str] = []
monkeypatch.setattr(GitSource, "is_cached", lambda self, *a, **kw: False)
monkeypatch.setattr(
GitSource,
"download",
lambda self, dir_suffix, force=False, salt="", namespace="": calls.append(
self.url
),
)
lib._prefetch_wave(
[("g", ConvertedLibrary("g", "*", GitSource("https://x/g.git", None)))],
"",
"idf",
)
assert calls == ["https://x/g.git"]
assert "Cloning 1 library repo(s): g" in caplog.text
assert "Downloading" not in caplog.text
def test_prefetch_wave_warm_git_cache_is_silent(
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""An already-complete clone is neither re-fetched nor announced."""
caplog.set_level("INFO")
monkeypatch.setattr(
GitSource,
"download",
lambda self, dir_suffix, **kw: (_ for _ in ()).throw(AssertionError("cloned")),
)
monkeypatch.setattr(GitSource, "is_cached", lambda self, *a, **kw: True)
wave = [("g", ConvertedLibrary("g", "*", GitSource("https://x/g.git", None)))]
lib._prefetch_wave(wave, "", "idf")
assert "Cloning" not in caplog.text
def test_prefetch_wave_unknown_size_left_to_sequential(
setup_core, monkeypatch: pytest.MonkeyPatch
) -> None:
+6 -10
View File
@@ -13,7 +13,6 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from filelock import Timeout
from platformio.dependencies import get_core_dependencies
from platformio.package.manager._install import PackageManagerInstallMixin
from platformio.package.manager.base import BasePackageManager
from platformio.package.manager.library import LibraryPackageManager
@@ -1729,31 +1728,30 @@ def test_preinstall_unlocks_even_when_pool_fails(tmp_path: Path) -> None:
m.unlock.assert_called_once_with()
def test_prefetch_replaces_platform_tool_scons_with_core_spec(tmp_path: Path) -> None:
"""A platform's own tool-scons spec gives way to the core's registry spec."""
def test_prefetch_skips_duplicate_tool_scons(tmp_path: Path) -> None:
"""A platform that lists tool-scons itself does not get it appended."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
fake_platform = MagicMock()
fake_platform.packages = {"tool-scons": {"optional": False}}
fake_platform.get_package_spec.side_effect = lambda name: _FakeSpec(
uri="https://x/scons.zip", name=name, owner=None
uri=None, name=name
)
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
batches: list[list] = []
batches: list[list[str]] = []
with (
patch.dict("sys.modules", modules),
patch.object(
pf,
"_registry_jobs",
side_effect=lambda mgr, specs, seen: (
batches.append(list(specs)) or ([], 0, [])
batches.append([s.name for s in specs]) or ([], 0, [])
),
),
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
):
pf._prefetch(tmp_path, "testenv")
(spec,) = batches[0]
assert (spec.name, spec.owner, spec.uri) == ("tool-scons", "platformio", None)
assert batches[0] == ["tool-scons"]
def test_platformio_private_api_contract() -> None:
@@ -1786,8 +1784,6 @@ def test_platformio_private_api_contract() -> None:
assert callable(getattr(BasePackageManager, name))
# The dependency wave mirrors install_dependency's builtin skip
assert callable(LibraryPackageManager.is_builtin_lib)
# The prefetch keys tool-scons on this core dependency
assert "tool-scons" in get_core_dependencies()
# The pre-install passes these positionally / by keyword
assert "compatibility" in inspect.signature(BasePackageManager.__init__).parameters
lib_params = inspect.signature(LibraryPackageManager.__init__).parameters