From da8ca14a6478b79ad221dcf9842af3d55923ac76 Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Thu, 29 Aug 2024 20:51:26 +0200 Subject: [PATCH 01/35] Implemented basic support for Zendure Solarflow batteries that are connected to local MQTT broker --- include/BatteryStats.h | 112 +++++++ include/Configuration.h | 8 + include/Utils.h | 3 + include/ZendureBattery.h | 50 ++++ include/defaults.h | 5 + src/Battery.cpp | 4 + src/BatteryStats.cpp | 401 ++++++++++++++++++++++++++ src/Configuration.cpp | 12 + src/Utils.cpp | 26 ++ src/WebApi_battery.cpp | 12 + src/ZendureBattery.cpp | 210 ++++++++++++++ webapp/src/locales/de.json | 44 ++- webapp/src/locales/en.json | 44 ++- webapp/src/types/BatteryConfig.ts | 6 + webapp/src/views/BatteryAdminView.vue | 87 ++++++ 15 files changed, 1020 insertions(+), 4 deletions(-) create mode 100644 include/ZendureBattery.h create mode 100644 src/ZendureBattery.cpp diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 94da35d78..ca53f4574 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -270,3 +270,115 @@ class MqttBatteryStats : public BatteryStats { // voltage (if available) is already displayed at the top. void getLiveViewData(JsonVariant& root) const final { } }; + +class ZendureBatteryStats : public BatteryStats { + friend class ZendureBattery; + + class ZendurePackStats { + friend class ZendureBatteryStats; + + public: + ZendurePackStats(String serial){ _serial = serial; } + void update(JsonObjectConst packData, unsigned int ms); + bool isCharging() const { return _state == 2; }; + bool isDischarging() const { return _state == 1; }; + uint16_t getCapacity() const { return 1920; } + + protected: + bool hasAlarmMaxTemp() const { return _cell_temperature_max >= 45; }; + bool hasAlarmMinTemp() const { return _cell_temperature_max <= (isCharging() ? 0 : -20); }; + bool hasAlarmLowSoC() const { return _soc_level < 5; } + bool hasAlarmLowVoltage() const { return _voltage_total <= 40.0; } + bool hasAlarmHighVoltage() const { return _voltage_total >= 58.4; } + + String _serial; + uint32_t _version; + uint16_t _cell_voltage_min; + uint16_t _cell_voltage_max; + uint16_t _cell_voltage_spread; + float _cell_temperature_max; + float _voltage_total; + float _current; + int16_t _power; + uint8_t _soc_level; + uint8_t _state; + + private: + uint32_t _lastUpdateTimestamp = 0; + uint32_t _totalVoltageTimestamp = 0; + uint32_t _totalCurrentTimestamp = 0; + + }; + + public: + virtual ~ZendureBatteryStats(){ + for (const auto& [key, item] : _packData){ + delete item; + } + _packData.clear(); + } + void mqttPublish() const; + void getLiveViewData(JsonVariant& root) const; + + bool isCharging() const { return _state == 1; }; + bool isDischarging() const { return _state == 2; }; + + protected: + std::optional getPackData(String serial) const; + void updatePackData(String serial, JsonObjectConst packData, unsigned int ms); + void update(JsonObjectConst props, unsigned int ms); + uint16_t getCapacity() const { return _capacity; }; + uint16_t getAvailableCapacity() const { return getCapacity() * float(_soc_max - _soc_min)/100; }; + + private: + std::string getBypassModeString() const; + std::string getStateString() const; + void calculateEfficiency(); + void calculateAggregatedPackData(); + + void setManuafacture(const char* manufacture) { + _manufacturer = String(manufacture); + } + + std::map _packData = std::map(); + + float _cellTemperature; + uint16_t _cellMinMilliVolt; + uint16_t _cellMaxMilliVolt; + uint16_t _cellDeltaMilliVolt; + + float _soc_max; + float _soc_min; + + uint16_t _inverse_max; + uint16_t _input_limit; + uint16_t _output_limit; + + float _efficiency = 0.0; + uint16_t _capacity; + uint16_t _charge_power; + uint16_t _discharge_power; + uint16_t _output_power; + uint16_t _input_power; + uint16_t _solar_power_1; + uint16_t _solar_power_2; + + uint16_t _remain_out_time; + uint16_t _remain_in_time; + + uint8_t _state; + uint8_t _num_batteries; + uint8_t _bypass_mode; + bool _bypass_state; + bool _auto_recover; + bool _heat_state; + bool _auto_shutdown; + bool _buzzer; + + bool _alarmLowSoC = false; + bool _alarmLowVoltage = false; + bool _alarmHightVoltage = false; + bool _alarmLowTemperature = false; + bool _alarmHighTemperature = false; + +}; diff --git a/include/Configuration.h b/include/Configuration.h index 9f433faf3..b16c2426a 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -43,6 +43,8 @@ #define POWERMETER_HTTP_JSON_MAX_PATH_STRLEN 256 #define BATTERY_JSON_MAX_PATH_STRLEN 128 +#define ZENDURE_MAX_SERIAL_STRLEN 8 + struct CHANNEL_CONFIG_T { uint16_t MaxChannelPower; char Name[CHAN_MAX_NAME_STRLEN]; @@ -288,6 +290,12 @@ struct CONFIG_T { char MqttVoltageTopic[MQTT_MAX_TOPIC_STRLEN + 1]; char MqttVoltageJsonPath[BATTERY_JSON_MAX_PATH_STRLEN + 1]; BatteryVoltageUnit MqttVoltageUnit; + uint8_t ZendureDeviceType; + char ZendureDeviceSerial[ZENDURE_MAX_SERIAL_STRLEN + 1]; + uint8_t ZendureMinSoC; + uint8_t ZendureMaxSoC; + uint8_t ZendureBypassMode; + uint16_t ZendureMaxOutput; } Battery; struct { diff --git a/include/Utils.h b/include/Utils.h index a6bc3b15e..fa09874ef 100644 --- a/include/Utils.h +++ b/include/Utils.h @@ -21,4 +21,7 @@ class Utils { template static std::optional getNumericValueFromMqttPayload(char const* client, std::string const& src, char const* topic, char const* jsonPath); + + template + static std::optional getJsonElement(JsonObjectConst root, char const* key, size_t nesting = 0); }; diff --git a/include/ZendureBattery.h b/include/ZendureBattery.h new file mode 100644 index 000000000..bb308b9a6 --- /dev/null +++ b/include/ZendureBattery.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include "Battery.h" +#include + + +#define ZENDURE_HUB1200 "73bkTV" +#define ZENDURE_HUB2000 "A8yh63" +#define ZENDURE_AIO2400 "yWF7hV)" +#define ZENDURE_ACE1500 "8bM93H" +#define ZENDURE_HYPER2000 "ja72U0ha)" + + +class ZendureBattery : public BatteryProvider { +public: + ZendureBattery() = default; + + bool init(bool verboseLogging) final; + void deinit() final; + void loop() final; + std::shared_ptr getStats() const final { return _stats; } + + uint16_t updateOutputLimit(uint16_t limit); + +protected: + void timesync(); + +private: + bool _verboseLogging = false; + + uint32_t _updateRateMs; + unsigned long _nextUpdate; + + uint32_t _timesyncRateMs; + unsigned long _nextTimesync; + + String _deviceId; + + String _baseTopic; + String _reportTopic; + String _readTopic; + String _writeTopic; + String _timesyncTopic; + String _settingsPayload; + std::shared_ptr _stats = std::make_shared(); + + void onMqttMessageReport(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total); +}; diff --git a/include/defaults.h b/include/defaults.h index e348ae63f..1753ad873 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -151,6 +151,11 @@ #define BATTERY_PROVIDER 0 // Pylontech CAN receiver #define BATTERY_JKBMS_INTERFACE 0 #define BATTERY_JKBMS_POLLING_INTERVAL 5 +#define BATTERY_ZENDURE_DEVICE 0 +#define BATTERY_ZENDURE_MIN_SOC 0 +#define BATTERY_ZENDURE_MAX_SOC 100 +#define BATTERY_ZENDURE_BYPASS_MODE 0 +#define BATTERY_ZENDURE_MAX_OUTPUT 800 #define HUAWEI_ENABLED false #define HUAWEI_CAN_CONTROLLER_FREQUENCY 8000000UL diff --git a/src/Battery.cpp b/src/Battery.cpp index 79ba70020..a65b46b0f 100644 --- a/src/Battery.cpp +++ b/src/Battery.cpp @@ -6,6 +6,7 @@ #include "VictronSmartShunt.h" #include "MqttBattery.h" #include "PytesCanReceiver.h" +#include "ZendureBattery.h" BatteryClass Battery; @@ -61,6 +62,9 @@ void BatteryClass::updateSettings() case 4: _upProvider = std::make_unique(); break; + case 5: + _upProvider = std::make_unique(); + break; default: MessageOutput.printf("[Battery] Unknown provider: %d\r\n", config.Battery.Provider); return; diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index 9f32931b7..99f9e7713 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -6,6 +6,7 @@ #include "MqttSettings.h" #include "JkBmsDataPoints.h" #include "MqttSettings.h" +#include "Utils.h" template static void addLiveViewInSection(JsonVariant& root, @@ -611,3 +612,403 @@ void VictronSmartShuntStats::mqttPublish() const { MqttSettings.publish("battery/midpointVoltage", String(_midpointVoltage)); MqttSettings.publish("battery/midpointDeviation", String(_midpointDeviation)); } + +void ZendureBatteryStats::update(JsonObjectConst props, unsigned int ms){ + auto soc = Utils::getJsonElement(props, "electricLevel"); + if (soc.has_value() && (*soc >= 0 && *soc <= 100)){ + setSoC(*soc, 0/*precision*/, ms); + } + + auto soc_max = Utils::getJsonElement(props, "socSet"); + if (soc_max.has_value()){ + *soc_max /= 10; + if (*soc_max >= 40 && *soc_max <= 100){ + _soc_max = *soc_max; + } + } + + auto soc_min = Utils::getJsonElement(props, "minSoc"); + if (soc_min.has_value()){ + *soc_min /= 10; + if (*soc_min >= 0 && *soc_min <= 60){ + _soc_min = *soc_min; + } + } + + auto input_limit = Utils::getJsonElement(props, "inputLimit"); + if (input_limit.has_value()){ + _input_limit = *input_limit; + } + + auto output_limit = Utils::getJsonElement(props, "outputLimit"); + if (output_limit.has_value()){ + _output_limit = *output_limit; + } + + auto inverse_max = Utils::getJsonElement(props, "inverseMaxPower"); + if (inverse_max.has_value()){ + _inverse_max = *inverse_max; + } + + auto pass_mode = Utils::getJsonElement(props, "passMode"); + if (pass_mode.has_value()){ + _bypass_mode = *pass_mode; + } + + auto pass_state = Utils::getJsonElement(props, "pass"); + if (pass_state.has_value()){ + _bypass_state = static_cast(*pass_state); + } + + auto batteries = Utils::getJsonElement(props, "packNum"); + if (batteries.has_value() && batteries <= 4){ + _num_batteries = *batteries; + } + + auto state = Utils::getJsonElement(props, "packState"); + if (state.has_value()){ + _state = *state; + } + + auto heat_state = Utils::getJsonElement(props, "heatState"); + if (heat_state.has_value()){ + _heat_state = *heat_state; + } + + auto auto_shutdown = Utils::getJsonElement(props, "hubState"); + if (auto_shutdown.has_value()){ + _auto_shutdown = *auto_shutdown; + } + + auto buzzer = Utils::getJsonElement(props, "buzzerSwitch"); + if (buzzer.has_value()){ + _buzzer = *buzzer; + } + + auto auto_recover = Utils::getJsonElement(props, "autoRecover"); + if (auto_recover.has_value()){ + _auto_recover = static_cast(*auto_recover); + } + + auto charge_power = Utils::getJsonElement(props, "outputPackPower"); + if (charge_power.has_value()){ + _charge_power = *charge_power; + } + + auto discharge_power = Utils::getJsonElement(props, "packInputPower"); + if (discharge_power.has_value()){ + _discharge_power = *discharge_power; + } + + auto output_power = Utils::getJsonElement(props, "outputHomePower"); + if (output_power.has_value()){ + _output_power = *output_power; + } + + auto input_power = Utils::getJsonElement(props, "solarInputPower"); + if (input_power.has_value()){ + _input_power = *input_power; + } + + auto solar_power_1 = Utils::getJsonElement(props, "solarPower1"); + if (solar_power_1.has_value()){ + _solar_power_1 = *solar_power_1; + } + + auto solar_power_2 = Utils::getJsonElement(props, "solarPower2"); + if (solar_power_2.has_value()){ + _solar_power_2 = *solar_power_2; + } + + float voltage = getVoltage(); + if (charge_power.has_value() && discharge_power.has_value() && voltage){ + setCurrent((*charge_power - *discharge_power) / voltage, 2, ms); + } + + calculateEfficiency(); +} + +std::optional ZendureBatteryStats::getPackData(String serial) const { + try + { + return _packData.at(serial); + } + catch(const std::out_of_range& ex) + { + return std::nullopt; + } +} + +void ZendureBatteryStats::updatePackData(String serial, JsonObjectConst packData, unsigned int ms){ + try + { + _packData.at(serial); + } + catch(const std::out_of_range& ex) + { + _packData[serial] = new ZendurePackStats(serial); + } + + _packData[serial]->update(packData, ms); + + + calculateAggregatedPackData(); +} + +void ZendureBatteryStats::ZendurePackStats::update(JsonObjectConst packData, unsigned int ms){ + _lastUpdateTimestamp = ms; + + auto power = Utils::getJsonElement(packData, "power"); + if (power.has_value()){ + _power = *power; + } + + auto soc_level = Utils::getJsonElement(packData, "socLevel"); + if (power.has_value()){ + _soc_level = *soc_level; + } + + auto state = Utils::getJsonElement(packData, "state"); + if (state.has_value()){ + _state = *state; + } + + auto cell_temp_max = Utils::getJsonElement(packData, "maxTemp"); + if (state.has_value()){ + *cell_temp_max -= 2731; + *cell_temp_max /= 10; + + if (*cell_temp_max > -100 && *cell_temp_max < 100) { + _cell_temperature_max = *cell_temp_max; + } + } + + auto total_voltage = Utils::getJsonElement(packData, "totalVol"); + if (total_voltage.has_value()){ + *total_voltage /= 100; + if (*total_voltage > 0 && *total_voltage < 65){ + _voltage_total = *total_voltage; + _totalVoltageTimestamp = _lastUpdateTimestamp; + } + } + + auto cell_voltage_max = Utils::getJsonElement(packData, "maxVol"); + if (cell_voltage_max.has_value()){ + *cell_voltage_max *= 10; + if (*cell_voltage_max > 2000 && *cell_voltage_max < 4000){ + _cell_voltage_max = *cell_voltage_max; + } + } + + auto cell_voltage_min = Utils::getJsonElement(packData, "minVol"); + if (cell_voltage_min.has_value()){ + *cell_voltage_min *= 10; + if (*cell_voltage_min > 2000 && *cell_voltage_min < 4000){ + _cell_voltage_min = *cell_voltage_min; + } + } + + auto version = Utils::getJsonElement(packData, "softVersion"); + if (version.has_value()){ + _version = *version; + } + + _cell_voltage_spread = _cell_voltage_max - _cell_voltage_min; +} + +void ZendureBatteryStats::getLiveViewData(JsonVariant& root) const { + BatteryStats::getLiveViewData(root); + + // values go into the "Status" card of the web application + addLiveViewValue(root, "solarInputPower1", _solar_power_1, "W", 0); + addLiveViewValue(root, "solarInputPower2", _solar_power_2, "W", 0); + addLiveViewValue(root, "totalInputPower", _input_power, "W", 0); + addLiveViewValue(root, "chargePower", _charge_power, "W", 0); + addLiveViewValue(root, "dischargePower", _discharge_power, "W", 0); + addLiveViewValue(root, "totalOutputPower", _output_power, "W", 0); + addLiveViewValue(root, "efficiency", _efficiency, "%", 3); + + addLiveViewValue(root, "capacity", _capacity, "Wh", 0); + addLiveViewValue(root, "availableCapacity", getAvailableCapacity(), "Wh", 0); + addLiveViewValue(root, "cellMaxTemperature", _cellTemperature, "°C", 1); + addLiveViewValue(root, "cellMinVoltage", _cellMinMilliVolt, "mV", 0); + addLiveViewValue(root, "cellMaxVoltage", _cellMaxMilliVolt, "mV", 0); + addLiveViewValue(root, "cellDiffVoltage", _cellDeltaMilliVolt, "mV", 0); + + addLiveViewValue(root, "maxInversePower", _inverse_max, "W", 0); + addLiveViewValue(root, "outputLimit", _output_limit, "W", 0); + addLiveViewValue(root, "inputLimit", _output_limit, "W", 0); + addLiveViewValue(root, "minSoC", _soc_min, "%", 1); + addLiveViewValue(root, "maxSoC", _soc_max, "%", 1); + + addLiveViewTextValue(root, "state", getStateString()); + addLiveViewTextValue(root, "bypassMode", getBypassModeString()); + addLiveViewTextValue(root, "bypassState", _bypass_state ? "activated" : "deactivated"); + addLiveViewTextValue(root, "heatState", _heat_state ? "activated" : "deactivated"); + addLiveViewTextValue(root, "autoRecover", _auto_recover ? "enabled" : "disabled"); + addLiveViewTextValue(root, "autoShutdown", _auto_shutdown ? "enabled" : "disabled"); + addLiveViewTextValue(root, "buzzer", _buzzer ? "enabled" : "disabled"); + + addLiveViewValue(root, "batteries", _num_batteries, "", 0); + + addLiveViewAlarm(root, "lowSOC", _alarmLowSoC); + addLiveViewAlarm(root, "lowVoltage", _alarmLowVoltage); + addLiveViewAlarm(root, "highVoltage", _alarmHightVoltage); + addLiveViewAlarm(root, "underTemperatureCharge", _alarmLowTemperature); + addLiveViewAlarm(root, "overTemperatureCharge", _alarmHighTemperature); +} + +void ZendureBatteryStats::mqttPublish() const { + BatteryStats::mqttPublish(); + + MqttSettings.publish("battery/cellMinMilliVolt", String(_cellMinMilliVolt)); + MqttSettings.publish("battery/cellMaxMilliVolt", String(_cellMaxMilliVolt)); + MqttSettings.publish("battery/cellDiffMilliVolt", String(_cellDeltaMilliVolt)); + MqttSettings.publish("battery/cellMaxTemperature", String(_cellTemperature)); + MqttSettings.publish("battery/chargePower", String(_charge_power)); + MqttSettings.publish("battery/dischargePower", String(_discharge_power)); + MqttSettings.publish("battery/heating", String(static_cast(_heat_state))); + MqttSettings.publish("battery/state", String(_state)); + MqttSettings.publish("battery/numPacks", String(_num_batteries)); + + for (const auto& [sn, value] : _packData){ + MqttSettings.publish("battery/" + sn + "/cellMinMilliVolt", String(value->_cell_voltage_min)); + MqttSettings.publish("battery/" + sn + "/cellMaxMilliVolt", String(value->_cell_voltage_max)); + MqttSettings.publish("battery/" + sn + "/cellDiffMilliVolt", String(value->_cell_voltage_spread)); + MqttSettings.publish("battery/" + sn + "/cellMaxTemperature", String(value->_cell_temperature_max)); + MqttSettings.publish("battery/" + sn + "/voltage", String(value->_voltage_total)); + MqttSettings.publish("battery/" + sn + "/power", String(value->_power)); + MqttSettings.publish("battery/" + sn + "/current", String(value->_current)); + MqttSettings.publish("battery/" + sn + "/stateOfCharge", String(value->_soc_level)); + MqttSettings.publish("battery/" + sn + "/state", String(value->_state)); + } + + MqttSettings.publish("battery/solarPowerMppt1", String(_solar_power_1)); + MqttSettings.publish("battery/solarPowerMppt2", String(_solar_power_2)); + MqttSettings.publish("battery/outputPower", String(_output_power)); + MqttSettings.publish("battery/inputPower", String(_input_power)); + MqttSettings.publish("battery/outputLimitPower", String(_output_limit)); + // MqttSettings.publish("battery/inputLimitPower", String(_output_limit)); + MqttSettings.publish("battery/bypass", String(static_cast(_bypass_state))); +} + +std::string ZendureBatteryStats::getBypassModeString() const { + switch (_bypass_mode) { + case 0: + return "auto"; + case 1: + return "alwaysoff"; + case 2: + return "alwayson"; + default: + return "invalid"; + } +} + +std::string ZendureBatteryStats::getStateString() const { + switch (_state) { + case 0: + return "idle"; + case 1: + return "charging"; + case 2: + return "discharging"; + default: + return "invalid"; + } +} + +void ZendureBatteryStats::calculateAggregatedPackData() { + // calcualte average voltage over all battery packs + float voltage = 0.0; + float temp = 0.0; + uint32_t cellMin = UINT32_MAX; + uint32_t cellMax = 0; + float current = 0.0; + uint16_t capacity = 0; + + uint32_t timestampVoltage = 0; + uint32_t timestampCurrent = 0; + + size_t countVoltage = 0; + size_t countValid = 0; + + + bool alarmLowSoC = false; + bool alarmTempLow = false; + bool alarmTempHigh = false; + bool alarmLowVoltage = false; + bool alarmHighVoltage = false; + + for (const auto& [sn, value] : _packData){ + capacity += value->getCapacity(); + + // sum all pack voltages + if (value->_totalVoltageTimestamp){ + voltage += value->_voltage_total; + timestampVoltage = max(timestampVoltage, value->_totalVoltageTimestamp); + + alarmLowVoltage |= value->hasAlarmLowVoltage(); + alarmHighVoltage |= value->hasAlarmHighVoltage(); + + countVoltage++; + } + + // sum all pack currents + if (value->_totalCurrentTimestamp){ + current += value->_current; + timestampCurrent = max(value->_totalCurrentTimestamp, timestampCurrent); + } + + // aggregate remaining values + if (value->_lastUpdateTimestamp){ + temp = max(temp, value->_cell_temperature_max); + + cellMax = max(cellMax, static_cast(value->_cell_voltage_max)); + if (value->_cell_voltage_min){ + cellMin = min(cellMin, static_cast(value->_cell_voltage_min)); + } + + alarmLowSoC |= value->hasAlarmLowSoC(); + alarmTempLow |= value->hasAlarmMinTemp(); + alarmTempHigh |= value->hasAlarmMaxTemp(); + + countValid++; + } + } + + if (countVoltage){ + setVoltage(voltage / countVoltage, timestampVoltage); + setCurrent(current, 2, timestampCurrent); + + _alarmLowVoltage = alarmLowVoltage; + _alarmHightVoltage = alarmHighVoltage; + } + + if(countValid){ + _cellMaxMilliVolt = static_cast(cellMax); + _cellMinMilliVolt = static_cast(cellMin); + _cellDeltaMilliVolt = _cellMaxMilliVolt - _cellMinMilliVolt; + + _cellTemperature = temp; + _alarmHighTemperature = alarmTempHigh; + _alarmLowTemperature = alarmTempLow; + _alarmLowSoC = alarmLowSoC; + } + + _capacity = capacity; +} + +void ZendureBatteryStats::calculateEfficiency() { + float in = _input_power; + float out = _output_power; + if (isCharging()){ + out += _charge_power; + } + if (isDischarging()){ + in += _discharge_power; + } + + _efficiency = in ? out / in : 0.0; + _efficiency *= 100; +} diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 9b3c7d4e4..b5d36fb4d 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -261,6 +261,12 @@ bool ConfigurationClass::write() battery["mqtt_voltage_topic"] = config.Battery.MqttVoltageTopic; battery["mqtt_voltage_json_path"] = config.Battery.MqttVoltageJsonPath; battery["mqtt_voltage_unit"] = config.Battery.MqttVoltageUnit; + battery["zendure_device_type"] = config.Battery.ZendureDeviceType; + battery["zendure_device_serial"] = config.Battery.ZendureDeviceSerial; + battery["zendure_soc_min"] = config.Battery.ZendureMinSoC; + battery["zendure_soc_max"] = config.Battery.ZendureMaxSoC; + battery["zendure_bypass_mode"] = config.Battery.ZendureBypassMode; + battery["zendure_max_output"] = config.Battery.ZendureMaxOutput; JsonObject huawei = doc["huawei"].to(); huawei["enabled"] = config.Huawei.Enabled; @@ -611,6 +617,12 @@ bool ConfigurationClass::read() strlcpy(config.Battery.MqttVoltageTopic, battery["mqtt_voltage_topic"] | "", sizeof(config.Battery.MqttVoltageTopic)); strlcpy(config.Battery.MqttVoltageJsonPath, battery["mqtt_voltage_json_path"] | "", sizeof(config.Battery.MqttVoltageJsonPath)); config.Battery.MqttVoltageUnit = battery["mqtt_voltage_unit"] | BatteryVoltageUnit::Volts; + config.Battery.ZendureDeviceType = battery["zendure_device_type"] | BATTERY_ZENDURE_DEVICE; + strlcpy(config.Battery.ZendureDeviceSerial, battery["zendure_device_serial"] | "", sizeof(config.Battery.ZendureDeviceSerial)); + config.Battery.ZendureMinSoC = battery["zendure_soc_min"] | BATTERY_ZENDURE_MIN_SOC; + config.Battery.ZendureMaxSoC = battery["zendure_soc_max"] | BATTERY_ZENDURE_MAX_SOC; + config.Battery.ZendureBypassMode = battery["zendure_bypass_mode"] | BATTERY_ZENDURE_BYPASS_MODE; + config.Battery.ZendureMaxOutput = battery["zendure_max_output"] | BATTERY_ZENDURE_MAX_OUTPUT; JsonObject huawei = doc["huawei"]; config.Huawei.Enabled = huawei["enabled"] | HUAWEI_ENABLED; diff --git a/src/Utils.cpp b/src/Utils.cpp index c2e40885b..e87e3efc1 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -247,3 +247,29 @@ std::optional Utils::getNumericValueFromMqttPayload(char const* client, template std::optional Utils::getNumericValueFromMqttPayload(char const* client, std::string const& src, char const* topic, char const* jsonPath); + +template +std::optional Utils::getJsonElement(JsonObjectConst const root, char const* key, size_t nesting /* = 0*/) { + if (root.containsKey(key)){ + + auto item = root[key].as(); + + if (item.is() && item.nesting() == nesting){ + return item.as(); + } + } + return std::nullopt; +} + +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); diff --git a/src/WebApi_battery.cpp b/src/WebApi_battery.cpp index aa8040d73..02bb0e4bd 100644 --- a/src/WebApi_battery.cpp +++ b/src/WebApi_battery.cpp @@ -45,6 +45,12 @@ void WebApiBatteryClass::onStatus(AsyncWebServerRequest* request) root["mqtt_voltage_topic"] = config.Battery.MqttVoltageTopic; root["mqtt_voltage_json_path"] = config.Battery.MqttVoltageJsonPath; root["mqtt_voltage_unit"] = config.Battery.MqttVoltageUnit; + root["zendure_device_type"] = config.Battery.ZendureDeviceType; + root["zendure_device_serial"] = config.Battery.ZendureDeviceSerial; + root["zendure_soc_min"] = config.Battery.ZendureMinSoC; + root["zendure_soc_max"] = config.Battery.ZendureMaxSoC; + root["zendure_bypass_mode"] = config.Battery.ZendureBypassMode; + root["zendure_max_output"] = config.Battery.ZendureMaxOutput; response->setLength(); request->send(response); @@ -91,6 +97,12 @@ void WebApiBatteryClass::onAdminPost(AsyncWebServerRequest* request) strlcpy(config.Battery.MqttVoltageTopic, root["mqtt_voltage_topic"].as().c_str(), sizeof(config.Battery.MqttVoltageTopic)); strlcpy(config.Battery.MqttVoltageJsonPath, root["mqtt_voltage_json_path"].as().c_str(), sizeof(config.Battery.MqttVoltageJsonPath)); config.Battery.MqttVoltageUnit = static_cast(root["mqtt_voltage_unit"].as()); + config.Battery.ZendureDeviceType = root["zendure_device_type"].as(); + strlcpy(config.Battery.ZendureDeviceSerial, root["zendure_device_serial"].as().c_str(), sizeof(config.Battery.ZendureDeviceSerial)); + config.Battery.ZendureMinSoC = root["zendure_soc_min"].as(); + config.Battery.ZendureMaxSoC = root["zendure_soc_max"].as(); + config.Battery.ZendureBypassMode = root["zendure_bypass_mode"].as(); + config.Battery.ZendureMaxOutput = root["zendure_max_output"].as(); WebApi.writeConfig(retMsg); diff --git a/src/ZendureBattery.cpp b/src/ZendureBattery.cpp new file mode 100644 index 000000000..e16dfe57c --- /dev/null +++ b/src/ZendureBattery.cpp @@ -0,0 +1,210 @@ +#include + +#include "Configuration.h" +#include "ZendureBattery.h" +#include "MqttSettings.h" +#include "MessageOutput.h" +#include "Utils.h" + +bool ZendureBattery::init(bool verboseLogging) +{ + _verboseLogging = verboseLogging; + + auto const& config = Configuration.get(); + String deviceType; + + switch (config.Battery.ZendureDeviceType){ + case 0: + deviceType = ZENDURE_HUB1200; + break; + case 1: + deviceType = ZENDURE_HUB2000; + break; + case 2: + deviceType = ZENDURE_AIO2400; + break; + case 3: + deviceType = ZENDURE_ACE1500; + break; + case 4: + deviceType = ZENDURE_HYPER2000; + break; + default: + MessageOutput.printf("ZendureBattery: Invalid device type!"); + return false; + } + + //_baseTopic = "/73bkTV/sU59jtkw/"; + if (strlen(config.Battery.ZendureDeviceSerial) != 8) { + MessageOutput.printf("ZendureBattery: Invalid serial number!"); + return false; + } + + _deviceId = config.Battery.ZendureDeviceSerial; + + _baseTopic = "/" + deviceType + "/" + _deviceId + "/"; + _reportTopic = _baseTopic + "properties/report"; + _readTopic = "iot" + _baseTopic + "properties/read"; + _writeTopic = "iot" + _baseTopic + "properties/write"; + _timesyncTopic = "iot" + _baseTopic + "time-sync/reply"; + + MqttSettings.subscribe(_reportTopic, 0/*QoS*/, + std::bind(&ZendureBattery::onMqttMessageReport, + this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6) + ); + + _updateRateMs = 15 * 1000; + _timesyncRateMs = 60 * 60 * 1000; + _nextUpdate = 0; + _nextTimesync = 0; + + _stats->setManuafacture("Zendure"); + + JsonDocument root; + JsonVariant prop = root["properties"].to(); + + prop["pvBrand"] = 1; + prop["autoRecover"] = 1; + prop["buzzerSwitch"] = 0; + prop["passMode"] = config.Battery.ZendureBypassMode; + prop["socSet"] = config.Battery.ZendureMaxSoC * 10; + prop["minSoc"] = config.Battery.ZendureMinSoC * 10; + //prop["outputLimit"] = config.Battery.ZendureMaxOutput; + prop["inverseMaxPower"] = config.Battery.ZendureMaxOutput; + prop["smartMode"] = 0; + prop["autoModel"] = 0; + + serializeJson(root, _settingsPayload); + + if (_verboseLogging) { + MessageOutput.printf("ZendureBattery: Subscribed to '%s' for status readings\r\n", _reportTopic.c_str()); + } + + return true; +} + +void ZendureBattery::deinit() +{ + if (!_reportTopic.isEmpty()) { + MqttSettings.unsubscribe(_reportTopic); + } +} + +void ZendureBattery::loop() +{ + auto ms = millis(); + + if (ms >= _nextUpdate) { + _nextUpdate = ms + _updateRateMs; + if (!_readTopic.isEmpty()) { + MqttSettings.publishGeneric(_readTopic, "{\"properties\": [\"getAll\"] }", false, 0); + } + } + + if (ms >= _nextTimesync) { + _nextTimesync = ms + _timesyncRateMs; + timesync(); + + // republish settings - just to be sure + if (!_writeTopic.isEmpty()) { + if (!_settingsPayload.isEmpty()){ + MqttSettings.publishGeneric(_writeTopic, _settingsPayload, false, 0); + } + } + } +} +uint16_t ZendureBattery::updateOutputLimit(uint16_t limit) +{ + if (_writeTopic.isEmpty()) { + return _stats->_output_limit; + } + + if (_stats->_output_limit != limit){ + if (limit < 100 && limit != 0){ + uint16_t base = limit / 30U; + uint16_t remain = (limit % 30U) / 15U; + limit = 30 * base + 30 * remain; + } + MqttSettings.publishGeneric(_writeTopic, "{\"properties\": {\"outputLimit\": " + String(limit) + "} }", false, 0); + } + + return limit; +} + +void ZendureBattery::timesync() +{ + if (!_timesyncTopic.isEmpty()) { + struct tm timeinfo; + if (getLocalTime(&timeinfo, 5)) { + char timeStringBuff[50]; + strftime(timeStringBuff, sizeof(timeStringBuff), "%s", &timeinfo); + MqttSettings.publishGeneric(_timesyncTopic,"{\"zoneOffset\": \"+00:00\", \"messageId\": 123, \"timestamp\": " + String(timeStringBuff) + "}", false, 0); + } + } +} + +void ZendureBattery::onMqttMessageReport(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) +{ + auto ms = millis(); + + std::string const src = std::string(reinterpret_cast(payload), len); + std::string logValue = src.substr(0, 64); + if (src.length() > logValue.length()) { logValue += "..."; } + + auto log = [_verboseLogging=_verboseLogging](char const* format, auto&&... args) -> void { + if (_verboseLogging) { + MessageOutput.printf("ZendureBattery: "); + MessageOutput.printf(format, args...); + MessageOutput.println(); + } + return; + }; + + JsonDocument json; + + const DeserializationError error = deserializeJson(json, src); + if (error) { + return log("cannot parse payload '%s' as JSON", logValue.c_str()); + } + + if (json.overflowed()) { + return log("payload too large to process as JSON"); + } + + auto obj = json.as(); + + // validate input data + // messageId has to be set to "123" + // deviceId has to be set to the configured deviceId + // product has to be set to "solarFlow" + if (!json["messageId"].as().equals("123")){ + return log("Invalid or missing 'messageId' in '%s'", logValue.c_str()); + } + if (!json["deviceId"].as().equals(_deviceId)){ + return log("Invalid or missing 'deviceId' in '%s'", logValue.c_str()); + } + if (!json["product"].as().equals("solarFlow")){ + return log("Invalid or missing 'product' in '%s'", logValue.c_str()); + } + + auto packData = Utils::getJsonElement(obj, "packData", 2); + if (packData.has_value()){ + for (JsonObjectConst battery : *packData) { + auto serial = Utils::getJsonElement(battery, "sn"); + if (serial.has_value() && (*serial).length() == 15){ + _stats->updatePackData(*serial, battery, ms); + }else{ + log("Invalid or missing serial of battery pack in '%s'", logValue.c_str()); + } + + } + } + + auto props = Utils::getJsonElement(obj, "properties", 1); + if (props.has_value()){ + _stats->update(*props, ms); + } +} diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 7771a0c22..6eff19899 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -672,6 +672,7 @@ "ProviderMqtt": "Batteriewerte aus MQTT Broker", "ProviderVictron": "Victron SmartShunt per VE.Direct Schnittstelle", "ProviderPytesCan": "Pytes per CAN-Bus", + "ProviderZendureLocalMqtt": "Zendure per lokalem MQTT Broker", "MqttSocConfiguration": "Einstellungen SoC", "MqttVoltageConfiguration": "Einstellungen Spannung", "MqttJsonPath": "Optional: JSON-Pfad", @@ -683,8 +684,17 @@ "JkBmsInterface": "Schnittstellentyp", "JkBmsInterfaceUart": "TTL-UART an der MCU", "JkBmsInterfaceTransceiver": "RS-485 Transceiver an der MCU", + "ZendureConfiguration": "Einstellungen", + "ZendureDeviceType": "Produkt", + "ZendureDeviceSerial": "Seriennummer", + "ZendureMinSoc": "Minimaler Ladezustand", + "ZendureMaxSoc": "Maximaler Ladezustand", + "ZendureBypassMode": "Bypass Modus", + "ZendureMaxOutput": "Limitierung Ausgangsleistung", "PollingInterval": "Abfrageintervall", - "Seconds": "@:base.Seconds" + "Seconds": "@:base.Seconds", + "Percent": "%", + "Watt": "W" }, "inverteradmin": { "InverterSettings": "Wechselrichter Einstellungen", @@ -989,6 +999,36 @@ "consumedAmpHours": "Verbrauchte Amperestunden", "midpointVoltage": "Mittelpunktspannung", "midpointDeviation": "Mittelpunktsabweichung", - "lastFullCharge": "Letztes mal Vollgeladen" + "lastFullCharge": "Letztes mal Vollgeladen", + "solarInputPower1": "Solarleistung MPPT1", + "solarInputPower2": "Solarleistung MPPT2", + "totalInputPower": "Eingangsleistung", + "chargePower": "Ladeleistung", + "dischargePower": "Entladeleistung", + "totalOutputPower": "Ausgangsleistung", + "maxInversePower": "Höchstmögliche Ausgangsleistung", + "outputLimit": "Limit Ausgangsleistung", + "inputLimit": "Limit Eingangsleistung", + "minSoC": "Minimaler erlaubter Ladezustand", + "maxSoC": "Maximaler erlaubter Ladezustand", + "state": "Aktueller Bertriebsmodus", + "bypassMode": "Bypass Modus", + "bypassState": "Bypass Status", + "heatState": "Batterieheizung", + "autoRecover": "Automatische Wiederhersteullung", + "autoShutdown": "Automatisches Herunterfahren", + "buzzer": "Integrierter Summer", + "batteries": "Anzahl installierter Batterien", + "charging": "Laden", + "discharging": "Entladen", + "idle": "Leerlauf", + "invalid": "Ungültig", + "deactivated": "Deaktiviert", + "activated": "Aktiviert", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "alwaysoff": "Dauerhaft AUS", + "alwayson": "Dauerhaft EIN", + "efficiency": "Wirkungsgrad" } } diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 0be700f01..d986a77f3 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -674,6 +674,7 @@ "ProviderMqtt": "Battery data from MQTT broker", "ProviderVictron": "Victron SmartShunt using VE.Direct interface", "ProviderPytesCan": "Pytes using CAN bus", + "ProviderZendureLocalMqtt": "Zendure using local MQTT broker", "MqttConfiguration": "MQTT Settings", "MqttSocConfiguration": "SoC Settings", "MqttVoltageConfiguration": "Voltage Settings", @@ -686,8 +687,17 @@ "JkBmsInterface": "Interface Type", "JkBmsInterfaceUart": "TTL-UART on MCU", "JkBmsInterfaceTransceiver": "RS-485 Transceiver on MCU", + "ZendureConfiguration": "Configuration", + "ZendureDeviceType": "Product type", + "ZendureDeviceSerial": "Serialnumber", + "ZendureMinSoc": "Minimum SoC", + "ZendureMaxSoc": "Maximum SoC", + "ZendureBypassMode": "Bypass mode", + "ZendureMaxOutput": "Maximum output power", "PollingInterval": "Polling Interval", - "Seconds": "@:base.Seconds" + "Seconds": "@:base.Seconds", + "Percent": "%", + "Watt": "W" }, "inverteradmin": { "InverterSettings": "Inverter Settings", @@ -993,6 +1003,36 @@ "consumedAmpHours": "Consumed Amp Hours", "midpointVoltage": "Midpoint Voltage", "midpointDeviation": "Midpoint Deviation", - "lastFullCharge": "Last full Charge" + "lastFullCharge": "Last full Charge", + "solarInputPower1": "Solar input power MPPT1", + "solarInputPower2": "Solar input power MPPT2", + "totalInputPower": "Total input power", + "chargePower": "Charge power", + "dischargePower": "Discharge power", + "totalOutputPower": "Total output power", + "maxInversePower": "Absolut maximum output power", + "outputLimit": "Output power limit", + "inputLimit": "Input power limit", + "minSoC": "Minimal allowed State of Charge", + "maxSoC": "Maximal allowed State of Charge", + "state": "Current state of operation", + "bypassMode": "Bypass mode", + "bypassState": "Bypass switch", + "heatState": "Battery heating", + "autoRecover": "Automatic recover", + "autoShutdown": "Automatic shutdown", + "buzzer": "Integrated buzzer", + "batteries": "Number of batteries installed", + "charging": "Charging", + "discharging": "Disharging", + "idle": "Idle", + "invalid": "Invalid", + "deactivated": "Deactivated", + "activated": "Activated", + "enabled": "Enabled", + "disabled": "Disabled", + "alwaysoff": "Always Off", + "alwayson": "Always On", + "efficiency": "Efficiency" } } diff --git a/webapp/src/types/BatteryConfig.ts b/webapp/src/types/BatteryConfig.ts index 348ed2a32..7973a77e5 100644 --- a/webapp/src/types/BatteryConfig.ts +++ b/webapp/src/types/BatteryConfig.ts @@ -9,4 +9,10 @@ export interface BatteryConfig { mqtt_voltage_topic: string; mqtt_voltage_json_path: string; mqtt_voltage_unit: number; + zendure_device_type: number; + zendure_device_serial: string; + zendure_soc_min: number; + zendure_soc_max: number; + zendure_bypass_mode: number; + zendure_max_output: number; } diff --git a/webapp/src/views/BatteryAdminView.vue b/webapp/src/views/BatteryAdminView.vue index bd54fbc08..830d481f7 100644 --- a/webapp/src/views/BatteryAdminView.vue +++ b/webapp/src/views/BatteryAdminView.vue @@ -120,6 +120,82 @@ + + @@ -156,6 +232,7 @@ export default defineComponent({ { key: 2, value: 'Mqtt' }, { key: 3, value: 'Victron' }, { key: 4, value: 'PytesCan' }, + { key: 5, value: 'ZendureLocalMqtt' }, ], jkBmsInterfaceTypeList: [ { key: 0, value: 'Uart' }, @@ -167,6 +244,16 @@ export default defineComponent({ { key: 1, value: 'dV' }, { key: 0, value: 'V' }, ], + zendureDeviceTypeList: [ + { key: 0, value: 'Hub 1200' }, + { key: 1, value: 'Hub 2000' }, + { key: 2, value: 'AIO 2400' }, + { key: 3, value: 'Ace 2000' }, + { key: 4, value: 'Hyper 2000' }, + ], + zendureBypassModeList: [ + { key: 0, value: 'Automatic' }, + ], }; }, created() { From 67df55b27c6aa2eae92c863234274759d0e50c41 Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Thu, 29 Aug 2024 20:59:26 +0200 Subject: [PATCH 02/35] fixed typo --- webapp/src/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index d986a77f3..abde90fb4 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -1024,7 +1024,7 @@ "buzzer": "Integrated buzzer", "batteries": "Number of batteries installed", "charging": "Charging", - "discharging": "Disharging", + "discharging": "Discharging", "idle": "Idle", "invalid": "Invalid", "deactivated": "Deactivated", From 78e0318afa0432eb48022ec075b66008fad56c98 Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Thu, 29 Aug 2024 22:51:52 +0200 Subject: [PATCH 03/35] fixed typos in translation --- src/BatteryStats.cpp | 4 ++-- webapp/src/locales/de.json | 8 +++----- webapp/src/locales/en.json | 2 -- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index 99f9e7713..a94070565 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -843,8 +843,8 @@ void ZendureBatteryStats::getLiveViewData(JsonVariant& root) const { addLiveViewTextValue(root, "state", getStateString()); addLiveViewTextValue(root, "bypassMode", getBypassModeString()); - addLiveViewTextValue(root, "bypassState", _bypass_state ? "activated" : "deactivated"); - addLiveViewTextValue(root, "heatState", _heat_state ? "activated" : "deactivated"); + addLiveViewTextValue(root, "bypassState", _bypass_state ? "enabled" : "disabled"); + addLiveViewTextValue(root, "heatState", _heat_state ? "enabled" : "disabled"); addLiveViewTextValue(root, "autoRecover", _auto_recover ? "enabled" : "disabled"); addLiveViewTextValue(root, "autoShutdown", _auto_shutdown ? "enabled" : "disabled"); addLiveViewTextValue(root, "buzzer", _buzzer ? "enabled" : "disabled"); diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 6eff19899..f2f571eeb 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -1015,7 +1015,7 @@ "bypassMode": "Bypass Modus", "bypassState": "Bypass Status", "heatState": "Batterieheizung", - "autoRecover": "Automatische Wiederhersteullung", + "autoRecover": "Automatischer Wiederanlauf", "autoShutdown": "Automatisches Herunterfahren", "buzzer": "Integrierter Summer", "batteries": "Anzahl installierter Batterien", @@ -1023,12 +1023,10 @@ "discharging": "Entladen", "idle": "Leerlauf", "invalid": "Ungültig", - "deactivated": "Deaktiviert", - "activated": "Aktiviert", "enabled": "Aktiviert", "disabled": "Deaktiviert", - "alwaysoff": "Dauerhaft AUS", - "alwayson": "Dauerhaft EIN", + "alwaysoff": "Dauerhaft Ausgeschaltet", + "alwayson": "Dauerhaft Eingeschaltet", "efficiency": "Wirkungsgrad" } } diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index abde90fb4..03fa9b74d 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -1027,8 +1027,6 @@ "discharging": "Discharging", "idle": "Idle", "invalid": "Invalid", - "deactivated": "Deactivated", - "activated": "Activated", "enabled": "Enabled", "disabled": "Disabled", "alwaysoff": "Always Off", From 69d41d99bf42c99ed7f5c81d684a05d732283533 Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Thu, 29 Aug 2024 22:53:21 +0200 Subject: [PATCH 04/35] making linter happy --- include/BatteryStats.h | 4 ++-- include/ZendureBattery.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/include/BatteryStats.h b/include/BatteryStats.h index ca53f4574..e01b34860 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -278,7 +278,7 @@ class ZendureBatteryStats : public BatteryStats { friend class ZendureBatteryStats; public: - ZendurePackStats(String serial){ _serial = serial; } + explicit ZendurePackStats(String serial){ _serial = serial; } void update(JsonObjectConst packData, unsigned int ms); bool isCharging() const { return _state == 2; }; bool isDischarging() const { return _state == 1; }; @@ -328,7 +328,7 @@ class ZendureBatteryStats : public BatteryStats { void updatePackData(String serial, JsonObjectConst packData, unsigned int ms); void update(JsonObjectConst props, unsigned int ms); uint16_t getCapacity() const { return _capacity; }; - uint16_t getAvailableCapacity() const { return getCapacity() * float(_soc_max - _soc_min)/100; }; + uint16_t getAvailableCapacity() const { return getCapacity() * (static_cast(_soc_max - _soc_min) / 100.0); }; private: std::string getBypassModeString() const; diff --git a/include/ZendureBattery.h b/include/ZendureBattery.h index bb308b9a6..1edbf0fdf 100644 --- a/include/ZendureBattery.h +++ b/include/ZendureBattery.h @@ -30,10 +30,10 @@ class ZendureBattery : public BatteryProvider { bool _verboseLogging = false; uint32_t _updateRateMs; - unsigned long _nextUpdate; + uint64_t _nextUpdate; uint32_t _timesyncRateMs; - unsigned long _nextTimesync; + uint64_t _nextTimesync; String _deviceId; From 9e05fd738feacfad15aeac953e49057ec4cd38f1 Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Thu, 29 Aug 2024 23:00:48 +0200 Subject: [PATCH 05/35] added missing translation for config option --- webapp/src/locales/de.json | 1 + webapp/src/locales/en.json | 1 + webapp/src/views/BatteryAdminView.vue | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index f2f571eeb..e4cb67293 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -690,6 +690,7 @@ "ZendureMinSoc": "Minimaler Ladezustand", "ZendureMaxSoc": "Maximaler Ladezustand", "ZendureBypassMode": "Bypass Modus", + "ZendureBypassModeAutomatic": "Automatisch", "ZendureMaxOutput": "Limitierung Ausgangsleistung", "PollingInterval": "Abfrageintervall", "Seconds": "@:base.Seconds", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 03fa9b74d..a9ce3337a 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -693,6 +693,7 @@ "ZendureMinSoc": "Minimum SoC", "ZendureMaxSoc": "Maximum SoC", "ZendureBypassMode": "Bypass mode", + "ZendureBypassModeAutomatic": "Automatic", "ZendureMaxOutput": "Maximum output power", "PollingInterval": "Polling Interval", "Seconds": "@:base.Seconds", diff --git a/webapp/src/views/BatteryAdminView.vue b/webapp/src/views/BatteryAdminView.vue index 830d481f7..1655fe5ba 100644 --- a/webapp/src/views/BatteryAdminView.vue +++ b/webapp/src/views/BatteryAdminView.vue @@ -188,7 +188,7 @@ v-model="batteryConfigList.zendure_bypass_mode" > From f992c4296c977aa254f86c8f1779c38d22c3fdf6 Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Fri, 30 Aug 2024 21:35:03 +0200 Subject: [PATCH 06/35] added dropdown setting --- webapp/src/views/BatteryAdminView.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/src/views/BatteryAdminView.vue b/webapp/src/views/BatteryAdminView.vue index 1655fe5ba..972443c5b 100644 --- a/webapp/src/views/BatteryAdminView.vue +++ b/webapp/src/views/BatteryAdminView.vue @@ -253,6 +253,7 @@ export default defineComponent({ ], zendureBypassModeList: [ { key: 0, value: 'Automatic' }, + { key: 1, value: 'AlwaysOff' }, ], }; }, From d6c2c43e48e0733824a422077883cf73dd609428 Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Fri, 30 Aug 2024 21:37:23 +0200 Subject: [PATCH 07/35] allow non-translated section captions --- webapp/src/components/BatteryView.vue | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/webapp/src/components/BatteryView.vue b/webapp/src/components/BatteryView.vue index d5b180fe5..f0fedfa15 100644 --- a/webapp/src/components/BatteryView.vue +++ b/webapp/src/components/BatteryView.vue @@ -47,7 +47,14 @@ class="col order-0" >
-
{{ $t('battery.' + section) }}
+
+ + +
From d94b2293ac5137199fcaf5d7a6e847d924efb9dc Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Fri, 30 Aug 2024 21:38:21 +0200 Subject: [PATCH 08/35] useing multiple cards and added basic battery type detection --- include/BatteryStats.h | 10 +++- src/BatteryStats.cpp | 97 +++++++++++++++++++++++++------------- webapp/src/locales/de.json | 16 ++++--- webapp/src/locales/en.json | 16 ++++--- 4 files changed, 92 insertions(+), 47 deletions(-) diff --git a/include/BatteryStats.h b/include/BatteryStats.h index e01b34860..68b19850a 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -278,11 +278,12 @@ class ZendureBatteryStats : public BatteryStats { friend class ZendureBatteryStats; public: - explicit ZendurePackStats(String serial){ _serial = serial; } + explicit ZendurePackStats(String serial){ setSerial(serial); } void update(JsonObjectConst packData, unsigned int ms); bool isCharging() const { return _state == 2; }; bool isDischarging() const { return _state == 1; }; uint16_t getCapacity() const { return 1920; } + std::string getStateString() const { return ZendureBatteryStats::getStateString(_state); }; protected: bool hasAlarmMaxTemp() const { return _cell_temperature_max >= 45; }; @@ -290,8 +291,11 @@ class ZendureBatteryStats : public BatteryStats { bool hasAlarmLowSoC() const { return _soc_level < 5; } bool hasAlarmLowVoltage() const { return _voltage_total <= 40.0; } bool hasAlarmHighVoltage() const { return _voltage_total >= 58.4; } + void setSerial(String serial); String _serial; + String _name = "unknown"; + uint16_t _capacity = 0; uint32_t _version; uint16_t _cell_voltage_min; uint16_t _cell_voltage_max; @@ -323,6 +327,8 @@ class ZendureBatteryStats : public BatteryStats { bool isCharging() const { return _state == 1; }; bool isDischarging() const { return _state == 2; }; + static std::string getStateString(uint8_t state); + protected: std::optional getPackData(String serial) const; void updatePackData(String serial, JsonObjectConst packData, unsigned int ms); @@ -332,7 +338,7 @@ class ZendureBatteryStats : public BatteryStats { private: std::string getBypassModeString() const; - std::string getStateString() const; + std::string getStateString() const { return ZendureBatteryStats::getStateString(_state); }; void calculateEfficiency(); void calculateAggregatedPackData(); diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index a94070565..ebb6422e0 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -819,38 +819,60 @@ void ZendureBatteryStats::ZendurePackStats::update(JsonObjectConst packData, uns void ZendureBatteryStats::getLiveViewData(JsonVariant& root) const { BatteryStats::getLiveViewData(root); - // values go into the "Status" card of the web application - addLiveViewValue(root, "solarInputPower1", _solar_power_1, "W", 0); - addLiveViewValue(root, "solarInputPower2", _solar_power_2, "W", 0); - addLiveViewValue(root, "totalInputPower", _input_power, "W", 0); - addLiveViewValue(root, "chargePower", _charge_power, "W", 0); - addLiveViewValue(root, "dischargePower", _discharge_power, "W", 0); - addLiveViewValue(root, "totalOutputPower", _output_power, "W", 0); - addLiveViewValue(root, "efficiency", _efficiency, "%", 3); - - addLiveViewValue(root, "capacity", _capacity, "Wh", 0); - addLiveViewValue(root, "availableCapacity", getAvailableCapacity(), "Wh", 0); - addLiveViewValue(root, "cellMaxTemperature", _cellTemperature, "°C", 1); - addLiveViewValue(root, "cellMinVoltage", _cellMinMilliVolt, "mV", 0); - addLiveViewValue(root, "cellMaxVoltage", _cellMaxMilliVolt, "mV", 0); - addLiveViewValue(root, "cellDiffVoltage", _cellDeltaMilliVolt, "mV", 0); - - addLiveViewValue(root, "maxInversePower", _inverse_max, "W", 0); - addLiveViewValue(root, "outputLimit", _output_limit, "W", 0); - addLiveViewValue(root, "inputLimit", _output_limit, "W", 0); - addLiveViewValue(root, "minSoC", _soc_min, "%", 1); - addLiveViewValue(root, "maxSoC", _soc_max, "%", 1); - - addLiveViewTextValue(root, "state", getStateString()); - addLiveViewTextValue(root, "bypassMode", getBypassModeString()); - addLiveViewTextValue(root, "bypassState", _bypass_state ? "enabled" : "disabled"); - addLiveViewTextValue(root, "heatState", _heat_state ? "enabled" : "disabled"); - addLiveViewTextValue(root, "autoRecover", _auto_recover ? "enabled" : "disabled"); - addLiveViewTextValue(root, "autoShutdown", _auto_shutdown ? "enabled" : "disabled"); - addLiveViewTextValue(root, "buzzer", _buzzer ? "enabled" : "disabled"); - - addLiveViewValue(root, "batteries", _num_batteries, "", 0); + auto bool2str = [](bool value) -> std::string { + return value ? "enabled" : "disabled"; + }; + + // values go into the "Status" card of the web application + std::string section("status"); + addLiveViewInSection(root, section, "totalInputPower", _input_power, "W", 0); + addLiveViewInSection(root, section, "chargePower", _charge_power, "W", 0); + addLiveViewInSection(root, section, "dischargePower", _discharge_power, "W", 0); + addLiveViewInSection(root, section, "totalOutputPower", _output_power, "W", 0); + addLiveViewInSection(root, section, "efficiency", _efficiency, "%", 3); + addLiveViewInSection(root, section, "capacity", _capacity, "Wh", 0); + addLiveViewInSection(root, section, "availableCapacity", getAvailableCapacity(), "Wh", 0); + addLiveViewTextInSection(root, section, "state", getStateString()); + addLiveViewTextInSection(root, section, "heatState", bool2str(_heat_state)); + + // values go into the "Settings" card of the web application + section = "settings"; + addLiveViewInSection(root, section, "maxInversePower", _inverse_max, "W", 0); + addLiveViewInSection(root, section, "outputLimit", _output_limit, "W", 0); + addLiveViewInSection(root, section, "inputLimit", _output_limit, "W", 0); + addLiveViewInSection(root, section, "minSoC", _soc_min, "%", 1); + addLiveViewInSection(root, section, "maxSoC", _soc_max, "%", 1); + addLiveViewTextInSection(root, section, "autoRecover", bool2str(_auto_recover)); + addLiveViewTextInSection(root, section, "autoShutdown", bool2str(_auto_shutdown)); + addLiveViewTextInSection(root, section, "bypassMode", getBypassModeString()); + addLiveViewTextInSection(root, section, "bypassState", bool2str(_bypass_state)); + addLiveViewTextInSection(root, section, "buzzer", bool2str(_buzzer)); + + // values go into the "Solar Panels" card of the web application + section = "panels"; + addLiveViewInSection(root, section, "solarInputPower1", _solar_power_1, "W", 0); + addLiveViewInSection(root, section, "solarInputPower2", _solar_power_2, "W", 0); + + // pack data goes to dedicated cards of the web application + char buff[50]; + for (const auto& [sn, value] : _packData){ + snprintf(buff, sizeof(buff), "__notranslate__%s [%s]", value->_name.c_str(), sn.c_str()); + section = std::string(buff); + addLiveViewTextInSection(root, section, "state", value->getStateString()); + addLiveViewInSection(root, section, "cellMaxTemperature", value->_cell_temperature_max, "°C", 1); + addLiveViewInSection(root, section, "cellMinVoltage", value->_cell_voltage_min, "mV", 0); + addLiveViewInSection(root, section, "cellMaxVoltage", value->_cell_voltage_max, "mV", 0); + addLiveViewInSection(root, section, "cellDiffVoltage", value->_cell_voltage_spread, "mV", 0); + addLiveViewInSection(root, section, "voltage", value->_voltage_total, "V", 0); + addLiveViewInSection(root, section, "power", value->_power, "W", 0); + addLiveViewInSection(root, section, "current", value->_current, "A", 2); + addLiveViewInSection(root, section, "SoC", value->_soc_level, "%", 1); + addLiveViewInSection(root, section, "capacity", value->_capacity, "Wh", 0); + addLiveViewInSection(root, section, "FwVersion", value->_version, "", 0); + } + + // values go into the alarms card of the web application addLiveViewAlarm(root, "lowSOC", _alarmLowSoC); addLiveViewAlarm(root, "lowVoltage", _alarmLowVoltage); addLiveViewAlarm(root, "highVoltage", _alarmHightVoltage); @@ -905,8 +927,8 @@ std::string ZendureBatteryStats::getBypassModeString() const { } } -std::string ZendureBatteryStats::getStateString() const { - switch (_state) { +std::string ZendureBatteryStats::getStateString(uint8_t state) { + switch (state) { case 0: return "idle"; case 1: @@ -1012,3 +1034,12 @@ void ZendureBatteryStats::calculateEfficiency() { _efficiency = in ? out / in : 0.0; _efficiency *= 100; } + + +void ZendureBatteryStats::ZendurePackStats::setSerial(String serial){ + if (serial.startsWith("CO4H")){ + _capacity = 1920; + _name = "AB2000"; + } + _serial = serial; +} diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index e4cb67293..66c02d446 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -691,6 +691,8 @@ "ZendureMaxSoc": "Maximaler Ladezustand", "ZendureBypassMode": "Bypass Modus", "ZendureBypassModeAutomatic": "Automatisch", + "ZendureBypassModeAlwaysOff": "Dauerhaft Ausgeschaltet", + "ZendureBypassModeAlwaysOn": "Dauerhaft Eingeschaltet", "ZendureMaxOutput": "Limitierung Ausgangsleistung", "PollingInterval": "Abfrageintervall", "Seconds": "@:base.Seconds", @@ -1001,17 +1003,17 @@ "midpointVoltage": "Mittelpunktspannung", "midpointDeviation": "Mittelpunktsabweichung", "lastFullCharge": "Letztes mal Vollgeladen", - "solarInputPower1": "Solarleistung MPPT1", - "solarInputPower2": "Solarleistung MPPT2", + "solarInputPower1": "Leistung MPPT1", + "solarInputPower2": "Leistung MPPT2", "totalInputPower": "Eingangsleistung", "chargePower": "Ladeleistung", "dischargePower": "Entladeleistung", "totalOutputPower": "Ausgangsleistung", - "maxInversePower": "Höchstmögliche Ausgangsleistung", + "maxInversePower": "Maximale Ausgangsleistung", "outputLimit": "Limit Ausgangsleistung", "inputLimit": "Limit Eingangsleistung", - "minSoC": "Minimaler erlaubter Ladezustand", - "maxSoC": "Maximaler erlaubter Ladezustand", + "minSoC": "Minimaler Ladezustand", + "maxSoC": "Maximaler Ladezustand", "state": "Aktueller Bertriebsmodus", "bypassMode": "Bypass Modus", "bypassState": "Bypass Status", @@ -1028,6 +1030,8 @@ "disabled": "Deaktiviert", "alwaysoff": "Dauerhaft Ausgeschaltet", "alwayson": "Dauerhaft Eingeschaltet", - "efficiency": "Wirkungsgrad" + "efficiency": "Wirkungsgrad", + "panels": "Solar Eingänge", + "settings": "Einstellungen" } } diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index a9ce3337a..5d811dbdd 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -694,6 +694,8 @@ "ZendureMaxSoc": "Maximum SoC", "ZendureBypassMode": "Bypass mode", "ZendureBypassModeAutomatic": "Automatic", + "ZendureBypassModeAlwaysOff": "Always Off", + "ZendureBypassModeAlwaysOn": "Always On", "ZendureMaxOutput": "Maximum output power", "PollingInterval": "Polling Interval", "Seconds": "@:base.Seconds", @@ -1005,17 +1007,17 @@ "midpointVoltage": "Midpoint Voltage", "midpointDeviation": "Midpoint Deviation", "lastFullCharge": "Last full Charge", - "solarInputPower1": "Solar input power MPPT1", - "solarInputPower2": "Solar input power MPPT2", + "solarInputPower1": "MPPT1 power", + "solarInputPower2": "MPPT2 power", "totalInputPower": "Total input power", "chargePower": "Charge power", "dischargePower": "Discharge power", "totalOutputPower": "Total output power", - "maxInversePower": "Absolut maximum output power", + "maxInversePower": "Maximum output power", "outputLimit": "Output power limit", "inputLimit": "Input power limit", - "minSoC": "Minimal allowed State of Charge", - "maxSoC": "Maximal allowed State of Charge", + "minSoC": "Minimal State of Charge", + "maxSoC": "Maximal State of Charge", "state": "Current state of operation", "bypassMode": "Bypass mode", "bypassState": "Bypass switch", @@ -1032,6 +1034,8 @@ "disabled": "Disabled", "alwaysoff": "Always Off", "alwayson": "Always On", - "efficiency": "Efficiency" + "efficiency": "Efficiency", + "panels": "Solar input", + "settings": "Settings" } } From 770dc4370cb8dc28ab837c8427017d952803b134 Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Fri, 30 Aug 2024 21:45:51 +0200 Subject: [PATCH 09/35] fixed pack current calculation --- src/BatteryStats.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index ebb6422e0..c0bfabc6c 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -814,6 +814,10 @@ void ZendureBatteryStats::ZendurePackStats::update(JsonObjectConst packData, uns } _cell_voltage_spread = _cell_voltage_max - _cell_voltage_min; + + if (_voltage_total){ + _current = static_cast(_power) / _voltage_total; + } } void ZendureBatteryStats::getLiveViewData(JsonVariant& root) const { @@ -864,7 +868,7 @@ void ZendureBatteryStats::getLiveViewData(JsonVariant& root) const { addLiveViewInSection(root, section, "cellMinVoltage", value->_cell_voltage_min, "mV", 0); addLiveViewInSection(root, section, "cellMaxVoltage", value->_cell_voltage_max, "mV", 0); addLiveViewInSection(root, section, "cellDiffVoltage", value->_cell_voltage_spread, "mV", 0); - addLiveViewInSection(root, section, "voltage", value->_voltage_total, "V", 0); + addLiveViewInSection(root, section, "voltage", value->_voltage_total, "V", 2); addLiveViewInSection(root, section, "power", value->_power, "W", 0); addLiveViewInSection(root, section, "current", value->_current, "A", 2); addLiveViewInSection(root, section, "SoC", value->_soc_level, "%", 1); From 1826245af3639540cca6e4bf799c8f81c113bf14 Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Fri, 30 Aug 2024 21:50:26 +0200 Subject: [PATCH 10/35] fixed setting battery current twice --- src/BatteryStats.cpp | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index c0bfabc6c..a0af01616 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -950,11 +950,9 @@ void ZendureBatteryStats::calculateAggregatedPackData() { float temp = 0.0; uint32_t cellMin = UINT32_MAX; uint32_t cellMax = 0; - float current = 0.0; uint16_t capacity = 0; uint32_t timestampVoltage = 0; - uint32_t timestampCurrent = 0; size_t countVoltage = 0; size_t countValid = 0; @@ -980,12 +978,6 @@ void ZendureBatteryStats::calculateAggregatedPackData() { countVoltage++; } - // sum all pack currents - if (value->_totalCurrentTimestamp){ - current += value->_current; - timestampCurrent = max(value->_totalCurrentTimestamp, timestampCurrent); - } - // aggregate remaining values if (value->_lastUpdateTimestamp){ temp = max(temp, value->_cell_temperature_max); @@ -1005,7 +997,6 @@ void ZendureBatteryStats::calculateAggregatedPackData() { if (countVoltage){ setVoltage(voltage / countVoltage, timestampVoltage); - setCurrent(current, 2, timestampCurrent); _alarmLowVoltage = alarmLowVoltage; _alarmHightVoltage = alarmHighVoltage; From 5853b9064ffd2aa1cc0b26b6b7a10e0e15c0865f Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Sat, 31 Aug 2024 11:56:00 +0200 Subject: [PATCH 11/35] Use _ as indicator for no translate to keep API clean --- webapp/src/components/BatteryView.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/BatteryView.vue b/webapp/src/components/BatteryView.vue index f0fedfa15..2d184b91d 100644 --- a/webapp/src/components/BatteryView.vue +++ b/webapp/src/components/BatteryView.vue @@ -48,8 +48,8 @@ >
- -