mirror of
https://github.com/esphome/esphome.git
synced 2026-09-10 23:07:34 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99beeb0c7c | ||
|
|
280fac11e6 | ||
|
|
9924148302 | ||
|
|
b984f63c49 | ||
|
|
78f055299c | ||
|
|
7f9812cef6 | ||
|
|
a684e7e67d | ||
|
|
afc93a4dc8 | ||
|
|
0a74cb4e89 | ||
|
|
9c4a08aaad | ||
|
|
68d5968405 | ||
|
|
30da0e81fa | ||
|
|
900638463a | ||
|
|
1665d509d4 | ||
|
|
29d8f9c7c6 | ||
|
|
18b5277254 | ||
|
|
b110cee973 | ||
|
|
1171fbf892 | ||
|
|
ae549b25bb | ||
|
|
35ab455c10 | ||
|
|
dae9a5d1a6 | ||
|
|
bf380037a6 | ||
|
|
493c7265ab | ||
|
|
238b6bb0b7 | ||
|
|
0211bdf2c0 | ||
|
|
a95bfe0bcb | ||
|
|
3a0bbc4c17 | ||
|
|
cc9dc95cab | ||
|
|
79325fe59a | ||
|
|
5fbe09b68c | ||
|
|
f41ff5aaeb | ||
|
|
7b57fdd35c | ||
|
|
fc671d38e5 | ||
|
|
4e0cda0287 | ||
|
|
676eac7686 |
@@ -5,7 +5,7 @@ from typing import Any
|
||||
from esphome import automation
|
||||
from esphome.automation import Condition
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_DESCRIPTION
|
||||
from esphome.components.const import CONF_DESCRIPTION, CONF_HOST
|
||||
from esphome.components.logger import request_log_listener
|
||||
|
||||
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
|
||||
@@ -25,6 +25,8 @@ from esphome.const import (
|
||||
CONF_CAPTURE_RESPONSE,
|
||||
CONF_DATA,
|
||||
CONF_DATA_TEMPLATE,
|
||||
CONF_DELAY,
|
||||
CONF_ENABLE_IPV6,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_EVENT,
|
||||
CONF_ID,
|
||||
@@ -48,6 +50,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import fnv1_hash
|
||||
from esphome.types import ConfigFragmentType, ConfigType
|
||||
|
||||
@@ -134,6 +137,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
||||
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
|
||||
CONF_LISTEN_BACKLOG = "listen_backlog"
|
||||
CONF_MAX_SEND_QUEUE = "max_send_queue"
|
||||
CONF_OUTGOING_CONNECTION = "outgoing_connection"
|
||||
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
||||
|
||||
|
||||
@@ -285,9 +289,44 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
|
||||
# (not max_connections, which is the upper limit rarely reached)
|
||||
socket.consume_sockets(3, "api")(config)
|
||||
socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config)
|
||||
if CONF_OUTGOING_CONNECTION in config:
|
||||
socket.consume_sockets(1, "api_outgoing_connection")(config)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_outgoing_connection(config: ConfigType) -> ConfigType:
|
||||
if CONF_OUTGOING_CONNECTION not in config:
|
||||
return config
|
||||
if CONF_ENCRYPTION not in config:
|
||||
raise cv.Invalid(
|
||||
"outgoing_connection requires 'encryption' so the peer is verified by key",
|
||||
path=[CONF_OUTGOING_CONNECTION],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
_OUTGOING_CONNECTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_HOST): cv.ipaddress,
|
||||
cv.Optional(CONF_PORT, default=6054): cv.port,
|
||||
# Bounded to half the device's uint32 millisecond range so the wait
|
||||
# always elapses under a wrapping clock
|
||||
cv.Optional(CONF_DELAY, default="60s"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(milliseconds=2147483647)),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType:
|
||||
# A bare `outgoing_connection:` block is valid; without a host the device
|
||||
# dials the remembered last dial-back client
|
||||
if config is None:
|
||||
config = {}
|
||||
return _OUTGOING_CONNECTION_SCHEMA(config)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -312,6 +351,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
): ACTIONS_SCHEMA,
|
||||
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
|
||||
cv.Optional(CONF_ENCRYPTION): encryption_schema,
|
||||
cv.Optional(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema,
|
||||
cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
|
||||
@@ -368,6 +408,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
|
||||
_validate_outgoing_connection,
|
||||
_consume_api_sockets,
|
||||
_register_provisioning_source,
|
||||
)
|
||||
@@ -424,7 +465,52 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings
|
||||
def _validate_outgoing_socket_implementation(config: ConfigType) -> ConfigType:
|
||||
"""Reject the raw lwip_tcp socket, the only option on ESP8266 and RP2040.
|
||||
|
||||
Checked against the resolved implementation so an explicit selection on
|
||||
another platform is caught the same way as the platform default.
|
||||
"""
|
||||
if CONF_OUTGOING_CONNECTION not in config:
|
||||
return config
|
||||
from esphome.components import socket
|
||||
|
||||
socket_conf = fv.full_config.get().get("socket") or {}
|
||||
if (
|
||||
impl := socket_conf.get(socket.CONF_IMPLEMENTATION)
|
||||
) in socket.IMPLEMENTATIONS_WITHOUT_CONNECT:
|
||||
raise cv.Invalid(
|
||||
f"outgoing_connection is not supported with the {impl} socket "
|
||||
"implementation (the only one on ESP8266 and RP2040) because it "
|
||||
"cannot make outgoing connections",
|
||||
path=[CONF_OUTGOING_CONNECTION],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
|
||||
"""An IPv6 host can never be parsed, so never dialed, without IPv6."""
|
||||
if (
|
||||
(outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None
|
||||
or (host := outgoing.get(CONF_HOST)) is None
|
||||
or host.version != 6
|
||||
):
|
||||
return config
|
||||
network_conf = fv.full_config.get().get("network") or {}
|
||||
if not network_conf.get(CONF_ENABLE_IPV6):
|
||||
raise cv.Invalid(
|
||||
"outgoing_connection host is an IPv6 address but IPv6 is not "
|
||||
"enabled; set 'network: enable_ipv6: true'",
|
||||
path=[CONF_OUTGOING_CONNECTION, CONF_HOST],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
_validate_esp8266_action_strings,
|
||||
_validate_outgoing_socket_implementation,
|
||||
_validate_outgoing_host_ipv6,
|
||||
)
|
||||
|
||||
|
||||
def _add_action_strings(
|
||||
@@ -606,6 +692,13 @@ async def to_code(config: ConfigType) -> None:
|
||||
else:
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
|
||||
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is not None:
|
||||
cg.add_define("USE_API_OUTGOING_CONNECTION")
|
||||
if (host := outgoing.get(CONF_HOST)) is not None:
|
||||
cg.add_define("API_OUTGOING_CONNECTION_HOST", str(host))
|
||||
cg.add_define("API_OUTGOING_CONNECTION_PORT", outgoing[CONF_PORT])
|
||||
cg.add_define("API_OUTGOING_CONNECTION_DELAY", outgoing[CONF_DELAY])
|
||||
|
||||
cg.add_define("USE_API")
|
||||
cg.add_global(api_ns.using)
|
||||
|
||||
@@ -992,6 +1085,7 @@ _define_filter = filter_source_files_from_defines(
|
||||
"user_services.cpp": "USE_API_USER_DEFINED_ACTIONS",
|
||||
"api_frame_helper_noise.cpp": "USE_API_NOISE",
|
||||
"api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT",
|
||||
"api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ service APIConnection {
|
||||
rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {}
|
||||
rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {}
|
||||
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
|
||||
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +113,11 @@ message HelloRequest {
|
||||
string client_info = 1;
|
||||
uint32 api_version_major = 2;
|
||||
uint32 api_version_minor = 3;
|
||||
|
||||
// Set by clients that can accept connections the device opens to them
|
||||
// (see api: outgoing_connection:). The device remembers this client's
|
||||
// address as the target to dial when no such client is connected.
|
||||
bool outgoing_connection_target = 4 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
|
||||
}
|
||||
|
||||
// Confirmation of successful connection request.
|
||||
@@ -331,6 +337,10 @@ message DeviceInfoResponse {
|
||||
// all-zeros PSK, so the api encryption key can be provisioned without being
|
||||
// sent in plaintext (protects against passive sniffing, not active MITM)
|
||||
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
|
||||
|
||||
// Device is built with the api outgoing_connection option and can open
|
||||
// the TCP connection to a dial-back target itself
|
||||
bool api_outgoing_connection_supported = 27 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
|
||||
}
|
||||
|
||||
// ==================== DEVICE CAPABILITIES ====================
|
||||
@@ -2726,7 +2736,8 @@ enum SerialProxyParity {
|
||||
SERIAL_PROXY_PARITY_ODD = 2;
|
||||
}
|
||||
|
||||
// Configure UART parameters for a serial proxy instance
|
||||
// Configure UART parameters for a serial proxy instance. Only the subscribed client may
|
||||
// configure the port; others are refused with PORT_IN_USE (since API 1.17).
|
||||
message SerialProxyConfigureRequest {
|
||||
option (id) = 138;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2752,7 +2763,8 @@ message SerialProxyDataReceived {
|
||||
bytes data = 2; // Raw data received from the serial device
|
||||
}
|
||||
|
||||
// Write data to a serial device
|
||||
// Write data to a serial device. Only the subscribed client may write; writes from
|
||||
// others are ignored (since API 1.17).
|
||||
message SerialProxyWriteRequest {
|
||||
option (id) = 140;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2763,7 +2775,8 @@ message SerialProxyWriteRequest {
|
||||
bytes data = 2; // Raw data to write to the serial device
|
||||
}
|
||||
|
||||
// Set modem control pin states (RTS and DTR)
|
||||
// Set modem control pin states (RTS and DTR). Only the subscribed client may set them;
|
||||
// others are refused with PORT_IN_USE (since API 1.17).
|
||||
message SerialProxySetModemPinsRequest {
|
||||
option (id) = 141;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2802,6 +2815,7 @@ enum SerialProxyRequestType {
|
||||
// error the device answers with INVALID_ARGUMENT.
|
||||
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5; // Acknowledges a SerialProxySetModeRequest (since API 1.17)
|
||||
}
|
||||
|
||||
enum SerialProxyStatus {
|
||||
@@ -2814,7 +2828,8 @@ enum SerialProxyStatus {
|
||||
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value
|
||||
}
|
||||
|
||||
// Generic request message for simple serial proxy operations
|
||||
// Generic request message for simple serial proxy operations. FLUSH requires an active
|
||||
// subscription; it is refused with PORT_IN_USE otherwise (since API 1.17).
|
||||
message SerialProxyRequest {
|
||||
option (id) = 144;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2838,6 +2853,29 @@ message SerialProxyRequestResponse {
|
||||
string error_message = 4; // Additional detail on failure (optional)
|
||||
}
|
||||
|
||||
// How a port treats the bytes passing through it. RAW is a plain byte pipe; PROTOCOL
|
||||
// activates the port's protocol-aware tap (if one is configured), letting it observe
|
||||
// traffic and inject protocol bytes such as acknowledgements. Which protocol the tap
|
||||
// speaks is a property of the device configuration, discoverable from the tap
|
||||
// component's own API surface. A client that is about to flash firmware selects RAW
|
||||
// first, which definitively disables that injection.
|
||||
enum SerialProxyMode {
|
||||
SERIAL_PROXY_MODE_RAW = 0;
|
||||
SERIAL_PROXY_MODE_PROTOCOL = 1;
|
||||
}
|
||||
|
||||
// Only the subscribed client may change the mode; any other caller -- including one that
|
||||
// never subscribed -- is refused with PORT_IN_USE. PROTOCOL is refused with NOT_SUPPORTED
|
||||
// when the port has no protocol-aware tap configured.
|
||||
message SerialProxySetModeRequest {
|
||||
option (id) = 152;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_SERIAL_PROXY";
|
||||
|
||||
uint32 instance = 1;
|
||||
SerialProxyMode mode = 2;
|
||||
}
|
||||
|
||||
// ==================== BLUETOOTH CONNECTION PARAMS ====================
|
||||
message BluetoothSetConnectionParamsRequest {
|
||||
option (id) = 145;
|
||||
|
||||
@@ -1661,6 +1661,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
break;
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE:
|
||||
// Response-only discriminators; never valid in a request
|
||||
ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
|
||||
status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
|
||||
@@ -1673,6 +1674,19 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
send_serial_proxy_ack(this, msg.instance, msg.type, status);
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
if (msg.instance >= proxies.size()) {
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE,
|
||||
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
|
||||
return;
|
||||
}
|
||||
serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode_from_client(this, msg.mode);
|
||||
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE,
|
||||
serial_proxy_result_to_status(result));
|
||||
}
|
||||
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
|
||||
@@ -1799,7 +1813,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
|
||||
HelloResponse resp;
|
||||
resp.api_version_major = 1;
|
||||
resp.api_version_minor = 16;
|
||||
resp.api_version_minor = 17;
|
||||
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
|
||||
resp.server_info = ESPHOME_VERSION_REF;
|
||||
resp.name = StringRef(App.get_name());
|
||||
@@ -1822,6 +1836,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
|
||||
this->complete_authentication_();
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// With a PSK set only key-verified transports reach hello: plaintext and
|
||||
// zero-PSK are rejected, and pre-activation sessions are force-closed
|
||||
if (msg.outgoing_connection_target && !this->flags_.outgoing_connection_target) {
|
||||
if (this->parent_->get_noise_ctx().has_psk()) {
|
||||
this->flags_.outgoing_connection_target = true;
|
||||
this->parent_->on_outgoing_target_client(this);
|
||||
} else {
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Dial-back target refused; no key active"));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
@@ -1944,6 +1971,9 @@ bool APIConnection::send_device_info_response_() {
|
||||
// one) so this advertisement survives the plaintext removal in 2027.2.0.
|
||||
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
resp.api_outgoing_connection_supported = true;
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_DEVICES
|
||||
size_t device_index = 0;
|
||||
|
||||
@@ -244,6 +244,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
|
||||
void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
|
||||
void on_serial_proxy_request(const SerialProxyRequest &msg);
|
||||
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg);
|
||||
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
|
||||
#endif
|
||||
|
||||
@@ -375,6 +376,21 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
return this->helper_->get_peername_to(buf);
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
/// Outgoing connection: send our server hello immediately so the peer can
|
||||
/// pick the matching key. Outgoing connections are only dialed when a PSK
|
||||
/// is set, so the helper is always the noise helper. Call after start().
|
||||
void mark_outgoing() {
|
||||
if (this->flags_.remove) {
|
||||
return; // start() failed; the connection is already being torn down
|
||||
}
|
||||
APIError err = static_cast<APINoiseFrameHelper *>(this->helper_.get())->send_server_hello_first();
|
||||
if (err != APIError::OK) {
|
||||
this->fatal_error_with_log_(LOG_STR("Server hello failed"), err);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool try_to_clear_buffer_slow_(bool log_out_of_space);
|
||||
|
||||
@@ -745,6 +761,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
uint8_t batch_first_message : 1; // For batch buffer allocation
|
||||
uint8_t should_try_send_immediately : 1; // True after initial states are sent
|
||||
uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
uint8_t outgoing_connection_target : 1; // Client declared itself a dial-back target in its hello
|
||||
#endif
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
uint8_t log_only_mode : 1;
|
||||
#endif
|
||||
|
||||
@@ -282,7 +282,8 @@ class APIFrameHelper {
|
||||
DATA = 5,
|
||||
CLOSED = 6,
|
||||
FAILED = 7,
|
||||
EXPLICIT_REJECT = 8, // Noise only
|
||||
EXPLICIT_REJECT = 8, // Noise only
|
||||
CLIENT_HELLO_OUTGOING = 9, // Noise only: like CLIENT_HELLO but the server hello already went out (outgoing conn)
|
||||
};
|
||||
|
||||
// Fast inline state check for read_packet/write_protobuf_messages hot path.
|
||||
|
||||
@@ -81,6 +81,13 @@ APIError APINoiseFrameHelper::init() {
|
||||
state_ = State::CLIENT_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
APIError APINoiseFrameHelper::send_server_hello_first() {
|
||||
// The peer needs our name and MAC to pick the key before its first message
|
||||
this->state_ = State::CLIENT_HELLO_OUTGOING;
|
||||
return this->send_server_hello_frame_();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
|
||||
APIError err = this->init();
|
||||
@@ -253,6 +260,9 @@ APIError APINoiseFrameHelper::state_action_() {
|
||||
HELPER_LOG("Bad state for method: %d", (int) this->state_);
|
||||
return APIError::BAD_STATE;
|
||||
case State::CLIENT_HELLO:
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
case State::CLIENT_HELLO_OUTGOING:
|
||||
#endif
|
||||
return this->state_action_client_hello_();
|
||||
case State::SERVER_HELLO:
|
||||
return this->state_action_server_hello_();
|
||||
@@ -285,11 +295,16 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
||||
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size);
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (this->state_ == State::CLIENT_HELLO_OUTGOING) {
|
||||
// Server hello already went out at handoff
|
||||
return this->start_handshake_();
|
||||
}
|
||||
#endif
|
||||
state_ = State::SERVER_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// send server hello
|
||||
APIError APINoiseFrameHelper::send_server_hello_frame_() {
|
||||
const auto &name = App.get_name();
|
||||
char mac[MAC_ADDRESS_BUFFER_SIZE];
|
||||
get_mac_address_into_buffer(mac);
|
||||
@@ -313,15 +328,18 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// node mac, terminated by null byte
|
||||
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
|
||||
|
||||
APIError aerr = write_frame_(msg, total_size);
|
||||
return write_frame_(msg, total_size);
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
APIError aerr = this->send_server_hello_frame_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
// start handshake
|
||||
aerr = init_handshake_();
|
||||
return this->start_handshake_();
|
||||
}
|
||||
APIError APINoiseFrameHelper::start_handshake_() {
|
||||
APIError aerr = init_handshake_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
state_ = State::HANDSHAKE;
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
// Seeds the already-read header bytes and pumps the handshake state machine
|
||||
// until it would block.
|
||||
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Send the server hello immediately so the peer can pick the key before
|
||||
// its PSK-mixed message. Call after init(); the mode is tracked in state_
|
||||
// so the helper does not grow.
|
||||
APIError send_server_hello_first();
|
||||
#endif
|
||||
APIError loop() override;
|
||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||
@@ -39,6 +45,8 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
APIError state_action_();
|
||||
APIError state_action_client_hello_();
|
||||
APIError state_action_server_hello_();
|
||||
APIError send_server_hello_frame_();
|
||||
APIError start_handshake_();
|
||||
APIError state_action_handshake_();
|
||||
APIError state_action_handshake_read_();
|
||||
APIError state_action_handshake_write_();
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
#include "api_outgoing_connection.h"
|
||||
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
|
||||
|
||||
#include "api_connection.h"
|
||||
#include "api_server.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
static const char *const TAG = "api.outgoing";
|
||||
|
||||
void OutgoingConnectionManager::setup() {
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
this->target_pref_ = global_preferences->make_preference<SavedOutgoingTarget>(629847102UL, true);
|
||||
if (this->target_pref_.load(&this->saved_)) {
|
||||
// Defend against a corrupt or truncated blob before the first read
|
||||
this->saved_.host[sizeof(this->saved_.host) - 1] = '\0';
|
||||
this->host_persisted_ = true;
|
||||
ESP_LOGD(TAG, "Loaded target %s", this->saved_.host);
|
||||
} else {
|
||||
// Never saved, or the blob failed its size/CRC check
|
||||
ESP_LOGD(TAG, "No saved target");
|
||||
this->saved_ = {};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::loop(APIServer *server) {
|
||||
if (server->has_outgoing_target_client_()) {
|
||||
return; // on_target_client() already reset the dial state
|
||||
}
|
||||
if (this->dialed_conn_ != nullptr) {
|
||||
// A live dialed session (flagged or not, e.g. a host: peer) is the
|
||||
// target; a silent one dies on the handshake timeout
|
||||
return;
|
||||
}
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
switch (this->state_) {
|
||||
case DialState::DIAL_STATE_IDLE:
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
// A deep sleep wake window is too short to spend on the delay
|
||||
this->schedule_wait_(now, BACKOFF_MIN_MS);
|
||||
#else
|
||||
// Target went away; give it the configured delay to reconnect first
|
||||
this->schedule_wait_(now, API_OUTGOING_CONNECTION_DELAY);
|
||||
#endif
|
||||
break;
|
||||
case DialState::DIAL_STATE_WAITING:
|
||||
if (now - this->state_ts_ >= this->wait_) {
|
||||
this->try_dial_(server, now);
|
||||
}
|
||||
break;
|
||||
case DialState::DIAL_STATE_CONNECTING:
|
||||
this->poll_connect_(server, now);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) {
|
||||
if (!network::is_connected()) {
|
||||
// Flips within seconds of boot; recheck fast so a deep sleep wake
|
||||
// window is not spent waiting
|
||||
this->schedule_wait_(now, NETWORK_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
const char *host = this->target_host_();
|
||||
if (host == nullptr) {
|
||||
// The steady state until a dial-back client has ever connected
|
||||
ESP_LOGV(TAG, "Not dialing: no target");
|
||||
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
const bool at_limit = server->at_client_limit_();
|
||||
if (at_limit || !server->noise_ctx_.has_psk()) {
|
||||
ESP_LOGD(TAG, "Not dialing: %s", at_limit ? "max connections" : "no key");
|
||||
// Not a dial failure; retry without escalating the backoff
|
||||
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
struct sockaddr_storage addr;
|
||||
socklen_t addr_len =
|
||||
socket::set_sockaddr((struct sockaddr *) &addr, sizeof(addr), host, API_OUTGOING_CONNECTION_PORT);
|
||||
if (addr_len == 0) {
|
||||
ESP_LOGW(TAG, "Invalid target %s", host);
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
// A corrupt remembered value can never become dialable; forget it
|
||||
// (covers an IPv6 literal left by an earlier enable_ipv6 build too)
|
||||
this->saved_ = {};
|
||||
if (!this->persist_target_()) {
|
||||
ESP_LOGW(TAG, "Failed to clear target");
|
||||
}
|
||||
#endif
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
this->dial_socket_ = socket::socket_loop_monitored(((struct sockaddr *) &addr)->sa_family, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (!this->dial_socket_ || this->dial_socket_->setblocking(false) != 0) {
|
||||
ESP_LOGW(TAG, "Socket %s failed: errno %d", this->dial_socket_ ? "setblocking" : "create", errno);
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Dialing %s:%u", host, API_OUTGOING_CONNECTION_PORT);
|
||||
int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len);
|
||||
if (err == 0) {
|
||||
// Immediate success (possible for localhost)
|
||||
this->handoff_(server, now);
|
||||
return;
|
||||
}
|
||||
if (errno != EINPROGRESS) {
|
||||
ESP_LOGW(TAG, "Connect failed: errno %d", errno);
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
this->state_ = DialState::DIAL_STATE_CONNECTING;
|
||||
this->state_ts_ = now;
|
||||
this->last_poll_ = now;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
|
||||
if (now - this->state_ts_ >= CONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "Connect timeout");
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
this->last_poll_ = now;
|
||||
int err = 0;
|
||||
switch (socket::poll_connect(*this->dial_socket_, err)) {
|
||||
case socket::ConnectPollResult::CONNECT_POLL_PENDING:
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_CONNECTED:
|
||||
this->handoff_(server, now);
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_ERROR:
|
||||
ESP_LOGW(TAG, "Connect failed: %d", err);
|
||||
this->schedule_retry_(now);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::handoff_(APIServer *server, uint32_t now) {
|
||||
this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_));
|
||||
if (this->dialed_conn_ == nullptr) {
|
||||
// Only preconditions (slot limit, key cleared) refuse the handoff; the
|
||||
// peer is reachable, so do not escalate the backoff
|
||||
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
// Connected; dialed_conn_ gates further dialing until the session settles
|
||||
this->state_ = DialState::DIAL_STATE_IDLE;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::schedule_wait_(uint32_t now, uint32_t wait) {
|
||||
this->dial_socket_.reset(); // no-op when the socket was handed off
|
||||
this->state_ = DialState::DIAL_STATE_WAITING;
|
||||
this->state_ts_ = now;
|
||||
this->wait_ = wait;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::schedule_retry_(uint32_t now) {
|
||||
// +/-20% jitter so a fleet of devices does not retry one server in lockstep
|
||||
const uint32_t jitter_span = this->backoff_ / 5;
|
||||
this->schedule_wait_(now, this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1)));
|
||||
this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS);
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_authenticated) {
|
||||
if (conn != this->dialed_conn_) {
|
||||
return;
|
||||
}
|
||||
this->dialed_conn_ = nullptr;
|
||||
if (was_authenticated) {
|
||||
// A working peer (e.g. a host: target that never sends the flag)
|
||||
// disconnected normally; state is IDLE, so loop() applies the delay
|
||||
this->backoff_ = BACKOFF_MIN_MS;
|
||||
} else {
|
||||
this->schedule_retry_(App.get_loop_component_start_time());
|
||||
}
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::on_target_client(APIConnection *conn) {
|
||||
// The target is connected; stop any dial in flight and reset the backoff.
|
||||
// A dialed connection stays tracked unless it is this one: an inbound
|
||||
// target must not orphan a still-open dial.
|
||||
this->dial_socket_.reset();
|
||||
if (conn == this->dialed_conn_) {
|
||||
this->dialed_conn_ = nullptr;
|
||||
}
|
||||
this->state_ = DialState::DIAL_STATE_IDLE;
|
||||
this->backoff_ = BACKOFF_MIN_MS;
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
SavedOutgoingTarget target{};
|
||||
conn->get_peername_to(target.host);
|
||||
if (target.host[0] == '\0') {
|
||||
ESP_LOGW(TAG, "Could not read peer address; not remembering target");
|
||||
return;
|
||||
}
|
||||
if (this->host_persisted_ && strcmp(target.host, this->saved_.host) == 0) {
|
||||
return; // unchanged and already on flash; avoid flash wear
|
||||
}
|
||||
// Use the fresh address this boot even if the flash write fails; a failed
|
||||
// write is retried on the next flagged hello via host_persisted_
|
||||
this->saved_ = target;
|
||||
if (!this->persist_target_()) {
|
||||
ESP_LOGW(TAG, "Failed to save target");
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Saved %s as outgoing connection target", this->saved_.host);
|
||||
#endif
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::dump_config() const {
|
||||
const char *host = this->target_host_();
|
||||
if (host == nullptr) {
|
||||
host = "none remembered yet";
|
||||
}
|
||||
// The boot delay differs from delay: on deep sleep builds, so print the
|
||||
// value that actually applies
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Outgoing connection port: %u\n"
|
||||
" Outgoing connection host: %s\n"
|
||||
" Outgoing connection boot delay: %" PRIu32 "ms",
|
||||
API_OUTGOING_CONNECTION_PORT, host, BOOT_WAIT_MS);
|
||||
}
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif // USE_API && USE_API_OUTGOING_CONNECTION
|
||||
@@ -0,0 +1,117 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
|
||||
|
||||
#ifdef USE_SOCKET_IMPL_LWIP_TCP
|
||||
#error "api outgoing_connection needs a socket implementation that can make outgoing connections"
|
||||
#endif
|
||||
#ifndef USE_API_NOISE
|
||||
#error "api outgoing_connection needs noise encryption so the peer is verified by key"
|
||||
#endif
|
||||
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
class APIServer;
|
||||
class APIConnection;
|
||||
|
||||
// Follows the build's address family (ifdef'd in socket/headers.h): toggling
|
||||
// enable_ipv6 changes the blob size, load() rejects the old blob, and the
|
||||
// target is simply relearned
|
||||
static constexpr size_t SAVED_TARGET_HOST_LEN = socket::SOCKADDR_STR_LEN;
|
||||
|
||||
struct SavedOutgoingTarget {
|
||||
// IP as text so the socket component's v4-mapped-IPv6 normalization is
|
||||
// reused on both ends; empty = none remembered
|
||||
char host[SAVED_TARGET_HOST_LEN];
|
||||
} PACKED; // NOLINT
|
||||
|
||||
/// Dials out when no dial-back target client is connected. Only the TCP
|
||||
/// direction flips: the device stays the Noise responder, so both sides
|
||||
/// still verify by key. Targets the YAML host or the last remembered client.
|
||||
class OutgoingConnectionManager {
|
||||
public:
|
||||
void setup();
|
||||
void loop(APIServer *server);
|
||||
/// A key-verified client declared itself a dial-back target; last one wins
|
||||
void on_target_client(APIConnection *conn);
|
||||
/// Clears the dialed-connection gate; dying unauthenticated escalates the backoff
|
||||
void on_client_removed(APIConnection *conn, bool was_authenticated);
|
||||
void on_shutdown() { this->dial_socket_.reset(); }
|
||||
void dump_config() const;
|
||||
|
||||
protected:
|
||||
enum class DialState : uint8_t {
|
||||
DIAL_STATE_IDLE,
|
||||
DIAL_STATE_WAITING,
|
||||
DIAL_STATE_CONNECTING,
|
||||
};
|
||||
|
||||
static constexpr uint32_t BACKOFF_MIN_MS = 5000;
|
||||
static constexpr uint32_t BACKOFF_MAX_MS = 300000;
|
||||
static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000;
|
||||
static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250;
|
||||
static constexpr uint32_t NETWORK_RETRY_MS = 500;
|
||||
static constexpr uint32_t PRECONDITION_RETRY_MS = 5000;
|
||||
// Boot waits for the client to connect in first; a deep sleep wake window
|
||||
// is short, so connecting out immediately is the wake state
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
static constexpr uint32_t BOOT_WAIT_MS = 0;
|
||||
#else
|
||||
static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
|
||||
#endif
|
||||
|
||||
void try_dial_(APIServer *server, uint32_t now);
|
||||
void poll_connect_(APIServer *server, uint32_t now);
|
||||
// Hand the connected socket to the server and gate on the new connection
|
||||
void handoff_(APIServer *server, uint32_t now);
|
||||
// Close any half-open dial and wait a jittered backoff before retrying
|
||||
void schedule_retry_(uint32_t now);
|
||||
// Wait without escalating the backoff (used for unmet preconditions)
|
||||
void schedule_wait_(uint32_t now, uint32_t wait);
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
// Write saved_ to flash, tracking success in host_persisted_
|
||||
bool persist_target_() {
|
||||
this->host_persisted_ = this->target_pref_.save(&this->saved_) && global_preferences->sync();
|
||||
return this->host_persisted_;
|
||||
}
|
||||
#endif
|
||||
const char *target_host_() const {
|
||||
#ifdef API_OUTGOING_CONNECTION_HOST
|
||||
return API_OUTGOING_CONNECTION_HOST;
|
||||
#else
|
||||
return this->saved_.host[0] != '\0' ? this->saved_.host : nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Pointers first (4 bytes each on 32-bit)
|
||||
std::unique_ptr<socket::Socket> dial_socket_;
|
||||
// Compared only, never dereferenced
|
||||
APIConnection *dialed_conn_{nullptr};
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
ESPPreferenceObject target_pref_;
|
||||
#endif
|
||||
|
||||
// 4-byte types
|
||||
uint32_t backoff_{BACKOFF_MIN_MS};
|
||||
uint32_t wait_{BOOT_WAIT_MS};
|
||||
uint32_t state_ts_{0};
|
||||
uint32_t last_poll_{0};
|
||||
|
||||
// Byte-aligned types last
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
SavedOutgoingTarget saved_{};
|
||||
// False while saved_ holds a value the flash write failed for; retried on
|
||||
// the next flagged hello
|
||||
bool host_persisted_{false};
|
||||
#endif
|
||||
DialState state_{DialState::DIAL_STATE_WAITING};
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif // USE_API && USE_API_OUTGOING_CONNECTION
|
||||
@@ -15,6 +15,11 @@ bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value)
|
||||
case 3:
|
||||
this->api_version_minor = value;
|
||||
break;
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
case 4:
|
||||
this->outgoing_connection_target = value != 0;
|
||||
break;
|
||||
#endif
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -175,6 +180,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 27, this->api_outgoing_connection_supported);
|
||||
#endif
|
||||
return pos;
|
||||
}
|
||||
@@ -240,6 +248,9 @@ uint32_t DeviceInfoResponse::calculate_size() const {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
size += ProtoSize::calc_bool(2, this->api_outgoing_connection_supported);
|
||||
#endif
|
||||
return size;
|
||||
}
|
||||
@@ -4253,6 +4264,19 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
|
||||
size += ProtoSize::calc_length(1, this->error_message.size());
|
||||
return size;
|
||||
}
|
||||
bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
this->instance = value;
|
||||
break;
|
||||
case 2:
|
||||
this->mode = static_cast<enums::SerialProxyMode>(value);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
|
||||
@@ -356,6 +356,7 @@ enum SerialProxyRequestType : uint32_t {
|
||||
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2,
|
||||
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3,
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4,
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5,
|
||||
};
|
||||
enum SerialProxyStatus : uint32_t {
|
||||
SERIAL_PROXY_STATUS_OK = 0,
|
||||
@@ -366,6 +367,10 @@ enum SerialProxyStatus : uint32_t {
|
||||
SERIAL_PROXY_STATUS_PORT_IN_USE = 5,
|
||||
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6,
|
||||
};
|
||||
enum SerialProxyMode : uint32_t {
|
||||
SERIAL_PROXY_MODE_RAW = 0,
|
||||
SERIAL_PROXY_MODE_PROTOCOL = 1,
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace enums
|
||||
@@ -412,13 +417,16 @@ class CommandProtoMessage : public ProtoDecodableMessage {
|
||||
class HelloRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 1;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 17;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 19;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("hello_request"); }
|
||||
#endif
|
||||
StringRef client_info{};
|
||||
uint32_t api_version_major{0};
|
||||
uint32_t api_version_minor{0};
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
bool outgoing_connection_target{false};
|
||||
#endif
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
@@ -549,7 +557,7 @@ class SerialProxyInfo final : public ProtoMessage {
|
||||
class DeviceInfoResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 10;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 312;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 315;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
|
||||
#endif
|
||||
@@ -607,6 +615,9 @@ class DeviceInfoResponse final : public ProtoMessage {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
bool api_encryption_provisionable{false};
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
bool api_outgoing_connection_supported{false};
|
||||
#endif
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
@@ -3403,6 +3414,22 @@ class SerialProxyRequestResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
class SerialProxySetModeRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 152;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 6;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); }
|
||||
#endif
|
||||
uint32_t instance{0};
|
||||
enums::SerialProxyMode mode{};
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
|
||||
|
||||
@@ -854,6 +854,8 @@ template<> const char *proto_enum_to_string<enums::SerialProxyRequestType>(enums
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE");
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS");
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODE");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
@@ -878,6 +880,16 @@ template<> const char *proto_enum_to_string<enums::SerialProxyStatus>(enums::Ser
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::SerialProxyMode value) {
|
||||
switch (value) {
|
||||
case enums::SERIAL_PROXY_MODE_RAW:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW");
|
||||
case enums::SERIAL_PROXY_MODE_PROTOCOL:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_MODE_PROTOCOL");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
const char *HelloRequest::dump_to(DumpBuffer &out) const {
|
||||
@@ -885,6 +897,9 @@ const char *HelloRequest::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("client_info"), this->client_info);
|
||||
dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major);
|
||||
dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor);
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
dump_field(out, ESPHOME_PSTR("outgoing_connection_target"), this->outgoing_connection_target);
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
const char *HelloResponse::dump_to(DumpBuffer &out) const {
|
||||
@@ -1008,6 +1023,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
dump_field(out, ESPHOME_PSTR("api_outgoing_connection_supported"), this->api_outgoing_connection_supported);
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
@@ -2805,6 +2823,12 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("error_message"), this->error_message);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
|
||||
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
|
||||
|
||||
@@ -712,6 +712,17 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
this->on_device_capabilities_request();
|
||||
break;
|
||||
}
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
case SerialProxySetModeRequest::MESSAGE_TYPE: {
|
||||
SerialProxySetModeRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg);
|
||||
#endif
|
||||
this->on_serial_proxy_set_mode_request(msg);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -235,6 +235,9 @@ class APIServerConnectionBase {
|
||||
void on_serial_proxy_request(const SerialProxyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
|
||||
#endif
|
||||
|
||||
@@ -34,7 +34,46 @@ APIServer::APIServer() { global_api_server = this; }
|
||||
void APIServer::socket_failed_(const LogString *msg) {
|
||||
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
|
||||
this->destroy_socket_();
|
||||
this->mark_failed();
|
||||
}
|
||||
|
||||
bool APIServer::create_listen_socket_() {
|
||||
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||
if (this->socket_ == nullptr) {
|
||||
this->socket_failed_(LOG_STR("creation"));
|
||||
return false;
|
||||
}
|
||||
int enable = 1;
|
||||
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
|
||||
// we can still continue
|
||||
}
|
||||
err = this->socket_->setblocking(false);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("nonblocking"));
|
||||
return false;
|
||||
}
|
||||
|
||||
struct sockaddr_storage server;
|
||||
|
||||
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
|
||||
if (sl == 0) {
|
||||
this->socket_failed_(LOG_STR("set sockaddr"));
|
||||
return false;
|
||||
}
|
||||
|
||||
err = this->socket_->bind((struct sockaddr *) &server, sl);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("bind"));
|
||||
return false;
|
||||
}
|
||||
|
||||
err = this->socket_->listen(this->listen_backlog_);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("listen"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void APIServer::setup() {
|
||||
@@ -53,41 +92,14 @@ void APIServer::setup() {
|
||||
#endif
|
||||
#endif
|
||||
|
||||
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||
if (this->socket_ == nullptr) {
|
||||
this->socket_failed_(LOG_STR("creation"));
|
||||
return;
|
||||
}
|
||||
int enable = 1;
|
||||
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
|
||||
// we can still continue
|
||||
}
|
||||
err = this->socket_->setblocking(false);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("nonblocking"));
|
||||
return;
|
||||
}
|
||||
|
||||
struct sockaddr_storage server;
|
||||
|
||||
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
|
||||
if (sl == 0) {
|
||||
this->socket_failed_(LOG_STR("set sockaddr"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->bind((struct sockaddr *) &server, sl);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("bind"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->listen(this->listen_backlog_);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("listen"));
|
||||
if (!this->create_listen_socket_()) {
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Dial-out needs no listener; degrade instead of stopping the component
|
||||
this->status_set_error(LOG_STR("listen socket failed"));
|
||||
#else
|
||||
this->mark_failed();
|
||||
return;
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_LOGGER
|
||||
@@ -135,6 +147,9 @@ void APIServer::setup() {
|
||||
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
}
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.setup();
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIServer::loop() {
|
||||
@@ -143,6 +158,12 @@ void APIServer::loop() {
|
||||
this->accept_new_connections_();
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (!this->shutting_down_) {
|
||||
this->outgoing_conn_.loop(this);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (this->api_connection_count_ == 0) {
|
||||
// Check reboot timeout - done in loop to avoid scheduler heap churn
|
||||
// (cancelled scheduler items sit in heap memory until their scheduled time).
|
||||
@@ -151,7 +172,12 @@ void APIServer::loop() {
|
||||
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->last_connected_ > this->reboot_timeout_) {
|
||||
ESP_LOGE(TAG, "No clients; rebooting");
|
||||
// Distinguish a wrong-key peer from nothing connecting at all
|
||||
if (this->saw_unauthenticated_client_) {
|
||||
ESP_LOGE(TAG, "Clients connected but none authenticated; rebooting");
|
||||
} else {
|
||||
ESP_LOGE(TAG, "No clients; rebooting");
|
||||
}
|
||||
App.reboot();
|
||||
}
|
||||
}
|
||||
@@ -203,6 +229,15 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
std::string client_peername(client->get_peername_to(peername_buf));
|
||||
#endif
|
||||
|
||||
// Read before the swap-and-reset below destroys the connection
|
||||
const bool was_authenticated = client->is_authenticated();
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (client->flags_.outgoing_connection_target) {
|
||||
this->outgoing_target_count_--;
|
||||
}
|
||||
this->outgoing_conn_.on_client_removed(client.get(), was_authenticated);
|
||||
#endif
|
||||
|
||||
// Close socket now (was deferred from on_fatal_error to allow getpeername)
|
||||
client->helper_->close();
|
||||
|
||||
@@ -221,9 +256,18 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
|
||||
// Last client disconnected - set warning and start tracking for reboot timeout
|
||||
// (suppressed while provisioning is pending - see loop()).
|
||||
// Refresh on every authenticated removal, not just the last one, so an
|
||||
// unauthenticated straggler removed later (e.g. a port scan, or a dial to
|
||||
// a host that accepts TCP but never speaks the API) cannot discard a
|
||||
// healthy session's timestamp and trigger a spurious reboot
|
||||
if (was_authenticated) {
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
this->saw_unauthenticated_client_ = false;
|
||||
} else {
|
||||
this->saw_unauthenticated_client_ = true;
|
||||
}
|
||||
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
|
||||
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
|
||||
@@ -245,7 +289,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
sock->getpeername_to(peername);
|
||||
|
||||
// Check if we're at the connection limit
|
||||
if (this->api_connection_count_ >= MAX_API_CONNECTIONS) {
|
||||
if (this->at_client_limit_()) {
|
||||
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
|
||||
// Immediately close - socket destructor will handle cleanup
|
||||
sock.reset();
|
||||
@@ -254,18 +298,54 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
|
||||
ESP_LOGD(TAG, "Accept %s", peername);
|
||||
|
||||
auto *conn = new APIConnection(std::move(sock), this);
|
||||
this->clients_[this->api_connection_count_++].reset(conn);
|
||||
conn->start();
|
||||
|
||||
// First client connected - clear warning and update timestamp
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_clear_warning();
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
this->add_client_(new APIConnection(std::move(sock), this));
|
||||
}
|
||||
}
|
||||
|
||||
bool APIServer::add_client_(APIConnection *conn) {
|
||||
if (this->at_client_limit_()) {
|
||||
// The accept path checks first to skip the allocation; the outgoing
|
||||
// handoff relies on this check
|
||||
ESP_LOGW(TAG, "Max connections (%d), dropping client", MAX_API_CONNECTIONS);
|
||||
delete conn;
|
||||
return false;
|
||||
}
|
||||
this->clients_[this->api_connection_count_++].reset(conn);
|
||||
conn->start();
|
||||
|
||||
// First client connected - clear warning. The reboot watchdog timestamp is
|
||||
// refreshed when an authenticated client is removed (see remove_client_),
|
||||
// never on bare TCP connects.
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_clear_warning();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
APIConnection *APIServer::add_outgoing_client_(std::unique_ptr<socket::Socket> sock) {
|
||||
// Re-check at the handoff: the PSK may have been cleared since the dial
|
||||
// started (mark_outgoing() needs the noise helper); add_client_ re-checks
|
||||
// the slot limit
|
||||
if (!this->noise_ctx_.has_psk()) {
|
||||
ESP_LOGW(TAG, "Dropping outgoing connection (no key)");
|
||||
return nullptr;
|
||||
}
|
||||
auto *conn = new APIConnection(std::move(sock), this);
|
||||
if (!this->add_client_(conn)) {
|
||||
return nullptr;
|
||||
}
|
||||
// After start(): sends our server hello first so the peer can pick the key
|
||||
conn->mark_outgoing();
|
||||
return conn;
|
||||
}
|
||||
|
||||
void APIServer::on_outgoing_target_client(APIConnection *conn) {
|
||||
this->outgoing_target_count_++;
|
||||
this->outgoing_conn_.on_target_client(conn);
|
||||
}
|
||||
#endif
|
||||
|
||||
void APIServer::dump_config() {
|
||||
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
@@ -282,6 +362,9 @@ void APIServer::dump_config() {
|
||||
#else
|
||||
ESP_LOGCONFIG(TAG, " Noise encryption: NO");
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.dump_config();
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIServer::handle_disconnect(APIConnection *conn) {}
|
||||
@@ -577,6 +660,8 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
||||
if (!c->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
|
||||
}
|
||||
// Force it: a session from before the key was active must not survive
|
||||
c->flags_.next_close = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -678,6 +763,9 @@ void APIServer::on_shutdown() {
|
||||
|
||||
// Close the listening socket to prevent new connections
|
||||
this->destroy_socket_();
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.on_shutdown();
|
||||
#endif
|
||||
|
||||
// Change batch delay to 5ms for quick flushing during shutdown
|
||||
this->batch_delay_ = 5;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#endif
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
#include "api_outgoing_connection.h"
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -86,6 +87,10 @@ class APIServer final : public Component,
|
||||
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
|
||||
#endif // USE_API_NOISE
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Called by APIConnection when a client declares itself a dial-back target in its hello
|
||||
void on_outgoing_target_client(APIConnection *conn);
|
||||
#endif
|
||||
|
||||
void handle_disconnect(APIConnection *conn);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
@@ -263,6 +268,16 @@ class APIServer final : public Component,
|
||||
protected:
|
||||
// Accept incoming socket connections. Only called when socket has pending connections.
|
||||
void __attribute__((noinline)) accept_new_connections_();
|
||||
// Insert a constructed connection into the client slots and start it.
|
||||
// Takes ownership; deletes the connection and returns false at the limit
|
||||
bool add_client_(APIConnection *conn);
|
||||
bool at_client_limit_() const { return this->api_connection_count_ >= MAX_API_CONNECTIONS; }
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Returns the new connection, or nullptr (socket dropped) when at the limit
|
||||
APIConnection *add_outgoing_client_(std::unique_ptr<socket::Socket> sock);
|
||||
bool has_outgoing_target_client_() const { return this->outgoing_target_count_ != 0; }
|
||||
friend class OutgoingConnectionManager;
|
||||
#endif
|
||||
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
|
||||
void __attribute__((noinline)) remove_client_(uint8_t client_index);
|
||||
|
||||
@@ -304,6 +319,7 @@ class APIServer final : public Component,
|
||||
this->socket_ = nullptr;
|
||||
}
|
||||
void socket_failed_(const LogString *msg);
|
||||
bool create_listen_socket_();
|
||||
// Pointers and pointer-like types first (4 bytes each)
|
||||
socket::ListenSocket *socket_{nullptr};
|
||||
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
|
||||
@@ -356,8 +372,16 @@ class APIServer final : public Component,
|
||||
// Connection limits - these defaults will be overridden by config values
|
||||
// from cv.SplitDefault in __init__.py which sets platform-specific defaults.
|
||||
uint8_t listen_backlog_{4};
|
||||
bool shutting_down_ = false;
|
||||
// Bit-packed so the two flags share one byte
|
||||
bool shutting_down_ : 1 = false;
|
||||
// For the reboot log: whether any removal since the last watchdog refresh
|
||||
// was an unauthenticated session (e.g. a wrong-key peer)
|
||||
bool saw_unauthenticated_client_ : 1 = false;
|
||||
uint8_t api_connection_count_{0};
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Connected clients whose hello declared them a dial-back target
|
||||
uint8_t outgoing_target_count_{0};
|
||||
#endif
|
||||
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
|
||||
// Index assigned by the provisioning manager for reporting this transport's state.
|
||||
uint8_t provisioning_source_{0};
|
||||
@@ -370,6 +394,9 @@ class APIServer final : public Component,
|
||||
#endif
|
||||
ESPPreferenceObject noise_pref_;
|
||||
#endif // USE_API_NOISE
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
OutgoingConnectionManager outgoing_conn_;
|
||||
#endif
|
||||
};
|
||||
|
||||
extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
@@ -42,7 +42,16 @@ bool AsyncClient::connect(const char *host, uint16_t port) {
|
||||
return false;
|
||||
}
|
||||
|
||||
socket_->setblocking(false);
|
||||
if (socket_->setblocking(false) != 0) {
|
||||
// Capture before the log and reset() below can clobber errno; a blocking
|
||||
// connect()/read() would otherwise stall the whole loop
|
||||
const int saved_errno = errno;
|
||||
ESP_LOGE(TAG, "Failed to set nonblocking: errno %d", saved_errno);
|
||||
socket_.reset();
|
||||
if (error_cb_)
|
||||
error_cb_(error_arg_, this, saved_errno);
|
||||
return false;
|
||||
}
|
||||
|
||||
int err = socket_->connect((struct sockaddr *) &addr, addrlen);
|
||||
if (err == 0) {
|
||||
@@ -97,45 +106,22 @@ void AsyncClient::loop() {
|
||||
return;
|
||||
|
||||
if (connecting_) {
|
||||
// For connecting, we need to check writability, not readability
|
||||
// The Application's select() only monitors read FDs, so we do our own check here
|
||||
// For ESP platforms lwip_select() might be faster, but this code isn't used
|
||||
// on those platforms anyway. If it was, we'd fix the Application select()
|
||||
// to report writability instead of doing it this way.
|
||||
int fd = socket_->get_fd();
|
||||
if (fd < 0) {
|
||||
ESP_LOGW(TAG, "Invalid socket fd");
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
fd_set writefds;
|
||||
FD_ZERO(&writefds);
|
||||
FD_SET(fd, &writefds);
|
||||
|
||||
struct timeval tv = {0, 0};
|
||||
int ret = select(fd + 1, nullptr, &writefds, nullptr, &tv);
|
||||
|
||||
if (ret > 0 && FD_ISSET(fd, &writefds)) {
|
||||
int error = 0;
|
||||
socklen_t len = sizeof(error);
|
||||
if (socket_->getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) == 0 && error == 0) {
|
||||
int err = 0;
|
||||
switch (socket::poll_connect(*socket_, err)) {
|
||||
case socket::ConnectPollResult::CONNECT_POLL_PENDING:
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_CONNECTED:
|
||||
connecting_ = false;
|
||||
connected_ = true;
|
||||
if (connect_cb_)
|
||||
connect_cb_(connect_arg_, this);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Connection failed: %d", error);
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_ERROR:
|
||||
ESP_LOGW(TAG, "Connection failed: %d", err);
|
||||
close();
|
||||
if (error_cb_)
|
||||
error_cb_(error_arg_, this, error);
|
||||
}
|
||||
} else if (ret < 0) {
|
||||
const int err = errno;
|
||||
ESP_LOGE(TAG, "Select error: %d", err);
|
||||
close();
|
||||
if (error_cb_)
|
||||
error_cb_(error_arg_, this, err);
|
||||
error_cb_(error_arg_, this, err);
|
||||
break;
|
||||
}
|
||||
} else if (connected_) {
|
||||
// For connected sockets, use the Application's select() results
|
||||
|
||||
@@ -23,6 +23,7 @@ CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection"
|
||||
CONF_ENABLED = "enabled"
|
||||
CONF_GYROSCOPE_ODR = "gyroscope_odr"
|
||||
CONF_GYROSCOPE_RANGE = "gyroscope_range"
|
||||
CONF_HOST = "host"
|
||||
CONF_IAQ = "iaq"
|
||||
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
|
||||
CONF_IS_WRGB = "is_wrgb"
|
||||
|
||||
@@ -447,7 +447,10 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
tv.tv_usec = 0;
|
||||
this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
this->client_->setblocking(true);
|
||||
if (this->client_->setblocking(true) != 0) {
|
||||
this->log_socket_error_(LOG_STR("blocking"));
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
|
||||
// Acknowledge auth OK - 1 byte
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
|
||||
|
||||
@@ -30,6 +30,7 @@ MULTI_CONF = True
|
||||
|
||||
serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy")
|
||||
SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice)
|
||||
SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap")
|
||||
|
||||
api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums")
|
||||
SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType")
|
||||
|
||||
@@ -29,26 +29,57 @@ void SerialProxy::setup() {
|
||||
#ifdef USE_API
|
||||
// instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data
|
||||
this->outgoing_msg_.instance = this->instance_index_;
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// A tap sets itself up before this runs (its setup priority is higher), so it may
|
||||
// already be waiting on the port -- a boot-time handshake with the device, say. Leaving
|
||||
// the loop enabled is what lets that finish; without it the tap would stall until a
|
||||
// client happened to subscribe.
|
||||
if (this->tap_ != nullptr && this->tap_->tap_needs_port()) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// No subscriber at startup; disable loop until a client subscribes
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void SerialProxy::loop() {
|
||||
#ifdef USE_API
|
||||
// Safety check — loop should only run when subscribed, but guard against races
|
||||
if (this->api_connection_ == nullptr) [[unlikely]] {
|
||||
this->disable_loop();
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
void SerialProxy::reset_mode_() {
|
||||
// The mode belongs to a session, not to the port. Carrying a departed client's choice
|
||||
// over to the next one would inject protocol bytes into a stream that never asked for
|
||||
// them -- a firmware upload, or any client built before this request existed and so
|
||||
// unable to turn it off. Guessing RAW is the safe direction: a client that wanted
|
||||
// protocol handling and did not ask for it merely sends its own acknowledgements.
|
||||
if (this->mode_ == api::enums::SERIAL_PROXY_MODE_RAW) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_);
|
||||
this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW;
|
||||
}
|
||||
#endif
|
||||
|
||||
void SerialProxy::loop() {
|
||||
#ifdef USE_API
|
||||
// Detect subscriber disconnect
|
||||
if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() ||
|
||||
!api_is_connected()) {
|
||||
if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() ||
|
||||
!this->api_connection_->is_connection_setup() || !api_is_connected())) {
|
||||
ESP_LOGW(TAG, "Subscriber disconnected");
|
||||
this->api_connection_ = nullptr;
|
||||
this->reset_mode_();
|
||||
}
|
||||
|
||||
// With no subscriber there is normally nothing to do, but a tap may still need the port
|
||||
// read -- it does its protocol work precisely while nobody else is listening.
|
||||
if (this->api_connection_ == nullptr) [[unlikely]] {
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) {
|
||||
this->disable_loop();
|
||||
return;
|
||||
}
|
||||
#else
|
||||
this->disable_loop();
|
||||
return;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Read available data from UART and forward to subscribed client
|
||||
@@ -69,11 +100,54 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) {
|
||||
if (!this->read_array(buffer, to_read))
|
||||
return;
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// Before forwarding, so a tap that answers the device (an acknowledgement, say) is not
|
||||
// waiting on the network round trip to a subscriber that may not even exist.
|
||||
if (this->tap_observing_()) {
|
||||
this->tap_->on_device_rx(buffer, to_read);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (this->api_connection_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
this->outgoing_msg_.set_data(buffer, to_read);
|
||||
this->api_connection_->send_serial_proxy_data(this->outgoing_msg_);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
|
||||
bool SerialProxy::tap_observing_() const {
|
||||
if (this->tap_ == nullptr) {
|
||||
return false;
|
||||
}
|
||||
// With no subscriber, a tap doing its own protocol work (the boot-time handshake with
|
||||
// the device, say) is served regardless of mode -- nobody has chosen one yet. Once a
|
||||
// subscriber holds the port, the mode alone decides, so RAW stays inert.
|
||||
if (this->api_connection_ == nullptr && this->tap_->tap_needs_port()) {
|
||||
return true;
|
||||
}
|
||||
// Otherwise the mode decides. RAW must be inert: a client that flips to RAW before
|
||||
// flashing firmware is entitled to a byte pipe with nothing injecting protocol bytes
|
||||
// into it, and "the tap turned out not to recognise the stream" is not good enough.
|
||||
return this->mode_ == api::enums::SERIAL_PROXY_MODE_PROTOCOL;
|
||||
}
|
||||
|
||||
void SerialProxy::tap_pump() {
|
||||
#ifdef USE_API
|
||||
// Nothing would consume the bytes; leave them in the FIFO
|
||||
if (!this->tap_observing_() && this->api_connection_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
const size_t available = this->available();
|
||||
if (available > 0) {
|
||||
this->read_and_send_(available);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
void SerialProxy::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Serial Proxy [%" PRIu32 "]:\n"
|
||||
@@ -92,8 +166,9 @@ void SerialProxy::dump_config() {
|
||||
SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control,
|
||||
uint8_t parity, uint8_t stop_bits, uint8_t data_size) {
|
||||
#ifdef USE_API
|
||||
if (this->port_claimed_by_other_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_);
|
||||
if (!this->is_subscriber_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring configure request from client without port subscription [%" PRIu32 "]",
|
||||
this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
|
||||
}
|
||||
#endif
|
||||
@@ -159,24 +234,80 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
|
||||
SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_connection,
|
||||
api::enums::SerialProxyMode mode) {
|
||||
#ifdef USE_API
|
||||
// Only the live subscriber may change the mode, so the mode cannot outlive a session
|
||||
if (!this->is_subscriber_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring mode request from client without port subscription [%" PRIu32 "]", this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
|
||||
}
|
||||
#endif
|
||||
// Values come from a remote client
|
||||
if (mode != api::enums::SERIAL_PROXY_MODE_RAW && mode != api::enums::SERIAL_PROXY_MODE_PROTOCOL) {
|
||||
ESP_LOGW(TAG, "Invalid mode: %" PRIu32, static_cast<uint32_t>(mode));
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
|
||||
}
|
||||
// PROTOCOL on a port with no tap would be a silent no-op; refuse so the client knows
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
const bool has_tap = this->tap_ != nullptr;
|
||||
#else
|
||||
const bool has_tap = false;
|
||||
#endif
|
||||
if (mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL && !has_tap) {
|
||||
ESP_LOGW(TAG, "No tap on serial proxy [%" PRIu32 "]; PROTOCOL mode unavailable", this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
|
||||
}
|
||||
ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_,
|
||||
mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW"));
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
const bool leaving_protocol_mode =
|
||||
this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW;
|
||||
this->mode_ = mode;
|
||||
|
||||
// Only for an explicit client request, not for reset_mode_() at the end of a session:
|
||||
// an ordinary disconnect says nothing about the device, whereas a client deliberately
|
||||
// asking for raw bytes usually precedes changing what the device is.
|
||||
if (leaving_protocol_mode && this->tap_ != nullptr) {
|
||||
this->tap_->on_protocol_disabled();
|
||||
}
|
||||
#endif
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
|
||||
void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {
|
||||
#ifdef USE_API
|
||||
// Bytes from a client other than the live subscriber would interleave with the
|
||||
// subscriber's traffic on the wire
|
||||
if (this->port_claimed_by_other_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring write from client without port access [%" PRIu32 "]", this->instance_index_);
|
||||
// Bytes from anyone but the live subscriber would interleave with the subscriber's
|
||||
// traffic -- or with an active tap's -- on the wire
|
||||
if (!this->is_subscriber_(api_connection)) {
|
||||
if (this->api_connection_ != nullptr) {
|
||||
ESP_LOGW(TAG, "Ignoring write from client that does not hold serial proxy [%" PRIu32 "]", this->instance_index_);
|
||||
} else {
|
||||
// A legacy client streaming writes without subscribing would flood WARN, one per
|
||||
// request; writes are the only high-rate, unacknowledged operation, so keep this
|
||||
// visible without drowning the log
|
||||
ESP_LOGV(TAG, "Ignoring write from client without port subscription [%" PRIu32 "]", this->instance_index_);
|
||||
}
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (data == nullptr || len == 0)
|
||||
return;
|
||||
this->write_array(data, len);
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// After the write, so the tap observes the same ordering the device does
|
||||
if (this->tap_observing_()) {
|
||||
this->tap_->on_client_tx(data, len);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
|
||||
#ifdef USE_API
|
||||
if (this->port_claimed_by_other_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_);
|
||||
if (!this->is_subscriber_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring modem pin request from client without port subscription [%" PRIu32 "]",
|
||||
this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
|
||||
}
|
||||
#endif
|
||||
@@ -210,8 +341,8 @@ uint32_t SerialProxy::get_modem_pins() const {
|
||||
SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) {
|
||||
#ifdef USE_API
|
||||
// Flushing stalls the port, so it gets the same ownership check as writes
|
||||
if (this->port_claimed_by_other_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_);
|
||||
if (!this->is_subscriber_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring flush from client without port subscription [%" PRIu32 "]", this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
|
||||
}
|
||||
#endif
|
||||
@@ -230,11 +361,6 @@ SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) {
|
||||
}
|
||||
|
||||
#ifdef USE_API
|
||||
bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) const {
|
||||
return this->api_connection_ != nullptr && this->api_connection_ != api_connection &&
|
||||
this->api_connection_->is_connection_setup();
|
||||
}
|
||||
|
||||
SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection,
|
||||
api::enums::SerialProxyRequestType type) {
|
||||
switch (type) {
|
||||
@@ -252,6 +378,10 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
|
||||
}
|
||||
ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription");
|
||||
// End the dead client's session before starting the new one, so its mode
|
||||
// cannot leak into a session that never asked for it
|
||||
this->api_connection_ = nullptr;
|
||||
this->reset_mode_();
|
||||
}
|
||||
this->api_connection_ = api_connection;
|
||||
this->enable_loop();
|
||||
@@ -264,7 +394,15 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
this->api_connection_ = nullptr;
|
||||
this->reset_mode_();
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// Keep the loop alive for a tap that still needs the port (mirrors loop())
|
||||
if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
#else
|
||||
this->disable_loop();
|
||||
#endif
|
||||
ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
default:
|
||||
|
||||
@@ -26,6 +26,7 @@ class APIConnection;
|
||||
namespace enums {
|
||||
enum SerialProxyPortType : uint32_t;
|
||||
enum SerialProxyRequestType : uint32_t;
|
||||
enum SerialProxyMode : uint32_t;
|
||||
} // namespace enums
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -52,6 +53,36 @@ enum class SerialProxyResult : uint8_t {
|
||||
/// Maximum bytes to read from UART in a single loop iteration
|
||||
inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256;
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// Observes a port's traffic without owning it, and may inject bytes of its own.
|
||||
///
|
||||
/// This exists so protocol-aware behaviour can be layered onto a plain byte pipe without
|
||||
/// the pipe knowing anything about the protocol: the tap is compiled in only when some
|
||||
/// component asks for one, so a proxy carrying an RS485 meter pays nothing for it.
|
||||
///
|
||||
/// A tap is an observer, never a gatekeeper -- it cannot suppress or alter the bytes
|
||||
/// flowing in either direction, so a misbehaving tap cannot corrupt the stream.
|
||||
class SerialProxyTap {
|
||||
public:
|
||||
/// Bytes read from the device, before they are forwarded to any subscriber.
|
||||
virtual void on_device_rx(const uint8_t *data, size_t len) = 0;
|
||||
|
||||
/// Bytes a subscriber sent towards the device, after they have been written.
|
||||
virtual void on_client_tx(const uint8_t *data, size_t len) = 0;
|
||||
|
||||
/// True when the port must keep reading even with no subscriber attached, so a tap can
|
||||
/// do its own protocol work while nobody is listening. Honoured only while no
|
||||
/// subscriber holds the port; with one attached, the port mode alone decides.
|
||||
virtual bool tap_needs_port() const = 0;
|
||||
|
||||
/// A client explicitly turned protocol handling off for this port. Distinct from the
|
||||
/// automatic reset when a session ends: this one means a client intends to do something
|
||||
/// else with the device -- reflash it, most likely -- so anything the tap believes about
|
||||
/// it should be treated as suspect.
|
||||
virtual void on_protocol_disabled() = 0;
|
||||
};
|
||||
#endif
|
||||
|
||||
class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
@@ -77,6 +108,9 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Get the port type
|
||||
api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; }
|
||||
|
||||
/// Handle a mode change requested by an API client
|
||||
SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode);
|
||||
|
||||
/// Configure UART parameters and apply them
|
||||
/// @param api_connection The API connection requesting the change
|
||||
/// @param baudrate Baud rate in bits per second
|
||||
@@ -121,13 +155,67 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Set the DTR GPIO pin (from YAML configuration)
|
||||
void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; }
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// Attach a traffic observer. At most one, set once at setup time.
|
||||
void set_tap(SerialProxyTap *tap) { this->tap_ = tap; }
|
||||
|
||||
/// Write bytes originating from the tap rather than from a client. Bypasses the
|
||||
/// subscriber ownership check, but only while the tap is being served bytes -- so a
|
||||
/// port in RAW mode with a subscriber attached stays inert. Returns false when the
|
||||
/// bytes were dropped for that reason.
|
||||
bool write_from_tap(const uint8_t *data, size_t len) {
|
||||
if (!this->tap_observing_()) {
|
||||
return false;
|
||||
}
|
||||
this->write_array(data, len);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Whether the tap is currently being served bytes. Can flip false with no callback
|
||||
/// (a subscriber attaching in RAW mode, say), so a tap should check before starting
|
||||
/// protocol work and when a reply seems overdue.
|
||||
bool tap_is_observed() const { return this->tap_observing_(); }
|
||||
|
||||
/// Resume reading after a tap's needs change. loop() disables itself when there is
|
||||
/// neither a subscriber nor a tap that wants the port, so a tap starting fresh work
|
||||
/// must ask for it back. Must be called from the main loop.
|
||||
void tap_request_port() { this->enable_loop(); }
|
||||
|
||||
/// Whether the underlying device is present. On a USB UART this tracks enumeration, so
|
||||
/// a tap can notice the device being unplugged and plugged back in.
|
||||
bool is_device_connected() const { return this->parent_->is_connected(); }
|
||||
|
||||
/// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the
|
||||
/// main loop is running -- during setup, for instance, while a component is still
|
||||
/// blocking on can_proceed(). Must not be called from on_device_rx() or
|
||||
/// on_client_tx(): each nested cycle costs a 256-byte stack frame.
|
||||
void tap_pump();
|
||||
#endif
|
||||
|
||||
protected:
|
||||
#ifdef USE_API
|
||||
/// Read from UART and send to API client (slow path with 256-byte stack buffer)
|
||||
/// Read from UART, hand the bytes to any tap, and forward them to a subscriber
|
||||
/// (slow path with a 256-byte stack buffer)
|
||||
void read_and_send_(size_t available);
|
||||
|
||||
/// True when a live subscriber other than the given connection holds the port
|
||||
bool port_claimed_by_other_(api::APIConnection *api_connection) const;
|
||||
/// True when the given connection is the live subscriber. Every port operation
|
||||
/// (write, configure, modem pins, flush, mode) requires this, so an unsubscribed
|
||||
/// client can never share the wire with the subscriber or an active tap.
|
||||
bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; }
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// Return the port to RAW when a subscriber goes away, so the mode never outlives it
|
||||
void reset_mode_();
|
||||
#else
|
||||
/// Without a tap, PROTOCOL is refused, so the mode is fixed at RAW and there is
|
||||
/// nothing to reset
|
||||
void reset_mode_() {}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// True when the tap should be shown the traffic passing through this port
|
||||
bool tap_observing_() const;
|
||||
#endif
|
||||
|
||||
/// Instance index for identifying this proxy in API messages
|
||||
@@ -147,6 +235,11 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Port type
|
||||
api::enums::SerialProxyPortType port_type_{};
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW
|
||||
api::enums::SerialProxyMode mode_{};
|
||||
#endif
|
||||
|
||||
/// Optional GPIO pins for modem control
|
||||
GPIOPin *rts_pin_{nullptr};
|
||||
GPIOPin *dtr_pin_{nullptr};
|
||||
@@ -154,6 +247,10 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Current modem pin states
|
||||
bool rts_state_{false};
|
||||
bool dtr_state_{false};
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
SerialProxyTap *tap_{nullptr};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::serial_proxy
|
||||
|
||||
@@ -17,6 +17,8 @@ CONF_IMPLEMENTATION = "implementation"
|
||||
IMPLEMENTATION_LWIP_TCP = "lwip_tcp"
|
||||
IMPLEMENTATION_LWIP_SOCKETS = "lwip_sockets"
|
||||
IMPLEMENTATION_BSD_SOCKETS = "bsd_sockets"
|
||||
# Implementations whose sockets cannot make outgoing connections
|
||||
IMPLEMENTATIONS_WITHOUT_CONNECT = frozenset({IMPLEMENTATION_LWIP_TCP})
|
||||
|
||||
# Socket tracking infrastructure
|
||||
# Components register their socket needs and platforms read this to configure appropriately
|
||||
|
||||
@@ -59,13 +59,15 @@ int BSDSocketImpl::close() {
|
||||
|
||||
int BSDSocketImpl::setblocking(bool blocking) {
|
||||
int fl = ::fcntl(this->fd_, F_GETFL, 0);
|
||||
if (fl < 0) {
|
||||
return fl;
|
||||
}
|
||||
if (blocking) {
|
||||
fl &= ~O_NONBLOCK;
|
||||
} else {
|
||||
fl |= O_NONBLOCK;
|
||||
}
|
||||
::fcntl(this->fd_, F_SETFL, fl);
|
||||
return 0;
|
||||
return ::fcntl(this->fd_, F_SETFL, fl);
|
||||
}
|
||||
|
||||
size_t BSDSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
|
||||
|
||||
@@ -49,13 +49,15 @@ int LwIPSocketImpl::close() {
|
||||
|
||||
int LwIPSocketImpl::setblocking(bool blocking) {
|
||||
int fl = lwip_fcntl(this->fd_, F_GETFL, 0);
|
||||
if (fl < 0) {
|
||||
return fl;
|
||||
}
|
||||
if (blocking) {
|
||||
fl &= ~O_NONBLOCK;
|
||||
} else {
|
||||
fl |= O_NONBLOCK;
|
||||
}
|
||||
lwip_fcntl(this->fd_, F_SETFL, fl);
|
||||
return 0;
|
||||
return lwip_fcntl(this->fd_, F_SETFL, fl);
|
||||
}
|
||||
|
||||
size_t LwIPSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#ifdef USE_SOCKET_IMPL_BSD_SOCKETS
|
||||
#include <sys/select.h>
|
||||
#endif
|
||||
#include <string>
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/application.h"
|
||||
@@ -165,7 +168,10 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
|
||||
#else
|
||||
// Use LWIP-specific functions
|
||||
ip6_addr_t ip6;
|
||||
inet6_aton(ip_address, &ip6);
|
||||
if (inet6_aton(ip_address, &ip6) == 0) {
|
||||
errno = EINVAL;
|
||||
return 0;
|
||||
}
|
||||
memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr));
|
||||
#endif
|
||||
return sizeof(sockaddr_in6);
|
||||
@@ -185,12 +191,58 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
|
||||
return 0;
|
||||
}
|
||||
#else
|
||||
server->sin_addr.s_addr = inet_addr(ip_address);
|
||||
// Unlike inet_addr(), inet_aton() can signal failure while still
|
||||
// accepting the broadcast address 255.255.255.255
|
||||
if (inet_aton(ip_address, &server->sin_addr) == 0) {
|
||||
errno = EINVAL;
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
server->sin_port = htons(port);
|
||||
return sizeof(sockaddr_in);
|
||||
}
|
||||
|
||||
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
|
||||
ConnectPollResult poll_connect(Socket &sock, int &err_out) {
|
||||
int fd = sock.get_fd();
|
||||
if (fd < 0 || fd >= FD_SETSIZE) {
|
||||
// FD_SET on either is undefined behavior
|
||||
err_out = EBADF;
|
||||
return ConnectPollResult::CONNECT_POLL_ERROR;
|
||||
}
|
||||
// Connect completion is a write event; the main loop only selects on reads
|
||||
fd_set writefds;
|
||||
FD_ZERO(&writefds);
|
||||
FD_SET(fd, &writefds);
|
||||
struct timeval tv = {0, 0};
|
||||
#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS
|
||||
// LWIP_COMPAT_SOCKETS may be off (LibreTiny), so use the lwip symbol directly
|
||||
int ret = lwip_select(fd + 1, nullptr, &writefds, nullptr, &tv);
|
||||
#else
|
||||
// Global-scope select: the entity namespace esphome::select shadows it here
|
||||
int ret = ::select(fd + 1, nullptr, &writefds, nullptr, &tv);
|
||||
#endif
|
||||
if (ret < 0) {
|
||||
err_out = errno;
|
||||
return ConnectPollResult::CONNECT_POLL_ERROR;
|
||||
}
|
||||
if (ret == 0 || !FD_ISSET(fd, &writefds)) {
|
||||
return ConnectPollResult::CONNECT_POLL_PENDING;
|
||||
}
|
||||
int error = 0;
|
||||
socklen_t len = sizeof(error);
|
||||
if (sock.getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) != 0) {
|
||||
err_out = errno;
|
||||
return ConnectPollResult::CONNECT_POLL_ERROR;
|
||||
}
|
||||
if (error != 0) {
|
||||
err_out = error;
|
||||
return ConnectPollResult::CONNECT_POLL_ERROR;
|
||||
}
|
||||
return ConnectPollResult::CONNECT_POLL_CONNECTED;
|
||||
}
|
||||
#endif
|
||||
|
||||
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port) {
|
||||
#if USE_NETWORK_IPV6
|
||||
if (addrlen < sizeof(sockaddr_in6)) {
|
||||
|
||||
@@ -145,6 +145,19 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st
|
||||
/// Set a sockaddr to the any address and specified port for the IP version used by socket_ip().
|
||||
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port);
|
||||
|
||||
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
|
||||
enum class ConnectPollResult : uint8_t {
|
||||
CONNECT_POLL_PENDING,
|
||||
CONNECT_POLL_CONNECTED,
|
||||
CONNECT_POLL_ERROR,
|
||||
};
|
||||
|
||||
/// Check a non-blocking connect() for completion without blocking. On
|
||||
/// CONNECT_POLL_ERROR, err_out holds the socket's SO_ERROR, or errno when the
|
||||
/// poll itself failed.
|
||||
ConnectPollResult poll_connect(Socket &sock, int &err_out);
|
||||
#endif
|
||||
|
||||
/// Format sockaddr into caller-provided buffer, returns length written (excluding null)
|
||||
size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span<char, SOCKADDR_STR_LEN> buf);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor, sensor
|
||||
from esphome.components.const import CONF_HOST
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BINARY_SENSORS,
|
||||
@@ -14,7 +15,6 @@ AUTO_LOAD = ["socket"]
|
||||
CODEOWNERS = ["@Links2004"]
|
||||
DEPENDENCIES = ["network"]
|
||||
|
||||
CONF_HOST = "host"
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
statsd_component_ns = cg.esphome_ns.namespace("statsd")
|
||||
|
||||
@@ -13,9 +13,16 @@ void UDPComponent::setup() {
|
||||
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
|
||||
for (const auto &address : this->addresses_) {
|
||||
struct sockaddr saddr {};
|
||||
socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_);
|
||||
if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) {
|
||||
ESP_LOGW(TAG, "Invalid address %s", address);
|
||||
continue;
|
||||
}
|
||||
this->sockaddrs_.push_back(saddr);
|
||||
}
|
||||
if (this->sockaddrs_.size() != this->addresses_.size()) {
|
||||
// A dropped address silently receives nothing; surface the misconfiguration
|
||||
this->status_set_warning(LOG_STR("invalid address"));
|
||||
}
|
||||
// set up broadcast socket
|
||||
if (this->should_broadcast_) {
|
||||
this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
||||
@@ -94,9 +101,15 @@ void UDPComponent::setup() {
|
||||
// 8266 and RP2040 `Duino
|
||||
for (const auto &address : this->addresses_) {
|
||||
auto ipaddr = IPAddress();
|
||||
ipaddr.fromString(address);
|
||||
if (!ipaddr.fromString(address)) {
|
||||
ESP_LOGW(TAG, "Invalid address %s", address);
|
||||
continue;
|
||||
}
|
||||
this->ipaddrs_.push_back(ipaddr);
|
||||
}
|
||||
if (this->ipaddrs_.size() != this->addresses_.size()) {
|
||||
this->status_set_warning(LOG_STR("invalid address"));
|
||||
}
|
||||
if (this->should_listen_)
|
||||
this->udp_client_.begin(this->listen_port_);
|
||||
#endif
|
||||
|
||||
@@ -34,6 +34,10 @@ void WakeOnLanButton::press_action() {
|
||||
struct sockaddr_storage saddr {};
|
||||
auto addr_len =
|
||||
socket::set_sockaddr(reinterpret_cast<sockaddr *>(&saddr), sizeof(saddr), "255.255.255.255", this->port_);
|
||||
if (addr_len == 0) {
|
||||
ESP_LOGW(TAG, "Invalid broadcast address");
|
||||
return;
|
||||
}
|
||||
uint8_t buffer[6 + sizeof this->macaddr_ * 16];
|
||||
memcpy(buffer, PREFIX, sizeof(PREFIX));
|
||||
for (size_t i = 0; i != 16; i++) {
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
#define USE_SENSOR
|
||||
#define USE_SENSOR_FILTER
|
||||
#define USE_SERIAL_PROXY
|
||||
#define USE_SERIAL_PROXY_TAP
|
||||
#define USE_SETUP_PRIORITY_OVERRIDE
|
||||
#define USE_STATUS_LED
|
||||
#define USE_STATUS_SENSOR
|
||||
@@ -216,6 +217,11 @@
|
||||
#define USE_API_HOMEASSISTANT_SERVICES
|
||||
#define USE_API_HOMEASSISTANT_STATES
|
||||
#define USE_API_NOISE
|
||||
#if !defined(USE_ESP8266) && !defined(USE_RP2) // raw-lwip sockets cannot make outgoing connections
|
||||
#define USE_API_OUTGOING_CONNECTION
|
||||
#define API_OUTGOING_CONNECTION_PORT 6054
|
||||
#define API_OUTGOING_CONNECTION_DELAY 60000
|
||||
#endif
|
||||
#define USE_API_VARINT64
|
||||
#define USE_API_PLAINTEXT
|
||||
#define USE_API_USER_DEFINED_ACTIONS
|
||||
|
||||
@@ -56,15 +56,6 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui
|
||||
this->flags_.entity_category = (entity_fields >> ENTITY_FIELD_ENTITY_CATEGORY_SHIFT) & 0x3;
|
||||
}
|
||||
|
||||
void EntityBase::set_internal(bool internal) {
|
||||
// Remove the after-setup path in 2027.3.0 and ignore the call instead.
|
||||
if (App.is_setup_complete()) {
|
||||
ESP_LOGE(TAG, "'%s': set_internal() after setup is undefined behavior, stops working in 2027.3.0",
|
||||
this->get_name().c_str());
|
||||
}
|
||||
this->flags_.internal = internal;
|
||||
}
|
||||
|
||||
// Weak default lookup functions — overridden by generated code in main.cpp
|
||||
__attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; }
|
||||
__attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; }
|
||||
|
||||
@@ -88,26 +88,13 @@ class EntityBase {
|
||||
// Get whether this Entity should be hidden outside ESPHome
|
||||
bool is_internal() const { return this->flags_.internal; }
|
||||
|
||||
// Set whether this Entity should be hidden outside ESPHome. Prefer the 'internal:' YAML key
|
||||
// whenever possible: it is guaranteed and has none of the limitations below. Use this only when
|
||||
// the decision can only be made at boot. Must be called before MQTT and the API read the flag:
|
||||
// from on_boot at the default priority, or a setup() that runs above setup_priority::AFTER_WIFI.
|
||||
// If the answer comes from a device handshake, hold setup with can_proceed() until it arrives.
|
||||
// Calls after setup finishes are undefined behavior: the flag is still written and an error is
|
||||
// logged, and from 2027.3.0 the call will be ignored.
|
||||
//
|
||||
// Known limitations. Not bugs, so no issue reports please; a PR that removes one with no RAM
|
||||
// or performance cost would be considered.
|
||||
// - No consumer is notified of a change, so the flag can only be decided once per boot.
|
||||
// - The guard is coarse: a call from a priority below AFTER_WIFI (an on_boot with a low priority,
|
||||
// or a setup() at LATE) still passes, but the API camera listener is already registered, MQTT
|
||||
// (AFTER_CONNECTION) has cached the flag, and an API client that connected while setup was
|
||||
// stalled on a slow component has already listed the entities, so they keep the old value.
|
||||
// - Un-hiding an entity declared 'internal: true' in YAML skips the duplicate name check that
|
||||
// codegen runs for exposed entities, so a name collision can surface at runtime. Entities with
|
||||
// only an 'id:' are forced internal and use the id as their name.
|
||||
// - Zigbee codegen skips YAML internal entities entirely, so un-hiding cannot add them to Zigbee.
|
||||
void set_internal(bool internal);
|
||||
// Deprecated: Calling set_internal() at runtime is undefined behavior. Components and clients
|
||||
// are NOT notified of the change, the flag may have already been read during setup, and there
|
||||
// is NO guarantee any consumer will observe the new value. Use the 'internal:' YAML key instead.
|
||||
ESPDEPRECATED("set_internal() is undefined behavior at runtime — components and Home Assistant are NOT "
|
||||
"notified. Use the 'internal:' YAML key instead. Will be removed in 2027.3.0.",
|
||||
"2026.3.0")
|
||||
void set_internal(bool internal) { this->flags_.internal = internal; }
|
||||
|
||||
// Check if this object is declared to be disabled by default.
|
||||
// That means that when the device gets added to Home Assistant (or other clients) it should
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.4.0
|
||||
click==8.3.3
|
||||
aioesphomeapi==46.3.0
|
||||
aioesphomeapi==46.4.0
|
||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||
zeroconf==0.151.3
|
||||
puremagic==2.2.0
|
||||
|
||||
@@ -40,6 +40,9 @@ class SerialProxy {
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {}
|
||||
SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) {
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Tests for the api outgoing_connection option."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import socket
|
||||
from esphome.components.api import (
|
||||
CONFIG_SCHEMA,
|
||||
_validate_outgoing_host_ipv6,
|
||||
_validate_outgoing_socket_implementation,
|
||||
)
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import PlatformFramework
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
KEY = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="
|
||||
ESP32_PLATFORM_DATA = {KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}
|
||||
|
||||
|
||||
def _api_config(outgoing: ConfigType, *, encryption: bool = True) -> ConfigType:
|
||||
config: ConfigType = {"outgoing_connection": outgoing}
|
||||
if encryption:
|
||||
config["encryption"] = {"key": KEY}
|
||||
return config
|
||||
|
||||
|
||||
def test_outgoing_connection_generates_defines(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""A valid config emits the compile-time defines with defaults applied."""
|
||||
generate_main("tests/component_tests/api/test_outgoing_connection.yaml")
|
||||
|
||||
defines = {define.name: define.value for define in CORE.defines}
|
||||
assert "USE_API_OUTGOING_CONNECTION" in defines
|
||||
assert str(defines["API_OUTGOING_CONNECTION_HOST"]) == '"192.168.1.2"'
|
||||
assert str(defines["API_OUTGOING_CONNECTION_PORT"]) == "6054"
|
||||
assert str(defines["API_OUTGOING_CONNECTION_DELAY"]) == "60000"
|
||||
|
||||
|
||||
def test_outgoing_connection_defaults(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}))
|
||||
outgoing = config["outgoing_connection"]
|
||||
assert outgoing["port"] == 6054
|
||||
assert outgoing["delay"].total_milliseconds == 60000
|
||||
|
||||
|
||||
def test_outgoing_connection_bare_block(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A bare outgoing_connection: block is valid; the device dials the
|
||||
remembered last dial-back client."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
config = CONFIG_SCHEMA(_api_config(None))
|
||||
outgoing = config["outgoing_connection"]
|
||||
assert "host" not in outgoing
|
||||
assert outgoing["port"] == 6054
|
||||
|
||||
|
||||
def test_outgoing_connection_delay_bounded(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A delay past half the uint32 millisecond range is rejected, not wrapped."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
with pytest.raises(cv.Invalid, match="value must be at most"):
|
||||
CONFIG_SCHEMA(_api_config({"delay": "60d"}))
|
||||
|
||||
|
||||
def test_outgoing_connection_requires_encryption(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
with pytest.raises(cv.Invalid, match="requires 'encryption'"):
|
||||
CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}, encryption=False))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform_framework", "platform_data", "socket_conf"),
|
||||
[
|
||||
# The platform default on these two, resolved like AUTO_LOAD does
|
||||
(PlatformFramework.ESP8266_ARDUINO, None, None),
|
||||
(PlatformFramework.RP2040_ARDUINO, None, None),
|
||||
# An explicit selection elsewhere
|
||||
(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
ESP32_PLATFORM_DATA,
|
||||
{"implementation": "lwip_tcp"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_outgoing_connection_rejects_lwip_tcp(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
platform_framework: PlatformFramework,
|
||||
platform_data: ConfigType | None,
|
||||
socket_conf: ConfigType | None,
|
||||
) -> None:
|
||||
"""The resolved lwip_tcp socket is rejected at final validate."""
|
||||
set_core_config(platform_framework, platform_data=platform_data)
|
||||
fv.full_config.set({"socket": socket_conf or socket.CONFIG_SCHEMA({})})
|
||||
config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}))
|
||||
with pytest.raises(cv.Invalid, match="lwip_tcp"):
|
||||
_validate_outgoing_socket_implementation(config)
|
||||
|
||||
|
||||
def test_outgoing_connection_rejects_hostnames(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
with pytest.raises(cv.Invalid, match="not a valid IP address"):
|
||||
CONFIG_SCHEMA(_api_config({"host": "homeassistant.local"}))
|
||||
|
||||
|
||||
def test_outgoing_connection_ipv6_host_requires_ipv6(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
config = CONFIG_SCHEMA(_api_config({"host": "fd00::1"}))
|
||||
with pytest.raises(cv.Invalid, match="IPv6 is not"):
|
||||
_validate_outgoing_host_ipv6(config)
|
||||
|
||||
|
||||
def test_outgoing_connection_ipv6_host_passes_with_ipv6_enabled(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data=ESP32_PLATFORM_DATA,
|
||||
full_config={"network": {"enable_ipv6": True}},
|
||||
)
|
||||
config = CONFIG_SCHEMA(_api_config({"host": "fd00::1"}))
|
||||
assert _validate_outgoing_host_ipv6(config) is config
|
||||
|
||||
|
||||
def test_outgoing_connection_ipv6_host_with_ipv6(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
generate_main("tests/component_tests/api/test_outgoing_connection_ipv6.yaml")
|
||||
|
||||
defines = {define.name: define.value for define in CORE.defines}
|
||||
assert str(defines["API_OUTGOING_CONNECTION_HOST"]) == '"fd00::1"'
|
||||
@@ -0,0 +1,17 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
|
||||
outgoing_connection:
|
||||
host: 192.168.1.2
|
||||
@@ -0,0 +1,20 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
|
||||
outgoing_connection:
|
||||
host: fd00::1
|
||||
@@ -0,0 +1,13 @@
|
||||
packages:
|
||||
common: !include common-base.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
# Outgoing connection on the lwip_sockets implementation used by LibreTiny
|
||||
api:
|
||||
encryption:
|
||||
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
|
||||
outgoing_connection:
|
||||
host: 192.168.1.2
|
||||
@@ -0,0 +1,16 @@
|
||||
packages:
|
||||
common: !include common-base.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
# Outgoing connection: the device dials out when no dial-back client is
|
||||
# connected. Requires encryption so the peer is verified by key.
|
||||
api:
|
||||
encryption:
|
||||
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
|
||||
outgoing_connection:
|
||||
host: 192.168.1.2
|
||||
port: 6054
|
||||
delay: 60s
|
||||
@@ -0,0 +1,11 @@
|
||||
packages:
|
||||
common: !include common-base.yaml
|
||||
|
||||
network:
|
||||
|
||||
# No host set: the device dials the last remembered Home Assistant address
|
||||
api:
|
||||
encryption:
|
||||
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
|
||||
outgoing_connection:
|
||||
delay: 30s
|
||||
@@ -0,0 +1,14 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
# Compile the tap code paths; no tap is attached, so this exercises the
|
||||
# null-tap branches that a normal build never defines.
|
||||
esphome:
|
||||
platformio_options:
|
||||
build_flags:
|
||||
- "-DUSE_SERIAL_PROXY_TAP"
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
serial_proxy: !include common.yaml
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: outgoing-conn-test
|
||||
|
||||
host:
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
|
||||
outgoing_connection:
|
||||
host: 127.0.0.1
|
||||
port: OUTGOING_PORT
|
||||
delay: 1s
|
||||
@@ -0,0 +1,13 @@
|
||||
esphome:
|
||||
name: outgoing-conn-test
|
||||
|
||||
host:
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
|
||||
outgoing_connection:
|
||||
port: OUTGOING_PORT
|
||||
delay: 1s
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: api-reboot-test
|
||||
host:
|
||||
api:
|
||||
reboot_timeout: 2s # Headroom to connect and authenticate a client first
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -1,34 +0,0 @@
|
||||
esphome:
|
||||
name: set-internal-at-boot
|
||||
on_boot:
|
||||
then:
|
||||
- lambda: |-
|
||||
id(hidden_at_boot).set_internal(true);
|
||||
id(shown_at_boot).set_internal(false);
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: set_internal_late
|
||||
then:
|
||||
- lambda: id(untouched).set_internal(true);
|
||||
|
||||
logger:
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Hidden At Boot"
|
||||
id: hidden_at_boot
|
||||
lambda: return 1.0;
|
||||
|
||||
- platform: template
|
||||
name: "Shown At Boot"
|
||||
id: shown_at_boot
|
||||
internal: true
|
||||
lambda: return 2.0;
|
||||
|
||||
- platform: template
|
||||
name: "Untouched"
|
||||
id: untouched
|
||||
lambda: return 3.0;
|
||||
@@ -0,0 +1,175 @@
|
||||
"""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
|
||||
|
||||
from .types import RunCompiledFunction
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -16,7 +16,9 @@ async def test_api_reboot_timeout(
|
||||
"""Test that the device reboots when no API clients connect within the timeout."""
|
||||
loop = asyncio.get_running_loop()
|
||||
reboot_future = loop.create_future()
|
||||
reboot_pattern = re.compile(r"No clients; rebooting")
|
||||
# The harness port probe always connects without authenticating, so the
|
||||
# reboot deterministically reports the unauthenticated form
|
||||
reboot_pattern = re.compile(r"none authenticated; rebooting")
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
"""Check output for reboot message."""
|
||||
@@ -33,3 +35,30 @@ async def test_api_reboot_timeout(
|
||||
pytest.fail("Device did not reboot within expected timeout")
|
||||
|
||||
# Test passes if we get here - reboot was detected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_reboot_timeout_after_authenticated_disconnect(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""An authenticated disconnect resets the flag; the clean branch reboots."""
|
||||
loop = asyncio.get_running_loop()
|
||||
reboot_future = loop.create_future()
|
||||
reboot_pattern = re.compile(r"No clients; rebooting")
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
"""Check output for reboot message."""
|
||||
if not reboot_future.done() and reboot_pattern.search(line):
|
||||
reboot_future.set_result(True)
|
||||
|
||||
async with run_compiled(yaml_config, line_callback=check_output):
|
||||
# An authenticated session refreshes the watchdog and clears the
|
||||
# unauthenticated flag the harness probe set
|
||||
async with api_client_connected() as client:
|
||||
await client.device_info()
|
||||
try:
|
||||
await asyncio.wait_for(reboot_future, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Device did not reboot within expected timeout")
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Integration test for set_internal() called during and after setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_internal_at_boot(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""set_internal() in on_boot changes API exposure, later calls log an error."""
|
||||
waiter = LineWaiter()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, services = await client.list_entities_services()
|
||||
names = {entity.name for entity in entities}
|
||||
|
||||
assert "Hidden At Boot" not in names
|
||||
assert "Shown At Boot" in names
|
||||
assert "Untouched" in names
|
||||
|
||||
late = next(s for s in services if s.name == "set_internal_late")
|
||||
await client.execute_service(late, {})
|
||||
await waiter.wait_for(
|
||||
"'Untouched'",
|
||||
"set_internal() after setup is undefined behavior",
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
# Still written during the deprecation window, ignored from 2027.3.0
|
||||
entities, _ = await client.list_entities_services()
|
||||
assert "Untouched" not in {entity.name for entity in entities}
|
||||
Reference in New Issue
Block a user