Skip to content

File timing.hpp

File List > espos_flow > include > espos_flow > transforms > timing.hpp

Go to the documentation of this file

// SPDX-FileCopyrightText: 2026 Dirk Wahrheit
// SPDX-License-Identifier: Apache-2.0
//
// espos::flow — the time-shaped transforms: repeat, expire, delay, run hours,
// timestamp.
//
// These are about WHEN a value exists rather than what it is, and the pair at
// the top — Repeat and Expire — are the two halves of the same problem, which
// SensESP answered with five classes between them.
//
// The problem: a Signal K server ages a value out if nothing refreshes it, so
// a channel that genuinely only changes twice a day disappears from the
// display. But a value that is repeated forever is a lie — a depth still being
// republished ten minutes after the sounder died reads as a working sounder.
//
// So: `Repeat` keeps a slow channel alive, with a mode that says when to STOP
// (`stop_after`), and `Expire` turns a stale channel into an explicit "no
// value" rather than a stale one. Between them a display can tell "steady" from
// "dead", which is the whole point.
#pragma once

#include <cstdint>
#include <cstring>
#include <optional>
#include <string>

#include "espos_flow/node.hpp"
#include "espos_flow/transforms/param.hpp"

// espos_time is optional the way espos_config is: a firmware without it still
// compiles every transform, and IsoTime reports that it has no clock.
#if ESPOS_FLOW_HAVE_TIME
#include "espos_time.h"
#endif

namespace espos::flow {

// ───────────────────────────────────────────────────────────────── Repeat
//
// Re-emit the last value on a timer. ONE node with a mode, replacing SensESP's
// Repeat, RepeatStopping, RepeatConstantRate and RepeatExpiring — which were
// four classes for one idea and, predictably, three of them were rarely used
// and none of them composed.
//
//   kAlways       — repeat forever, every `interval_ms`. For a channel that
//                   really is constant (a tank capacity, a device serial) and
//                   for which a stale value is still a true value.
//   kStopAfter    — repeat until the last real input is `max_age_ms` old, then
//                   go quiet and let the server age the channel out. This is
//                   the honest default for a SENSOR: it keeps a slow reading
//                   alive but stops lying when the sensor stops.
//   kConstantRate — emit at exactly `interval_ms` whether or not anything
//                   arrives, and pass real inputs straight through as well as
//                   restarting the clock. For a consumer that needs a fixed
//                   cadence — a display refresh, a fixed-rate log.
//
// In every mode a real input is passed through immediately; the timer is about
// what happens in the gaps.
enum class RepeatMode : uint8_t {
  kAlways,
  kStopAfter,
  kConstantRate,
};

template <typename T = float>
class Repeat : public Symmetric<T> {
 public:
  // `max_age_ms` is used by kStopAfter only; leave it 0 in the other modes.
  Repeat(const char* id, uint32_t interval_ms,
         RepeatMode mode = RepeatMode::kStopAfter, uint32_t max_age_ms = 0)
      : Symmetric<T>(id),
        interval_ms_(interval_ms),
        max_age_ms_(max_age_ms),
        mode_(mode) {}

  void set(const T& v) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    last_ = v;
    have_ = true;
    last_input_ms_ = espos_flow_now_ms();
    // kConstantRate keeps its own cadence: restarting the timer here would
    // make a burst of inputs stretch the interval, which is the one thing
    // "constant rate" promises not to do.
    if (mode_ != RepeatMode::kConstantRate) arm();
    this->emit(v);
  }

  // Start repeating. Separate from the constructor for the same reason Poll's
  // start() is: a node built in a static initialiser must not arm a timer
  // before the runtime exists.
  esp_err_t start() {
    if (mode_ == RepeatMode::kConstantRate) return arm();
    return ESP_OK;  // the other modes arm on their first input
  }

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

  uint32_t repeats() const { return repeats_; }
  void set_interval(uint32_t ms) { interval_ms_ = ms; }
  void set_max_age(uint32_t ms) { max_age_ms_ = ms; }

  esp_err_t register_config(const char* title = "Repeat") {
    params_.add_int("ivl", "Repeat every", "ms",
                    static_cast<int32_t>(interval_ms_), 0, 86400000);
    params_.add_int("age", "Stop after", "ms",
                    static_cast<int32_t>(max_age_ms_), 0, 86400000);
    esp_err_t err = params_.register_ns(this->id(), title, "Repeat");
    if (err != ESP_OK) return err;
    int32_t iv = static_cast<int32_t>(interval_ms_);
    int32_t ag = static_cast<int32_t>(max_age_ms_);
    params_.load_int("ivl", &iv);
    params_.load_int("age", &ag);
    if (iv > 0) interval_ms_ = static_cast<uint32_t>(iv);
    if (ag >= 0) max_age_ms_ = static_cast<uint32_t>(ag);
    return ESP_OK;
  }

 private:
  esp_err_t arm() {
    stop();
    return espos_flow_every(interval_ms_, &Repeat::tick, this, &timer_);
  }

  static void tick(void* self) {
    Repeat* r = static_cast<Repeat*>(self);
    if (!r->have_) return;  // nothing to repeat yet
    if (r->mode_ == RepeatMode::kStopAfter && r->max_age_ms_ > 0) {
      // Modular subtraction across the 49.7-day wrap.
      if ((espos_flow_now_ms() - r->last_input_ms_) > r->max_age_ms_) {
        r->stop();
        return;
      }
    }
    r->repeats_++;
    r->emit(r->last_);
  }

  uint32_t interval_ms_;
  uint32_t max_age_ms_;
  RepeatMode mode_;
  T last_{};
  bool have_ = false;
  uint32_t last_input_ms_ = 0;
  uint32_t repeats_ = 0;
  espos_flow_timer_t timer_ = ESPOS_FLOW_TIMER_NONE;
  ParamSet<2> params_;
};

// ───────────────────────────────────────────────────────────────── Expire
//
// Turn a value that has gone stale into an explicit "nothing".
//
// Out is `std::optional<T>`: a fresh input emits an engaged optional, and
// `max_age_ms` after the last input the node emits `std::nullopt` exactly once.
//
// This replaces SensESP's Nullable, which used a sentinel value to mean "no
// reading" — and a sentinel is a bug waiting to be averaged. `std::optional`
// makes the absence a different TYPE, so a consumer cannot accidentally treat
// it as a number: it has to write `if (v)`, and the compiler makes it.
//
// Downstream, an `sk::Output<std::optional<float>>` sends a Signal K null,
// which is how the spec says "this value is no longer available" — the display
// blanks instead of showing a stale depth.
template <typename T = float>
class Expire : public Transform<T, std::optional<T>> {
 public:
  Expire(const char* id, uint32_t max_age_ms)
      : Transform<T, std::optional<T>>(id), max_age_ms_(max_age_ms) {}

  void set(const T& v) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    expired_ = false;
    if (timer_ != ESPOS_FLOW_TIMER_NONE) {
      espos_flow_cancel(timer_);
      timer_ = ESPOS_FLOW_TIMER_NONE;
    }
    espos_flow_after(max_age_ms_, &Expire::fire, this, &timer_);
    this->emit(std::optional<T>(v));
  }

  bool expired() const { return expired_; }
  void set_max_age(uint32_t ms) { max_age_ms_ = ms; }

  esp_err_t register_config(const char* title = "Expiry") {
    params_.add_int("age", "Expire after", "ms",
                    static_cast<int32_t>(max_age_ms_), 0, 86400000);
    esp_err_t err = params_.register_ns(this->id(), title, "Expire");
    if (err != ESP_OK) return err;
    int32_t ag = static_cast<int32_t>(max_age_ms_);
    params_.load_int("age", &ag);
    if (ag > 0) max_age_ms_ = static_cast<uint32_t>(ag);
    return ESP_OK;
  }

 private:
  static void fire(void* self) {
    Expire* e = static_cast<Expire*>(self);
    e->timer_ = ESPOS_FLOW_TIMER_NONE;
    // Once only. Repeating the null every max_age_ms would be as chatty as
    // the stale value it replaced.
    if (e->expired_) return;
    e->expired_ = true;
    e->emit(std::nullopt);
  }

  uint32_t max_age_ms_;
  bool expired_ = false;
  espos_flow_timer_t timer_ = ESPOS_FLOW_TIMER_NONE;
  ParamSet<1> params_;
};

// ────────────────────────────────────────────────────────────────── Delay
//
// Pass a value on, `ms` later.
//
// Not in SensESP. What it is for: a start interlock (do not report the engine
// as running until the oil pressure has had two seconds to come up), a
// sequenced relay, an alarm that must be true continuously rather than
// momentarily.
//
// Depth is the number of values that may be in flight at once. Each holds a
// timer, so Depth is bounded by ESPOS_FLOW_MAX_TIMERS across the whole
// firmware; a value arriving with all slots busy is DROPPED and counted, not
// queued, because a delay line that silently grows is a memory leak with a
// schedule.
template <typename T = float, std::size_t Depth = 4>
class Delay : public Symmetric<T> {
  static_assert(Depth >= 1, "a delay needs at least one slot");

 public:
  Delay(const char* id, uint32_t ms) : Symmetric<T>(id), ms_(ms) {
    for (std::size_t i = 0; i < Depth; i++) slots_[i].owner = this;
  }

  void set(const T& v) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    for (std::size_t i = 0; i < Depth; i++) {
      if (slots_[i].busy) continue;
      slots_[i].value = v;
      slots_[i].busy = true;
      if (espos_flow_after(ms_, &Delay::fire, &slots_[i], &slots_[i].timer) !=
          ESP_OK) {
        slots_[i].busy = false;
        dropped_++;
      }
      return;
    }
    dropped_++;
  }

  uint32_t dropped() const { return dropped_; }
  void set_delay(uint32_t ms) { ms_ = ms; }

 private:
  struct Slot {
    Delay* owner = nullptr;
    T value{};
    bool busy = false;
    espos_flow_timer_t timer = ESPOS_FLOW_TIMER_NONE;
  };

  static void fire(void* self) {
    Slot* s = static_cast<Slot*>(self);
    s->timer = ESPOS_FLOW_TIMER_NONE;
    s->busy = false;
    s->owner->emit(s->value);
  }

  uint32_t ms_;
  Slot slots_[Depth];
  uint32_t dropped_ = 0;
};

// ─────────────────────────────────────────────────────────────── RunHours
//
// Accumulate the time an input is TRUE. SensESP's TimeCounter.
//
// This is engine hours, generator hours, watermaker hours, autopilot hours —
// the numbers a service schedule is written against, and the ones an owner is
// asked for when the boat is sold. Which is why two things about it matter
// more than the arithmetic:
//
//   * It is STORED IN SECONDS and DISPLAYED IN HOURS. The SI unit is the
//     second, so that is what goes in NVS and into a Signal K delta; the UI
//     multiplies by 1/3600 (`displayMultiplier`, docs/config.md) so the config
//     page reads "412.5 h" and a user types hours. Storing hours would mean a
//     float with three digits before the point and a resolution of seconds
//     nowhere in sight after a few thousand hours.
//   * It PERSISTS, and the total is READ-ONLY in the UI. An engine-hours
//     counter that resets on a flat battery is worse than no counter, because
//     it is believed. `persist()` writes to NVS and, crucially, is NOT called
//     on every tick — flash has a finite erase budget and a per-second write
//     would exhaust it in a season. Call it every few minutes from a Ticker.
//
// The total is read-only in config because a user who can type a new engine
// age can destroy the one record of it. Correcting it after a rebuild is a
// deliberate `set_total()` from code, not a text box.
class RunHours : public Transform<bool, float> {
 public:
  // `emit_interval_ms` is how often the running total is emitted while the
  // input is true; the total itself is exact regardless, computed from the
  // clock rather than by counting ticks.
  RunHours(const char* id, uint32_t emit_interval_ms = 60000)
      : Transform<bool, float>(id), emit_ms_(emit_interval_ms) {}

  void set(const bool& running) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    const uint32_t now = espos_flow_now_ms();
    if (running && !running_) {
      running_ = true;
      since_ms_ = now;
      espos_flow_every(emit_ms_, &RunHours::tick, this, &timer_);
    } else if (!running && running_) {
      accumulate(now);
      running_ = false;
      if (timer_ != ESPOS_FLOW_TIMER_NONE) {
        espos_flow_cancel(timer_);
        timer_ = ESPOS_FLOW_TIMER_NONE;
      }
    }
    this->emit(total_s());
  }

  // Total run time in SECONDS, including the current run if any.
  float total_s() const {
    if (!running_) return total_s_;
    return total_s_ +
           static_cast<float>(espos_flow_now_ms() - since_ms_) / 1000.0f;
  }
  // The same in hours, for a caller that wants the human number without going
  // through the config layer's display multiplier.
  float total_h() const { return total_s() / 3600.0f; }

  bool running() const { return running_; }

  // Correct the total — after an engine rebuild, or when adopting a counter
  // from an existing hour meter. Deliberately a code call: the config key is
  // read-only precisely so this cannot happen by accident from a web page.
  void set_total_s(float seconds) {
    total_s_ = seconds;
    if (running_) since_ms_ = espos_flow_now_ms();
  }

  // Register the total on a config page: read-only, seconds stored, hours
  // shown. 1/3600 is the displayMultiplier from docs/config.md.
  esp_err_t register_config(const char* title = "Run hours") {
    params_.add_float_ro("total", "Total", "s", total_s_, 1.0f / 3600.0f);
    esp_err_t err = params_.register_ns(this->id(), title, "Run hours");
    if (err != ESP_OK) return err;
    float t = total_s_;
    params_.load_float("total", &t);
    total_s_ = t;
    return ESP_OK;
  }

  // Write the total to NVS. Call from a slow Ticker (minutes), never per tick:
  // see the note at the top about flash wear.
  esp_err_t persist() {
    if (running_) accumulate(espos_flow_now_ms());
    return params_.store_float("total", total_s_);
  }

 private:
  void accumulate(uint32_t now) {
    // Modular subtraction: correct across the flow clock's 49.7-day wrap,
    // which a genset on a liveaboard will absolutely cross.
    total_s_ += static_cast<float>(now - since_ms_) / 1000.0f;
    since_ms_ = now;
  }

  static void tick(void* self) {
    RunHours* r = static_cast<RunHours*>(self);
    r->accumulate(espos_flow_now_ms());
    r->emit(r->total_s_);
  }

  uint32_t emit_ms_;
  float total_s_ = 0.0f;
  uint32_t since_ms_ = 0;
  bool running_ = false;
  espos_flow_timer_t timer_ = ESPOS_FLOW_TIMER_NONE;
  ParamSet<1> params_;
};

// ──────────────────────────────────────────────────────────────── IsoTime
//
// Emit the current wall-clock time as an ISO 8601 string, on every input.
//
// SensESP had a TimeString; this one reads espos_time, which is the device's
// ONE clock (docs/time.md) — SNTP, or a manual set, or the Signal K stream, or
// an RTC value carried through a deep sleep, ranked so a coarse source never
// overrides a good one.
//
// While the clock is unsynced it emits nothing at all. That is the important
// behaviour: espos_time returns "" rather than a plausible-looking 1970, and
// publishing 1970-01-01 as a timestamp is how an hour of data ends up in the
// wrong place in a log. No clock means no timestamp, visibly.
class IsoTime : public Transform<bool, std::string> {
 public:
  explicit IsoTime(const char* id) : Transform<bool, std::string>(id) {}

  void set(const bool&) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    emit_now();
  }

  // Emit the current time without needing an input, for a Ticker-driven chain.
  void emit_now() {
#if ESPOS_FLOW_HAVE_TIME
    char buf[ESPOS_TIME_ISO_MAX];
    if (espos_time_iso8601(buf, sizeof(buf)) == 0) return;  // unsynced
    this->emit(std::string(buf));
#endif
  }

  // Whether the device has a clock at all. A caller can publish this as its
  // own channel, which is more useful than it sounds: "the timestamps you are
  // seeing are made up" is worth knowing.
  static bool has_clock() {
#if ESPOS_FLOW_HAVE_TIME
    return espos_time_is_synced();
#else
    return false;
#endif
  }
};

// ─────────────────────────────────────────────────────── ParseBool / Format
//
// Between a bool and the strings a human or a Signal K PUT uses.
//
// ParseBool accepts what people and servers actually send: "true"/"false",
// "on"/"off", "1"/"0", "yes"/"no", in any case. It emits nothing for anything
// else — an unrecognised string is not "false", it is a message that was not
// understood, and treating it as false is how a switch panel turns everything
// off when a typo arrives.
class ParseBool : public Transform<std::string, bool> {
 public:
  explicit ParseBool(const char* id) : Transform<std::string, bool>(id) {}

  void set(const std::string& s) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    std::optional<bool> v = parse(s);
    if (v.has_value()) this->emit(*v);
  }

  static std::optional<bool> parse(const std::string& s) {
    char buf[8] = {};
    std::size_t n = s.size() < sizeof(buf) - 1 ? s.size() : sizeof(buf) - 1;
    for (std::size_t i = 0; i < n; i++) {
      char c = s[i];
      buf[i] = (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
    }
    if (std::strcmp(buf, "true") == 0 || std::strcmp(buf, "on") == 0 ||
        std::strcmp(buf, "1") == 0 || std::strcmp(buf, "yes") == 0) {
      return true;
    }
    if (std::strcmp(buf, "false") == 0 || std::strcmp(buf, "off") == 0 ||
        std::strcmp(buf, "0") == 0 || std::strcmp(buf, "no") == 0) {
      return false;
    }
    return std::nullopt;
  }
};

// The other direction, with the two words configurable: a display wants
// "Running"/"Stopped", a Signal K string path wants "on"/"off", and a log
// wants "true"/"false".
class FormatBool : public Transform<bool, std::string> {
 public:
  FormatBool(const char* id, const char* true_text = "true",
             const char* false_text = "false")
      : Transform<bool, std::string>(id), t_(true_text), f_(false_text) {}

  void set(const bool& v) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    this->emit(std::string(v ? t_ : f_));
  }

 private:
  const char* t_;
  const char* f_;
};

}  // namespace espos::flow