Skip to content

File graph.hpp

File List > espos_flow > include > espos_flow > graph.hpp

Go to the documentation of this file

// SPDX-FileCopyrightText: 2026 Dirk Wahrheit
// SPDX-License-Identifier: Apache-2.0
//
// espos::flow::Graph — who owns the nodes.
//
// Two ownership styles, both without `new` in user code and both without
// SensESP's ConfigItem(T*) trap (#893), where a raw pointer was handed to a
// registry that neither owned it nor outlived it:
//
//   1. `g.make<Poll<float>>("temp", 1000, read)` — the Graph holds the node
//      for the life of the firmware and hands back a reference. This is the
//      wiring-function style, and the one the tutorials use.
//
//   2. A node as a member of your own struct, wired in its constructor. The
//      struct owns it; the Graph never sees it. This is the driver style: a
//      `struct Bme280 { Poll<float> temp; ... }` that registers itself.
//
// Both are safe because nothing in the graph ever deletes a node, and an edge
// is a plain pointer into memory that outlives the loop.
//
// Storage for make<T>() is a static arena, not the heap: the size of a graph
// is decided at build time like every other espOS table, and a firmware that
// makes no nodes pays no arena — the arena is only linked when make<T>() is
// instantiated.
#pragma once

#include <cstddef>
#include <cstdint>
#include <new>
#include <type_traits>
#include <utility>

#include "esp_err.h"

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

namespace espos::flow {

// Bytes handed out by Graph::make<T>(). Not a Kconfig option because it is a
// linker-visible arena whose size only matters if make<T>() is used at all;
// a firmware that needs more overrides it at the compiler command line.
#ifndef ESPOS_FLOW_ARENA_BYTES
#define ESPOS_FLOW_ARENA_BYTES 2048
#endif

// Raw storage for make<T>(). Out of line so it is only linked into a firmware
// that actually calls make<T>().
void* graph_arena_alloc(std::size_t bytes, std::size_t align);
std::size_t graph_arena_used();
std::size_t graph_arena_capacity();
[[noreturn]] void graph_arena_exhausted(std::size_t wanted);

class Graph {
 public:
  Graph() = default;
  Graph(const Graph&) = delete;
  Graph& operator=(const Graph&) = delete;

  // Construct a node in the graph's arena and keep it forever. The
  // reference is stable: nothing moves or frees it.
  //
  //   auto& poll = g.make<Poll<float>>("depth", 1000, read_depth);
  //   auto& lin  = g.make<Linear<float>>("cal", 1.7f, -0.16f);
  //   poll >> lin >> g.make<Sink<float>>("out", publish);
  template <typename T, typename... Args>
  T& make(Args&&... args) {
    void* mem = graph_arena_alloc(sizeof(T), alignof(T));
    if (!mem) graph_arena_exhausted(sizeof(T));
    T* node = new (mem) T(std::forward<Args>(args)...);
    adopt(*node);
    return *node;
  }

  // Put an externally owned node on the graph's list, so it appears in
  // for_each() and in a future config page. The node must outlive the
  // graph; a member of a struct that lives forever does.
  void adopt(NodeBase& n) {
    n.next_node = nullptr;
    if (!head_) {
      head_ = &n;
    } else {
      tail_->next_node = &n;
    }
    tail_ = &n;
    count_++;
  }

  // Start the flow loop. Nothing runs before this: a node constructed and
  // wired is inert until the loop exists, which is what lets a whole graph
  // be built in static initialisers before app_main() decides to run it.
  esp_err_t start() { return espos_flow_start(); }

  std::size_t size() const { return count_; }
  NodeBase* first() const { return head_; }

  // Walk every adopted node. `fn` is any callable taking NodeBase& — a
  // template rather than std::function, which is not allowed here.
  template <typename Fn>
  void for_each(Fn&& fn) const {
    for (NodeBase* n = head_; n; n = n->next_node) fn(*n);
  }

 private:
  NodeBase* head_ = nullptr;
  NodeBase* tail_ = nullptr;
  std::size_t count_ = 0;
};

esp_err_t api_register(const Graph* graph);

}  // namespace espos::flow