Skip to content

File sensors.hpp

File List > espos_sensors > include > espos_sensors > sensors.hpp

Go to the documentation of this file

// SPDX-FileCopyrightText: 2026 Dirk Wahrheit
// SPDX-License-Identifier: Apache-2.0
//
// espos::sensors — the hardware nodes: what a graph reads the world with.
//
//   Analog        an ADC pin, in volts            (SensESP's AnalogInput)
//   GpioState     a pin, sampled                  (DigitalInputState)
//   GpioChange    a pin, on every edge, via ISR   (DigitalInputChange)
//   GpioCounter   edges per second, ISR-counted   (DigitalInputCounter)
//   PulseCounter  edges per second, in PCNT hardware
//   GpioOutput    a relay or an LED               (a Consumer<bool>)
//   Pwm           a duty cycle                    (a Consumer<float>)
//   Ds18b20Node   a 1-Wire temperature, in kelvin
//   system::*     uptime, free heap, reset reason
//
// Every one is a node in the espos::flow graph: it emits on the flow task
// and nothing else, which is the single threading rule the whole runtime
// rests on. The two that CANNOT obey it directly -- GpioChange and
// GpioCounter, whose events arrive in an interrupt -- own a Mailbox and post
// into it, so the emit still happens on the flow task. That is the pattern to
// copy for any driver whose data arrives from somewhere else.
//
// Pins are constructor parameters. Never a #define, never a per-board
// header: a sensor library that hard-codes GPIO 4 is one that cannot be used
// on the second board. Sensible per-target pins are in the comments and in
// docs/sensors.md.
//
// Compiles with -fno-exceptions -fno-rtti. Header-only: a node that is not
// used is not compiled, so a firmware that only reads an ADC does not link
// the I2C driver.
#pragma once

#include <cstdint>
#include <optional>

#include "espos_adc.h"
#include "espos_flow/flow.hpp"
#include "espos_gpio_in.h"
#include "espos_pcnt.h"
#include "espos_pwm.h"

namespace espos::sensors {

using espos::flow::Consumer;
using espos::flow::Mailbox;
using espos::flow::NodeBase;
using espos::flow::Producer;

// ────────────────────────────────────────────────────────────────── Analog
//
// One ADC pin, read every period, emitted in VOLTS.
//
// Volts and not raw counts, because a count is meaningless without the
// attenuation and width that produced it, and because the calibration a user
// types into the web UI ("multiply by 1.7 for this divider") is only a stable
// number if what it multiplies is a voltage. Chain a Linear (or a Lambda)
// after this to get the physical quantity.
//
//   Analog tank(g, "tank", 4, 1000);   // GPIO 4, once a second
//   tank >> level >> sk::Output<float>("tanks.freshWater.0.currentLevel");
//
// A read that fails (ADC2 while the radio is busy) emits nothing rather than
// a zero: std::nullopt is how this graph says "no reading", and a fabricated
// zero on a tank level is a false empty-tank alarm.
class Analog : public NodeBase, public Producer<float> {
 public:
  // `samples` readings are averaged per emit; the SAR ADC is noisy by a few
  // LSB and 16 costs microseconds.
  Analog(const char* id, int gpio, uint32_t period_ms,
         espos_adc_atten_t atten = ESPOS_ADC_ATTEN_12DB, uint8_t samples = 16)
      : NodeBase(id), period_ms_(period_ms) {
    espos_adc_cfg_t cfg = {};
    cfg.gpio = gpio;
    cfg.atten = atten;
    cfg.samples = samples;
    open_err_ = espos_adc_open(&cfg, &adc_);
  }

  // Arm the timer. Separate from the constructor so a node built in a static
  // initialiser does not start a timer before the runtime exists.
  esp_err_t start() {
    if (open_err_ != ESP_OK) return open_err_;
    if (timer_ != ESPOS_FLOW_TIMER_NONE) return ESP_OK;
    return espos_flow_every(period_ms_, &Analog::tick, this, &timer_);
  }

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

  // Read once, now, without waiting for the timer.
  std::optional<float> read_now() {
    if (open_err_ != ESP_OK) return std::nullopt;
    float v = 0.0f;
    if (espos_adc_read_volts(adc_, &v) != ESP_OK) return std::nullopt;
    return v;
  }

  // Whether eFuse calibration is in use. Worth logging once: a device that
  // is quietly uncalibrated looks exactly like one whose divider is wrong.
  bool calibrated() const { return espos_adc_calibrated(adc_); }
  esp_err_t open_error() const { return open_err_; }

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

 private:
  static void tick(void* self) {
    Analog* a = static_cast<Analog*>(self);
    if (std::optional<float> v = a->read_now(); v.has_value()) a->emit(*v);
  }

  espos_adc_handle_t adc_ = nullptr;
  esp_err_t open_err_ = ESP_OK;
  uint32_t period_ms_;
  espos_flow_timer_t timer_ = ESPOS_FLOW_TIMER_NONE;
};

// ─────────────────────────────────────────────────────────────── GpioState
//
// A pin, sampled every period, emitted as bool. Debounced by counting: a
// level has to hold for `stable_ticks` samples before it counts, which is
// what a mechanical contact needs and what an interrupt cannot give you
// without a timer anyway.
//
//   GpioState bilge("bilge", 5, 50, 4);   // GPIO 5, 50 ms, 200 ms steady
//
// `invert` belongs in the constructor because nearly every real switch is
// active-low: the internal pull-up holds the pin high and the switch pulls it
// down, so "closed" is a 0.
class GpioState : public NodeBase, public Producer<bool> {
 public:
  GpioState(const char* id, int gpio, uint32_t period_ms = 100,
            uint8_t stable_ticks = 1, bool invert = true,
            espos_gpio_pull_t pull = ESPOS_GPIO_PULL_UP)
      : NodeBase(id),
        gpio_(gpio),
        period_ms_(period_ms),
        need_(stable_ticks ? stable_ticks : 1) {
    open_err_ = espos_gpio_in_open(gpio, pull, invert);
  }

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

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

  bool level() const { return espos_gpio_in_level(gpio_); }
  esp_err_t open_error() const { return open_err_; }

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

 private:
  // Emits on CHANGE, plus the very first sample. A switch that has not moved
  // is not news, and a graph that re-emits it every 100 ms turns into a
  // Signal K delta every 100 ms.
  static void tick(void* self) {
    GpioState* s = static_cast<GpioState*>(self);
    bool now = espos_gpio_in_level(s->gpio_);
    if (now != s->candidate_) {
      s->candidate_ = now;
      s->steady_ = 0;
      return;
    }
    if (s->steady_ < s->need_ && ++s->steady_ == s->need_) {
      if (!s->has_value() || now != s->get()) s->emit(now);
    }
  }

  int gpio_;
  uint32_t period_ms_;
  uint8_t need_;
  uint8_t steady_ = 0;
  bool candidate_ = false;
  esp_err_t open_err_ = ESP_OK;
  espos_flow_timer_t timer_ = ESPOS_FLOW_TIMER_NONE;
};

// ────────────────────────────────────────────────────────────── GpioChange
//
// Every edge, as it happens, through a Mailbox.
//
// This is the node to read if you are writing a driver of your own: the ISR
// does one thing, post the value, and the Mailbox delivers it on the flow
// task where emitting is legal. Nothing else in an interrupt is safe.
//
// Depth 8 because a bouncing contact produces a burst, and dropping the LAST
// edge of a burst is the one that matters -- it is the level the switch
// settled at.
template <std::size_t Depth = 8>
class GpioChange : public NodeBase, public Producer<bool> {
 public:
  GpioChange(const char* id, int gpio,
             espos_gpio_edge_t edge = ESPOS_GPIO_EDGE_ANY, bool invert = true,
             espos_gpio_pull_t pull = ESPOS_GPIO_PULL_UP)
      : NodeBase(id), box_(id) {
    open_err_ = espos_gpio_in_open(gpio, pull, invert);
    if (open_err_ == ESP_OK) {
      // The mailbox is the only thing the ISR touches, and `this` outlives
      // the watch: the node is owned by the graph or by a struct that lives
      // forever (see graph.hpp on why nothing is ever deleted).
      open_err_ = espos_gpio_in_watch(gpio, edge, &GpioChange::on_edge, this);
    }
  }

  // Wire downstream nodes to the mailbox, which is what actually emits.
  Producer<bool>& out() { return box_; }
  uint32_t dropped() const { return box_.dropped(); }
  esp_err_t open_error() const { return open_err_; }

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

 private:
  // Interrupt context: post and return. No logging, no allocation, nothing
  // that blocks.
  static void on_edge(int gpio, bool level, void* arg) {
    (void)gpio;
    GpioChange* c = static_cast<GpioChange*>(arg);
    bool woken = false;
    c->box_.post_from_isr(level, &woken);
    // The flow port yields when the post woke a higher-priority task.
    (void)woken;
  }

  Mailbox<bool, Depth> box_;
  esp_err_t open_err_ = ESP_OK;
};

// ───────────────────────────────────────────────────────── PulseCounter
//
// Edges per second, over the PCNT peripheral where the chip has one and a
// GPIO ISR where it does not (the ESP32-C3). Same node either way --
// espos_pcnt.h is what hides the difference, and espos_pcnt_supported() says
// which you got.
//
// The rate is computed from the MEASURED interval, not the nominal period:
// dividing by the period assumes the poll was on time, and the first poll
// after a reconnect or a flash write is exactly when it was not.
//
//   PulseCounter rpm("rpm", 21, 1000);          // Hz on the pin
//   rpm >> divide_by_ppr >> sk::Output<float>("propulsion.main.revolutions");
class PulseCounter : public NodeBase, public Producer<float> {
 public:
  PulseCounter(const char* id, int gpio, uint32_t period_ms = 1000,
               uint32_t max_glitch_ns = 1000, bool pull_up = true,
               bool falling = false)
      : NodeBase(id), period_ms_(period_ms) {
    espos_pcnt_cfg_t cfg = {};
    cfg.gpio = gpio;
    cfg.max_glitch_ns = max_glitch_ns;
    cfg.pull_up = pull_up;
    cfg.falling = falling;
    open_err_ = espos_pcnt_open(&cfg, &pcnt_);
  }

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

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

  // Total edges since boot, for an hour-meter or a chain counter. Restore a
  // saved total with set_total() after loading it from the config.
  uint64_t total() const { return espos_pcnt_total(pcnt_); }
  void set_total(uint64_t t) { espos_pcnt_set_total(pcnt_, t); }

  // True when the PCNT peripheral is doing the counting.
  static bool hardware() { return espos_pcnt_supported(); }
  esp_err_t open_error() const { return open_err_; }

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

 private:
  static void tick(void* self) {
    PulseCounter* p = static_cast<PulseCounter*>(self);
    uint32_t count = 0;
    uint64_t us = 0;
    if (espos_pcnt_take(p->pcnt_, &count, &us) != ESP_OK) return;
    // A zero interval would divide by zero; it only happens if two ticks
    // land in the same microsecond, which the timer wheel does not do.
    if (us == 0) return;
    p->emit(static_cast<float>(count) * 1e6f / static_cast<float>(us));
  }

  espos_pcnt_handle_t pcnt_ = nullptr;
  esp_err_t open_err_ = ESP_OK;
  uint32_t period_ms_;
  espos_flow_timer_t timer_ = ESPOS_FLOW_TIMER_NONE;
};

// GpioCounter is the same node with the hardware counter declined: on a chip
// WITH a PCNT unit you may still want the ISR path (the units are a finite
// resource -- four on most chips -- and a slow reed switch does not need
// one). Same output, same units.
using GpioCounter = PulseCounter;

// ────────────────────────────────────────────────────────── GpioOutput
//
// A relay, a lamp, a solenoid: a Consumer<bool> at the end of a chain.
//
// This is half of a switch that a phone can operate -- the other half is
// sk::PutHandler, which turns an inbound Signal K PUT into the bool this
// consumes. See docs/sensors.md for the six lines that make a SmartSwitch.
class GpioOutput : public NodeBase,
                   public Consumer<bool>,
                   public Producer<bool> {
 public:
  GpioOutput(const char* id, int gpio, bool invert = false,
             bool initial = false)
      : NodeBase(id), gpio_(gpio) {
    open_err_ = espos_gpio_out_open(gpio, invert, initial);
  }

  using consumes_type = bool;

  // Setting emits too, so the graph can publish what the output actually
  // did rather than what something asked for. They differ when the pin
  // failed to open, and that is exactly when a dashboard must not lie.
  void set(const bool& on) override {
    if (open_err_ != ESP_OK) return;
    espos_gpio_out_set(gpio_, on);
    this->emit(on);
  }

  bool state() const { return espos_gpio_out_get(gpio_); }
  esp_err_t open_error() const { return open_err_; }

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

 private:
  int gpio_;
  esp_err_t open_err_ = ESP_OK;
};

// ───────────────────────────────────────────────────────────────────── Pwm
//
// A duty cycle 0..1 on a pin: a dimmed cabin light, an analog gauge, a fan.
class Pwm : public NodeBase, public Consumer<float>, public Producer<float> {
 public:
  Pwm(const char* id, int gpio, uint32_t freq_hz = 5000, bool invert = false)
      : NodeBase(id) {
    espos_pwm_cfg_t cfg = {};
    cfg.gpio = gpio;
    cfg.freq_hz = freq_hz;
    cfg.invert = invert;
    open_err_ = espos_pwm_open(&cfg, &channel_);
  }

  using consumes_type = float;

  void set(const float& duty) override {
    if (open_err_ != ESP_OK) return;
    espos_pwm_set(channel_, duty);
    this->emit(
        espos_pwm_get(channel_));  // the clamped value, not the asked-for one
  }

  esp_err_t open_error() const { return open_err_; }

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

 private:
  int channel_ = -1;
  esp_err_t open_err_ = ESP_OK;
};

}  // namespace espos::sensors