mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 01:25:57 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bb86f8094 | ||
|
|
bf9e2b7a54 | ||
|
|
fd000ead9f | ||
|
|
42956e0173 | ||
|
|
7edd1a8ea7 | ||
|
|
35e03009e6 | ||
|
|
df69028380 | ||
|
|
46c0cd859f | ||
|
|
419660bb67 | ||
|
|
22ba443107 | ||
|
|
4015cea895 | ||
|
|
d4a8b149a9 | ||
|
|
97732c9a25 | ||
|
|
e3bb14be43 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user