mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 01:25:57 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c2c850cb4 | ||
|
|
95e4837b99 | ||
|
|
16f14317a7 | ||
|
|
08387b0fb0 | ||
|
|
e18413c2b7 | ||
|
|
086bfba8b3 | ||
|
|
92e8a64669 | ||
|
|
32bde52dba | ||
|
|
32240fd69f | ||
|
|
8aa327264a | ||
|
|
b9a5ccea65 | ||
|
|
aee3476828 | ||
|
|
602c751c2f |
No files matched your search
@@ -144,6 +144,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
||||
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
|
||||
CONF_LISTEN_BACKLOG = "listen_backlog"
|
||||
CONF_MAX_SEND_QUEUE = "max_send_queue"
|
||||
CONF_ON_CLIENT_SYNCED = "on_client_synced"
|
||||
CONF_OUTGOING_CONNECTION = "outgoing_connection"
|
||||
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
||||
|
||||
@@ -453,6 +454,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ON_CLIENT_DISCONNECTED): automation.validate_automation(
|
||||
single=True
|
||||
),
|
||||
cv.Optional(CONF_ON_CLIENT_SYNCED): automation.validate_automation(
|
||||
single=True
|
||||
),
|
||||
# Connection limits to prevent memory exhaustion on resource-constrained devices
|
||||
# Each connection uses ~500-1000 bytes of RAM plus system resources
|
||||
# Platform defaults based on available RAM and network stack implementation:
|
||||
@@ -706,21 +710,34 @@ async def to_code(config: ConfigType) -> None:
|
||||
if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None:
|
||||
await wizard.to_code(wizard_config)
|
||||
|
||||
if CONF_ON_CLIENT_CONNECTED in config:
|
||||
cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER")
|
||||
await automation.build_automation(
|
||||
var.get_client_connected_trigger(),
|
||||
[(cg.std_string, "client_info"), (cg.std_string, "client_address")],
|
||||
config[CONF_ON_CLIENT_CONNECTED],
|
||||
)
|
||||
|
||||
if CONF_ON_CLIENT_DISCONNECTED in config:
|
||||
cg.add_define("USE_API_CLIENT_DISCONNECTED_TRIGGER")
|
||||
await automation.build_automation(
|
||||
var.get_client_disconnected_trigger(),
|
||||
[(cg.std_string, "client_info"), (cg.std_string, "client_address")],
|
||||
config[CONF_ON_CLIENT_DISCONNECTED],
|
||||
)
|
||||
for conf_key, define, callback_method in (
|
||||
(
|
||||
CONF_ON_CLIENT_CONNECTED,
|
||||
"USE_API_CLIENT_CONNECTED_TRIGGER",
|
||||
"set_on_client_connected_callback",
|
||||
),
|
||||
(
|
||||
CONF_ON_CLIENT_DISCONNECTED,
|
||||
"USE_API_CLIENT_DISCONNECTED_TRIGGER",
|
||||
"set_on_client_disconnected_callback",
|
||||
),
|
||||
(
|
||||
CONF_ON_CLIENT_SYNCED,
|
||||
"USE_API_CLIENT_SYNCED_TRIGGER",
|
||||
"set_on_client_synced_callback",
|
||||
),
|
||||
):
|
||||
if (automation_conf := config.get(conf_key)) is not None:
|
||||
cg.add_define(define)
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
callback_method,
|
||||
automation.owning_args(
|
||||
[(cg.StringRef, "client_info"), (cg.StringRef, "client_address")],
|
||||
automation_conf,
|
||||
),
|
||||
automation_conf,
|
||||
)
|
||||
|
||||
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
|
||||
if key := encryption_config.get(CONF_KEY):
|
||||
|
||||
@@ -251,6 +251,9 @@ void APIConnection::destroy_active_iterator_() {
|
||||
this->iterator_storage_.initial_state.~InitialStateIterator();
|
||||
break;
|
||||
case ActiveIterator::NONE:
|
||||
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
|
||||
case ActiveIterator::CLIENT_SYNCED_PENDING:
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
this->active_iterator_ = ActiveIterator::NONE;
|
||||
@@ -415,10 +418,25 @@ void APIConnection::process_active_iterator_() {
|
||||
} else {
|
||||
this->process_iterator_batch_(this->iterator_storage_.list_entities);
|
||||
}
|
||||
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
|
||||
} else if (this->active_iterator_ == ActiveIterator::CLIENT_SYNCED_PENDING) {
|
||||
// Fired after the sync, not on SubscribeStates, so the subscriptions sent with it are active.
|
||||
// Waits for the batch to drain so a direct send from the automation cannot overtake queued states.
|
||||
// next_close: a DisconnectRequest this pass already answered; remove is only set next loop.
|
||||
if (this->deferred_batch_.empty() && !this->flags_.remove && !this->flags_.next_close) {
|
||||
this->active_iterator_ = ActiveIterator::NONE;
|
||||
char peername[socket::SOCKADDR_STR_LEN];
|
||||
this->parent_->get_client_synced_callback().call(StringRef(this->helper_->get_client_name()),
|
||||
StringRef(this->helper_->get_peername_to(peername)));
|
||||
}
|
||||
#endif
|
||||
} else { // INITIAL_STATE
|
||||
if (this->iterator_storage_.initial_state.completed()) {
|
||||
this->destroy_active_iterator_();
|
||||
this->finalize_iterator_sync_();
|
||||
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
|
||||
this->active_iterator_ = ActiveIterator::CLIENT_SYNCED_PENDING;
|
||||
#endif
|
||||
} else {
|
||||
this->process_iterator_batch_(this->iterator_storage_.initial_state);
|
||||
}
|
||||
@@ -1936,8 +1954,8 @@ void APIConnection::complete_authentication_() {
|
||||
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
|
||||
{
|
||||
char peername[socket::SOCKADDR_STR_LEN];
|
||||
this->parent_->get_client_connected_trigger()->trigger(std::string(this->helper_->get_client_name()),
|
||||
std::string(this->helper_->get_peername_to(peername)));
|
||||
this->parent_->get_client_connected_callback().call(StringRef(this->helper_->get_client_name()),
|
||||
StringRef(this->helper_->get_peername_to(peername)));
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
|
||||
@@ -296,7 +296,11 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
this->flags_.state_subscription = true;
|
||||
// Start initial state iterator only if no iterator is active
|
||||
// If list_entities is running, we'll start initial_state when it completes
|
||||
if (this->active_iterator_ == ActiveIterator::NONE) {
|
||||
if (this->active_iterator_ == ActiveIterator::NONE
|
||||
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
|
||||
|| this->active_iterator_ == ActiveIterator::CLIENT_SYNCED_PENDING
|
||||
#endif
|
||||
) {
|
||||
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
|
||||
}
|
||||
}
|
||||
@@ -642,7 +646,15 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Group 2: Iterator union (saves ~16 bytes vs separate iterators)
|
||||
// These iterators are never active simultaneously - list_entities runs to completion
|
||||
// before initial_state begins, so we use a union with explicit construction/destruction.
|
||||
enum class ActiveIterator : uint8_t { NONE, LIST_ENTITIES, INITIAL_STATE };
|
||||
enum class ActiveIterator : uint8_t {
|
||||
NONE,
|
||||
LIST_ENTITIES,
|
||||
INITIAL_STATE,
|
||||
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
|
||||
// Initial states sent, on_client_synced not fired yet; uses no iterator storage
|
||||
CLIENT_SYNCED_PENDING,
|
||||
#endif
|
||||
};
|
||||
|
||||
union IteratorUnion {
|
||||
ListEntitiesIterator list_entities;
|
||||
|
||||
@@ -257,9 +257,11 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
|
||||
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
|
||||
// Save client info before closing socket and removal for the trigger
|
||||
char peername_buf[socket::SOCKADDR_STR_LEN];
|
||||
std::string client_name(client->get_name());
|
||||
std::string client_peername(client->get_peername_to(peername_buf));
|
||||
char client_name[CLIENT_INFO_NAME_MAX_LEN];
|
||||
strncpy(client_name, client->get_name(), sizeof(client_name) - 1);
|
||||
client_name[sizeof(client_name) - 1] = '\0';
|
||||
char peername[socket::SOCKADDR_STR_LEN];
|
||||
client->get_peername_to(peername);
|
||||
#endif
|
||||
|
||||
// Read before the swap-and-reset below destroys the connection
|
||||
@@ -302,7 +304,7 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
|
||||
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
|
||||
// Fire trigger after client is removed so api.connected reflects the true state
|
||||
this->client_disconnected_trigger_.trigger(client_name, client_peername);
|
||||
this->client_disconnected_callback_.call(StringRef(client_name), StringRef(peername));
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -277,11 +277,27 @@ class APIServer final : public Component
|
||||
const std::vector<UserServiceDescriptor *> &get_user_services() const { return this->user_services_; }
|
||||
#endif
|
||||
|
||||
#if defined(USE_API_CLIENT_CONNECTED_TRIGGER) || defined(USE_API_CLIENT_DISCONNECTED_TRIGGER) || \
|
||||
defined(USE_API_CLIENT_SYNCED_TRIGGER)
|
||||
/// Called with the client name and address, which are only valid during the call
|
||||
using ClientCallback = Callback<void(StringRef, StringRef)>;
|
||||
#endif
|
||||
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
|
||||
Trigger<std::string, std::string> *get_client_connected_trigger() { return &this->client_connected_trigger_; }
|
||||
template<typename F> void set_on_client_connected_callback(F &&callback) {
|
||||
this->client_connected_callback_ = ClientCallback::create(std::forward<F>(callback));
|
||||
}
|
||||
const ClientCallback &get_client_connected_callback() const { return this->client_connected_callback_; }
|
||||
#endif
|
||||
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
|
||||
Trigger<std::string, std::string> *get_client_disconnected_trigger() { return &this->client_disconnected_trigger_; }
|
||||
template<typename F> void set_on_client_disconnected_callback(F &&callback) {
|
||||
this->client_disconnected_callback_ = ClientCallback::create(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
|
||||
template<typename F> void set_on_client_synced_callback(F &&callback) {
|
||||
this->client_synced_callback_ = ClientCallback::create(std::forward<F>(callback));
|
||||
}
|
||||
const ClientCallback &get_client_synced_callback() const { return this->client_synced_callback_; }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
@@ -343,10 +359,13 @@ class APIServer final : public Component
|
||||
// Pointers and pointer-like types first (4 bytes each)
|
||||
socket::ListenSocket *socket_{nullptr};
|
||||
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
|
||||
Trigger<std::string, std::string> client_connected_trigger_;
|
||||
ClientCallback client_connected_callback_;
|
||||
#endif
|
||||
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
|
||||
Trigger<std::string, std::string> client_disconnected_trigger_;
|
||||
ClientCallback client_disconnected_callback_;
|
||||
#endif
|
||||
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
|
||||
ClientCallback client_synced_callback_;
|
||||
#endif
|
||||
|
||||
// 4-byte aligned types
|
||||
|
||||
@@ -401,6 +401,7 @@
|
||||
#define USE_API
|
||||
#define USE_API_CLIENT_CONNECTED_TRIGGER
|
||||
#define USE_API_CLIENT_DISCONNECTED_TRIGGER
|
||||
#define USE_API_CLIENT_SYNCED_TRIGGER
|
||||
#define USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
#define USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
#define USE_API_HOMEASSISTANT_SERVICES
|
||||
|
||||
@@ -98,6 +98,16 @@ esphome:
|
||||
api:
|
||||
port: 8000
|
||||
reboot_timeout: 0min
|
||||
on_client_connected:
|
||||
# Deferring actions get owned copies of the arguments
|
||||
- delay: 1s
|
||||
- logger.log:
|
||||
format: "Client %s connected from %s"
|
||||
args: [client_info.c_str(), client_address.c_str()]
|
||||
on_client_synced:
|
||||
- logger.log:
|
||||
format: "Client %s synced from %s"
|
||||
args: [client_info.c_str(), client_address.c_str()]
|
||||
actions:
|
||||
- action: hello_world
|
||||
description: Log a greeting
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
esphome:
|
||||
name: api-client-synced
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
on_client_synced:
|
||||
- logger.log: "Synced trigger entered"
|
||||
- if:
|
||||
condition:
|
||||
api.connected:
|
||||
state_subscription_only: true
|
||||
then:
|
||||
- logger.log: "Synced trigger fired"
|
||||
# Home Assistant subscribes to actions in the same packet as states
|
||||
- homeassistant.event:
|
||||
event: esphome.client_synced
|
||||
|
||||
switch:
|
||||
# A known state at boot, so the test can check it is sent before the trigger
|
||||
- platform: template
|
||||
id: ready_switch
|
||||
name: "Ready switch"
|
||||
optimistic: true
|
||||
restore_mode: ALWAYS_ON
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Integration test for the api on_client_synced trigger."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import EntityState, HomeassistantServiceCall, SwitchState
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_synced(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""The trigger fires after the initial states, with the action subscription active."""
|
||||
waiter = LineWaiter()
|
||||
calls: asyncio.Queue[HomeassistantServiceCall] = asyncio.Queue()
|
||||
# Everything the client receives, in order
|
||||
received: list[EntityState | HomeassistantServiceCall] = []
|
||||
|
||||
def on_service_call(call: HomeassistantServiceCall) -> None:
|
||||
received.append(call)
|
||||
calls.put_nowait(call)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
await client.device_info()
|
||||
assert waiter.count("Synced trigger entered") == 0
|
||||
|
||||
client.subscribe_home_assistant_states_and_services(
|
||||
on_state=received.append,
|
||||
on_service_call=on_service_call,
|
||||
on_state_sub=lambda *_: None,
|
||||
)
|
||||
await waiter.wait_for_each(
|
||||
"Synced trigger entered", "Synced trigger fired", timeout=5.0
|
||||
)
|
||||
call = await asyncio.wait_for(calls.get(), timeout=5.0)
|
||||
assert call.service == "esphome.client_synced"
|
||||
assert call.is_event
|
||||
# The initial switch state arrives before the trigger's event
|
||||
synced_at = received.index(call)
|
||||
switch_states = [
|
||||
i
|
||||
for i, item in enumerate(received[:synced_at])
|
||||
if isinstance(item, SwitchState)
|
||||
]
|
||||
assert switch_states, received
|
||||
assert received[switch_states[0]].state is True
|
||||
Reference in new issue
Block a user