From c523f066b3b303bf0e66b8b27edddf1fde4f99d8 Mon Sep 17 00:00:00 2001 From: ranma Date: Thu, 10 Oct 2024 18:04:58 +0200 Subject: [PATCH] feature: Add support for native pytes CAN protocol (#1196) * Allow scaleValue() for 32bit values * Victron: Implement CAN message 0x360 This one-byte message is set to 0xff to request charging below a certain SoC threshold (10% in my tests). * Pytes: 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 return to DIP-switch based protocol setting: ``` login config setprt DIP logout ``` --- include/BatteryCanReceiver.h | 2 +- include/BatteryStats.h | 10 +- src/BatteryCanReceiver.cpp | 2 +- src/BatteryStats.cpp | 20 ++- src/MqttHandleBatteryHass.cpp | 4 + src/PytesCanReceiver.cpp | 232 +++++++++++++++++++++++++++++++--- 6 files changed, 249 insertions(+), 21 deletions(-) diff --git a/include/BatteryCanReceiver.h b/include/BatteryCanReceiver.h index 5f35c09aa..225cceed8 100644 --- a/include/BatteryCanReceiver.h +++ b/include/BatteryCanReceiver.h @@ -19,7 +19,7 @@ class BatteryCanReceiver : public BatteryProvider { int16_t readSignedInt16(uint8_t *data); uint32_t readUnsignedInt32(uint8_t *data); int32_t readSignedInt24(uint8_t *data); - float scaleValue(int16_t value, float factor); + float scaleValue(int32_t value, float factor); bool getBit(uint8_t value, uint8_t bit); bool _verboseLogging = true; diff --git a/include/BatteryStats.h b/include/BatteryStats.h index a7eea2f97..bcf7cedc5 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -183,6 +183,7 @@ class PytesBatteryStats : public BatteryStats { public: void getLiveViewData(JsonVariant& root) const final; void mqttPublish() const final; + bool getImmediateChargingRequest() const { return _chargeImmediately; }; float getChargeCurrentLimitation() const { return _chargeCurrentLimit; }; private: @@ -201,6 +202,8 @@ class PytesBatteryStats : public BatteryStats { float _dischargeVoltageLimit; uint16_t _stateOfHealth; + int _chargeCycles = -1; + int _balance = -1; float _temperature; @@ -220,8 +223,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; @@ -247,6 +251,8 @@ class PytesBatteryStats : public BatteryStats { bool _warningHighTemperatureCharge; bool _warningInternalFailure; bool _warningCellImbalance; + + bool _chargeImmediately; }; class JkBmsBatteryStats : public BatteryStats { diff --git a/src/BatteryCanReceiver.cpp b/src/BatteryCanReceiver.cpp index ff16fd043..af84d0b48 100644 --- a/src/BatteryCanReceiver.cpp +++ b/src/BatteryCanReceiver.cpp @@ -178,7 +178,7 @@ uint32_t BatteryCanReceiver::readUnsignedInt32(uint8_t *data) return (data[3] << 24) | (data[2] << 16) | (data[1] << 8) | data[0]; } -float BatteryCanReceiver::scaleValue(int16_t value, float factor) +float BatteryCanReceiver::scaleValue(int32_t value, float factor) { return value * factor; } diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index 500fb497f..796158d2c 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -189,10 +189,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,11 @@ void PytesBatteryStats::getLiveViewData(JsonVariant& root) const if (_dischargedEnergy != -1) { addLiveViewValue(root, "dischargedEnergy", _dischargedEnergy, "kWh", 1); } + 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); @@ -433,6 +441,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) { @@ -482,6 +496,8 @@ void PytesBatteryStats::mqttPublish() const MqttSettings.publish("battery/warning/highTemperatureCharge", String(_warningHighTemperatureCharge)); MqttSettings.publish("battery/warning/bmsInternal", String(_warningInternalFailure)); MqttSettings.publish("battery/warning/cellImbalance", String(_warningCellImbalance)); + + MqttSettings.publish("battery/charging/chargeImmediately", String(_chargeImmediately)); } void JkBmsBatteryStats::mqttPublish() const diff --git a/src/MqttHandleBatteryHass.cpp b/src/MqttHandleBatteryHass.cpp index 89327b110..b2af9e66b 100644 --- a/src/MqttHandleBatteryHass.cpp +++ b/src/MqttHandleBatteryHass.cpp @@ -138,6 +138,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"); @@ -182,6 +183,9 @@ void MqttHandleBatteryHassClass::loop() publishBinarySensor("Warning Temperature high (charge)", "mdi:thermometer-high", "warning/highTemperatureCharge", "1", "0"); 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; case 5: // SBS Unipower diff --git a/src/PytesCanReceiver.cpp b/src/PytesCanReceiver.cpp index bcdd0d274..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,22 @@ 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 0x372: { // BankInfo + case 0x360: { // Victron protocol: Charging request + _stats->_chargeImmediately = rx_message.data[0]; // 0xff requests charging. + if (_verboseLogging) { + MessageOutput.printf("[Pytes] chargeImmediately: %d\r\n", + _stats->_chargeImmediately); + } + break; + } + + 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); @@ -159,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; @@ -173,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); @@ -188,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); @@ -203,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); @@ -218,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); @@ -233,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); @@ -248,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; @@ -284,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;