From 091fca3fd515213ba14ef72ba2721b9e7de42135 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 ``` to retrun to DIP-switch based protocol setting: ``` login config setprt DIP logout ``` The protocol switch should take effect immediately. Tested on Pytes E-Box 4850 --- include/BatteryStats.h | 7 +- src/BatteryStats.cpp | 17 ++- src/MqttHandleBatteryHass.cpp | 2 + src/PytesCanReceiver.cpp | 225 +++++++++++++++++++++++++++++++--- 4 files changed, 231 insertions(+), 20 deletions(-) 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..069f84608 100644 --- a/src/PytesCanReceiver.cpp +++ b/src/PytesCanReceiver.cpp @@ -5,6 +5,23 @@ #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) { + 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 +30,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 +45,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 +56,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 +69,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 +136,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 +151,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 +159,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 +174,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 +188,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 +202,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 +217,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 +232,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 +247,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 +262,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 +278,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 +314,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->_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]; + // Note: Should use std::popcount once supported by the compiler. + _stats->_moduleCountBlockingCharge = popCount(rx_message.data[5]); + _stats->_moduleCountBlockingDischarge = 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;