diff --git a/README.md b/README.md index 2dda1d67..7572d3a2 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,12 @@ Topics for 3 phases of a power meter is configurable. Given is an example for th | huawei/output_temp | R | Output air temperature | °C | | huawei/efficiency | R | Efficiency | Percentage | +## Power Limiter topics +| Topic | R / W | Description | Value / Unit | +| --------------------------------------- | ----- | ---------------------------------------------------- | -------------------------- | +| powerlimiter/cmd/disable | W | Power Limiter disable override for external PL control | 0 / 1 | +| powerlimiter/status/disabled | R | Power Limiter disable override status | 0 / 1 | + ## Currently supported Inverters | Model | Required RF Module | DC Inputs | MPP-Tracker | AC Phases | diff --git a/include/MqttHandlePowerLimiter.h b/include/MqttHandlePowerLimiter.h new file mode 100644 index 00000000..82d736ea --- /dev/null +++ b/include/MqttHandlePowerLimiter.h @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include "Configuration.h" +#include + +class MqttHandlePowerLimiterClass { +public: + void init(); + void loop(); + +private: + void onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total); + + uint32_t _lastPublishStats; + uint32_t _lastPublish; + +}; + +extern MqttHandlePowerLimiterClass MqttHandlePowerLimiter; \ No newline at end of file diff --git a/include/PowerLimiter.h b/include/PowerLimiter.h index 96baaf7c..fefa9ed0 100644 --- a/include/PowerLimiter.h +++ b/include/PowerLimiter.h @@ -8,10 +8,8 @@ #include typedef enum { - STATE_DISCOVER = 0, - STATE_OFF, - STATE_CONSUME_SOLAR_POWER_ONLY, - STATE_NORMAL_OPERATION + SHUTDOWN = 0, + ACTIVE } plStates; typedef enum { @@ -26,13 +24,16 @@ public: void loop(); plStates getPowerLimiterState(); int32_t getLastRequestedPowewrLimit(); + void setDisable(bool disable); + bool getDisable(); private: - uint32_t _lastCommandSent = 0; uint32_t _lastLoop = 0; int32_t _lastRequestedPowerLimit = 0; uint32_t _lastLimitSetTime = 0; - plStates _plState = STATE_DISCOVER; + plStates _plState = ACTIVE; + bool _disabled = false; + bool _batteryDischargeEnabled = false; float _powerMeter1Power; float _powerMeter2Power; diff --git a/src/MqttHandlePowerLimiter.cpp b/src/MqttHandlePowerLimiter.cpp new file mode 100644 index 00000000..fdff3ee7 --- /dev/null +++ b/src/MqttHandlePowerLimiter.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2022 Thomas Basler, Malte Schmidt and others + */ +#include "MessageOutput.h" +#include "MqttSettings.h" +#include "MqttHandlePowerLimiter.h" +#include "PowerLimiter.h" +#include + +#define TOPIC_SUB_POWER_LIMITER "disable" + +MqttHandlePowerLimiterClass MqttHandlePowerLimiter; + +void MqttHandlePowerLimiterClass::init() +{ + using std::placeholders::_1; + using std::placeholders::_2; + using std::placeholders::_3; + using std::placeholders::_4; + using std::placeholders::_5; + using std::placeholders::_6; + + String topic = MqttSettings.getPrefix(); + MqttSettings.subscribe(String(topic + "powerlimiter/cmd/" + TOPIC_SUB_POWER_LIMITER).c_str(), 0, std::bind(&MqttHandlePowerLimiterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); + + _lastPublish = millis(); + +} + + +void MqttHandlePowerLimiterClass::loop() +{ + if (!MqttSettings.getConnected() ) { + return; + } + + const CONFIG_T& config = Configuration.get(); + + if ((millis() - _lastPublish) > (config.Mqtt_PublishInterval * 1000) ) { + MqttSettings.publish("powerlimiter/status/disabled", String(PowerLimiter.getDisable())); + + yield(); + _lastPublish = millis(); + } +} + + +void MqttHandlePowerLimiterClass::onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) +{ + const CONFIG_T& config = Configuration.get(); + + char token_topic[MQTT_MAX_TOPIC_STRLEN + 40]; // respect all subtopics + strncpy(token_topic, topic, MQTT_MAX_TOPIC_STRLEN + 40); // convert const char* to char* + + char* setting; + char* rest = &token_topic[strlen(config.Mqtt_Topic)]; + + strtok_r(rest, "/", &rest); // Remove "powerlimiter" + strtok_r(rest, "/", &rest); // Remove "cmd" + + setting = strtok_r(rest, "/", &rest); + + if (setting == NULL) { + return; + } + + char* str = new char[len + 1]; + memcpy(str, payload, len); + str[len] = '\0'; + uint8_t payload_val = atoi(str); + delete[] str; + + if (!strcmp(setting, TOPIC_SUB_POWER_LIMITER)) { + if(payload_val == 1) { + MessageOutput.println("Power limiter disabled"); + PowerLimiter.setDisable(true); + return; + } + if(payload_val == 0) { + MessageOutput.println("Power limiter enabled"); + PowerLimiter.setDisable(false); + return; + } + MessageOutput.println("Power limiter enable / disable - unknown command received. Please use 0 or 1"); + } +} \ No newline at end of file diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index e6112bfb..e2b1a98c 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -23,13 +23,10 @@ void PowerLimiterClass::loop() { CONFIG_T& config = Configuration.get(); - if (!config.PowerLimiter_Enabled - || !config.PowerMeter_Enabled + // Run inital checks to make sure we have met the basic conditions + if (!config.PowerMeter_Enabled || !Hoymiles.isAllRadioIdle() - || (millis() - _lastCommandSent) < (config.PowerLimiter_Interval * 1000) || (millis() - _lastLoop) < (config.PowerLimiter_Interval * 1000)) { - if (!config.PowerLimiter_Enabled) - _plState = STATE_DISCOVER; // ensure STATE_DISCOVER is set, if PowerLimiter will be enabled. return; } @@ -40,106 +37,64 @@ void PowerLimiterClass::loop() return; } - float dcVoltage = inverter->Statistics()->getChannelFieldValue(TYPE_DC, (ChannelNum_t) config.PowerLimiter_InverterChannelId, FLD_UDC); - float acPower = inverter->Statistics()->getChannelFieldValue(TYPE_AC, (ChannelNum_t) config.PowerLimiter_InverterChannelId, FLD_PAC); - float correctedDcVoltage = dcVoltage + (acPower * config.PowerLimiter_VoltageLoadCorrectionFactor); + // Make sure inverter is turned off if PL is disabled by user/MQTT + // Make sure inverter is turned off when low battery threshold is reached + if (((!config.PowerLimiter_Enabled || _disabled) && _plState != SHUTDOWN) + || isStopThresholdReached(inverter)) { + if (inverter->isProducing()) { + MessageOutput.printf("PL initiated inverter shutdown.\r\n"); + inverter->sendPowerControlRequest(false); + } else { + _plState = SHUTDOWN; + } + return; + } + + // Return if power limiter is disabled + if (!config.PowerLimiter_Enabled || _disabled) { + return; + } + // At this point the PL is enabled but we could still be in the shutdown state + _plState = ACTIVE; // If the last inverter update is too old, don't do anything. // If the last inverter update was before the last limit updated, don't do anything. - // Also give the Power meter 3 seconds time to recognize power changes because of the last set limit - // and also because the Hoymiles MPPT might not react immediately. + // Also give the Power meter 3 seconds time to recognize power changes after the last set limit + // as the Hoymiles MPPT might not react immediately. if ((millis() - inverter->Statistics()->getLastUpdate()) > 10000 || inverter->Statistics()->getLastUpdate() <= _lastLimitSetTime || PowerMeter.getLastPowerMeterUpdate() <= (_lastLimitSetTime + 3000)) { return; } + // Printout some stats if (millis() - PowerMeter.getLastPowerMeterUpdate() < (30 * 1000)) { + float dcVoltage = inverter->Statistics()->getChannelFieldValue(TYPE_DC, (ChannelNum_t) config.PowerLimiter_InverterChannelId, FLD_UDC); MessageOutput.printf("[PowerLimiterClass::loop] dcVoltage: %.2f Voltage Start Threshold: %.2f Voltage Stop Threshold: %.2f inverter->isProducing(): %d\r\n", dcVoltage, config.PowerLimiter_VoltageStartThreshold, config.PowerLimiter_VoltageStopThreshold, inverter->isProducing()); } - while(true) { - switch(_plState) { - case STATE_DISCOVER: - if (!inverter->isProducing() || isStopThresholdReached(inverter)) { - _plState = STATE_OFF; - } - else if (canUseDirectSolarPower()) { - _plState = STATE_CONSUME_SOLAR_POWER_ONLY; - } - else { - _plState = STATE_NORMAL_OPERATION; - } - break; - case STATE_OFF: - // if on turn off - if (inverter->isProducing()) { - MessageOutput.printf("[PowerLimiterClass::loop] DC voltage: %.2f Corrected DC voltage: %.2f...\r\n", - dcVoltage, correctedDcVoltage); - setNewPowerLimit(inverter, -1); - return; - } - // do nothing if battery is empty - if (isStopThresholdReached(inverter)) - return; - // check for possible state changes - if (canUseDirectSolarPower()) { - _plState = STATE_CONSUME_SOLAR_POWER_ONLY; - } - if (isStartThresholdReached(inverter)) { - _plState = STATE_NORMAL_OPERATION; - } - return; - break; - case STATE_CONSUME_SOLAR_POWER_ONLY: { - int32_t newPowerLimit = calcPowerLimit(inverter, true); - if (isStopThresholdReached(inverter)) { - _plState = STATE_OFF; - break; - } - if (isStartThresholdReached(inverter)) { - _plState = STATE_NORMAL_OPERATION; - break; - } - - if (!canUseDirectSolarPower()) { - if (config.PowerLimiter_BatteryDrainStategy == EMPTY_AT_NIGHT) - _plState = STATE_NORMAL_OPERATION; - else - _plState = STATE_OFF; - break; - } - - setNewPowerLimit(inverter, newPowerLimit); - return; - break; - } - case STATE_NORMAL_OPERATION: { - int32_t newPowerLimit = calcPowerLimit(inverter, false); - if (isStopThresholdReached(inverter)) { - _plState = STATE_OFF; - break; - } - if (!isStartThresholdReached(inverter) && canUseDirectSolarPower() && (config.PowerLimiter_BatteryDrainStategy == EMPTY_AT_NIGHT)) { - _plState = STATE_CONSUME_SOLAR_POWER_ONLY; - break; - } - - // check if grid power consumption is not within the upper and lower threshold of the target consumption - if (newPowerLimit >= (config.PowerLimiter_TargetPowerConsumption - config.PowerLimiter_TargetPowerConsumptionHysteresis) && - newPowerLimit <= (config.PowerLimiter_TargetPowerConsumption + config.PowerLimiter_TargetPowerConsumptionHysteresis) && - _lastRequestedPowerLimit >= (config.PowerLimiter_TargetPowerConsumption - config.PowerLimiter_TargetPowerConsumptionHysteresis) && - _lastRequestedPowerLimit <= (config.PowerLimiter_TargetPowerConsumption + config.PowerLimiter_TargetPowerConsumptionHysteresis) ) { - return; - } - setNewPowerLimit(inverter, newPowerLimit);; - return; - break; - } - } + // Battery charging cycle conditions + // The battery can only be discharged after a full charge in the + // EMPTY_WHEN_FULL case + if (isStopThresholdReached(inverter)) { + // Disable battery discharge when empty + _batteryDischargeEnabled = false; + } else if (!canUseDirectSolarPower() || + config.PowerLimiter_BatteryDrainStategy == EMPTY_AT_NIGHT) { + // Enable battery discharge + _batteryDischargeEnabled = true; } + + // This checks if the battery discharge start conditions are met for the EMPTY_WHEN_FULL case + if (isStartThresholdReached(inverter) && config.PowerLimiter_BatteryDrainStategy == EMPTY_WHEN_FULL) { + _batteryDischargeEnabled = true; + } + + // Calculate and set Power Limit + int32_t newPowerLimit = calcPowerLimit(inverter, !_batteryDischargeEnabled); + setNewPowerLimit(inverter, newPowerLimit); } plStates PowerLimiterClass::getPowerLimiterState() { @@ -150,6 +105,14 @@ int32_t PowerLimiterClass::getLastRequestedPowewrLimit() { return _lastRequestedPowerLimit; } +bool PowerLimiterClass::getDisable() { + return _disabled; +} + +void PowerLimiterClass::setDisable(bool disable) { + _disabled = disable; +} + bool PowerLimiterClass::canUseDirectSolarPower() { CONFIG_T& config = Configuration.get(); @@ -167,67 +130,95 @@ bool PowerLimiterClass::canUseDirectSolarPower() return true; } + + + int32_t PowerLimiterClass::calcPowerLimit(std::shared_ptr inverter, bool consumeSolarPowerOnly) { CONFIG_T& config = Configuration.get(); int32_t newPowerLimit = round(PowerMeter.getPowerTotal()); - float efficency = inverter->Statistics()->getChannelFieldValue(TYPE_AC, (ChannelNum_t) config.PowerLimiter_InverterChannelId, FLD_EFF); - int32_t victronChargePower = this->getDirectSolarPower(); - int32_t adjustedVictronChargePower = victronChargePower * (efficency > 0.0 ? (efficency / 100.0) : 1.0); // if inverter is off, use 1.0 - - MessageOutput.printf("[PowerLimiterClass::loop] victronChargePower: %d, efficiency: %.2f, consumeSolarPowerOnly: %s, powerConsumption: %d \r\n", - victronChargePower, efficency, consumeSolarPowerOnly ? "true" : "false", newPowerLimit); - - // Safety check: Are the power meter values not too old? - // Are the reported inverter data not too old? - if (millis() - PowerMeter.getLastPowerMeterUpdate() < (30 * 1000) - && millis() - inverter->Statistics()->getLastUpdate() < (15 * 1000)) { - if (config.PowerLimiter_IsInverterBehindPowerMeter) { - // If the inverter the behind the power meter (part of measurement), - // the produced power of this inverter has also to be taken into account. - float acPower = inverter->Statistics()->getChannelFieldValue(TYPE_AC, (ChannelNum_t) config.PowerLimiter_InverterChannelId, FLD_PAC); - newPowerLimit += static_cast(acPower); - } - - newPowerLimit -= config.PowerLimiter_TargetPowerConsumption; - - int32_t upperPowerLimit = config.PowerLimiter_UpperPowerLimit; - if (consumeSolarPowerOnly && (upperPowerLimit > adjustedVictronChargePower)) { - // Battery voltage too low, use Victron solar power (corrected by efficency factor) only - upperPowerLimit = adjustedVictronChargePower; - } - - if (newPowerLimit > upperPowerLimit) - newPowerLimit = upperPowerLimit; - } else { - // If the power meter values are older than 30 seconds, - // set the limit to config.PowerLimiter_LowerPowerLimit for safety reasons. - newPowerLimit = config.PowerLimiter_LowerPowerLimit; + // Safety check, return on too old power meter values + if (millis() - PowerMeter.getLastPowerMeterUpdate() > (30 * 1000) + && (millis() - inverter->Statistics()->getLastUpdate()) > (config.Dtu_PollInterval * 10 * 1000)) { + // If the power meter values are older than 30 seconds, + // and the Inverter Stats are older then 10x the poll interval + // set the limit to 0W for safety reasons. + MessageOutput.println("[PowerLimiterClass::loop] Power Meter/Inverter values too old. Using 0W (i.e. disable inverter)"); + return 0; } + + if (config.PowerLimiter_IsInverterBehindPowerMeter) { + // If the inverter the behind the power meter (part of measurement), + // the produced power of this inverter has also to be taken into account. + // We don't use FLD_PAC from the statistics, because that + // data might be too old and unreliable. + float acPower = inverter->Statistics()->getChannelFieldValue(TYPE_AC, (ChannelNum_t) config.PowerLimiter_InverterChannelId, FLD_PAC); + newPowerLimit += static_cast(acPower); + } + + // We're not trying to hit 0 exactly but take an offset into account + // This means we never fully compensate the used power with the inverter + newPowerLimit -= config.PowerLimiter_TargetPowerConsumption; + + // Check if the new value is within the limits of the hysteresis and + // if we're not limited to Solar Power only (i.e. we can discharge the battery) + // If things did not change much we just use the old setting + if (newPowerLimit >= (-config.PowerLimiter_TargetPowerConsumptionHysteresis) && + newPowerLimit <= (+config.PowerLimiter_TargetPowerConsumptionHysteresis) && + !consumeSolarPowerOnly ) { + MessageOutput.println("[PowerLimiterClass::loop] reusing old limit"); + return _lastRequestedPowerLimit; + } + + // We should use Victron solar power only (corrected by efficiency factor) + if (consumeSolarPowerOnly) { + float efficiency = inverter->Statistics()->getChannelFieldValue(TYPE_AC, (ChannelNum_t) config.PowerLimiter_InverterChannelId, FLD_EFF); + int32_t victronChargePower = this->getDirectSolarPower(); + int32_t adjustedVictronChargePower = victronChargePower * (efficiency > 0.0 ? (efficiency / 100.0) : 1.0); // if inverter is off, use 1.0 + + MessageOutput.printf("[PowerLimiterClass::loop] Consuming Solar Power Only -> victronChargePower: %d, efficiency: %.2f, powerConsumption: %d \r\n", + victronChargePower, efficiency, newPowerLimit); + + // Limit power to solar power only + if (adjustedVictronChargePower < newPowerLimit) + newPowerLimit = adjustedVictronChargePower; + } + + // Respect power limit + if (newPowerLimit > config.PowerLimiter_UpperPowerLimit) + newPowerLimit = config.PowerLimiter_UpperPowerLimit; + MessageOutput.printf("[PowerLimiterClass::loop] newPowerLimit: %d\r\n", newPowerLimit); return newPowerLimit; } void PowerLimiterClass::setNewPowerLimit(std::shared_ptr inverter, int32_t newPowerLimit) { - if(_lastRequestedPowerLimit != newPowerLimit) { - CONFIG_T& config = Configuration.get(); + CONFIG_T& config = Configuration.get(); - // if limit too low turn inverter offf - if (newPowerLimit < config.PowerLimiter_LowerPowerLimit) { - if (inverter->isProducing()) { - MessageOutput.println("[PowerLimiterClass::loop] Stopping inverter..."); - inverter->sendPowerControlRequest(false); - _lastCommandSent = millis(); - } - newPowerLimit = config.PowerLimiter_LowerPowerLimit; - } else if (!inverter->isProducing()) { - MessageOutput.println("[PowerLimiterClass::loop] Starting up inverter..."); - inverter->sendPowerControlRequest(true); - _lastCommandSent = millis(); - } + // Start the inverter in case it's inactive and if the requested power is high enough + if (!inverter->isProducing() && newPowerLimit > config.PowerLimiter_LowerPowerLimit) { + MessageOutput.println("[PowerLimiterClass::loop] Starting up inverter..."); + inverter->sendPowerControlRequest(true); + } + + // Stop the inverter if limit is below threshold. + // We'll also set the power limit to the lower value in this case + if (newPowerLimit < config.PowerLimiter_LowerPowerLimit) { + if (inverter->isProducing()) { + MessageOutput.println("[PowerLimiterClass::loop] Stopping inverter..."); + inverter->sendPowerControlRequest(false); + } + newPowerLimit = config.PowerLimiter_LowerPowerLimit; + } + + // Set the actual limit. We'll only do this is if the limit is in the right range + // and differs from the last requested value + if( _lastRequestedPowerLimit != newPowerLimit && + /* newPowerLimit > config.PowerLimiter_LowerPowerLimit && --> This will always be true given the check above, kept for code readability */ + newPowerLimit <= config.PowerLimiter_UpperPowerLimit ) { MessageOutput.printf("[PowerLimiterClass::loop] Limit Non-Persistent: %d W\r\n", newPowerLimit); int32_t effPowerLimit = newPowerLimit; @@ -316,4 +307,4 @@ bool PowerLimiterClass::isStopThresholdReached(std::shared_ptr float correctedDcVoltage = getLoadCorrectedVoltage(inverter); return correctedDcVoltage <= config.PowerLimiter_VoltageStopThreshold; -} +} \ No newline at end of file diff --git a/src/WebApi_powerlimiter.cpp b/src/WebApi_powerlimiter.cpp index 2adb1dde..a031ed74 100644 --- a/src/WebApi_powerlimiter.cpp +++ b/src/WebApi_powerlimiter.cpp @@ -119,6 +119,7 @@ void WebApiPowerLimiterClass::onAdminPost(AsyncWebServerRequest* request) CONFIG_T& config = Configuration.get(); config.PowerLimiter_Enabled = root[F("enabled")].as(); + PowerLimiter.setDisable(false); // User input clears the PL internal disable flag config.PowerLimiter_SolarPassThroughEnabled = root[F("solar_passtrough_enabled")].as(); config.PowerLimiter_BatteryDrainStategy= root[F("battery_drain_strategy")].as(); config.PowerLimiter_IsInverterBehindPowerMeter = root[F("is_inverter_behind_powermeter")].as(); diff --git a/src/main.cpp b/src/main.cpp index 33c32907..4505c1f8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -17,6 +17,7 @@ #include "MqttHandleInverterTotal.h" #include "MqttHandleVedirect.h" #include "MqttHandleHuawei.h" +#include "MqttHandlePowerLimiter.h" #include "MqttSettings.h" #include "NetworkSettings.h" #include "NtpSettings.h" @@ -106,6 +107,7 @@ void setup() MqttHandleHass.init(); MqttHandleVedirectHass.init(); MqttHandleHuawei.init(); + MqttHandlePowerLimiter.init(); MessageOutput.println("done"); // Initialize WebApi @@ -216,6 +218,8 @@ void loop() yield(); MqttHandleHuawei.loop(); yield(); + MqttHandlePowerLimiter.loop(); + yield(); WebApi.loop(); yield(); Display.loop();