Skip to content

Commit

Permalink
feature: show battery voltage, current, and power in live view (#1131)
Browse files Browse the repository at this point in the history
* show battery voltage, current, and power in live view header (the "totals")
* show battery current and power in extra card
* use soc and current precision in live view
* BatteryStats: do not knowingly publish invalid data: not all battery
  providers know all values the base class manages. make sure to
  prevent publishing invalid values.

Co-authored-by: Bernhard Kirchen <schlimmchen@posteo.net>
  • Loading branch information
AndreasBoehm and schlimmchen authored Jul 27, 2024
1 parent e95b70e commit accc70d
Show file tree
Hide file tree
Showing 11 changed files with 119 additions and 50 deletions.
35 changes: 21 additions & 14 deletions include/BatteryStats.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,37 @@ class BatteryStats {
public:
String const& getManufacturer() const { return _manufacturer; }

// the last time *any* datum was updated
// the last time *any* data was updated
uint32_t getAgeSeconds() const { return (millis() - _lastUpdate) / 1000; }
bool updateAvailable(uint32_t since) const;

uint8_t getSoC() const { return _soc; }
uint32_t getSoCAgeSeconds() const { return (millis() - _lastUpdateSoC) / 1000; }
uint8_t getSoCPrecision() const { return _socPrecision; }

float getVoltage() const { return _voltage; }
uint32_t getVoltageAgeSeconds() const { return (millis() - _lastUpdateVoltage) / 1000; }

float getChargeCurrent() const { return _current; };
uint8_t getChargeCurrentPrecision() const { return _currentPrecision; }

// convert stats to JSON for web application live view
virtual void getLiveViewData(JsonVariant& root) const;

void mqttLoop();

// the interval at which all battery datums will be re-published, even
// the interval at which all battery data will be re-published, even
// if they did not change. used to calculate Home Assistent expiration.
virtual uint32_t getMqttFullPublishIntervalMs() const;

bool isSoCValid() const { return _lastUpdateSoC > 0; }
bool isVoltageValid() const { return _lastUpdateVoltage > 0; }
bool isCurrentValid() const { return _lastUpdateCurrent > 0; }

// returns true if the battery reached a critically low voltage/SoC,
// such that it is in need of charging to prevent degredation.
virtual bool getImmediateChargingRequest() const { return false; };

virtual float getChargeCurrent() const { return 0; };
virtual float getChargeCurrentLimitation() const { return FLT_MAX; };

protected:
Expand All @@ -57,6 +61,12 @@ class BatteryStats {
_lastUpdateVoltage = _lastUpdate = timestamp;
}

void setCurrent(float current, uint8_t precision, uint32_t timestamp) {
_current = current;
_currentPrecision = precision;
_lastUpdateCurrent = _lastUpdate = timestamp;
}

String _manufacturer = "unknown";
String _hwversion = "";
String _fwversion = "";
Expand All @@ -70,6 +80,12 @@ class BatteryStats {
uint32_t _lastUpdateSoC = 0;
float _voltage = 0; // total battery pack voltage
uint32_t _lastUpdateVoltage = 0;

// total current into (positive) or from (negative)
// the battery, i.e., the charging current
float _current = 0;
uint8_t _currentPrecision = 0; // decimal places
uint32_t _lastUpdateCurrent = 0;
};

class PylontechBatteryStats : public BatteryStats {
Expand All @@ -79,7 +95,6 @@ class PylontechBatteryStats : public BatteryStats {
void getLiveViewData(JsonVariant& root) const final;
void mqttPublish() const final;
bool getImmediateChargingRequest() const { return _chargeImmediately; } ;
float getChargeCurrent() const { return _current; } ;
float getChargeCurrentLimitation() const { return _chargeCurrentLimitation; } ;

private:
Expand All @@ -90,9 +105,6 @@ class PylontechBatteryStats : public BatteryStats {
float _chargeCurrentLimitation;
float _dischargeCurrentLimitation;
uint16_t _stateOfHealth;
// total current into (positive) or from (negative)
// the battery, i.e., the charging current
float _current;
float _temperature;

bool _alarmOverCurrentDischarge;
Expand Down Expand Up @@ -122,7 +134,6 @@ class PytesBatteryStats : public BatteryStats {
public:
void getLiveViewData(JsonVariant& root) const final;
void mqttPublish() const final;
float getChargeCurrent() const { return _current; } ;
float getChargeCurrentLimitation() const { return _chargeCurrentLimit; } ;

private:
Expand All @@ -144,9 +155,6 @@ class PytesBatteryStats : public BatteryStats {

uint16_t _stateOfHealth;

// total current into (positive) or from (negative)
// the battery, i.e., the charging current
float _current;
float _temperature;

uint16_t _cellMinMilliVolt;
Expand Down Expand Up @@ -231,7 +239,6 @@ class VictronSmartShuntStats : public BatteryStats {
void updateFrom(VeDirectShuntController::data_t const& shuntData);

private:
float _current;
float _temperature;
bool _tempPresent;
uint8_t _chargeCycles;
Expand Down Expand Up @@ -259,7 +266,7 @@ class MqttBatteryStats : public BatteryStats {
// we do NOT publish the same data under a different topic.
void mqttPublish() const final { }

// 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
// we don't need a card in the liveview, since the SoC and
// voltage (if available) is already displayed at the top.
void getLiveViewData(JsonVariant& root) const final { }
};
32 changes: 18 additions & 14 deletions src/BatteryStats.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ void BatteryStats::getLiveViewData(JsonVariant& root) const

addLiveViewValue(root, "SoC", _soc, "%", _socPrecision);
addLiveViewValue(root, "voltage", _voltage, "V", 2);
addLiveViewValue(root, "current", _current, "A", _currentPrecision);
}

void PylontechBatteryStats::getLiveViewData(JsonVariant& root) const
Expand All @@ -89,7 +90,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, "current", _current, "A", 1);
addLiveViewValue(root, "temperature", _temperature, "°C", 1);

addLiveViewTextValue(root, "chargeEnabled", (_chargeEnabled?"yes":"no"));
Expand Down Expand Up @@ -124,7 +124,6 @@ void PytesBatteryStats::getLiveViewData(JsonVariant& root) const
BatteryStats::getLiveViewData(root);

// values go into the "Status" card of the web application
addLiveViewValue(root, "current", _current, "A", 1);
addLiveViewValue(root, "chargeVoltage", _chargeVoltageLimit, "V", 1);
addLiveViewValue(root, "chargeCurrentLimitation", _chargeCurrentLimit, "A", 1);
addLiveViewValue(root, "dischargeVoltageLimitation", _dischargeVoltageLimit, "V", 1);
Expand Down Expand Up @@ -198,11 +197,6 @@ void JkBmsBatteryStats::getJsonData(JsonVariant& root, bool verbose) const
using Label = JkBms::DataPointLabel;

auto oCurrent = _dataPoints.get<Label::BatteryCurrentMilliAmps>();
if (oCurrent.has_value()) {
addLiveViewValue(root, "current",
static_cast<float>(*oCurrent) / 1000, "A", 2);
}

auto oVoltage = _dataPoints.get<Label::BatteryVoltageMilliVolt>();
if (oVoltage.has_value() && oCurrent.has_value()) {
auto current = static_cast<float>(*oCurrent) / 1000;
Expand Down Expand Up @@ -304,8 +298,15 @@ void BatteryStats::mqttPublish() const
{
MqttSettings.publish("battery/manufacturer", _manufacturer);
MqttSettings.publish("battery/dataAge", String(getAgeSeconds()));
MqttSettings.publish("battery/stateOfCharge", String(_soc));
MqttSettings.publish("battery/voltage", String(_voltage));
if (isSoCValid()) {
MqttSettings.publish("battery/stateOfCharge", String(_soc));
}
if (isVoltageValid()) {
MqttSettings.publish("battery/voltage", String(_voltage));
}
if (isCurrentValid()) {
MqttSettings.publish("battery/current", String(_current));
}
}

void PylontechBatteryStats::mqttPublish() const
Expand All @@ -316,7 +317,6 @@ void PylontechBatteryStats::mqttPublish() const
MqttSettings.publish("battery/settings/chargeCurrentLimitation", String(_chargeCurrentLimitation));
MqttSettings.publish("battery/settings/dischargeCurrentLimitation", String(_dischargeCurrentLimitation));
MqttSettings.publish("battery/stateOfHealth", String(_stateOfHealth));
MqttSettings.publish("battery/current", String(_current));
MqttSettings.publish("battery/temperature", String(_temperature));
MqttSettings.publish("battery/alarm/overCurrentDischarge", String(_alarmOverCurrentDischarge));
MqttSettings.publish("battery/alarm/overCurrentCharge", String(_alarmOverCurrentCharge));
Expand Down Expand Up @@ -347,7 +347,6 @@ void PytesBatteryStats::mqttPublish() const
MqttSettings.publish("battery/settings/dischargeVoltageLimitation", String(_dischargeVoltageLimit));

MqttSettings.publish("battery/stateOfHealth", String(_stateOfHealth));
MqttSettings.publish("battery/current", String(_current));
MqttSettings.publish("battery/temperature", String(_temperature));

if (_chargedEnergy != -1) {
Expand Down Expand Up @@ -505,6 +504,13 @@ void JkBmsBatteryStats::updateFrom(JkBms::DataPointContainer const& dp)
oVoltageDataPoint->getTimestamp());
}

auto oCurrent = dp.get<Label::BatteryCurrentMilliAmps>();
if (oCurrent.has_value()) {
auto oCurrentDataPoint = dp.getDataPointFor<Label::BatteryCurrentMilliAmps>();
BatteryStats::setCurrent(static_cast<float>(*oCurrent) / 1000, 2/*precision*/,
oCurrentDataPoint->getTimestamp());
}

_dataPoints.updateFrom(dp);

auto oCellVoltages = _dataPoints.get<Label::CellsMilliVolt>();
Expand Down Expand Up @@ -545,9 +551,9 @@ void JkBmsBatteryStats::updateFrom(JkBms::DataPointContainer const& dp)
void VictronSmartShuntStats::updateFrom(VeDirectShuntController::data_t const& shuntData) {
BatteryStats::setVoltage(shuntData.batteryVoltage_V_mV / 1000.0, millis());
BatteryStats::setSoC(static_cast<float>(shuntData.SOC) / 10, 1/*precision*/, millis());
BatteryStats::setCurrent(static_cast<float>(shuntData.batteryCurrent_I_mA) / 1000, 2/*precision*/, millis());
_fwversion = shuntData.getFwVersionFormatted();

_current = static_cast<float>(shuntData.batteryCurrent_I_mA) / 1000;
_chargeCycles = shuntData.H4;
_timeToGo = shuntData.TTG / 60;
_chargedEnergy = static_cast<float>(shuntData.H18) / 100;
Expand All @@ -574,7 +580,6 @@ void VictronSmartShuntStats::getLiveViewData(JsonVariant& root) const {
BatteryStats::getLiveViewData(root);

// values go into the "Status" card of the web application
addLiveViewValue(root, "current", _current, "A", 1);
addLiveViewValue(root, "chargeCycles", _chargeCycles, "", 0);
addLiveViewValue(root, "chargedEnergy", _chargedEnergy, "kWh", 2);
addLiveViewValue(root, "dischargedEnergy", _dischargedEnergy, "kWh", 2);
Expand All @@ -597,7 +602,6 @@ void VictronSmartShuntStats::getLiveViewData(JsonVariant& root) const {
void VictronSmartShuntStats::mqttPublish() const {
BatteryStats::mqttPublish();

MqttSettings.publish("battery/current", String(_current));
MqttSettings.publish("battery/chargeCycles", String(_chargeCycles));
MqttSettings.publish("battery/chargedEnergy", String(_chargedEnergy));
MqttSettings.publish("battery/dischargedEnergy", String(_dischargedEnergy));
Expand Down
6 changes: 3 additions & 3 deletions src/PylontechCanReceiver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ void PylontechCanReceiver::onMessage(twai_message_t rx_message)

case 0x356: {
_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->setCurrent(this->scaleValue(this->readSignedInt16(rx_message.data + 2), 0.1), 1/*precision*/, millis());
_stats->_temperature = this->scaleValue(this->readSignedInt16(rx_message.data + 4), 0.1);

if (_verboseLogging) {
MessageOutput.printf("[Pylontech] voltage: %f current: %f temperature: %f\r\n",
_stats->getVoltage(), _stats->_current, _stats->_temperature);
_stats->getVoltage(), _stats->getChargeCurrent(), _stats->_temperature);
}
break;
}
Expand Down Expand Up @@ -157,7 +157,7 @@ void PylontechCanReceiver::dummyData()
_stats->_dischargeCurrentLimitation = dummyFloat(12);
_stats->_stateOfHealth = 99;
_stats->setVoltage(48.67, millis());
_stats->_current = dummyFloat(-1);
_stats->setCurrent(dummyFloat(-1), 1/*precision*/, millis());
_stats->_temperature = dummyFloat(20);

_stats->_chargeEnabled = true;
Expand Down
4 changes: 2 additions & 2 deletions src/PytesCanReceiver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message)

case 0x356: {
_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->setCurrent(this->scaleValue(this->readSignedInt16(rx_message.data + 2), 0.1), 1/*precision*/, millis());
_stats->_temperature = this->scaleValue(this->readSignedInt16(rx_message.data + 4), 0.1);

if (_verboseLogging) {
MessageOutput.printf("[Pytes] voltage: %f current: %f temperature: %f\r\n",
_stats->getVoltage(), _stats->_current, _stats->_temperature);
_stats->getVoltage(), _stats->getChargeCurrent(), _stats->_temperature);
}
break;
}
Expand Down
16 changes: 15 additions & 1 deletion src/WebApi_ws_live.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,21 @@ void WebApiWsLiveClass::generateOnBatteryJsonResponse(JsonVariant& root, bool al
batteryObj["enabled"] = config.Battery.Enabled;

if (config.Battery.Enabled) {
addTotalField(batteryObj, "soc", spStats->getSoC(), "%", 0);
if (spStats->isSoCValid()) {
addTotalField(batteryObj, "soc", spStats->getSoC(), "%", spStats->getSoCPrecision());
}

if (spStats->isVoltageValid()) {
addTotalField(batteryObj, "voltage", spStats->getVoltage(), "V", 2);
}

if (spStats->isCurrentValid()) {
addTotalField(batteryObj, "current", spStats->getChargeCurrent(), "A", spStats->getChargeCurrentPrecision());
}

if (spStats->isVoltageValid() && spStats->isCurrentValid()) {
addTotalField(batteryObj, "power", spStats->getVoltage() * spStats->getChargeCurrent(), "W", 1);
}
}

if (!all) { _lastPublishBattery = millis(); }
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/components/CardElement.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div :class="['card', addSpace ? 'mt-5' : '' ]">
<div :class="['card-header', textVariant]">{{ text }}</div>
<div :class="['card-body', 'card-text', centerContent ? 'text-center' : '']">
<div :class="['card-body', 'card-text', centerContent ? 'text-center' : '', flexChildren ? 'd-flex' : '']">
<slot />
</div>
</div>
Expand All @@ -16,6 +16,7 @@ export default defineComponent({
'textVariant': String,
'addSpace': Boolean,
'centerContent': Boolean,
'flexChildren': Boolean,
},
});
</script>
59 changes: 48 additions & 11 deletions webapp/src/components/InverterTotalInfo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,54 @@
</h2>
</CardElement>
</div>
<div class="col" v-if="totalBattData.enabled">
<CardElement centerContent textVariant="text-bg-success" :text="$t('invertertotalinfo.BatterySoc')">
<h2>
{{ $n(totalBattData.soc.v, 'decimal', {
minimumFractionDigits: totalBattData.soc.d,
maximumFractionDigits: totalBattData.soc.d
}) }}
<small class="text-muted">{{ totalBattData.soc.u }}</small>
</h2>
</CardElement>
</div>
<template v-if="totalBattData.enabled">
<div class="col">
<CardElement centerContent flexChildren textVariant="text-bg-success" :text="$t('invertertotalinfo.BatteryCharge')">
<div class="flex-fill" v-if="totalBattData.soc">
<h2>
{{ $n(totalBattData.soc.v, 'decimal', {
minimumFractionDigits: totalBattData.soc.d,
maximumFractionDigits: totalBattData.soc.d
}) }}
<small class="text-muted">{{ totalBattData.soc.u }}</small>
</h2>
</div>

<div class="flex-fill" v-if="totalBattData.voltage">
<h2>
{{ $n(totalBattData.voltage.v, 'decimal', {
minimumFractionDigits: totalBattData.voltage.d,
maximumFractionDigits: totalBattData.voltage.d
}) }}
<small class="text-muted">{{ totalBattData.voltage.u }}</small>
</h2>
</div>
</CardElement>
</div>
<div class="col" v-if="totalBattData.power || totalBattData.current">
<CardElement centerContent flexChildren textVariant="text-bg-success" :text="$t('invertertotalinfo.BatteryPower')">
<div class="flex-fill" v-if="totalBattData.power">
<h2>
{{ $n(totalBattData.power.v, 'decimal', {
minimumFractionDigits: totalBattData.power.d,
maximumFractionDigits: totalBattData.power.d
}) }}
<small class="text-muted">{{ totalBattData.power.u }}</small>
</h2>
</div>

<div class="flex-fill" v-if="totalBattData.current">
<h2>
{{ $n(totalBattData.current.v, 'decimal', {
minimumFractionDigits: totalBattData.current.d,
maximumFractionDigits: totalBattData.current.d
}) }}
<small class="text-muted">{{ totalBattData.current.u }}</small>
</h2>
</div>
</CardElement>
</div>
</template>
<div class="col" v-if="powerMeterData.enabled">
<CardElement centerContent textVariant="text-bg-success" :text="$t('invertertotalinfo.HomePower')">
<h2>
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,8 @@
"MpptTotalYieldTotal": "MPPT Gesamtertrag Insgesamt",
"MpptTotalYieldDay": "MPPT Gesamtertrag Heute",
"MpptTotalPower": "MPPT Gesamtleistung",
"BatterySoc": "Ladezustand",
"BatteryCharge": "Batterie Ladezustand",
"BatteryPower": "Batterie Leistung",
"HomePower": "Leistung / Netz",
"HuaweiPower": "Huawei AC Leistung"
},
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,8 @@
"MpptTotalYieldTotal": "MPPT Total Yield Total",
"MpptTotalYieldDay": "MPPT Total Yield Day",
"MpptTotalPower": "MPPT Total Power",
"BatterySoc": "State of charge",
"BatteryCharge": "Battery Charge",
"BatteryPower": "Battery Power",
"HomePower": "Grid Power",
"HuaweiPower": "Huawei AC Power"
},
Expand Down
Loading

0 comments on commit accc70d

Please sign in to comment.