diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 30106657a..bcd07c57d 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -199,6 +199,8 @@ class PytesBatteryStats : public BatteryStats { float _dischargeVoltageLimit; uint16_t _stateOfHealth; + int _chargeCycles = -1; + int _balance = -1; float _temperature; @@ -218,8 +220,9 @@ class PytesBatteryStats : public BatteryStats { uint8_t _moduleCountBlockingCharge; uint8_t _moduleCountBlockingDischarge; - uint16_t _totalCapacity; - uint16_t _availableCapacity; + float _totalCapacity; + float _availableCapacity; + uint8_t _capacityPrecision = 0; // decimal places float _chargedEnergy = -1; float _dischargedEnergy = -1; diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index 08600f5c7..822330d57 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -187,10 +187,13 @@ void PytesBatteryStats::getLiveViewData(JsonVariant& root) const addLiveViewValue(root, "chargeCurrentLimitation", _chargeCurrentLimit, "A", 1); addLiveViewValue(root, "dischargeVoltageLimitation", _dischargeVoltageLimit, "V", 1); addLiveViewValue(root, "stateOfHealth", _stateOfHealth, "%", 0); + if (_chargeCycles != -1) { + addLiveViewValue(root, "chargeCycles", _chargeCycles, "", 0); + } addLiveViewValue(root, "temperature", _temperature, "°C", 1); - addLiveViewValue(root, "capacity", _totalCapacity, "Ah", 0); - addLiveViewValue(root, "availableCapacity", _availableCapacity, "Ah", 0); + addLiveViewValue(root, "capacity", _totalCapacity, "Ah", _capacityPrecision); + addLiveViewValue(root, "availableCapacity", _availableCapacity, "Ah", _capacityPrecision); if (_chargedEnergy != -1) { addLiveViewValue(root, "chargedEnergy", _chargedEnergy, "kWh", 1); @@ -201,6 +204,10 @@ void PytesBatteryStats::getLiveViewData(JsonVariant& root) const } addLiveViewTextValue(root, "chargeImmediately", (_chargeImmediately?"yes":"no")); + if (_balance != -1) { + addLiveViewTextValue(root, "balancingActive", (_balance?"yes":"no")); + } + addLiveViewInSection(root, "cells", "cellMinVoltage", static_cast(_cellMinMilliVolt)/1000, "V", 3); addLiveViewInSection(root, "cells", "cellMaxVoltage", static_cast(_cellMaxMilliVolt)/1000, "V", 3); addLiveViewInSection(root, "cells", "cellDiffVoltage", (_cellMaxMilliVolt - _cellMinMilliVolt), "mV", 0); @@ -430,6 +437,12 @@ void PytesBatteryStats::mqttPublish() const MqttSettings.publish("battery/settings/dischargeVoltageLimitation", String(_dischargeVoltageLimit)); MqttSettings.publish("battery/stateOfHealth", String(_stateOfHealth)); + if (_chargeCycles != -1) { + MqttSettings.publish("battery/chargeCycles", String(_chargeCycles)); + } + if (_balance != -1) { + MqttSettings.publish("battery/balancingActive", String(_balance ? 1 : 0)); + } MqttSettings.publish("battery/temperature", String(_temperature)); if (_chargedEnergy != -1) { diff --git a/src/MqttHandleBatteryHass.cpp b/src/MqttHandleBatteryHass.cpp index 8db9be485..d022ed16c 100644 --- a/src/MqttHandleBatteryHass.cpp +++ b/src/MqttHandleBatteryHass.cpp @@ -136,6 +136,7 @@ void MqttHandleBatteryHassClass::loop() publishSensor("Current", "mdi:current-dc", "current", "current", "measurement", "A"); publishSensor("State of Health (SOH)", "mdi:heart-plus", "stateOfHealth", NULL, "measurement", "%"); publishSensor("Temperature", "mdi:thermometer", "temperature", "temperature", "measurement", "°C"); + publishSensor("Charge Cycles", "mdi:counter", "chargeCycles"); publishSensor("Charged Energy", NULL, "chargedEnergy", "energy", "total_increasing", "kWh"); publishSensor("Discharged Energy", NULL, "dischargedEnergy", "energy", "total_increasing", "kWh"); @@ -181,6 +182,7 @@ void MqttHandleBatteryHassClass::loop() publishBinarySensor("Warning BMS internal", "mdi:alert-outline", "warning/bmsInternal", "1", "0"); publishBinarySensor("Warning Cell Imbalance", "mdi:alert-outline", "warning/cellImbalance", "1", "0"); + publishBinarySensor("Balancing Active", "mdi:scale-balance", "balancingActive", "1", "0"); publishBinarySensor("Charge immediately", "mdi:alert", "charging/chargeImmediately", "1", "0"); break; diff --git a/src/PytesCanReceiver.cpp b/src/PytesCanReceiver.cpp index 1d6be8623..b956f8f80 100644 --- a/src/PytesCanReceiver.cpp +++ b/src/PytesCanReceiver.cpp @@ -5,6 +5,24 @@ #include #include +namespace { + +static void pytesSetCellLabel(String& label, uint16_t value) { + char name[8]; + snprintf(name, sizeof(name), "%02d%02d", value & 0xff, value >> 8); + label = name; // updates existing string in-place +} + +static uint32_t popCount(uint32_t val) { + // TODO: Use std::popcount once supported + uint32_t cnt = 0; + for (; val; ++cnt) + val &= val - 1; + return cnt; +} + +}; // namespace + bool PytesCanReceiver::init(bool verboseLogging) { return BatteryCanReceiver::init(verboseLogging, "Pytes"); @@ -13,7 +31,8 @@ bool PytesCanReceiver::init(bool verboseLogging) void PytesCanReceiver::onMessage(twai_message_t rx_message) { switch (rx_message.identifier) { - case 0x351: { + case 0x351: + case 0x400: { _stats->_chargeVoltageLimit = this->scaleValue(this->readUnsignedInt16(rx_message.data), 0.1); _stats->_chargeCurrentLimit = this->scaleValue(this->readUnsignedInt16(rx_message.data + 2), 0.1); _stats->setDischargeCurrentLimit(this->scaleValue(this->readUnsignedInt16(rx_message.data + 4), 0.1), millis()); @@ -27,7 +46,7 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x355: { + case 0x355: { // Victron protocol: SOC/SOH _stats->setSoC(static_cast(this->readUnsignedInt16(rx_message.data)), 0/*precision*/, millis()); _stats->_stateOfHealth = this->readUnsignedInt16(rx_message.data + 2); @@ -38,7 +57,8 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x356: { + case 0x356: + case 0x405: { _stats->setVoltage(this->scaleValue(this->readSignedInt16(rx_message.data), 0.01), millis()); _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); @@ -50,7 +70,7 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x35A: { // Alarms and Warnings + case 0x35A: { // Victron protocol: Alarms and Warnings uint16_t alarmBits = rx_message.data[0]; _stats->_alarmOverVoltage = this->getBit(alarmBits, 2); _stats->_alarmUnderVoltage = this->getBit(alarmBits, 4); @@ -117,7 +137,8 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x35E: { + case 0x35E: + case 0x40A: { String manufacturer(reinterpret_cast(rx_message.data), rx_message.data_length_code); @@ -131,7 +152,7 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x35F: { // BatteryInfo + case 0x35F: { // Victron protocol: BatteryInfo auto fwVersionPart1 = String(this->readUnsignedInt8(rx_message.data + 2)); auto fwVersionPart2 = String(this->readUnsignedInt8(rx_message.data + 3)); _stats->_fwversion = "v" + fwVersionPart1 + "." + fwVersionPart2; @@ -139,13 +160,13 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) _stats->_availableCapacity = this->readUnsignedInt16(rx_message.data + 4); if (_verboseLogging) { - MessageOutput.printf("[Pytes] fwversion: %s availableCapacity: %d Ah\r\n", + MessageOutput.printf("[Pytes] fwversion: %s availableCapacity: %f Ah\r\n", _stats->_fwversion.c_str(), _stats->_availableCapacity); } break; } - case 0x360: { // Charging request + case 0x360: { // Victron protocol: Charging request _stats->_chargeImmediately = rx_message.data[0]; // 0xff requests charging. if (_verboseLogging) { MessageOutput.printf("[Pytes] chargeImmediately: %d\r\n", @@ -154,7 +175,7 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x372: { // BankInfo + case 0x372: { // Victron protocol: BankInfo _stats->_moduleCountOnline = this->readUnsignedInt16(rx_message.data); _stats->_moduleCountBlockingCharge = this->readUnsignedInt16(rx_message.data + 2); _stats->_moduleCountBlockingDischarge = this->readUnsignedInt16(rx_message.data + 4); @@ -168,7 +189,7 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x373: { // CellInfo + case 0x373: { // Victron protocol: CellInfo _stats->_cellMinMilliVolt = this->readUnsignedInt16(rx_message.data); _stats->_cellMaxMilliVolt = this->readUnsignedInt16(rx_message.data + 2); _stats->_cellMinTemperature = this->readUnsignedInt16(rx_message.data + 4) - 273; @@ -182,7 +203,7 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x374: { // Battery/Cell name (string) with "Lowest Cell Voltage" + case 0x374: { // Victron protocol: Battery/Cell name (string) with "Lowest Cell Voltage" String cellMinVoltageName(reinterpret_cast(rx_message.data), rx_message.data_length_code); @@ -197,7 +218,7 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x375: { // Battery/Cell name (string) with "Highest Cell Voltage" + case 0x375: { // Victron protocol: Battery/Cell name (string) with "Highest Cell Voltage" String cellMaxVoltageName(reinterpret_cast(rx_message.data), rx_message.data_length_code); @@ -212,7 +233,7 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x376: { // Battery/Cell name (string) with "Minimum Cell Temperature" + case 0x376: { // Victron Protocol: Battery/Cell name (string) with "Minimum Cell Temperature" String cellMinTemperatureName(reinterpret_cast(rx_message.data), rx_message.data_length_code); @@ -227,7 +248,7 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x377: { // Battery/Cell name (string) with "Maximum Cell Temperature" + case 0x377: { // Victron Protocol: Battery/Cell name (string) with "Maximum Cell Temperature" String cellMaxTemperatureName(reinterpret_cast(rx_message.data), rx_message.data_length_code); @@ -242,7 +263,8 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } - case 0x378: { // History: Charged / Discharged Energy + case 0x378: + case 0x41e: { // History: Charged / Discharged Energy _stats->_chargedEnergy = this->scaleValue(this->readUnsignedInt32(rx_message.data), 0.1); _stats->_dischargedEnergy = this->scaleValue(this->readUnsignedInt32(rx_message.data + 4), 0.1); @@ -257,7 +279,7 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) _stats->_totalCapacity = this->readUnsignedInt16(rx_message.data); if (_verboseLogging) { - MessageOutput.printf("[Pytes] totalCapacity: %d Ah\r\n", + MessageOutput.printf("[Pytes] totalCapacity: %f Ah\r\n", _stats->_totalCapacity); } break; @@ -293,6 +315,178 @@ void PytesCanReceiver::onMessage(twai_message_t rx_message) break; } + case 0x401: { // Pytes protocol: Highest/Lowest Cell Voltage + _stats->_cellMaxMilliVolt = this->readUnsignedInt16(rx_message.data); + _stats->_cellMinMilliVolt = this->readUnsignedInt16(rx_message.data + 2); + pytesSetCellLabel(_stats->_cellMaxVoltageName, this->readUnsignedInt8(rx_message.data + 4)); + pytesSetCellLabel(_stats->_cellMinVoltageName, this->readUnsignedInt8(rx_message.data + 6)); + + if (_verboseLogging) { + MessageOutput.printf("[Pytes] lowestCellMilliVolt: %d highestCellMilliVolt: %d cellMinVoltageName: %s cellMaxVoltageName: %s\r\n", + _stats->_cellMinMilliVolt, _stats->_cellMaxMilliVolt, + _stats->_cellMinVoltageName.c_str(), _stats->_cellMaxVoltageName.c_str()); + } + break; + } + + case 0x402: { // Pytes protocol: Highest/Lowest Cell Temperature + _stats->setVoltage(this->scaleValue(this->readSignedInt16(rx_message.data), 0.01), millis()); + _stats->_cellMaxTemperature = this->scaleValue(this->readUnsignedInt16(rx_message.data), 0.1); + _stats->_cellMinTemperature = this->scaleValue(this->readUnsignedInt16(rx_message.data + 2), 0.1); + pytesSetCellLabel(_stats->_cellMaxTemperatureName, this->readUnsignedInt16(rx_message.data + 4)); + pytesSetCellLabel(_stats->_cellMinTemperatureName, this->readUnsignedInt16(rx_message.data + 6)); + + if (_verboseLogging) { + MessageOutput.printf("[Pytes] minimumCellTemperature: %f maximumCellTemperature: %f cellMinTemperatureName: %s cellMaxTemperatureName: %s\r\n", + _stats->_cellMinTemperature, _stats->_cellMaxTemperature, + _stats->_cellMinTemperatureName.c_str(), _stats->_cellMaxTemperatureName.c_str()); + } + break; + } + + case 0x403: { // Pytes protocol: Alarms and Warnings (part 1) + uint32_t alarmBits1 = this->readUnsignedInt32(rx_message.data); + uint32_t alarmBits2 = this->readUnsignedInt32(rx_message.data + 4); + uint32_t mergedBits = alarmBits1 | alarmBits2; + + bool overVoltage = this->getBit(mergedBits, 0); + bool highVoltage = this->getBit(mergedBits, 1); + bool lowVoltage = this->getBit(mergedBits, 3); + bool underVoltage = this->getBit(mergedBits, 4); + bool overTemp = this->getBit(mergedBits, 8); + bool highTemp = this->getBit(mergedBits, 9); + bool lowTemp = this->getBit(mergedBits, 11); + bool underTemp = this->getBit(mergedBits, 12); + bool overCurrentDischarge = this->getBit(mergedBits, 17) || this->getBit(mergedBits, 18); + bool overCurrentCharge = this->getBit(mergedBits, 19) || this->getBit(mergedBits, 20); + bool highCurrentDischarge = this->getBit(mergedBits, 21); + bool highCurrentCharge = this->getBit(mergedBits, 22); + bool stateCharging = this->getBit(mergedBits, 26); + bool stateDischarging = this->getBit(mergedBits, 27); + + _stats->_alarmOverVoltage = overVoltage; + _stats->_alarmUnderVoltage = underVoltage; + _stats->_alarmOverTemperature = stateDischarging && overTemp; + _stats->_alarmUnderTemperature = stateDischarging && underTemp; + _stats->_alarmOverTemperatureCharge = stateCharging && overTemp; + _stats->_alarmUnderTemperatureCharge = stateCharging && underTemp; + + _stats->_alarmOverCurrentDischarge = overCurrentDischarge; + _stats->_alarmOverCurrentCharge = overCurrentCharge; + + if (_verboseLogging) { + MessageOutput.printf("[Pytes] Alarms: %d %d %d %d %d %d %d %d\r\n", + _stats->_alarmOverVoltage, + _stats->_alarmUnderVoltage, + _stats->_alarmOverTemperature, + _stats->_alarmUnderTemperature, + _stats->_alarmOverTemperatureCharge, + _stats->_alarmUnderTemperatureCharge, + _stats->_alarmOverCurrentDischarge, + _stats->_alarmOverCurrentCharge); + } + + _stats->_warningHighVoltage = highVoltage; + _stats->_warningLowVoltage = lowVoltage; + _stats->_warningHighTemperature = stateDischarging && highTemp; + _stats->_warningLowTemperature = stateDischarging && lowTemp; + _stats->_warningHighTemperatureCharge = stateCharging && highTemp; + _stats->_warningLowTemperatureCharge = stateCharging && lowTemp; + + _stats->_warningHighDischargeCurrent = highCurrentDischarge; + _stats->_warningHighChargeCurrent = highCurrentCharge; + + if (_verboseLogging) { + MessageOutput.printf("[Pytes] Warnings: %d %d %d %d %d %d %d %d\r\n", + _stats->_warningHighVoltage, + _stats->_warningLowVoltage, + _stats->_warningHighTemperature, + _stats->_warningLowTemperature, + _stats->_warningHighTemperatureCharge, + _stats->_warningLowTemperatureCharge, + _stats->_warningHighDischargeCurrent, + _stats->_warningHighChargeCurrent); + } + break; + } + + case 0x404: { // Pytes protocol: SOC/SOH + // soc (byte 0+1) isn't used here since it is generated with higher + // precision in message 0x0409 below. + _stats->_stateOfHealth = this->readUnsignedInt16(rx_message.data + 2); + _stats->_chargeCycles = this->readUnsignedInt16(rx_message.data + 6); + + if (_verboseLogging) { + MessageOutput.printf("[Pytes] soh: %d cycles: %d\r\n", + _stats->_stateOfHealth, _stats->_chargeCycles); + } + break; + } + + case 0x406: { // Pytes protocol: alarms (part 2) + uint32_t alarmBits = this->readUnsignedInt32(rx_message.data); + _stats->_alarmInternalFailure = this->getBit(alarmBits, 15); + + if (_verboseLogging) { + MessageOutput.printf("[Pytes] internalFailure: %d (bits: %08x)\r\n", + _stats->_alarmInternalFailure, alarmBits); + } + break; + } + + case 0x408: { // Pytes protocol: charge status + bool chargeEnabled = rx_message.data[0]; + bool dischargeEnabled = rx_message.data[1]; + _stats->_chargeImmediately = rx_message.data[2]; + _stats->_moduleCountBlockingDischarge = popCount(rx_message.data[5]); + _stats->_moduleCountBlockingCharge = popCount(rx_message.data[6]); + + if (_verboseLogging) { + MessageOutput.printf("[Pytes] chargeEnabled: %d dischargeEnabled: %d chargeImmediately: %d moduleCountBlockingDischarge: %d moduleCountBlockingCharge: %d\r\n", + chargeEnabled, dischargeEnabled, _stats->_chargeImmediately, + _stats->_moduleCountBlockingCharge, _stats->_moduleCountBlockingDischarge); + } + break; + } + + case 0x409: { // Pytes protocol: full mAh / remaining mAh + _stats->_totalCapacity = this->scaleValue(this->readUnsignedInt32(rx_message.data), 0.001); + _stats->_availableCapacity = this->scaleValue(this->readUnsignedInt32(rx_message.data + 4), 0.001); + _stats->_capacityPrecision = 2; + float soc = 100.0 * _stats->_availableCapacity / _stats->_totalCapacity; + _stats->setSoC(soc, 2/*precision*/, millis()); + + if (_verboseLogging) { + MessageOutput.printf("[Pytes] soc: %.2f totalCapacity: %.2f Ah availableCapacity: %.2f Ah \r\n", + soc, _stats->_totalCapacity, _stats->_availableCapacity); + } + break; + } + + case 0x40b: { // Pytes protocol: online / offline module count + _stats->_moduleCountOnline = this->readUnsignedInt8(rx_message.data + 6); + _stats->_moduleCountOffline = this->readUnsignedInt8(rx_message.data + 7); + + if (_verboseLogging) { + MessageOutput.printf("[Pytes] moduleCountOnline: %d moduleCountOffline: %d\r\n", + _stats->_moduleCountOnline, _stats->_moduleCountOffline); + } + break; + } + + case 0x40d: { // Pytes protocol: balancing info + // We don't know the exact unit for this yet, so we only use + // it to publish active / not active. + // It is somewhat likely that this is a percentage value on + // the scale of 0-32768, but that is just a theory. + _stats->_balance = this->readUnsignedInt16(rx_message.data + 4); + if (_verboseLogging) { + MessageOutput.printf("[Pytes] balance: %d\r\n", + _stats->_balance); + } + break; + } + default: return; // do not update last update timestamp break;