Skip to content

File nodes.hpp

File List > espos_flow > include > espos_flow > nodes.hpp

Go to the documentation of this file

// SPDX-FileCopyrightText: 2026 Dirk Wahrheit
// SPDX-License-Identifier: Apache-2.0
//
// espos::flow — the node library: the pieces every sensor program is made of.
//
//   Poll<T>      read something every N ms          (SensESP's RepeatSensor)
//   Ticker       fire every N ms, no value          (onRepeat)
//   Constant<T>  a value emitted once at start
//   Lambda<...>  arbitrary arithmetic, variadic     (SensESP #903)
//   Sink<T>      the end of a chain
//   Join<Ts...>  the ONE multi-input node           (SensESP #899)
//   Mailbox<T>   the way in from another task or an ISR
//
// Every callable here is stored BY VALUE, not in a std::function: a captured
// lambda lives inside the node, there is no allocation, and nothing to
// dangle. That is why Lambda takes its callable as a template parameter.
//
// SensESP #571 — "a transform that sometimes has nothing to say" — is a
// callable returning std::optional<Out>: engaged emits, disengaged does not.
// No sentinel values, no separate "valid" output.
#pragma once

#include <atomic>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <tuple>
#include <type_traits>
#include <utility>

#include "espos_flow.h"
#include "espos_flow/node.hpp"

namespace espos::flow {

// ──────────────────────────────────────────────────────────────────── Poll
//
// Call `fn` every `period_ms` and emit what it returns. This is the idiom
// every SensESP add-on library is built on (RepeatSensor<float>(500, read)),
// and the reason it is first in this file.
//
// `fn` may return T (always emits) or std::optional<T> (emits only when
// engaged) — a sensor that is still warming up, or a read that failed, simply
// produces nothing rather than a fabricated zero.
//
// The timer is armed by start(), which the Graph does not call for you: a
// node constructed in a static initialiser must not start a timer before the
// runtime exists. Call start() from your wiring function, or let
// Graph::start() do it — see Poll::autostart below.
template <typename T, typename Fn>
class Poll : public NodeBase, public Producer<T> {
 public:
  Poll(const char* id, uint32_t period_ms, Fn fn)
      : NodeBase(id), fn_(std::move(fn)), period_ms_(period_ms) {}

  // Arm the timer. Idempotent; ESP_ERR_NO_MEM when the timer table is full,
  // which is a loud log line from espos_flow rather than a silent no-op.
  esp_err_t start() {
    if (timer_ != ESPOS_FLOW_TIMER_NONE) return ESP_OK;
    return espos_flow_every(period_ms_, &Poll::tick, this, &timer_);
  }

  // Stop polling. The node keeps its last value; start() resumes.
  void stop() {
    if (timer_ != ESPOS_FLOW_TIMER_NONE) {
      espos_flow_cancel(timer_);
      timer_ = ESPOS_FLOW_TIMER_NONE;
    }
  }

  // Read once, now, on the calling task. What start() does on every tick;
  // useful to prime a chain before the first period elapses.
  void read_now() { tick(this); }

  uint32_t period_ms() const { return period_ms_; }

  // Change the period. Takes effect on the next tick after a restart, which
  // is what a config change wants: `poll.set_period(cfg.period_ms)`.
  void set_period(uint32_t ms) {
    if (ms == 0 || ms == period_ms_) return;
    period_ms_ = ms;
    if (timer_ != ESPOS_FLOW_TIMER_NONE) {
      stop();
      start();
    }
  }

 protected:
  const char* node_id_for_error() const override { return id(); }

 private:
  static void tick(void* self) {
    Poll* p = static_cast<Poll*>(self);
    ESPOS_FLOW_ASSERT_TASK(p->id());
    // Both shapes in one node: `-> T` and `-> std::optional<T>`. The
    // branch is a compile-time one, so the optional costs nothing when
    // it is not used.
    using R = decltype(p->fn_());
    if constexpr (std::is_same_v<R, std::optional<T>>) {
      if (std::optional<T> v = p->fn_(); v.has_value()) p->emit(*v);
    } else {
      p->emit(static_cast<T>(p->fn_()));
    }
  }

  Fn fn_;
  uint32_t period_ms_;
  espos_flow_timer_t timer_ = ESPOS_FLOW_TIMER_NONE;
};

// Deduction so `Poll{"depth", 1000, read}` works without naming Fn. The value
// type comes from what the callable returns, with std::optional unwrapped.
template <typename Fn>
Poll(const char*, uint32_t, Fn)
    -> Poll<std::remove_cvref_t<decltype(std::declval<Fn&>()())>, Fn>;

// ────────────────────────────────────────────────────────────────── Ticker
//
// A clock with no value: emits its own tick count every period. For a chain
// that wants a heartbeat rather than a reading — a keepalive, a display
// refresh, a "still alive" publish.
class Ticker : public NodeBase, public Producer<uint32_t> {
 public:
  Ticker(const char* id, uint32_t period_ms)
      : NodeBase(id), period_ms_(period_ms) {}

  esp_err_t start() {
    if (timer_ != ESPOS_FLOW_TIMER_NONE) return ESP_OK;
    return espos_flow_every(period_ms_, &Ticker::tick, this, &timer_);
  }

  void stop() {
    if (timer_ != ESPOS_FLOW_TIMER_NONE) {
      espos_flow_cancel(timer_);
      timer_ = ESPOS_FLOW_TIMER_NONE;
    }
  }

  uint32_t count() const { return count_; }

 protected:
  const char* node_id_for_error() const override { return id(); }

 private:
  static void tick(void* self) {
    Ticker* t = static_cast<Ticker*>(self);
    t->emit(++t->count_);
  }

  uint32_t period_ms_;
  uint32_t count_ = 0;
  espos_flow_timer_t timer_ = ESPOS_FLOW_TIMER_NONE;
};

// ──────────────────────────────────────────────────────────────── Constant
//
// A value that never changes, emitted once when emit_now() is called (or by
// Graph wiring at start). For a calibration constant that feeds a Join, or a
// unit conversion factor a Lambda multiplies by.
template <typename T>
class Constant : public NodeBase, public Producer<T> {
 public:
  Constant(const char* id, const T& v) : NodeBase(id), v_(v) {}

  void emit_now() { this->emit(v_); }
  const T& value() const { return v_; }

 protected:
  const char* node_id_for_error() const override { return id(); }

 private:
  T v_;
};

// ──────────────────────────────────────────────────────────────────── Sink
//
// The end of a chain: a consumer that calls your function and produces
// nothing. `sk::Output<T>` will be one of these; so is "set a GPIO", "draw a
// number", "log it".
template <typename T, typename Fn>
class Sink : public NodeBase, public Consumer<T> {
 public:
  Sink(const char* id, Fn fn) : NodeBase(id), fn_(std::move(fn)) {}

  using consumes_type = T;

  void set(const T& v) override {
    ESPOS_FLOW_ASSERT_TASK(id());
    fn_(v);
  }

 private:
  Fn fn_;
};

// ────────────────────────────────────────────────────────────────── Lambda
//
// The general transform: N inputs, one output, arbitrary arithmetic, plus
// named parameters the config layer can drive.
//
// SensESP #903 asked for a variadic LambdaTransform because the fixed 0..3
// parameter overloads ran out and each shape was a separate class. Here the
// parameters are a std::tuple<Ps...> stored in the node, exposed by index, and
// the callable is invoked as fn(input, params...).
//
// Returning std::optional<Out> emits only when engaged (SensESP #571): the
// idiomatic way to write "ignore readings outside the plausible range" without
// inventing a magic value that a downstream node has to know about.
//
//   auto& cal = g.make<Lambda<float, float, float, float>>(
//       "cal", [](float v, float m, float b) { return v * m + b; }, 1.7f,
//       -0.16f);
//   cal.param<0>() = new_multiplier;   // from a config change
template <typename In, typename Out, typename Fn, typename... Ps>
class Lambda : public Transform<In, Out> {
 public:
  Lambda(const char* id, Fn fn, Ps... params)
      : Transform<In, Out>(id),
        fn_(std::move(fn)),
        params_(std::move(params)...) {}

  void set(const In& v) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    apply(v, std::index_sequence_for<Ps...>{});
  }

  // Live parameters. Writing one does not emit — the next input does, with
  // the new value. That is deliberate: a config change should not fabricate
  // a reading out of a stale input.
  template <std::size_t I>
  auto& param() {
    return std::get<I>(params_);
  }
  template <std::size_t I>
  const auto& param() const {
    return std::get<I>(params_);
  }

  static constexpr std::size_t param_count = sizeof...(Ps);

 private:
  template <std::size_t... I>
  void apply(const In& v, std::index_sequence<I...>) {
    using R = decltype(fn_(v, std::get<I>(params_)...));
    if constexpr (std::is_same_v<R, std::optional<Out>>) {
      if (std::optional<Out> r = fn_(v, std::get<I>(params_)...); r.has_value())
        this->emit(*r);
    } else {
      this->emit(static_cast<Out>(fn_(v, std::get<I>(params_)...)));
    }
  }

  Fn fn_;
  std::tuple<Ps...> params_;
};

// ──────────────────────────────────────────────────────────────────── Join
//
// The ONE multi-input node. SensESP #899 observed that Join, Zip and
// connect_from were three answers to one question, each with its own age and
// completeness rules; this is the answer.
//
// Join<float, bool>("j", 2000, Policy::kAll) has two typed slots, in<0>() and
// in<1>(), and emits std::tuple<float, bool>:
//
//   * Policy::kAll  — emit when every slot has a value AND every value is
//     younger than max_age_ms. This is "combine a depth and a heading into one
//     message" and it must not pair a fresh depth with a heading from a minute
//     ago.
//   * Policy::kAny  — emit on every input, carrying whatever the other slots
//     last held, provided they are all still within max_age_ms. This is
//     "publish the whole state whenever any part of it changes".
//
// max_age_ms of 0 disables the age rule entirely: values never go stale, which
// is what you want for something that genuinely changes once a day.
enum class Policy : uint8_t {
  kAny,  // emit on any input, if the others are present and fresh
  kAll,  // emit only when every slot has been filled since the last emit
};

template <typename... Ts>
class Join : public NodeBase, public Producer<std::tuple<Ts...>> {
  static_assert(sizeof...(Ts) >= 2,
                "Join needs at least two inputs; a single input needs no join");

 public:
  Join(const char* id, uint32_t max_age_ms, Policy policy)
      : NodeBase(id), max_age_ms_(max_age_ms), policy_(policy) {
    init_slots(std::index_sequence_for<Ts...>{});
  }

  // One typed slot per input. `producer.connect_to(join.in<0>())`.
  template <std::size_t I>
  auto& in() {
    return std::get<I>(slots_);
  }

  static constexpr std::size_t arity = sizeof...(Ts);

  // How many slots currently hold a value at all (regardless of age).
  std::size_t filled() const {
    return count_filled(std::index_sequence_for<Ts...>{});
  }

 protected:
  const char* node_id_for_error() const override { return id(); }

 private:
  // A slot is a Consumer<T> that records the value, its arrival time and,
  // for kAll, whether it has been fed since the last emit.
  template <std::size_t I, typename T>
  class Slot : public Consumer<T> {
   public:
    using consumes_type = T;

    void set(const T& v) override {
      value = v;
      present = true;
      fresh_for_all = true;
      at_ms = espos_flow_now_ms();
      if (owner) owner->on_input();
    }

    T value{};
    bool present = false;
    bool fresh_for_all = false;
    uint32_t at_ms = 0;
    Join* owner = nullptr;
  };

  template <std::size_t... I>
  void init_slots(std::index_sequence<I...>) {
    ((std::get<I>(slots_).owner = this), ...);
  }

  template <std::size_t... I>
  std::size_t count_filled(std::index_sequence<I...>) const {
    return (static_cast<std::size_t>(std::get<I>(slots_).present) + ...);
  }

  // Fresh means "arrived within max_age_ms of now", on the modular clock:
  // subtract, never compare. 0 disables the rule.
  template <std::size_t I>
  bool slot_fresh(uint32_t now) const {
    const auto& sl = std::get<I>(slots_);
    if (!sl.present) return false;
    if (max_age_ms_ == 0) return true;
    return (now - sl.at_ms) <= max_age_ms_;
  }

  template <std::size_t... I>
  bool all_fresh(uint32_t now, std::index_sequence<I...>) const {
    return (slot_fresh<I>(now) && ...);
  }

  template <std::size_t... I>
  bool all_fed(std::index_sequence<I...>) const {
    return (std::get<I>(slots_).fresh_for_all && ...);
  }

  template <std::size_t... I>
  void clear_fed(std::index_sequence<I...>) {
    ((std::get<I>(slots_).fresh_for_all = false), ...);
  }

  template <std::size_t... I>
  std::tuple<Ts...> gather(std::index_sequence<I...>) const {
    return std::tuple<Ts...>(std::get<I>(slots_).value...);
  }

  void on_input() {
    using Idx = std::index_sequence_for<Ts...>;
    uint32_t now = espos_flow_now_ms();
    if (!all_fresh(now, Idx{})) return;

    if (policy_ == Policy::kAll) {
      if (!all_fed(Idx{})) return;
      // Every slot must be fed again before the next emit: kAll is
      // "one output per complete set of inputs", not "one output per
      // input once the set is complete".
      clear_fed(Idx{});
    }
    this->emit(gather(Idx{}));
  }

  // Index the slot types so each Slot is a distinct class even when two
  // inputs have the same T — two Consumer<float> base classes in one object
  // would otherwise be ambiguous.
  template <typename Seq>
  struct SlotsFor;
  template <std::size_t... I>
  struct SlotsFor<std::index_sequence<I...>> {
    using type = std::tuple<Slot<I, Ts>...>;
  };

  typename SlotsFor<std::index_sequence_for<Ts...>>::type slots_;
  uint32_t max_age_ms_;
  Policy policy_;
};

// ───────────────────────────────────────────────────────────────── Mailbox
//
// The only supported way into a graph from another task or an interrupt.
//
// post() copies the value into the node and asks the flow loop to emit it;
// the emit happens on the flow task, so everything downstream obeys the one
// threading rule without knowing where the value came from.
//
// The value is held in a small ring, not a single slot: two posts arriving
// before the loop runs must both be delivered, and an ISR that posts twice in
// a burst is the ordinary case, not the exception. Depth is a template
// parameter because the right depth is a property of the source (a button
// wants 2, a pulse counter wants 16), not of the firmware.
template <typename T, std::size_t Depth = 4>
class Mailbox : public NodeBase, public Producer<T> {
  static_assert(Depth >= 1, "a Mailbox needs at least one slot");

 public:
  explicit Mailbox(const char* id) : NodeBase(id) {}

  // A post hands the loop a callback holding `this`, and the loop may not
  // have run it yet when the mailbox dies -- a Mailbox with block scope, or
  // one owned by a node that is torn down. Running that callback afterwards
  // reads a destroyed object and emits into a destroyed producer.
  //
  // There is no way to withdraw a queued post (the flow mailbox is a plain
  // FreeRTOS queue of {callback, argument} and cancelling by argument would
  // have to walk it), so the object marks itself dead instead and deliver()
  // becomes a no-op. The flag is atomic because the loop runs on another
  // task, and release/acquire so that a delivery which observes `live_` as
  // true also sees the ring contents that were published before it.
  //
  // This only makes the stale delivery harmless. A Mailbox still must not
  // outlive its graph, and the usual arrangement -- Graph::make<T>(), owned
  // for the firmware's lifetime -- never reaches this path at all.
  ~Mailbox() { live_.store(false, std::memory_order_release); }

  Mailbox(const Mailbox&) = delete;
  Mailbox& operator=(const Mailbox&) = delete;

  // From any task. Never blocks. Returns ESP_ERR_NO_MEM when the ring is
  // full (the loop is behind) or the flow mailbox is, and the value is
  // dropped — see espos_flow.h on why dropping beats blocking.
  esp_err_t post(const T& v) {
    uint32_t seq;
    if (!claim(&seq)) return ESP_ERR_NO_MEM;
    ring_[seq % Depth] = v;
    commit(seq);
    esp_err_t err = espos_flow_post(&Mailbox::deliver, this);
    /* A refused flow post leaves the value in the ring; the next accepted
     * post drains it, since deliver() takes one entry per call and the
     * ring is FIFO. Nothing is lost that the ring still holds. */
    return err;
  }

  // From an interrupt. `hp_task_woken` is forwarded to
  // espos_flow_post_from_isr(); yield from the ISR when it comes back true.
  esp_err_t post_from_isr(const T& v, bool* hp_task_woken = nullptr) {
    uint32_t seq;
    if (!claim(&seq)) return ESP_ERR_NO_MEM;
    ring_[seq % Depth] = v;
    commit(seq);
    return espos_flow_post_from_isr(&Mailbox::deliver, this, hp_task_woken);
  }

  // Posts refused because the ring was full. A growing number means the
  // producer is faster than the loop.
  uint32_t dropped() const { return dropped_; }

 protected:
  const char* node_id_for_error() const override { return id(); }

 private:
  // Three monotonically increasing sequence numbers, never decremented, so
  // a reservation is never handed back and a concurrent claim can never be
  // confused by one. `claimed_` reserves, `ready_` publishes, `read_`
  // consumes; a slot is only visible to the loop once ready_ passes it.
  //
  // This is the one place in the C++ layer that needs atomics: post() is
  // callable from any task and post_from_isr() from an interrupt, where a
  // mutex is not an option.
  bool claim(uint32_t* seq) {
    uint32_t c = claimed_.load(std::memory_order_relaxed);
    for (;;) {
      if (c - read_.load(std::memory_order_acquire) >= Depth) {
        dropped_++;
        return false;
      }
      if (claimed_.compare_exchange_weak(c, c + 1, std::memory_order_acq_rel,
                                         std::memory_order_relaxed)) {
        *seq = c;
        return true;
      }
    }
  }

  // Publish slot `seq`. Writers finish out of order under contention, so a
  // writer waits for its predecessor before advancing the ready mark —
  // otherwise the loop could read a slot whose value is still being
  // written. Depth is small and posts are rare; the spin is bounded by the
  // number of writers actually in flight.
  void commit(uint32_t seq) {
    uint32_t expected = seq;
    while (!ready_.compare_exchange_weak(expected, seq + 1,
                                         std::memory_order_acq_rel,
                                         std::memory_order_relaxed)) {
      expected = seq;
    }
  }

  static void deliver(void* self) {
    Mailbox* m = static_cast<Mailbox*>(self);
    // The mailbox was destroyed after this delivery was queued: there is
    // nothing left to emit into, and the object under `self` is gone.
    if (!m->live_.load(std::memory_order_acquire)) return;
    uint32_t r = m->read_.load(std::memory_order_relaxed);
    // Nothing published yet: a post whose flow-post was accepted before a
    // slower writer committed. The value is not lost — the next delivery
    // takes it.
    if (r == m->ready_.load(std::memory_order_acquire)) return;
    T v = m->ring_[r % Depth];
    m->read_.store(r + 1, std::memory_order_release);
    m->emit(v);
  }

  T ring_[Depth] = {};
  std::atomic<uint32_t> claimed_{0};
  std::atomic<uint32_t> ready_{0};
  std::atomic<uint32_t> read_{0};
  uint32_t dropped_ = 0;
  std::atomic<bool> live_{true};
};

}  // namespace espos::flow