Merge pull request #726 from helgeerbe/development
Prepare Release 2024.03.07
This commit is contained in:
commit
490a38f909
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@ -60,6 +60,15 @@ jobs:
|
|||||||
- name: Get tags
|
- name: Get tags
|
||||||
run: git fetch --force --tags origin
|
run: git fetch --force --tags origin
|
||||||
|
|
||||||
|
- name: Create and switch to a meaningful branch for pull-requests
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
OWNER=${{ github.repository_owner }}
|
||||||
|
NAME=${{ github.event.repository.name }}
|
||||||
|
ID=${{ github.event.pull_request.number }}
|
||||||
|
DATE=$(date +'%Y%m%d%H%M')
|
||||||
|
git switch -c ${OWNER}/${NAME}/pr${ID}-${DATE}
|
||||||
|
|
||||||
- name: Cache pip
|
- name: Cache pip
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@ -15,7 +15,7 @@ class BatteryStats {
|
|||||||
|
|
||||||
// the last time *any* datum was updated
|
// the last time *any* datum was updated
|
||||||
uint32_t getAgeSeconds() const { return (millis() - _lastUpdate) / 1000; }
|
uint32_t getAgeSeconds() const { return (millis() - _lastUpdate) / 1000; }
|
||||||
bool updateAvailable(uint32_t since) const { return _lastUpdate > since; }
|
bool updateAvailable(uint32_t since) const;
|
||||||
|
|
||||||
uint8_t getSoC() const { return _soc; }
|
uint8_t getSoC() const { return _soc; }
|
||||||
uint32_t getSoCAgeSeconds() const { return (millis() - _lastUpdateSoC) / 1000; }
|
uint32_t getSoCAgeSeconds() const { return (millis() - _lastUpdateSoC) / 1000; }
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
#include <Hoymiles.h>
|
#include <Hoymiles.h>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <optional>
|
||||||
#include <TaskSchedulerDeclarations.h>
|
#include <TaskSchedulerDeclarations.h>
|
||||||
#include <frozen/string.h>
|
#include <frozen/string.h>
|
||||||
|
|
||||||
@ -15,10 +16,6 @@
|
|||||||
#define PL_UI_STATE_USE_SOLAR_ONLY 2
|
#define PL_UI_STATE_USE_SOLAR_ONLY 2
|
||||||
#define PL_UI_STATE_USE_SOLAR_AND_BATTERY 3
|
#define PL_UI_STATE_USE_SOLAR_AND_BATTERY 3
|
||||||
|
|
||||||
#define PL_MODE_ENABLE_NORMAL_OP 0
|
|
||||||
#define PL_MODE_FULL_DISABLE 1
|
|
||||||
#define PL_MODE_SOLAR_PT_ONLY 2
|
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
EMPTY_WHEN_FULL= 0,
|
EMPTY_WHEN_FULL= 0,
|
||||||
EMPTY_AT_NIGHT
|
EMPTY_AT_NIGHT
|
||||||
@ -51,7 +48,7 @@ public:
|
|||||||
|
|
||||||
void init(Scheduler& scheduler);
|
void init(Scheduler& scheduler);
|
||||||
uint8_t getPowerLimiterState();
|
uint8_t getPowerLimiterState();
|
||||||
int32_t getLastRequestedPowerLimit();
|
int32_t getLastRequestedPowerLimit() { return _lastRequestedPowerLimit; }
|
||||||
|
|
||||||
enum class Mode : unsigned {
|
enum class Mode : unsigned {
|
||||||
Normal = 0,
|
Normal = 0,
|
||||||
@ -69,8 +66,10 @@ private:
|
|||||||
Task _loopTask;
|
Task _loopTask;
|
||||||
|
|
||||||
int32_t _lastRequestedPowerLimit = 0;
|
int32_t _lastRequestedPowerLimit = 0;
|
||||||
uint32_t _lastPowerLimitMillis = 0;
|
bool _shutdownPending = false;
|
||||||
uint32_t _shutdownTimeout = 0;
|
std::optional<uint32_t> _oUpdateStartMillis = std::nullopt;
|
||||||
|
std::optional<int32_t> _oTargetPowerLimitWatts = std::nullopt;
|
||||||
|
std::optional<bool> _oTargetPowerState = std::nullopt;
|
||||||
Status _lastStatus = Status::Initializing;
|
Status _lastStatus = Status::Initializing;
|
||||||
uint32_t _lastStatusPrinted = 0;
|
uint32_t _lastStatusPrinted = 0;
|
||||||
uint32_t _lastCalculation = 0;
|
uint32_t _lastCalculation = 0;
|
||||||
@ -93,7 +92,7 @@ private:
|
|||||||
void unconditionalSolarPassthrough(std::shared_ptr<InverterAbstract> inverter);
|
void unconditionalSolarPassthrough(std::shared_ptr<InverterAbstract> inverter);
|
||||||
bool canUseDirectSolarPower();
|
bool canUseDirectSolarPower();
|
||||||
int32_t calcPowerLimit(std::shared_ptr<InverterAbstract> inverter, bool solarPowerEnabled, bool batteryDischargeEnabled);
|
int32_t calcPowerLimit(std::shared_ptr<InverterAbstract> inverter, bool solarPowerEnabled, bool batteryDischargeEnabled);
|
||||||
void commitPowerLimit(std::shared_ptr<InverterAbstract> inverter, int32_t limit, bool enablePowerProduction);
|
bool updateInverter();
|
||||||
bool setNewPowerLimit(std::shared_ptr<InverterAbstract> inverter, int32_t newPowerLimit);
|
bool setNewPowerLimit(std::shared_ptr<InverterAbstract> inverter, int32_t newPowerLimit);
|
||||||
int32_t getSolarChargePower();
|
int32_t getSolarChargePower();
|
||||||
float getLoadCorrectedVoltage();
|
float getLoadCorrectedVoltage();
|
||||||
|
|||||||
@ -17,6 +17,9 @@ private:
|
|||||||
static void generateInverterChannelJsonResponse(JsonObject& root, std::shared_ptr<InverterAbstract> inv);
|
static void generateInverterChannelJsonResponse(JsonObject& root, std::shared_ptr<InverterAbstract> inv);
|
||||||
static void generateCommonJsonResponse(JsonVariant& root);
|
static void generateCommonJsonResponse(JsonVariant& root);
|
||||||
|
|
||||||
|
void generateOnBatteryJsonResponse(JsonVariant& root, bool all);
|
||||||
|
void sendOnBatteryStats();
|
||||||
|
|
||||||
static void addField(JsonObject& root, std::shared_ptr<InverterAbstract> inv, const ChannelType_t type, const ChannelNum_t channel, const FieldId_t fieldId, String topic = "");
|
static void addField(JsonObject& root, std::shared_ptr<InverterAbstract> inv, const ChannelType_t type, const ChannelNum_t channel, const FieldId_t fieldId, String topic = "");
|
||||||
static void addTotalField(JsonObject& root, const String& name, const float value, const String& unit, const uint8_t digits);
|
static void addTotalField(JsonObject& root, const String& name, const float value, const String& unit, const uint8_t digits);
|
||||||
|
|
||||||
@ -25,6 +28,12 @@ private:
|
|||||||
|
|
||||||
AsyncWebSocket _ws;
|
AsyncWebSocket _ws;
|
||||||
|
|
||||||
|
uint32_t _lastPublishOnBatteryFull = 0;
|
||||||
|
uint32_t _lastPublishVictron = 0;
|
||||||
|
uint32_t _lastPublishHuawei = 0;
|
||||||
|
uint32_t _lastPublishBattery = 0;
|
||||||
|
uint32_t _lastPublishPowerMeter = 0;
|
||||||
|
|
||||||
uint32_t _lastPublishStats[INV_MAX_COUNT] = { 0 };
|
uint32_t _lastPublishStats[INV_MAX_COUNT] = { 0 };
|
||||||
|
|
||||||
std::mutex _mutex;
|
std::mutex _mutex;
|
||||||
|
|||||||
@ -51,6 +51,12 @@ static void addLiveViewAlarm(JsonVariant& root, std::string const& name,
|
|||||||
root["issues"][name] = 2;
|
root["issues"][name] = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool BatteryStats::updateAvailable(uint32_t since) const
|
||||||
|
{
|
||||||
|
auto constexpr halfOfAllMillis = std::numeric_limits<uint32_t>::max() / 2;
|
||||||
|
return (_lastUpdate - since) < halfOfAllMillis;
|
||||||
|
}
|
||||||
|
|
||||||
void BatteryStats::getLiveViewData(JsonVariant& root) const
|
void BatteryStats::getLiveViewData(JsonVariant& root) const
|
||||||
{
|
{
|
||||||
root[F("manufacturer")] = _manufacturer;
|
root[F("manufacturer")] = _manufacturer;
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
#include "Huawei_can.h"
|
#include "Huawei_can.h"
|
||||||
#include <VictronMppt.h>
|
#include <VictronMppt.h>
|
||||||
#include "MessageOutput.h"
|
#include "MessageOutput.h"
|
||||||
|
#include "inverters/HMS_4CH.h"
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <frozen/map.h>
|
#include <frozen/map.h>
|
||||||
@ -30,7 +31,7 @@ frozen::string const& PowerLimiterClass::getStatusText(PowerLimiterClass::Status
|
|||||||
{
|
{
|
||||||
static const frozen::string missing = "programmer error: missing status text";
|
static const frozen::string missing = "programmer error: missing status text";
|
||||||
|
|
||||||
static const frozen::map<Status, frozen::string, 19> texts = {
|
static const frozen::map<Status, frozen::string, 18> texts = {
|
||||||
{ Status::Initializing, "initializing (should not see me)" },
|
{ Status::Initializing, "initializing (should not see me)" },
|
||||||
{ Status::DisabledByConfig, "disabled by configuration" },
|
{ Status::DisabledByConfig, "disabled by configuration" },
|
||||||
{ Status::DisabledByMqtt, "disabled by MQTT" },
|
{ Status::DisabledByMqtt, "disabled by MQTT" },
|
||||||
@ -48,7 +49,6 @@ frozen::string const& PowerLimiterClass::getStatusText(PowerLimiterClass::Status
|
|||||||
{ Status::InverterStatsPending, "waiting for sufficiently recent inverter data" },
|
{ Status::InverterStatsPending, "waiting for sufficiently recent inverter data" },
|
||||||
{ Status::UnconditionalSolarPassthrough, "unconditionally passing through all solar power (MQTT override)" },
|
{ Status::UnconditionalSolarPassthrough, "unconditionally passing through all solar power (MQTT override)" },
|
||||||
{ Status::NoVeDirect, "VE.Direct disabled, connection broken, or data outdated" },
|
{ Status::NoVeDirect, "VE.Direct disabled, connection broken, or data outdated" },
|
||||||
{ Status::Settling, "waiting for the system to settle" },
|
|
||||||
{ Status::Stable, "the system is stable, the last power limit is still valid" },
|
{ Status::Stable, "the system is stable, the last power limit is still valid" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -79,36 +79,18 @@ void PowerLimiterClass::announceStatus(PowerLimiterClass::Status status)
|
|||||||
/**
|
/**
|
||||||
* returns true if the inverter state was changed or is about to change, i.e.,
|
* returns true if the inverter state was changed or is about to change, i.e.,
|
||||||
* if it is actually in need of a shutdown. returns false otherwise, i.e., the
|
* if it is actually in need of a shutdown. returns false otherwise, i.e., the
|
||||||
* inverter is already (assumed to be) shut down.
|
* inverter is already shut down and the inverter limit is set to the configured
|
||||||
|
* lower power limit.
|
||||||
*/
|
*/
|
||||||
bool PowerLimiterClass::shutdown(PowerLimiterClass::Status status)
|
bool PowerLimiterClass::shutdown(PowerLimiterClass::Status status)
|
||||||
{
|
{
|
||||||
announceStatus(status);
|
announceStatus(status);
|
||||||
|
|
||||||
if (_inverter == nullptr || !_inverter->isProducing() ||
|
_shutdownPending = true;
|
||||||
(_shutdownTimeout > 0 && _shutdownTimeout < millis()) ) {
|
|
||||||
// we are actually (already) done with shutting down the inverter,
|
|
||||||
// or a shutdown attempt was initiated but it timed out.
|
|
||||||
_inverter = nullptr;
|
|
||||||
_shutdownTimeout = 0;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_inverter->isReachable()) { return true; } // retry later (until timeout)
|
_oTargetPowerState = false;
|
||||||
|
_oTargetPowerLimitWatts = Configuration.get().PowerLimiter.LowerPowerLimit;
|
||||||
// retry shutdown for a maximum amount of time before giving up
|
return updateInverter();
|
||||||
if (_shutdownTimeout == 0) { _shutdownTimeout = millis() + 10 * 1000; }
|
|
||||||
|
|
||||||
auto lastLimitCommandState = _inverter->SystemConfigPara()->getLastLimitCommandSuccess();
|
|
||||||
if (CMD_PENDING == lastLimitCommandState) { return true; }
|
|
||||||
|
|
||||||
auto lastPowerCommandState = _inverter->PowerCommand()->getLastPowerCommandSuccess();
|
|
||||||
if (CMD_PENDING == lastPowerCommandState) { return true; }
|
|
||||||
|
|
||||||
CONFIG_T& config = Configuration.get();
|
|
||||||
commitPowerLimit(_inverter, config.PowerLimiter.LowerPowerLimit, false);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void PowerLimiterClass::loop()
|
void PowerLimiterClass::loop()
|
||||||
@ -124,12 +106,13 @@ void PowerLimiterClass::loop()
|
|||||||
return announceStatus(Status::WaitingForValidTimestamp);
|
return announceStatus(Status::WaitingForValidTimestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_shutdownTimeout > 0) {
|
// take care that the last requested power
|
||||||
// we transition from SHUTDOWN to OFF when we know the inverter was
|
// limit and power state are actually reached
|
||||||
// shut down. until then, we retry shutting it down. in this case we
|
if (updateInverter()) { return; }
|
||||||
// preserve the original status that lead to the decision to shut down.
|
|
||||||
shutdown();
|
if (_shutdownPending) {
|
||||||
return;
|
_shutdownPending = false;
|
||||||
|
_inverter = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.PowerLimiter.Enabled) {
|
if (!config.PowerLimiter.Enabled) {
|
||||||
@ -172,18 +155,6 @@ void PowerLimiterClass::loop()
|
|||||||
return announceStatus(Status::InverterCommandsDisabled);
|
return announceStatus(Status::InverterCommandsDisabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
// concerns active power commands (power limits) only (also from web app or MQTT)
|
|
||||||
auto lastLimitCommandState = _inverter->SystemConfigPara()->getLastLimitCommandSuccess();
|
|
||||||
if (CMD_PENDING == lastLimitCommandState) {
|
|
||||||
return announceStatus(Status::InverterLimitPending);
|
|
||||||
}
|
|
||||||
|
|
||||||
// concerns power commands (start, stop, restart) only (also from web app or MQTT)
|
|
||||||
auto lastPowerCommandState = _inverter->PowerCommand()->getLastPowerCommandSuccess();
|
|
||||||
if (CMD_PENDING == lastPowerCommandState) {
|
|
||||||
return announceStatus(Status::InverterPowerCmdPending);
|
|
||||||
}
|
|
||||||
|
|
||||||
// a calculated power limit will always be limited to the reported
|
// a calculated power limit will always be limited to the reported
|
||||||
// device's max power. that upper limit is only known after the first
|
// device's max power. that upper limit is only known after the first
|
||||||
// DevInfoSimpleCommand succeeded.
|
// DevInfoSimpleCommand succeeded.
|
||||||
@ -214,16 +185,11 @@ void PowerLimiterClass::loop()
|
|||||||
_inverter->SystemConfigPara()->getLastUpdateCommand(),
|
_inverter->SystemConfigPara()->getLastUpdateCommand(),
|
||||||
_inverter->PowerCommand()->getLastUpdateCommand());
|
_inverter->PowerCommand()->getLastUpdateCommand());
|
||||||
|
|
||||||
// wait for power meter and inverter stat updates after a settling phase
|
if (_inverter->Statistics()->getLastUpdate() <= lastUpdateCmd) {
|
||||||
auto settlingEnd = lastUpdateCmd + 3 * 1000;
|
|
||||||
|
|
||||||
if (millis() < settlingEnd) { return announceStatus(Status::Settling); }
|
|
||||||
|
|
||||||
if (_inverter->Statistics()->getLastUpdate() <= settlingEnd) {
|
|
||||||
return announceStatus(Status::InverterStatsPending);
|
return announceStatus(Status::InverterStatsPending);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PowerMeter.getLastPowerMeterUpdate() <= settlingEnd) {
|
if (PowerMeter.getLastPowerMeterUpdate() <= lastUpdateCmd) {
|
||||||
return announceStatus(Status::PowerMeterPending);
|
return announceStatus(Status::PowerMeterPending);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -323,12 +289,6 @@ void PowerLimiterClass::loop()
|
|||||||
int32_t newPowerLimit = calcPowerLimit(_inverter, canUseDirectSolarPower(), _batteryDischargeEnabled);
|
int32_t newPowerLimit = calcPowerLimit(_inverter, canUseDirectSolarPower(), _batteryDischargeEnabled);
|
||||||
bool limitUpdated = setNewPowerLimit(_inverter, newPowerLimit);
|
bool limitUpdated = setNewPowerLimit(_inverter, newPowerLimit);
|
||||||
|
|
||||||
if (_verboseLogging) {
|
|
||||||
MessageOutput.printf("[DPL::loop] ******************* Leaving PL, calculated limit: %d W, requested limit: %d W (%s)\r\n",
|
|
||||||
newPowerLimit, _lastRequestedPowerLimit,
|
|
||||||
(limitUpdated?"updated from calculated":"kept last requested"));
|
|
||||||
}
|
|
||||||
|
|
||||||
_lastCalculation = millis();
|
_lastCalculation = millis();
|
||||||
|
|
||||||
if (!limitUpdated) {
|
if (!limitUpdated) {
|
||||||
@ -441,10 +401,6 @@ uint8_t PowerLimiterClass::getPowerLimiterState() {
|
|||||||
return PL_UI_STATE_INACTIVE;
|
return PL_UI_STATE_INACTIVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
int32_t PowerLimiterClass::getLastRequestedPowerLimit() {
|
|
||||||
return _lastRequestedPowerLimit;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool PowerLimiterClass::canUseDirectSolarPower()
|
bool PowerLimiterClass::canUseDirectSolarPower()
|
||||||
{
|
{
|
||||||
CONFIG_T& config = Configuration.get();
|
CONFIG_T& config = Configuration.get();
|
||||||
@ -527,34 +483,190 @@ int32_t PowerLimiterClass::calcPowerLimit(std::shared_ptr<InverterAbstract> inve
|
|||||||
return newPowerLimit;
|
return newPowerLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PowerLimiterClass::commitPowerLimit(std::shared_ptr<InverterAbstract> inverter, int32_t limit, bool enablePowerProduction)
|
/**
|
||||||
|
* updates the inverter state (power production and limit). returns true if a
|
||||||
|
* change to its state was requested or is pending. this function only requests
|
||||||
|
* one change (limit value or production on/off) at a time.
|
||||||
|
*/
|
||||||
|
bool PowerLimiterClass::updateInverter()
|
||||||
{
|
{
|
||||||
|
auto reset = [this]() -> bool {
|
||||||
|
_oTargetPowerState = std::nullopt;
|
||||||
|
_oTargetPowerLimitWatts = std::nullopt;
|
||||||
|
_oUpdateStartMillis = std::nullopt;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (nullptr == _inverter) { return reset(); }
|
||||||
|
|
||||||
|
if (!_oUpdateStartMillis.has_value()) {
|
||||||
|
_oUpdateStartMillis = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((millis() - *_oUpdateStartMillis) > 30 * 1000) {
|
||||||
|
MessageOutput.printf("[DPL::updateInverter] timeout, "
|
||||||
|
"state transition pending: %s, limit pending: %s\r\n",
|
||||||
|
(_oTargetPowerState.has_value()?"yes":"no"),
|
||||||
|
(_oTargetPowerLimitWatts.has_value()?"yes":"no"));
|
||||||
|
return reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto constexpr halfOfAllMillis = std::numeric_limits<uint32_t>::max() / 2;
|
||||||
|
|
||||||
|
auto switchPowerState = [this](bool transitionOn) -> bool {
|
||||||
|
// no power state transition requested at all
|
||||||
|
if (!_oTargetPowerState.has_value()) { return false; }
|
||||||
|
|
||||||
|
// the transition that may be started is not the one which is requested
|
||||||
|
if (transitionOn != *_oTargetPowerState) { return false; }
|
||||||
|
|
||||||
|
// wait for pending power command(s) to complete
|
||||||
|
auto lastPowerCommandState = _inverter->PowerCommand()->getLastPowerCommandSuccess();
|
||||||
|
if (CMD_PENDING == lastPowerCommandState) {
|
||||||
|
announceStatus(Status::InverterPowerCmdPending);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we need to wait for statistics that are more recent than the last
|
||||||
|
// power update command to reliably use _inverter->isProducing()
|
||||||
|
auto lastPowerCommandMillis = _inverter->PowerCommand()->getLastUpdateCommand();
|
||||||
|
auto lastStatisticsMillis = _inverter->Statistics()->getLastUpdate();
|
||||||
|
if ((lastStatisticsMillis - lastPowerCommandMillis) > halfOfAllMillis) { return true; }
|
||||||
|
|
||||||
|
if (_inverter->isProducing() != *_oTargetPowerState) {
|
||||||
|
MessageOutput.printf("[DPL::updateInverter] %s inverter...\r\n",
|
||||||
|
((*_oTargetPowerState)?"Starting":"Stopping"));
|
||||||
|
_inverter->sendPowerControlRequest(*_oTargetPowerState);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_oTargetPowerState = std::nullopt; // target power state reached
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// we use a lambda function here to be able to use return statements,
|
||||||
|
// which allows to avoid if-else-indentions and improves code readability
|
||||||
|
auto updateLimit = [this]() -> bool {
|
||||||
|
// no limit update requested at all
|
||||||
|
if (!_oTargetPowerLimitWatts.has_value()) { return false; }
|
||||||
|
|
||||||
|
// wait for pending limit command(s) to complete
|
||||||
|
auto lastLimitCommandState = _inverter->SystemConfigPara()->getLastLimitCommandSuccess();
|
||||||
|
if (CMD_PENDING == lastLimitCommandState) {
|
||||||
|
announceStatus(Status::InverterLimitPending);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto maxPower = _inverter->DevInfo()->getMaxPower();
|
||||||
|
auto newRelativeLimit = static_cast<float>(*_oTargetPowerLimitWatts * 100) / maxPower;
|
||||||
|
|
||||||
|
// if no limit command is pending, the SystemConfigPara does report the
|
||||||
|
// current limit, as the answer by the inverter to a limit command is
|
||||||
|
// the canonical source that updates the known current limit.
|
||||||
|
auto currentRelativeLimit = _inverter->SystemConfigPara()->getLimitPercent();
|
||||||
|
|
||||||
|
// we assume having exclusive control over the inverter. if the last
|
||||||
|
// limit command was successful and sent after we started the last
|
||||||
|
// update cycle, we should assume *our* requested limit was set.
|
||||||
|
uint32_t lastLimitCommandMillis = _inverter->SystemConfigPara()->getLastUpdateCommand();
|
||||||
|
if ((lastLimitCommandMillis - *_oUpdateStartMillis) < halfOfAllMillis &&
|
||||||
|
CMD_OK == lastLimitCommandState) {
|
||||||
|
MessageOutput.printf("[DPL:updateInverter] actual limit is %.1f %% "
|
||||||
|
"(%.0f W respectively), effective %d ms after update started, "
|
||||||
|
"requested were %.1f %%\r\n",
|
||||||
|
currentRelativeLimit,
|
||||||
|
(currentRelativeLimit * maxPower / 100),
|
||||||
|
(lastLimitCommandMillis - *_oUpdateStartMillis),
|
||||||
|
newRelativeLimit);
|
||||||
|
|
||||||
|
if (std::abs(newRelativeLimit - currentRelativeLimit) > 2.0) {
|
||||||
|
MessageOutput.printf("[DPL:updateInverter] NOTE: expected limit of %.1f %% "
|
||||||
|
"and actual limit of %.1f %% mismatch by more than 2 %%, "
|
||||||
|
"is the DPL in exclusive control over the inverter?\r\n",
|
||||||
|
newRelativeLimit, currentRelativeLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
_oTargetPowerLimitWatts = std::nullopt;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageOutput.printf("[DPL::updateInverter] sending limit of %.1f %% "
|
||||||
|
"(%.0f W respectively), max output is %d W\r\n",
|
||||||
|
newRelativeLimit, (newRelativeLimit * maxPower / 100), maxPower);
|
||||||
|
|
||||||
|
_inverter->sendActivePowerControlRequest(static_cast<float>(newRelativeLimit),
|
||||||
|
PowerLimitControlType::RelativNonPersistent);
|
||||||
|
|
||||||
|
_lastRequestedPowerLimit = *_oTargetPowerLimitWatts;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// disable power production as soon as possible.
|
// disable power production as soon as possible.
|
||||||
// setting the power limit is less important.
|
// setting the power limit is less important once the inverter is off.
|
||||||
if (!enablePowerProduction && inverter->isProducing()) {
|
if (switchPowerState(false)) { return true; }
|
||||||
MessageOutput.println("[DPL::commitPowerLimit] Stopping inverter...");
|
|
||||||
inverter->sendPowerControlRequest(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
inverter->sendActivePowerControlRequest(static_cast<float>(limit),
|
if (updateLimit()) { return true; }
|
||||||
PowerLimitControlType::AbsolutNonPersistent);
|
|
||||||
|
|
||||||
_lastRequestedPowerLimit = limit;
|
// enable power production only after setting the desired limit
|
||||||
_lastPowerLimitMillis = millis();
|
if (switchPowerState(true)) { return true; }
|
||||||
|
|
||||||
// enable power production only after setting the desired limit,
|
return reset();
|
||||||
// such that an older, greater limit will not cause power spikes.
|
|
||||||
if (enablePowerProduction && !inverter->isProducing()) {
|
|
||||||
MessageOutput.println("[DPL::commitPowerLimit] Starting up inverter...");
|
|
||||||
inverter->sendPowerControlRequest(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* enforces limits and a hystersis on the requested power limit, after scaling
|
* scale the desired inverter limit such that the actual inverter AC output is
|
||||||
* the power limit to the ratio of total and producing inverter channels.
|
* close to the desired power limit, even if some input channels are producing
|
||||||
* commits the sanitized power limit. returns true if a limit update was
|
* less than the limit allows. this happens because the inverter seems to split
|
||||||
* committed, false otherwise.
|
* the total power limit equally among all MPPTs (not inputs; some inputs share
|
||||||
|
* the same MPPT on some models).
|
||||||
|
*
|
||||||
|
* TODO(schlimmchen): the current implementation is broken and is in need of
|
||||||
|
* refactoring. currently it only works for inverters that provide one MPPT for
|
||||||
|
* each input. it also does not work as expected if any input produces *some*
|
||||||
|
* energy, but is limited by its respective solar input.
|
||||||
|
*/
|
||||||
|
static int32_t scalePowerLimit(std::shared_ptr<InverterAbstract> inverter, int32_t newLimit, int32_t currentLimitWatts)
|
||||||
|
{
|
||||||
|
// prevent scaling if inverter is not producing, as input channels are not
|
||||||
|
// producing energy and hence are detected as not-producing, causing
|
||||||
|
// unreasonable scaling.
|
||||||
|
if (!inverter->isProducing()) { return newLimit; }
|
||||||
|
|
||||||
|
std::list<ChannelNum_t> dcChnls = inverter->Statistics()->getChannelsByType(TYPE_DC);
|
||||||
|
size_t dcTotalChnls = dcChnls.size();
|
||||||
|
|
||||||
|
// according to the upstream projects README (table with supported devs),
|
||||||
|
// every 2 channel inverter has 2 MPPTs. then there are the HM*S* 4 channel
|
||||||
|
// models which have 4 MPPTs. all others have a different number of MPPTs
|
||||||
|
// than inputs. those are not supported by the current scaling mechanism.
|
||||||
|
bool supported = dcTotalChnls == 2;
|
||||||
|
supported |= dcTotalChnls == 4 && HMS_4CH::isValidSerial(inverter->serial());
|
||||||
|
if (!supported) { return newLimit; }
|
||||||
|
|
||||||
|
// test for a reasonable power limit that allows us to assume that an input
|
||||||
|
// channel with little energy is actually not producing, rather than
|
||||||
|
// producing very little due to the very low limit.
|
||||||
|
if (currentLimitWatts < dcTotalChnls * 10) { return newLimit; }
|
||||||
|
|
||||||
|
size_t dcProdChnls = 0;
|
||||||
|
for (auto& c : dcChnls) {
|
||||||
|
if (inverter->Statistics()->getChannelFieldValue(TYPE_DC, c, FLD_PDC) > 2.0) {
|
||||||
|
dcProdChnls++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dcProdChnls == 0 || dcProdChnls == dcTotalChnls) { return newLimit; }
|
||||||
|
|
||||||
|
MessageOutput.printf("[DPL::scalePowerLimit] %d channels total, %d producing "
|
||||||
|
"channels, scaling power limit\r\n", dcTotalChnls, dcProdChnls);
|
||||||
|
return round(newLimit * static_cast<float>(dcTotalChnls) / dcProdChnls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* enforces limits on the requested power limit, after scaling the power limit
|
||||||
|
* to the ratio of total and producing inverter channels. commits the sanitized
|
||||||
|
* power limit. returns true if an inverter update was committed, false
|
||||||
|
* otherwise.
|
||||||
*/
|
*/
|
||||||
bool PowerLimiterClass::setNewPowerLimit(std::shared_ptr<InverterAbstract> inverter, int32_t newPowerLimit)
|
bool PowerLimiterClass::setNewPowerLimit(std::shared_ptr<InverterAbstract> inverter, int32_t newPowerLimit)
|
||||||
{
|
{
|
||||||
@ -570,48 +682,32 @@ bool PowerLimiterClass::setNewPowerLimit(std::shared_ptr<InverterAbstract> inver
|
|||||||
// enforce configured upper power limit
|
// enforce configured upper power limit
|
||||||
int32_t effPowerLimit = std::min(newPowerLimit, config.PowerLimiter.UpperPowerLimit);
|
int32_t effPowerLimit = std::min(newPowerLimit, config.PowerLimiter.UpperPowerLimit);
|
||||||
|
|
||||||
// scale the power limit by the amount of all inverter channels devided by
|
// early in the loop we make it a pre-requisite that this
|
||||||
// the amount of producing inverter channels. the inverters limit each of
|
// value is non-zero, so we can assume it to be valid.
|
||||||
// the n channels to 1/n of the total power limit. scaling the power limit
|
auto maxPower = inverter->DevInfo()->getMaxPower();
|
||||||
// ensures the total inverter output is what we are asking for.
|
|
||||||
std::list<ChannelNum_t> dcChnls = inverter->Statistics()->getChannelsByType(TYPE_DC);
|
|
||||||
int dcProdChnls = 0, dcTotalChnls = dcChnls.size();
|
|
||||||
for (auto& c : dcChnls) {
|
|
||||||
if (inverter->Statistics()->getChannelFieldValue(TYPE_DC, c, FLD_PDC) > 2.0) {
|
|
||||||
dcProdChnls++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ((dcProdChnls > 0) && (dcProdChnls != dcTotalChnls)) {
|
|
||||||
MessageOutput.printf("[DPL::setNewPowerLimit] %d channels total, %d producing channels, scaling power limit\r\n",
|
|
||||||
dcTotalChnls, dcProdChnls);
|
|
||||||
effPowerLimit = round(effPowerLimit * static_cast<float>(dcTotalChnls) / dcProdChnls);
|
|
||||||
}
|
|
||||||
|
|
||||||
effPowerLimit = std::min<int32_t>(effPowerLimit, inverter->DevInfo()->getMaxPower());
|
float currentLimitPercent = inverter->SystemConfigPara()->getLimitPercent();
|
||||||
|
auto currentLimitAbs = static_cast<int32_t>(currentLimitPercent * maxPower / 100);
|
||||||
|
|
||||||
// Check if the new value is within the limits of the hysteresis
|
effPowerLimit = scalePowerLimit(inverter, effPowerLimit, currentLimitAbs);
|
||||||
auto diff = std::abs(effPowerLimit - _lastRequestedPowerLimit);
|
|
||||||
|
effPowerLimit = std::min<int32_t>(effPowerLimit, maxPower);
|
||||||
|
|
||||||
|
auto diff = std::abs(currentLimitAbs - effPowerLimit);
|
||||||
auto hysteresis = config.PowerLimiter.TargetPowerConsumptionHysteresis;
|
auto hysteresis = config.PowerLimiter.TargetPowerConsumptionHysteresis;
|
||||||
|
|
||||||
// (re-)send power limit in case the last was sent a long time ago. avoids
|
|
||||||
// staleness in case a power limit update was not received by the inverter.
|
|
||||||
auto ageMillis = millis() - _lastPowerLimitMillis;
|
|
||||||
|
|
||||||
if (diff < hysteresis && ageMillis < 60 * 1000) {
|
|
||||||
if (_verboseLogging) {
|
if (_verboseLogging) {
|
||||||
MessageOutput.printf("[DPL::setNewPowerLimit] requested: %d W, last limit: %d W, diff: %d W, hysteresis: %d W, age: %ld ms\r\n",
|
MessageOutput.printf("[DPL::setNewPowerLimit] calculated: %d W, "
|
||||||
newPowerLimit, _lastRequestedPowerLimit, diff, hysteresis, ageMillis);
|
"requesting: %d W, reported: %d W, diff: %d W, hysteresis: %d W\r\n",
|
||||||
}
|
newPowerLimit, effPowerLimit, currentLimitAbs, diff, hysteresis);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_verboseLogging) {
|
if (diff > hysteresis) {
|
||||||
MessageOutput.printf("[DPL::setNewPowerLimit] requested: %d W, (re-)sending limit: %d W\r\n",
|
_oTargetPowerLimitWatts = effPowerLimit;
|
||||||
newPowerLimit, effPowerLimit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
commitPowerLimit(inverter, effPowerLimit, true);
|
_oTargetPowerState = true;
|
||||||
return true;
|
return updateInverter();
|
||||||
}
|
}
|
||||||
|
|
||||||
int32_t PowerLimiterClass::getSolarChargePower()
|
int32_t PowerLimiterClass::getSolarChargePower()
|
||||||
|
|||||||
@ -54,6 +54,66 @@ void WebApiWsLiveClass::wsCleanupTaskCb()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void WebApiWsLiveClass::generateOnBatteryJsonResponse(JsonVariant& root, bool all)
|
||||||
|
{
|
||||||
|
auto constexpr halfOfAllMillis = std::numeric_limits<uint32_t>::max() / 2;
|
||||||
|
|
||||||
|
if (all || (millis() - _lastPublishVictron) > VictronMppt.getDataAgeMillis()) {
|
||||||
|
JsonObject vedirectObj = root.createNestedObject("vedirect");
|
||||||
|
vedirectObj["enabled"] = Configuration.get().Vedirect.Enabled;
|
||||||
|
JsonObject totalVeObj = vedirectObj.createNestedObject("total");
|
||||||
|
|
||||||
|
addTotalField(totalVeObj, "Power", VictronMppt.getPanelPowerWatts(), "W", 1);
|
||||||
|
addTotalField(totalVeObj, "YieldDay", VictronMppt.getYieldDay() * 1000, "Wh", 0);
|
||||||
|
addTotalField(totalVeObj, "YieldTotal", VictronMppt.getYieldTotal(), "kWh", 2);
|
||||||
|
|
||||||
|
if (!all) { _lastPublishVictron = millis(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (all || (HuaweiCan.getLastUpdate() - _lastPublishHuawei) < halfOfAllMillis ) {
|
||||||
|
JsonObject huaweiObj = root.createNestedObject("huawei");
|
||||||
|
huaweiObj["enabled"] = Configuration.get().Huawei.Enabled;
|
||||||
|
const RectifierParameters_t * rp = HuaweiCan.get();
|
||||||
|
addTotalField(huaweiObj, "Power", rp->output_power, "W", 2);
|
||||||
|
|
||||||
|
if (!all) { _lastPublishHuawei = millis(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
auto spStats = Battery.getStats();
|
||||||
|
if (all || spStats->updateAvailable(_lastPublishBattery)) {
|
||||||
|
JsonObject batteryObj = root.createNestedObject("battery");
|
||||||
|
batteryObj["enabled"] = Configuration.get().Battery.Enabled;
|
||||||
|
addTotalField(batteryObj, "soc", spStats->getSoC(), "%", 0);
|
||||||
|
|
||||||
|
if (!all) { _lastPublishBattery = millis(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (all || (PowerMeter.getLastPowerMeterUpdate() - _lastPublishPowerMeter) < halfOfAllMillis) {
|
||||||
|
JsonObject powerMeterObj = root.createNestedObject("power_meter");
|
||||||
|
powerMeterObj["enabled"] = Configuration.get().PowerMeter.Enabled;
|
||||||
|
addTotalField(powerMeterObj, "Power", PowerMeter.getPowerTotal(false), "W", 1);
|
||||||
|
|
||||||
|
if (!all) { _lastPublishPowerMeter = millis(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebApiWsLiveClass::sendOnBatteryStats()
|
||||||
|
{
|
||||||
|
DynamicJsonDocument root(512);
|
||||||
|
if (!Utils::checkJsonAlloc(root, __FUNCTION__, __LINE__)) { return; }
|
||||||
|
|
||||||
|
JsonVariant var = root;
|
||||||
|
|
||||||
|
bool all = (millis() - _lastPublishOnBatteryFull) > 10 * 1000;
|
||||||
|
if (all) { _lastPublishOnBatteryFull = millis(); }
|
||||||
|
generateOnBatteryJsonResponse(var, all);
|
||||||
|
|
||||||
|
String buffer;
|
||||||
|
serializeJson(root, buffer);
|
||||||
|
|
||||||
|
_ws.textAll(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
void WebApiWsLiveClass::sendDataTaskCb()
|
void WebApiWsLiveClass::sendDataTaskCb()
|
||||||
{
|
{
|
||||||
// do nothing if no WS client is connected
|
// do nothing if no WS client is connected
|
||||||
@ -61,6 +121,8 @@ void WebApiWsLiveClass::sendDataTaskCb()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendOnBatteryStats();
|
||||||
|
|
||||||
// Loop all inverters
|
// Loop all inverters
|
||||||
for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) {
|
for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) {
|
||||||
auto inv = Hoymiles.getInverterByPos(i);
|
auto inv = Hoymiles.getInverterByPos(i);
|
||||||
@ -115,27 +177,6 @@ void WebApiWsLiveClass::generateCommonJsonResponse(JsonVariant& root)
|
|||||||
hintObj["time_sync"] = !getLocalTime(&timeinfo, 5);
|
hintObj["time_sync"] = !getLocalTime(&timeinfo, 5);
|
||||||
hintObj["radio_problem"] = (Hoymiles.getRadioNrf()->isInitialized() && (!Hoymiles.getRadioNrf()->isConnected() || !Hoymiles.getRadioNrf()->isPVariant())) || (Hoymiles.getRadioCmt()->isInitialized() && (!Hoymiles.getRadioCmt()->isConnected()));
|
hintObj["radio_problem"] = (Hoymiles.getRadioNrf()->isInitialized() && (!Hoymiles.getRadioNrf()->isConnected() || !Hoymiles.getRadioNrf()->isPVariant())) || (Hoymiles.getRadioCmt()->isInitialized() && (!Hoymiles.getRadioCmt()->isConnected()));
|
||||||
hintObj["default_password"] = strcmp(Configuration.get().Security.Password, ACCESS_POINT_PASSWORD) == 0;
|
hintObj["default_password"] = strcmp(Configuration.get().Security.Password, ACCESS_POINT_PASSWORD) == 0;
|
||||||
|
|
||||||
JsonObject vedirectObj = root.createNestedObject("vedirect");
|
|
||||||
vedirectObj["enabled"] = Configuration.get().Vedirect.Enabled;
|
|
||||||
JsonObject totalVeObj = vedirectObj.createNestedObject("total");
|
|
||||||
|
|
||||||
addTotalField(totalVeObj, "Power", VictronMppt.getPanelPowerWatts(), "W", 1);
|
|
||||||
addTotalField(totalVeObj, "YieldDay", VictronMppt.getYieldDay() * 1000, "Wh", 0);
|
|
||||||
addTotalField(totalVeObj, "YieldTotal", VictronMppt.getYieldTotal(), "kWh", 2);
|
|
||||||
|
|
||||||
JsonObject huaweiObj = root.createNestedObject("huawei");
|
|
||||||
huaweiObj["enabled"] = Configuration.get().Huawei.Enabled;
|
|
||||||
const RectifierParameters_t * rp = HuaweiCan.get();
|
|
||||||
addTotalField(huaweiObj, "Power", rp->output_power, "W", 2);
|
|
||||||
|
|
||||||
JsonObject batteryObj = root.createNestedObject("battery");
|
|
||||||
batteryObj["enabled"] = Configuration.get().Battery.Enabled;
|
|
||||||
addTotalField(batteryObj, "soc", Battery.getStats()->getSoC(), "%", 0);
|
|
||||||
|
|
||||||
JsonObject powerMeterObj = root.createNestedObject("power_meter");
|
|
||||||
powerMeterObj["enabled"] = Configuration.get().PowerMeter.Enabled;
|
|
||||||
addTotalField(powerMeterObj, "Power", PowerMeter.getPowerTotal(false), "W", 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void WebApiWsLiveClass::generateInverterCommonJsonResponse(JsonObject& root, std::shared_ptr<InverterAbstract> inv)
|
void WebApiWsLiveClass::generateInverterCommonJsonResponse(JsonObject& root, std::shared_ptr<InverterAbstract> inv)
|
||||||
@ -279,6 +320,8 @@ void WebApiWsLiveClass::onLivedataStatus(AsyncWebServerRequest* request)
|
|||||||
|
|
||||||
generateCommonJsonResponse(root);
|
generateCommonJsonResponse(root);
|
||||||
|
|
||||||
|
generateOnBatteryJsonResponse(root, true);
|
||||||
|
|
||||||
response->setLength();
|
response->setLength();
|
||||||
request->send(response);
|
request->send(response);
|
||||||
|
|
||||||
|
|||||||
@ -123,8 +123,8 @@ export default defineComponent({
|
|||||||
totalData: { type: Object as PropType<Total>, required: true },
|
totalData: { type: Object as PropType<Total>, required: true },
|
||||||
totalVeData: { type: Object as PropType<Vedirect>, required: true },
|
totalVeData: { type: Object as PropType<Vedirect>, required: true },
|
||||||
totalBattData: { type: Object as PropType<Battery>, required: true },
|
totalBattData: { type: Object as PropType<Battery>, required: true },
|
||||||
powerMeterData: { type: Object as PropType<Huawei>, required: true },
|
powerMeterData: { type: Object as PropType<PowerMeter>, required: true },
|
||||||
huaweiData: { type: Object as PropType<PowerMeter>, required: true },
|
huaweiData: { type: Object as PropType<Huawei>, required: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -461,6 +461,14 @@ export default defineComponent({
|
|||||||
console.log(event);
|
console.log(event);
|
||||||
if (event.data != "{}") {
|
if (event.data != "{}") {
|
||||||
const newData = JSON.parse(event.data);
|
const newData = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (typeof newData.vedirect !== 'undefined') { Object.assign(this.liveData.vedirect, newData.vedirect); }
|
||||||
|
if (typeof newData.huawei !== 'undefined') { Object.assign(this.liveData.huawei, newData.huawei); }
|
||||||
|
if (typeof newData.battery !== 'undefined') { Object.assign(this.liveData.battery, newData.battery); }
|
||||||
|
if (typeof newData.power_meter !== 'undefined') { Object.assign(this.liveData.power_meter, newData.power_meter); }
|
||||||
|
|
||||||
|
if (typeof newData.total === 'undefined') { return; }
|
||||||
|
|
||||||
Object.assign(this.liveData.total, newData.total);
|
Object.assign(this.liveData.total, newData.total);
|
||||||
Object.assign(this.liveData.hints, newData.hints);
|
Object.assign(this.liveData.hints, newData.hints);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user