Compare commits

...
Author SHA1 Message Date
J. Nick Koston d0b0cd6e13 Merge remote-tracking branch 'origin/dev' into HEAD 2026-10-09 18:27:34 -10:00
J. Nick Koston 18e93a96fc Merge remote-tracking branch 'origin/dev' into autorepeat-progmem-timing 2026-10-04 18:06:30 -05:00
J. Nick Koston 01f1139476 Also guard the struct size so an added field fails the build 2026-10-03 05:48:38 -05:00
J. Nick Koston 053cb337aa Split the field type guards into one static_assert per field
clang-tidy flags the chained is_same_v checks as a redundant expression.
2026-10-02 21:42:31 -05:00
J. Nick Koston 3ac5918ec8 [core] Let shared_progmem_array emit an unshared array
Identical lambdas can carry separate static state, so callers need a way
to keep such tables apart while still getting a collision free name.
2026-10-02 21:19:12 -05:00
J. Nick Koston bf5d095cf6 End the autorepeat table with a never-run step so the filter stores no count 2026-10-02 20:59:52 -05:00
J. Nick Koston eae1b99232 Guard the AutorepeatFilterTiming field types read from flash 2026-10-02 20:59:52 -05:00
J. Nick Koston 15936f9a55 [core] Emit shared PROGMEM arrays at namespace scope
A setup() local table could only be reused from the scope of its first
use. The arrays are now globals. A lambda in a table only references
variables that codegen has already declared, so writing the global
straight away keeps the order valid.
2026-10-02 20:03:21 -05:00
J. Nick Koston aac085c5c7 Build the autorepeat timing table with cg.shared_progmem_array 2026-10-02 19:49:24 -05:00
J. Nick Koston fe66ac546a [core] Add cg.shared_progmem_array for deduplicated PROGMEM tables
Emits one PROGMEM array per distinct type and contents and returns the
same variable to later callers. The array name is made unique against
every config id and registered variable, so a generated table can never
collide with a user id.
2026-10-02 19:48:06 -05:00
J. Nick Koston a8e5fd0d2f Build the default autorepeat timing from the schema and tidy the tests 2026-10-02 19:07:18 -05:00
J. Nick Koston 93afb0d092 [binary_sensor] Keep autorepeat filter timings in a shared PROGMEM table 2026-10-02 18:53:18 -05:00
5 changed files with 130 additions and 77 deletions
+40 -43
View File
@@ -146,6 +146,7 @@ DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter)
DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter)
InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter)
AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter)
AutorepeatFilterTiming = binary_sensor_ns.struct("AutorepeatFilterTiming")
LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter)
StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter)
SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter)
@@ -233,53 +234,49 @@ async def delayed_off_filter_to_code(config, filter_id):
return var
AUTOREPEAT_TIMING_SCHEMA = cv.Schema(
{
cv.Optional(
CONF_DELAY, default=DEFAULT_DELAY
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TIME_OFF, default=DEFAULT_TIME_OFF
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TIME_ON, default=DEFAULT_TIME_ON
): cv.positive_time_period_milliseconds,
}
)
@register_filter(
"autorepeat",
AutorepeatFilter,
cv.All(
cv.ensure_list(
{
cv.Optional(
CONF_DELAY, default=DEFAULT_DELAY
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TIME_OFF, default=DEFAULT_TIME_OFF
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TIME_ON, default=DEFAULT_TIME_ON
): cv.positive_time_period_milliseconds,
}
),
cv.Length(max=254),
),
cv.All(cv.ensure_list(AUTOREPEAT_TIMING_SCHEMA), cv.Length(max=254)),
)
async def autorepeat_filter_to_code(config, filter_id):
if len(config) > 0:
timings = [
cg.StructInitializer(
cg.MockObj("AutorepeatFilterTiming", "esphome::binary_sensor::"),
("delay", conf[CONF_DELAY]),
("time_off", conf[CONF_TIME_OFF]),
("time_on", conf[CONF_TIME_ON]),
)
for conf in config
]
else:
timings = [
cg.StructInitializer(
cg.MockObj("AutorepeatFilterTiming", "esphome::binary_sensor::"),
("delay", cv.time_period_str_unit(DEFAULT_DELAY).total_milliseconds),
(
"time_off",
cv.time_period_str_unit(DEFAULT_TIME_OFF).total_milliseconds,
),
(
"time_on",
cv.time_period_str_unit(DEFAULT_TIME_ON).total_milliseconds,
),
)
]
return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings)
async def autorepeat_filter_to_code(config: list[ConfigType], filter_id: ID) -> MockObj:
config = config or [AUTOREPEAT_TIMING_SCHEMA({})]
timings = [
cg.StructInitializer(
AutorepeatFilterTiming,
("delay", conf[CONF_DELAY].total_milliseconds),
("time_off", conf[CONF_TIME_OFF].total_milliseconds),
("time_on", conf[CONF_TIME_ON].total_milliseconds),
)
for conf in config
]
# A never-run delay ends the table, so the filter stores no count.
timings.append(
cg.StructInitializer(
AutorepeatFilterTiming, ("delay", cg.RawExpression("SCHEDULER_DONT_RUN"))
)
)
table = cg.shared_progmem_array(
"binary_sensor_autorepeat_timings",
AutorepeatFilterTiming,
cg.ArrayInitializer(*timings),
)
return cg.new_Pvariable(filter_id, table)
@register_filter("lambda", LambdaFilter, cv.returning_lambda)
+9 -9
View File
@@ -63,12 +63,12 @@ optional<bool> DelayedOffFilter::new_value(bool value) {
optional<bool> InvertFilter::new_value(bool value) { return !value; }
// AutorepeatFilterBase
// AutorepeatFilter
// Two independent timers per instance, keyed off two stable addresses inside
// the filter: `this` for the timing-step timer, `&active_timing_` for the
// on/off timer. Both are unique per instance and don't collide with anything
// else, so the self-keyed scheduler API is sufficient.
optional<bool> AutorepeatFilterBase::new_value(bool value) {
optional<bool> AutorepeatFilter::new_value(bool value) {
if (value) {
if (this->active_timing_ != 0)
return {};
@@ -82,18 +82,18 @@ optional<bool> AutorepeatFilterBase::new_value(bool value) {
}
}
void AutorepeatFilterBase::next_timing_() {
if (this->active_timing_ < this->timings_count_) {
App.scheduler.set_timeout(this, this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); });
}
if (this->active_timing_ <= this->timings_count_) {
this->active_timing_++;
void AutorepeatFilter::next_timing_() {
const uint32_t delay = this->timings_[this->active_timing_].delay;
if (delay != SCHEDULER_DONT_RUN) {
App.scheduler.set_timeout(this, delay, [this]() { this->next_timing_(); });
}
// Only reached with active_timing_ at most the end-marker index, so this never passes it.
this->active_timing_++;
if (this->active_timing_ == 2)
this->next_value_(false);
}
void AutorepeatFilterBase::next_value_(bool val) {
void AutorepeatFilter::next_value_(bool val) {
const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2];
this->output(val);
App.scheduler.set_timeout(&this->active_timing_, val ? timing.time_on : timing.time_off,
+20 -25
View File
@@ -1,9 +1,9 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BINARY_SENSOR_FILTER
#include <array>
#include <type_traits>
#ifdef USE_BINARY_SENSOR_FILTER
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
@@ -87,39 +87,34 @@ struct AutorepeatFilterTiming {
uint32_t time_off;
uint32_t time_on;
};
// Read straight from flash on ESP8266, so every field must stay a word.
static_assert(std::is_same_v<decltype(AutorepeatFilterTiming::delay), uint32_t>,
"AutorepeatFilterTiming fields must stay uint32_t");
static_assert(std::is_same_v<decltype(AutorepeatFilterTiming::time_off), uint32_t>,
"AutorepeatFilterTiming fields must stay uint32_t");
static_assert(std::is_same_v<decltype(AutorepeatFilterTiming::time_on), uint32_t>,
"AutorepeatFilterTiming fields must stay uint32_t");
static_assert(sizeof(AutorepeatFilterTiming) == 3 * sizeof(uint32_t),
"AutorepeatFilterTiming is read from flash with word loads");
/// Non-template base for AutorepeatFilter — all methods in filter.cpp.
/// Lambdas capture this base pointer, so set_timeout/cancel_timeout are instantiated once.
/// Timings live in a PROGMEM table emitted by codegen, ended by an entry whose delay is
/// SCHEDULER_DONT_RUN (a step that could never advance anyway). Aligned loads are ESP8266 safe.
/// The two scheduled timers are keyed off `this` and `&active_timing_`; since the address
/// of `active_timing_` is taken as a scheduler key, the class must not be copied or moved.
class AutorepeatFilterBase : public Filter {
class AutorepeatFilter : public Filter {
public:
explicit AutorepeatFilter(const AutorepeatFilterTiming *timings) : timings_(timings) {}
AutorepeatFilter(const AutorepeatFilter &) = delete;
AutorepeatFilter &operator=(const AutorepeatFilter &) = delete;
optional<bool> new_value(bool value) override;
AutorepeatFilterBase(const AutorepeatFilterBase &) = delete;
AutorepeatFilterBase &operator=(const AutorepeatFilterBase &) = delete;
protected:
AutorepeatFilterBase() = default;
void next_timing_();
void next_value_(bool val);
const AutorepeatFilterTiming *timings_{nullptr};
uint8_t timings_count_{0};
uint8_t active_timing_{0};
};
/// Template wrapper that provides inline std::array storage for timings.
/// N is set by code generation to match the exact number of timings configured in YAML.
template<size_t N> class AutorepeatFilter : public AutorepeatFilterBase {
public:
explicit AutorepeatFilter(std::initializer_list<AutorepeatFilterTiming> timings) {
init_array_from(this->timings_storage_, timings);
this->timings_ = this->timings_storage_.data();
this->timings_count_ = N;
}
protected:
std::array<AutorepeatFilterTiming, N> timings_storage_{};
uint8_t active_timing_{0}; // before timings_ so it fills the padding after the Filter base
const AutorepeatFilterTiming *timings_;
};
class LambdaFilter : public Filter {
@@ -0,0 +1,26 @@
"""Tests for the binary sensor autorepeat filter codegen."""
from collections.abc import Callable
import re
def test_autorepeat_timings_shared_progmem_table(
generate_main: Callable[[str], str],
) -> None:
"""Identical timings share one PROGMEM table; an empty autorepeat gets the defaults."""
main_cpp = generate_main("tests/component_tests/binary_sensor/test_autorepeat.yaml")
tables = re.findall(
r"static constexpr binary_sensor::AutorepeatFilterTiming (\w+)\[\] PROGMEM",
main_cpp,
)
assert len(tables) == 2
shared, default = tables
assert main_cpp.count(f"AutorepeatFilter({shared});") == 2
assert f"AutorepeatFilter({default});" in main_cpp
assert "binary_sensor_autorepeat_timings" not in tables
default_table = main_cpp.split(f"{default}[] PROGMEM = ", 1)[1].split(";", 1)[0]
for field in (".delay = 1000", ".time_off = 100", ".time_on = 900"):
assert field in default_table
# Each table ends with a never-run delay instead of the filter storing a count.
assert main_cpp.count(".delay = SCHEDULER_DONT_RUN") == len(tables)
@@ -0,0 +1,35 @@
---
esphome:
name: test
esp32:
board: esp32dev
binary_sensor:
- platform: template
id: repeat_a
filters:
- autorepeat:
- delay: 2s
time_off: 200ms
time_on: 800ms
- delay: 5s
time_off: 100ms
time_on: 400ms
- platform: template
id: repeat_b
filters:
- autorepeat:
- delay: 2s
time_off: 200ms
time_on: 800ms
- delay: 5s
time_off: 100ms
time_on: 400ms
- platform: template
id: repeat_default
filters:
- autorepeat:
# A user id equal to the table's preferred name must not break codegen.
- platform: template
id: binary_sensor_autorepeat_timings