Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9e11925
Runtime: Base
SW-Niko Sep 2, 2025
fc84ebd
Runtime: Integration
SW-Niko Sep 2, 2025
10c327f
Runtime: fix read() timing
SW-Niko Oct 9, 2025
15de96f
Runtime: remove static bool from getWriteTrigger()
SW-Niko Oct 12, 2025
e2b03c4
Runtime: commit the new state only after a successful write
SW-Niko Oct 12, 2025
d54e11c
Runtime: move write() to onFirmwareUpdateFinish()
SW-Niko Oct 13, 2025
2ce71c8
Runtime: move default value from .cpp to .h
SW-Niko Oct 14, 2025
8982927
Runtime: use lock for protect _lastTrigger
SW-Niko Oct 15, 2025
97ffc77
Runtime: enable write on demand, fix lock issue in write()
SW-Niko Oct 17, 2025
9132262
Runtime: add missing bracket
SW-Niko Oct 17, 2025
33bac66
BatteryState: refactor getBatteryPower()
SW-Niko Nov 5, 2025
4581151
BatteryState: use the runtime file to store the state
SW-Niko Nov 5, 2025
5218d11
BatteryState: remove debug logging
SW-Niko Nov 13, 2025
00f0b79
PowerLimiter: add AllowStandby
SW-Niko Nov 18, 2025
b116ab9
InverterMeter: configuration
SW-Niko Dec 11, 2025
05a46c0
InverterMeter: base Up1
SW-Niko Dec 11, 2025
9007e53
InverterMeter: integration Up1
SW-Niko Dec 11, 2025
1ccd9ad
InverterMeter: webApi
SW-Niko Dec 11, 2025
7c1be3d
InverterMeter: webUI
SW-Niko Dec 12, 2025
934dbb4
Runtime: Base (added read mode ON_DEMAND)
SW-Niko Dec 1, 2025
0b74992
InverterATF: Configuration
SW-Niko Nov 18, 2025
8434f0f
InverterATF: Base Up1-24
SW-Niko Apr 3, 2025
70bd759
InverterATF: Integration Up1-11
SW-Niko Apr 3, 2025
9a98b02
InverterATF: WebApp Up1-4
SW-Niko Nov 18, 2025
7d996ef
yarn prettier
SW-Niko Dec 21, 2025
d2f1f5d
InverterATF: Base (solve cpplint issue)
SW-Niko Dec 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions include/Configuration.h
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ struct POWERLIMITER_INVERTER_CONFIG_T {
bool IsBehindPowerMeter;
bool UseOverscaling;
bool AllowStandby;
bool UseATF;
uint16_t LowerPowerLimit;
uint16_t UpperPowerLimit;

Expand Down Expand Up @@ -437,6 +438,17 @@ struct CONFIG_T {
PowerMeterUdpVictronConfig UdpVictron;
} PowerMeter;

struct InverterMeterConfig {
bool Enabled;
uint32_t Source;
uint64_t Serial;
PowerMeterMqttConfig Mqtt;
PowerMeterSerialSdmConfig SerialSdm;
PowerMeterHttpJsonConfig HttpJson;
PowerMeterHttpSmlConfig HttpSml;
PowerMeterUdpVictronConfig UdpVictron;
} InverterMeter;

PowerLimiterConfig PowerLimiter;

BatteryConfig Battery;
Expand Down
85 changes: 85 additions & 0 deletions include/InverterATF.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-2.0-or-later

/*
* Locking policy:
* - Public getters having the prefix 'get' take a shared lock internally.
* - Public mutating methods take an exclusive lock internally.
* - Private getters having the prefix 'g' do not take a lock.
* - Private methods are called with the appropriate lock held by the public methods.
* - Fetching external data is done before acquiring _mutex. The only exception is data from the configuration or
* data used just for visualization.
* - Flags marked as atomic may be read lock-free.
*/

#pragma once
#include <Arduino.h>
#include <shared_mutex>
#include <atomic>
#include <ArduinoJson.h>


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


// indicates whether ATF is actually active
bool isATFActive() const { return _useATF.load(); }

// activate the ATF data structure
// use a unique lock when calling this method
bool activateATF(uint16_t const nomPower);

// deactivate the ATF data structure
// use a unique lock when calling this method
void deactivateATF(void);

// update the ATF data with the current power and limit pair
// use a unique lock when calling this method
void setATFData(float const power, float const limit);

// deserialize the ATF data from the runtime data file
// use a unique lock when calling this method
void deserializeATFData(JsonObject obj);

// serialize the ATF data to the runtime data file
// use a shared lock when calling this method
void serializeATFData(JsonObject obj) const;

// print the ATF report to the log
// use a shared lock when calling this method
void printATFReport(char const* serialStr) const;

// returns the power for the given limit according to the ATF
// use a shared/unique lock when calling this method
uint16_t getATFPower(float const limit) const;

// returns the limit for the given power according to the ATF
// use a shared/unique lock when calling this method
float getATFLimit(uint16_t const power) const;

private:
float makeAveragePower(float newValue, float const oldValue);
enum class State : uint8_t { OFF, DEFAULT_INIT, RTD_INIT };

std::atomic<bool> _useATF = false; // false: ATF inactive and no memory allocated for the data array
State _state = State::OFF; // state of the ATF data array
static constexpr uint8_t _size = 101; // Fixed size of the ATF data array, never NEVER change the size!
std::unique_ptr<float[]> _realPower = nullptr; // ATF data array, index 0-100%

uint16_t _nomInvPower = 0; // Inverter nominal power [W]
uint16_t _maxInvPower = 0; // Inverter maximum power (nominal power * MAX_OVERDRIVE_FACTOR) [W]
uint16_t _absDiffPower = 0; // maximum absolute difference, used for power value checks [W]
mutable std::pair<uint16_t, float> _cache; // cache to avoid recalculations, first = power, second = limit
mutable std::shared_mutex _mutex; // mutex to protect the shared data

uint16_t _linearFault = 0; // counts the number of range check faults
uint16_t _averageWarning = 0; // counts the number of average warnings
std::pair<uint16_t, float> _linearPairFault; // buffer the maximum range check fault
std::pair<float, float> _averagePairWarning; // buffer the maximum average check warning
};
22 changes: 20 additions & 2 deletions include/PowerLimiter.h
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class PowerLimiterClass {
InverterStatsPending,
UnconditionalSolarPassthrough,
Stable,
InverterPowerMeterPending,
};

void init(Scheduler& scheduler);
Expand All @@ -57,6 +58,15 @@ class PowerLimiterClass {
// used to interlock Huawei R48xx grid charger against battery-powered inverters
bool isGovernedBatteryPoweredInverterProducing() const;

void serializeRTD(JsonObject const& obj) const;
void deserializeRTD(JsonObject const& obj);

// ATF: used for the ATF functionality
bool initATF(void);
void serializeATFtoRTD(JsonVariant obj) const;
void deserializeRTDtoATF(JsonVariant obj);
std::optional<uint16_t> getATFInverterPower(uint64_t inverterSerial, float limit) const;

private:
void loop();

Expand All @@ -73,12 +83,20 @@ class PowerLimiterClass {

std::deque<std::unique_ptr<PowerLimiterInverter>> _inverters;
std::deque<std::unique_ptr<PowerLimiterInverter>> _retirees;
bool _batteryDischargeEnabled = false;
bool _nighttimeDischarging = false;

enum class BatteryState : uint8_t { STOP = 0, NO_DISCHARGE = 1, DISCHARGE_ALLOWED = 2, DISCHARGE_NIGHT = 3 };
BatteryState _batteryState = BatteryState::STOP;
bool _fromStart = false;
bool _oneStopPerNightDone = false;

std::pair<bool, uint32_t> _nextInverterRestart = { false, 0 };
bool _fullSolarPassThroughActive = false;
float _loadCorrectedVoltage = 0.0f;

// ATF: used for the ATF functionality
bool _checkATF = true;
uint32_t _lastATFPrint = 0;

frozen::string const& getStatusText(Status status) const;
void announceStatus(Status status);
void reloadConfig();
Expand Down
29 changes: 28 additions & 1 deletion include/PowerLimiterInverter.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
#include <Hoymiles.h>
#include <optional>
#include <memory>
#include "InverterATF.h"

class PowerLimiterInverter {
class PowerLimiterInverter : public InverterATF {
public:
Comment on lines +8 to 11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a virtual destructor and clean up ATF helper surface

The new ATF integration and external meter plumbing are conceptually fine, but there are a couple of structural issues:

  1. Non-virtual base destructor with polymorphic use

    PowerLimiterInverter is used polymorphically (std::unique_ptr<PowerLimiterInverter> holding derived instances), but it has no virtual destructor. This is undefined behavior when deleting derived objects via a base pointer. Please add:

    class PowerLimiterInverter : public InverterATF {
    public:
        virtual ~PowerLimiterInverter() = default;
        // ...
    };
  2. Inline ATF helpers couple the header tightly to implementation details

    • setATFData() calls into InverterATF::setATFData(getCurrentOutputAcWatts(), _spInverter->SystemConfigPara()->getLimitPercent()); directly from the header. That’s OK, but it makes every inclusion of this header depend on these internals; consider moving the definition to the .cpp if compile times become an issue.
    • getATFConfigPower() is marked “todo: delete after testing”. It’s better to remove or #ifdef test-only APIs before merging to avoid public API creep.
  3. External meter state

    _oInverterMeterPower as std::optional<float> aligns with the new override semantics; just ensure any concurrent access is guarded consistently with how the rest of the class is used.

Also applies to: 78-99, 162-164

static std::unique_ptr<PowerLimiterInverter> create(PowerLimiterInverterConfig const& config);

Expand Down Expand Up @@ -37,6 +38,8 @@ class PowerLimiterInverter {
// upper power limit (additionally restricted by inverter's absolute max)
uint16_t getConfiguredMaxPowerWatts() const;

// returns the current amount of AC output power
// either as per inverter stats or preferred from the external power meter
uint16_t getCurrentOutputAcWatts() const;

// this differs from current output power if new limit was assigned
Expand Down Expand Up @@ -72,6 +75,27 @@ class PowerLimiterInverter {
bool isReachable() const { return _spInverter->isReachable(); }
bool isProducing() const { return _spInverter->isProducing(); }

// sets the AC value measured by the external power meter
// compares the timestamp of the external power meter with the timestamp of the inverter stats
// returns true if the value is accepted or if further waiting makes no sense
// returns false if we want to wait longer for a newer value
bool setCurrentOutputAcWatts(float power, uint32_t timestamp);

// indicates whether ATF is enabled in the configuration
// use isATFActive() to check whether ATF is actually active
bool isATFEnabled(void) const { return _config.UseATF; }

// update the ATF data with the current power and limit pair
// just call this method after getting new stats from the inverter
// because the values must be in sync
void setATFData(void) { InverterATF::setATFData(getCurrentOutputAcWatts(), _spInverter->SystemConfigPara()->getLimitPercent()); }

// print the ATF report to the log
void printATFReport(void) const { InverterATF::printATFReport(getSerialStr()); }

// todo: delete after testing
uint16_t getATFConfigPower(void) const { return _config.UpperPowerLimit; }

uint64_t getSerial() const { return _config.Serial; }
char const* getSerialStr() const { return _serialStr; }
bool isBehindPowerMeter() const { return _config.IsBehindPowerMeter; }
Expand Down Expand Up @@ -135,6 +159,9 @@ class PowerLimiterInverter {
std::optional<bool> _oTargetPowerState = std::nullopt;
mutable std::optional<uint32_t> _oStatsMillis = std::nullopt;

// the external power meter value if available and recent enough
std::optional<float> _oInverterMeterPower = std::nullopt;

// the expected AC output (possibly is different from the target limit)
uint16_t _expectedOutputAcWatts = 0;
};
48 changes: 48 additions & 0 deletions include/RuntimeData.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once

#include <ArduinoJson.h>
#include <TaskSchedulerDeclarations.h>
#include <mutex>
#include <atomic>


class RuntimeClass {
public:
explicit RuntimeClass(uint16_t version) : _writeVersion(version) {};
~RuntimeClass() = default;
RuntimeClass(const RuntimeClass&) = delete;
RuntimeClass& operator=(const RuntimeClass&) = delete;
RuntimeClass(RuntimeClass&&) = delete;
RuntimeClass& operator=(RuntimeClass&&) = delete;

void init(Scheduler& scheduler);
enum class ReadMode : uint8_t { START_UP, ON_DEMAND };
bool read(ReadMode const mode = ReadMode::START_UP); // read runtime data
bool write(uint16_t const freezeMinutes = 10); // do not write if last write operation was less than freezeMinutes ago
void requestWriteOnNextTaskLoop(void) { _writeNow = true; }; // use this member function to store data on demand
void requestReadOnNextTaskLoop(void) { _readNow = true; }; // use this member function to read data on demand

uint16_t getWriteCount(void) const;
time_t getWriteEpochTime(void) const;
bool getReadState(void) const { return _readOK; }
bool getWriteState(void) const { return _writeOK; }
String getWriteCountAndTimeString(void) const;

private:
void loop(void);
bool getWriteTrigger(void);

Task _loopTask;
std::atomic<bool> _readOK = false; // true if the last read operation was successful
std::atomic<bool> _writeOK = false; // true if the last write operation was successful
std::atomic<bool> _readNow = false; // if true, the data is read in the next loop()
std::atomic<bool> _writeNow = false; // if true, the data is stored in the next loop()
mutable std::mutex _mutex; // to protect the shared data below
bool _lastTrigger = false; // auxiliary value to prevent multiple triggering on the same day
uint16_t _writeVersion = 0; // shared data: version of the runtime data
uint16_t _writeCount = 0; // shared data: number of write operations
time_t _writeEpoch = 0; // shared data: epoch time when the data was written
};

extern RuntimeClass RuntimeData;
2 changes: 2 additions & 0 deletions include/WebApi.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include "WebApi_ntp.h"
#include "WebApi_power.h"
#include "WebApi_powermeter.h"
#include "WebApi_invertermeter.h"
#include "WebApi_powerlimiter.h"
#include "WebApi_prometheus.h"
#include "WebApi_security.h"
Expand Down Expand Up @@ -74,6 +75,7 @@ class WebApiClass {
WebApiNtpClass _webApiNtp;
WebApiPowerClass _webApiPower;
WebApiPowerMeterClass _webApiPowerMeter;
WebApiInverterMeterClass _webApiInverterMeter;
WebApiPowerLimiterClass _webApiPowerLimiter;
WebApiPrometheusClass _webApiPrometheus;
WebApiSecurityClass _webApiSecurity;
Expand Down
21 changes: 21 additions & 0 deletions include/WebApi_invertermeter.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once

#include <ESPAsyncWebServer.h>
#include <TaskSchedulerDeclarations.h>
#include <ArduinoJson.h>
#include "Configuration.h"

class WebApiInverterMeterClass {
public:
void init(AsyncWebServer& server, Scheduler& scheduler);

private:
void onStatus(AsyncWebServerRequest* request);
void onAdminGet(AsyncWebServerRequest* request);
void onAdminPost(AsyncWebServerRequest* request);
void onTestHttpJsonRequest(AsyncWebServerRequest* request);
void onTestHttpSmlRequest(AsyncWebServerRequest* request);

AsyncWebServer* _server;
};
Comment thread
SW-Niko marked this conversation as resolved.
53 changes: 53 additions & 0 deletions include/invertermeter/Controller.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once

/*
* Locking policy:
* - Public getters having the prefix 'get' take a shared lock internally.
* - Public mutating methods take an exclusive lock internally.
* - Private getters having the prefix 'g' do not take a lock.
* - Private methods are called with the appropriate lock held by the public methods.
* - Fetching external data is done before acquiring _mutex. The only exception is data from the configuration or
* data used just for visualization.
* - Flags marked as atomic may be read lock-free.
*/

#include <powermeter/Provider.h>
#include <TaskSchedulerDeclarations.h>
#include <memory>
#include <mutex>

Comment thread
SW-Niko marked this conversation as resolved.
namespace InverterMeters {

class Controller {
public:
void init(Scheduler& scheduler);
void updateSettings();

// todo: consider removing getPowerTotal in favor of getPower with inverterID
float getPowerTotal() const;
uint32_t getLastUpdate() const;

// returns true if the data is not older than 30 seconds
bool isDataValid() const;

// returns power for the given inverter ID if available
std::optional<float> getPower(uint64_t inverterID) const;

// returns the time of the last update for the given inverter serial number
uint32_t getTime(uint64_t inverterSN) const;

// returns the time of the measurement request in milliseconds
uint32_t getRequestTime() const;

private:
void loop();

Task _loopTask;
mutable std::mutex _mutex;
std::unique_ptr<PowerMeters::Provider> _upProvider = nullptr;
};

} // namespace InverterMeters

extern InverterMeters::Controller InverterMeter;
Loading