File filters.hpp¶
File List > espos_flow > include > espos_flow > transforms > filters.hpp
Go to the documentation of this file
// SPDX-FileCopyrightText: 2026 Dirk Wahrheit
// SPDX-License-Identifier: Apache-2.0
//
// espos::flow — the gates: what gets through, and how often.
//
// Half of a marine graph is deciding NOT to send something. A boat's link to
// its server is a WiFi association that comes and goes, the server writes
// every delta to disk, and a 20 Hz temperature channel that changes in the
// fourth decimal is a megabyte a day of nothing. These nodes are how a chain
// says "only when it matters".
//
// Four different questions, four nodes, because conflating them is what makes
// SensESP's users reach for a Lambda:
//
// ChangeFilter — did the value change ENOUGH? (about the value)
// Throttle — has enough TIME passed? (about the clock)
// Debounce — has it been STABLE long enough? (about settling)
// Filter — is it plausible at all? (about validity)
#pragma once
#include <cmath>
#include <cstdint>
#include <optional>
#include <type_traits>
#include "espos_flow/node.hpp"
#include "espos_flow/transforms/param.hpp"
namespace espos::flow {
// ──────────────────────────────────────────────────────────── ChangeFilter
//
// Pass a value only when it differs from the last PASSED value by at least
// `min_delta` — with two escape hatches SensESP got right and that are easy to
// get wrong when writing this by hand.
//
// * `max_delta` — a change LARGER than this is dropped too. That is the
// glitch filter: a depth that jumps 40 m between two 1 Hz samples did not
// happen, and passing it means the plotter draws a spike and the shallow
// alarm fires in open water. 0 disables the upper bound.
// * `max_skips` — after this many consecutive drops, pass the value anyway.
// Without it, a channel that is genuinely steady stops publishing
// entirely, the server ages the value out, and the display goes blank on a
// boat where nothing is wrong. This is the setting people discover by
// losing their tank levels overnight.
//
// The first value always passes: there is nothing to compare it against, and
// a chain that starts silent is a chain that looks broken.
template <typename T = float>
class ChangeFilter : public Symmetric<T> {
public:
ChangeFilter(const char* id, T min_delta, T max_delta = T{0},
uint32_t max_skips = 0)
: Symmetric<T>(id),
min_delta_(min_delta),
max_delta_(max_delta),
max_skips_(max_skips) {}
void set(const T& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
if (!have_) {
pass(v);
return;
}
const T d = static_cast<T>(
std::fabs(static_cast<double>(v) - static_cast<double>(last_)));
const bool too_small = d < min_delta_;
const bool too_large = max_delta_ > T{0} && d > max_delta_;
if (!too_small && !too_large) {
pass(v);
return;
}
skips_++;
// The keepalive applies to the "did not change enough" case only. A value
// that was rejected as a GLITCH must never be forced through by the skip
// counter: that would publish the one reading the filter exists to stop,
// just later. SensESP counted both together; this is a deliberate
// difference and the reason max_skips is safe to turn on.
if (too_small && max_skips_ > 0 && skips_ >= max_skips_) pass(v);
}
uint32_t skipped() const { return skips_; }
void set_min_delta(T d) { min_delta_ = d; }
void set_max_delta(T d) { max_delta_ = d; }
void set_max_skips(uint32_t n) { max_skips_ = n; }
esp_err_t register_config(const char* title = "Change filter",
const char* unit = "") {
params_.add_float("min", "Minimum change", unit,
static_cast<float>(min_delta_));
params_.add_float("max", "Maximum change (0 = off)", unit,
static_cast<float>(max_delta_));
params_.add_int("skips", "Send anyway after N skips", "",
static_cast<int32_t>(max_skips_), 0, 100000);
esp_err_t err = params_.register_ns(this->id(), title, "Change filter");
if (err != ESP_OK) return err;
float mn = static_cast<float>(min_delta_);
float mx = static_cast<float>(max_delta_);
int32_t sk = static_cast<int32_t>(max_skips_);
params_.load_float("min", &mn);
params_.load_float("max", &mx);
params_.load_int("skips", &sk);
min_delta_ = static_cast<T>(mn);
max_delta_ = static_cast<T>(mx);
max_skips_ = static_cast<uint32_t>(sk < 0 ? 0 : sk);
return ESP_OK;
}
private:
void pass(const T& v) {
last_ = v;
have_ = true;
skips_ = 0;
this->emit(v);
}
T last_{};
T min_delta_;
T max_delta_;
uint32_t max_skips_;
uint32_t skips_ = 0;
bool have_ = false;
ParamSet<3> params_;
};
// ─────────────────────────────────────────────────────────────── Throttle
//
// Pass at most one value per `min_interval_ms`, dropping the rest.
//
// The rate limiter, and the right tool when the input rate is the problem
// rather than the input's stability: an N2K bus delivering position at 10 Hz
// into a server that wants 1 Hz. It keeps the LATEST value in each interval
// and discards the earlier ones — a stale value passed on schedule would be
// worse than a fresh one passed a little late.
//
// Note this drops rather than buffers. A chain that must not lose values wants
// a Mailbox and a slower consumer, not a Throttle.
template <typename T = float>
class Throttle : public Symmetric<T> {
public:
Throttle(const char* id, uint32_t min_interval_ms)
: Symmetric<T>(id), interval_ms_(min_interval_ms) {}
void set(const T& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
const uint32_t now = espos_flow_now_ms();
// Modular subtraction, correct across the 49.7-day wrap of the flow clock.
if (have_ && (now - last_ms_) < interval_ms_) {
dropped_++;
return;
}
last_ms_ = now;
have_ = true;
this->emit(v);
}
uint32_t dropped() const { return dropped_; }
void set_interval(uint32_t ms) { interval_ms_ = ms; }
private:
uint32_t interval_ms_;
uint32_t last_ms_ = 0;
uint32_t dropped_ = 0;
bool have_ = false;
};
// ─────────────────────────────────────────────────────────────── Debounce
//
// Emit only after the value has been UNCHANGED for `stable_ms`.
//
// SensESP's DebounceTemplate. The classic case is a mechanical switch, whose
// contacts chatter for a few milliseconds and which would otherwise register
// as a dozen presses; the marine case is a float switch in a bilge, which does
// exactly the same thing on every wave.
//
// It works by DEFERRING: a value arriving schedules an emit `stable_ms` later,
// and a different value arriving before then cancels and reschedules. So the
// output always lags the input by at least stable_ms, and a value that never
// settles is never emitted at all. Both are the point.
//
// Bounded by the timer table (ESPOS_FLOW_MAX_TIMERS): each Debounce holds at
// most one pending timer, taken when a change arrives and released when it
// fires.
template <typename T = bool>
class Debounce : public Symmetric<T> {
public:
Debounce(const char* id, uint32_t stable_ms)
: Symmetric<T>(id), stable_ms_(stable_ms) {}
void set(const T& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
// Same value as the one already pending: leave the timer alone. Restarting
// it would mean a switch held closed while something else chatters never
// settles.
if (pending_ && v == candidate_) return;
candidate_ = v;
if (timer_ != ESPOS_FLOW_TIMER_NONE) {
espos_flow_cancel(timer_);
timer_ = ESPOS_FLOW_TIMER_NONE;
}
pending_ = true;
espos_flow_after(stable_ms_, &Debounce::fire, this, &timer_);
}
void set_stable_ms(uint32_t ms) { stable_ms_ = ms; }
uint32_t stable_ms() const { return stable_ms_; }
esp_err_t register_config(const char* title = "Debounce") {
params_.add_int("ms", "Stable for", "ms", static_cast<int32_t>(stable_ms_),
0, 60000);
esp_err_t err = params_.register_ns(this->id(), title, "Debounce");
if (err != ESP_OK) return err;
int32_t ms = static_cast<int32_t>(stable_ms_);
params_.load_int("ms", &ms);
if (ms >= 0) stable_ms_ = static_cast<uint32_t>(ms);
return ESP_OK;
}
private:
static void fire(void* self) {
Debounce* d = static_cast<Debounce*>(self);
d->timer_ = ESPOS_FLOW_TIMER_NONE;
d->pending_ = false;
// Only emit on an actual change of the settled value: a switch that
// bounces and comes back to where it was is not an event.
if (d->have_ && d->settled_ == d->candidate_) return;
d->settled_ = d->candidate_;
d->have_ = true;
d->emit(d->settled_);
}
uint32_t stable_ms_;
T candidate_{};
T settled_{};
bool pending_ = false;
bool have_ = false;
espos_flow_timer_t timer_ = ESPOS_FLOW_TIMER_NONE;
ParamSet<1> params_;
};
// ───────────────────────────────────────────────────────────────── Filter
//
// Pass a value only when `pred(value)` is true. The general escape hatch, and
// the honest way to say "this reading is not real": a GPS speed of 300 kn, a
// temperature of -200 C, a depth of zero from a sounder that lost bottom.
//
// auto& plausible = g.make<Filter<float, bool(*)(float)>>(
// "plaus", [](float d) { return d > 0.1f && d < 200.0f; });
//
// Dropping beats clamping for data: a clamped 200 m depth is indistinguishable
// from a real one downstream, whereas a dropped reading simply does not appear
// and the server ages the channel out — which is the truth.
template <typename T, typename Pred>
class Filter : public Symmetric<T> {
public:
Filter(const char* id, Pred pred)
: Symmetric<T>(id), pred_(std::move(pred)) {}
void set(const T& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
if (pred_(v)) {
this->emit(v);
} else {
rejected_++;
}
}
uint32_t rejected() const { return rejected_; }
private:
Pred pred_;
uint32_t rejected_ = 0;
};
// ───────────────────────────────────────────────────────────────── Enable
//
// A gate with a switch: pass values while enabled, drop them while not.
//
// SensESP's Enable. The switch is set from outside — a config key, a REST
// call, another chain — via `enable()` / `disable()`, or by wiring a bool
// producer into `control()`.
//
// sw.output >> tank.gate.control(); // publish tank level only under way
//
// Unlike Filter the decision does not depend on the value, which is why the
// two are separate: one is about the data, the other about the situation.
template <typename T = float>
class Enable : public Symmetric<T> {
public:
Enable(const char* id, bool enabled = true)
: Symmetric<T>(id), enabled_(enabled) {}
void set(const T& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
if (enabled_) this->emit(v);
}
void enable() { enabled_ = true; }
void disable() { enabled_ = false; }
void set_enabled(bool e) { enabled_ = e; }
bool enabled() const { return enabled_; }
// A Consumer<bool> that drives the gate, so the switch can be a chain of its
// own rather than a call from somewhere. Returned by reference and lives in
// the node, so wiring it is safe for the node's lifetime.
class Control : public Consumer<bool> {
public:
using consumes_type = bool;
void set(const bool& on) override {
if (owner) owner->enabled_ = on;
}
Enable* owner = nullptr;
};
Control& control() {
control_.owner = this;
return control_;
}
private:
bool enabled_;
Control control_;
};
// ────────────────────────────────────────────────────────────── Threshold
//
// Is the value inside (or outside) a range? In to bool, out as an alarm.
//
// SensESP's FloatThreshold / IntThreshold, generalised. `in_range` picks the
// polarity: true emits "the value is between min and max", false emits "it is
// outside them", which is the shape an alarm wants — engine temperature
// outside 60..95 C is the condition, not inside.
//
// Bounds are inclusive. It emits on EVERY input, not only on a change; put a
// ChangeFilter after it if the consumer is a publisher. That is deliberate:
// an alarm consumer usually wants to know the state is still true.
template <typename T = float>
class Threshold : public Transform<T, bool> {
public:
Threshold(const char* id, T min, T max, bool in_range = true)
: Transform<T, bool>(id), min_(min), max_(max), in_range_(in_range) {}
void set(const T& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
const bool inside = (v >= min_ && v <= max_);
this->emit(in_range_ ? inside : !inside);
}
void set_limits(T min, T max) {
min_ = min;
max_ = max;
}
T min() const { return min_; }
T max() const { return max_; }
esp_err_t register_config(const char* title = "Threshold",
const char* unit = "", float display_mul = 0.0f) {
params_.add_float("min", "Minimum", unit, static_cast<float>(min_), "",
display_mul);
params_.add_float("max", "Maximum", unit, static_cast<float>(max_), "",
display_mul);
esp_err_t err = params_.register_ns(this->id(), title, "Threshold");
if (err != ESP_OK) return err;
float mn = static_cast<float>(min_);
float mx = static_cast<float>(max_);
params_.load_float("min", &mn);
params_.load_float("max", &mx);
min_ = static_cast<T>(mn);
max_ = static_cast<T>(mx);
return ESP_OK;
}
private:
T min_;
T max_;
bool in_range_;
ParamSet<2> params_;
};
// ───────────────────────────────────────────────────────────── Hysteresis
//
// Two thresholds and a memory: the node that keeps a relay from chattering.
//
// SensESP's Hysteresis. The state goes HIGH when the input rises above
// `upper`, and LOW again only when it falls below `lower` — not when it drops
// back under `upper`. The gap between the two is the whole point.
//
// A bilge pump wired through a single threshold at 50 mm runs in bursts of a
// tenth of a second as the water sloshes around the switch point, which wears
// the pump out and flattens the battery. With `lower` at 20 mm and `upper` at
// 60 mm it runs once, properly, and stops.
//
// The same applies to a fridge compressor, an engine-temperature alarm, and a
// low-battery cutout. If you are switching anything physical, this node rather
// than Threshold.
//
// Out is any type, so it can emit a bool, an int for a display code, or a
// string state. Before the first input it emits nothing: there is no state
// until something has been measured.
template <typename In = float, typename Out = bool>
class Hysteresis : public Transform<In, Out> {
public:
Hysteresis(const char* id, In lower, In upper, Out low_value, Out high_value)
: Transform<In, Out>(id),
lower_(lower),
upper_(upper),
low_(low_value),
high_(high_value) {}
void set(const In& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
bool next = state_;
if (v >= upper_) {
next = true;
} else if (v <= lower_) {
next = false;
} else if (!have_) {
// First value, inside the dead band: there is no previous state to
// hold, and guessing wrong would switch something on. Assume LOW —
// the safe side for a pump, a heater and a cutout alike.
next = false;
}
const bool changed = !have_ || next != state_;
state_ = next;
have_ = true;
// Emits only on a change of state. Hysteresis exists to reduce switching,
// so re-announcing the same state on every sample would defeat it; a
// consumer that needs the current state can read get().
if (changed) this->emit(state_ ? high_ : low_);
}
bool state() const { return state_; }
void set_limits(In lower, In upper) {
lower_ = lower;
upper_ = upper;
}
esp_err_t register_config(const char* title = "Hysteresis",
const char* unit = "", float display_mul = 0.0f) {
params_.add_float("lower", "Switch off below", unit,
static_cast<float>(lower_), "", display_mul);
params_.add_float("upper", "Switch on above", unit,
static_cast<float>(upper_), "", display_mul);
esp_err_t err = params_.register_ns(this->id(), title, "Hysteresis");
if (err != ESP_OK) return err;
float lo = static_cast<float>(lower_);
float hi = static_cast<float>(upper_);
params_.load_float("lower", &lo);
params_.load_float("upper", &hi);
lower_ = static_cast<In>(lo);
upper_ = static_cast<In>(hi);
return ESP_OK;
}
private:
In lower_;
In upper_;
Out low_;
Out high_;
bool state_ = false;
bool have_ = false;
ParamSet<2> params_;
};
// ──────────────────────────────────────────────────────────────── Deadband
//
// Pass values, but hold the output steady while the input stays within
// `width` of the last output.
//
// Not in SensESP, and different from ChangeFilter in a way worth the second
// node: ChangeFilter DROPS a small change, so nothing is emitted at all;
// Deadband emits the OLD value, so the output keeps flowing at full rate but
// stops jittering.
//
// That distinction is the difference between a display that goes stale and a
// display that sits still. Use ChangeFilter before a publisher (save
// bandwidth); use Deadband before a gauge, a relay or a helm display (stop the
// last digit flickering).
template <typename T = float>
class Deadband : public Symmetric<T> {
public:
Deadband(const char* id, T width) : Symmetric<T>(id), width_(width) {}
void set(const T& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
if (!have_) {
held_ = v;
have_ = true;
this->emit(held_);
return;
}
const double d =
std::fabs(static_cast<double>(v) - static_cast<double>(held_));
if (d > static_cast<double>(width_)) held_ = v;
this->emit(held_);
}
void set_width(T w) { width_ = w; }
T width() const { return width_; }
private:
T width_;
T held_{};
bool have_ = false;
};
// ────────────────────────────────────────────────────────────────── Latch
//
// Once the input is true, stay true until reset.
//
// Not in SensESP. What it is for: an alarm that must not clear itself. A high
// bilge level that lasted two seconds at 03:00 and then drained is exactly the
// event you need to know about in the morning, and a plain Threshold forgets
// it the moment the water goes down.
//
// `reset()` clears it, which is what an acknowledge button does.
class Latch : public Symmetric<bool> {
public:
Latch(const char* id, bool latch_on = true)
: Symmetric<bool>(id), latch_on_(latch_on) {}
void set(const bool& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
if (v == latch_on_) latched_ = true;
this->emit(latched_ ? latch_on_ : v);
}
bool latched() const { return latched_; }
// Clear the latch and emit the released state, so an acknowledge is visible
// downstream immediately rather than at the next reading.
void reset() {
latched_ = false;
this->emit(!latch_on_);
}
private:
bool latch_on_;
bool latched_ = false;
};
} // namespace espos::flow