Compare commits

..
Author SHA1 Message Date
J. Nick Koston 3c2c850cb4 Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:38:58 -06:00
J. Nick Koston 95e4837b99 Merge branch 'dev' into api-client-synced-trigger 2026-11-01 12:32:34 -10:00
J. Nick Koston 16f14317a7 [api] Check the initial states arrive before on_client_synced fires 2026-10-29 20:04:02 -10:00
J. Nick Koston 08387b0fb0 [api] Don't fire on_client_synced after answering a disconnect request 2026-10-29 19:37:09 -10:00
J. Nick Koston e18413c2b7 [api] Catch an early on_client_synced in the integration test
Log unconditionally at the start of the trigger, so firing before the
subscription is seen even when the api.connected condition is false.
2026-10-29 19:15:17 -10:00
J. Nick Koston 086bfba8b3 [api] Fire on_client_synced once the deferred batch has drained
A direct send from the automation could overtake initial states still
queued under backpressure, and the trigger fired even when the final
flush killed the connection. The pending state rides in active_iterator_,
so it costs no RAM and the loop keeps its single iterator check.
2026-10-29 19:14:18 -10:00
J. Nick Koston 92e8a64669 Merge branch 'automation-owning-args' into api-client-synced-trigger 2026-10-29 17:09:42 -10:00
J. Nick Koston 32bde52dba [core] Move owning_args tests next to the automation tests 2026-10-29 17:09:39 -10:00
J. Nick Koston 32240fd69f Merge branch 'automation-owning-args' into api-client-synced-trigger 2026-10-29 17:06:59 -10:00
J. Nick Koston 8aa327264a Merge remote-tracking branch 'origin/dev' into automation-owning-args 2026-10-29 17:06:57 -10:00
J. Nick Koston b9a5ccea65 Merge branch 'automation-owning-args' into api-client-synced-trigger 2026-10-29 16:38:25 -10:00
J. Nick Koston aee3476828 [core] Move owning_args from nextion into automation
Picks std::string over StringRef for automation arguments when the
automation contains a deferring action. Shared so other components can
pass StringRef to their triggers without a lifetime issue.
2026-10-29 16:37:14 -10:00
J. Nick Koston 602c751c2f [api] Add on_client_synced trigger and pass client info as StringRef
on_client_connected fires during the hello, before the client subscribes
to states, so state_subscription_only is never true inside it. The new
on_client_synced trigger fires once the initial states have been sent,
when the subscriptions sent with SubscribeStates are active.

All three client triggers now pass StringRef; automations that defer get
std::string copies, chosen at compile time by automation.owning_args().
2026-10-29 16:30:08 -10:00
16 changed files with 301 additions and 170 deletions

No files matched your search

+2 -9
View File
@@ -123,17 +123,10 @@ Both live in `esphome/core/helpers.h` and accept `std::function`, lambdas and fo
their templatized `add()` method. `LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first
callback is added. Prefer it for entity-level callbacks where most instances have no subscribers.
A filter that keeps state between calls (timing, edges) still needs no Trigger class: give each automation a
small object with constant initializers, created from its `CONF_TRIGGER_ID` before the automations are built,
and let `when` ask it, e.g. `ApplyCall("{}->check(state, {}, {})", ((CONF_TRIGGER_ID, ...), ...))`. An `ID`
value in an `ApplyCall` is written as `::id`, so a callback parameter cannot hide it. `on_click` in
`esphome/components/binary_sensor/__init__.py` is the in-tree example; once a second component needs this,
move the object creation into `CallbackAutomation`.
## Triggers: Trigger class method
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the state must be set up from the
parent (in the constructor or in `setup()`), or the trigger needs its own timers.
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the forwarder needs **mutable state
beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
**Python:**
```python
+32 -15
View File
@@ -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):
+20 -2
View File
@@ -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
+14 -2
View File
@@ -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;
+6 -4
View File
@@ -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
}
+23 -4
View File
@@ -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
+47 -49
View File
@@ -134,8 +134,10 @@ BinarySensorInitiallyOff = binary_sensor_ns.class_(
BinarySensorPtr = BinarySensor.operator("ptr")
# Triggers
ClickDetector = binary_sensor_ns.class_("ClickDetector")
DoubleClickDetector = binary_sensor_ns.class_("DoubleClickDetector")
ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.template())
DoubleClickTrigger = binary_sensor_ns.class_(
"DoubleClickTrigger", automation.Trigger.template()
)
MultiClickTrigger = binary_sensor_ns.class_(
"MultiClickTrigger", automation.Trigger.template(), cg.Component
)
@@ -448,23 +450,6 @@ def validate_click_timing(value):
return value
def _click_automation_schema(detector: MockObjClass) -> cv.All:
return cv.All(
automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(detector),
cv.Optional(
CONF_MIN_LENGTH, default="50ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_MAX_LENGTH, default="350ms"
): cv.positive_time_period_milliseconds,
}
),
validate_click_timing,
)
def validate_publish_initial_state(value):
value = cv.boolean(value)
_LOGGER.warning(
@@ -497,9 +482,35 @@ _BINARY_SENSOR_SCHEMA = (
): validate_filters,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
cv.Optional(CONF_ON_CLICK): _click_automation_schema(ClickDetector),
cv.Optional(CONF_ON_DOUBLE_CLICK): _click_automation_schema(
DoubleClickDetector
cv.Optional(CONF_ON_CLICK): cv.All(
automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ClickTrigger),
cv.Optional(
CONF_MIN_LENGTH, default="50ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_MAX_LENGTH, default="350ms"
): cv.positive_time_period_milliseconds,
}
),
validate_click_timing,
),
cv.Optional(CONF_ON_DOUBLE_CLICK): cv.All(
automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DoubleClickTrigger
),
cv.Optional(
CONF_MIN_LENGTH, default="50ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_MAX_LENGTH, default="350ms"
): cv.positive_time_period_milliseconds,
}
),
validate_click_timing,
),
cv.Optional(CONF_ON_MULTI_CLICK): automation.validate_automation(
{
@@ -553,11 +564,6 @@ def binary_sensor_schema(
return _BINARY_SENSOR_SCHEMA.extend(schema)
_CLICK_DETECTORS = (
(CONF_ON_CLICK, ClickDetector),
(CONF_ON_DOUBLE_CLICK, DoubleClickDetector),
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_PRESS,
@@ -580,40 +586,32 @@ _CALLBACK_AUTOMATIONS = (
"add_full_state_callback",
[(cg.optional.template(bool), "x_previous"), (cg.optional.template(bool), "x")],
),
*(
automation.CallbackAutomation(
conf_key,
"add_on_state_callback",
params=[(bool, "state")],
forward=[],
when=automation.ApplyCall(
"{}->check(state, {}, {})",
(
(CONF_TRIGGER_ID, detector.operator("ptr")),
(CONF_MIN_LENGTH, cg.uint32),
(CONF_MAX_LENGTH, cg.uint32),
),
),
)
for conf_key, detector in _CLICK_DETECTORS
),
)
@coroutine_with_priority(CoroPriority.AUTOMATION)
async def _build_binary_sensor_automations(var, config):
# The detectors must exist before the automations that name them are built
if clicks := [c for key, _ in _CLICK_DETECTORS for c in config.get(key, ())]:
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
for conf in clicks:
cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK):
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
if config.get(CONF_ON_MULTI_CLICK):
cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER")
if config.get(CONF_ON_RAW_VALUE):
cg.add_define("USE_BINARY_SENSOR_RAW_CALLBACK")
for conf in config.get(CONF_ON_CLICK, []):
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_DOUBLE_CLICK, []):
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_MULTI_CLICK, []):
timings = [
cg.StructInitializer(
@@ -136,28 +136,6 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
return length >= min_length && length <= max_length;
}
}
bool ClickDetector::check(bool state, uint32_t min_length, uint32_t max_length) {
if (state) {
this->start_time_ = millis();
return false;
}
return match_interval(min_length, max_length, millis() - this->start_time_);
}
bool DoubleClickDetector::check(bool state, uint32_t min_length, uint32_t max_length) {
const uint32_t now = millis();
if (state && this->start_time_ != 0 && this->end_time_ != 0 &&
match_interval(min_length, max_length, this->end_time_ - this->start_time_) &&
match_interval(min_length, max_length, now - this->end_time_)) {
this->start_time_ = 0;
this->end_time_ = 0;
return true;
}
this->start_time_ = this->end_time_;
this->end_time_ = now;
return false;
}
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER
} // namespace esphome::binary_sensor
+40 -11
View File
@@ -24,25 +24,54 @@ static_assert(std::is_same_v<decltype(MultiClickTriggerEvent::state), uint32_t>
bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length);
/// Times the presses of one on_click automation.
class ClickDetector {
class ClickTrigger final : public Trigger<> {
public:
/// Whether `state` ends a press of min_length to max_length ms; a max_length of 0 means no maximum.
bool check(bool state, uint32_t min_length, uint32_t max_length);
explicit ClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length)
: min_length_(min_length), max_length_(max_length) {
parent->add_on_state_callback([this](bool state) {
if (state) {
this->start_time_ = millis();
} else {
const uint32_t length = millis() - this->start_time_;
if (match_interval(this->min_length_, this->max_length_, length))
this->trigger();
}
});
}
protected:
uint32_t start_time_{0}; ///< The millis() time when the press started.
uint32_t start_time_{0}; /// The millis() time when the click started.
uint32_t min_length_; /// Minimum length of click. 0 means no minimum.
uint32_t max_length_; /// Maximum length of click. 0 means no maximum.
};
/// Times the presses of one on_double_click automation.
class DoubleClickDetector {
class DoubleClickTrigger final : public Trigger<> {
public:
/// Whether `state` is the second press, with the first press and the gap both in the window.
bool check(bool state, uint32_t min_length, uint32_t max_length);
explicit DoubleClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length)
: min_length_(min_length), max_length_(max_length) {
parent->add_on_state_callback([this](bool state) {
const uint32_t now = millis();
if (state && this->start_time_ != 0 && this->end_time_ != 0) {
if (match_interval(this->min_length_, this->max_length_, this->end_time_ - this->start_time_) &&
match_interval(this->min_length_, this->max_length_, now - this->end_time_)) {
this->trigger();
this->start_time_ = 0;
this->end_time_ = 0;
return;
}
}
this->start_time_ = this->end_time_;
this->end_time_ = now;
});
}
protected:
uint32_t start_time_{0}; ///< The millis() time of the edge before the last one.
uint32_t end_time_{0}; ///< The millis() time of the last edge.
uint32_t start_time_{0};
uint32_t end_time_{0};
uint32_t min_length_; /// Minimum length of click. 0 means no minimum.
uint32_t max_length_; /// Maximum length of click. 0 means no maximum.
};
class MultiClickTrigger final : public Trigger<>, public Component {
+1
View File
@@ -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
@@ -8,24 +8,18 @@ from esphome.core import CORE
def test_click_triggers(generate_main: Callable[[str | Path], str]) -> None:
"""Each trigger gets a detector and a callback with its timing in milliseconds, defaults 50 and 350."""
"""Each trigger gets its timing in milliseconds, defaults 50 and 350, and its own automation."""
main_cpp = generate_main("tests/component_tests/binary_sensor/test_click.yaml")
kinds = dict(
re.findall(
r"new\((\w+)\) binary_sensor::(Click|DoubleClick)Detector\(\);", main_cpp
)
triggers = re.findall(
r"new\((\w+)\) binary_sensor::(Click|DoubleClick)Trigger\(button_a, (\d+), (\d+)\);",
main_cpp,
)
calls = re.findall(r"if \(!\(::(\w+)->check\(state, (\d+), (\d+)\)\)\)", main_cpp)
assert [(kinds[detector_id], *timing) for detector_id, *timing in calls] == [
assert [trigger[1:] for trigger in triggers] == [
("Click", "50", "350"),
("Click", "1000", "3000"),
("DoubleClick", "20", "400"),
]
assert main_cpp.count("button_a->add_on_state_callback([](") == 3
# Only the automation with a delay keeps an Automation object
assert re.findall(r"new\((\w+)\) Automation<>\(\);", main_cpp) == [
"automation_id_2"
]
assert "::automation_id_2->trigger();" in main_cpp
for trigger_id, *_ in triggers:
assert f"Automation<>({trigger_id});" in main_cpp
assert "USE_BINARY_SENSOR_CLICK_TRIGGER" in {define.name for define in CORE.defines}
@@ -21,9 +21,7 @@ binary_sensor:
- delay: 1s
- logger.log: a long click done
on_double_click:
# Named like the callback parameter, which must not hide it
- trigger_id: state
min_length: 20ms
- min_length: 20ms
max_length: 400ms
then:
- logger.log: a double click
+10
View File
@@ -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
@@ -1,15 +1,16 @@
#include <gtest/gtest.h>
#include <functional>
#include <utility>
#include <memory>
#include "esphome/components/binary_sensor/automation.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/binary_sensor/filter.h"
#include "esphome/core/automation.h"
#include "esphome/core/base_automation.h"
#include "esphome/core/hal.h"
namespace esphome::binary_sensor::testing {
// The click detectors read the real millis(), so every wait is at least 80 ms away from a window edge
// The triggers read the real millis(), so every wait is at least 80 ms away from a window edge
static constexpr uint32_t MIN_MS = 100;
static constexpr uint32_t MAX_MS = 300;
static constexpr uint32_t SHORT_MS = 20;
@@ -23,24 +24,18 @@ class ClickTriggerTest : public ::testing::Test {
void SetUp() override {
fired = 0;
this->sensor_.publish_state(false);
this->automation_.add_action(&this->action_);
}
// As codegen builds on_click and on_double_click
void add_click_(uint32_t min_length = MIN_MS, uint32_t max_length = MAX_MS, BinarySensor *sensor = nullptr) {
this->watch_(this->click_, min_length, max_length, sensor != nullptr ? *sensor : this->sensor_);
this->click_ = std::make_unique<ClickTrigger>(sensor != nullptr ? sensor : &this->sensor_, min_length, max_length);
this->click_->set_automation_parent(&this->automation_);
}
void add_double_click_(std::function<void()> on_fire = count_fire) {
this->watch_(this->double_click_, MIN_MS, MAX_MS, this->sensor_, std::move(on_fire));
void add_double_click_() {
this->double_click_ = std::make_unique<DoubleClickTrigger>(&this->sensor_, MIN_MS, MAX_MS);
this->double_click_->set_automation_parent(&this->automation_);
}
template<typename D>
void watch_(D &detector, uint32_t min_length, uint32_t max_length, BinarySensor &sensor,
std::function<void()> on_fire = count_fire) {
sensor.add_on_state_callback([&detector, min_length, max_length, on_fire = std::move(on_fire)](bool state) {
if (detector.check(state, min_length, max_length))
on_fire();
});
}
static void count_fire() { fired++; }
void press_for_(uint32_t length) {
this->sensor_.publish_state(true);
@@ -49,8 +44,10 @@ class ClickTriggerTest : public ::testing::Test {
}
BinarySensor sensor_;
ClickDetector click_;
DoubleClickDetector double_click_;
StatelessLambdaAction<> action_{[]() { fired++; }};
Automation<> automation_;
std::unique_ptr<ClickTrigger> click_;
std::unique_ptr<DoubleClickTrigger> double_click_;
};
TEST(ClickMatchInterval, BoundsAreInclusive) {
@@ -201,23 +198,4 @@ TEST_F(ClickTriggerTest, DoubleClickDoesNotFireTwiceWithoutReset) {
EXPECT_EQ(fired, 1);
}
TEST_F(ClickTriggerTest, DoubleClickKeepsPressPublishedByItsActions) {
// The times reset before the actions run, so a press the first action publishes is kept
this->add_double_click_([this]() {
if (++fired == 1) {
this->sensor_.publish_state(false);
this->sensor_.publish_state(true);
}
});
this->press_for_(INSIDE_MS);
delay(INSIDE_MS);
this->sensor_.publish_state(true);
EXPECT_EQ(fired, 1);
delay(INSIDE_MS);
this->sensor_.publish_state(false);
delay(INSIDE_MS);
this->sensor_.publish_state(true);
EXPECT_EQ(fired, 2);
}
} // namespace esphome::binary_sensor::testing
@@ -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