mirror of
https://github.com/esphome/esphome.git
synced 2026-09-09 06:18:46 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7279341f | ||
|
|
69f2a7d556 | ||
|
|
008677298a | ||
|
|
d2bc056f0a | ||
|
|
42fffd16fe | ||
|
|
9c16aba6f7 | ||
|
|
ac79173f4a | ||
|
|
0a1e2acbcb | ||
|
|
9b6facb20d | ||
|
|
f89b9e704c | ||
|
|
f8b2e53609 | ||
|
|
7660dd7fa7 | ||
|
|
c3ce07755f | ||
|
|
f8a4cfa945 | ||
|
|
866ddb6e57 | ||
|
|
ca864c22b4 | ||
|
|
c9729244af | ||
|
|
442e4a1ec2 | ||
|
|
199acdf522 | ||
|
|
9340863652 | ||
|
|
d5cff6e9df | ||
|
|
e7f45a0d31 | ||
|
|
628ebe23ec | ||
|
|
823d79c948 | ||
|
|
b947094f45 | ||
|
|
6c5ab89d5f | ||
|
|
8f511a365a | ||
|
|
006f31af93 | ||
|
|
5bb112f407 | ||
|
|
4ab9298ab3 | ||
|
|
3926612281 |
@@ -5,7 +5,7 @@ from typing import Any
|
|||||||
from esphome import automation
|
from esphome import automation
|
||||||
from esphome.automation import Condition
|
from esphome.automation import Condition
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components.const import CONF_DESCRIPTION, CONF_HOST
|
from esphome.components.const import CONF_DESCRIPTION
|
||||||
from esphome.components.logger import request_log_listener
|
from esphome.components.logger import request_log_listener
|
||||||
|
|
||||||
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
|
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
|
||||||
@@ -25,8 +25,6 @@ from esphome.const import (
|
|||||||
CONF_CAPTURE_RESPONSE,
|
CONF_CAPTURE_RESPONSE,
|
||||||
CONF_DATA,
|
CONF_DATA,
|
||||||
CONF_DATA_TEMPLATE,
|
CONF_DATA_TEMPLATE,
|
||||||
CONF_DELAY,
|
|
||||||
CONF_ENABLE_IPV6,
|
|
||||||
CONF_ENCRYPTION,
|
CONF_ENCRYPTION,
|
||||||
CONF_EVENT,
|
CONF_EVENT,
|
||||||
CONF_ID,
|
CONF_ID,
|
||||||
@@ -50,7 +48,6 @@ from esphome.const import (
|
|||||||
)
|
)
|
||||||
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
|
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
|
||||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||||
import esphome.final_validate as fv
|
|
||||||
from esphome.helpers import fnv1_hash
|
from esphome.helpers import fnv1_hash
|
||||||
from esphome.types import ConfigFragmentType, ConfigType
|
from esphome.types import ConfigFragmentType, ConfigType
|
||||||
|
|
||||||
@@ -137,7 +134,6 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
|||||||
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
|
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
|
||||||
CONF_LISTEN_BACKLOG = "listen_backlog"
|
CONF_LISTEN_BACKLOG = "listen_backlog"
|
||||||
CONF_MAX_SEND_QUEUE = "max_send_queue"
|
CONF_MAX_SEND_QUEUE = "max_send_queue"
|
||||||
CONF_OUTGOING_CONNECTION = "outgoing_connection"
|
|
||||||
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
||||||
|
|
||||||
|
|
||||||
@@ -289,44 +285,9 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
|
|||||||
# (not max_connections, which is the upper limit rarely reached)
|
# (not max_connections, which is the upper limit rarely reached)
|
||||||
socket.consume_sockets(3, "api")(config)
|
socket.consume_sockets(3, "api")(config)
|
||||||
socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(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
|
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(
|
CONFIG_SCHEMA = cv.All(
|
||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
@@ -351,7 +312,6 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
): ACTIONS_SCHEMA,
|
): ACTIONS_SCHEMA,
|
||||||
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
|
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
|
||||||
cv.Optional(CONF_ENCRYPTION): encryption_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.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All(
|
||||||
cv.positive_time_period_milliseconds,
|
cv.positive_time_period_milliseconds,
|
||||||
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
|
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
|
||||||
@@ -408,7 +368,6 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
}
|
}
|
||||||
).extend(cv.COMPONENT_SCHEMA),
|
).extend(cv.COMPONENT_SCHEMA),
|
||||||
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
|
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
|
||||||
_validate_outgoing_connection,
|
|
||||||
_consume_api_sockets,
|
_consume_api_sockets,
|
||||||
_register_provisioning_source,
|
_register_provisioning_source,
|
||||||
)
|
)
|
||||||
@@ -465,52 +424,7 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _validate_outgoing_socket_implementation(config: ConfigType) -> ConfigType:
|
FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings
|
||||||
"""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(
|
def _add_action_strings(
|
||||||
@@ -692,13 +606,6 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
else:
|
else:
|
||||||
cg.add_define("USE_API_PLAINTEXT")
|
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_define("USE_API")
|
||||||
cg.add_global(api_ns.using)
|
cg.add_global(api_ns.using)
|
||||||
|
|
||||||
@@ -1085,7 +992,6 @@ _define_filter = filter_source_files_from_defines(
|
|||||||
"user_services.cpp": "USE_API_USER_DEFINED_ACTIONS",
|
"user_services.cpp": "USE_API_USER_DEFINED_ACTIONS",
|
||||||
"api_frame_helper_noise.cpp": "USE_API_NOISE",
|
"api_frame_helper_noise.cpp": "USE_API_NOISE",
|
||||||
"api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT",
|
"api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT",
|
||||||
"api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -112,11 +112,6 @@ message HelloRequest {
|
|||||||
string client_info = 1;
|
string client_info = 1;
|
||||||
uint32 api_version_major = 2;
|
uint32 api_version_major = 2;
|
||||||
uint32 api_version_minor = 3;
|
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.
|
// Confirmation of successful connection request.
|
||||||
@@ -336,10 +331,6 @@ message DeviceInfoResponse {
|
|||||||
// all-zeros PSK, so the api encryption key can be provisioned without being
|
// all-zeros PSK, so the api encryption key can be provisioned without being
|
||||||
// sent in plaintext (protects against passive sniffing, not active MITM)
|
// sent in plaintext (protects against passive sniffing, not active MITM)
|
||||||
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
|
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 ====================
|
// ==================== DEVICE CAPABILITIES ====================
|
||||||
|
|||||||
@@ -1822,19 +1822,6 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
|||||||
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
|
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
|
||||||
this->complete_authentication_();
|
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);
|
return this->send_message(resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1957,9 +1944,6 @@ bool APIConnection::send_device_info_response_() {
|
|||||||
// one) so this advertisement survives the plaintext removal in 2027.2.0.
|
// one) so this advertisement survives the plaintext removal in 2027.2.0.
|
||||||
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
|
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
|
||||||
#endif
|
#endif
|
||||||
#ifdef USE_API_OUTGOING_CONNECTION
|
|
||||||
resp.api_outgoing_connection_supported = true;
|
|
||||||
#endif
|
|
||||||
#endif
|
#endif
|
||||||
#ifdef USE_DEVICES
|
#ifdef USE_DEVICES
|
||||||
size_t device_index = 0;
|
size_t device_index = 0;
|
||||||
|
|||||||
@@ -375,21 +375,6 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
return this->helper_->get_peername_to(buf);
|
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:
|
protected:
|
||||||
bool try_to_clear_buffer_slow_(bool log_out_of_space);
|
bool try_to_clear_buffer_slow_(bool log_out_of_space);
|
||||||
|
|
||||||
@@ -760,9 +745,6 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
uint8_t batch_first_message : 1; // For batch buffer allocation
|
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 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
|
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
|
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||||
uint8_t log_only_mode : 1;
|
uint8_t log_only_mode : 1;
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -282,8 +282,7 @@ class APIFrameHelper {
|
|||||||
DATA = 5,
|
DATA = 5,
|
||||||
CLOSED = 6,
|
CLOSED = 6,
|
||||||
FAILED = 7,
|
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.
|
// Fast inline state check for read_packet/write_protobuf_messages hot path.
|
||||||
|
|||||||
@@ -81,13 +81,6 @@ APIError APINoiseFrameHelper::init() {
|
|||||||
state_ = State::CLIENT_HELLO;
|
state_ = State::CLIENT_HELLO;
|
||||||
return APIError::OK;
|
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
|
#ifdef USE_API_PLAINTEXT
|
||||||
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
|
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
|
||||||
APIError err = this->init();
|
APIError err = this->init();
|
||||||
@@ -260,9 +253,6 @@ APIError APINoiseFrameHelper::state_action_() {
|
|||||||
HELPER_LOG("Bad state for method: %d", (int) this->state_);
|
HELPER_LOG("Bad state for method: %d", (int) this->state_);
|
||||||
return APIError::BAD_STATE;
|
return APIError::BAD_STATE;
|
||||||
case State::CLIENT_HELLO:
|
case State::CLIENT_HELLO:
|
||||||
#ifdef USE_API_OUTGOING_CONNECTION
|
|
||||||
case State::CLIENT_HELLO_OUTGOING:
|
|
||||||
#endif
|
|
||||||
return this->state_action_client_hello_();
|
return this->state_action_client_hello_();
|
||||||
case State::SERVER_HELLO:
|
case State::SERVER_HELLO:
|
||||||
return this->state_action_server_hello_();
|
return this->state_action_server_hello_();
|
||||||
@@ -295,16 +285,11 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
|||||||
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size);
|
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;
|
state_ = State::SERVER_HELLO;
|
||||||
return APIError::OK;
|
return APIError::OK;
|
||||||
}
|
}
|
||||||
APIError APINoiseFrameHelper::send_server_hello_frame_() {
|
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||||
|
// send server hello
|
||||||
const auto &name = App.get_name();
|
const auto &name = App.get_name();
|
||||||
char mac[MAC_ADDRESS_BUFFER_SIZE];
|
char mac[MAC_ADDRESS_BUFFER_SIZE];
|
||||||
get_mac_address_into_buffer(mac);
|
get_mac_address_into_buffer(mac);
|
||||||
@@ -328,18 +313,15 @@ APIError APINoiseFrameHelper::send_server_hello_frame_() {
|
|||||||
// node mac, terminated by null byte
|
// node mac, terminated by null byte
|
||||||
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
|
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
|
||||||
|
|
||||||
return write_frame_(msg, total_size);
|
APIError aerr = write_frame_(msg, total_size);
|
||||||
}
|
|
||||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
|
||||||
APIError aerr = this->send_server_hello_frame_();
|
|
||||||
if (aerr != APIError::OK)
|
if (aerr != APIError::OK)
|
||||||
return aerr;
|
return aerr;
|
||||||
return this->start_handshake_();
|
|
||||||
}
|
// start handshake
|
||||||
APIError APINoiseFrameHelper::start_handshake_() {
|
aerr = init_handshake_();
|
||||||
APIError aerr = init_handshake_();
|
|
||||||
if (aerr != APIError::OK)
|
if (aerr != APIError::OK)
|
||||||
return aerr;
|
return aerr;
|
||||||
|
|
||||||
state_ = State::HANDSHAKE;
|
state_ = State::HANDSHAKE;
|
||||||
return APIError::OK;
|
return APIError::OK;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,6 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
|||||||
// Seeds the already-read header bytes and pumps the handshake state machine
|
// Seeds the already-read header bytes and pumps the handshake state machine
|
||||||
// until it would block.
|
// until it would block.
|
||||||
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
|
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
|
#endif
|
||||||
APIError loop() override;
|
APIError loop() override;
|
||||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||||
@@ -45,8 +39,6 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
|||||||
APIError state_action_();
|
APIError state_action_();
|
||||||
APIError state_action_client_hello_();
|
APIError state_action_client_hello_();
|
||||||
APIError state_action_server_hello_();
|
APIError state_action_server_hello_();
|
||||||
APIError send_server_hello_frame_();
|
|
||||||
APIError start_handshake_();
|
|
||||||
APIError state_action_handshake_();
|
APIError state_action_handshake_();
|
||||||
APIError state_action_handshake_read_();
|
APIError state_action_handshake_read_();
|
||||||
APIError state_action_handshake_write_();
|
APIError state_action_handshake_write_();
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
#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
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
#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,11 +15,6 @@ bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value)
|
|||||||
case 3:
|
case 3:
|
||||||
this->api_version_minor = value;
|
this->api_version_minor = value;
|
||||||
break;
|
break;
|
||||||
#ifdef USE_API_OUTGOING_CONNECTION
|
|
||||||
case 4:
|
|
||||||
this->outgoing_connection_target = value != 0;
|
|
||||||
break;
|
|
||||||
#endif
|
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -180,9 +175,6 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
|
|||||||
#endif
|
#endif
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
|
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
|
#endif
|
||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
@@ -248,9 +240,6 @@ uint32_t DeviceInfoResponse::calculate_size() const {
|
|||||||
#endif
|
#endif
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
|
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
|
#endif
|
||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,16 +412,13 @@ class CommandProtoMessage : public ProtoDecodableMessage {
|
|||||||
class HelloRequest final : public ProtoDecodableMessage {
|
class HelloRequest final : public ProtoDecodableMessage {
|
||||||
public:
|
public:
|
||||||
static constexpr uint16_t MESSAGE_TYPE = 1;
|
static constexpr uint16_t MESSAGE_TYPE = 1;
|
||||||
static constexpr uint8_t ESTIMATED_SIZE = 19;
|
static constexpr uint8_t ESTIMATED_SIZE = 17;
|
||||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||||
const LogString *message_name() const override { return LOG_STR("hello_request"); }
|
const LogString *message_name() const override { return LOG_STR("hello_request"); }
|
||||||
#endif
|
#endif
|
||||||
StringRef client_info{};
|
StringRef client_info{};
|
||||||
uint32_t api_version_major{0};
|
uint32_t api_version_major{0};
|
||||||
uint32_t api_version_minor{0};
|
uint32_t api_version_minor{0};
|
||||||
#ifdef USE_API_OUTGOING_CONNECTION
|
|
||||||
bool outgoing_connection_target{false};
|
|
||||||
#endif
|
|
||||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||||
const char *dump_to(DumpBuffer &out) const override;
|
const char *dump_to(DumpBuffer &out) const override;
|
||||||
#endif
|
#endif
|
||||||
@@ -552,7 +549,7 @@ class SerialProxyInfo final : public ProtoMessage {
|
|||||||
class DeviceInfoResponse final : public ProtoMessage {
|
class DeviceInfoResponse final : public ProtoMessage {
|
||||||
public:
|
public:
|
||||||
static constexpr uint16_t MESSAGE_TYPE = 10;
|
static constexpr uint16_t MESSAGE_TYPE = 10;
|
||||||
static constexpr uint16_t ESTIMATED_SIZE = 315;
|
static constexpr uint16_t ESTIMATED_SIZE = 312;
|
||||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||||
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
|
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
|
||||||
#endif
|
#endif
|
||||||
@@ -610,9 +607,6 @@ class DeviceInfoResponse final : public ProtoMessage {
|
|||||||
#endif
|
#endif
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
bool api_encryption_provisionable{false};
|
bool api_encryption_provisionable{false};
|
||||||
#endif
|
|
||||||
#ifdef USE_API_OUTGOING_CONNECTION
|
|
||||||
bool api_outgoing_connection_supported{false};
|
|
||||||
#endif
|
#endif
|
||||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||||
uint32_t calculate_size() const;
|
uint32_t calculate_size() const;
|
||||||
|
|||||||
@@ -885,9 +885,6 @@ const char *HelloRequest::dump_to(DumpBuffer &out) const {
|
|||||||
dump_field(out, ESPHOME_PSTR("client_info"), this->client_info);
|
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_major"), this->api_version_major);
|
||||||
dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor);
|
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();
|
return out.c_str();
|
||||||
}
|
}
|
||||||
const char *HelloResponse::dump_to(DumpBuffer &out) const {
|
const char *HelloResponse::dump_to(DumpBuffer &out) const {
|
||||||
@@ -1011,9 +1008,6 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
|
|||||||
#endif
|
#endif
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
|
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
|
#endif
|
||||||
return out.c_str();
|
return out.c_str();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,46 +34,7 @@ APIServer::APIServer() { global_api_server = this; }
|
|||||||
void APIServer::socket_failed_(const LogString *msg) {
|
void APIServer::socket_failed_(const LogString *msg) {
|
||||||
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
|
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
|
||||||
this->destroy_socket_();
|
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() {
|
void APIServer::setup() {
|
||||||
@@ -92,14 +53,41 @@ void APIServer::setup() {
|
|||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if (!this->create_listen_socket_()) {
|
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||||
#ifdef USE_API_OUTGOING_CONNECTION
|
if (this->socket_ == nullptr) {
|
||||||
// Dial-out needs no listener; degrade instead of stopping the component
|
this->socket_failed_(LOG_STR("creation"));
|
||||||
this->status_set_error(LOG_STR("listen socket failed"));
|
return;
|
||||||
#else
|
}
|
||||||
this->mark_failed();
|
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"));
|
||||||
return;
|
return;
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef USE_LOGGER
|
#ifdef USE_LOGGER
|
||||||
@@ -147,9 +135,6 @@ void APIServer::setup() {
|
|||||||
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||||
}
|
}
|
||||||
#ifdef USE_API_OUTGOING_CONNECTION
|
|
||||||
this->outgoing_conn_.setup();
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void APIServer::loop() {
|
void APIServer::loop() {
|
||||||
@@ -158,12 +143,6 @@ void APIServer::loop() {
|
|||||||
this->accept_new_connections_();
|
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) {
|
if (this->api_connection_count_ == 0) {
|
||||||
// Check reboot timeout - done in loop to avoid scheduler heap churn
|
// Check reboot timeout - done in loop to avoid scheduler heap churn
|
||||||
// (cancelled scheduler items sit in heap memory until their scheduled time).
|
// (cancelled scheduler items sit in heap memory until their scheduled time).
|
||||||
@@ -172,12 +151,7 @@ void APIServer::loop() {
|
|||||||
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||||
const uint32_t now = App.get_loop_component_start_time();
|
const uint32_t now = App.get_loop_component_start_time();
|
||||||
if (now - this->last_connected_ > this->reboot_timeout_) {
|
if (now - this->last_connected_ > this->reboot_timeout_) {
|
||||||
// Distinguish a wrong-key peer from nothing connecting at all
|
ESP_LOGE(TAG, "No clients; rebooting");
|
||||||
if (this->saw_unauthenticated_client_) {
|
|
||||||
ESP_LOGE(TAG, "Clients connected but none authenticated; rebooting");
|
|
||||||
} else {
|
|
||||||
ESP_LOGE(TAG, "No clients; rebooting");
|
|
||||||
}
|
|
||||||
App.reboot();
|
App.reboot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,15 +203,6 @@ void APIServer::remove_client_(uint8_t client_index) {
|
|||||||
std::string client_peername(client->get_peername_to(peername_buf));
|
std::string client_peername(client->get_peername_to(peername_buf));
|
||||||
#endif
|
#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)
|
// Close socket now (was deferred from on_fatal_error to allow getpeername)
|
||||||
client->helper_->close();
|
client->helper_->close();
|
||||||
|
|
||||||
@@ -256,18 +221,9 @@ void APIServer::remove_client_(uint8_t client_index) {
|
|||||||
|
|
||||||
// Last client disconnected - set warning and start tracking for reboot timeout
|
// Last client disconnected - set warning and start tracking for reboot timeout
|
||||||
// (suppressed while provisioning is pending - see loop()).
|
// (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_()) {
|
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
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
|
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
|
||||||
@@ -289,7 +245,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
|||||||
sock->getpeername_to(peername);
|
sock->getpeername_to(peername);
|
||||||
|
|
||||||
// Check if we're at the connection limit
|
// Check if we're at the connection limit
|
||||||
if (this->at_client_limit_()) {
|
if (this->api_connection_count_ >= MAX_API_CONNECTIONS) {
|
||||||
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
|
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
|
||||||
// Immediately close - socket destructor will handle cleanup
|
// Immediately close - socket destructor will handle cleanup
|
||||||
sock.reset();
|
sock.reset();
|
||||||
@@ -298,54 +254,18 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
|||||||
|
|
||||||
ESP_LOGD(TAG, "Accept %s", peername);
|
ESP_LOGD(TAG, "Accept %s", peername);
|
||||||
|
|
||||||
this->add_client_(new APIConnection(std::move(sock), this));
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
void APIServer::dump_config() {
|
||||||
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
|
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
|
||||||
ESP_LOGCONFIG(TAG,
|
ESP_LOGCONFIG(TAG,
|
||||||
@@ -362,9 +282,6 @@ void APIServer::dump_config() {
|
|||||||
#else
|
#else
|
||||||
ESP_LOGCONFIG(TAG, " Noise encryption: NO");
|
ESP_LOGCONFIG(TAG, " Noise encryption: NO");
|
||||||
#endif
|
#endif
|
||||||
#ifdef USE_API_OUTGOING_CONNECTION
|
|
||||||
this->outgoing_conn_.dump_config();
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void APIServer::handle_disconnect(APIConnection *conn) {}
|
void APIServer::handle_disconnect(APIConnection *conn) {}
|
||||||
@@ -660,8 +577,6 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
|||||||
if (!c->send_message(req)) {
|
if (!c->send_message(req)) {
|
||||||
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
|
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;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -763,9 +678,6 @@ void APIServer::on_shutdown() {
|
|||||||
|
|
||||||
// Close the listening socket to prevent new connections
|
// Close the listening socket to prevent new connections
|
||||||
this->destroy_socket_();
|
this->destroy_socket_();
|
||||||
#ifdef USE_API_OUTGOING_CONNECTION
|
|
||||||
this->outgoing_conn_.on_shutdown();
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Change batch delay to 5ms for quick flushing during shutdown
|
// Change batch delay to 5ms for quick flushing during shutdown
|
||||||
this->batch_delay_ = 5;
|
this->batch_delay_ = 5;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
#endif
|
#endif
|
||||||
#include "api_pb2.h"
|
#include "api_pb2.h"
|
||||||
#include "api_pb2_service.h"
|
#include "api_pb2_service.h"
|
||||||
#include "api_outgoing_connection.h"
|
|
||||||
#include "esphome/components/socket/socket.h"
|
#include "esphome/components/socket/socket.h"
|
||||||
#include "esphome/core/automation.h"
|
#include "esphome/core/automation.h"
|
||||||
#include "esphome/core/component.h"
|
#include "esphome/core/component.h"
|
||||||
@@ -87,10 +86,6 @@ class APIServer final : public Component,
|
|||||||
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||||
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
|
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
|
||||||
#endif // USE_API_NOISE
|
#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);
|
void handle_disconnect(APIConnection *conn);
|
||||||
#ifdef USE_BINARY_SENSOR
|
#ifdef USE_BINARY_SENSOR
|
||||||
@@ -268,16 +263,6 @@ class APIServer final : public Component,
|
|||||||
protected:
|
protected:
|
||||||
// Accept incoming socket connections. Only called when socket has pending connections.
|
// Accept incoming socket connections. Only called when socket has pending connections.
|
||||||
void __attribute__((noinline)) accept_new_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.
|
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
|
||||||
void __attribute__((noinline)) remove_client_(uint8_t client_index);
|
void __attribute__((noinline)) remove_client_(uint8_t client_index);
|
||||||
|
|
||||||
@@ -319,7 +304,6 @@ class APIServer final : public Component,
|
|||||||
this->socket_ = nullptr;
|
this->socket_ = nullptr;
|
||||||
}
|
}
|
||||||
void socket_failed_(const LogString *msg);
|
void socket_failed_(const LogString *msg);
|
||||||
bool create_listen_socket_();
|
|
||||||
// Pointers and pointer-like types first (4 bytes each)
|
// Pointers and pointer-like types first (4 bytes each)
|
||||||
socket::ListenSocket *socket_{nullptr};
|
socket::ListenSocket *socket_{nullptr};
|
||||||
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
|
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
|
||||||
@@ -372,16 +356,8 @@ class APIServer final : public Component,
|
|||||||
// Connection limits - these defaults will be overridden by config values
|
// Connection limits - these defaults will be overridden by config values
|
||||||
// from cv.SplitDefault in __init__.py which sets platform-specific defaults.
|
// from cv.SplitDefault in __init__.py which sets platform-specific defaults.
|
||||||
uint8_t listen_backlog_{4};
|
uint8_t listen_backlog_{4};
|
||||||
// Bit-packed so the two flags share one byte
|
bool shutting_down_ = false;
|
||||||
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};
|
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)
|
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
|
||||||
// Index assigned by the provisioning manager for reporting this transport's state.
|
// Index assigned by the provisioning manager for reporting this transport's state.
|
||||||
uint8_t provisioning_source_{0};
|
uint8_t provisioning_source_{0};
|
||||||
@@ -394,9 +370,6 @@ class APIServer final : public Component,
|
|||||||
#endif
|
#endif
|
||||||
ESPPreferenceObject noise_pref_;
|
ESPPreferenceObject noise_pref_;
|
||||||
#endif // USE_API_NOISE
|
#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)
|
extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||||
|
|||||||
@@ -42,16 +42,7 @@ bool AsyncClient::connect(const char *host, uint16_t port) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (socket_->setblocking(false) != 0) {
|
socket_->setblocking(false);
|
||||||
// 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);
|
int err = socket_->connect((struct sockaddr *) &addr, addrlen);
|
||||||
if (err == 0) {
|
if (err == 0) {
|
||||||
@@ -106,22 +97,45 @@ void AsyncClient::loop() {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
if (connecting_) {
|
if (connecting_) {
|
||||||
int err = 0;
|
// For connecting, we need to check writability, not readability
|
||||||
switch (socket::poll_connect(*socket_, err)) {
|
// The Application's select() only monitors read FDs, so we do our own check here
|
||||||
case socket::ConnectPollResult::CONNECT_POLL_PENDING:
|
// For ESP platforms lwip_select() might be faster, but this code isn't used
|
||||||
break;
|
// on those platforms anyway. If it was, we'd fix the Application select()
|
||||||
case socket::ConnectPollResult::CONNECT_POLL_CONNECTED:
|
// 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) {
|
||||||
connecting_ = false;
|
connecting_ = false;
|
||||||
connected_ = true;
|
connected_ = true;
|
||||||
if (connect_cb_)
|
if (connect_cb_)
|
||||||
connect_cb_(connect_arg_, this);
|
connect_cb_(connect_arg_, this);
|
||||||
break;
|
} else {
|
||||||
case socket::ConnectPollResult::CONNECT_POLL_ERROR:
|
ESP_LOGW(TAG, "Connection failed: %d", error);
|
||||||
ESP_LOGW(TAG, "Connection failed: %d", err);
|
|
||||||
close();
|
close();
|
||||||
if (error_cb_)
|
if (error_cb_)
|
||||||
error_cb_(error_arg_, this, err);
|
error_cb_(error_arg_, this, error);
|
||||||
break;
|
}
|
||||||
|
} 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);
|
||||||
}
|
}
|
||||||
} else if (connected_) {
|
} else if (connected_) {
|
||||||
// For connected sockets, use the Application's select() results
|
// For connected sockets, use the Application's select() results
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
|
|||||||
if (current_audio_file_ != nullptr) {
|
if (current_audio_file_ != nullptr) {
|
||||||
// A transfer buffer isn't ncessary for a local file
|
// A transfer buffer isn't ncessary for a local file
|
||||||
this->file_ring_buffer_ = output_ring_buffer.lock();
|
this->file_ring_buffer_ = output_ring_buffer.lock();
|
||||||
|
if (this->file_ring_buffer_ == nullptr) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le
|
|||||||
|
|
||||||
void AudioTransferBuffer::clear_buffered_data() {
|
void AudioTransferBuffer::clear_buffered_data() {
|
||||||
this->buffer_length_ = 0;
|
this->buffer_length_ = 0;
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
this->ring_buffer_->reset();
|
this->ring_buffer_->reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void AudioSinkTransferBuffer::clear_buffered_data() {
|
void AudioSinkTransferBuffer::clear_buffered_data() {
|
||||||
this->buffer_length_ = 0;
|
this->buffer_length_ = 0;
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
this->ring_buffer_->reset();
|
this->ring_buffer_->reset();
|
||||||
}
|
}
|
||||||
#ifdef USE_SPEAKER
|
#ifdef USE_SPEAKER
|
||||||
@@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool AudioTransferBuffer::has_buffered_data() const {
|
bool AudioTransferBuffer::has_buffered_data() const {
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
|
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
|
||||||
}
|
}
|
||||||
return (this->available() > 0);
|
return (this->available() > 0);
|
||||||
@@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_
|
|||||||
size_t bytes_to_read = AudioTransferBuffer::free();
|
size_t bytes_to_read = AudioTransferBuffer::free();
|
||||||
size_t bytes_read = 0;
|
size_t bytes_read = 0;
|
||||||
if (bytes_to_read > 0) {
|
if (bytes_to_read > 0) {
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait);
|
bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait,
|
|||||||
bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait);
|
bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait);
|
||||||
} else
|
} else
|
||||||
#endif
|
#endif
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
bytes_written =
|
bytes_written =
|
||||||
this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait);
|
this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait);
|
||||||
} else if (this->sink_callback_ != nullptr) {
|
} else if (this->sink_callback_ != nullptr) {
|
||||||
@@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const {
|
|||||||
return (this->speaker_->has_buffered_data() || (this->available() > 0));
|
return (this->speaker_->has_buffered_data() || (this->available() > 0));
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
|
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
|
||||||
}
|
}
|
||||||
return (this->available() > 0);
|
return (this->available() > 0);
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection"
|
|||||||
CONF_ENABLED = "enabled"
|
CONF_ENABLED = "enabled"
|
||||||
CONF_GYROSCOPE_ODR = "gyroscope_odr"
|
CONF_GYROSCOPE_ODR = "gyroscope_odr"
|
||||||
CONF_GYROSCOPE_RANGE = "gyroscope_range"
|
CONF_GYROSCOPE_RANGE = "gyroscope_range"
|
||||||
CONF_HOST = "host"
|
|
||||||
CONF_IAQ = "iaq"
|
CONF_IAQ = "iaq"
|
||||||
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
|
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
|
||||||
CONF_IS_WRGB = "is_wrgb"
|
CONF_IS_WRGB = "is_wrgb"
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
|
|||||||
#endif
|
#endif
|
||||||
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
|
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
|
||||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
|
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
|
||||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
|
// Milliseconds for data transfer. Covers the lwIP retransmit run seen in
|
||||||
|
// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits
|
||||||
|
// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries
|
||||||
|
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000;
|
||||||
|
|
||||||
// Single-instance pointer — multi-port configs are rejected in final_validate.
|
// Single-instance pointer — multi-port configs are rejected in final_validate.
|
||||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||||
@@ -444,10 +447,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
|||||||
tv.tv_usec = 0;
|
tv.tv_usec = 0;
|
||||||
this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||||
this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||||
if (this->client_->setblocking(true) != 0) {
|
this->client_->setblocking(true);
|
||||||
this->log_socket_error_(LOG_STR("blocking"));
|
|
||||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acknowledge auth OK - 1 byte
|
// Acknowledge auth OK - 1 byte
|
||||||
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
|
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
|
||||||
|
|||||||
@@ -118,21 +118,24 @@ void I2SAudioSpeakerBase::loop() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Still starting up or winding down from a previous run
|
||||||
|
if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
|
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
|
||||||
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
|
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
|
||||||
this->status_momentary_error("driver-failure", 1000);
|
this->status_momentary_error("driver-failure", 1000);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this->speaker_task_handle_ == nullptr) {
|
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||||
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
&this->speaker_task_handle_);
|
||||||
&this->speaker_task_handle_);
|
|
||||||
|
|
||||||
if (this->speaker_task_handle_ == nullptr) {
|
if (this->speaker_task_handle_ == nullptr) {
|
||||||
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
|
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
|
||||||
this->status_momentary_error("task-failure", 1000);
|
this->status_momentary_error("task-failure", 1000);
|
||||||
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
|
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case speaker::STATE_RUNNING: // Intentional fallthrough
|
case speaker::STATE_RUNNING: // Intentional fallthrough
|
||||||
@@ -218,8 +221,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool I2SAudioSpeakerBase::has_buffered_data() const {
|
bool I2SAudioSpeakerBase::has_buffered_data() const {
|
||||||
if (this->audio_ring_buffer_.use_count() > 0) {
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
if (temp_ring_buffer != nullptr) {
|
||||||
return temp_ring_buffer->available() > 0;
|
return temp_ring_buffer->available() > 0;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ void MicroWakeWord::setup() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||||
if (this->ring_buffer_.use_count() > 1) {
|
if (temp_ring_buffer != nullptr) {
|
||||||
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
|
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
|
||||||
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
|
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
|
||||||
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
|
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
|
||||||
@@ -446,9 +446,9 @@ void MicroWakeWord::loop() {
|
|||||||
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING);
|
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
|
// Retries on a subsequent loop if the task is still running on the other core
|
||||||
|
if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) {
|
||||||
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
|
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
|
||||||
this->inference_task_.deallocate();
|
|
||||||
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
||||||
xQueueReset(this->detection_queue_);
|
xQueueReset(this->detection_queue_);
|
||||||
this->set_state_(State::STOPPED);
|
this->set_state_(State::STOPPED);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class MicrophoneSource final {
|
|||||||
template<typename F> void add_data_callback(F &&data_callback) {
|
template<typename F> void add_data_callback(F &&data_callback) {
|
||||||
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
|
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
|
||||||
if (this->enabled_ || this->passive_) {
|
if (this->enabled_ || this->passive_) {
|
||||||
if (this->processed_samples_.use_count() == 0) {
|
if (this->processed_samples_ == nullptr) {
|
||||||
// Create vector if its unused
|
// Create vector if its unused
|
||||||
this->processed_samples_ = std::make_shared<std::vector<uint8_t>>();
|
this->processed_samples_ = std::make_shared<std::vector<uint8_t>>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_
|
|||||||
}
|
}
|
||||||
size_t bytes_written = 0;
|
size_t bytes_written = 0;
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||||
if (temp_ring_buffer.use_count() > 0) {
|
if (temp_ring_buffer != nullptr) {
|
||||||
// Only write to the ring buffer if the reference is valid
|
// Only write to the ring buffer if the reference is valid
|
||||||
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
||||||
if (bytes_written > 0) {
|
if (bytes_written > 0) {
|
||||||
@@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() {
|
|||||||
// avoids unnecessary single-frame splices.
|
// avoids unnecessary single-frame splices.
|
||||||
const size_t ring_buffer_size =
|
const size_t ring_buffer_size =
|
||||||
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
|
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
|
||||||
if (this->audio_source_.use_count() == 0) {
|
if (this->audio_source_ == nullptr) {
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||||
if (!temp_ring_buffer) {
|
if (temp_ring_buffer == nullptr) {
|
||||||
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
||||||
this->ring_buffer_ = temp_ring_buffer;
|
this->ring_buffer_ = temp_ring_buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!temp_ring_buffer) {
|
if (temp_ring_buffer == nullptr) {
|
||||||
return ESP_ERR_NO_MEM;
|
return ESP_ERR_NO_MEM;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); }
|
|||||||
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
|
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
|
||||||
|
|
||||||
bool SourceSpeaker::has_buffered_data() const {
|
bool SourceSpeaker::has_buffered_data() const {
|
||||||
return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data());
|
return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data());
|
||||||
}
|
}
|
||||||
|
|
||||||
void SourceSpeaker::set_mute_state(bool mute_state) {
|
void SourceSpeaker::set_mute_state(bool mute_state) {
|
||||||
@@ -382,8 +382,8 @@ void MixerSpeaker::loop() {
|
|||||||
ESP_LOGV(TAG, "Stopping");
|
ESP_LOGV(TAG, "Stopping");
|
||||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
|
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
|
||||||
}
|
}
|
||||||
if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
|
// Retries on a subsequent loop if the task is still running on the other core
|
||||||
this->task_.deallocate();
|
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
|
||||||
ESP_LOGD(TAG, "Stopped");
|
ESP_LOGD(TAG, "Stopped");
|
||||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
|
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
|
||||||
this->all_stopped_since_ms_ = 0;
|
this->all_stopped_since_ms_ = 0;
|
||||||
@@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
|||||||
if (speaker->is_running() && !speaker->get_pause_state()) {
|
if (speaker->is_running() && !speaker->get_pause_state()) {
|
||||||
// Speaker is running and not paused, so it possibly can provide audio data
|
// Speaker is running and not paused, so it possibly can provide audio data
|
||||||
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
|
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
|
||||||
if (audio_source.use_count() == 0) {
|
if (audio_source == nullptr) {
|
||||||
// No audio source allocated, so skip processing this speaker
|
// No audio source allocated, so skip processing this speaker
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
|
|||||||
|
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
cg.add_define("USE_NOISE")
|
cg.add_define("USE_NOISE")
|
||||||
cg.add_library("esphome/noise-c", "0.1.24")
|
cg.add_library("esphome/noise-c", "0.1.26")
|
||||||
# noise-c depends on libsodium, but declaring it here too lets the
|
# noise-c depends on libsodium, but declaring it here too lets the
|
||||||
# library manager see the full set up front instead of discovering
|
# library manager see the full set up front instead of discovering
|
||||||
# libsodium only after noise-c has downloaded, so the two can download
|
# libsodium only after noise-c has downloaded, so the two can download
|
||||||
# in parallel. The version must match noise-c's library.json.
|
# in parallel. The version must match noise-c's library.json.
|
||||||
cg.add_library("esphome/libsodium", "1.10021.6")
|
cg.add_library("esphome/libsodium", "1.10021.8")
|
||||||
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
|
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
|
||||||
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
||||||
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() {
|
|||||||
ESP_LOGV(TAG, "Stopping");
|
ESP_LOGV(TAG, "Stopping");
|
||||||
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
|
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
|
||||||
}
|
}
|
||||||
if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) {
|
// Retries on a subsequent loop if the task is still running on the other core
|
||||||
this->task_.deallocate();
|
if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) {
|
||||||
ESP_LOGD(TAG, "Stopped");
|
ESP_LOGD(TAG, "Stopped");
|
||||||
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS);
|
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS);
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic
|
|||||||
bytes_written = this->output_speaker_->play(data, length, ticks_to_wait);
|
bytes_written = this->output_speaker_->play(data, length, ticks_to_wait);
|
||||||
} else {
|
} else {
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||||
if (temp_ring_buffer) {
|
if (temp_ring_buffer != nullptr) {
|
||||||
// Only write to the ring buffer if the reference is valid
|
// Only write to the ring buffer if the reference is valid
|
||||||
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
||||||
} else {
|
} else {
|
||||||
@@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const {
|
|||||||
bool has_ring_buffer_data = false;
|
bool has_ring_buffer_data = false;
|
||||||
if (this->requires_resampling_()) {
|
if (this->requires_resampling_()) {
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||||
if (temp_ring_buffer) {
|
if (temp_ring_buffer != nullptr) {
|
||||||
has_ring_buffer_data = (temp_ring_buffer->available() > 0);
|
has_ring_buffer_data = (temp_ring_buffer->available() > 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) {
|
|||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
|
||||||
this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_));
|
this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_));
|
||||||
|
|
||||||
if (!temp_ring_buffer) {
|
if (temp_ring_buffer == nullptr) {
|
||||||
err = ESP_ERR_NO_MEM;
|
err = ESP_ERR_NO_MEM;
|
||||||
} else {
|
} else {
|
||||||
this_resampler->ring_buffer_ = temp_ring_buffer;
|
this_resampler->ring_buffer_ = temp_ring_buffer;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ CONF_SENDSPIN_ID = "sendspin_id"
|
|||||||
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
|
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
|
||||||
CONF_FIXED_DELAY = "fixed_delay"
|
CONF_FIXED_DELAY = "fixed_delay"
|
||||||
CONF_DECODE_MEMORY = "decode_memory"
|
CONF_DECODE_MEMORY = "decode_memory"
|
||||||
|
CONF_CODECS = "codecs"
|
||||||
|
|
||||||
# Matches ARTWORK_MAX_SLOTS in sendspin-cpp.
|
# Matches ARTWORK_MAX_SLOTS in sendspin-cpp.
|
||||||
MAX_ARTWORK_SLOTS = 4
|
MAX_ARTWORK_SLOTS = 4
|
||||||
@@ -44,6 +45,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
|
|||||||
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
|
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
|
||||||
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
|
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
|
||||||
|
|
||||||
|
CODEC_FLAC = "flac"
|
||||||
|
CODEC_OPUS = "opus"
|
||||||
|
CODEC_PCM = "pcm"
|
||||||
|
|
||||||
|
CODECS = {
|
||||||
|
CODEC_FLAC: CODEC_FORMAT_FLAC,
|
||||||
|
CODEC_OPUS: CODEC_FORMAT_OPUS,
|
||||||
|
CODEC_PCM: CODEC_FORMAT_PCM,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Opus only supports 48 kHz audio, so it is left out of the default list at other rates.
|
||||||
|
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
|
||||||
|
OPUS_SAMPLE_RATE = 48000
|
||||||
|
|
||||||
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
|
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
|
||||||
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
|
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
|
||||||
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
|
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
|
||||||
@@ -286,16 +301,13 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
if data.player_support:
|
if data.player_support:
|
||||||
cg.add_define("USE_SENDSPIN_PLAYER", True)
|
cg.add_define("USE_SENDSPIN_PLAYER", True)
|
||||||
|
|
||||||
# Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate
|
# Configures the player role. Each configured codec is advertised for 16 bits per sample
|
||||||
# (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server
|
# mono and stereo at the configured sample rate. The order is a preference order, both for
|
||||||
|
# the codecs themselves and for stereo over mono.
|
||||||
player_cfg = data.player_config
|
player_cfg = data.player_config
|
||||||
sample_rate = player_cfg[CONF_SAMPLE_RATE]
|
sample_rate = player_cfg[CONF_SAMPLE_RATE]
|
||||||
|
|
||||||
# OPUS only supports 48 kHz audio
|
codecs = player_cfg[CONF_CODECS]
|
||||||
codecs = [CODEC_FORMAT_FLAC]
|
|
||||||
if sample_rate == 48000:
|
|
||||||
codecs.append(CODEC_FORMAT_OPUS)
|
|
||||||
codecs.append(CODEC_FORMAT_PCM)
|
|
||||||
|
|
||||||
def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer:
|
def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer:
|
||||||
return cg.StructInitializer(
|
return cg.StructInitializer(
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ from esphome.cpp_generator import MockObj, TemplateArgsType
|
|||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from .. import (
|
from .. import (
|
||||||
|
CODEC_OPUS,
|
||||||
|
CODECS,
|
||||||
|
CONF_CODECS,
|
||||||
CONF_DECODE_MEMORY,
|
CONF_DECODE_MEMORY,
|
||||||
CONF_FIXED_DELAY,
|
CONF_FIXED_DELAY,
|
||||||
CONF_INITIAL_STATIC_DELAY,
|
CONF_INITIAL_STATIC_DELAY,
|
||||||
CONF_SENDSPIN_ID,
|
CONF_SENDSPIN_ID,
|
||||||
|
DEFAULT_CODECS,
|
||||||
MEMORY_LOCATIONS,
|
MEMORY_LOCATIONS,
|
||||||
|
OPUS_SAMPLE_RATE,
|
||||||
SendspinHub,
|
SendspinHub,
|
||||||
register_player_config,
|
register_player_config,
|
||||||
request_controller_support,
|
request_controller_support,
|
||||||
@@ -49,10 +54,32 @@ DisableStaticDelayAdjustmentAction = sendspin_ns.class_(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_codecs(config: ConfigType) -> ConfigType:
|
||||||
|
"""Validate the codec preference list, filling in the default when it is not set."""
|
||||||
|
sample_rate = config[CONF_SAMPLE_RATE]
|
||||||
|
if (codecs := config.get(CONF_CODECS)) is None:
|
||||||
|
config[CONF_CODECS] = [
|
||||||
|
codec
|
||||||
|
for codec in DEFAULT_CODECS
|
||||||
|
if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE
|
||||||
|
]
|
||||||
|
return config
|
||||||
|
|
||||||
|
if len(set(codecs)) != len(codecs):
|
||||||
|
raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS])
|
||||||
|
if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}",
|
||||||
|
path=[CONF_CODECS],
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _register(config: ConfigType) -> ConfigType:
|
def _register(config: ConfigType) -> ConfigType:
|
||||||
request_controller_support()
|
request_controller_support()
|
||||||
register_player_config(
|
register_player_config(
|
||||||
{
|
{
|
||||||
|
CONF_CODECS: config[CONF_CODECS],
|
||||||
CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE],
|
CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE],
|
||||||
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
|
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
|
||||||
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
|
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
|
||||||
@@ -85,9 +112,13 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
min=16000, max=96000
|
min=16000, max=96000
|
||||||
),
|
),
|
||||||
cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True),
|
cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True),
|
||||||
|
cv.Optional(CONF_CODECS): cv.All(
|
||||||
|
cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
cv.only_on_esp32,
|
cv.only_on_esp32,
|
||||||
|
_resolve_codecs,
|
||||||
_register,
|
_register,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ CONF_IMPLEMENTATION = "implementation"
|
|||||||
IMPLEMENTATION_LWIP_TCP = "lwip_tcp"
|
IMPLEMENTATION_LWIP_TCP = "lwip_tcp"
|
||||||
IMPLEMENTATION_LWIP_SOCKETS = "lwip_sockets"
|
IMPLEMENTATION_LWIP_SOCKETS = "lwip_sockets"
|
||||||
IMPLEMENTATION_BSD_SOCKETS = "bsd_sockets"
|
IMPLEMENTATION_BSD_SOCKETS = "bsd_sockets"
|
||||||
# Implementations whose sockets cannot make outgoing connections
|
|
||||||
IMPLEMENTATIONS_WITHOUT_CONNECT = frozenset({IMPLEMENTATION_LWIP_TCP})
|
|
||||||
|
|
||||||
# Socket tracking infrastructure
|
# Socket tracking infrastructure
|
||||||
# Components register their socket needs and platforms read this to configure appropriately
|
# Components register their socket needs and platforms read this to configure appropriately
|
||||||
|
|||||||
@@ -59,15 +59,13 @@ int BSDSocketImpl::close() {
|
|||||||
|
|
||||||
int BSDSocketImpl::setblocking(bool blocking) {
|
int BSDSocketImpl::setblocking(bool blocking) {
|
||||||
int fl = ::fcntl(this->fd_, F_GETFL, 0);
|
int fl = ::fcntl(this->fd_, F_GETFL, 0);
|
||||||
if (fl < 0) {
|
|
||||||
return fl;
|
|
||||||
}
|
|
||||||
if (blocking) {
|
if (blocking) {
|
||||||
fl &= ~O_NONBLOCK;
|
fl &= ~O_NONBLOCK;
|
||||||
} else {
|
} else {
|
||||||
fl |= O_NONBLOCK;
|
fl |= O_NONBLOCK;
|
||||||
}
|
}
|
||||||
return ::fcntl(this->fd_, F_SETFL, fl);
|
::fcntl(this->fd_, F_SETFL, fl);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t BSDSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
|
size_t BSDSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
|
||||||
|
|||||||
@@ -49,15 +49,13 @@ int LwIPSocketImpl::close() {
|
|||||||
|
|
||||||
int LwIPSocketImpl::setblocking(bool blocking) {
|
int LwIPSocketImpl::setblocking(bool blocking) {
|
||||||
int fl = lwip_fcntl(this->fd_, F_GETFL, 0);
|
int fl = lwip_fcntl(this->fd_, F_GETFL, 0);
|
||||||
if (fl < 0) {
|
|
||||||
return fl;
|
|
||||||
}
|
|
||||||
if (blocking) {
|
if (blocking) {
|
||||||
fl &= ~O_NONBLOCK;
|
fl &= ~O_NONBLOCK;
|
||||||
} else {
|
} else {
|
||||||
fl |= O_NONBLOCK;
|
fl |= O_NONBLOCK;
|
||||||
}
|
}
|
||||||
return lwip_fcntl(this->fd_, F_SETFL, fl);
|
lwip_fcntl(this->fd_, F_SETFL, fl);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t LwIPSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
|
size_t LwIPSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)
|
#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#ifdef USE_SOCKET_IMPL_BSD_SOCKETS
|
|
||||||
#include <sys/select.h>
|
|
||||||
#endif
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include "esphome/core/log.h"
|
#include "esphome/core/log.h"
|
||||||
#include "esphome/core/application.h"
|
#include "esphome/core/application.h"
|
||||||
@@ -168,10 +165,7 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
|
|||||||
#else
|
#else
|
||||||
// Use LWIP-specific functions
|
// Use LWIP-specific functions
|
||||||
ip6_addr_t ip6;
|
ip6_addr_t ip6;
|
||||||
if (inet6_aton(ip_address, &ip6) == 0) {
|
inet6_aton(ip_address, &ip6);
|
||||||
errno = EINVAL;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr));
|
memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr));
|
||||||
#endif
|
#endif
|
||||||
return sizeof(sockaddr_in6);
|
return sizeof(sockaddr_in6);
|
||||||
@@ -191,58 +185,12 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
// Unlike inet_addr(), inet_aton() can signal failure while still
|
server->sin_addr.s_addr = inet_addr(ip_address);
|
||||||
// accepting the broadcast address 255.255.255.255
|
|
||||||
if (inet_aton(ip_address, &server->sin_addr) == 0) {
|
|
||||||
errno = EINVAL;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
server->sin_port = htons(port);
|
server->sin_port = htons(port);
|
||||||
return sizeof(sockaddr_in);
|
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) {
|
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port) {
|
||||||
#if USE_NETWORK_IPV6
|
#if USE_NETWORK_IPV6
|
||||||
if (addrlen < sizeof(sockaddr_in6)) {
|
if (addrlen < sizeof(sockaddr_in6)) {
|
||||||
|
|||||||
@@ -145,19 +145,6 @@ 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().
|
/// 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);
|
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)
|
/// 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);
|
size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span<char, SOCKADDR_STR_LEN> buf);
|
||||||
|
|
||||||
|
|||||||
@@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() {
|
|||||||
if (!this->is_playing_) {
|
if (!this->is_playing_) {
|
||||||
// The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks
|
// The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks
|
||||||
if (this->read_task_.is_created() || this->decode_task_.is_created()) {
|
if (this->read_task_.is_created() || this->decode_task_.is_created()) {
|
||||||
this->read_task_.deallocate();
|
// Both are attempted every time; a task that is still running on the other core is freed by a
|
||||||
this->decode_task_.deallocate();
|
// subsequent call, and freeing an already freed task succeeds without doing anything
|
||||||
|
bool read_task_freed = this->read_task_.deallocate();
|
||||||
|
bool decode_task_freed = this->decode_task_.deallocate();
|
||||||
|
if (!read_task_freed || !decode_task_freed) {
|
||||||
|
// A task is still running on the other core, so keep the pipeline in its current state and try
|
||||||
|
// again on the next call
|
||||||
|
return AudioPipelineState::PLAYING;
|
||||||
|
}
|
||||||
if (this->hard_stop_) {
|
if (this->hard_stop_) {
|
||||||
// Stop command was sent, so immediately end the playback
|
// Stop command was sent, so immediately end the playback
|
||||||
this->speaker_->stop();
|
this->speaker_->stop();
|
||||||
@@ -315,17 +322,17 @@ void AudioPipeline::read_task(void *params) {
|
|||||||
if (err == ESP_OK) {
|
if (err == ESP_OK) {
|
||||||
size_t file_ring_buffer_size = this_pipeline->buffer_size_;
|
size_t file_ring_buffer_size = this_pipeline->buffer_size_;
|
||||||
|
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer;
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock();
|
||||||
|
|
||||||
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
|
if (temp_ring_buffer == nullptr) {
|
||||||
temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size);
|
temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size);
|
||||||
this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer;
|
this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
|
if (temp_ring_buffer == nullptr) {
|
||||||
err = ESP_ERR_NO_MEM;
|
err = ESP_ERR_NO_MEM;
|
||||||
} else {
|
} else {
|
||||||
reader->add_sink(this_pipeline->raw_file_ring_buffer_);
|
err = reader->add_sink(temp_ring_buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +403,9 @@ void AudioPipeline::decode_task(void *params) {
|
|||||||
make_unique<audio::AudioDecoder>(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_);
|
make_unique<audio::AudioDecoder>(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_);
|
||||||
|
|
||||||
esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_);
|
esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_);
|
||||||
decoder->add_source(this_pipeline->raw_file_ring_buffer_);
|
if (err == ESP_OK) {
|
||||||
|
err = decoder->add_source(this_pipeline->raw_file_ring_buffer_);
|
||||||
|
}
|
||||||
|
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
// Send specific error message
|
// Send specific error message
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components import binary_sensor, sensor
|
from esphome.components import binary_sensor, sensor
|
||||||
from esphome.components.const import CONF_HOST
|
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
CONF_BINARY_SENSORS,
|
CONF_BINARY_SENSORS,
|
||||||
@@ -15,6 +14,7 @@ AUTO_LOAD = ["socket"]
|
|||||||
CODEOWNERS = ["@Links2004"]
|
CODEOWNERS = ["@Links2004"]
|
||||||
DEPENDENCIES = ["network"]
|
DEPENDENCIES = ["network"]
|
||||||
|
|
||||||
|
CONF_HOST = "host"
|
||||||
CONF_PREFIX = "prefix"
|
CONF_PREFIX = "prefix"
|
||||||
|
|
||||||
statsd_component_ns = cg.esphome_ns.namespace("statsd")
|
statsd_component_ns = cg.esphome_ns.namespace("statsd")
|
||||||
|
|||||||
@@ -13,16 +13,9 @@ void UDPComponent::setup() {
|
|||||||
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
|
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
|
||||||
for (const auto &address : this->addresses_) {
|
for (const auto &address : this->addresses_) {
|
||||||
struct sockaddr saddr {};
|
struct sockaddr saddr {};
|
||||||
if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) {
|
socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_);
|
||||||
ESP_LOGW(TAG, "Invalid address %s", address);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this->sockaddrs_.push_back(saddr);
|
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
|
// set up broadcast socket
|
||||||
if (this->should_broadcast_) {
|
if (this->should_broadcast_) {
|
||||||
this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
||||||
@@ -101,15 +94,9 @@ void UDPComponent::setup() {
|
|||||||
// 8266 and RP2040 `Duino
|
// 8266 and RP2040 `Duino
|
||||||
for (const auto &address : this->addresses_) {
|
for (const auto &address : this->addresses_) {
|
||||||
auto ipaddr = IPAddress();
|
auto ipaddr = IPAddress();
|
||||||
if (!ipaddr.fromString(address)) {
|
ipaddr.fromString(address);
|
||||||
ESP_LOGW(TAG, "Invalid address %s", address);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this->ipaddrs_.push_back(ipaddr);
|
this->ipaddrs_.push_back(ipaddr);
|
||||||
}
|
}
|
||||||
if (this->ipaddrs_.size() != this->addresses_.size()) {
|
|
||||||
this->status_set_warning(LOG_STR("invalid address"));
|
|
||||||
}
|
|
||||||
if (this->should_listen_)
|
if (this->should_listen_)
|
||||||
this->udp_client_.begin(this->listen_port_);
|
this->udp_client_.begin(this->listen_port_);
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ void WakeOnLanButton::press_action() {
|
|||||||
struct sockaddr_storage saddr {};
|
struct sockaddr_storage saddr {};
|
||||||
auto addr_len =
|
auto addr_len =
|
||||||
socket::set_sockaddr(reinterpret_cast<sockaddr *>(&saddr), sizeof(saddr), "255.255.255.255", this->port_);
|
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];
|
uint8_t buffer[6 + sizeof this->macaddr_ * 16];
|
||||||
memcpy(buffer, PREFIX, sizeof(PREFIX));
|
memcpy(buffer, PREFIX, sizeof(PREFIX));
|
||||||
for (size_t i = 0; i != 16; i++) {
|
for (size_t i = 0; i != 16; i++) {
|
||||||
|
|||||||
@@ -216,11 +216,6 @@
|
|||||||
#define USE_API_HOMEASSISTANT_SERVICES
|
#define USE_API_HOMEASSISTANT_SERVICES
|
||||||
#define USE_API_HOMEASSISTANT_STATES
|
#define USE_API_HOMEASSISTANT_STATES
|
||||||
#define USE_API_NOISE
|
#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_VARINT64
|
||||||
#define USE_API_PLAINTEXT
|
#define USE_API_PLAINTEXT
|
||||||
#define USE_API_USER_DEFINED_ACTIONS
|
#define USE_API_USER_DEFINED_ACTIONS
|
||||||
|
|||||||
@@ -40,16 +40,31 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void StaticTask::destroy() {
|
bool StaticTask::destroy() {
|
||||||
if (this->handle_ != nullptr) {
|
if (this->handle_ == nullptr) {
|
||||||
TaskHandle_t handle = this->handle_;
|
return true;
|
||||||
this->handle_ = nullptr;
|
|
||||||
vTaskDelete(handle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks
|
||||||
|
// the other core to yield though, so the task may still be running on it for a moment.
|
||||||
|
vTaskSuspend(this->handle_);
|
||||||
|
if (eTaskGetState(this->handle_) != eSuspended) {
|
||||||
|
// The task is still running on the other core and using its stack. Deleting it now would only put it on
|
||||||
|
// the termination list and return, so the caller has to try again once it has been swapped out.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The task cannot run again, so the delete completes right away instead of being left to the idle task.
|
||||||
|
TaskHandle_t handle = this->handle_;
|
||||||
|
this->handle_ = nullptr;
|
||||||
|
vTaskDelete(handle);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void StaticTask::deallocate() {
|
bool StaticTask::deallocate() {
|
||||||
this->destroy();
|
if (!this->destroy()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (this->stack_buffer_ != nullptr) {
|
if (this->stack_buffer_ != nullptr) {
|
||||||
RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL
|
RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL
|
||||||
: RAMAllocator<StackType_t>::ALLOC_INTERNAL);
|
: RAMAllocator<StackType_t>::ALLOC_INTERNAL);
|
||||||
@@ -57,6 +72,7 @@ void StaticTask::deallocate() {
|
|||||||
this->stack_buffer_ = nullptr;
|
this->stack_buffer_ = nullptr;
|
||||||
this->stack_size_ = 0;
|
this->stack_size_ = 0;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace esphome
|
} // namespace esphome
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ namespace esphome {
|
|||||||
|
|
||||||
/** Helper for FreeRTOS static task management.
|
/** Helper for FreeRTOS static task management.
|
||||||
* Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods.
|
* Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods.
|
||||||
|
* Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on.
|
||||||
*/
|
*/
|
||||||
class StaticTask {
|
class StaticTask {
|
||||||
public:
|
public:
|
||||||
@@ -23,7 +24,7 @@ class StaticTask {
|
|||||||
/// @brief Allocate stack and create task.
|
/// @brief Allocate stack and create task.
|
||||||
/// @param fn Task function
|
/// @param fn Task function
|
||||||
/// @param name Task name (for debug)
|
/// @param name Task name (for debug)
|
||||||
/// @param stack_size Stack size in StackType_t words
|
/// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF)
|
||||||
/// @param param Parameter passed to task function
|
/// @param param Parameter passed to task function
|
||||||
/// @param priority FreeRTOS task priority
|
/// @param priority FreeRTOS task priority
|
||||||
/// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM
|
/// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM
|
||||||
@@ -31,11 +32,17 @@ class StaticTask {
|
|||||||
bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
|
bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
|
||||||
bool use_psram);
|
bool use_psram);
|
||||||
|
|
||||||
/// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call.
|
/// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call.
|
||||||
void destroy();
|
/// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is
|
||||||
|
/// suspended here so that it cannot be scheduled again, and it is given no chance to clean up.
|
||||||
|
/// @return true if the task was deleted; false if it is still running on another core, in which case the
|
||||||
|
/// caller should try again later.
|
||||||
|
bool destroy();
|
||||||
|
|
||||||
/// @brief Delete the task (if running) and free the stack buffer.
|
/// @brief Delete the task (if created) and free the stack buffer.
|
||||||
void deallocate();
|
/// @return true if the stack buffer was freed; false if the task is still running on another core, in
|
||||||
|
/// which case the caller should try again later.
|
||||||
|
bool deallocate();
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
TaskHandle_t handle_{nullptr};
|
TaskHandle_t handle_{nullptr};
|
||||||
|
|||||||
+6
-3
@@ -96,6 +96,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
|
|||||||
# across the addresses on top of that.
|
# across the addresses on top of that.
|
||||||
EXTRA_UPLOAD_ATTEMPTS = 2
|
EXTRA_UPLOAD_ATTEMPTS = 2
|
||||||
UPLOAD_RETRY_DELAY = 5.0
|
UPLOAD_RETRY_DELAY = 5.0
|
||||||
|
# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA
|
||||||
|
# (105 s) so a stalled session is gone before a retry, and long enough for lwIP
|
||||||
|
# to get a lost chunk ack through after the retransmit run seen in practice
|
||||||
|
DATA_PHASE_TIMEOUT = 160.0
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -694,8 +698,7 @@ def perform_ota(
|
|||||||
|
|
||||||
_LOGGER.info("Handshake complete")
|
_LOGGER.info("Handshake complete")
|
||||||
|
|
||||||
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
|
sock.settimeout(DATA_PHASE_TIMEOUT)
|
||||||
sock.settimeout(90.0)
|
|
||||||
|
|
||||||
if extended_proto:
|
if extended_proto:
|
||||||
send_check(sock, ota_type, "ota type")
|
send_check(sock, ota_type, "ota type")
|
||||||
@@ -854,7 +857,7 @@ def run_ota_impl_(
|
|||||||
# clean up a half-open connection (its handshake watchdog runs at 20s);
|
# clean up a half-open connection (its handshake watchdog runs at 20s);
|
||||||
# moving on to the next address family stays immediate. Known limitation:
|
# moving on to the next address family stays immediate. Known limitation:
|
||||||
# a silent mid-transfer drop with no reset can wedge the device until its
|
# a silent mid-transfer drop with no reset can wedge the device until its
|
||||||
# 90s data timeout, which outlasts this budget; the retries target the
|
# 105s data timeout, which outlasts this budget; the retries target the
|
||||||
# common failures where the device resets or closes the link promptly.
|
# common failures where the device resets or closes the link promptly.
|
||||||
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
|
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
|
||||||
last_error = ""
|
last_error = ""
|
||||||
|
|||||||
+3
-3
@@ -45,7 +45,7 @@ lib_deps_base =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${common.lib_deps_base}
|
${common.lib_deps_base}
|
||||||
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
|
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
|
||||||
esphome/noise-c@0.1.24 ; noise (api, ota)
|
esphome/noise-c@0.1.26 ; noise (api, ota)
|
||||||
improv/Improv@1.2.7 ; improv_serial / esp32_improv
|
improv/Improv@1.2.7 ; improv_serial / esp32_improv
|
||||||
kikuchan98/pngle@1.1.0 ; online_image
|
kikuchan98/pngle@1.1.0 ; online_image
|
||||||
; Using the repository directly, otherwise ESP-IDF can't use the library
|
; Using the repository directly, otherwise ESP-IDF can't use the library
|
||||||
@@ -244,7 +244,7 @@ lib_deps =
|
|||||||
${common:idf-component-libs.lib_deps}
|
${common:idf-component-libs.lib_deps}
|
||||||
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
|
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
|
||||||
droscy/esp_wireguard@0.4.5 ; wireguard
|
droscy/esp_wireguard@0.4.5 ; wireguard
|
||||||
esphome/noise-c@0.1.24 ; noise (api, ota)
|
esphome/noise-c@0.1.26 ; noise (api, ota)
|
||||||
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
|
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
|
||||||
DNSServer ; captive_portal
|
DNSServer ; captive_portal
|
||||||
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
|
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
|
||||||
@@ -641,7 +641,7 @@ build_unflags =
|
|||||||
extends = common
|
extends = common
|
||||||
platform = platformio/native
|
platform = platformio/native
|
||||||
lib_deps =
|
lib_deps =
|
||||||
esphome/noise-c@0.1.24 ; used by noise (api, ota)
|
esphome/noise-c@0.1.26 ; used by noise (api, ota)
|
||||||
lvgl/lvgl@9.5.0 ; lvgl
|
lvgl/lvgl@9.5.0 ; lvgl
|
||||||
build_flags =
|
build_flags =
|
||||||
${common.build_flags}
|
${common.build_flags}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time
|
|||||||
tzdata>=2026.3 # from time
|
tzdata>=2026.3 # from time
|
||||||
pyserial==3.5
|
pyserial==3.5
|
||||||
platformio==6.1.19
|
platformio==6.1.19
|
||||||
esptool==5.3.1
|
esptool==5.4.0
|
||||||
click==8.3.3
|
click==8.3.3
|
||||||
aioesphomeapi==46.3.0
|
aioesphomeapi==46.3.0
|
||||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
"""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"'
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
esphome:
|
|
||||||
name: test
|
|
||||||
|
|
||||||
esp32:
|
|
||||||
board: esp32dev
|
|
||||||
|
|
||||||
wifi:
|
|
||||||
ssid: SomeNetwork
|
|
||||||
password: SomePassword
|
|
||||||
|
|
||||||
logger:
|
|
||||||
|
|
||||||
api:
|
|
||||||
encryption:
|
|
||||||
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
|
|
||||||
outgoing_connection:
|
|
||||||
host: 192.168.1.2
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Validation tests for the sendspin media_source platform.
|
||||||
|
|
||||||
|
These cover the codec preference list, whose rejection branches a compile test
|
||||||
|
cannot reach: a `test*.yaml` can only assert that a configuration is accepted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from esphome import config_validation as cv
|
||||||
|
from esphome.components.sendspin import CONF_CODECS, _get_data
|
||||||
|
from esphome.components.sendspin.media_source import CONFIG_SCHEMA
|
||||||
|
from esphome.const import PlatformFramework
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
from tests.component_tests.types import SetCoreConfigCallable
|
||||||
|
|
||||||
|
|
||||||
|
def _media_source_config(**overrides: Any) -> ConfigType:
|
||||||
|
"""Build a minimal valid media source config, allowing field overrides."""
|
||||||
|
config: ConfigType = {
|
||||||
|
"id": "sendspin_media_source",
|
||||||
|
"sendspin_id": "sendspin_hub",
|
||||||
|
}
|
||||||
|
config.update(overrides)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_codecs_at_48_khz(set_core_config: SetCoreConfigCallable) -> None:
|
||||||
|
"""Every codec is advertised when the sample rate suits all of them."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
config = CONFIG_SCHEMA(_media_source_config())
|
||||||
|
|
||||||
|
assert config[CONF_CODECS] == ["flac", "opus", "pcm"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_codecs_drop_opus_at_other_rates(
|
||||||
|
set_core_config: SetCoreConfigCallable,
|
||||||
|
) -> None:
|
||||||
|
"""Opus only supports 48 kHz, so it leaves the default list at other rates."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
config = CONFIG_SCHEMA(_media_source_config(sample_rate=44100))
|
||||||
|
|
||||||
|
assert config[CONF_CODECS] == ["flac", "pcm"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured_order_is_preserved(set_core_config: SetCoreConfigCallable) -> None:
|
||||||
|
"""The list is a preference order, so it reaches the player role as written."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA(_media_source_config(codecs=["pcm", "flac"]))
|
||||||
|
|
||||||
|
assert _get_data().player_config[CONF_CODECS] == ["pcm", "flac"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_codec_list_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||||
|
"""A player with no codecs at all could never be given a stream."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
with pytest.raises(cv.Invalid, match="length of value must be at least 1"):
|
||||||
|
CONFIG_SCHEMA(_media_source_config(codecs=[]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_codec_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||||
|
"""A repeated codec has no meaning in a preference order."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
with pytest.raises(cv.Invalid, match="may only be listed once"):
|
||||||
|
CONFIG_SCHEMA(_media_source_config(codecs=["flac", "flac"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_codec_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||||
|
"""Only codecs the player role can decode are accepted."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
with pytest.raises(cv.Invalid, match="Unknown value"):
|
||||||
|
CONFIG_SCHEMA(_media_source_config(codecs=["mp3"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_opus_at_wrong_sample_rate_rejected(
|
||||||
|
set_core_config: SetCoreConfigCallable,
|
||||||
|
) -> None:
|
||||||
|
"""Asking for Opus at a rate it cannot handle fails rather than silently
|
||||||
|
dropping the stated preference."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"):
|
||||||
|
CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100))
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -9,3 +9,4 @@ media_source:
|
|||||||
static_delay_adjustable: true
|
static_delay_adjustable: true
|
||||||
fixed_delay: 480us
|
fixed_delay: 480us
|
||||||
decode_memory: internal
|
decode_memory: internal
|
||||||
|
codecs: [pcm, opus, flac]
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
esphome:
|
|
||||||
name: outgoing-conn-test
|
|
||||||
|
|
||||||
host:
|
|
||||||
|
|
||||||
logger:
|
|
||||||
|
|
||||||
api:
|
|
||||||
encryption:
|
|
||||||
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
|
|
||||||
outgoing_connection:
|
|
||||||
host: 127.0.0.1
|
|
||||||
port: OUTGOING_PORT
|
|
||||||
delay: 1s
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
esphome:
|
|
||||||
name: outgoing-conn-test
|
|
||||||
|
|
||||||
host:
|
|
||||||
|
|
||||||
logger:
|
|
||||||
|
|
||||||
api:
|
|
||||||
encryption:
|
|
||||||
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
|
|
||||||
outgoing_connection:
|
|
||||||
port: OUTGOING_PORT
|
|
||||||
delay: 1s
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
esphome:
|
|
||||||
name: api-reboot-test
|
|
||||||
host:
|
|
||||||
api:
|
|
||||||
reboot_timeout: 2s # Headroom to connect and authenticate a client first
|
|
||||||
logger:
|
|
||||||
level: DEBUG
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
"""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"
|
|
||||||
|
|
||||||
|
|
||||||
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
|
import pytest
|
||||||
|
|
||||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
from .types import RunCompiledFunction
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -16,9 +16,7 @@ async def test_api_reboot_timeout(
|
|||||||
"""Test that the device reboots when no API clients connect within the timeout."""
|
"""Test that the device reboots when no API clients connect within the timeout."""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
reboot_future = loop.create_future()
|
reboot_future = loop.create_future()
|
||||||
# The harness port probe always connects without authenticating, so the
|
reboot_pattern = re.compile(r"No clients; rebooting")
|
||||||
# reboot deterministically reports the unauthenticated form
|
|
||||||
reboot_pattern = re.compile(r"none authenticated; rebooting")
|
|
||||||
|
|
||||||
def check_output(line: str) -> None:
|
def check_output(line: str) -> None:
|
||||||
"""Check output for reboot message."""
|
"""Check output for reboot message."""
|
||||||
@@ -35,30 +33,3 @@ async def test_api_reboot_timeout(
|
|||||||
pytest.fail("Device did not reboot within expected timeout")
|
pytest.fail("Device did not reboot within expected timeout")
|
||||||
|
|
||||||
# Test passes if we get here - reboot was detected
|
# 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")
|
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ def _load_script():
|
|||||||
def test_spec_key_collapses_destinations() -> None:
|
def test_spec_key_collapses_destinations() -> None:
|
||||||
"""Two specs delivering one package share a directory and one key."""
|
"""Two specs delivering one package share a directory and one key."""
|
||||||
mod = _load_script()
|
mod = _load_script()
|
||||||
assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c"
|
assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c"
|
||||||
assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c"
|
assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c"
|
||||||
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
|
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
|
||||||
"esp32async/asynctcp @ 3.5.0"
|
"esp32async/asynctcp @ 3.5.0"
|
||||||
)
|
)
|
||||||
@@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
|
|||||||
"[env:a]\n"
|
"[env:a]\n"
|
||||||
"platform = fake/platform@1\n"
|
"platform = fake/platform@1\n"
|
||||||
"lib_deps =\n"
|
"lib_deps =\n"
|
||||||
" esphome/noise-c @ 0.1.24\n"
|
" esphome/noise-c @ 0.1.26\n"
|
||||||
" ${common.lib_deps}\n"
|
" ${common.lib_deps}\n"
|
||||||
" internal_lib\n"
|
" internal_lib\n"
|
||||||
"[env:b]\n"
|
"[env:b]\n"
|
||||||
"lib_deps =\n"
|
"lib_deps =\n"
|
||||||
" esphome/noise-c @ 0.1.24\n"
|
" esphome/noise-c @ 0.1.26\n"
|
||||||
)
|
)
|
||||||
mod = _load_script()
|
mod = _load_script()
|
||||||
args = Namespace(libraries=True, platforms=True, tools=False)
|
args = Namespace(libraries=True, platforms=True, tools=False)
|
||||||
libs, platforms, tools = mod.parse_specs(str(ini), args)
|
libs, platforms, tools = mod.parse_specs(str(ini), args)
|
||||||
# exact-string duplicates collapse; distinct version pins survive
|
# exact-string duplicates collapse; distinct version pins survive
|
||||||
assert libs == ["esphome/noise-c @ 0.1.24"]
|
assert libs == ["esphome/noise-c @ 0.1.26"]
|
||||||
assert platforms == ["fake/platform@1"]
|
assert platforms == ["fake/platform@1"]
|
||||||
assert tools == []
|
assert tools == []
|
||||||
assert mod.build_cli_args(libs, platforms, tools) == [
|
assert mod.build_cli_args(libs, platforms, tools) == [
|
||||||
"-l",
|
"-l",
|
||||||
"esphome/noise-c @ 0.1.24",
|
"esphome/noise-c @ 0.1.26",
|
||||||
"-p",
|
"-p",
|
||||||
"fake/platform@1",
|
"fake/platform@1",
|
||||||
]
|
]
|
||||||
@@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None:
|
|||||||
mod.parallel_install(
|
mod.parallel_install(
|
||||||
cls,
|
cls,
|
||||||
[
|
[
|
||||||
"esphome/noise-c @ 0.1.24",
|
"esphome/noise-c @ 0.1.26",
|
||||||
"esphome/noise-c @ 0.1.24",
|
"esphome/noise-c @ 0.1.26",
|
||||||
"esphome/already @ 1.0",
|
"esphome/already @ 1.0",
|
||||||
"https://x/framework.tar.xz",
|
"https://x/framework.tar.xz",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
assert cls.calls == ["esphome/noise-c @ 0.1.24"]
|
assert cls.calls == ["esphome/noise-c @ 0.1.26"]
|
||||||
assert cls.lock_events == ["lock", "unlock"]
|
assert cls.lock_events == ["lock", "unlock"]
|
||||||
|
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
|
|||||||
mod = _load_script()
|
mod = _load_script()
|
||||||
cls = _reset_fake(str(tmp_path))
|
cls = _reset_fake(str(tmp_path))
|
||||||
cls.deps = {
|
cls.deps = {
|
||||||
"esphome/noise-c @ 0.1.24": [
|
"esphome/noise-c @ 0.1.26": [
|
||||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||||
{"name": "SPI"},
|
{"name": "SPI"},
|
||||||
],
|
],
|
||||||
@@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
|
|||||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"])
|
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"])
|
||||||
assert len(cls.calls) == 3 # the shared dep installs exactly once
|
assert len(cls.calls) == 3 # the shared dep installs exactly once
|
||||||
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
|
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
|
||||||
# Wave-1 strings carry no compatibility; the dependency wave does
|
# Wave-1 strings carry no compatibility; the dependency wave does
|
||||||
compats = dict(cls.compat_calls)
|
compats = dict(cls.compat_calls)
|
||||||
assert compats["esphome/noise-c @ 0.1.24"] is None
|
assert compats["esphome/noise-c @ 0.1.26"] is None
|
||||||
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
|
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
|
||||||
assert dep_compat is not None # mirrors pio's install_dependency
|
assert dep_compat is not None # mirrors pio's install_dependency
|
||||||
|
|
||||||
@@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None:
|
|||||||
mod = _load_script()
|
mod = _load_script()
|
||||||
cls = _reset_fake(str(tmp_path))
|
cls = _reset_fake(str(tmp_path))
|
||||||
cls.deps = {
|
cls.deps = {
|
||||||
"esphome/noise-c @ 0.1.24": [
|
"esphome/noise-c @ 0.1.26": [
|
||||||
{"name": "vendored", "version": "https://github.com/x/y.git"},
|
{"name": "vendored", "version": "https://github.com/x/y.git"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
|
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"])
|
||||||
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
|
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
|
||||||
|
|
||||||
|
|
||||||
@@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None:
|
|||||||
"""Already-installed top-level packages still feed the dependency
|
"""Already-installed top-level packages still feed the dependency
|
||||||
wave; a warm store can be missing a transitive dep."""
|
wave; a warm store can be missing a transitive dep."""
|
||||||
mod = _load_script()
|
mod = _load_script()
|
||||||
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"})
|
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"})
|
||||||
cls.deps = {
|
cls.deps = {
|
||||||
"esphome/noise-c @ 0.1.24": [
|
"esphome/noise-c @ 0.1.26": [
|
||||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
|
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"])
|
||||||
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
|
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -416,6 +416,9 @@ def test_perform_ota_no_auth(
|
|||||||
"Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)"
|
"Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)"
|
||||||
in caplog.text
|
in caplog.text
|
||||||
)
|
)
|
||||||
|
# The data phase timeout must outlast the device's 105 s data timeout
|
||||||
|
mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT)
|
||||||
|
assert espota2.DATA_PHASE_TIMEOUT > 105.0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("mock_time")
|
@pytest.mark.usefixtures("mock_time")
|
||||||
|
|||||||
@@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None:
|
|||||||
{"name": "SPI"},
|
{"name": "SPI"},
|
||||||
]
|
]
|
||||||
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
|
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
|
||||||
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
|
pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))])
|
||||||
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
|
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
|
||||||
# The dep wave carries its compatibility so _install searches qualified
|
# The dep wave carries its compatibility so _install searches qualified
|
||||||
dep_call = m._install.call_args_list[-1]
|
dep_call = m._install.call_args_list[-1]
|
||||||
@@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
|
|||||||
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
|
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
|
||||||
installed.append(getattr(spec, "name", str(spec)))
|
installed.append(getattr(spec, "name", str(spec)))
|
||||||
)
|
)
|
||||||
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
|
pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))])
|
||||||
assert installed == ["noise-c"]
|
assert installed == ["noise-c"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user