From c560d1d90e9f191f51733f53d67dfc5c5c4229e1 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Thu, 29 Feb 2024 17:09:18 +0100 Subject: [PATCH 1/8] fix: copy OnBattery-specific data from live view websocket (#696) closes #685. closes #682. --- webapp/src/components/InverterTotalInfo.vue | 4 ++-- webapp/src/views/HomeView.vue | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/InverterTotalInfo.vue b/webapp/src/components/InverterTotalInfo.vue index b7c671955..755a870ce 100644 --- a/webapp/src/components/InverterTotalInfo.vue +++ b/webapp/src/components/InverterTotalInfo.vue @@ -123,8 +123,8 @@ export default defineComponent({ totalData: { type: Object as PropType, required: true }, totalVeData: { type: Object as PropType, required: true }, totalBattData: { type: Object as PropType, required: true }, - powerMeterData: { type: Object as PropType, required: true }, - huaweiData: { type: Object as PropType, required: true }, + powerMeterData: { type: Object as PropType, required: true }, + huaweiData: { type: Object as PropType, required: true }, }, }); diff --git a/webapp/src/views/HomeView.vue b/webapp/src/views/HomeView.vue index f35ecd98f..b8f4b682c 100644 --- a/webapp/src/views/HomeView.vue +++ b/webapp/src/views/HomeView.vue @@ -463,6 +463,10 @@ export default defineComponent({ const newData = JSON.parse(event.data); Object.assign(this.liveData.total, newData.total); Object.assign(this.liveData.hints, newData.hints); + Object.assign(this.liveData.vedirect, newData.vedirect); + Object.assign(this.liveData.huawei, newData.huawei); + Object.assign(this.liveData.battery, newData.battery); + Object.assign(this.liveData.power_meter, newData.power_meter); const foundIdx = this.liveData.inverters.findIndex((element) => element.serial == newData.inverters[0].serial); if (foundIdx == -1) { From 8b6e57cda75c2e5577cd2a197afdf3a84b855e3b Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Tue, 27 Feb 2024 22:07:30 +0100 Subject: [PATCH 2/8] Fix: DPL: ensure inverter reaches requested state we previously only called commitPowerLimit() if the desired limit changed such that the change was bigger than the hysteresis. we found that if the limit update was not received and the desired limit would not change much, the limit of the inverter was wrong for a long time. to mitigate this, we introduced re-sending the limit update every 60 seconds, regardless of what the limit reported by the inverter was at that time. if the power-up command was not received, we also would repeat it only once every 60 seconds. this leads to a new kind of staleness and the actual inverter state was still not matching the desired state. this new approach effectively adds an additional control loop at the start of the DPL loop(). that new function compares the requested inverter state to the actual reported state. it sends updates (limit update or power on state) until the desired inverter state is reached, or until a (hard-coded) timeout occurs. this approach also allows us to send power-up, power-down, and limit update commands independent from one another and in a particular order. this should make sure that the inverter is in the desired state even if conditions change slowly and commands were not received as expected. --- include/PowerLimiter.h | 15 ++- src/PowerLimiter.cpp | 258 +++++++++++++++++++++++++---------------- 2 files changed, 166 insertions(+), 107 deletions(-) diff --git a/include/PowerLimiter.h b/include/PowerLimiter.h index 32260150a..81e5be381 100644 --- a/include/PowerLimiter.h +++ b/include/PowerLimiter.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -15,10 +16,6 @@ #define PL_UI_STATE_USE_SOLAR_ONLY 2 #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 { EMPTY_WHEN_FULL= 0, EMPTY_AT_NIGHT @@ -51,7 +48,7 @@ class PowerLimiterClass { void init(Scheduler& scheduler); uint8_t getPowerLimiterState(); - int32_t getLastRequestedPowerLimit(); + int32_t getLastRequestedPowerLimit() { return _lastRequestedPowerLimit; } enum class Mode : unsigned { Normal = 0, @@ -69,8 +66,10 @@ class PowerLimiterClass { Task _loopTask; int32_t _lastRequestedPowerLimit = 0; - uint32_t _lastPowerLimitMillis = 0; - uint32_t _shutdownTimeout = 0; + bool _shutdownPending = false; + std::optional _oUpdateStartMillis = std::nullopt; + std::optional _oTargetPowerLimitWatts = std::nullopt; + std::optional _oTargetPowerState = std::nullopt; Status _lastStatus = Status::Initializing; uint32_t _lastStatusPrinted = 0; uint32_t _lastCalculation = 0; @@ -93,7 +92,7 @@ class PowerLimiterClass { void unconditionalSolarPassthrough(std::shared_ptr inverter); bool canUseDirectSolarPower(); int32_t calcPowerLimit(std::shared_ptr inverter, bool solarPowerEnabled, bool batteryDischargeEnabled); - void commitPowerLimit(std::shared_ptr inverter, int32_t limit, bool enablePowerProduction); + bool updateInverter(); bool setNewPowerLimit(std::shared_ptr inverter, int32_t newPowerLimit); int32_t getSolarChargePower(); float getLoadCorrectedVoltage(); diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index b4c2229a9..58b35db93 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -30,7 +30,7 @@ frozen::string const& PowerLimiterClass::getStatusText(PowerLimiterClass::Status { static const frozen::string missing = "programmer error: missing status text"; - static const frozen::map texts = { + static const frozen::map texts = { { Status::Initializing, "initializing (should not see me)" }, { Status::DisabledByConfig, "disabled by configuration" }, { Status::DisabledByMqtt, "disabled by MQTT" }, @@ -48,7 +48,6 @@ frozen::string const& PowerLimiterClass::getStatusText(PowerLimiterClass::Status { Status::InverterStatsPending, "waiting for sufficiently recent inverter data" }, { Status::UnconditionalSolarPassthrough, "unconditionally passing through all solar power (MQTT override)" }, { 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" }, }; @@ -79,36 +78,18 @@ void PowerLimiterClass::announceStatus(PowerLimiterClass::Status status) /** * 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 - * 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) { announceStatus(status); - if (_inverter == nullptr || !_inverter->isProducing() || - (_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) - - // retry shutdown for a maximum amount of time before giving up - 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); + _shutdownPending = true; - return true; + _oTargetPowerState = false; + _oTargetPowerLimitWatts = Configuration.get().PowerLimiter.LowerPowerLimit; + return updateInverter(); } void PowerLimiterClass::loop() @@ -124,12 +105,13 @@ void PowerLimiterClass::loop() return announceStatus(Status::WaitingForValidTimestamp); } - if (_shutdownTimeout > 0) { - // we transition from SHUTDOWN to OFF when we know the inverter was - // shut down. until then, we retry shutting it down. in this case we - // preserve the original status that lead to the decision to shut down. - shutdown(); - return; + // take care that the last requested power + // limit and power state are actually reached + if (updateInverter()) { return; } + + if (_shutdownPending) { + _shutdownPending = false; + _inverter = nullptr; } if (!config.PowerLimiter.Enabled) { @@ -172,18 +154,6 @@ void PowerLimiterClass::loop() 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 // device's max power. that upper limit is only known after the first // DevInfoSimpleCommand succeeded. @@ -214,16 +184,11 @@ void PowerLimiterClass::loop() _inverter->SystemConfigPara()->getLastUpdateCommand(), _inverter->PowerCommand()->getLastUpdateCommand()); - // wait for power meter and inverter stat updates after a settling phase - auto settlingEnd = lastUpdateCmd + 3 * 1000; - - if (millis() < settlingEnd) { return announceStatus(Status::Settling); } - - if (_inverter->Statistics()->getLastUpdate() <= settlingEnd) { + if (_inverter->Statistics()->getLastUpdate() <= lastUpdateCmd) { return announceStatus(Status::InverterStatsPending); } - if (PowerMeter.getLastPowerMeterUpdate() <= settlingEnd) { + if (PowerMeter.getLastPowerMeterUpdate() <= lastUpdateCmd) { return announceStatus(Status::PowerMeterPending); } @@ -323,12 +288,6 @@ void PowerLimiterClass::loop() int32_t newPowerLimit = calcPowerLimit(_inverter, canUseDirectSolarPower(), _batteryDischargeEnabled); 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(); if (!limitUpdated) { @@ -441,10 +400,6 @@ uint8_t PowerLimiterClass::getPowerLimiterState() { return PL_UI_STATE_INACTIVE; } -int32_t PowerLimiterClass::getLastRequestedPowerLimit() { - return _lastRequestedPowerLimit; -} - bool PowerLimiterClass::canUseDirectSolarPower() { CONFIG_T& config = Configuration.get(); @@ -527,34 +482,141 @@ int32_t PowerLimiterClass::calcPowerLimit(std::shared_ptr inve return newPowerLimit; } -void PowerLimiterClass::commitPowerLimit(std::shared_ptr 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() { - // disable power production as soon as possible. - // setting the power limit is less important. - if (!enablePowerProduction && inverter->isProducing()) { - MessageOutput.println("[DPL::commitPowerLimit] Stopping inverter..."); - inverter->sendPowerControlRequest(false); - } + auto reset = [this]() -> bool { + _oTargetPowerState = std::nullopt; + _oTargetPowerLimitWatts = std::nullopt; + _oUpdateStartMillis = std::nullopt; + return false; + }; - inverter->sendActivePowerControlRequest(static_cast(limit), - PowerLimitControlType::AbsolutNonPersistent); + if (nullptr == _inverter) { return reset(); } - _lastRequestedPowerLimit = limit; - _lastPowerLimitMillis = millis(); + if (!_oUpdateStartMillis.has_value()) { + _oUpdateStartMillis = millis(); + } - // enable power production only after setting the desired limit, - // 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); + 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::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(*_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(newRelativeLimit), + PowerLimitControlType::RelativNonPersistent); + + _lastRequestedPowerLimit = *_oTargetPowerLimitWatts; + return true; + }; + + // disable power production as soon as possible. + // setting the power limit is less important once the inverter is off. + if (switchPowerState(false)) { return true; } + + if (updateLimit()) { return true; } + + // enable power production only after setting the desired limit + if (switchPowerState(true)) { return true; } + + return reset(); } /** - * enforces limits and a hystersis 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 a limit update was - * committed, false otherwise. + * 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 inverter, int32_t newPowerLimit) { @@ -587,31 +649,29 @@ bool PowerLimiterClass::setNewPowerLimit(std::shared_ptr inver effPowerLimit = round(effPowerLimit * static_cast(dcTotalChnls) / dcProdChnls); } - effPowerLimit = std::min(effPowerLimit, inverter->DevInfo()->getMaxPower()); + // early in the loop we make it a pre-requisite that this + // value is non-zero, so we can assume it to be valid. + auto maxPower = inverter->DevInfo()->getMaxPower(); - // Check if the new value is within the limits of the hysteresis - auto diff = std::abs(effPowerLimit - _lastRequestedPowerLimit); - auto hysteresis = config.PowerLimiter.TargetPowerConsumptionHysteresis; + effPowerLimit = std::min(effPowerLimit, maxPower); - // (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; + float currentLimitPercent = inverter->SystemConfigPara()->getLimitPercent(); + auto currentLimitAbs = static_cast(currentLimitPercent * maxPower / 100); + auto diff = std::abs(currentLimitAbs - effPowerLimit); + auto hysteresis = config.PowerLimiter.TargetPowerConsumptionHysteresis; - if (diff < hysteresis && ageMillis < 60 * 1000) { - if (_verboseLogging) { - MessageOutput.printf("[DPL::setNewPowerLimit] requested: %d W, last limit: %d W, diff: %d W, hysteresis: %d W, age: %ld ms\r\n", - newPowerLimit, _lastRequestedPowerLimit, diff, hysteresis, ageMillis); - } - return false; + if (_verboseLogging) { + MessageOutput.printf("[DPL::setNewPowerLimit] calculated: %d W, " + "requesting: %d W, reported: %d W, diff: %d W, hysteresis: %d W\r\n", + newPowerLimit, effPowerLimit, currentLimitAbs, diff, hysteresis); } - if (_verboseLogging) { - MessageOutput.printf("[DPL::setNewPowerLimit] requested: %d W, (re-)sending limit: %d W\r\n", - newPowerLimit, effPowerLimit); + if (diff > hysteresis) { + _oTargetPowerLimitWatts = effPowerLimit; } - commitPowerLimit(inverter, effPowerLimit, true); - return true; + _oTargetPowerState = true; + return updateInverter(); } int32_t PowerLimiterClass::getSolarChargePower() From fe7e622e2d12a513235187719ffaf6b4691b7762 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Sun, 3 Mar 2024 23:16:13 +0100 Subject: [PATCH 3/8] pull requests: use a meaningful branch when building This change makes the build runner switch to a meaningful branch name, which will then appear as the "Firmware Branch" in the System Info of the web application. This helps users testing pull-request builds identify that they are actually using the changes from the respective pull request. --- .github/workflows/build.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d8a9b1f65..83f9bab23 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,6 +60,15 @@ jobs: - name: Get tags 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 uses: actions/cache@v4 with: From e432f0eca36d73f0a87423918c3d09eefef43962 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Mon, 4 Mar 2024 22:18:43 +0100 Subject: [PATCH 4/8] make BateryStats::updateAvailable wrap-around-safe --- include/BatteryStats.h | 2 +- src/BatteryStats.cpp | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/include/BatteryStats.h b/include/BatteryStats.h index e4bf4144e..c11f45f95 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -15,7 +15,7 @@ class BatteryStats { // the last time *any* datum was updated 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; } uint32_t getSoCAgeSeconds() const { return (millis() - _lastUpdateSoC) / 1000; } diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index 807f1a4c2..48d089165 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -51,6 +51,12 @@ static void addLiveViewAlarm(JsonVariant& root, std::string const& name, root["issues"][name] = 2; } +bool BatteryStats::updateAvailable(uint32_t since) const +{ + auto constexpr halfOfAllMillis = std::numeric_limits::max() / 2; + return (_lastUpdate - since) < halfOfAllMillis; +} + void BatteryStats::getLiveViewData(JsonVariant& root) const { root[F("manufacturer")] = _manufacturer; From 50635ee2ce64494f8a745cc4771d3d457bcbaf64 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Mon, 4 Mar 2024 20:27:47 +0100 Subject: [PATCH 5/8] Feature: live view: update with respective frequency the update frequency of Victron MPPT charger data, the battery Soc, the huawei charger power, and the power meter differ from one another, and differ in particular from the inverter update frequency. the OnBattery-specific data is now handled in a new method, outside the upstream code, which merely call the new function(s). the new function will update the websocket independently from inverter updates. also, it adds the respective data if it actually changed since it was last updated through the websocket. for the webapp to be able to recover in case of errors, all values are also written to the websocket with a fixed interval of 10 seconds. --- include/WebApi_ws_live.h | 9 ++++ src/WebApi_ws_live.cpp | 85 ++++++++++++++++++++++++++--------- webapp/src/views/HomeView.vue | 12 +++-- 3 files changed, 81 insertions(+), 25 deletions(-) diff --git a/include/WebApi_ws_live.h b/include/WebApi_ws_live.h index 05f8ab8f9..4a29fff5b 100644 --- a/include/WebApi_ws_live.h +++ b/include/WebApi_ws_live.h @@ -17,6 +17,9 @@ class WebApiWsLiveClass { static void generateInverterChannelJsonResponse(JsonObject& root, std::shared_ptr inv); static void generateCommonJsonResponse(JsonVariant& root); + void generateOnBatteryJsonResponse(JsonVariant& root, bool all); + void sendOnBatteryStats(); + static void addField(JsonObject& root, std::shared_ptr 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); @@ -25,6 +28,12 @@ class WebApiWsLiveClass { 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 }; std::mutex _mutex; diff --git a/src/WebApi_ws_live.cpp b/src/WebApi_ws_live.cpp index e51361d7c..d2ed35d9d 100644 --- a/src/WebApi_ws_live.cpp +++ b/src/WebApi_ws_live.cpp @@ -54,6 +54,66 @@ void WebApiWsLiveClass::wsCleanupTaskCb() } } +void WebApiWsLiveClass::generateOnBatteryJsonResponse(JsonVariant& root, bool all) +{ + auto constexpr halfOfAllMillis = std::numeric_limits::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() { // do nothing if no WS client is connected @@ -61,6 +121,8 @@ void WebApiWsLiveClass::sendDataTaskCb() return; } + sendOnBatteryStats(); + // Loop all inverters for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) { auto inv = Hoymiles.getInverterByPos(i); @@ -115,27 +177,6 @@ void WebApiWsLiveClass::generateCommonJsonResponse(JsonVariant& root) 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["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 inv) @@ -279,6 +320,8 @@ void WebApiWsLiveClass::onLivedataStatus(AsyncWebServerRequest* request) generateCommonJsonResponse(root); + generateOnBatteryJsonResponse(root, true); + response->setLength(); request->send(response); diff --git a/webapp/src/views/HomeView.vue b/webapp/src/views/HomeView.vue index b8f4b682c..9371d0fae 100644 --- a/webapp/src/views/HomeView.vue +++ b/webapp/src/views/HomeView.vue @@ -461,12 +461,16 @@ export default defineComponent({ console.log(event); if (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.hints, newData.hints); - Object.assign(this.liveData.vedirect, newData.vedirect); - Object.assign(this.liveData.huawei, newData.huawei); - Object.assign(this.liveData.battery, newData.battery); - Object.assign(this.liveData.power_meter, newData.power_meter); const foundIdx = this.liveData.inverters.findIndex((element) => element.serial == newData.inverters[0].serial); if (foundIdx == -1) { From 64ad4bded14b4d2aef020e8d8d9eea70f675a513 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Thu, 7 Mar 2024 20:39:40 +0100 Subject: [PATCH 6/8] fix: DPL: limit scaling sanity checks do not scale limit if inverter is not producing, as DC channel power is expected to be close to zero anyways. do not scale limit if current inverter limit is small, such that channels might produce very little power exactly because the limit is so low. move the calculation out of setNewPowerLimit and into a new function, so that we can make use of return statements there. --- src/PowerLimiter.cpp | 65 +++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index 58b35db93..32fa9762d 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -612,6 +612,47 @@ bool PowerLimiterClass::updateInverter() return reset(); } +/** + * scale the desired inverter limit such that the actual inverter AC output is + * close to the desired power limit, even if some input channels are producing + * less than the limit allows. this happens because the inverter seems to split + * 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 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 dcChnls = inverter->Statistics()->getChannelsByType(TYPE_DC); + size_t dcTotalChnls = dcChnls.size(); + + // 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 == 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(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 @@ -632,31 +673,17 @@ bool PowerLimiterClass::setNewPowerLimit(std::shared_ptr inver // enforce configured upper power limit int32_t effPowerLimit = std::min(newPowerLimit, config.PowerLimiter.UpperPowerLimit); - // scale the power limit by the amount of all inverter channels devided by - // the amount of producing inverter channels. the inverters limit each of - // the n channels to 1/n of the total power limit. scaling the power limit - // ensures the total inverter output is what we are asking for. - std::list 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(dcTotalChnls) / dcProdChnls); - } - // early in the loop we make it a pre-requisite that this // value is non-zero, so we can assume it to be valid. auto maxPower = inverter->DevInfo()->getMaxPower(); - effPowerLimit = std::min(effPowerLimit, maxPower); - float currentLimitPercent = inverter->SystemConfigPara()->getLimitPercent(); auto currentLimitAbs = static_cast(currentLimitPercent * maxPower / 100); + + effPowerLimit = scalePowerLimit(inverter, effPowerLimit, currentLimitAbs); + + effPowerLimit = std::min(effPowerLimit, maxPower); + auto diff = std::abs(currentLimitAbs - effPowerLimit); auto hysteresis = config.PowerLimiter.TargetPowerConsumptionHysteresis; From b0795a21310ec04a05b96736b5e9c11e698c77ee Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Thu, 7 Mar 2024 21:15:43 +0100 Subject: [PATCH 7/8] DPL limit scaling: only for supported models --- src/PowerLimiter.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index 32fa9762d..ec3542ad7 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -12,6 +12,7 @@ #include "Huawei_can.h" #include #include "MessageOutput.h" +#include "inverters/HMS_4CH.h" #include #include #include @@ -634,6 +635,14 @@ static int32_t scalePowerLimit(std::shared_ptr inverter, int32 std::list 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. From c42d68812c57aa5153d8e44507d032a51ce0c97c Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Thu, 7 Mar 2024 21:16:07 +0100 Subject: [PATCH 8/8] DPL limit scaling: prevent division by zero this check was removed on error when moving the scaling code into its own function. --- src/PowerLimiter.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index ec3542ad7..a128f9584 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -655,7 +655,7 @@ static int32_t scalePowerLimit(std::shared_ptr inverter, int32 } } - if (dcProdChnls == dcTotalChnls) { return newLimit; } + if (dcProdChnls == 0 || dcProdChnls == dcTotalChnls) { return newLimit; } MessageOutput.printf("[DPL::scalePowerLimit] %d channels total, %d producing " "channels, scaling power limit\r\n", dcTotalChnls, dcProdChnls);