diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 94da35d78..30edb1a06 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -270,3 +270,152 @@ 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; + + // "power": 355, + // "socLevel": 76, + // "state": 2, + // "maxTemp": 3091, + // "totalVol": 4940, + // "maxVol": 330, + // "minVol": 329, + // "softVersion": 4107, + // "sn": "CO4H MAXM C120 099" + public: + 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; }; + 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; } + 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; + 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; }; + + static std::string getStateString(uint8_t state); + + protected: + std::optional<ZendureBatteryStats::ZendurePackStats*> 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() * (static_cast<float>(_soc_max - _soc_min) / 100.0); }; + + private: + std::string getBypassModeString() const; + std::string getStateString() const { return ZendureBatteryStats::getStateString(_state); }; + void calculateEfficiency(); + void calculateAggregatedPackData(); + + void setManufacture(const char* manufacture) { + _manufacturer = String(manufacture); + if (_device){ + _manufacturer += " " + _device; + } + } + + void setSerial(const char* serial) { + _serial = String(serial); + } + void setSerial(String serial) { + _serial = serial; + } + + void setDevice(const char* device) { + _device = String(device); + } + void setDevice(String device) { + _device = device; + } + + String _device; + + std::map<String, ZendurePackStats*> _packData = std::map<String, ZendurePackStats*>(); + + 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; + + uint32_t _swVersion; + uint32_t _hwVersion; + + 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 <typename T> static std::optional<T> getNumericValueFromMqttPayload(char const* client, std::string const& src, char const* topic, char const* jsonPath); + + template<typename T> + static std::optional<T> 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..1edbf0fdf --- /dev/null +++ b/include/ZendureBattery.h @@ -0,0 +1,50 @@ +#pragma once + +#include <optional> +#include "Battery.h" +#include <espMqttClient.h> + + +#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<BatteryStats> getStats() const final { return _stats; } + + uint16_t updateOutputLimit(uint16_t limit); + +protected: + void timesync(); + +private: + bool _verboseLogging = false; + + uint32_t _updateRateMs; + uint64_t _nextUpdate; + + uint32_t _timesyncRateMs; + uint64_t _nextTimesync; + + String _deviceId; + + String _baseTopic; + String _reportTopic; + String _readTopic; + String _writeTopic; + String _timesyncTopic; + String _settingsPayload; + std::shared_ptr<ZendureBatteryStats> _stats = std::make_shared<ZendureBatteryStats>(); + + 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<PytesCanReceiver>(); break; + case 5: + _upProvider = std::make_unique<ZendureBattery>(); + 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..7c93c3026 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<typename T> static void addLiveViewInSection(JsonVariant& root, @@ -611,3 +612,465 @@ 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<float>(props, "electricLevel"); + if (soc.has_value() && (*soc >= 0 && *soc <= 100)){ + setSoC(*soc, 0/*precision*/, ms); + } + + auto soc_max = Utils::getJsonElement<float>(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<float>(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<uint16_t>(props, "inputLimit"); + if (input_limit.has_value()){ + _input_limit = *input_limit; + } + + auto output_limit = Utils::getJsonElement<uint16_t>(props, "outputLimit"); + if (output_limit.has_value()){ + _output_limit = *output_limit; + } + + auto inverse_max = Utils::getJsonElement<uint16_t>(props, "inverseMaxPower"); + if (inverse_max.has_value()){ + _inverse_max = *inverse_max; + } + + auto pass_mode = Utils::getJsonElement<uint8_t>(props, "passMode"); + if (pass_mode.has_value()){ + _bypass_mode = *pass_mode; + } + + auto pass_state = Utils::getJsonElement<uint8_t>(props, "pass"); + if (pass_state.has_value()){ + _bypass_state = static_cast<bool>(*pass_state); + } + + auto batteries = Utils::getJsonElement<uint8_t>(props, "packNum"); + if (batteries.has_value() && batteries <= 4){ + _num_batteries = *batteries; + } + + auto state = Utils::getJsonElement<uint8_t>(props, "packState"); + if (state.has_value()){ + _state = *state; + } + + auto heat_state = Utils::getJsonElement<uint8_t>(props, "heatState"); + if (heat_state.has_value()){ + _heat_state = *heat_state; + } + + auto auto_shutdown = Utils::getJsonElement<uint8_t>(props, "hubState"); + if (auto_shutdown.has_value()){ + _auto_shutdown = *auto_shutdown; + } + + auto buzzer = Utils::getJsonElement<uint8_t>(props, "buzzerSwitch"); + if (buzzer.has_value()){ + _buzzer = *buzzer; + } + + auto auto_recover = Utils::getJsonElement<uint8_t>(props, "autoRecover"); + if (auto_recover.has_value()){ + _auto_recover = static_cast<bool>(*auto_recover); + } + + auto charge_power = Utils::getJsonElement<uint16_t>(props, "outputPackPower"); + if (charge_power.has_value()){ + _charge_power = *charge_power; + } + + auto discharge_power = Utils::getJsonElement<uint16_t>(props, "packInputPower"); + if (discharge_power.has_value()){ + _discharge_power = *discharge_power; + } + + auto output_power = Utils::getJsonElement<uint16_t>(props, "outputHomePower"); + if (output_power.has_value()){ + _output_power = *output_power; + } + + auto input_power = Utils::getJsonElement<uint16_t>(props, "solarInputPower"); + if (input_power.has_value()){ + _input_power = *input_power; + } + + auto solar_power_1 = Utils::getJsonElement<uint16_t>(props, "solarPower1"); + if (solar_power_1.has_value()){ + _solar_power_1 = *solar_power_1; + } + + auto solar_power_2 = Utils::getJsonElement<uint16_t>(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); + } + + auto sw_version = Utils::getJsonElement<uint32_t>(props, "masterSoftVersion"); + if (sw_version.has_value()){ + _fwversion = String(*sw_version); + } + + auto hw_version = Utils::getJsonElement<uint32_t>(props, "masterhaerVersion"); + if (hw_version.has_value()){ + _hwversion = String(*hw_version); + } else { + hw_version = Utils::getJsonElement<uint32_t>(props, "masterHaerVersion"); + if (hw_version.has_value()){ + _hwversion = String(*hw_version); + } + } + + auto outtime = Utils::getJsonElement<uint16_t>(props, "remainOutTime"); + if (outtime.has_value()){ + _remain_out_time = *outtime; + } + + auto intime = Utils::getJsonElement<uint16_t>(props, "remainInputTime"); + if (intime.has_value()){ + _remain_in_time = *intime; + } + + calculateEfficiency(); +} + +std::optional<ZendureBatteryStats::ZendurePackStats*> 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<int16_t>(packData, "power"); + if (power.has_value()){ + _power = *power; + } + + auto soc_level = Utils::getJsonElement<uint8_t>(packData, "socLevel"); + if (power.has_value()){ + _soc_level = *soc_level; + } + + auto state = Utils::getJsonElement<uint8_t>(packData, "state"); + if (state.has_value()){ + _state = *state; + } + + auto cell_temp_max = Utils::getJsonElement<float>(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<float>(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<uint16_t>(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<uint16_t>(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<uint32_t>(packData, "softVersion"); + if (version.has_value()){ + _version = *version; + } + + _cell_voltage_spread = _cell_voltage_max - _cell_voltage_min; + + if (_voltage_total){ + _current = static_cast<float>(_power) / _voltage_total; + } +} + +void ZendureBatteryStats::getLiveViewData(JsonVariant& root) const { + BatteryStats::getLiveViewData(root); + + + auto bool2str = [](bool value) -> std::string { + return value ? "enabled" : "disabled"; + }; + + auto addRemainingTime = [](auto root, auto section, const char* name, uint16_t value) { + if (value >= 59940){ + addLiveViewTextInSection(root, section, name, "unavail"); + }else{ + addLiveViewInSection(root, section, name, value, "min", 0); + } + }; + + // 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)); + addLiveViewTextInSection(root, section, "bypassState", bool2str(_bypass_state)); + addLiveViewInSection(root, section, "batteries", _num_batteries, "", 0); + addRemainingTime(root, section, "remainOutTime", _remain_out_time); + addRemainingTime(root, section, "remainInTime", _remain_in_time); + + // 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, "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[30]; + for (const auto& [sn, value] : _packData){ + snprintf(buff, sizeof(buff), "_%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", 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); + 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); + 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<uint8_t>(_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<uint8_t>(_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(uint8_t state) { + 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; + uint16_t capacity = 0; + + uint32_t timestampVoltage = 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++; + } + + // aggregate remaining values + if (value->_lastUpdateTimestamp){ + temp = max(temp, value->_cell_temperature_max); + + cellMax = max(cellMax, static_cast<uint32_t>(value->_cell_voltage_max)); + if (value->_cell_voltage_min){ + cellMin = min(cellMin, static_cast<uint32_t>(value->_cell_voltage_min)); + } + + alarmLowSoC |= value->hasAlarmLowSoC(); + alarmTempLow |= value->hasAlarmMinTemp(); + alarmTempHigh |= value->hasAlarmMaxTemp(); + + countValid++; + } + } + + if (countVoltage){ + setVoltage(voltage / countVoltage, timestampVoltage); + + _alarmLowVoltage = alarmLowVoltage; + _alarmHightVoltage = alarmHighVoltage; + } + + if(countValid){ + _cellMaxMilliVolt = static_cast<uint16_t>(cellMax); + _cellMinMilliVolt = static_cast<uint16_t>(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; +} + + +void ZendureBatteryStats::ZendurePackStats::setSerial(String serial){ + if (serial.startsWith("CO4H")){ + _capacity = 1920; + _name = "AB2000"; + } + _serial = serial; +} 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<JsonObject>(); 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<T> Utils::getNumericValueFromMqttPayload(char const* client, template std::optional<float> Utils::getNumericValueFromMqttPayload(char const* client, std::string const& src, char const* topic, char const* jsonPath); + +template<typename T> +std::optional<T> Utils::getJsonElement(JsonObjectConst const root, char const* key, size_t nesting /* = 0*/) { + if (root.containsKey(key)){ + + auto item = root[key].as<JsonVariantConst>(); + + if (item.is<T>() && item.nesting() == nesting){ + return item.as<T>(); + } + } + return std::nullopt; +} + +template std::optional<const char*> Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional<String> Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional<std::string> Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional<uint8_t> Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional<uint16_t> Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional<uint32_t> Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional<int8_t> Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional<int16_t> Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional<int32_t> Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional<float> Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional<JsonObjectConst> Utils::getJsonElement(JsonObjectConst root, char const* key, size_t nesting /* = 0*/); +template std::optional<JsonArrayConst> 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<String>().c_str(), sizeof(config.Battery.MqttVoltageTopic)); strlcpy(config.Battery.MqttVoltageJsonPath, root["mqtt_voltage_json_path"].as<String>().c_str(), sizeof(config.Battery.MqttVoltageJsonPath)); config.Battery.MqttVoltageUnit = static_cast<BatteryVoltageUnit>(root["mqtt_voltage_unit"].as<uint8_t>()); + config.Battery.ZendureDeviceType = root["zendure_device_type"].as<uint8_t>(); + strlcpy(config.Battery.ZendureDeviceSerial, root["zendure_device_serial"].as<String>().c_str(), sizeof(config.Battery.ZendureDeviceSerial)); + config.Battery.ZendureMinSoC = root["zendure_soc_min"].as<uint8_t>(); + config.Battery.ZendureMaxSoC = root["zendure_soc_max"].as<uint8_t>(); + config.Battery.ZendureBypassMode = root["zendure_bypass_mode"].as<uint8_t>(); + config.Battery.ZendureMaxOutput = root["zendure_max_output"].as<uint16_t>(); WebApi.writeConfig(retMsg); diff --git a/src/ZendureBattery.cpp b/src/ZendureBattery.cpp new file mode 100644 index 000000000..a277cb420 --- /dev/null +++ b/src/ZendureBattery.cpp @@ -0,0 +1,218 @@ +#include <functional> + +#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; + String deviceName; + + switch (config.Battery.ZendureDeviceType){ + case 0: + deviceType = ZENDURE_HUB1200; + deviceName = String("HUB 1200"); + break; + case 1: + deviceType = ZENDURE_HUB2000; + deviceName = String("HUB 2000"); + break; + case 2: + deviceType = ZENDURE_AIO2400; + deviceName = String("AIO 2400"); + break; + case 3: + deviceType = ZENDURE_ACE1500; + deviceName = String("Ace 1500"); + break; + case 4: + deviceType = ZENDURE_HYPER2000; + deviceName = String("Hyper 2000"); + 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->setSerial(_deviceId); + _stats->setDevice(deviceName); + _stats->setManufacture("Zendure"); + + JsonDocument root; + JsonVariant prop = root["properties"].to<JsonObject>(); + + 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<const char*>(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<JsonObjectConst>(); + + // 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<String>().equals("123")){ + return log("Invalid or missing 'messageId' in '%s'", logValue.c_str()); + } + if (!json["deviceId"].as<String>().equals(_deviceId)){ + return log("Invalid or missing 'deviceId' in '%s'", logValue.c_str()); + } + if (!json["product"].as<String>().equals("solarFlow")){ + return log("Invalid or missing 'product' in '%s'", logValue.c_str()); + } + + auto packData = Utils::getJsonElement<JsonArrayConst>(obj, "packData", 2); + if (packData.has_value()){ + for (JsonObjectConst battery : *packData) { + auto serial = Utils::getJsonElement<String>(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<JsonObjectConst>(obj, "properties", 1); + if (props.has_value()){ + _stats->update(*props, ms); + } +} diff --git a/webapp/src/components/BatteryView.vue b/webapp/src/components/BatteryView.vue index d5b180fe5..2d184b91d 100644 --- a/webapp/src/components/BatteryView.vue +++ b/webapp/src/components/BatteryView.vue @@ -47,7 +47,14 @@ class="col order-0" > <div class="card" :class="{ 'border-info': true }"> - <div class="card-header text-bg-info">{{ $t('battery.' + section) }}</div> + <div class="card-header text-bg-info"> + <template v-if="section.toString().startsWith('_')"> + {{ section.toString().substring(1) }} + </template> + <template v-else> + {{ $t('battery.' + section) }} + </template> + </div> <div class="card-body"> <table class="table table-striped table-hover"> <thead> diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 7771a0c22..03c8d143c 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,20 @@ "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", + "ZendureBypassModeAutomatic": "Automatisch", + "ZendureBypassModeAlwaysOff": "Dauerhaft Ausgeschaltet", + "ZendureBypassModeAlwaysOn": "Dauerhaft Eingeschaltet", + "ZendureMaxOutput": "Limitierung Ausgangsleistung", "PollingInterval": "Abfrageintervall", - "Seconds": "@:base.Seconds" + "Seconds": "@:base.Seconds", + "Percent": "%", + "Watt": "W" }, "inverteradmin": { "InverterSettings": "Wechselrichter Einstellungen", @@ -989,6 +1002,39 @@ "consumedAmpHours": "Verbrauchte Amperestunden", "midpointVoltage": "Mittelpunktspannung", "midpointDeviation": "Mittelpunktsabweichung", - "lastFullCharge": "Letztes mal Vollgeladen" + "lastFullCharge": "Letztes mal Vollgeladen", + "solarInputPower1": "Leistung MPPT1", + "solarInputPower2": "Leistung MPPT2", + "totalInputPower": "Eingangsleistung", + "chargePower": "Ladeleistung", + "dischargePower": "Entladeleistung", + "totalOutputPower": "Ausgangsleistung", + "maxInversePower": "Maximale Ausgangsleistung", + "outputLimit": "Limit Ausgangsleistung", + "inputLimit": "Limit Eingangsleistung", + "minSoC": "Minimaler Ladezustand", + "maxSoC": "Maximaler Ladezustand", + "state": "Aktueller Bertriebsmodus", + "bypassMode": "Bypass Modus", + "bypassState": "Bypass Status", + "heatState": "Batterieheizung", + "autoRecover": "Automatischer Wiederanlauf", + "autoShutdown": "Automatisches Herunterfahren", + "buzzer": "Integrierter Summer", + "batteries": "Anzahl installierter Batterien", + "charging": "Laden", + "discharging": "Entladen", + "idle": "Leerlauf", + "invalid": "Ungültig", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "alwaysoff": "Dauerhaft Ausgeschaltet", + "alwayson": "Dauerhaft Eingeschaltet", + "efficiency": "Wirkungsgrad", + "panels": "Solar Eingänge", + "settings": "Einstellungen", + "remainOutTime": "Verbleibende Entladezeit", + "remainInTime": "Verbleibende Ladezeit", + "unavail": "N/A" } } diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 0be700f01..ad7823446 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,20 @@ "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", + "ZendureBypassModeAutomatic": "Automatic", + "ZendureBypassModeAlwaysOff": "Always Off", + "ZendureBypassModeAlwaysOn": "Always On", + "ZendureMaxOutput": "Maximum output power", "PollingInterval": "Polling Interval", - "Seconds": "@:base.Seconds" + "Seconds": "@:base.Seconds", + "Percent": "%", + "Watt": "W" }, "inverteradmin": { "InverterSettings": "Inverter Settings", @@ -993,6 +1006,40 @@ "consumedAmpHours": "Consumed Amp Hours", "midpointVoltage": "Midpoint Voltage", "midpointDeviation": "Midpoint Deviation", - "lastFullCharge": "Last full Charge" + "lastFullCharge": "Last full Charge", + "solarInputPower1": "MPPT1 power", + "solarInputPower2": "MPPT2 power", + "totalInputPower": "Total input power", + "chargePower": "Charge power", + "dischargePower": "Discharge power", + "totalOutputPower": "Total output power", + "maxInversePower": "Maximum output power", + "outputLimit": "Output power limit", + "inputLimit": "Input power limit", + "minSoC": "Minimal State of Charge", + "maxSoC": "Maximal 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", + "charging": "Charging", + "discharging": "Discharging", + "idle": "Idle", + "invalid": "Invalid", + "enabled": "Enabled", + "disabled": "Disabled", + "auto": "Automatic", + "alwaysoff": "Always Off", + "alwayson": "Always On", + "efficiency": "Efficiency", + "panels": "Solar input", + "settings": "Settings", + "remainOutTime": "Remaining discharge time", + "remainInTime": "Remaining charge time", + "unavail": "N/A" } } diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 316d19d69..e1527be51 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -947,6 +947,40 @@ "consumedAmpHours": "Consumed Amp Hours", "midpointVoltage": "Midpoint Voltage", "midpointDeviation": "Midpoint Deviation", - "lastFullCharge": "Last full Charge" + "lastFullCharge": "Last full Charge", + "solarInputPower1": "MPPT1 power", + "solarInputPower2": "MPPT2 power", + "totalInputPower": "Total input power", + "chargePower": "Charge power", + "dischargePower": "Discharge power", + "totalOutputPower": "Total output power", + "maxInversePower": "Maximum output power", + "outputLimit": "Output power limit", + "inputLimit": "Input power limit", + "minSoC": "Minimal State of Charge", + "maxSoC": "Maximal 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", + "charging": "Charging", + "discharging": "Discharging", + "idle": "Idle", + "invalid": "Invalid", + "enabled": "Enabled", + "disabled": "Disabled", + "auto": "Automatic", + "alwaysoff": "Always Off", + "alwayson": "Always On", + "efficiency": "Efficiency", + "panels": "Solar input", + "settings": "Settings", + "remainOutTime": "Remaining discharge time", + "remainInTime": "Remaining charge time", + "unavail": "N/A" } } 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..972443c5b 100644 --- a/webapp/src/views/BatteryAdminView.vue +++ b/webapp/src/views/BatteryAdminView.vue @@ -120,6 +120,82 @@ </CardElement> </template> + <template v-if="batteryConfigList.enabled && batteryConfigList.provider == 5"> + <CardElement :text="$t('batteryadmin.ZendureConfiguration')" textVariant="text-bg-primary" addSpace> + <div class="row mb-3"> + <label for="zendure_device_type" class="col-sm-2 col-form-label"> + {{ $t('batteryadmin.ZendureDeviceType') }} + </label> + <div class="col-sm-10"> + <select + id="zendure_device_type" + class="form-select" + v-model="batteryConfigList.zendure_device_type" + > + <option v-for="u in zendureDeviceTypeList" :key="u.key" :value="u.key"> + {{ u.value }} + </option> + </select> + </div> + </div> + + <InputElement + :label="$t('batteryadmin.ZendureDeviceSerial')" + v-model="batteryConfigList.zendure_device_serial" + type="text" + minlength="8" + maxlength="8" + /> + + <InputElement + :label="$t('batteryadmin.ZendureMaxOutput')" + v-model="batteryConfigList.zendure_max_output" + type="number" + min="100" + max="2000" + step="100" + :postfix="$t('batteryadmin.Watt')" + /> + + <InputElement + :label="$t('batteryadmin.ZendureMinSoc')" + v-model="batteryConfigList.zendure_soc_min" + type="number" + min="0" + max="60" + step="1" + :postfix="$t('batteryadmin.Percent')" + /> + + <InputElement + :label="$t('batteryadmin.ZendureMaxSoc')" + v-model="batteryConfigList.zendure_soc_max" + type="number" + min="40" + max="100" + step="1" + :postfix="$t('batteryadmin.Percent')" + /> + + <div class="row mb-3"> + <label for="zendure_bypass_mode" class="col-sm-2 col-form-label"> + {{ $t('batteryadmin.ZendureBypassMode') }} + </label> + <div class="col-sm-10"> + <select + id="zendure_bypass_mode" + class="form-select" + v-model="batteryConfigList.zendure_bypass_mode" + > + <option v-for="u in zendureBypassModeList" :key="u.key" :value="u.key"> + {{ $t(`batteryadmin.ZendureBypassMode` + u.value) }} + </option> + </select> + </div> + </div> + </CardElement> + </template> + <FormFooter @reload="getBatteryConfig" /> </form> </BasePage> @@ -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,17 @@ 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' }, + { key: 1, value: 'AlwaysOff' }, + ], }; }, created() {