Compare commits

..
Author SHA1 Message Date
J. Nick Koston 0bb86f8094 Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:37:40 -06:00
J. Nick Koston bf9e2b7a54 Merge branch 'dev' into binary-sensor-click-callback 2026-11-01 13:03:51 -10:00
J. Nick Koston fd000ead9f Merge dev 2026-11-01 11:52:49 -10:00
J. Nick Koston 42956e0173 [binary_sensor] Read the click configs once and share the test callback
Pair each click key with its detector class in one table, add the define
and create the detectors in one pass, register every test callback
through one helper, and point the skill at the in-tree example instead
of copying it.
2026-10-31 08:33:28 -10:00
J. Nick Koston 7edd1a8ea7 Merge branch 'binary-sensor-click-callback-tests' into binary-sensor-click-callback
# Conflicts:
#	tests/components/binary_sensor/click_trigger_test.cpp
2026-10-31 08:15:50 -10:00
J. Nick Koston 35e03009e6 [binary_sensor] Test that a double click resets its times
A short second press after a double click would otherwise pair with the
first release and the next gap.
2026-10-31 08:14:30 -10:00
J. Nick Koston df69028380 [binary_sensor] Test a press published by double click actions
Pin that the double click times reset before the actions run, so a
press an action publishes on the same sensor is kept.
2026-10-31 08:13:31 -10:00
J. Nick Koston 46c0cd859f Merge branch 'binary-sensor-click-callback-tests' into binary-sensor-click-callback
# Conflicts:
#	tests/components/binary_sensor/click_trigger_test.cpp
2026-10-31 07:54:21 -10:00
J. Nick Koston 419660bb67 [binary_sensor] Fix clang-tidy in the click test and hold the press after a double click 2026-10-31 07:54:11 -10:00
J. Nick Koston 22ba443107 [binary_sensor] Fix clang-tidy in the click test and hold the press after a double click 2026-10-31 07:54:08 -10:00
pre-commit-ci-lite[bot] 4015cea895 apply automatic formatting fixes 2026-10-31 17:51:49 +00:00
J. Nick Koston d4a8b149a9 [binary_sensor] Name the click detectors and build them as callback entries
Rename the state objects to ClickDetector and DoubleClickDetector with
check(), write the detector id as ::id through ApplyCall so a trigger id
named like the callback parameter cannot hide it, build both automations
from _CALLBACK_AUTOMATIONS, share one schema helper, and document the
stateful filter pattern in the automations skill.
2026-10-31 07:46:59 -10:00
J. Nick Koston 97732c9a25 [binary_sensor] Build on_click and on_double_click as callback automations
The click timing moves into small ClickState and DoubleClickState objects
that a generated state callback asks, so an automation whose actions are
all inline runs as one function with no Trigger or Automation object.
ClickTrigger and DoubleClickTrigger are removed; only codegen used them.
2026-10-31 07:40:40 -10:00
J. Nick Koston e3bb14be43 [binary_sensor] Add tests for the click and double click triggers
Pin the click and double click timing with a C++ unit test, and the
generated trigger code with a codegen test.
2026-10-31 07:38:50 -10:00
16 changed files with 170 additions and 301 deletions

No files matched your search

+9 -2
View File
@@ -123,10 +123,17 @@ 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 forwarder needs **mutable state
beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
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.
**Python:**
```python
+15 -32
View File
@@ -144,7 +144,6 @@ 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"
@@ -454,9 +453,6 @@ 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:
@@ -710,34 +706,21 @@ async def to_code(config: ConfigType) -> None:
if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None:
await wizard.to_code(wizard_config)
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 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],
)
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
if key := encryption_config.get(CONF_KEY):
+2 -20
View File
@@ -251,9 +251,6 @@ 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;
@@ -418,25 +415,10 @@ 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);
}
@@ -1954,8 +1936,8 @@ void APIConnection::complete_authentication_() {
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
{
char peername[socket::SOCKADDR_STR_LEN];
this->parent_->get_client_connected_callback().call(StringRef(this->helper_->get_client_name()),
StringRef(this->helper_->get_peername_to(peername)));
this->parent_->get_client_connected_trigger()->trigger(std::string(this->helper_->get_client_name()),
std::string(this->helper_->get_peername_to(peername)));
}
#endif
#ifdef USE_HOMEASSISTANT_TIME
+2 -14
View File
@@ -296,11 +296,7 @@ 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
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
|| this->active_iterator_ == ActiveIterator::CLIENT_SYNCED_PENDING
#endif
) {
if (this->active_iterator_ == ActiveIterator::NONE) {
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
}
}
@@ -646,15 +642,7 @@ 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,
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
// Initial states sent, on_client_synced not fired yet; uses no iterator storage
CLIENT_SYNCED_PENDING,
#endif
};
enum class ActiveIterator : uint8_t { NONE, LIST_ENTITIES, INITIAL_STATE };
union IteratorUnion {
ListEntitiesIterator list_entities;
+4 -6
View File
@@ -257,11 +257,9 @@ 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 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);
char peername_buf[socket::SOCKADDR_STR_LEN];
std::string client_name(client->get_name());
std::string client_peername(client->get_peername_to(peername_buf));
#endif
// Read before the swap-and-reset below destroys the connection
@@ -304,7 +302,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_callback_.call(StringRef(client_name), StringRef(peername));
this->client_disconnected_trigger_.trigger(client_name, client_peername);
#endif
}
+4 -23
View File
@@ -277,27 +277,11 @@ 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
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_; }
Trigger<std::string, std::string> *get_client_connected_trigger() { return &this->client_connected_trigger_; }
#endif
#ifdef USE_API_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_; }
Trigger<std::string, std::string> *get_client_disconnected_trigger() { return &this->client_disconnected_trigger_; }
#endif
protected:
@@ -359,13 +343,10 @@ class APIServer final : public Component
// Pointers and pointer-like types first (4 bytes each)
socket::ListenSocket *socket_{nullptr};
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
ClientCallback client_connected_callback_;
Trigger<std::string, std::string> client_connected_trigger_;
#endif
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
ClientCallback client_disconnected_callback_;
#endif
#ifdef USE_API_CLIENT_SYNCED_TRIGGER
ClientCallback client_synced_callback_;
Trigger<std::string, std::string> client_disconnected_trigger_;
#endif
// 4-byte aligned types
+49 -47
View File
@@ -134,10 +134,8 @@ BinarySensorInitiallyOff = binary_sensor_ns.class_(
BinarySensorPtr = BinarySensor.operator("ptr")
# Triggers
ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.template())
DoubleClickTrigger = binary_sensor_ns.class_(
"DoubleClickTrigger", automation.Trigger.template()
)
ClickDetector = binary_sensor_ns.class_("ClickDetector")
DoubleClickDetector = binary_sensor_ns.class_("DoubleClickDetector")
MultiClickTrigger = binary_sensor_ns.class_(
"MultiClickTrigger", automation.Trigger.template(), cg.Component
)
@@ -450,6 +448,23 @@ 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(
@@ -482,35 +497,9 @@ _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): 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_CLICK): _click_automation_schema(ClickDetector),
cv.Optional(CONF_ON_DOUBLE_CLICK): _click_automation_schema(
DoubleClickDetector
),
cv.Optional(CONF_ON_MULTI_CLICK): automation.validate_automation(
{
@@ -564,6 +553,11 @@ 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,
@@ -586,32 +580,40 @@ _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,6 +136,28 @@ 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
+11 -40
View File
@@ -24,54 +24,25 @@ 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);
class ClickTrigger final : public Trigger<> {
/// Times the presses of one on_click automation.
class ClickDetector {
public:
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();
}
});
}
/// 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);
protected:
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.
uint32_t start_time_{0}; ///< The millis() time when the press started.
};
class DoubleClickTrigger final : public Trigger<> {
/// Times the presses of one on_double_click automation.
class DoubleClickDetector {
public:
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;
});
}
/// 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);
protected:
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.
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.
};
class MultiClickTrigger final : public Trigger<>, public Component {
-1
View File
@@ -401,7 +401,6 @@
#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,18 +8,24 @@ from esphome.core import CORE
def test_click_triggers(generate_main: Callable[[str | Path], str]) -> None:
"""Each trigger gets its timing in milliseconds, defaults 50 and 350, and its own automation."""
"""Each trigger gets a detector and a callback with its timing in milliseconds, defaults 50 and 350."""
main_cpp = generate_main("tests/component_tests/binary_sensor/test_click.yaml")
triggers = re.findall(
r"new\((\w+)\) binary_sensor::(Click|DoubleClick)Trigger\(button_a, (\d+), (\d+)\);",
main_cpp,
kinds = dict(
re.findall(
r"new\((\w+)\) binary_sensor::(Click|DoubleClick)Detector\(\);", main_cpp
)
)
assert [trigger[1:] for trigger in triggers] == [
calls = re.findall(r"if \(!\(::(\w+)->check\(state, (\d+), (\d+)\)\)\)", main_cpp)
assert [(kinds[detector_id], *timing) for detector_id, *timing in calls] == [
("Click", "50", "350"),
("Click", "1000", "3000"),
("DoubleClick", "20", "400"),
]
for trigger_id, *_ in triggers:
assert f"Automation<>({trigger_id});" in main_cpp
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
assert "USE_BINARY_SENSOR_CLICK_TRIGGER" in {define.name for define in CORE.defines}
@@ -21,7 +21,9 @@ binary_sensor:
- delay: 1s
- logger.log: a long click done
on_double_click:
- min_length: 20ms
# Named like the callback parameter, which must not hide it
- trigger_id: state
min_length: 20ms
max_length: 400ms
then:
- logger.log: a double click
-10
View File
@@ -98,16 +98,6 @@ 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,16 +1,15 @@
#include <gtest/gtest.h>
#include <memory>
#include <functional>
#include <utility>
#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 triggers read the real millis(), so every wait is at least 80 ms away from a window edge
// The click detectors 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;
@@ -24,18 +23,24 @@ 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->click_ = std::make_unique<ClickTrigger>(sensor != nullptr ? sensor : &this->sensor_, min_length, max_length);
this->click_->set_automation_parent(&this->automation_);
this->watch_(this->click_, min_length, max_length, sensor != nullptr ? *sensor : this->sensor_);
}
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_);
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));
}
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);
@@ -44,10 +49,8 @@ class ClickTriggerTest : public ::testing::Test {
}
BinarySensor sensor_;
StatelessLambdaAction<> action_{[]() { fired++; }};
Automation<> automation_;
std::unique_ptr<ClickTrigger> click_;
std::unique_ptr<DoubleClickTrigger> double_click_;
ClickDetector click_;
DoubleClickDetector double_click_;
};
TEST(ClickMatchInterval, BoundsAreInclusive) {
@@ -198,4 +201,23 @@ 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
@@ -1,28 +0,0 @@
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
@@ -1,56 +0,0 @@
"""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