From da43028e952304b4fdb17923a1517169c6cc6e57 Mon Sep 17 00:00:00 2001 From: Tobias Diedrich Date: Sat, 7 Sep 2024 23:49:01 +0200 Subject: [PATCH] Pytes battery: Add support for native CAN protocol The recently added PytesCanReceiver.cpp implements the Victron CAN protocol. This change additionally adds support for the native Pytes CAN protocol messages. Features only supported in Pytes protocol: - High-resolution state of charge / full and remaining mAh - Charge cycle counter - Balancing state Features only supported in Victron protocol: - FW version - Serial number Note that the only known way to select the native Pytes protocol is via the serial console (Cisco-compatible cables work): ``` login config setprt PYTES logout ``` to return to Victron protocol use: ``` login config setprt VICTRON logout ``` The protocol switch will take effect immediately. Tested on Pytes E-Box 4850 See https://github.com/helgeerbe/OpenDTU-OnBattery/issues/1188 --- include/BatteryStats.h | 7 +- src/BatteryStats.cpp | 17 ++- src/MqttHandleBatteryHass.cpp | 2 + src/PytesCanReceiver.cpp | 215 +++++++++++++++++++++++++++++++--- 4 files changed, 221 insertions(+), 20 deletions(-) diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 16324517a..20e9e9517 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -167,6 +167,8 @@ class PytesBatteryStats : public BatteryStats { float _dischargeVoltageLimit; uint16_t _stateOfHealth; + int _chargeCycles = -1; + int _balance = -1; float _temperature; @@ -186,8 +188,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 4c1521f1f..dc5b57c0c 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -163,10 +163,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); @@ -177,6 +180,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); @@ -387,6 +394,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 985883cb5..2290d1818 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..11275a8cf 100644 --- a/src/PytesCanReceiver.cpp +++ b/src/PytesCanReceiver.cpp @@ -5,6 +5,16 @@ #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 +} + +}; // namespace + bool PytesCanReceiver::init(bool verboseLogging) { return BatteryCanReceiver::init(verboseLogging, "Pytes"); @@ -13,7 +23,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 +38,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 +49,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 +62,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 +129,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 +144,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 +152,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 +167,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 +181,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 +195,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 +210,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 +225,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 +240,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 +255,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 +271,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 +307,175 @@ 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->readUnsignedInt8(rx_message.data + 4)); + pytesSetCellLabel(_stats->_cellMinTemperatureName, this->readUnsignedInt8(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 (similar to Pylontech msg 0x35e) + bool chargeEnabled = rx_message.data[0]; + bool dischargeEnabled = rx_message.data[1]; + _stats->_chargeImmediately = rx_message.data[2]; + + if (_verboseLogging) { + MessageOutput.printf("[Pytes] chargeEnabled: %d dischargeEnabled: %d chargeImmediately: %d\r\n", + chargeEnabled, dischargeEnabled, _stats->_chargeImmediately); + } + 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;