Skip to content

File engine.hpp

File List > espos_devices > include > espos_devices > engine.hpp

Go to the documentation of this file

// SPDX-FileCopyrightText: 2026 Dirk Wahrheit
// SPDX-License-Identifier: Apache-2.0
//
// EngineRpm and EngineHours — the two things every engine installation
// publishes, and the two that are easiest to get subtly wrong.
//
//   espos::devices::EngineRpm rpm(g, {.id = "rpm", .gpio = 21,
//                                     .pulses_per_rev = 2.0f});
//   espos::devices::EngineHours hours(g, {.id = "hrs"});
//   rpm.running() >> hours.input();   // hours accrue while the engine turns
//   rpm.start();
//
// **Signal K wants revolutions per SECOND on propulsion.*.revolutions**, not
// RPM. The path is defined in SI units like every other numeric path in the
// spec, and publishing 2400 where the server expects 40 is the single most
// common mistake on an engine gateway -- it looks right on a gauge that
// happens to scale it and wrong everywhere else. That conversion is done
// here, once, rather than left to a magic constant in each firmware.
#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 EngineRpmConfig {
    const char *id = "rpm";
    int gpio = -1;
    const char *path = "propulsion.main.revolutions";

    // Pulses per revolution of the crankshaft. An alternator W terminal gives
    // pole-pairs times the pulley ratio and is rarely a round number, which is
    // why this is a float and belongs on a config page: it is calibrated
    // against a hand tachometer, not read off a datasheet.
    float pulses_per_rev = 1.0f;

    // The engine is considered running above this, in revolutions per second.
    // 5 rev/s = 300 rpm, comfortably above a starter and below any idle.
    float running_above_hz = 5.0f;

    uint32_t period_ms = 1000;
    uint32_t max_glitch_ns = 1000;  // ignition noise is the usual problem
};

// Pulse rate on a pin -> revolutions per second on a Signal K path.
class EngineRpm
{
  public:
    EngineRpm(espos::flow::Graph &g, const EngineRpmConfig &cfg)
        : pulses_(g.make<espos::sensors::PulseCounter>(
              cfg.id, cfg.gpio, cfg.period_ms, cfg.max_glitch_ns)),
          // Hz on the pin / pulses per rev = revolutions per second, which is
          // what the spec asks for. A multiplier, not a divisor, so the config
          // page shows one number rather than a reciprocal nobody can sanity
          // check.
          scale_(g.make<espos::flow::Linear<float>>(
              detail::SubId(cfg.id, 's'),
              cfg.pulses_per_rev > 0.0f ? 1.0f / cfg.pulses_per_rev : 1.0f,
              0.0f)),
          out_(g.make<espos::sk::Output<float>>(cfg.path)),
          // Above the threshold the engine is turning. Hysteresis rather than a
          // comparison: an engine hovering at the idle boundary would otherwise
          // start and stop the hour meter every second.
          running_(g.make<espos::flow::Hysteresis<float, bool>>(
              detail::SubId(cfg.id, 'r'), cfg.running_above_hz * 0.8f,
              cfg.running_above_hz, false, true))
    {
        pulses_ >> scale_ >> out_;
        scale_ >> running_;
    }

    esp_err_t start() { return pulses_.start(); }
    void stop() { pulses_.stop(); }

    // Calibration belongs on a config page: pulses per revolution is measured
    // against a hand tachometer, and getting it wrong is invisible until
    // somebody compares two instruments.
    esp_err_t register_config(const char *title = "Engine RPM")
    {
        return scale_.register_config(title);
    }

    // True while the engine turns. Wire it into EngineHours, a Notify, or
    // anything else that cares.
    espos::flow::Producer<bool> &running() { return running_; }

    espos::sensors::PulseCounter &pulses() { return pulses_; }
    espos::flow::Linear<float> &scale() { return scale_; }
    espos::sk::Output<float> &output() { return out_; }

  private:
    espos::sensors::PulseCounter &pulses_;
    espos::flow::Linear<float> &scale_;
    espos::sk::Output<float> &out_;
    espos::flow::Hysteresis<float, bool> &running_;
};

struct EngineHoursConfig {
    const char *id = "hrs";
    // Signal K's runTime is SECONDS, which is what RunHours already emits --
    // no conversion, deliberately. An hour meter reads hours, and that
    // temptation to multiply is exactly how a runTime ends up 3600x wrong and
    // still plausible on a gauge that scales it.
    const char *path = "propulsion.main.runTime";
    uint32_t emit_interval_ms = 60000;
};

// Accumulated running time on a Signal K path.
//
// **The total does NOT survive a reboot on its own.** RunHours has no config
// registration: it counts from zero at every start. A firmware that wants a
// real hour meter has to persist total_s() itself and restore it with
// set_total_s() at boot -- there is no way to do that here without inventing
// a storage policy that belongs to the application. Said plainly because an
// hour meter that silently resets is worse than none: it reads plausibly.
class EngineHours
{
  public:
    EngineHours(espos::flow::Graph &g, const EngineHoursConfig &cfg)
        : hours_(g.make<espos::flow::RunHours>(cfg.id, cfg.emit_interval_ms)),
          out_(g.make<espos::sk::Output<float>>(cfg.path))
    {
        hours_ >> out_;
    }

    // Feed this the engine's running state -- EngineRpm::running(), an oil
    // pressure switch, an ignition sense line.
    espos::flow::Consumer<bool> &input() { return hours_; }

    // Restore a total at boot, and read it back to persist. The pair is what a
    // firmware needs to make the meter survive a power cycle.
    void set_total_s(float seconds) { hours_.set_total_s(seconds); }
    float total_s() const { return hours_.total_s(); }

    espos::flow::RunHours &hours() { return hours_; }
    espos::sk::Output<float> &output() { return out_; }

  private:
    espos::flow::RunHours &hours_;
    espos::sk::Output<float> &out_;
};

}  // namespace espos::devices