Skip to content

File node.hpp

File List > espos_flow > include > espos_flow > node.hpp

Go to the documentation of this file

// SPDX-FileCopyrightText: 2026 Dirk Wahrheit
// SPDX-License-Identifier: Apache-2.0
//
// espos::flow — nodes and edges: the typed graph over espos_flow's C loop.
//
// This is SensESP's producer/consumer model with the four things that hurt in
// practice taken out:
//
//   * No `new` in user code and no ownership trap. A node is a member of your
//     struct, or lives in the Graph's intrusive list via make<T>(). SensESP's
//     ConfigItem(T*) took a pointer whose lifetime nobody owned (#893); here
//     there is nothing to hand over.
//   * No std::function and no shared_ptr per edge. An edge is a function
//     pointer, a sink pointer and a next pointer, taken from a static pool —
//     twelve bytes, no allocation, and it cannot fail at runtime because it
//     fails at wiring time instead.
//   * No per-type duplication of the bookkeeping. Everything that does not
//     depend on T (id, title, list link, wiring) lives in NodeBase, which is
//     compiled once; a new value type duplicates emit() and set() and nothing
//     else (SensESP #339, where the templates were most of the flash).
//   * One threading rule instead of five. Everything runs on the flow task.
//     See espos_flow.h; CONFIG_ESPOS_FLOW_CHECK_TASK enforces it.
//
// Implicit numeric conversion happens inside the edge trampoline, so wiring a
// producer of float into a consumer of int32_t needs no adapter node — the
// trampoline is instantiated for the (From, To) pair and does the cast.
//
// Compiles with -fno-exceptions -fno-rtti. No iostreams, no std::format, no
// allocation after start-up.
#pragma once

#include <cstddef>
#include <cstdint>
#include <cstring>
#include <string_view>
#include <type_traits>
#include <utility>

#include "espos_flow.h"

namespace espos::flow {

// A node id is short and stable: it is what a log line, the config UI and a
// wiring error name. 12 characters plus NUL, stored inline — no allocation and
// no dangling pointer to a caller's temporary.
inline constexpr std::size_t kIdMax = 12;

// ─────────────────────────────────────────────────────────────────── edges
//
// The trampoline is `void (*)(void* sink, const void* value)`: type-erased so
// the pool is one array rather than one per T, and re-typed by the template
// that installed it. Nothing else may write these.
struct Edge {
  void (*fn)(void* sink, const void* value);
  void* sink;
  Edge* next;
};

// Static pool. Exhaustion is loud (a log line naming the producer, then an
// abort) rather than a dropped connection: a graph missing an edge looks
// exactly like a sensor that stopped working, and finding that months later
// costs more than failing at boot.
Edge* edge_alloc();
std::size_t edge_used();
std::size_t edge_capacity();
[[noreturn]] void edge_exhausted(const char* node_id);

// Called by emit() under CONFIG_ESPOS_FLOW_CHECK_TASK. Out of line so the
// check is one call, not an inlined log statement per instantiation.
void assert_on_loop_task(const char* node_id);

#if CONFIG_ESPOS_FLOW_CHECK_TASK
#define ESPOS_FLOW_ASSERT_TASK(id) ::espos::flow::assert_on_loop_task(id)
#else
#define ESPOS_FLOW_ASSERT_TASK(id) ((void)0)
#endif

// ──────────────────────────────────────────────────────────────── NodeBase
//
// Everything about a node that does not depend on its value type. Compiled
// once for the whole firmware.
class NodeBase {
 public:
  explicit NodeBase(const char* id = "") { set_id(id); }

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

  // Not virtual: a node is never deleted through a base pointer. The graph
  // owns its nodes for the life of the firmware, which is the only lifetime
  // an embedded data-flow graph has ever needed, and a vtable per node type
  // is flash spent on a destructor that never runs.
  ~NodeBase() = default;

  const char* id() const { return id_; }
  const char* title() const { return title_; }

  // Chainable, so a node can be described where it is created:
  //   auto& t = g.make<Poll<float>>("temp", 1000, read).with_title("Coolant");
  NodeBase& with_title(const char* t) {
    title_ = t ? t : "";
    return *this;
  }

  // Intrusive list link, walked by Graph. Public because Graph is not a
  // friend of every node type and an accessor pair would be the same thing
  // with more code.
  NodeBase* next_node = nullptr;

 protected:
  void set_id(const char* id) {
    if (!id) id = "";
    std::size_t n = 0;
    while (n < kIdMax && id[n] != '\0') n++;
    std::memcpy(id_, id, n);
    id_[n] = '\0';
  }

 private:
  char id_[kIdMax + 1] = {};
  const char* title_ = "";
};

// ──────────────────────────────────────────────────────────────── Consumer
//
// Anything that can be handed a T. `set()` runs on the flow task.
template <typename T>
class Consumer {
 public:
  virtual void set(const T& value) = 0;

 protected:
  // Protected and non-virtual: consumers are never deleted polymorphically
  // (see NodeBase), and this keeps the class free of a deleting destructor
  // in every vtable.
  ~Consumer() = default;
};

// ──────────────────────────────────────────────────────────────── Producer
//
// Anything that emits a T. Holds the head of its edge list, its last value,
// and whether it has ever produced one — the distinction between "0.0" and
// "nothing yet" that a Join's age rule and an SK publish both need.
template <typename T>
class Producer {
 public:
  // The last value emitted. Undefined-but-safe (a value-initialised T)
  // before the first emit; check has_value() when that matters.
  const T& get() const { return value_; }
  bool has_value() const { return has_value_; }

  // When it was emitted, on espos_flow_now_ms()'s modular clock. 0 before
  // the first emit.
  uint32_t emitted_at_ms() const { return at_ms_; }

  // Push a value to every connected consumer, in connection order.
  //
  // Runs on the flow task; under CONFIG_ESPOS_FLOW_CHECK_TASK a call from
  // anywhere else aborts naming the node. Re-entrant emits are fine (a
  // consumer may emit onward, which is what a chain IS); a cycle is the
  // caller's problem and shows up as a stack overflow, not as a hang.
  void emit(const T& value) {
    value_ = value;
    has_value_ = true;
    at_ms_ = espos_flow_now_ms();
    for (Edge* e = edges_; e; e = e->next) e->fn(e->sink, &value_);
  }

  // Wire this producer to a consumer of U. T must be convertible to U;
  // the conversion happens in the trampoline, so float → int32_t needs no
  // adapter node.
  //
  // Returns the SINK, so chains read left to right in the order the data
  // moves: a.connect_to(b).connect_to(c). SensESP returns the same thing
  // for the same reason.
  template <typename Sink>
  Sink& connect_to(Sink& sink) {
    using U = typename Sink::consumes_type;
    static_assert(std::is_convertible_v<T, U>,
                  "connect_to: this producer's value type does not convert to "
                  "the consumer's");

    Edge* e = edge_alloc();
    if (!e) edge_exhausted(node_id_for_error());

    e->sink = static_cast<Consumer<U>*>(&sink);
    // One trampoline per (T, U) pair, not per node: the compiler folds
    // every float→float edge in the firmware onto the same function.
    e->fn = [](void* s, const void* v) {
      static_cast<Consumer<U>*>(s)->set(
          static_cast<U>(*static_cast<const T*>(v)));
    };
    e->next = nullptr;

    // Append rather than prepend: consumers are served in the order they
    // were connected, which is the order the wiring reads in. A reversed
    // fan-out is the kind of thing that is only noticed when two sinks
    // disagree about which value was newest.
    if (!edges_) {
      edges_ = e;
    } else {
      Edge* tail = edges_;
      while (tail->next) tail = tail->next;
      tail->next = e;
    }
    return sink;
  }

  // Sugar for the same thing: a >> b >> c. Returns the sink like
  // connect_to(), so the chain composes.
  template <typename Sink>
  Sink& operator>>(Sink& sink) {
    return connect_to(sink);
  }

  // How many consumers this producer feeds. For tests and diagnostics.
  std::size_t edge_count() const {
    std::size_t n = 0;
    for (const Edge* e = edges_; e; e = e->next) n++;
    return n;
  }

  using produces_type = T;

 protected:
  ~Producer() = default;

  // Nodes that are also NodeBase override this so a wiring failure names
  // them. A bare Producer has no id to give.
  virtual const char* node_id_for_error() const { return "?"; }

 private:
  Edge* edges_ = nullptr;
  T value_{};
  bool has_value_ = false;
  uint32_t at_ms_ = 0;
};

// A node that is both: the shape of every transform.
template <typename In, typename Out>
class Transform : public NodeBase, public Consumer<In>, public Producer<Out> {
 public:
  explicit Transform(const char* id) : NodeBase(id) {}
  using consumes_type = In;

 protected:
  ~Transform() = default;
  const char* node_id_for_error() const override { return id(); }
};

// In == Out. Most transforms are this: a calibration, a filter, a limiter.
template <typename T>
using Symmetric = Transform<T, T>;

// ─────────────────────────────────────────────────────────────────── Value
//
// SensESP's ObservableValue and ValueProducer collapsed into one type: a
// producer whose value anything may set. Setting emits.
//
// This is the node to reach for when something outside the graph — a config
// change, a REST handler, another component — has a number the graph should
// see, and the setting already happens on the flow task. When it does not,
// wrap it in a Mailbox.
template <typename T>
class Value : public NodeBase, public Producer<T>, public Consumer<T> {
 public:
  explicit Value(const char* id, const T& initial = T{}) : NodeBase(id) {
    if constexpr (!std::is_same_v<T, void>) initial_ = initial;
  }

  using consumes_type = T;

  // Set and emit. Also the Consumer entry point, so a Value can sit in the
  // middle of a chain as a named, inspectable tap.
  void set(const T& v) override {
    ESPOS_FLOW_ASSERT_TASK(id());
    this->emit(v);
  }

  // The value the node was constructed with, before anything set it. Kept
  // so a node can be reset to it without the caller remembering.
  const T& initial() const { return initial_; }

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

 private:
  T initial_{};
};

}  // namespace espos::flow