mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 09:36:04 +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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user