440 lines
19 KiB
C++
440 lines
19 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;
|
|
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();
|
|
}
|
|
|
|
_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());
|
|
|
|
_current = static_cast<float>(shuntData.batteryCurrent_I_mA) / 1000;
|
|
_modelName = shuntData.getPidAsString().data();
|
|
_chargeCycles = shuntData.H4;
|
|
_timeToGo = shuntData.TTG / 60;
|
|
_chargedEnergy = static_cast<float>(shuntData.H18) / 100;
|
|
_dischargedEnergy = static_cast<float>(shuntData.H17) / 100;
|
|
_manufacturer = "Victron " + _modelName;
|
|
_temperature = shuntData.T;
|
|
_tempPresent = shuntData.tempPresent;
|
|
_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, "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));
|
|
}
|