mirror of
https://github.com/esphome/esphome.git
synced 2026-09-06 21:16:00 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f9812cef6 | ||
|
|
a684e7e67d | ||
|
|
afc93a4dc8 | ||
|
|
0a74cb4e89 | ||
|
|
9c4a08aaad | ||
|
|
68d5968405 | ||
|
|
30da0e81fa | ||
|
|
900638463a | ||
|
|
1665d509d4 | ||
|
|
29d8f9c7c6 | ||
|
|
18b5277254 | ||
|
|
b110cee973 | ||
|
|
1171fbf892 | ||
|
|
ae549b25bb | ||
|
|
35ab455c10 | ||
|
|
dae9a5d1a6 | ||
|
|
bf380037a6 | ||
|
|
493c7265ab | ||
|
|
238b6bb0b7 | ||
|
|
0211bdf2c0 | ||
|
|
a95bfe0bcb | ||
|
|
3a0bbc4c17 | ||
|
|
cc9dc95cab | ||
|
|
79325fe59a | ||
|
|
5fbe09b68c | ||
|
|
f41ff5aaeb | ||
|
|
7b57fdd35c | ||
|
|
fc671d38e5 | ||
|
|
4e0cda0287 | ||
|
|
676eac7686 |
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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_() {
|
||||
|
||||
@@ -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_() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user