From 921302bf7319ef4fc6cfef79f935322d2046b8e7 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Mon, 12 Feb 2024 21:34:18 +0100 Subject: [PATCH 1/6] Feature: DPL: add switch allowing to ignore SoC unfortunately, the battery SoC values reported by battery BMSs are unreliable, at least for some users, or at least without regular (manual) full charge cycles to calibrate the BMS. it offers great advantages to connect OpenDTU-OnBattery to a BMS (MQTT publishing of values, Home Assistent integration, etc.), but previously the users were then forced to configure the DPL by SoC values. this change allows to configure the DPL such that SoC values are ignored. instead, the voltage limits are used to make DPL decisions, as if no SoC was available in the first place. the SoC related setting are hidden from the DPL settings view if SoC values are configured to be ignored. closes #654. --- include/Configuration.h | 1 + include/defaults.h | 1 + src/Configuration.cpp | 2 ++ src/PowerLimiter.cpp | 11 +++++++---- src/WebApi_powerlimiter.cpp | 2 ++ webapp/src/locales/de.json | 5 +++-- webapp/src/locales/en.json | 6 ++---- webapp/src/locales/fr.json | 6 ++---- webapp/src/types/PowerLimiterConfig.ts | 1 + webapp/src/views/PowerLimiterAdminView.vue | 13 +++++++++---- 10 files changed, 30 insertions(+), 18 deletions(-) diff --git a/include/Configuration.h b/include/Configuration.h index cc3456434..f4a4a4a9b 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -213,6 +213,7 @@ struct CONFIG_T { int32_t TargetPowerConsumptionHysteresis; int32_t LowerPowerLimit; int32_t UpperPowerLimit; + bool IgnoreSoc; uint32_t BatterySocStartThreshold; uint32_t BatterySocStopThreshold; float VoltageStartThreshold; diff --git a/include/defaults.h b/include/defaults.h index 7e1d7a0c1..56030d9b3 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -128,6 +128,7 @@ #define POWERLIMITER_TARGET_POWER_CONSUMPTION_HYSTERESIS 0 #define POWERLIMITER_LOWER_POWER_LIMIT 10 #define POWERLIMITER_UPPER_POWER_LIMIT 800 +#define POWERLIMITER_IGNORE_SOC false #define POWERLIMITER_BATTERY_SOC_START_THRESHOLD 80 #define POWERLIMITER_BATTERY_SOC_STOP_THRESHOLD 20 #define POWERLIMITER_VOLTAGE_START_THRESHOLD 50.0 diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 5004b3cbe..50968d40f 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -191,6 +191,7 @@ bool ConfigurationClass::write() powerlimiter["target_power_consumption_hysteresis"] = config.PowerLimiter.TargetPowerConsumptionHysteresis; powerlimiter["lower_power_limit"] = config.PowerLimiter.LowerPowerLimit; powerlimiter["upper_power_limit"] = config.PowerLimiter.UpperPowerLimit; + powerlimiter["ignore_soc"] = config.PowerLimiter.IgnoreSoc; powerlimiter["battery_soc_start_threshold"] = config.PowerLimiter.BatterySocStartThreshold; powerlimiter["battery_soc_stop_threshold"] = config.PowerLimiter.BatterySocStopThreshold; powerlimiter["voltage_start_threshold"] = config.PowerLimiter.VoltageStartThreshold; @@ -435,6 +436,7 @@ bool ConfigurationClass::read() config.PowerLimiter.TargetPowerConsumptionHysteresis = powerlimiter["target_power_consumption_hysteresis"] | POWERLIMITER_TARGET_POWER_CONSUMPTION_HYSTERESIS; config.PowerLimiter.LowerPowerLimit = powerlimiter["lower_power_limit"] | POWERLIMITER_LOWER_POWER_LIMIT; config.PowerLimiter.UpperPowerLimit = powerlimiter["upper_power_limit"] | POWERLIMITER_UPPER_POWER_LIMIT; + config.PowerLimiter.IgnoreSoc = powerlimiter["ignore_soc"] | POWERLIMITER_IGNORE_SOC; config.PowerLimiter.BatterySocStartThreshold = powerlimiter["battery_soc_start_threshold"] | POWERLIMITER_BATTERY_SOC_START_THRESHOLD; config.PowerLimiter.BatterySocStopThreshold = powerlimiter["battery_soc_stop_threshold"] | POWERLIMITER_BATTERY_SOC_STOP_THRESHOLD; config.PowerLimiter.VoltageStartThreshold = powerlimiter["voltage_start_threshold"] | POWERLIMITER_VOLTAGE_START_THRESHOLD; diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index e7f2ea2d6..56dc11e4b 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -290,12 +290,13 @@ void PowerLimiterClass::loop() } if (_verboseLogging) { - MessageOutput.printf("[DPL::loop] battery interface %s, SoC: %d %%, StartTH: %d %%, StopTH: %d %%, SoC age: %d s\r\n", + MessageOutput.printf("[DPL::loop] battery interface %s, SoC: %d %%, StartTH: %d %%, StopTH: %d %%, SoC age: %d s, ignore: %s\r\n", (config.Battery.Enabled?"enabled":"disabled"), Battery.getStats()->getSoC(), config.PowerLimiter.BatterySocStartThreshold, config.PowerLimiter.BatterySocStopThreshold, - Battery.getStats()->getSoCAgeSeconds()); + Battery.getStats()->getSoCAgeSeconds(), + (config.PowerLimiter.IgnoreSoc?"yes":"no")); float dcVoltage = _inverter->Statistics()->getChannelFieldValue(TYPE_DC, (ChannelNum_t)config.PowerLimiter.InverterChannelId, FLD_UDC); MessageOutput.printf("[DPL::loop] dcVoltage: %.2f V, loadCorrectedVoltage: %.2f V, StartTH: %.2f V, StopTH: %.2f V\r\n", @@ -608,8 +609,10 @@ bool PowerLimiterClass::testThreshold(float socThreshold, float voltThreshold, { CONFIG_T& config = Configuration.get(); - // prefer SoC provided through battery interface - if (config.Battery.Enabled && socThreshold > 0.0 + // prefer SoC provided through battery interface, unless disabled by user + if (!config.PowerLimiter.IgnoreSoc + && config.Battery.Enabled + && socThreshold > 0.0 && Battery.getStats()->isValid() && Battery.getStats()->getSoCAgeSeconds() < 60) { return compare(Battery.getStats()->getSoC(), socThreshold); diff --git a/src/WebApi_powerlimiter.cpp b/src/WebApi_powerlimiter.cpp index 25cb42e22..df530ca8b 100644 --- a/src/WebApi_powerlimiter.cpp +++ b/src/WebApi_powerlimiter.cpp @@ -45,6 +45,7 @@ void WebApiPowerLimiterClass::onStatus(AsyncWebServerRequest* request) root["target_power_consumption_hysteresis"] = config.PowerLimiter.TargetPowerConsumptionHysteresis; root["lower_power_limit"] = config.PowerLimiter.LowerPowerLimit; root["upper_power_limit"] = config.PowerLimiter.UpperPowerLimit; + root["ignore_soc"] = config.PowerLimiter.IgnoreSoc; root["battery_soc_start_threshold"] = config.PowerLimiter.BatterySocStartThreshold; root["battery_soc_stop_threshold"] = config.PowerLimiter.BatterySocStopThreshold; root["voltage_start_threshold"] = static_cast(config.PowerLimiter.VoltageStartThreshold * 100 +0.5) / 100.0; @@ -133,6 +134,7 @@ void WebApiPowerLimiterClass::onAdminPost(AsyncWebServerRequest* request) config.PowerLimiter.TargetPowerConsumptionHysteresis = root["target_power_consumption_hysteresis"].as(); config.PowerLimiter.LowerPowerLimit = root["lower_power_limit"].as(); config.PowerLimiter.UpperPowerLimit = root["upper_power_limit"].as(); + config.PowerLimiter.IgnoreSoc = root["ignore_soc"].as(); config.PowerLimiter.BatterySocStartThreshold = root["battery_soc_start_threshold"].as(); config.PowerLimiter.BatterySocStopThreshold = root["battery_soc_stop_threshold"].as(); config.PowerLimiter.VoltageStartThreshold = root["voltage_start_threshold"].as(); diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index ae442ec2a..ddd458fb3 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -595,17 +595,18 @@ "LowerPowerLimit": "Unteres Leistungslimit", "UpperPowerLimit": "Oberes Leistungslimit", "PowerMeters": "Leistungsmesser", + "IgnoreSoc": "Batterie SoC ignorieren", "BatterySocStartThreshold": "Akku SoC - Start", "BatterySocStopThreshold": "Akku SoC - Stop", "BatterySocSolarPassthroughStartThreshold": "Akku SoC - Start solar passthrough", - "BatterySocSolarPassthroughStartThresholdHint": "Wenn der Batterie SOC über diesem Limit ist wird die Inverter Leistung entsprechend der Victron MPPT Leistung gesetzt (abzüglich Effizienzkorrekturfaktor). Kann verwendet werden um überschüssige Solarleistung an das Netz zu liefern wenn die Batterie voll ist.", + "BatterySocSolarPassthroughStartThresholdHint": "Wenn der Batterie SoC über diesem Limit ist wird die Inverter Leistung entsprechend der Victron MPPT Leistung gesetzt (abzüglich Effizienzkorrekturfaktor). Kann verwendet werden um überschüssige Solarleistung an das Netz zu liefern wenn die Batterie voll ist.", "VoltageStartThreshold": "DC Spannung - Start", "VoltageStopThreshold": "DC Spannung - Stop", "VoltageSolarPassthroughStartThreshold": "DC Spannung - Start Solar-Passthrough", "VoltageSolarPassthroughStopThreshold": "DC Spannung - Stop Solar-Passthrough", "VoltageSolarPassthroughStartThresholdHint": "Wenn der Batteriespannung über diesem Limit ist wird die Inverter Leistung entsprechend der Victron MPPT Leistung gesetzt (abzüglich Effizienzkorrekturfaktor). Kann verwendet werden um überschüssige Solarleistung an das Netz zu liefern wenn die Batterie voll ist. Dieser Mode wird aktiv wenn das Start Spannungslimit überschritten wird und inaktiv wenn das Stop Spannungslimit unterschritten wird.", "VoltageLoadCorrectionFactor": "DC Spannung - Lastkorrekturfaktor", - "BatterySocInfo": "Hinweis: Die Akku SoC (State of Charge) Werte können nur benutzt werden, wenn die Batterie-Kommunikationsschnittstelle aktiviert ist. Wenn die Batterie innerhalb der letzten Minute keine Werte geschickt hat, werden als Fallback-Option die Spannungseinstellungen verwendet.", + "BatterySocInfo": "Hinweis: Die Akku SoC (State of Charge) Werte werden nur benutzt, wenn die Batterie-Kommunikationsschnittstelle innerhalb der letzten Minute gültige Werte geschickt hat. Andernfalls werden als Fallback-Option die Spannungseinstellungen verwendet.", "InverterIsBehindPowerMeter": "Welchselrichter ist hinter Leistungsmesser", "Battery": "DC / Akku", "VoltageLoadCorrectionInfo": "Hinweis: Wenn Leistung von der Batterie abgegeben wird, bricht normalerweise die Spannung etwas ein. Damit nicht vorzeitig der Wechelrichter ausgeschaltet wird sobald der \"Stop\"-Schwellenwert erreicht wird, wird der hier angegebene Korrekturfaktor mit einberechnet. Korrigierte Spannung = DC Spannung + (Aktuelle Leistung (W) * Korrekturfaktor).", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 668e095ee..57cb9b05a 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -601,9 +601,7 @@ "LowerPowerLimit": "Lower power limit", "UpperPowerLimit": "Upper power limit", "PowerMeters": "Power meter", - "MqttTopicPowerMeter1": "MQTT topic - Power meter #1", - "MqttTopicPowerMeter2": "MQTT topic - Power meter #2 (optional)", - "MqttTopicPowerMeter3": "MQTT topic - Power meter #3 (optional)", + "IgnoreSoc": "Ignore Battery SoC", "BatterySocStartThreshold": "Battery SoC - Start threshold", "BatterySocStopThreshold": "Battery SoC - Stop threshold", "BatterySocSolarPassthroughStartThreshold": "Battery SoC - Start threshold for full solar passthrough", @@ -614,7 +612,7 @@ "VoltageSolarPassthroughStopThreshold": "DC Voltage - Stop threshold for full solar passthrough", "VoltageSolarPassthroughStartThresholdHint": "Inverter power is set according to Victron MPPT power (minus efficiency factors) when full solar passthrough is active. Use this if you like to supply excess power to the grid when battery is full. This is started when battery voltage goes over this limit and stopped if voltage drops below stop limit.", "VoltageLoadCorrectionFactor": "DC Voltage - Load correction factor", - "BatterySocInfo": "Hint: The battery SoC (State of Charge) values can only be used if the battery communication interface is enabled. If the battery has not reported any SoC updates in the last minute, the voltage thresholds will be used as fallback.", + "BatterySocInfo": "Hint: The battery SoC (State of Charge) values are only used if the battery communication interface reported SoC updates in the last minute. Otherwise the voltage thresholds will be used as fallback.", "InverterIsBehindPowerMeter": "Inverter is behind Power meter", "Battery": "DC / Battery", "VoltageLoadCorrectionInfo": "Hint: When the power output is higher, the voltage is usually decreasing. In order to not stop the inverter too early (Stop treshold), a power factor can be specified here to correct this. Corrected voltage = DC Voltage + (Current power * correction factor).", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 7c845c741..4fbdc8d73 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -648,9 +648,7 @@ "LowerPowerLimit": "Lower power limit", "UpperPowerLimit": "Upper power limit", "PowerMeters": "Power meter", - "MqttTopicPowerMeter1": "MQTT topic - Power meter #1", - "MqttTopicPowerMeter2": "MQTT topic - Power meter #2 (optional)", - "MqttTopicPowerMeter3": "MQTT topic - Power meter #3 (optional)", + "IgnoreSoc": "Ignore Battery SoC", "BatterySocStartThreshold": "Battery SoC - Start threshold", "BatterySocStopThreshold": "Battery SoC - Stop threshold", "BatterySocSolarPassthroughStartThreshold": "Battery SoC - Start threshold for full solar passthrough", @@ -661,7 +659,7 @@ "VoltageSolarPassthroughStopThreshold": "DC Voltage - Stop threshold for full solar passthrough", "VoltageSolarPassthroughStartThresholdHint": "Inverter power is set according to Victron MPPT power (minus efficiency factors) when full solar passthrough is active. Use this if you like to supply excess power to the grid when battery is full. This is started when battery voltage goes over this limit and stopped if voltage drops below stop limit.", "VoltageLoadCorrectionFactor": "DC Voltage - Load correction factor", - "BatterySocInfo": "Hint: The battery SoC (State of Charge) values can only be used if the battery communication interface is enabled. If the battery has not reported any SoC updates in the last minute, the voltage thresholds will be used as fallback.", + "BatterySocInfo": "Hint: The battery SoC (State of Charge) values are only used if the battery communication interface reported SoC updates in the last minute. Otherwise the voltage thresholds will be used as fallback.", "InverterIsBehindPowerMeter": "Inverter is behind Power meter", "Battery": "DC / Battery", "VoltageLoadCorrectionInfo": "Hint: When the power output is higher, the voltage is usually decreasing. In order to not stop the inverter too early (Stop treshold), a power factor can be specified here to correct this. Corrected voltage = DC Voltage + (Current power * correction factor)." diff --git a/webapp/src/types/PowerLimiterConfig.ts b/webapp/src/types/PowerLimiterConfig.ts index 00b9a1425..594361d77 100644 --- a/webapp/src/types/PowerLimiterConfig.ts +++ b/webapp/src/types/PowerLimiterConfig.ts @@ -11,6 +11,7 @@ export interface PowerLimiterConfig { target_power_consumption_hysteresis: number; lower_power_limit: number; upper_power_limit: number; + ignore_soc: boolean; battery_soc_start_threshold: number; battery_soc_stop_threshold: number; voltage_start_threshold: number; diff --git a/webapp/src/views/PowerLimiterAdminView.vue b/webapp/src/views/PowerLimiterAdminView.vue index 8f10a4cd1..898cd4f64 100644 --- a/webapp/src/views/PowerLimiterAdminView.vue +++ b/webapp/src/views/PowerLimiterAdminView.vue @@ -142,7 +142,12 @@ -
+ + +
@@ -154,7 +159,7 @@
-
+
@@ -166,7 +171,7 @@
-
+
@@ -180,7 +185,7 @@
- +
From 30bfffb848a8043d2eb9c48ba5594191f6aa5393 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Sat, 17 Feb 2024 11:16:35 +0100 Subject: [PATCH 2/6] BatteryStats: manage battery pack voltage in base class the BatteryStats base class shall be able to tell the total battery pack voltage. for that reason, and to avoid code duplication, the voltage is now handled in the base class and treated as a datum that is common to all battery providers. --- include/BatteryStats.h | 11 +++++++++-- src/BatteryStats.cpp | 27 ++++++++++++++++----------- src/PylontechCanReceiver.cpp | 6 +++--- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 36eed06a3..47c22e721 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -20,6 +20,9 @@ class BatteryStats { uint8_t getSoC() const { return _SoC; } uint32_t getSoCAgeSeconds() const { return (millis() - _lastUpdateSoC) / 1000; } + float getVoltage() const { return _voltage; } + uint32_t getVoltageAgeSeconds() const { return (millis() - _lastUpdateVoltage) / 1000; } + // convert stats to JSON for web application live view virtual void getLiveViewData(JsonVariant& root) const; @@ -33,6 +36,10 @@ class BatteryStats { protected: virtual void mqttPublish() const; + void setVoltage(float voltage, uint32_t timestamp) { + _voltage = voltage; + _lastUpdateVoltage = timestamp; + } String _manufacturer = "unknown"; uint8_t _SoC = 0; @@ -41,6 +48,8 @@ class BatteryStats { private: uint32_t _lastMqttPublish = 0; + float _voltage = 0; // total battery pack voltage + uint32_t _lastUpdateVoltage = 0; }; class PylontechBatteryStats : public BatteryStats { @@ -59,7 +68,6 @@ class PylontechBatteryStats : public BatteryStats { float _chargeCurrentLimitation; float _dischargeCurrentLimitation; uint16_t _stateOfHealth; - float _voltage; // total voltage of the battery pack // total current into (positive) or from (negative) // the battery, i.e., the charging current float _current; @@ -123,7 +131,6 @@ class VictronSmartShuntStats : public BatteryStats { void updateFrom(VeDirectShuntController::veShuntStruct const& shuntData); private: - float _voltage; float _current; float _temperature; bool _tempPresent; diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index 606a372fd..976694bf6 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -57,6 +57,7 @@ void BatteryStats::getLiveViewData(JsonVariant& root) const root[F("data_age")] = getAgeSeconds(); addLiveViewValue(root, "SoC", _SoC, "%", 0); + addLiveViewValue(root, "voltage", _voltage, "V", 2); } void PylontechBatteryStats::getLiveViewData(JsonVariant& root) const @@ -68,7 +69,6 @@ void PylontechBatteryStats::getLiveViewData(JsonVariant& root) const addLiveViewValue(root, "chargeCurrentLimitation", _chargeCurrentLimitation, "A", 1); addLiveViewValue(root, "dischargeCurrentLimitation", _dischargeCurrentLimitation, "A", 1); addLiveViewValue(root, "stateOfHealth", _stateOfHealth, "%", 0); - addLiveViewValue(root, "voltage", _voltage, "V", 2); addLiveViewValue(root, "current", _current, "A", 1); addLiveViewValue(root, "temperature", _temperature, "°C", 1); @@ -105,18 +105,13 @@ void JkBmsBatteryStats::getJsonData(JsonVariant& root, bool verbose) const using Label = JkBms::DataPointLabel; - auto oVoltage = _dataPoints.get(); - if (oVoltage.has_value()) { - addLiveViewValue(root, "voltage", - static_cast(*oVoltage) / 1000, "V", 2); - } - auto oCurrent = _dataPoints.get(); if (oCurrent.has_value()) { addLiveViewValue(root, "current", static_cast(*oCurrent) / 1000, "A", 2); } + auto oVoltage = _dataPoints.get(); if (oVoltage.has_value() && oCurrent.has_value()) { auto current = static_cast(*oCurrent) / 1000; auto voltage = static_cast(*oVoltage) / 1000; @@ -218,6 +213,7 @@ void BatteryStats::mqttPublish() const MqttSettings.publish(F("battery/manufacturer"), _manufacturer); MqttSettings.publish(F("battery/dataAge"), String(getAgeSeconds())); MqttSettings.publish(F("battery/stateOfCharge"), String(_SoC)); + MqttSettings.publish(F("battery/voltage"), String(_voltage)); } void PylontechBatteryStats::mqttPublish() const @@ -228,7 +224,6 @@ void PylontechBatteryStats::mqttPublish() const MqttSettings.publish(F("battery/settings/chargeCurrentLimitation"), String(_chargeCurrentLimitation)); MqttSettings.publish(F("battery/settings/dischargeCurrentLimitation"), String(_dischargeCurrentLimitation)); MqttSettings.publish(F("battery/stateOfHealth"), String(_stateOfHealth)); - MqttSettings.publish(F("battery/voltage"), String(_voltage)); MqttSettings.publish(F("battery/current"), String(_current)); MqttSettings.publish(F("battery/temperature"), String(_temperature)); MqttSettings.publish(F("battery/alarm/overCurrentDischarge"), String(_alarmOverCurrentDischarge)); @@ -260,6 +255,10 @@ void JkBmsBatteryStats::mqttPublish() const Label::CellsMilliVolt, // complex data format Label::ModificationPassword, // sensitive data Label::BatterySoCPercent // already published by base class + // NOTE that voltage is also published by the base class, however, we + // previously published it only from here using the respective topic. + // to avoid a breaking change, we publish the value again using the + // "old" topic. }; // regularly publish all topics regardless of whether or not their value changed @@ -340,6 +339,13 @@ void JkBmsBatteryStats::updateFrom(JkBms::DataPointContainer const& dp) _lastUpdateSoC = oSoCDataPoint->getTimestamp(); } + auto oVoltage = dp.get(); + if (oVoltage.has_value()) { + auto oVoltageDataPoint = dp.getDataPointFor(); + BatteryStats::setVoltage(static_cast(*oVoltage) / 1000, + oVoltageDataPoint->getTimestamp()); + } + _dataPoints.updateFrom(dp); auto oCellVoltages = _dataPoints.get(); @@ -360,8 +366,9 @@ void JkBmsBatteryStats::updateFrom(JkBms::DataPointContainer const& dp) } void VictronSmartShuntStats::updateFrom(VeDirectShuntController::veShuntStruct const& shuntData) { + BatteryStats::setVoltage(shuntData.V, millis()); + _SoC = shuntData.SOC / 10; - _voltage = shuntData.V; _current = shuntData.I; _modelName = shuntData.getPidAsString().data(); _chargeCycles = shuntData.H4; @@ -387,7 +394,6 @@ void VictronSmartShuntStats::getLiveViewData(JsonVariant& root) const { BatteryStats::getLiveViewData(root); // values go into the "Status" card of the web application - addLiveViewValue(root, "voltage", _voltage, "V", 2); addLiveViewValue(root, "current", _current, "A", 1); addLiveViewValue(root, "chargeCycles", _chargeCycles, "", 0); addLiveViewValue(root, "chargedEnergy", _chargedEnergy, "KWh", 1); @@ -406,7 +412,6 @@ void VictronSmartShuntStats::getLiveViewData(JsonVariant& root) const { void VictronSmartShuntStats::mqttPublish() const { BatteryStats::mqttPublish(); - MqttSettings.publish(F("battery/voltage"), String(_voltage)); MqttSettings.publish(F("battery/current"), String(_current)); MqttSettings.publish(F("battery/chargeCycles"), String(_chargeCycles)); MqttSettings.publish(F("battery/chargedEnergy"), String(_chargedEnergy)); diff --git a/src/PylontechCanReceiver.cpp b/src/PylontechCanReceiver.cpp index c1b26176b..8091f1df1 100644 --- a/src/PylontechCanReceiver.cpp +++ b/src/PylontechCanReceiver.cpp @@ -147,13 +147,13 @@ void PylontechCanReceiver::loop() } case 0x356: { - _stats->_voltage = this->scaleValue(this->readSignedInt16(rx_message.data), 0.01); + _stats->setVoltage(this->scaleValue(this->readSignedInt16(rx_message.data), 0.01), millis()); _stats->_current = this->scaleValue(this->readSignedInt16(rx_message.data + 2), 0.1); _stats->_temperature = this->scaleValue(this->readSignedInt16(rx_message.data + 4), 0.1); if (_verboseLogging) { MessageOutput.printf("[Pylontech] voltage: %f current: %f temperature: %f\n", - _stats->_voltage, _stats->_current, _stats->_temperature); + _stats->getVoltage(), _stats->_current, _stats->_temperature); } break; } @@ -287,7 +287,7 @@ void PylontechCanReceiver::dummyData() _stats->_chargeCurrentLimitation = dummyFloat(33); _stats->_dischargeCurrentLimitation = dummyFloat(12); _stats->_stateOfHealth = 99; - _stats->_voltage = 48.67; + _stats->setVoltage(48.67, millis()); _stats->_current = dummyFloat(-1); _stats->_temperature = dummyFloat(20); From 3595725f8aac83e42d26a4dc1cd26dbf66f9ec77 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Sat, 17 Feb 2024 12:25:07 +0100 Subject: [PATCH 3/6] Feature: implement subscription to battery voltage MQTT topic this extends the MqttBattery implementation by an additional topic which allows to subscribe to receive battery voltage readings through the MQTT broker. similar to the battery SoC topic, this allows to import a critical battery data point for the DPL, in case the user chooses to use voltage thresholds rather than SoC thresholds to control the DPL. if an otherwise incompatible BMS is available which publishes the battery pack voltage through MQTT, this can now be used to feed accurate voltage readings to the DPL. --- include/BatteryStats.h | 2 + include/Configuration.h | 3 +- include/MqttBattery.h | 7 +- src/Configuration.cpp | 6 +- src/MqttBattery.cpp | 99 ++++++++++++++++++++------- src/WebApi_battery.cpp | 8 ++- webapp/src/locales/de.json | 5 +- webapp/src/locales/en.json | 5 +- webapp/src/locales/fr.json | 5 +- webapp/src/types/BatteryConfig.ts | 3 +- webapp/src/views/BatteryAdminView.vue | 14 +++- 11 files changed, 116 insertions(+), 41 deletions(-) diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 47c22e721..12d6330d2 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -148,6 +148,8 @@ class VictronSmartShuntStats : public BatteryStats { }; class MqttBatteryStats : public BatteryStats { + friend class MqttBattery; + public: // since the source of information was MQTT in the first place, // we do NOT publish the same data under a different topic. diff --git a/include/Configuration.h b/include/Configuration.h index f4a4a4a9b..5fdd21c7f 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -231,7 +231,8 @@ struct CONFIG_T { uint8_t Provider; uint8_t JkBmsInterface; uint8_t JkBmsPollingInterval; - char MqttTopic[MQTT_MAX_TOPIC_STRLEN + 1]; + char MqttSocTopic[MQTT_MAX_TOPIC_STRLEN + 1]; + char MqttVoltageTopic[MQTT_MAX_TOPIC_STRLEN + 1]; } Battery; struct { diff --git a/include/MqttBattery.h b/include/MqttBattery.h index 83ff412d3..61df04500 100644 --- a/include/MqttBattery.h +++ b/include/MqttBattery.h @@ -1,5 +1,6 @@ #pragma once +#include #include "Battery.h" #include @@ -15,8 +16,12 @@ class MqttBattery : public BatteryProvider { private: bool _verboseLogging = false; String _socTopic; + String _voltageTopic; std::shared_ptr _stats = std::make_shared(); - void onMqttMessage(espMqttClientTypes::MessageProperties const& properties, + std::optional getFloat(std::string const& src, char const* topic); + void onMqttMessageSoC(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total); + void onMqttMessageVoltage(espMqttClientTypes::MessageProperties const& properties, char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total); }; diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 50968d40f..441d2dac9 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -208,7 +208,8 @@ bool ConfigurationClass::write() battery["provider"] = config.Battery.Provider; battery["jkbms_interface"] = config.Battery.JkBmsInterface; battery["jkbms_polling_interval"] = config.Battery.JkBmsPollingInterval; - battery["mqtt_topic"] = config.Battery.MqttTopic; + battery["mqtt_topic"] = config.Battery.MqttSocTopic; + battery["mqtt_voltage_topic"] = config.Battery.MqttVoltageTopic; JsonObject huawei = doc.createNestedObject("huawei"); huawei["enabled"] = config.Huawei.Enabled; @@ -453,7 +454,8 @@ bool ConfigurationClass::read() config.Battery.Provider = battery["provider"] | BATTERY_PROVIDER; config.Battery.JkBmsInterface = battery["jkbms_interface"] | BATTERY_JKBMS_INTERFACE; config.Battery.JkBmsPollingInterval = battery["jkbms_polling_interval"] | BATTERY_JKBMS_POLLING_INTERVAL; - strlcpy(config.Battery.MqttTopic, battery["mqtt_topic"] | "", sizeof(config.Battery.MqttTopic)); + strlcpy(config.Battery.MqttSocTopic, battery["mqtt_topic"] | "", sizeof(config.Battery.MqttSocTopic)); + strlcpy(config.Battery.MqttVoltageTopic, battery["mqtt_voltage_topic"] | "", sizeof(config.Battery.MqttVoltageTopic)); JsonObject huawei = doc["huawei"]; config.Huawei.Enabled = huawei["enabled"] | HUAWEI_ENABLED; diff --git a/src/MqttBattery.cpp b/src/MqttBattery.cpp index 9e1992429..3d3034547 100644 --- a/src/MqttBattery.cpp +++ b/src/MqttBattery.cpp @@ -10,20 +10,35 @@ bool MqttBattery::init(bool verboseLogging) _verboseLogging = verboseLogging; auto const& config = Configuration.get(); - _socTopic = config.Battery.MqttTopic; - if (_socTopic.isEmpty()) { return false; } + _socTopic = config.Battery.MqttSocTopic; + if (!_socTopic.isEmpty()) { + MqttSettings.subscribe(_socTopic, 0/*QoS*/, + std::bind(&MqttBattery::onMqttMessageSoC, + this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6) + ); - MqttSettings.subscribe(_socTopic, 0/*QoS*/, - std::bind(&MqttBattery::onMqttMessage, - this, std::placeholders::_1, std::placeholders::_2, - std::placeholders::_3, std::placeholders::_4, - std::placeholders::_5, std::placeholders::_6) - ); + if (_verboseLogging) { + MessageOutput.printf("MqttBattery: Subscribed to '%s' for SoC readings\r\n", + _socTopic.c_str()); + } + } - if (_verboseLogging) { - MessageOutput.printf("MqttBattery: Subscribed to '%s'\r\n", - _socTopic.c_str()); + _voltageTopic = config.Battery.MqttVoltageTopic; + if (!_voltageTopic.isEmpty()) { + MqttSettings.subscribe(_voltageTopic, 0/*QoS*/, + std::bind(&MqttBattery::onMqttMessageVoltage, + this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6) + ); + + if (_verboseLogging) { + MessageOutput.printf("MqttBattery: Subscribed to '%s' for voltage readings\r\n", + _voltageTopic.c_str()); + } } return true; @@ -31,35 +46,69 @@ bool MqttBattery::init(bool verboseLogging) void MqttBattery::deinit() { - if (_socTopic.isEmpty()) { return; } - MqttSettings.unsubscribe(_socTopic); + if (!_voltageTopic.isEmpty()) { + MqttSettings.unsubscribe(_voltageTopic); + } + + if (!_socTopic.isEmpty()) { + MqttSettings.unsubscribe(_socTopic); + } } -void MqttBattery::onMqttMessage(espMqttClientTypes::MessageProperties const& properties, - char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) -{ - float soc = 0; - std::string value(reinterpret_cast(payload), len); +std::optional MqttBattery::getFloat(std::string const& src, char const* topic) { + float res = 0; try { - soc = std::stof(value); + res = std::stof(src); } catch(std::invalid_argument const& e) { MessageOutput.printf("MqttBattery: Cannot parse payload '%s' in topic '%s' as float\r\n", - value.c_str(), topic); - return; + src.c_str(), topic); + return std::nullopt; } - if (soc < 0 || soc > 100) { + return res; +} + +void MqttBattery::onMqttMessageSoC(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) +{ + auto soc = getFloat(std::string(reinterpret_cast(payload), len), topic); + if (!soc.has_value()) { return; } + + if (*soc < 0 || *soc > 100) { MessageOutput.printf("MqttBattery: Implausible SoC '%.2f' in topic '%s'\r\n", - soc, topic); + *soc, topic); return; } - _stats->setSoC(static_cast(soc)); + _stats->setSoC(static_cast(*soc)); if (_verboseLogging) { MessageOutput.printf("MqttBattery: Updated SoC to %d from '%s'\r\n", - static_cast(soc), topic); + static_cast(*soc), topic); + } +} + +void MqttBattery::onMqttMessageVoltage(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) +{ + auto voltage = getFloat(std::string(reinterpret_cast(payload), len), topic); + if (!voltage.has_value()) { return; } + + // since this project is revolving around Hoymiles microinverters, which can + // only handle up to 65V of input voltage at best, it is safe to assume that + // an even higher voltage is implausible. + if (*voltage < 0 || *voltage > 65) { + MessageOutput.printf("MqttBattery: Implausible voltage '%.2f' in topic '%s'\r\n", + *voltage, topic); + return; + } + + _stats->setVoltage(*voltage, millis()); + + if (_verboseLogging) { + MessageOutput.printf("MqttBattery: Updated voltage to %.2f from '%s'\r\n", + *voltage, topic); } } diff --git a/src/WebApi_battery.cpp b/src/WebApi_battery.cpp index b96eb2cf6..9e2230c4e 100644 --- a/src/WebApi_battery.cpp +++ b/src/WebApi_battery.cpp @@ -39,7 +39,8 @@ void WebApiBatteryClass::onStatus(AsyncWebServerRequest* request) root["provider"] = config.Battery.Provider; root["jkbms_interface"] = config.Battery.JkBmsInterface; root["jkbms_polling_interval"] = config.Battery.JkBmsPollingInterval; - root["mqtt_topic"] = config.Battery.MqttTopic; + root["mqtt_soc_topic"] = config.Battery.MqttSocTopic; + root["mqtt_voltage_topic"] = config.Battery.MqttVoltageTopic; response->setLength(); request->send(response); @@ -103,8 +104,9 @@ void WebApiBatteryClass::onAdminPost(AsyncWebServerRequest* request) config.Battery.Provider = root["provider"].as(); config.Battery.JkBmsInterface = root["jkbms_interface"].as(); config.Battery.JkBmsPollingInterval = root["jkbms_polling_interval"].as(); - strlcpy(config.Battery.MqttTopic, root["mqtt_topic"].as().c_str(), sizeof(config.Battery.MqttTopic)); - + strlcpy(config.Battery.MqttSocTopic, root["mqtt_soc_topic"].as().c_str(), sizeof(config.Battery.MqttSocTopic)); + strlcpy(config.Battery.MqttVoltageTopic, root["mqtt_voltage_topic"].as().c_str(), sizeof(config.Battery.MqttVoltageTopic)); + WebApi.writeConfig(retMsg); response->setLength(); diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index ddd458fb3..b4ff3333b 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -622,10 +622,11 @@ "Provider": "Datenanbieter", "ProviderPylontechCan": "Pylontech per CAN-Bus", "ProviderJkBmsSerial": "Jikong (JK) BMS per serieller Verbindung", - "ProviderMqtt": "State of Charge (SoC) Wert aus MQTT Broker", + "ProviderMqtt": "Batteriewerte aus MQTT Broker", "ProviderVictron": "Victron SmartShunt per VE.Direct Schnittstelle", "MqttConfiguration": "MQTT Einstellungen", - "MqttTopic": "SoC-Wert Topic", + "MqttSocTopic": "Topic für Batterie-SoC", + "MqttVoltageTopic": "Topic für Batteriespannung", "JkBmsConfiguration": "JK BMS Einstellungen", "JkBmsInterface": "Schnittstellentyp", "JkBmsInterfaceUart": "TTL-UART an der MCU", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 57cb9b05a..7286e280a 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -628,10 +628,11 @@ "Provider": "Data Provider", "ProviderPylontechCan": "Pylontech using CAN bus", "ProviderJkBmsSerial": "Jikong (JK) BMS using serial connection", - "ProviderMqtt": "State of Charge (SoC) value from MQTT broker", + "ProviderMqtt": "Battery data from MQTT broker", "ProviderVictron": "Victron SmartShunt using VE.Direct interface", "MqttConfiguration": "MQTT Settings", - "MqttTopic": "SoC value topic", + "MqttSocTopic": "SoC value topic", + "MqttVoltageTopic": "Voltage value topic", "JkBmsConfiguration": "JK BMS Settings", "JkBmsInterface": "Interface Type", "JkBmsInterfaceUart": "TTL-UART on MCU", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 4fbdc8d73..4cdb5b6ac 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -546,10 +546,11 @@ "Provider": "Data Provider", "ProviderPylontechCan": "Pylontech using CAN bus", "ProviderJkBmsSerial": "Jikong (JK) BMS using serial connection", - "ProviderMqtt": "State of Charge (SoC) value from MQTT broker", + "ProviderMqtt": "Battery data from MQTT broker", "ProviderVictron": "Victron SmartShunt using VE.Direct interface", "MqttConfiguration": "MQTT Settings", - "MqttTopic": "SoC value topic", + "MqttSocTopic": "SoC value topic", + "MqttVoltageTopic": "Voltage value topic", "JkBmsConfiguration": "JK BMS Settings", "JkBmsInterface": "Interface Type", "JkBmsInterfaceUart": "TTL-UART on MCU", diff --git a/webapp/src/types/BatteryConfig.ts b/webapp/src/types/BatteryConfig.ts index fc83e84d9..4399c211b 100644 --- a/webapp/src/types/BatteryConfig.ts +++ b/webapp/src/types/BatteryConfig.ts @@ -4,5 +4,6 @@ export interface BatteryConfig { provider: number; jkbms_interface: number; jkbms_polling_interval: number; - mqtt_topic: string; + mqtt_soc_topic: string; + mqtt_voltage_topic: string; } diff --git a/webapp/src/views/BatteryAdminView.vue b/webapp/src/views/BatteryAdminView.vue index 88b67df4b..de938bb3a 100644 --- a/webapp/src/views/BatteryAdminView.vue +++ b/webapp/src/views/BatteryAdminView.vue @@ -53,11 +53,21 @@ :text="$t('batteryadmin.MqttConfiguration')" textVariant="text-bg-primary" addSpace>
- + +
+
+
+
+ +
+
+
From 7c069b1cc4374a32f5870d986c211f6ca15e6313 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Sat, 17 Feb 2024 12:44:51 +0100 Subject: [PATCH 4/6] replace BatteryStats::isValid() method in the respective context, the DPL only needs to be sure that the SoC value is not outdated. it should not even care about other values reported by the battery interface. hence, the isValid() method shall be concerned with the SoC value timestamp only. the method is renamed for clarity. --- include/BatteryStats.h | 2 +- src/PowerLimiter.cpp | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 12d6330d2..287f0d1b1 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -32,7 +32,7 @@ class BatteryStats { // if they did not change. used to calculate Home Assistent expiration. virtual uint32_t getMqttFullPublishIntervalMs() const; - bool isValid() const { return _lastUpdateSoC > 0 && _lastUpdate > 0; } + bool isSoCValid() const { return _lastUpdateSoC > 0; } protected: virtual void mqttPublish() const; diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index 56dc11e4b..68564e37a 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -610,12 +610,13 @@ bool PowerLimiterClass::testThreshold(float socThreshold, float voltThreshold, CONFIG_T& config = Configuration.get(); // prefer SoC provided through battery interface, unless disabled by user + auto stats = Battery.getStats(); if (!config.PowerLimiter.IgnoreSoc && config.Battery.Enabled && socThreshold > 0.0 - && Battery.getStats()->isValid() - && Battery.getStats()->getSoCAgeSeconds() < 60) { - return compare(Battery.getStats()->getSoC(), socThreshold); + && stats->isSoCValid() + && stats->getSoCAgeSeconds() < 60) { + return compare(stats->getSoC(), socThreshold); } // use voltage threshold as fallback From 6df358242ccaa88a2324f5fcf97e72f023635684 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Sat, 17 Feb 2024 12:48:23 +0100 Subject: [PATCH 5/6] Feature: know and use SoC precision the Victron SmartShunt communicates the SoC value in permille. this should be displayed in the web UI accordingly. this is a good excuse to fully move ownership of the SoC value to the BatteryStats base class and add a precision indicator variable. this is required to be set each time a derived class (a battery provider) wants to update the SoC value. the precision is then used when populating the JSON data for the web UI (live view). related to #573. --- include/BatteryStats.h | 21 +++++++++++++-------- src/BatteryStats.cpp | 11 +++++------ src/MqttBattery.cpp | 2 +- src/PylontechCanReceiver.cpp | 4 ++-- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 287f0d1b1..916bf3115 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -17,7 +17,7 @@ class BatteryStats { uint32_t getAgeSeconds() const { return (millis() - _lastUpdate) / 1000; } bool updateAvailable(uint32_t since) const { return _lastUpdate > since; } - uint8_t getSoC() const { return _SoC; } + uint8_t getSoC() const { return _soc; } uint32_t getSoCAgeSeconds() const { return (millis() - _lastUpdateSoC) / 1000; } float getVoltage() const { return _voltage; } @@ -36,18 +36,26 @@ class BatteryStats { protected: virtual void mqttPublish() const; + + void setSoC(float soc, uint8_t precision, uint32_t timestamp) { + _soc = soc; + _socPrecision = precision; + _lastUpdateSoC = timestamp; + } + void setVoltage(float voltage, uint32_t timestamp) { _voltage = voltage; _lastUpdateVoltage = timestamp; } String _manufacturer = "unknown"; - uint8_t _SoC = 0; - uint32_t _lastUpdateSoC = 0; uint32_t _lastUpdate = 0; private: uint32_t _lastMqttPublish = 0; + float _soc = 0; + uint8_t _socPrecision = 0; // decimal places + uint32_t _lastUpdateSoC = 0; float _voltage = 0; // total battery pack voltage uint32_t _lastUpdateVoltage = 0; }; @@ -61,7 +69,6 @@ class PylontechBatteryStats : public BatteryStats { private: void setManufacturer(String&& m) { _manufacturer = std::move(m); } - void setSoC(uint8_t SoC) { _SoC = SoC; _lastUpdateSoC = millis(); } void setLastUpdate(uint32_t ts) { _lastUpdate = ts; } float _chargeVoltage; @@ -155,9 +162,7 @@ class MqttBatteryStats : public BatteryStats { // we do NOT publish the same data under a different topic. void mqttPublish() const final { } - // the SoC is the only interesting value in this case, which is already - // displayed at the top of the live view. do not generate a card. + // if the voltage is subscribed to at all, it alone does not warrant a + // card in the live view, since the SoC is already displayed at the top void getLiveViewData(JsonVariant& root) const final { } - - void setSoC(uint8_t SoC) { _SoC = SoC; _lastUpdateSoC = _lastUpdate = millis(); } }; diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index 976694bf6..807f1a4c2 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -56,7 +56,7 @@ void BatteryStats::getLiveViewData(JsonVariant& root) const root[F("manufacturer")] = _manufacturer; root[F("data_age")] = getAgeSeconds(); - addLiveViewValue(root, "SoC", _SoC, "%", 0); + addLiveViewValue(root, "SoC", _soc, "%", _socPrecision); addLiveViewValue(root, "voltage", _voltage, "V", 2); } @@ -212,7 +212,7 @@ void BatteryStats::mqttPublish() const { MqttSettings.publish(F("battery/manufacturer"), _manufacturer); MqttSettings.publish(F("battery/dataAge"), String(getAgeSeconds())); - MqttSettings.publish(F("battery/stateOfCharge"), String(_SoC)); + MqttSettings.publish(F("battery/stateOfCharge"), String(_soc)); MqttSettings.publish(F("battery/voltage"), String(_voltage)); } @@ -334,9 +334,9 @@ void JkBmsBatteryStats::updateFrom(JkBms::DataPointContainer const& dp) auto oSoCValue = dp.get(); if (oSoCValue.has_value()) { - _SoC = *oSoCValue; auto oSoCDataPoint = dp.getDataPointFor(); - _lastUpdateSoC = oSoCDataPoint->getTimestamp(); + BatteryStats::setSoC(*oSoCValue, 0/*precision*/, + oSoCDataPoint->getTimestamp()); } auto oVoltage = dp.get(); @@ -367,8 +367,8 @@ void JkBmsBatteryStats::updateFrom(JkBms::DataPointContainer const& dp) void VictronSmartShuntStats::updateFrom(VeDirectShuntController::veShuntStruct const& shuntData) { BatteryStats::setVoltage(shuntData.V, millis()); + BatteryStats::setSoC(static_cast(shuntData.SOC) / 10, 1/*precision*/, millis()); - _SoC = shuntData.SOC / 10; _current = shuntData.I; _modelName = shuntData.getPidAsString().data(); _chargeCycles = shuntData.H4; @@ -387,7 +387,6 @@ void VictronSmartShuntStats::updateFrom(VeDirectShuntController::veShuntStruct c _alarmHighTemperature = shuntData.AR & 64; _lastUpdate = VeDirectShunt.getLastUpdate(); - _lastUpdateSoC = VeDirectShunt.getLastUpdate(); } void VictronSmartShuntStats::getLiveViewData(JsonVariant& root) const { diff --git a/src/MqttBattery.cpp b/src/MqttBattery.cpp index 3d3034547..03e141e2f 100644 --- a/src/MqttBattery.cpp +++ b/src/MqttBattery.cpp @@ -82,7 +82,7 @@ void MqttBattery::onMqttMessageSoC(espMqttClientTypes::MessageProperties const& return; } - _stats->setSoC(static_cast(*soc)); + _stats->setSoC(*soc, 0/*precision*/, millis()); if (_verboseLogging) { MessageOutput.printf("MqttBattery: Updated SoC to %d from '%s'\r\n", diff --git a/src/PylontechCanReceiver.cpp b/src/PylontechCanReceiver.cpp index 8091f1df1..e19cff599 100644 --- a/src/PylontechCanReceiver.cpp +++ b/src/PylontechCanReceiver.cpp @@ -136,7 +136,7 @@ void PylontechCanReceiver::loop() } case 0x355: { - _stats->setSoC(static_cast(this->readUnsignedInt16(rx_message.data))); + _stats->setSoC(static_cast(this->readUnsignedInt16(rx_message.data)), 0/*precision*/, millis()); _stats->_stateOfHealth = this->readUnsignedInt16(rx_message.data + 2); if (_verboseLogging) { @@ -282,7 +282,7 @@ void PylontechCanReceiver::dummyData() }; _stats->setManufacturer("Pylontech US3000C"); - _stats->setSoC(42); + _stats->setSoC(42, 0/*precision*/, millis()); _stats->_chargeVoltage = dummyFloat(50); _stats->_chargeCurrentLimitation = dummyFloat(33); _stats->_dischargeCurrentLimitation = dummyFloat(12); From c93001876458aa43e24b37832503e80b809da86e Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Sat, 17 Feb 2024 18:29:11 +0100 Subject: [PATCH 6/6] Feature: DPL: use best available voltage value the DPL is interested in the battery's voltage to make decisions about draining the battery or letting it charge (if the user opts to use voltage thresholds rather than SoC thresholds). using the DC input voltage reported by the inverter under control has disadvantages: * the data might be quite old due to the communication protocol implementation. more inverters being polled means even more lag. the connection being wireless makes this even worse, due to the need to retry the occasional lost packet, etc. * the data is not very accurate, since the DC input of the inverter is actually some cabling and a couple of junctions away from the actual battery. this voltage drop can mostly only be estimated and is worse with higher load. the load correction factor is there to mitigate this, but it has its own problems and is cumbersome to calibrate. instead, this change aims to use more accurate battery voltage readings, if possible. the DPL now prefers the voltage as reported by the BMS, since it is for sure the closest to the battery of all measuring points and measures its voltage accurately regardless of the load (the voltage reading will still drop with higher loads, but this will be only due to the battery's internal resistance, not that of cabling or junctions). if no BMS voltage reading is available, the DPL will instead use the charge controller's voltage reading, as it is available with much higher frequency and is assumed to be more accurate as it offers a resolution of 10mV. only if none of these two sources can be used, the inverter DC input voltage is assumed as the battery voltage. closes #655. --- include/BatteryStats.h | 1 + include/PowerLimiter.h | 1 + include/VictronMppt.h | 3 +++ src/PowerLimiter.cpp | 45 +++++++++++++++++++++++++++++++++++++++--- src/VictronMppt.cpp | 13 ++++++++++++ 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 916bf3115..e4bf4144e 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -33,6 +33,7 @@ class BatteryStats { virtual uint32_t getMqttFullPublishIntervalMs() const; bool isSoCValid() const { return _lastUpdateSoC > 0; } + bool isVoltageValid() const { return _lastUpdateVoltage > 0; } protected: virtual void mqttPublish() const; diff --git a/include/PowerLimiter.h b/include/PowerLimiter.h index af6ed28f5..32260150a 100644 --- a/include/PowerLimiter.h +++ b/include/PowerLimiter.h @@ -88,6 +88,7 @@ class PowerLimiterClass { void announceStatus(Status status); bool shutdown(Status status); bool shutdown() { return shutdown(_lastStatus); } + float getBatteryVoltage(bool log = false); int32_t inverterPowerDcToAc(std::shared_ptr inverter, int32_t dcPower); void unconditionalSolarPassthrough(std::shared_ptr inverter); bool canUseDirectSolarPower(); diff --git a/include/VictronMppt.h b/include/VictronMppt.h index 091ddb005..12d6bdf75 100644 --- a/include/VictronMppt.h +++ b/include/VictronMppt.h @@ -35,6 +35,9 @@ class VictronMpptClass { // sum of today's yield of all MPPT charge controllers in kWh double getYieldDay() const; + // minimum of all MPPT charge controllers' output voltages in V + double getOutputVoltage() const; + private: void loop(); VictronMpptClass(VictronMpptClass const& other) = delete; diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index 68564e37a..b4c2229a9 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -298,7 +298,7 @@ void PowerLimiterClass::loop() Battery.getStats()->getSoCAgeSeconds(), (config.PowerLimiter.IgnoreSoc?"yes":"no")); - float dcVoltage = _inverter->Statistics()->getChannelFieldValue(TYPE_DC, (ChannelNum_t)config.PowerLimiter.InverterChannelId, FLD_UDC); + auto dcVoltage = getBatteryVoltage(true/*log voltages only once per DPL loop*/); MessageOutput.printf("[DPL::loop] dcVoltage: %.2f V, loadCorrectedVoltage: %.2f V, StartTH: %.2f V, StopTH: %.2f V\r\n", dcVoltage, getLoadCorrectedVoltage(), config.PowerLimiter.VoltageStartThreshold, @@ -340,6 +340,46 @@ void PowerLimiterClass::loop() _calculationBackoffMs = _calculationBackoffMsDefault; } +/** + * determines the battery's voltage, trying multiple data providers. the most + * accurate data is expected to be delivered by a BMS, if it's available. more + * accurate and more recent than the inverter's voltage reading is the volage + * at the charge controller's output, if it's available. only as a fallback + * the voltage reported by the inverter is used. + */ +float PowerLimiterClass::getBatteryVoltage(bool log) { + if (!_inverter) { + // there should be no need to call this method if no target inverter is known + MessageOutput.println("DPL getBatteryVoltage: no inverter (programmer error)"); + return 0.0; + } + + auto const& config = Configuration.get(); + auto channel = static_cast(config.PowerLimiter.InverterChannelId); + float inverterVoltage = _inverter->Statistics()->getChannelFieldValue(TYPE_DC, channel, FLD_UDC); + float res = inverterVoltage; + + float chargeControllerVoltage = -1; + if (VictronMppt.isDataValid()) { + res = chargeControllerVoltage = static_cast(VictronMppt.getOutputVoltage()); + } + + float bmsVoltage = -1; + auto stats = Battery.getStats(); + if (config.Battery.Enabled + && stats->isVoltageValid() + && stats->getVoltageAgeSeconds() < 60) { + res = bmsVoltage = stats->getVoltage(); + } + + if (log) { + MessageOutput.printf("[DPL::getBatteryVoltage] BMS: %.2f V, MPPT: %.2f V, inverter: %.2f V, returning: %.2fV\r\n", + bmsVoltage, chargeControllerVoltage, inverterVoltage, res); + } + + return res; +} + /** * calculate the AC output power (limit) to set, such that the inverter uses * the given power on its DC side, i.e., adjust the power for the inverter's @@ -593,9 +633,8 @@ float PowerLimiterClass::getLoadCorrectedVoltage() CONFIG_T& config = Configuration.get(); - auto channel = static_cast(config.PowerLimiter.InverterChannelId); float acPower = _inverter->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_PAC); - float dcVoltage = _inverter->Statistics()->getChannelFieldValue(TYPE_DC, channel, FLD_UDC); + float dcVoltage = getBatteryVoltage(); if (dcVoltage <= 0.0) { return 0.0; diff --git a/src/VictronMppt.cpp b/src/VictronMppt.cpp index fd1073a78..c4dd0bd5a 100644 --- a/src/VictronMppt.cpp +++ b/src/VictronMppt.cpp @@ -137,3 +137,16 @@ double VictronMpptClass::getYieldDay() const return sum; } + +double VictronMpptClass::getOutputVoltage() const +{ + double min = -1; + + for (const auto& upController : _controllers) { + double volts = upController->getData()->V; + if (min == -1) { min = volts; } + min = std::min(min, volts); + } + + return min; +}