OpenDTU-old/src/BatteryStats.cpp

470 lines
20 KiB
C++

// SPDX-License-Identifier: GPL-2.0-or-later
#include <vector>
#include <algorithm>
#include "BatteryStats.h"
#include "Configuration.h"
#include "MqttSettings.h"
#include "JkBmsDataPoints.h"
#include "MqttSettings.h"
template<typename T>
static void addLiveViewInSection(JsonVariant& root,
std::string const& section, std::string const& name,
T&& value, std::string const& unit, uint8_t precision)
{
auto jsonValue = root["values"][section][name];
jsonValue["v"] = value;
jsonValue["u"] = unit;
jsonValue["d"] = precision;
}
template<typename T>
static void addLiveViewValue(JsonVariant& root, std::string const& name,
T&& value, std::string const& unit, uint8_t precision)
{
addLiveViewInSection(root, "status", name, value, unit, precision);
}
static void addLiveViewTextInSection(JsonVariant& root,
std::string const& section, std::string const& name, std::string const& text)
{
root["values"][section][name] = text;
}
static void addLiveViewTextValue(JsonVariant& root, std::string const& name,
std::string const& text)
{
addLiveViewTextInSection(root, "status", name, text);
}
static void addLiveViewWarning(JsonVariant& root, std::string const& name,
bool warning)
{
if (!warning) { return; }
root["issues"][name] = 1;
}
static void addLiveViewAlarm(JsonVariant& root, std::string const& name,
bool alarm)
{
if (!alarm) { return; }
root["issues"][name] = 2;
}
bool BatteryStats::updateAvailable(uint32_t since) const
{
if (_lastUpdate == 0) { return false; } // no data at all processed yet
auto constexpr halfOfAllMillis = std::numeric_limits<uint32_t>::max() / 2;
return (_lastUpdate - since) < halfOfAllMillis;
}
void BatteryStats::getLiveViewData(JsonVariant& root) const
{
root["manufacturer"] = _manufacturer;
if (!_fwversion.isEmpty()) {
root["fwversion"] = _fwversion;
}
if (!_hwversion.isEmpty()) {
root["hwversion"] = _hwversion;
}
root["data_age"] = getAgeSeconds();
addLiveViewValue(root, "SoC", _soc, "%", _socPrecision);
addLiveViewValue(root, "voltage", _voltage, "V", 2);
}
void PylontechBatteryStats::getLiveViewData(JsonVariant& root) const
{
BatteryStats::getLiveViewData(root);
// values go into the "Status" card of the web application
addLiveViewValue(root, "chargeVoltage", _chargeVoltage, "V", 1);
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"));
addLiveViewTextValue(root, "dischargeEnabled", (_dischargeEnabled?"yes":"no"));
addLiveViewTextValue(root, "chargeImmediately", (_chargeImmediately?"yes":"no"));
// alarms and warnings go into the "Issues" card of the web application
addLiveViewWarning(root, "highCurrentDischarge", _warningHighCurrentDischarge);
addLiveViewAlarm(root, "overCurrentDischarge", _alarmOverCurrentDischarge);
addLiveViewWarning(root, "highCurrentCharge", _warningHighCurrentCharge);
addLiveViewAlarm(root, "overCurrentCharge", _alarmOverCurrentCharge);
addLiveViewWarning(root, "lowTemperature", _warningLowTemperature);
addLiveViewAlarm(root, "underTemperature", _alarmUnderTemperature);
addLiveViewWarning(root, "highTemperature", _warningHighTemperature);
addLiveViewAlarm(root, "overTemperature", _alarmOverTemperature);
addLiveViewWarning(root, "lowVoltage", _warningLowVoltage);
addLiveViewAlarm(root, "underVoltage", _alarmUnderVoltage);
addLiveViewWarning(root, "highVoltage", _warningHighVoltage);
addLiveViewAlarm(root, "overVoltage", _alarmOverVoltage);
addLiveViewWarning(root, "bmsInternal", _warningBmsInternal);
addLiveViewAlarm(root, "bmsInternal", _alarmBmsInternal);
}
void JkBmsBatteryStats::getJsonData(JsonVariant& root, bool verbose) const
{
BatteryStats::getLiveViewData(root);
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;
auto voltage = static_cast<float>(*oVoltage) / 1000;
addLiveViewValue(root, "power", current * voltage , "W", 2);
}
auto oTemperatureBms = _dataPoints.get<Label::BmsTempCelsius>();
if (oTemperatureBms.has_value()) {
addLiveViewValue(root, "bmsTemp", *oTemperatureBms, "°C", 0);
}
// labels BatteryChargeEnabled, BatteryDischargeEnabled, and
// BalancingEnabled refer to the user setting. we want to show the
// actual MOSFETs' state which control whether charging and discharging
// is possible and whether the BMS is currently balancing cells.
auto oStatus = _dataPoints.get<Label::StatusBitmask>();
if (oStatus.has_value()) {
using Bits = JkBms::StatusBits;
auto chargeEnabled = *oStatus & static_cast<uint16_t>(Bits::ChargingActive);
addLiveViewTextValue(root, "chargeEnabled", (chargeEnabled?"yes":"no"));
auto dischargeEnabled = *oStatus & static_cast<uint16_t>(Bits::DischargingActive);
addLiveViewTextValue(root, "dischargeEnabled", (dischargeEnabled?"yes":"no"));
}
auto oTemperatureOne = _dataPoints.get<Label::BatteryTempOneCelsius>();
if (oTemperatureOne.has_value()) {
addLiveViewInSection(root, "cells", "batOneTemp", *oTemperatureOne, "°C", 0);
}
auto oTemperatureTwo = _dataPoints.get<Label::BatteryTempTwoCelsius>();
if (oTemperatureTwo.has_value()) {
addLiveViewInSection(root, "cells", "batTwoTemp", *oTemperatureTwo, "°C", 0);
}
if (_cellVoltageTimestamp > 0) {
addLiveViewInSection(root, "cells", "cellMinVoltage", static_cast<float>(_cellMinMilliVolt)/1000, "V", 3);
addLiveViewInSection(root, "cells", "cellAvgVoltage", static_cast<float>(_cellAvgMilliVolt)/1000, "V", 3);
addLiveViewInSection(root, "cells", "cellMaxVoltage", static_cast<float>(_cellMaxMilliVolt)/1000, "V", 3);
addLiveViewInSection(root, "cells", "cellDiffVoltage", (_cellMaxMilliVolt - _cellMinMilliVolt), "mV", 0);
}
if (oStatus.has_value()) {
using Bits = JkBms::StatusBits;
auto balancingActive = *oStatus & static_cast<uint16_t>(Bits::BalancingActive);
addLiveViewTextInSection(root, "cells", "balancingActive", (balancingActive?"yes":"no"));
}
auto oAlarms = _dataPoints.get<Label::AlarmsBitmask>();
if (oAlarms.has_value()) {
#define ISSUE(t, x) \
auto x = *oAlarms & static_cast<uint16_t>(JkBms::AlarmBits::x); \
addLiveView##t(root, "JkBmsIssue"#x, x > 0);
ISSUE(Warning, LowCapacity);
ISSUE(Alarm, BmsOvertemperature);
ISSUE(Alarm, ChargingOvervoltage);
ISSUE(Alarm, DischargeUndervoltage);
ISSUE(Alarm, BatteryOvertemperature);
ISSUE(Alarm, ChargingOvercurrent);
ISSUE(Alarm, DischargeOvercurrent);
ISSUE(Alarm, CellVoltageDifference);
ISSUE(Alarm, BatteryBoxOvertemperature);
ISSUE(Alarm, BatteryUndertemperature);
ISSUE(Alarm, CellOvervoltage);
ISSUE(Alarm, CellUndervoltage);
ISSUE(Alarm, AProtect);
ISSUE(Alarm, BProtect);
#undef ISSUE
}
}
void BatteryStats::mqttLoop()
{
auto& config = Configuration.get();
if (!MqttSettings.getConnected()
|| (millis() - _lastMqttPublish) < (config.Mqtt.PublishInterval * 1000)) {
return;
}
mqttPublish();
_lastMqttPublish = millis();
}
uint32_t BatteryStats::getMqttFullPublishIntervalMs() const
{
auto& config = Configuration.get();
// this is the default interval, see mqttLoop(). mqttPublish()
// implementations in derived classes may choose to publish some values
// with a lower frequency and hence implement this method with a different
// return value.
return config.Mqtt.PublishInterval * 1000;
}
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));
}
void PylontechBatteryStats::mqttPublish() const
{
BatteryStats::mqttPublish();
MqttSettings.publish("battery/settings/chargeVoltage", String(_chargeVoltage));
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));
MqttSettings.publish("battery/alarm/underTemperature", String(_alarmUnderTemperature));
MqttSettings.publish("battery/alarm/overTemperature", String(_alarmOverTemperature));
MqttSettings.publish("battery/alarm/underVoltage", String(_alarmUnderVoltage));
MqttSettings.publish("battery/alarm/overVoltage", String(_alarmOverVoltage));
MqttSettings.publish("battery/alarm/bmsInternal", String(_alarmBmsInternal));
MqttSettings.publish("battery/warning/highCurrentDischarge", String(_warningHighCurrentDischarge));
MqttSettings.publish("battery/warning/highCurrentCharge", String(_warningHighCurrentCharge));
MqttSettings.publish("battery/warning/lowTemperature", String(_warningLowTemperature));
MqttSettings.publish("battery/warning/highTemperature", String(_warningHighTemperature));
MqttSettings.publish("battery/warning/lowVoltage", String(_warningLowVoltage));
MqttSettings.publish("battery/warning/highVoltage", String(_warningHighVoltage));
MqttSettings.publish("battery/warning/bmsInternal", String(_warningBmsInternal));
MqttSettings.publish("battery/charging/chargeEnabled", String(_chargeEnabled));
MqttSettings.publish("battery/charging/dischargeEnabled", String(_dischargeEnabled));
MqttSettings.publish("battery/charging/chargeImmediately", String(_chargeImmediately));
}
void JkBmsBatteryStats::mqttPublish() const
{
BatteryStats::mqttPublish();
using Label = JkBms::DataPointLabel;
static std::vector<Label> mqttSkip = {
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
bool neverFullyPublished = _lastFullMqttPublish == 0;
bool intervalElapsed = _lastFullMqttPublish + getMqttFullPublishIntervalMs() < millis();
bool fullPublish = neverFullyPublished || intervalElapsed;
for (auto iter = _dataPoints.cbegin(); iter != _dataPoints.cend(); ++iter) {
// skip data points that did not change since last published
if (!fullPublish && iter->second.getTimestamp() < _lastMqttPublish) { continue; }
auto skipMatch = std::find(mqttSkip.begin(), mqttSkip.end(), iter->first);
if (skipMatch != mqttSkip.end()) { continue; }
String topic((std::string("battery/") + iter->second.getLabelText()).c_str());
MqttSettings.publish(topic, iter->second.getValueText().c_str());
}
auto oCellVoltages = _dataPoints.get<Label::CellsMilliVolt>();
if (oCellVoltages.has_value() && (fullPublish || _cellVoltageTimestamp > _lastMqttPublish)) {
unsigned idx = 1;
for (auto iter = oCellVoltages->cbegin(); iter != oCellVoltages->cend(); ++iter) {
String topic("battery/Cell");
topic += String(idx);
topic += "MilliVolt";
MqttSettings.publish(topic, String(iter->second));
++idx;
}
MqttSettings.publish("battery/CellMinMilliVolt", String(_cellMinMilliVolt));
MqttSettings.publish("battery/CellAvgMilliVolt", String(_cellAvgMilliVolt));
MqttSettings.publish("battery/CellMaxMilliVolt", String(_cellMaxMilliVolt));
MqttSettings.publish("battery/CellDiffMilliVolt", String(_cellMaxMilliVolt - _cellMinMilliVolt));
}
auto oAlarms = _dataPoints.get<Label::AlarmsBitmask>();
if (oAlarms.has_value()) {
for (auto iter = JkBms::AlarmBitTexts.begin(); iter != JkBms::AlarmBitTexts.end(); ++iter) {
auto bit = iter->first;
String value = (*oAlarms & static_cast<uint16_t>(bit))?"1":"0";
MqttSettings.publish(String("battery/alarms/") + iter->second.data(), value);
}
}
auto oStatus = _dataPoints.get<Label::StatusBitmask>();
if (oStatus.has_value()) {
for (auto iter = JkBms::StatusBitTexts.begin(); iter != JkBms::StatusBitTexts.end(); ++iter) {
auto bit = iter->first;
String value = (*oStatus & static_cast<uint16_t>(bit))?"1":"0";
MqttSettings.publish(String("battery/status/") + iter->second.data(), value);
}
}
_lastMqttPublish = millis();
if (fullPublish) { _lastFullMqttPublish = _lastMqttPublish; }
}
void JkBmsBatteryStats::updateFrom(JkBms::DataPointContainer const& dp)
{
using Label = JkBms::DataPointLabel;
_manufacturer = "JKBMS";
auto oProductId = dp.get<Label::ProductId>();
if (oProductId.has_value()) {
// the first twelve chars are expected to be the "User Private Data"
// setting (see smartphone app). the remainder is expected be the BMS
// name, which can be changed at will using the smartphone app. so
// there is not always a "JK" in this string. if there is, we still cut
// the string there to avoid possible regressions.
_manufacturer = oProductId->substr(12).c_str();
auto pos = oProductId->rfind("JK");
if (pos != std::string::npos) {
_manufacturer = oProductId->substr(pos).c_str();
}
}
auto oSoCValue = dp.get<Label::BatterySoCPercent>();
if (oSoCValue.has_value()) {
auto oSoCDataPoint = dp.getDataPointFor<Label::BatterySoCPercent>();
BatteryStats::setSoC(*oSoCValue, 0/*precision*/,
oSoCDataPoint->getTimestamp());
}
auto oVoltage = dp.get<Label::BatteryVoltageMilliVolt>();
if (oVoltage.has_value()) {
auto oVoltageDataPoint = dp.getDataPointFor<Label::BatteryVoltageMilliVolt>();
BatteryStats::setVoltage(static_cast<float>(*oVoltage) / 1000,
oVoltageDataPoint->getTimestamp());
}
_dataPoints.updateFrom(dp);
auto oCellVoltages = _dataPoints.get<Label::CellsMilliVolt>();
if (oCellVoltages.has_value()) {
for (auto iter = oCellVoltages->cbegin(); iter != oCellVoltages->cend(); ++iter) {
if (iter == oCellVoltages->cbegin()) {
_cellMinMilliVolt = _cellAvgMilliVolt = _cellMaxMilliVolt = iter->second;
continue;
}
_cellMinMilliVolt = std::min(_cellMinMilliVolt, iter->second);
_cellAvgMilliVolt = (_cellAvgMilliVolt + iter->second) / 2;
_cellMaxMilliVolt = std::max(_cellMaxMilliVolt, iter->second);
}
_cellVoltageTimestamp = millis();
}
auto oVersion = _dataPoints.get<Label::BmsSoftwareVersion>();
if (oVersion.has_value()) {
// raw: "11.XW_S11.262H_"
// => Hardware "V11.XW" (displayed in Android app)
// => Software "V11.262H" (displayed in Android app)
auto first = oVersion->find('_');
if (first != std::string::npos) {
_hwversion = oVersion->substr(0, first).c_str();
auto second = oVersion->find('_', first + 1);
// the 'S' seems to be merely an indicator for "software"?
if (oVersion->at(first + 1) == 'S') { first++; }
_fwversion = oVersion->substr(first + 1, second - first - 1).c_str();
}
}
_lastUpdate = millis();
}
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());
_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;
_dischargedEnergy = static_cast<float>(shuntData.H17) / 100;
_manufacturer = String("Victron ") + shuntData.getPidAsString().data();
_temperature = shuntData.T;
_tempPresent = shuntData.tempPresent;
_midpointVoltage = static_cast<float>(shuntData.VM) / 1000;
_midpointDeviation = static_cast<float>(shuntData.DM) / 10;
_instantaneousPower = shuntData.P;
_consumedAmpHours = static_cast<float>(shuntData.CE) / 1000;
_lastFullCharge = shuntData.H9 / 60;
// shuntData.AR is a bitfield, so we need to check each bit individually
_alarmLowVoltage = shuntData.alarmReason_AR & 1;
_alarmHighVoltage = shuntData.alarmReason_AR & 2;
_alarmLowSOC = shuntData.alarmReason_AR & 4;
_alarmLowTemperature = shuntData.alarmReason_AR & 32;
_alarmHighTemperature = shuntData.alarmReason_AR & 64;
_lastUpdate = VeDirectShunt.getLastUpdate();
}
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);
addLiveViewValue(root, "instantaneousPower", _instantaneousPower, "W", 0);
addLiveViewValue(root, "consumedAmpHours", _consumedAmpHours, "Ah", 3);
addLiveViewValue(root, "midpointVoltage", _midpointVoltage, "V", 2);
addLiveViewValue(root, "midpointDeviation", _midpointDeviation, "%", 1);
addLiveViewValue(root, "lastFullCharge", _lastFullCharge, "min", 0);
if (_tempPresent) {
addLiveViewValue(root, "temperature", _temperature, "°C", 0);
}
addLiveViewAlarm(root, "lowVoltage", _alarmLowVoltage);
addLiveViewAlarm(root, "highVoltage", _alarmHighVoltage);
addLiveViewAlarm(root, "lowSOC", _alarmLowSOC);
addLiveViewAlarm(root, "lowTemperature", _alarmLowTemperature);
addLiveViewAlarm(root, "highTemperature", _alarmHighTemperature);
}
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));
MqttSettings.publish("battery/instantaneousPower", String(_instantaneousPower));
MqttSettings.publish("battery/consumedAmpHours", String(_consumedAmpHours));
MqttSettings.publish("battery/lastFullCharge", String(_lastFullCharge));
MqttSettings.publish("battery/midpointVoltage", String(_midpointVoltage));
MqttSettings.publish("battery/midpointDeviation", String(_midpointDeviation));
}