Skip to content

File tank.hpp

File List > espos_devices > include > espos_devices > tank.hpp

Go to the documentation of this file

// SPDX-FileCopyrightText: 2026 Dirk Wahrheit
// SPDX-License-Identifier: Apache-2.0
//
// TankLevel — a resistive tank sender on a Signal K path.
//
// This is the shape SensESP asked for in #264 and never had: a device class
// that owns its nodes, so a firmware says what the thing IS rather than how
// it is wired.
//
//   espos::devices::TankLevel fresh(g, {
//       .id = "fresh", .gpio = 4,
//       .path = "tanks.freshWater.0.currentLevel",
//       .empty_ohms = 190.0f, .full_ohms = 3.0f,
//       .divider_ohms = 1000.0f,
//   });
//   fresh.start();
//
// Against the same device wired by hand that is four nodes and five edges,
// and the wiring is the part a firmware gets wrong -- an ADC that emits
// volts into a transform expecting ohms is a tank that reads plausibly and
// is wrong all season.
//
// What it does NOT hide: every node is still reachable (`analog()`,
// `level()`), because a device class that cannot be taken apart is a worse
// deal than the wiring it replaced. Add a filter, retitle a parameter, wire
// a second consumer -- all of it stays possible.
#pragma once

#include "espos_devices/detail.hpp"
#include "espos_flow/graph.hpp"
#include "espos_flow/transforms.hpp"
#include "espos_sensors/sensors.hpp"
#include "espos_sk_flow/sk.hpp"

namespace espos::devices
{

struct TankLevelConfig {
    const char *id = "tank";
    int gpio = -1;
    // A path from the Signal K specification, so no metadata is sent: the
    // server already knows currentLevel is a ratio. A path of your own needs
    // sk::Output's Meta overload instead, which this deliberately does not
    // take -- see the note in espos_sk_flow/sk.hpp.
    const char *path = "tanks.freshWater.0.currentLevel";

    // The sender's resistance at the two ends of its travel. Marine senders
    // are commonly 240-33 (US) or 10-180 (European), and plenty are reversed:
    // empty > full is normal, not a mistake.
    float empty_ohms = 190.0f;
    float full_ohms = 3.0f;

    // The fixed leg of the divider the sender sits in, and the voltage across
    // the pair. Both are properties of the board, not of the tank.
    float divider_ohms = 1000.0f;
    float supply_volts = 3.3f;

    uint32_t period_ms = 2000;  // a tank does not move quickly
    uint8_t samples = 16;
};

class TankLevel
{
  public:
    TankLevel(espos::flow::Graph &g, const TankLevelConfig &cfg)
        : analog_(g.make<espos::sensors::Analog>(cfg.id, cfg.gpio, cfg.period_ms,
                                                 ESPOS_ADC_ATTEN_12DB,
                                                 cfg.samples)),
          ohms_(g.make<espos::flow::DividerR2>(
              detail::SubId(cfg.id, 'r'), cfg.supply_volts, cfg.divider_ohms)),
          level_(g.make<espos::flow::TankLevel>(detail::SubId(cfg.id, 'l'),
                                                cfg.empty_ohms, cfg.full_ohms)),
          out_(g.make<espos::sk::Output<float>>(cfg.path))
    {
        // volts -> ohms -> ratio -> the server. The chain is the whole point:
        // each step is a unit conversion someone would otherwise fold into a
        // magic constant that nobody can check later.
        analog_ >> ohms_ >> level_ >> out_;
    }

    // Arm the poll. Separate from construction for the reason every node in
    // this tree is: a device built in a static initialiser must not start a
    // timer before the flow runtime exists.
    esp_err_t start() { return analog_.start(); }
    void stop() { analog_.stop(); }

    // Put the sender's two calibration points on a config page, so a tank can
    // be calibrated with a full tank and an empty one rather than a rebuild.
    esp_err_t register_config(const char *title = "Tank calibration")
    {
        return level_.register_config(title, "ohm");
    }

    // The nodes, for a firmware that wants to do more than the default. A
    // device class that cannot be opened is worse than no device class.
    espos::sensors::Analog &analog() { return analog_; }
    espos::flow::DividerR2 &ohms() { return ohms_; }
    espos::flow::TankLevel &level() { return level_; }
    espos::sk::Output<float> &output() { return out_; }

    esp_err_t open_error() const { return analog_.open_error(); }

  private:
    espos::sensors::Analog &analog_;
    espos::flow::DividerR2 &ohms_;
    espos::flow::TankLevel &level_;
    espos::sk::Output<float> &out_;
};

}  // namespace espos::devices