diff --git a/include/Configuration.h b/include/Configuration.h index c08a56bae..d50afbb3d 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -49,6 +49,8 @@ #define POWERMETER_MQTT_MAX_VALUES 3 #define POWERMETER_HTTP_JSON_MAX_VALUES 3 +#define ZENDURE_MAX_SERIAL_STRLEN 8 + struct CHANNEL_CONFIG_T { uint16_t MaxChannelPower; char Name[CHAN_MAX_NAME_STRLEN]; @@ -176,6 +178,8 @@ enum BatteryVoltageUnit { Volts = 0, DeciVolts = 1, CentiVolts = 2, MilliVolts = enum BatteryAmperageUnit { Amps = 0, MilliAmps = 1 }; +enum ZendureBatteryOutputControl { ControlNone = 0, ControlFixed = 1, ControlSchedule = 2 }; + struct BATTERY_CONFIG_T { bool Enabled; bool VerboseLogging; @@ -195,6 +199,22 @@ struct BATTERY_CONFIG_T { char MqttDischargeCurrentTopic[MQTT_MAX_TOPIC_STRLEN + 1]; char MqttDischargeCurrentJsonPath[MQTT_MAX_JSON_PATH_STRLEN + 1]; BatteryAmperageUnit MqttAmperageUnit; + uint8_t ZendureDeviceType; + char ZendureDeviceId[ZENDURE_MAX_SERIAL_STRLEN + 1]; + uint8_t ZendurePollingInterval; + uint8_t ZendureMinSoC; + uint8_t ZendureMaxSoC; + uint8_t ZendureBypassMode; + uint16_t ZendureMaxOutput; + bool ZendureAutoShutdown; + uint16_t ZendureOutputLimit; + ZendureBatteryOutputControl ZendureOutputControl; + int16_t ZendureSunriseOffset; + int16_t ZendureSunsetOffset; + uint16_t ZendureOutputLimitDay; + uint16_t ZendureOutputLimitNight; + bool ZendureChargeThroughEnable; + uint16_t ZendureChargeThroughInterval; }; using BatteryConfig = struct BATTERY_CONFIG_T; diff --git a/include/Utils.h b/include/Utils.h index 51ab5a481..85779ac6d 100644 --- a/include/Utils.h +++ b/include/Utils.h @@ -23,4 +23,9 @@ 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); + + static bool getEpoch(time_t* epoch, uint32_t ms); }; diff --git a/include/battery/HassIntegration.h b/include/battery/HassIntegration.h index 9456a38a2..a49cf91c8 100644 --- a/include/battery/HassIntegration.h +++ b/include/battery/HassIntegration.h @@ -23,6 +23,18 @@ class HassIntegration { const char* subTopic, const char* deviceClass = nullptr, const char* stateClass = nullptr, const char* unitOfMeasurement = nullptr) const; + void publishSensor(const String& caption, const char* icon, + const String& subTopic, const char* deviceClass = nullptr, + const char* stateClass = nullptr, + const char* unitOfMeasurement = nullptr) const; + void publishSensor(const String& caption, const char* icon, + const char* subTopic, const char* deviceClass = nullptr, + const char* stateClass = nullptr, + const char* unitOfMeasurement = nullptr) const; + void publishSensor(const char* caption, const char* icon, + const String& subTopic, const char* deviceClass = nullptr, + const char* stateClass = nullptr, + const char* unitOfMeasurement = nullptr) const; void createDeviceInfo(JsonObject& object) const; virtual void publishSensors() const; diff --git a/include/battery/Stats.h b/include/battery/Stats.h index 85099b502..36166f032 100644 --- a/include/battery/Stats.h +++ b/include/battery/Stats.h @@ -84,7 +84,7 @@ class Stats { template static void addLiveViewInSection(JsonVariant& root, std::string const& section, std::string const& name, - T&& value, std::string const& unit, uint8_t precision) + T&& value, std::string const& unit, uint8_t precision, bool dummy = true) { auto jsonValue = root["values"][section][name]; jsonValue["v"] = value; @@ -92,6 +92,36 @@ class Stats { jsonValue["d"] = precision; } + template + static void addLiveViewInSection(JsonVariant& root, + std::string const& section, std::string const& name, + const std::optional& value, std::string const& unit, uint8_t precision, bool hideMissing = false) + { + if (value.has_value()) { + addLiveViewInSection(root, section, name, *value, unit, precision); + }else if (!hideMissing) { + addLiveViewTextInSection(root, section, name, "unavail", true); + } + } + + static void addLiveViewBooleanInSection(JsonVariant& root, + std::string const& section, std::string const& name, + bool value, bool translate = true, bool dummy = true) + { + addLiveViewTextInSection(root, section, name, value ? "enabled" : "disabled"); + } + + static void addLiveViewBooleanInSection(JsonVariant& root, + std::string const& section, std::string const& name, + std::optional value, bool translate = true, bool hideMissing = true) + { + if (value.has_value()) { + addLiveViewBooleanInSection(root, section, name, *value, translate); + }else if (!hideMissing) { + addLiveViewTextInSection(root, section, name, "unavail", true); + } + } + template static void addLiveViewValue(JsonVariant& root, std::string const& name, T&& value, std::string const& unit, uint8_t precision) diff --git a/include/battery/zendure/Constants.h b/include/battery/zendure/Constants.h new file mode 100644 index 000000000..bebdab624 --- /dev/null +++ b/include/battery/zendure/Constants.h @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +namespace Batteries::Zendure { + +// DeviceIDs for compatible Solarflow devices +#define ZENDURE_HUB1200 "73bkTV" +#define ZENDURE_HUB2000 "A8yh63" +#define ZENDURE_AIO2400 "yWF7hV)" +#define ZENDURE_ACE1500 "8bM93H" +#define ZENDURE_HYPER2000 "ja72U0ha)" + +#define ZENDURE_MAX_PACKS 4U +#define ZENDURE_REMAINING_TIME_OVERFLOW 59940U + +#define ZENDURE_SECONDS_SUNPOSITION 60U +#define ZENDURE_SECONDS_TIMESYNC 3600U + +#define ZENDURE_LOG_ROOT "log" +#define ZENDURE_LOG_SERIAL "sn" +#define ZENDURE_LOG_PARAMS "params" + +/* Payload of log messages is not fully decrypted, yet + * It seems like different products and FW versions vari at least + * in number of elements. It's currently unkown, if existing entry + * may be updated between FW versions + * + * Following things are known so far: + * + * +---------+------------+--------------------+ + * | Product | FW-Version | Number of Elements | + * +---------+------------+--------------------+ + * | HUB1200 | v2.0.48 | 107 | + * +---------+------------+--------------------+ + * | HUB2000 | v3.0.21 | 113 | + * +---------+------------+--------------------+ + */ +#define ZENDURE_LOG_OFFSET_SOC 0U // [%] +#define ZENDURE_LOG_OFFSET_PACKNUM 1U // [1] +#define ZENDURE_LOG_OFFSET_PACK_SOC(pack) (2U+(pack)-1U) // [d%] +#define ZENDURE_LOG_OFFSET_PACK_VOLTAGE(pack) (6U+(pack)-1U) // [cV] +#define ZENDURE_LOG_OFFSET_PACK_CURRENT(pack) (10U+(pack)-1U) // [dA] +#define ZENDURE_LOG_OFFSET_PACK_CELL_MIN(pack) (14U+(pack)-1U) // [cV] +#define ZENDURE_LOG_OFFSET_PACK_CELL_MAX(pack) (18U+(pack)-1U) // [cV] +#define ZENDURE_LOG_OFFSET_PACK_UNKOWN_1(pack) (22U+(pack)-1U) // ? => always (0 | 0 | 0 | 0) +#define ZENDURE_LOG_OFFSET_PACK_UNKOWN_2(pack) (26U+(pack)-1U) // ? => always (0 | 0 | 0 | 0) +#define ZENDURE_LOG_OFFSET_PACK_UNKOWN_3(pack) (30U+(pack)-1U) // ? => always (8449 | 257 | 0 | 0) +#define ZENDURE_LOG_OFFSET_PACK_TEMPERATURE(pack) (34U+(pack)-1U) // [°C] +#define ZENDURE_LOG_OFFSET_PACK_UNKOWN_5(pack) (38U+(pack)-1U) // ? => always (1340 | 99 | 0 | 0) +#define ZENDURE_LOG_OFFSET_VOLTAGE 42U // [dV] +#define ZENDURE_LOG_OFFSET_SOLAR_POWER_MPPT_2 43U // [W] +#define ZENDURE_LOG_OFFSET_SOLAR_POWER_MPPT_1 44U // [W] +#define ZENDURE_LOG_OFFSET_OUTPUT_POWER 45U // [W] +#define ZENDURE_LOG_OFFSET_UNKOWN_05 46U // ? => 1, 413 +#define ZENDURE_LOG_OFFSET_DISCHARGE_POWER 47U // [W] +#define ZENDURE_LOG_OFFSET_CHARGE_POWER 48U // [W] +#define ZENDURE_LOG_OFFSET_OUTPUT_POWER_LIMIT 49U // [cA] +#define ZENDURE_LOG_OFFSET_UNKOWN_08 50U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_09 51U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_10 52U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_11 53U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_12 54U // ? => always 0 +#define ZENDURE_LOG_OFFSET_BYPASS_MODE 55U // [0=Auto | 1=AlwaysOff | 2=AlwaysOn] +#define ZENDURE_LOG_OFFSET_UNKOWN_14 56U // ? => always 3 +#define ZENDURE_LOG_OFFSET_UNKOWN_15 57U // ? Some kind of bitmask => e.g. 813969441 +#define ZENDURE_LOG_OFFSET_UNKOWN_16 58U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_17 59U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_18 60U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_19 61U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_20 62U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_21 63U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_22 64U // ? => always 1 +#define ZENDURE_LOG_OFFSET_UNKOWN_23 65U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_24 66U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_25 67U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_26 68U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_27 69U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_28 70U // ? => always 1 +#define ZENDURE_LOG_OFFSET_UNKOWN_29 71U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_30 72U // ? some counter => 258, 263, 25 +#define ZENDURE_LOG_OFFSET_UNKOWN_31 73U // ? some counter => 309, 293, 23 +#define ZENDURE_LOG_OFFSET_UNKOWN_32 74U // ? => always 1 +#define ZENDURE_LOG_OFFSET_UNKOWN_33 75U // ? => always 1 +#define ZENDURE_LOG_OFFSET_UNKOWN_34 76U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_35 77U // ? => always 0 or 1 +#define ZENDURE_LOG_OFFSET_UNKOWN_36 78U // ? => always 0 or 1 +#define ZENDURE_LOG_OFFSET_UNKOWN_37 79U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_38 80U // ? => always 1 or 0 +#define ZENDURE_LOG_OFFSET_AUTO_RECOVER 81U // [bool] +#define ZENDURE_LOG_OFFSET_UNKOWN_40 82U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_41 83U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_42 84U // ? => always 0 +#define ZENDURE_LOG_OFFSET_MIN_SOC 85U // [%] +#define ZENDURE_LOG_OFFSET_UNKOWN_44 86U // State 0 == Idle | 1 == Discharge +#define ZENDURE_LOG_OFFSET_UNKOWN_45 87U // ? => always 512 +#define ZENDURE_LOG_OFFSET_UNKOWN_46 88U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_47 89U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_48 90U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_49 91U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_50 92U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_51 93U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_52 94U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_53 95U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_54 96U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_55 97U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_56 98U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_57 99U // ? => always 20.000 +#define ZENDURE_LOG_OFFSET_UNKOWN_58 100U // ? => always 100 +#define ZENDURE_LOG_OFFSET_UNKOWN_59 101U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_60 102U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_61 103U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_62 104U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_63 105U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_64 106U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_65 107U // ? => always 255 (?) +#define ZENDURE_LOG_OFFSET_UNKOWN_66 108U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_67 109U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_68 110U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_69 111U // ? => always 0 +#define ZENDURE_LOG_OFFSET_UNKOWN_70 112U // ? => always 0 + + + +#define ZENDURE_REPORT_PROPERTIES "properties" +#define ZENDURE_REPORT_MIN_SOC "minSoc" +#define ZENDURE_REPORT_MAX_SOC "socSet" +#define ZENDURE_REPORT_INPUT_LIMIT "inputLimit" +#define ZENDURE_REPORT_OUTPUT_LIMIT "outputLimit" +#define ZENDURE_REPORT_INVERSE_MAX_POWER "inverseMaxPower" +#define ZENDURE_REPORT_HEAT_STATE "heatState" +#define ZENDURE_REPORT_AUTO_SHUTDOWN "hubState" +#define ZENDURE_REPORT_BUZZER_SWITCH "buzzerSwitch" +#define ZENDURE_REPORT_REMAIN_OUT_TIME "remainOutTime" +#define ZENDURE_REPORT_REMAIN_IN_TIME "remainInputTime" +#define ZENDURE_REPORT_MASTER_FW_VERSION "masterSoftVersion" +#define ZENDURE_REPORT_MASTER_HW_VERSION "masterhaerVersion" +#define ZENDURE_REPORT_HUB_STATE "state" +#define ZENDURE_REPORT_BATTERY_STATE "packState" +#define ZENDURE_REPORT_AUTO_RECOVER "autoRecover" +#define ZENDURE_REPORT_BYPASS_STATE "pass" +#define ZENDURE_REPORT_BYPASS_MODE "passMode" +#define ZENDURE_REPORT_PV_BRAND "pvBrand" +#define ZENDURE_REPORT_PV_AUTO_MODEL "autoModel" +#define ZENDURE_REPORT_MASTER_SWITCH "masterSwitch" +#define ZENDURE_REPORT_AC_MODE "acMode" +#define ZENDURE_REPORT_INPUT_MODE "inputMode" + +// momentary values - may not sum up correctly! +#define ZENDURE_REPORT_SOLAR_POWER_MPPT(x) "solarPower"##x +#define ZENDURE_REPORT_SOLAR_INPUT_POWER "solarInputPower" +#define ZENDURE_REPORT_GRID_INPUT_POWER "gridInputPower" // Hyper2000/Ace1500 only - need to check +#define ZENDURE_REPORT_CHARGE_POWER "packInputPower" +#define ZENDURE_REPORT_DISCHARGE_POWER "outputPackPower" +#define ZENDURE_REPORT_OUTPUT_POWER "outputHomePower" +#define ZENDURE_REPORT_DC_OUTPUT_POWER "dcOutputPower" // Ace1500 only - need to check +#define ZENDURE_REPORT_AC_OUTPUT_POWER "acOutputPower" // Hyper2000 only - need to check + +// values smoothend over some given time frame - may be more accurate? +#define ZENDURE_REPORT_SOLAR_POWER_MPPT_CYCLE(x) "solarPower"##x##"Cycle" +#define ZENDURE_REPORT_DISCHARGE_POWER_CYCLE "packInputPowerCycle" +#define ZENDURE_REPORT_OUTPUT_POWER_CYCLE "outputHomePowerCycle" + +#define ZENDURE_REPORT_SMART_MODE "smartMode" +#define ZENDURE_REPORT_SMART_POWER "smartPower" +#define ZENDURE_REPORT_GRID_POWER "gridPower" +#define ZENDURE_REPORT_BLUE_OTA "blueOta" +#define ZENDURE_REPORT_WIFI_STATE "wifiState" +#define ZENDURE_REPORT_AC_SWITCH "acSwitch" // Hyper2000/Ace1500 only - need to check +#define ZENDURE_REPORT_DC_SWITCH "dcSwitch" // Ace1500 only - need to check + +#define ZENDURE_REPORT_EXIT_PASS_TIME "exitPassTime" // seems to be statically set to 360 +#define ZENDURE_REPORT_LOCAL_STATE "localState" // always 0 + +#define ZENDURE_REPORT_PACK_DATA "packData" +#define ZENDURE_REPORT_PACK_SERIAL "sn" +#define ZENDURE_REPORT_PACK_STATE "state" +#define ZENDURE_REPORT_PACK_POWER "power" +#define ZENDURE_REPORT_PACK_SOC "socLevel" +#define ZENDURE_REPORT_PACK_CELL_MAX_TEMPERATURE "maxTemp" +#define ZENDURE_REPORT_PACK_CELL_MIN_VOLATAGE "minVol" +#define ZENDURE_REPORT_PACK_CELL_MAX_VOLATAGE "maxVol" +#define ZENDURE_REPORT_PACK_TOTAL_VOLATAGE "totalVol" +#define ZENDURE_REPORT_PACK_FW_VERSION "softVersion" +#define ZENDURE_REPORT_PACK_HEALTH "soh" + +#define ZENDURE_ALIVE_SECONDS ( 5 * 60 ) +#define ZENDURE_NO_REDUCED_UPDATE + +#define ZENDURE_PERSISTENT_SETTINGS_LAST_FULL "lastFullEpoch" +#define ZENDURE_PERSISTENT_SETTINGS_LAST_EMPTY "lastEmptyEpoch" +#define ZENDURE_PERSISTENT_SETTINGS_CHARGE_THROUGH "chargeThrough" +#define ZENDURE_PERSISTENT_SETTINGS {ZENDURE_PERSISTENT_SETTINGS_LAST_FULL, ZENDURE_PERSISTENT_SETTINGS_LAST_EMPTY, ZENDURE_PERSISTENT_SETTINGS_CHARGE_THROUGH} + +} // namespace Batteries::Zendure diff --git a/include/battery/zendure/HassIntegration.h b/include/battery/zendure/HassIntegration.h new file mode 100644 index 000000000..b1a9a4062 --- /dev/null +++ b/include/battery/zendure/HassIntegration.h @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include + +namespace Batteries::Zendure { + +class HassIntegration : public ::Batteries::HassIntegration { +public: + void publishSensors() const final; +}; + +} // namespace Batteries::Zendure diff --git a/include/battery/zendure/Provider.h b/include/battery/zendure/Provider.h new file mode 100644 index 000000000..49f213e99 --- /dev/null +++ b/include/battery/zendure/Provider.h @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace Batteries::Zendure { + +class Provider : public ::Batteries::Provider { +public: + bool init(bool verboseLogging) final; + void deinit() final; + void loop() final; + std::shared_ptr<::Batteries::Stats> getStats() const final { return _stats; } + std::shared_ptr<::Batteries::HassIntegration> getHassIntegration() final { return _hassIntegration; } + + uint16_t setOutputLimit(uint16_t limit) const; + uint16_t setInverterMax(uint16_t limit) const; + void shutdown() const; + + bool checkChargeThrough(uint32_t predictHours = 0U); + +protected: + void timesync(); + static String parseVersion(uint32_t version); + uint16_t calcOutputLimit(uint16_t limit) const; + void setTargetSoCs(const float soc_min, const float soc_max); + +private: + bool _verboseLogging = false; + uint32_t _lastUpdate = 0; + std::shared_ptr _stats = std::make_shared(); + std::shared_ptr _hassIntegration; + + + void calculateEfficiency(); + void calculateFullChargeAge(); + void publishProperty(const String& topic, const String& property, const String& value) const; + template + void publishProperties(const String& topic, Arg&&... args) const; + + void setSoC(const float soc, const uint32_t timestamp = 0, const uint8_t precision = 2); + bool setChargeThrough(const bool value, const bool publish = true); + + void rescheduleSunCalc() { _nextSunCalc = 0; } + bool alive() const { return _stats->getAgeSeconds() < ZENDURE_ALIVE_SECONDS; } + + void publishPersistentSettings(const char* subtopic, const String& payload); + + template + void log(char const* format, Args&&... args) const { + if (_verboseLogging) { + MessageOutput.printf("ZendureBattery: "); + MessageOutput.printf(format, std::forward(args)...); + MessageOutput.println(); + } + return; + }; + +#ifndef ZENDURE_NO_REDUCED_UPDATE + uint32_t _rateUpdateMs; + uint64_t _nextUpdate; +#endif + + uint32_t _rateFullUpdateMs = 0; + uint64_t _nextFullUpdate = 0; + + uint32_t _rateTimesyncMs = 0; + uint64_t _nextTimesync = 0; + + uint32_t _rateSunCalcMs = 0; + uint64_t _nextSunCalc = 0; + + uint32_t _messageCounter = 0; + + String _deviceId = String(); + + String _baseTopic = String(); + String _topicLog = String(); + String _topicReadReply = String(); + String _topicReport = String(); + String _topicRead = String(); + String _topicWrite = String(); + String _topicTimesync = String(); + String _topicPersistentSettings = String(); + + String _payloadSettings = String(); + String _payloadFullUpdate = String(); +#ifndef ZENDURE_NO_REDUCED_UPDATE + String _payloadUpdate; +#endif + + void onMqttMessageReport(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total); + + void onMqttMessageLog(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total); + + void onMqttMessageTimesync(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total); + + void onMqttMessagePersistentSettings(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total); + +#ifndef ZENDURE_NO_REDUCED_UPDATE + void onMqttMessageRead(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total); +#endif + +}; + +} // namespace Batteries::Zendure diff --git a/include/battery/zendure/Stats.h b/include/battery/zendure/Stats.h new file mode 100644 index 000000000..8ad77af59 --- /dev/null +++ b/include/battery/zendure/Stats.h @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include + +namespace Batteries::Zendure { + +enum class State : uint8_t { + Idle = 0, + Charging = 1, + Discharging = 2, + Invalid = 255 +}; + +enum class BypassMode : uint8_t { + Automatic = 0, + AlwaysOff = 1, + AlwaysOn = 2, + Invalid = 255 +}; + +class PackStats; + +class Stats : public ::Batteries::Stats { + friend class Provider; + + template + static T stateToString(State state) { + switch (state) { + case State::Idle: + return "idle"; + case State::Charging: + return "charging"; + case State::Discharging: + return "discharging"; + default: + return "invalid"; + } + } + template + static T bypassModeToString(BypassMode state) { + switch (state) { + case BypassMode::Automatic: + return "automatic"; + case BypassMode::AlwaysOff: + return "alwaysoff"; + case BypassMode::AlwaysOn: + return "alwayson"; + default: + return "invalid"; + } + } + inline static bool isDischarging(State state) { + return state == State::Discharging; + } + inline static bool isCharging(State state) { + return state == State::Charging; + } + +public: + void getLiveViewData(JsonVariant& root) const final; + void mqttPublish() const final; + + bool supportsAlarmsAndWarnings() const final { return false; } + + std::map> getPackDataList() const { return _packData; } + +protected: + std::shared_ptr getPackData(size_t index) const; + std::shared_ptr addPackData(size_t index, String serial); + + uint16_t getCapacity() const { return _capacity; }; + uint16_t getUseableCapacity() const { return _capacity_avail * (static_cast(_soc_max - _soc_min) / 100.0); }; + +private: + void setLastUpdate(uint32_t ts) { _lastUpdate = ts; } + + void setHwVersion(String&& version) { + if (!version.isEmpty()) { + _hwversion = _device + " (" + std::move(version) + ")"; + }else{ + _hwversion = _device; + } + } + void setFwVersion(String&& version) { _fwversion = std::move(version); } + + void setSerial(String serial) { + _serial = serial; + } + void setSerial(std::optional serial) { + if (serial.has_value()) { + setSerial(*serial); + } + } + + void setDevice(String&& device) { + _device = std::move(device); + } + + String _device = String("Unkown"); + + std::map> _packData = std::map >(); + + int16_t _cellTemperature = 0; + uint16_t _cellMinMilliVolt = 0; + uint16_t _cellMaxMilliVolt = 0; + uint16_t _cellDeltaMilliVolt = 0; + uint16_t _cellAvgMilliVolt = 0; + + float _soc_max = 0.0; + float _soc_min = 0.0; + + uint16_t _inverse_max = 0; + uint16_t _input_limit = 0; + uint16_t _output_limit = 0; + + float _efficiency = 0.0; + uint16_t _capacity = 0; + uint16_t _capacity_avail = 0; + + uint16_t _charge_power = 0; + uint16_t _discharge_power = 0; + uint16_t _output_power = 0; + uint16_t _input_power = 0; + uint16_t _solar_power_1 = 0; + uint16_t _solar_power_2 = 0; + + int16_t _remain_out_time = 0; + int16_t _remain_in_time = 0; + + State _state = State::Invalid; + uint8_t _num_batteries = 0; + BypassMode _bypass_mode = BypassMode::Invalid; + bool _bypass_state = false; + bool _auto_recover = false; + bool _heat_state = false; + bool _auto_shutdown = false; + bool _buzzer = false; + + std::optional _last_full_timestamp = std::nullopt; + std::optional _last_full_charge_hours = std::nullopt; + std::optional _last_empty_timestamp = std::nullopt; + std::optional _charge_through_state = std::nullopt; +}; + +class PackStats { + friend class Stats; + friend class Provider; + + public: + PackStats() {} + explicit PackStats(String serial) : _serial(serial) {} + virtual ~PackStats() {} + + String getSerial() const { return _serial; } + + inline uint8_t getCellCount() const { return _cellCount; } + inline uint16_t getCapacity() const { return _capacity; } + inline uint16_t getAvailableCapacity() const { return _capacity_avail; }; + inline String getName() const { return _name; } + + static std::shared_ptr fromSerial(String serial) { + if (serial.length() == 15) { + if (serial.startsWith("AO4H")) { + return std::make_shared(PackStats(serial, "AB1000", 960)); + } + if (serial.startsWith("CO4H")) { + return std::make_shared(PackStats(serial, "AB2000", 1920)); + } + if (serial.startsWith("R04Y")) { + return std::make_shared(PackStats(serial, "AIO2400", 2400)); + } + return std::make_shared(PackStats(serial)); + } + return nullptr; + }; + + protected: + explicit PackStats(String serial, String name, uint16_t capacity, uint8_t cellCount = 15) : + _serial(serial), _name(name), _capacity(capacity), _cellCount(cellCount) {} + void setSerial(String serial) { _serial = serial; } + void setHwVersion(String&& version) { _hwversion = std::move(version); } + void setFwVersion(String&& version) { _fwversion = std::move(version); } + + private: + String _serial = String(); + String _name = String("UNKOWN"); + uint16_t _capacity = 0; + uint8_t _cellCount = 15; + + String _fwversion = String(); + String _hwversion = String(); + + uint16_t _cell_voltage_min = 0; + uint16_t _cell_voltage_max = 0; + uint16_t _cell_voltage_spread = 0; + uint16_t _cell_voltage_avg = 0; + int16_t _cell_temperature_max = 0; + + float _state_of_health = 1; + uint16_t _capacity_avail = 0; + + float _voltage_total = 0.0; + float _current = 0.0; + int16_t _power = 0; + float _soc_level = 0.0; + State _state = State::Invalid; + + uint32_t _lastUpdate = 0; +}; + + +} // namespace Batteries::Zendure diff --git a/include/defaults.h b/include/defaults.h index c096d152e..ef54e3747 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -159,6 +159,20 @@ #define BATTERY_DISCHARGE_CURRENT_LIMIT_BELOW_SOC 100.0 #define BATTERY_DISCHARGE_CURRENT_LIMIT_BELOW_VOLTAGE 60.0 #define BATTERY_USE_BATTERY_REPORTED_DISCHARGE_CURRENT_LIMIT false +#define BATTERY_ZENDURE_POLLING_INTERVAL 15 +#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 BATTERY_ZENDURE_AUTO_SHUTDOWN true +#define BATTERY_ZENDURE_OUTPUT_LIMIT BATTERY_ZENDURE_MAX_OUTPUT +#define BATTERY_ZENDURE_OUTPUT_LIMIT_DAY 0 +#define BATTERY_ZENDURE_OUTPUT_LIMIT_NIGHT BATTERY_ZENDURE_MAX_OUTPUT +#define BATTERY_ZENDURE_SUNRISE_OFFSET 90 +#define BATTERY_ZENDURE_SUNSET_OFFSET -BATTERY_ZENDURE_SUNRISE_OFFSET +#define BATTERY_ZENDURE_CHARGE_THROUGH_INTERVAL 200 +#define BATTERY_ZENDURE_CHARGE_THROUGH_ENABLE false #define HUAWEI_ENABLED false #define HUAWEI_CAN_CONTROLLER_FREQUENCY 8000000UL diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 500618854..514add96c 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -138,6 +138,22 @@ void ConfigurationClass::serializeBatteryConfig(BatteryConfig const& source, Jso target["mqtt_discharge_current_topic"] = config.Battery.MqttDischargeCurrentTopic; target["mqtt_discharge_current_json_path"] = config.Battery.MqttDischargeCurrentJsonPath; target["mqtt_amperage_unit"] = config.Battery.MqttAmperageUnit; + target["zendure_device_type"] = config.Battery.ZendureDeviceType; + target["zendure_device_id"] = config.Battery.ZendureDeviceId; + target["zendure_polling_interval"] = config.Battery.ZendurePollingInterval; + target["zendure_soc_min"] = config.Battery.ZendureMinSoC; + target["zendure_soc_max"] = config.Battery.ZendureMaxSoC; + target["zendure_bypass_mode"] = config.Battery.ZendureBypassMode; + target["zendure_max_output"] = config.Battery.ZendureMaxOutput; + target["zendure_auto_shutdown"] = config.Battery.ZendureAutoShutdown; + target["zendure_output_limit"] = config.Battery.ZendureOutputLimit; + target["zendure_output_control"] = config.Battery.ZendureOutputControl; + target["zendure_output_limit_day"] = config.Battery.ZendureOutputLimitDay; + target["zendure_output_limit_night"] = config.Battery.ZendureOutputLimitNight; + target["zendure_sunrise_offset"] = config.Battery.ZendureSunriseOffset; + target["zendure_sunset_offset"] = config.Battery.ZendureSunsetOffset; + target["zendure_charge_through_enable"] = config.Battery.ZendureChargeThroughEnable; + target["zendure_charge_through_interval"] = config.Battery.ZendureChargeThroughInterval; } void ConfigurationClass::serializePowerLimiterConfig(PowerLimiterConfig const& source, JsonObject& target) @@ -483,6 +499,22 @@ void ConfigurationClass::deserializeBatteryConfig(JsonObject const& source, Batt strlcpy(target.MqttDischargeCurrentTopic, source["mqtt_discharge_current_topic"] | "", sizeof(config.Battery.MqttDischargeCurrentTopic)); strlcpy(target.MqttDischargeCurrentJsonPath, source["mqtt_discharge_current_json_path"] | "", sizeof(config.Battery.MqttDischargeCurrentJsonPath)); target.MqttAmperageUnit = source["mqtt_amperage_unit"] | BatteryAmperageUnit::Amps; + target.ZendureDeviceType = source["zendure_device_type"] | BATTERY_ZENDURE_DEVICE; + strlcpy(target.ZendureDeviceId, source["zendure_device_id"] | "", sizeof(config.Battery.ZendureDeviceId)); + target.ZendurePollingInterval = source["zendure_polling_interval"] | BATTERY_ZENDURE_POLLING_INTERVAL; + target.ZendureMinSoC = source["zendure_soc_min"] | BATTERY_ZENDURE_MIN_SOC; + target.ZendureMaxSoC = source["zendure_soc_max"] | BATTERY_ZENDURE_MAX_SOC; + target.ZendureBypassMode = source["zendure_bypass_mode"] | BATTERY_ZENDURE_BYPASS_MODE; + target.ZendureMaxOutput = source["zendure_max_output"] | BATTERY_ZENDURE_MAX_OUTPUT; + target.ZendureAutoShutdown = source["zendure_auto_shutdown"] | BATTERY_ZENDURE_AUTO_SHUTDOWN; + target.ZendureOutputLimit = source["zendure_output_limit"] | BATTERY_ZENDURE_OUTPUT_LIMIT; + target.ZendureOutputControl = source["zendure_output_control"] | ZendureBatteryOutputControl::ControlNone; + target.ZendureOutputLimitDay = source["zendure_output_limit_day"] | BATTERY_ZENDURE_OUTPUT_LIMIT_DAY; + target.ZendureOutputLimitNight = source["zendure_output_limit_night"] | BATTERY_ZENDURE_OUTPUT_LIMIT_NIGHT; + target.ZendureSunriseOffset = source["zendure_sunrise_offset"] | BATTERY_ZENDURE_SUNRISE_OFFSET; + target.ZendureSunsetOffset = source["zendure_sunset_offset"] | BATTERY_ZENDURE_SUNSET_OFFSET; + target.ZendureChargeThroughEnable = source["zendure_charge_through_enable"] | BATTERY_ZENDURE_CHARGE_THROUGH_ENABLE; + target.ZendureChargeThroughInterval = source["zendure_charge_through_interval"] | BATTERY_ZENDURE_CHARGE_THROUGH_INTERVAL; } void ConfigurationClass::deserializePowerLimiterConfig(JsonObject const& source, PowerLimiterConfig& target) diff --git a/src/Utils.cpp b/src/Utils.cpp index f1602a9a9..afc2d2ce0 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -278,3 +278,38 @@ 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[key].isNull() && root[key].is() && root[key].nesting() == nesting) { + return root[key].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*/); + + +bool Utils::getEpoch(time_t* epoch, uint32_t ms) +{ + uint32_t start = millis(); + while((millis()-start) <= ms) { + time(epoch); + if (*epoch > 1577836800) { /* epoch 2020-01-01T00:00:00 */ + return true; + } + delay(10); + } + return false; +} diff --git a/src/battery/Controller.cpp b/src/battery/Controller.cpp index f62cdf152..3f520523f 100644 --- a/src/battery/Controller.cpp +++ b/src/battery/Controller.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -72,6 +73,9 @@ void Controller::updateSettings() case 6: _upProvider = std::make_unique(); break; + case 7: + _upProvider = std::make_unique(); + break; default: MessageOutput.printf("[Battery] Unknown provider: %d\r\n", config.Battery.Provider); return; diff --git a/src/battery/HassIntegration.cpp b/src/battery/HassIntegration.cpp index fc0b5ea72..da2975a78 100644 --- a/src/battery/HassIntegration.cpp +++ b/src/battery/HassIntegration.cpp @@ -39,7 +39,39 @@ void HassIntegration::publishSensors() const { publishSensor("Manufacturer", "mdi:factory", "manufacturer"); publishSensor("Data Age", "mdi:timer-sand", "dataAge", "duration", "measurement", "s"); - publishSensor("State of Charge (SoC)", "mdi:battery-medium", "stateOfCharge", "battery", "measurement", "%"); + + if (_spStats->isSoCValid()) { + publishSensor("State of Charge (SoC)", "mdi:battery-medium", "stateOfCharge", "battery", "measurement", "%"); + } + + if (_spStats->isVoltageValid()) { + publishSensor("Voltage", "mdi:battery-charging", "voltage", "voltage", "measurement", "V"); + } + + if (_spStats->isCurrentValid()) { + publishSensor("Current", "mdi:current-dc", "current", "current", "measurement", "A"); + } +} + +void HassIntegration::publishSensor(const String& caption, const char* icon, + const String& subTopic, const char* deviceClass, + const char* stateClass, const char* unitOfMeasurement) const +{ + publishSensor(caption.c_str(), icon, subTopic.c_str(), deviceClass, stateClass, unitOfMeasurement); +} + +void HassIntegration::publishSensor(const char* caption, const char* icon, + const String& subTopic, const char* deviceClass, + const char* stateClass, const char* unitOfMeasurement) const +{ + publishSensor(caption, icon, subTopic.c_str(), deviceClass, stateClass, unitOfMeasurement); +} + +void HassIntegration::publishSensor(const String& caption, const char* icon, + const char* subTopic, const char* deviceClass, + const char* stateClass, const char* unitOfMeasurement) const +{ + publishSensor(caption.c_str(), icon, subTopic, deviceClass, stateClass, unitOfMeasurement); } void HassIntegration::publishSensor(const char* caption, const char* icon, diff --git a/src/battery/jbdbms/HassIntegration.cpp b/src/battery/jbdbms/HassIntegration.cpp index 53fdf0511..bbf9e93ca 100644 --- a/src/battery/jbdbms/HassIntegration.cpp +++ b/src/battery/jbdbms/HassIntegration.cpp @@ -12,8 +12,6 @@ void HassIntegration::publishSensors() const ::Batteries::HassIntegration::publishSensors(); // caption icon topic dev. class state class unit - publishSensor("Voltage", "mdi:battery-charging", "voltage", "voltage", "measurement", "V"); - publishSensor("Current", "mdi:current-dc", "current", "current", "measurement", "A"); publishSensor("Cell Min Voltage", NULL, "CellMinMilliVolt", "voltage", "measurement", "mV"); publishSensor("Cell Average Voltage", NULL, "CellAvgMilliVolt", "voltage", "measurement", "mV"); publishSensor("Cell Max Voltage", NULL, "CellMaxMilliVolt", "voltage", "measurement", "mV"); diff --git a/src/battery/jkbms/HassIntegration.cpp b/src/battery/jkbms/HassIntegration.cpp index 0298e950a..898ec4e00 100644 --- a/src/battery/jkbms/HassIntegration.cpp +++ b/src/battery/jkbms/HassIntegration.cpp @@ -12,8 +12,6 @@ void HassIntegration::publishSensors() const ::Batteries::HassIntegration::publishSensors(); // caption icon topic dev. class state class unit - publishSensor("Voltage", "mdi:battery-charging", "BatteryVoltageMilliVolt", "voltage", "measurement", "mV"); - publishSensor("Current", "mdi:current-dc", "BatteryCurrentMilliAmps", "current", "measurement", "mA"); publishSensor("BMS Temperature", "mdi:thermometer", "BmsTempCelsius", "temperature", "measurement", "°C"); publishSensor("Cell Voltage Diff", "mdi:battery-alert", "CellDiffMilliVolt", "voltage", "measurement", "mV"); publishSensor("Battery Temperature 1", "mdi:thermometer", "BatteryTempOneCelsius", "temperature", "measurement", "°C"); diff --git a/src/battery/pylontech/HassIntegration.cpp b/src/battery/pylontech/HassIntegration.cpp index 10437ecf0..dacaefa9d 100644 --- a/src/battery/pylontech/HassIntegration.cpp +++ b/src/battery/pylontech/HassIntegration.cpp @@ -11,8 +11,6 @@ void HassIntegration::publishSensors() const { ::Batteries::HassIntegration::publishSensors(); - publishSensor("Battery voltage", NULL, "voltage", "voltage", "measurement", "V"); - publishSensor("Battery current", NULL, "current", "current", "measurement", "A"); publishSensor("Temperature", NULL, "temperature", "temperature", "measurement", "°C"); publishSensor("State of Health (SOH)", "mdi:heart-plus", "stateOfHealth", NULL, "measurement", "%"); publishSensor("Charge voltage (BMS)", NULL, "settings/chargeVoltage", "voltage", "measurement", "V"); diff --git a/src/battery/pytes/HassIntegration.cpp b/src/battery/pytes/HassIntegration.cpp index c0d1a17d6..ae9e4ba31 100644 --- a/src/battery/pytes/HassIntegration.cpp +++ b/src/battery/pytes/HassIntegration.cpp @@ -16,8 +16,6 @@ void HassIntegration::publishSensors() const publishSensor("Discharge current limit", NULL, "settings/dischargeCurrentLimitation", "current", "measurement", "A"); publishSensor("Discharge voltage limit", NULL, "settings/dischargeVoltageLimitation", "voltage", "measurement", "V"); - publishSensor("Voltage", "mdi:battery-charging", "voltage", "voltage", "measurement", "V"); - publishSensor("Current", "mdi:current-dc", "current", "current", "measurement", "A"); publishSensor("State of Health (SOH)", "mdi:heart-plus", "stateOfHealth", NULL, "measurement", "%"); publishSensor("Temperature", "mdi:thermometer", "temperature", "temperature", "measurement", "°C"); publishSensor("Charge Cycles", "mdi:counter", "chargeCycles"); diff --git a/src/battery/sbs/HassIntegration.cpp b/src/battery/sbs/HassIntegration.cpp index 470c5854b..e91e380d9 100644 --- a/src/battery/sbs/HassIntegration.cpp +++ b/src/battery/sbs/HassIntegration.cpp @@ -11,8 +11,6 @@ void HassIntegration::publishSensors() const { ::Batteries::HassIntegration::publishSensors(); - publishSensor("Battery voltage", NULL, "voltage", "voltage", "measurement", "V"); - publishSensor("Battery current", NULL, "current", "current", "measurement", "A"); publishSensor("Temperature", NULL, "temperature", "temperature", "measurement", "°C"); publishSensor("State of Health (SOH)", "mdi:heart-plus", "stateOfHealth", NULL, "measurement", "%"); publishSensor("Charge voltage (BMS)", NULL, "settings/chargeVoltage", "voltage", "measurement", "V"); diff --git a/src/battery/victronsmartshunt/HassIntegration.cpp b/src/battery/victronsmartshunt/HassIntegration.cpp index e66c08c0e..f9183c9e9 100644 --- a/src/battery/victronsmartshunt/HassIntegration.cpp +++ b/src/battery/victronsmartshunt/HassIntegration.cpp @@ -11,8 +11,6 @@ void HassIntegration::publishSensors() const { ::Batteries::HassIntegration::publishSensors(); - publishSensor("Voltage", "mdi:battery-charging", "voltage", "voltage", "measurement", "V"); - publishSensor("Current", "mdi:current-dc", "current", "current", "measurement", "A"); publishSensor("Instantaneous Power", NULL, "instantaneousPower", "power", "measurement", "W"); publishSensor("Charged Energy", NULL, "chargedEnergy", "energy", "total_increasing", "kWh"); publishSensor("Discharged Energy", NULL, "dischargedEnergy", "energy", "total_increasing", "kWh"); diff --git a/src/battery/zendure/HassIntegration.cpp b/src/battery/zendure/HassIntegration.cpp new file mode 100644 index 000000000..2bd12a221 --- /dev/null +++ b/src/battery/zendure/HassIntegration.cpp @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include +#include + +namespace Batteries::Zendure { + +void HassIntegration::publishSensors() const +{ + ::Batteries::HassIntegration::publishSensors(); + + publishSensor("Voltage", "mdi:battery-charging", "voltage", "voltage", "measurement", "V"); + publishSensor("Current", "mdi:current-dc", "current", "current", "measurement", "A"); + + publishSensor("Cell Min Voltage", NULL, "CellMinMilliVolt", "voltage", "measurement", "mV"); + publishSensor("Cell Average Voltage", NULL, "CellAvgMilliVolt", "voltage", "measurement", "mV"); + publishSensor("Cell Max Voltage", NULL, "CellMaxMilliVolt", "voltage", "measurement", "mV"); + publishSensor("Cell Voltage Diff", "mdi:battery-alert", "CellDiffMilliVolt", "voltage", "measurement", "mV"); + publishSensor("Cell Max Temperature", NULL, "CellMaxTemperature", "temperature", "measurement", "°C"); + publishSensor("Charge Power", "mdi:battery-charging", "chargePower", "power", "measurement", "W"); + publishSensor("Discharge Power", "mdi:battery-discharging", "dischargePower", "power", "measurement", "W"); + publishBinarySensor("Battery Heating", NULL, "heating", "1", "0"); + publishSensor("State", NULL, "state"); + publishSensor("Number of Batterie Packs", "mdi:counter", "numPacks"); + publishSensor("Efficiency", NULL, "efficiency", NULL, "measurement", "%"); + publishSensor("Last Full Charge", "mdi:timelapse", "lastFullCharge", NULL, NULL, "h"); + + for (size_t i = 1 ; i <= ZENDURE_MAX_PACKS ; i++) { + const auto id = String(i); + const auto bat = "Pack#" + id + ": "; + publishSensor(bat + "Cell Min Voltage", NULL, id + "/CellMinMilliVolt", "voltage", "measurement", "mV"); + publishSensor(bat + "Cell Average Voltage", NULL, id + "/CellAvgMilliVolt", "voltage", "measurement", "mV"); + publishSensor(bat + "Cell Max Voltage", NULL, id + "/CellMaxMilliVolt", "voltage", "measurement", "mV"); + publishSensor(bat + "Cell Voltage Diff", "mdi:battery-alert", id + "/CellDiffMilliVolt", "voltage", "measurement", "mV"); + publishSensor(bat + "Cell Max Temperature", NULL, id + "/CellMaxTemperature", "temperature", "measurement", "°C"); + publishSensor(bat + "Power", NULL, id + "/power", "power", "measurement", "W"); + publishSensor(bat + "Voltage", NULL, id + "/voltage", "voltage", "measurement", "V"); + publishSensor(bat + "Current", NULL, id + "/current", "current", "measurement", "A"); + publishSensor(bat + "State Of Charge", NULL, id + "/stateOfCharge", NULL, "measurement", "%"); + publishSensor(bat + "State Of Health", NULL, id + "/stateOfHealth", NULL, "measurement", "%"); + publishSensor(bat + "State", NULL, id + "/state"); + } + + publishSensor("Solar Power MPPT 1", "mdi:solar-power", "solarPowerMppt1", "power", "measurement", "W"); + publishSensor("Solar Power MPPT 2", "mdi:solar-power", "solarPowerMppt2", "power", "measurement", "W"); + publishSensor("Total Output Power", NULL, "outputPower", "power", "measurement", "W"); + publishSensor("Total Input Power", NULL, "inputPower", "power", "measurement", "W"); + publishBinarySensor("Bypass State", NULL, "bypass", "1", "0"); + + publishSensor("Output Power Limit", NULL, "settings/outputLimitPower", "power", "settings", "W"); + publishSensor("Input Power Limit", NULL, "settings/inputLimitPower", "power", "settings", "W"); + publishSensor("Minimum State of Charge", NULL, "settings/stateOfChargeMin", NULL, "settings", "%"); + publishSensor("Maximum State of Charge", NULL, "settings/stateOfChargeMax", NULL, "settings", "%"); + publishSensor("Bypass Mode", NULL, "settings/bypassMode", "settings"); +} + +} // namespace Batteries::Zendure diff --git a/src/battery/zendure/Provider.cpp b/src/battery/zendure/Provider.cpp new file mode 100644 index 000000000..4f3c396c7 --- /dev/null +++ b/src/battery/zendure/Provider.cpp @@ -0,0 +1,862 @@ +#include +#include +#include +#include +#include +#include +#include + +namespace Batteries::Zendure { + +bool Provider::init(bool verboseLogging) +{ + _verboseLogging = verboseLogging; + + auto const& config = Configuration.get(); + String deviceType = String(); + + log("Settings %d", config.Battery.ZendureDeviceType); + { + String deviceName = String(); + 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: + log("Invalid device type!"); + return false; + } + + if (strlen(config.Battery.ZendureDeviceId) != 8) { + MessageOutput.printf("ZendureBattery: Invalid device id '%s'!\r\n", config.Battery.ZendureDeviceId); + return false; + } + + // setup static device info + MessageOutput.printf("ZendureBattery: Device name '%s'\r\n", deviceName.c_str()); + _stats->setDevice(std::move(deviceName)); + } + + // store device ID as we will need them for checking when receiving messages + _deviceId = config.Battery.ZendureDeviceId; + + _baseTopic = "/" + deviceType + "/" + _deviceId + "/"; + _topicRead = "iot" + _baseTopic + "properties/read"; + _topicWrite = "iot" + _baseTopic + "properties/write"; + + _topicPersistentSettings = MqttSettings.getPrefix() + "battery/persistent/"; + + auto topic = _topicPersistentSettings + "#"; + MqttSettings.subscribe(topic, 0/*QoS*/, + std::bind(&Provider::onMqttMessagePersistentSettings, + this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6) + ); + log("Subscribed to '%s' for persistent settings", topic.c_str()); + + // subscribe for log messages + _topicLog = _baseTopic + "log"; + MqttSettings.subscribe(_topicLog, 0/*QoS*/, + std::bind(&Provider::onMqttMessageLog, + this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6) + ); + log("Subscribed to '%s' for status readings", _topicLog.c_str()); + + // subscribe for report messages + _topicReport = _baseTopic + "properties/report"; + MqttSettings.subscribe(_topicReport, 0/*QoS*/, + std::bind(&Provider::onMqttMessageReport, + this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6) + ); + log("Subscribed to '%s' for status readings", _topicReport.c_str()); + + // subscribe for timesync messages + _topicTimesync = _baseTopic + "time-sync"; + MqttSettings.subscribe(_topicTimesync, 0/*QoS*/, + std::bind(&Provider::onMqttMessageTimesync, + this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6) + ); + log("Subscribed to '%s' for timesync requests", _topicTimesync.c_str()); + +#ifndef ZENDURE_NO_REDUCED_UPDATE + // subscribe for read messages + _topicReadReply = _baseTopic + "properties/read/reply"; + MqttSettings.subscribe(_topicReadReply, 0/*QoS*/, + std::bind(&Provider::onMqttMessageReport, + this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6) + ); + log("Subscribed to '%s' for status readings\r\n", _topicReadReply.c_str()); + + _rateUpdateMs = min(static_cast(config.Battery.ZendurePollingInterval * 100), 10U * 1000); + _nextUpdate = 0; +#endif + + _rateFullUpdateMs = config.Battery.ZendurePollingInterval * 1000; + _nextFullUpdate = 0; + _rateTimesyncMs = ZENDURE_SECONDS_TIMESYNC * 1000; + _nextTimesync = 0; + _rateSunCalcMs = ZENDURE_SECONDS_SUNPOSITION * 1000; + _nextSunCalc = millis() + _rateSunCalcMs / 2; + + // pre-generate the settings request + JsonDocument root; + JsonVariant prop = root[ZENDURE_REPORT_PROPERTIES].to(); + prop[ZENDURE_REPORT_PV_BRAND] = 1; // means Hoymiles + prop[ZENDURE_REPORT_PV_AUTO_MODEL] = 0; // we did static setup + prop[ZENDURE_REPORT_AUTO_RECOVER] = static_cast(config.Battery.ZendureBypassMode == static_cast(BypassMode::Automatic)); + prop[ZENDURE_REPORT_AUTO_SHUTDOWN] = static_cast(config.Battery.ZendureAutoShutdown); + prop[ZENDURE_REPORT_BUZZER_SWITCH] = 0; // disable, as it is anoying + prop[ZENDURE_REPORT_BYPASS_MODE] = config.Battery.ZendureBypassMode; + prop[ZENDURE_REPORT_SMART_MODE] = 0; // should be disabled + serializeJson(root, _payloadSettings); + + // pre-generate the full update request + root.clear(); + JsonArray array = root[ZENDURE_REPORT_PROPERTIES].to(); + array.add("getAll"); + array.add("getInfo"); + serializeJson(root, _payloadFullUpdate); + +#ifndef ZENDURE_NO_REDUCED_UPDATE + // pre-generate the partitial update request + root.clear(); + array = root[ZENDURE_REPORT_PROPERTIES].to(); + array.add(ZENDURE_REPORT_MIN_SOC); + array.add(ZENDURE_REPORT_MAX_SOC); + array.add(ZENDURE_REPORT_INPUT_LIMIT); + array.add(ZENDURE_REPORT_OUTPUT_LIMIT); + array.add(ZENDURE_REPORT_INVERSE_MAX_POWER); + array.add(ZENDURE_REPORT_BATTERY_STATE); + array.add(ZENDURE_REPORT_HEAT_STATE); + array.add(ZENDURE_REPORT_AUTO_SHUTDOWN); + array.add(ZENDURE_REPORT_BUZZER_SWITCH); + array.add(ZENDURE_REPORT_REMAIN_OUT_TIME); + array.add(ZENDURE_REPORT_REMAIN_IN_TIME); + serializeJson(root, _payloadUpdate); +#endif + + // initial setup + if (!config.Battery.ZendureChargeThroughEnable) { + setChargeThrough(false); + } + setTargetSoCs(config.Battery.ZendureMinSoC, config.Battery.ZendureMaxSoC); + + + MessageOutput.printf("ZendureBattery: INIT DONE!\r\n"); + return true; +} + +void Provider::deinit() +{ + if (!_topicReport.isEmpty()) { + MqttSettings.unsubscribe(_topicReport); + _topicReport.clear(); + } + if (!_topicLog.isEmpty()) { + MqttSettings.unsubscribe(_topicLog); + _topicLog.clear(); + } + if (!_topicTimesync.isEmpty()) { + MqttSettings.unsubscribe(_topicTimesync); + _topicTimesync.clear(); + } + if (!_topicPersistentSettings.isEmpty()) { + MqttSettings.unsubscribe(_topicPersistentSettings + "#"); + _topicPersistentSettings.clear(); + } +#ifndef ZENDURE_NO_REDUCED_UPDATE + if (!_topicReadReply.isEmpty()) { + MqttSettings.unsubscribe(_topicReadReply); + _topicReadReply.clear(); + } +#endif +} + +void Provider::loop() +{ + auto ms = millis(); + auto const& config = Configuration.get(); + const bool isDayPeriod = SunPosition.isSunsetAvailable() ? SunPosition.isDayPeriod() : true; + + // if auto shutdown is enabled and battery switches to idle at night, turn off status requests to prevent keeping battery awake + if (config.Battery.ZendureAutoShutdown && !isDayPeriod && _stats->_state == State::Idle) { + return; + } + + // check if we run in schedule mode + if (ms >= _nextSunCalc) { + _nextSunCalc = ms + _rateSunCalcMs; + + calculateFullChargeAge(); + + struct tm timeinfo_local; + struct tm timeinfo_sun; + if (getLocalTime(&timeinfo_local, 5)) { + std::time_t current = std::mktime(&timeinfo_local); + + std::time_t sunrise = 0; + std::time_t sunset = 0; + + if (SunPosition.sunriseTime(&timeinfo_sun)) { + sunrise = std::mktime(&timeinfo_sun) + config.Battery.ZendureSunriseOffset * 60; + } + + if (SunPosition.sunsetTime(&timeinfo_sun)) { + sunset = std::mktime(&timeinfo_sun) + config.Battery.ZendureSunsetOffset * 60; + } + + if (sunrise && sunset) { + // check charge-through at sunrise (make sure its triggered at least once) + if (current > sunrise && current < (sunrise + ZENDURE_SECONDS_SUNPOSITION + ZENDURE_SECONDS_SUNPOSITION/2)) { + // Calculate expected daylight to asure charge through starts in the morning if sheduled for this day + // We just use the time between rise and set, as we do not know anything about the actual conditions, + // we can only expect that there will be NO sun between sunset and sunrise ;) + uint32_t maxDaylightHours = (sunset - sunrise + 1800U) / 3600U; + checkChargeThrough(maxDaylightHours); + } + + // running in appointment mode - set outputlimit accordingly + if (config.Battery.ZendureOutputControl == ZendureBatteryOutputControl::ControlSchedule) { + if (current >= sunrise && current < sunset) { + setOutputLimit(min(config.Battery.ZendureMaxOutput, config.Battery.ZendureOutputLimitDay)); + } else if (current >= sunset || current < sunrise) { + setOutputLimit(min(config.Battery.ZendureMaxOutput, config.Battery.ZendureOutputLimitNight)); + } + } + } + + + } + + // ensure charge through settings + if (_stats->_charge_through_state.value_or(false) && config.Battery.ZendureChargeThroughEnable) { + setTargetSoCs(config.Battery.ZendureMinSoC, 100); + setOutputLimit(0); + }else{ + setTargetSoCs(config.Battery.ZendureMinSoC, config.Battery.ZendureMaxSoC); + if (config.Battery.ZendureOutputControl == ZendureBatteryOutputControl::ControlFixed) { + setOutputLimit(min(config.Battery.ZendureMaxOutput, config.Battery.ZendureOutputLimit)); + } + } + } + + if (!_topicRead.isEmpty()) { + if (!_payloadFullUpdate.isEmpty() && ms >= _nextFullUpdate) { + _nextFullUpdate = ms + _rateFullUpdateMs; +#ifndef ZENDURE_NO_REDUCED_UPDATE + _nextUpdate = ms + _rateUpdateMs; +#endif + MqttSettings.publishGeneric(_topicRead, _payloadFullUpdate, false, 0); + } +#ifndef ZENDURE_NO_REDUCED_UPDATE + if (!_payloadUpdate.isEmpty() && ms >= _nextUpdate) { + _nextUpdate = ms + _rateUpdateMs; + MqttSettings.publishGeneric(_topicRead, _payloadUpdate, false, 0); + } +#endif + } + + if (ms >= _nextTimesync) { + _nextTimesync = ms + _rateTimesyncMs; + timesync(); + + // update settings (will be skipped if unchanged) + setInverterMax(config.Battery.ZendureMaxOutput); + + // republish settings - just to be sure + if (!_topicWrite.isEmpty() && !_payloadSettings.isEmpty()) { + MqttSettings.publishGeneric(_topicWrite, _payloadSettings, false, 0); + } + } +} + +void Provider::calculateFullChargeAge() +{ + time_t now; + if (Utils::getEpoch(&now, 20) && _stats->_last_full_timestamp.has_value()) { + auto last_full = *(_stats->_last_full_timestamp); + uint32_t age = now > last_full ? (now - last_full) / 3600U : 0U; + + log("Now: %ld, LastFull: %ld, Diff: %d", now, last_full, age); + + // store for webview + _stats->_last_full_charge_hours = age; + } +} + +bool Provider::checkChargeThrough(uint32_t predictHours /* = 0 */) +{ + auto const& config = Configuration.get(); + if (config.Battery.ZendureChargeThroughEnable && ( + !_stats->_last_full_timestamp.has_value() || + _stats->_last_full_charge_hours.value_or(0) + predictHours > config.Battery.ZendureChargeThroughInterval ) + ) { + return setChargeThrough(true); + } + + return false; +} + +void Provider::setTargetSoCs(const float soc_min, const float soc_max) +{ + if (_topicWrite.isEmpty() || !alive()) { + return; + } + + if (_stats->_soc_min != soc_min || _stats->_soc_max != soc_max) { + MqttSettings.publishGeneric(_topicWrite, "{\"properties\": {\"" ZENDURE_REPORT_MIN_SOC "\": " + String(soc_min * 10, 0) + ", \"" ZENDURE_REPORT_MAX_SOC "\": " + String(soc_max * 10, 0) + "} }", false, 0); + publishProperties(_topicWrite, ZENDURE_REPORT_MIN_SOC, String(soc_min * 10, 0), ZENDURE_REPORT_MAX_SOC, String(soc_max * 10, 0)); + log("Setting target minSoC from %.1f %% to %.1f %% and target maxSoC from %.1f %% to %.1f %%", _stats->_soc_min, soc_min, _stats->_soc_max, soc_max); + } +} + +uint16_t Provider::calcOutputLimit(uint16_t limit) const +{ + if (limit >= 100 || limit == 0 ) { + return limit; + } + + uint16_t base = limit / 30U; + uint16_t remain = (limit % 30U) / 15U; + return 30 * base + 30 * remain; +} + +uint16_t Provider::setOutputLimit(uint16_t limit) const +{ + auto const& config = Configuration.get(); + + if (_topicWrite.isEmpty() || !alive()) { + return _stats->_output_limit; + } + + // force valid limit and ensure fixed output is always dominant + if (config.Battery.ZendureOutputControl == ZendureBatteryOutputControl::ControlFixed) { + limit = config.Battery.ZendureOutputLimit; + } else { + limit = min(config.Battery.ZendureMaxOutput, limit); + } + + // enforce output limit during charge through + if (_stats->_charge_through_state.value_or(false)) { + limit = 0; + } + + if (_stats->_output_limit != limit) { + limit = calcOutputLimit(limit); + publishProperty(_topicWrite, ZENDURE_REPORT_OUTPUT_LIMIT, String(limit)); + log("Adjusting outputlimit from %d W to %d W", _stats->_output_limit, limit); + } + + return limit; +} + +uint16_t Provider::setInverterMax(uint16_t limit) const +{ + if (_topicWrite.isEmpty() || !alive()) { + return _stats->_inverse_max; + } + + if (_stats->_inverse_max != limit) { + limit = calcOutputLimit(limit); + publishProperty(_topicWrite, ZENDURE_REPORT_INVERSE_MAX_POWER, String(limit)); + log("Adjusting inverter max output from %d W to %d W", _stats->_inverse_max, limit); + } + + return limit; +} + +void Provider::shutdown() const +{ + if (!_topicWrite.isEmpty()) { + publishProperty(_topicWrite, ZENDURE_REPORT_MASTER_SWITCH, "1"); + log("Shutting down HUB"); + } +} + +void Provider::publishProperty(const String& topic, const String& property, const String& value) const +{ + MqttSettings.publishGeneric(topic, "{\"properties\": {\"" + property + "\": " + value + "} }", false, 0); +} + +template +void Provider::publishProperties(const String& topic, Arg&&... args) const +{ + static_assert((sizeof...(args) % 2) == 0); + + String out = "{\"properties\": {"; + bool even = true; + for (const String d : std::initializer_list({args...})) + { + if (even) { + out += "\"" + d + "\": "; + } else { + out += d + ", "; + } + even = !even; + } + out += "} }"; + MqttSettings.publishGeneric(topic, out, false, 0); +} + +void Provider::timesync() +{ + time_t now; + if (!_baseTopic.isEmpty() && Utils::getEpoch(&now, 20)) { + MqttSettings.publishGeneric("iot" + _baseTopic + "time-sync/reply", "{\"zoneOffset\": \"+00:00\", \"messageId\": " + String(++_messageCounter) + ", \"timestamp\": " + String(now) + "}", false, 0); + log("Timesync Reply"); + } +} + +bool Provider::setChargeThrough(const bool value, const bool publish /* = true */) +{ + if (!_stats->_charge_through_state.has_value() || value != _stats->_charge_through_state) { + _stats->_charge_through_state = value; + log("%s charge-through mode!", value ? "Enabling" : "Disabling"); + if (publish) { + publishPersistentSettings(ZENDURE_PERSISTENT_SETTINGS_CHARGE_THROUGH, value ? "1" : "0"); + } + + // re-run suncalc to force updates in schedule mode + rescheduleSunCalc(); + } + + return value; +} + +#ifndef ZENDURE_NO_REDUCED_UPDATE +void Provider::onMqttMessageRead(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) +{ +} +#endif + +void Provider::onMqttMessagePersistentSettings(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) +{ + String t(topic); + String p(reinterpret_cast(payload), len); + auto integer = static_cast(p.toInt()); + + log("Received Persistent Settings %s = %s [aka %" PRId64 "]", topic, p.substring(0, 32).c_str(), integer); + + if (t.endsWith(ZENDURE_PERSISTENT_SETTINGS_LAST_FULL) && integer) { + _stats->_last_full_timestamp = integer; + return; + } + if (t.endsWith(ZENDURE_PERSISTENT_SETTINGS_LAST_EMPTY) && integer) { + _stats->_last_empty_timestamp = integer; + return; + } + if (t.endsWith(ZENDURE_PERSISTENT_SETTINGS_CHARGE_THROUGH)) { + setChargeThrough(integer > 0, false); + return; + } +} + +void Provider::onMqttMessageTimesync(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) +{ + timesync(); +} + +void Provider::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 += "..."; } + + 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 + 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()); + } + + auto props = Utils::getJsonElement(obj, ZENDURE_REPORT_PROPERTIES, 1); + if (props.has_value()) { + auto sw_version = Utils::getJsonElement(*props, ZENDURE_REPORT_MASTER_FW_VERSION); + if (sw_version.has_value()) { + _stats->setFwVersion(std::move(parseVersion(*sw_version))); + } + + auto hw_version = Utils::getJsonElement(*props, ZENDURE_REPORT_MASTER_HW_VERSION); + if (hw_version.has_value()) { + _stats->setHwVersion(std::move(parseVersion(*hw_version))); + } + + auto soc_max = Utils::getJsonElement(*props, ZENDURE_REPORT_MAX_SOC); + if (soc_max.has_value()) { + *soc_max /= 10; + if (*soc_max >= 40 && *soc_max <= 100) { + _stats->_soc_max = *soc_max; + } + } + + auto soc_min = Utils::getJsonElement(*props, ZENDURE_REPORT_MIN_SOC); + if (soc_min.has_value()) { + *soc_min /= 10; + if (*soc_min >= 0 && *soc_min <= 60) { + _stats->_soc_min = *soc_min; + } + } + + auto input_limit = Utils::getJsonElement(*props, ZENDURE_REPORT_INPUT_LIMIT); + if (input_limit.has_value()) { + _stats->_input_limit = *input_limit; + } + + auto inverse_max = Utils::getJsonElement(*props, ZENDURE_REPORT_INVERSE_MAX_POWER); + if (inverse_max.has_value()) { + _stats->_inverse_max = *inverse_max; + } + + auto state = Utils::getJsonElement(*props, ZENDURE_REPORT_BATTERY_STATE); + if (state.has_value() && *state <= 2) { + _stats->_state = static_cast(*state); + } + + auto heat_state = Utils::getJsonElement(*props, ZENDURE_REPORT_HEAT_STATE); + if (heat_state.has_value()) { + _stats->_heat_state = static_cast(*heat_state); + } + + auto auto_shutdown = Utils::getJsonElement(*props, ZENDURE_REPORT_AUTO_SHUTDOWN); + if (auto_shutdown.has_value()) { + _stats->_auto_shutdown = static_cast(*auto_shutdown); + } + + auto buzzer = Utils::getJsonElement(*props, ZENDURE_REPORT_BUZZER_SWITCH); + if (buzzer.has_value()) { + _stats->_buzzer = static_cast(*buzzer); + } + + auto outtime = Utils::getJsonElement(*props, ZENDURE_REPORT_REMAIN_OUT_TIME); + if (outtime.has_value()) { + _stats->_remain_out_time = *outtime >= ZENDURE_REMAINING_TIME_OVERFLOW ? -1 : *outtime; + } + + auto intime = Utils::getJsonElement(*props, ZENDURE_REPORT_REMAIN_IN_TIME); + if (intime.has_value()) { + _stats->_remain_in_time = *intime >= ZENDURE_REMAINING_TIME_OVERFLOW ? -1 : *intime; + } + + _stats->_lastUpdate = ms; + } + + // stop processing here, if no pack data found in message + auto packData = Utils::getJsonElement(obj, ZENDURE_REPORT_PACK_DATA, 2); + if (!packData.has_value() || _stats->_num_batteries == 0) { + return; + } + + // get serial number related to index only if all packs given in message + if ((*packData).size() == _stats->_num_batteries) { + for (size_t i = 0 ; i < _stats->_num_batteries ; i++) { + auto serial = Utils::getJsonElement((*packData)[i], ZENDURE_REPORT_PACK_SERIAL); + if (!serial.has_value()) { + log("Missing serial of battery pack in '%s'", logValue.c_str()); + continue; + } + if (_stats->addPackData(i+1, *serial) == nullptr) { + log("Invalid or unkown serial '%s' in '%s'", (*serial).c_str(), logValue.c_str()); + } + } + } + + // check if our array has got inconsistant + if (_stats->_packData.size() > _stats->_num_batteries) { + log("Detected inconsitency of pack data - resetting internal data buffer!"); + _stats->_packData.clear(); + return; + } + + // get additional data only if all packs were identified + if (_stats->_packData.size() != _stats->_num_batteries) { + return; + } + + for (auto packDataJson : *packData) { + auto serial = Utils::getJsonElement(packDataJson, ZENDURE_REPORT_PACK_SERIAL); + auto state = Utils::getJsonElement(packDataJson, ZENDURE_REPORT_PACK_STATE); + auto version = Utils::getJsonElement(packDataJson, ZENDURE_REPORT_PACK_FW_VERSION); + auto soh = Utils::getJsonElement(packDataJson, ZENDURE_REPORT_PACK_HEALTH); + + // do not waste processing time if nothing to do + if (!serial.has_value() || !(state.has_value() || version.has_value() || soh.has_value())) { + continue; + } + + // find pack data related to serial number + for (auto& entry : _stats->_packData) { + auto pack = entry.second; + if (pack->_serial != serial) { + continue; + } + if (state.has_value()) { + pack->_state = static_cast(*state); + } + + if (version.has_value()) { + pack->setFwVersion(std::move(parseVersion(*version))); + } + + if (soh.has_value()) { + pack->_state_of_health = static_cast(*soh) / 10.0; + pack->_capacity_avail = pack->_capacity * pack->_state_of_health / 100.0; + } + + pack->_lastUpdate = ms; + + // we found the pack we searched for, so terminate loop here + break; + } + } +} + +void Provider::onMqttMessageLog(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) +{ + auto ms = millis(); + + log("Logging Frame received!"); + + std::string const src = std::string(reinterpret_cast(payload), len); + std::string logValue = src.substr(0, 64); + if (src.length() > logValue.length()) { logValue += "..."; } + + 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 + // deviceId has to be set to the configured deviceId + // logType has to be set to "2" + if (!json["deviceId"].as().equals(_deviceId)) { + return log("Invalid or missing 'deviceId' in '%s'", logValue.c_str()); + } + if (!json["logType"].as().equals("2")) { + return log("Invalid or missing 'v' in '%s'", logValue.c_str()); + } + + auto data = Utils::getJsonElement(obj, ZENDURE_LOG_ROOT, 2); + if (!data.has_value()) { + return log("Unable to find 'log' in '%s'", logValue.c_str()); + } + + _stats->setSerial(Utils::getJsonElement(*data, ZENDURE_LOG_SERIAL)); + + auto params = Utils::getJsonElement(*data, ZENDURE_LOG_PARAMS, 1); + if (!params.has_value()) { + return log("Unable to find 'params' in '%s'", logValue.c_str()); + } + + auto v = *params; + + uint8_t num = v[ZENDURE_LOG_OFFSET_PACKNUM].as(); + if (num > 0 && num <= ZENDURE_MAX_PACKS) { + uint16_t soc = 0; + uint16_t voltage = 0; + int16_t power = 0; + int16_t current = 0; + uint32_t cellMin = UINT32_MAX; + uint32_t cellMax = 0; + uint32_t cellAvg = 0; + uint32_t cellDelta = 0; + int32_t cellTemp = 0; + uint16_t capacity = 0; + float capacity_avail = 0; + + for (size_t i = 1 ; i <= num ; i++) { + auto pvol = v[ZENDURE_LOG_OFFSET_PACK_VOLTAGE(i)].as() * 10; + auto pcur = v[ZENDURE_LOG_OFFSET_PACK_CURRENT(i)].as(); + auto psoc = v[ZENDURE_LOG_OFFSET_PACK_SOC(i)].as(); + + auto ctmp = v[ZENDURE_LOG_OFFSET_PACK_TEMPERATURE(i)].as(); + auto cmin = v[ZENDURE_LOG_OFFSET_PACK_CELL_MIN(i)].as() * 10; + auto cmax = v[ZENDURE_LOG_OFFSET_PACK_CELL_MAX(i)].as() * 10; + auto cdel = cmax - cmin; + + auto pack = _stats->getPackData(i); + if (pack != nullptr) { + auto cavg = pvol / pack->getCellCount(); + + pack->_cell_voltage_min = static_cast(cmin); + pack->_cell_voltage_max = static_cast(cmax); + pack->_cell_voltage_avg = static_cast(cavg); + pack->_cell_voltage_spread = static_cast(cdel); + pack->_cell_temperature_max = static_cast(ctmp); + pack->_current = static_cast(pcur) / 10.0; + pack->_voltage_total = static_cast(pvol) / 1000.0; + pack->_soc_level = static_cast(psoc) / 10.0; + pack->_power = static_cast(pack->_current * pack->_voltage_total); + pack->_lastUpdate = ms; + + capacity_avail += pack->_capacity_avail; + capacity += pack->_capacity; + cellAvg += cavg; + power += pack->_power; + } + + cellMin = min(cmin, cellMin); + cellMax = max(cmax, cellMax); + cellDelta = max(cdel, cellDelta); + cellTemp = max(ctmp, cellTemp); + + soc += psoc; + voltage += pvol; + current += pcur; + } + + _stats->_num_batteries = num; + setSoC(static_cast(soc) / 10.0 / num, ms); + _stats->setVoltage(v[ZENDURE_LOG_OFFSET_VOLTAGE].as() / 10.0, ms); + _stats->setCurrent(static_cast(current) / 10.0, 1, ms); + _stats->setDischargeCurrentLimit(static_cast(_stats->_inverse_max) / _stats->getVoltage(), ms); + if (capacity) { + _stats->_capacity = capacity; + } + if (capacity_avail) { + _stats->_capacity_avail = static_cast(capacity_avail); + } + + _stats->_auto_recover = static_cast(v[ZENDURE_LOG_OFFSET_AUTO_RECOVER].as()); + _stats->_bypass_mode = static_cast(v[ZENDURE_LOG_OFFSET_BYPASS_MODE].as()); + _stats->_soc_min = v[ZENDURE_LOG_OFFSET_MIN_SOC].as(); + + _stats->_solar_power_1 = v[ZENDURE_LOG_OFFSET_SOLAR_POWER_MPPT_1].as(); + _stats->_solar_power_2 = v[ZENDURE_LOG_OFFSET_SOLAR_POWER_MPPT_2].as(); + _stats->_input_power = _stats->_solar_power_1 + _stats->_solar_power_2; + + _stats->_output_limit = static_cast(v[ZENDURE_LOG_OFFSET_OUTPUT_POWER_LIMIT].as() / 100); + //_stats->_input_power = v[ZENDURE_LOG_OFFSET_INPUT_POWER].as(); + _stats->_output_power = v[ZENDURE_LOG_OFFSET_OUTPUT_POWER].as(); + _stats->_charge_power = v[ZENDURE_LOG_OFFSET_CHARGE_POWER].as(); + _stats->_discharge_power = v[ZENDURE_LOG_OFFSET_DISCHARGE_POWER].as(); + + _stats->_cellMinMilliVolt = static_cast(cellMin); + _stats->_cellMaxMilliVolt = static_cast(cellMax); + _stats->_cellAvgMilliVolt = static_cast(cellAvg) / num; + _stats->_cellDeltaMilliVolt = static_cast(cellDelta); + _stats->_cellTemperature = static_cast(cellTemp); + + _stats->_lastUpdate = ms; + + calculateEfficiency(); + } +} + +String Provider::parseVersion(uint32_t version) +{ + if (version == 0) { + return String(); + } + + uint8_t major = (version >> 12) & 0xF; + uint8_t minor = (version >> 8) & 0xF; + uint8_t bugfix = version & 0xFF; + + char buffer[16]; + snprintf(buffer, sizeof(buffer), "%d.%d.%d", major, minor, bugfix); + return String(buffer); +} + +void Provider::calculateEfficiency() +{ + float in = static_cast(_stats->_input_power); + float out = static_cast(_stats->_output_power); + float efficiency = 0.0; + + in += static_cast(_stats->_discharge_power); + out += static_cast(_stats->_charge_power); + + efficiency = in ? out / in : 0.0; + + if (efficiency <= 1 && efficiency >= 0) { + _stats->_efficiency = efficiency * 100; + } +} + +void Provider::setSoC(const float soc, const uint32_t timestamp /* = 0 */, const uint8_t precision /* = 2 */) +{ + time_t now; + + if (Utils::getEpoch(&now, 20)) { + if (soc >= 100.0) { + _stats->_last_full_timestamp = now; + publishPersistentSettings(ZENDURE_PERSISTENT_SETTINGS_LAST_FULL, String(now)); + publishPersistentSettings(ZENDURE_PERSISTENT_SETTINGS_CHARGE_THROUGH, "0"); + } + if (soc <= 0.0) { + _stats->_last_empty_timestamp = now; + publishPersistentSettings(ZENDURE_PERSISTENT_SETTINGS_LAST_EMPTY, String(now)); + } + } + + _stats->setSoC(soc, precision, timestamp ? timestamp : millis()); +} + +void Provider::publishPersistentSettings(const char* subtopic, const String& payload) +{ + if (!_topicPersistentSettings.isEmpty()) + { + log("Writing Persistent Settings %s = %s\r\n", String(_topicPersistentSettings + subtopic).c_str(), payload.substring(0, 32).c_str()); + MqttSettings.publishGeneric(_topicPersistentSettings + subtopic, payload, true); + } +} + + +} // namespace Batteries::Zendure diff --git a/src/battery/zendure/Stats.cpp b/src/battery/zendure/Stats.cpp new file mode 100644 index 000000000..d67237e52 --- /dev/null +++ b/src/battery/zendure/Stats.cpp @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include + +namespace Batteries::Zendure { + +void Stats::getLiveViewData(JsonVariant& root) const +{ + ::Batteries::Stats::getLiveViewData(root); + + auto addRemainingTime = [this](auto root, auto section, const char* name, int16_t value, bool charge = false) { + bool notInScope = charge ? !isCharging(this->_state) : !isDischarging(this->_state); + if (value < 0 || notInScope) { + 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, "batteries", _num_batteries, "", 0); + addLiveViewInSection(root, section, "capacity", _capacity, "Wh", 0); + addLiveViewInSection(root, section, "availableCapacity", _capacity_avail, "Wh", 0); + addLiveViewInSection(root, section, "useableCapacity", getUseableCapacity(), "Wh", 0); + addLiveViewTextInSection(root, section, "state", stateToString(_state)); + addLiveViewBooleanInSection(root, section, "heatState", _heat_state); + addLiveViewBooleanInSection(root, section, "bypassState", _bypass_state); + addLiveViewBooleanInSection(root, section, "chargethrough", _charge_through_state); + addLiveViewInSection(root, section, "lastFullCharge", _last_full_charge_hours, "h", 0); + addRemainingTime(root, section, "remainOutTime", _remain_out_time, false); + addRemainingTime(root, section, "remainInTime", _remain_in_time, true); + + // 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); + addLiveViewBooleanInSection(root, section, "autoRecover", _auto_recover); + addLiveViewBooleanInSection(root, section, "autoShutdown", _auto_shutdown); + addLiveViewTextInSection(root, section, "bypassMode", bypassModeToString(_bypass_mode)); + addLiveViewBooleanInSection(root, section, "buzzer", _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& [index, value] : _packData) { + snprintf(buff, sizeof(buff), "_%s [%s]", value->getName().c_str(), value->getSerial().c_str()); + section = std::string(buff); + addLiveViewTextInSection(root, section, "state", stateToString(value->_state)); + addLiveViewInSection(root, section, "cellMinVoltage", value->_cell_voltage_min, "mV", 0); + addLiveViewInSection(root, section, "cellAvgVoltage", value->_cell_voltage_avg, "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, "cellMaxTemperature", value->_cell_temperature_max, "°C", 1); + 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, "stateOfHealth", value->_state_of_health, "%", 1); + addLiveViewInSection(root, section, "capacity", value->_capacity, "Wh", 0); + addLiveViewInSection(root, section, "availableCapacity", value->_capacity_avail, "Wh", 0); + addLiveViewTextInSection(root, section, "FwVersion", std::string(value->_fwversion.c_str()), false); + } +} + +void Stats::mqttPublish() const +{ + ::Batteries::Stats::mqttPublish(); + + MqttSettings.publish("battery/cellMinMilliVolt", String(_cellMinMilliVolt)); + MqttSettings.publish("battery/cellAvgMilliVolt", String(_cellAvgMilliVolt)); + 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(static_cast(_state))); + MqttSettings.publish("battery/numPacks", String(_num_batteries)); + MqttSettings.publish("battery/efficiency", String(_efficiency)); + MqttSettings.publish("battery/serial", _serial); + + for (const auto& [index, value] : _packData) { + auto id = String(index); + MqttSettings.publish("battery/" + id + "/cellMinMilliVolt", String(value->_cell_voltage_min)); + MqttSettings.publish("battery/" + id + "/cellMaxMilliVolt", String(value->_cell_voltage_max)); + MqttSettings.publish("battery/" + id + "/cellDiffMilliVolt", String(value->_cell_voltage_spread)); + MqttSettings.publish("battery/" + id + "/cellAvgMilliVolt", String(value->_cell_voltage_avg)); + MqttSettings.publish("battery/" + id + "/cellMaxTemperature", String(value->_cell_temperature_max)); + MqttSettings.publish("battery/" + id + "/voltage", String(value->_voltage_total)); + MqttSettings.publish("battery/" + id + "/power", String(value->_power)); + MqttSettings.publish("battery/" + id + "/current", String(value->_current)); + MqttSettings.publish("battery/" + id + "/stateOfCharge", String(value->_soc_level, 1)); + MqttSettings.publish("battery/" + id + "/stateOfHealth", String(value->_state_of_health, 1)); + MqttSettings.publish("battery/" + id + "/state", String(static_cast(value->_state))); + MqttSettings.publish("battery/" + id + "/serial", value->getSerial()); + MqttSettings.publish("battery/" + id + "/name", value->getName()); + MqttSettings.publish("battery/" + id + "/capacity", String(value->_capacity)); + } + + 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/bypass", String(static_cast(_bypass_state))); + if (_last_full_charge_hours.has_value()) { + MqttSettings.publish("battery/lastFullCharge", String(*_last_full_charge_hours)); + } + + MqttSettings.publish("battery/settings/outputLimitPower", String(_output_limit)); + MqttSettings.publish("battery/settings/inputLimitPower", String(_input_limit)); + MqttSettings.publish("battery/settings/stateOfChargeMin", String(_soc_min, 1)); + MqttSettings.publish("battery/settings/stateOfChargeMax", String(_soc_max, 1)); + MqttSettings.publish("battery/settings/bypassModeString", bypassModeToString(_bypass_mode)); + MqttSettings.publish("battery/settings/bypassMode", String(static_cast(_bypass_mode))); +} + +std::shared_ptr Stats::getPackData(size_t index) const { + try + { + return _packData.at(index); + } + catch(const std::out_of_range& ex) + { + return nullptr; + } +} + +std::shared_ptr Stats::addPackData(size_t index, String serial) { + std::shared_ptr pack; + try + { + pack = _packData.at(index); + pack->setSerial(serial); + } + catch(const std::out_of_range& ex) + { + pack = PackStats::fromSerial(serial); + + if (pack != nullptr) { + _packData[index] = pack; + } + } + return pack; +} + +} // namespace Batteries::Zendure diff --git a/webapp/src/components/BatteryView.vue b/webapp/src/components/BatteryView.vue index 0b4c0cffa..70ef1d98a 100644 --- a/webapp/src/components/BatteryView.vue +++ b/webapp/src/components/BatteryView.vue @@ -46,7 +46,14 @@ class="col order-0" >
-
{{ $t('battery.' + section) }}
+
+ + +
diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index fc3cd008f..9dcf56065 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -751,6 +751,7 @@ "ProviderMqtt": "Batteriewerte aus MQTT Broker", "ProviderVictron": "Victron SmartShunt per VE.Direct Schnittstelle", "ProviderPytesCan": "Pytes per CAN-Bus", + "ProviderZendureMqtt": "Zendure per lokalem MQTT Broker", "MqttSocConfiguration": "Einstellungen SoC", "MqttVoltageConfiguration": "Einstellungen Spannung", "MqttJsonPath": "@:base.MqttJsonPath", @@ -775,7 +776,39 @@ "UseBatteryReportedDischargeCurrentLimit": "Von der Batterie übermitteltes Limit verwenden", "BatteryReportedDischargeCurrentLimitInfo": "Hinweis: Das niedrigste Limit wird angewendet, wobei das von der Batterie übermittelte Entladestromlimit nur verwendet wird, wenn in der letzten Minute ein Update eingegangen ist; andernfalls dient das zuvor festgelegte Limit als Fallback.", "MqttDischargeCurrentTopic": "Topic für Entladestromlimit", - "MqttAmperageUnit": "@:base.Unit" + "MqttAmperageUnit": "@:base.Unit", + "ZendureConfiguration": "Einstellungen", + "ZendureDeviceType": "Produkt Typ", + "ZendureDeviceId": "Produkt Identifikation", + "ZendureDeviceIdDescription": "Die Produkt Identifikation muss manuell via Bluetooth ausgelesen werden", + "ZendureMinSoc": "Minimaler Ladezustand", + "ZendureMaxSoc": "Maximaler Ladezustand", + "ZendureBypassMode": "Bypass Modus", + "ZendureBypassModeAutomatic": "Automatisch", + "ZendureBypassModeAlwaysOff": "Dauerhaft Ausgeschaltet", + "ZendureBypassModeAlwaysOn": "Dauerhaft Eingeschaltet", + "ZendureMaxOutput": "Wechselrichter Eingangsleistung", + "ZendureAutoShutdown": "Automatisches Herunterfahren", + "ZendureAutoShutdownDescription": "Das Batteriemanagementsystem wird bei Fehlender Ladespannung nach erreichen der unteren Entladegrenze automatisch heruntergefahren", + "ZendureForceLimit": "Ausgangsleistung Vorgeben", + "ZendureForceLimitDescription": "Ausgangsleistung fest vorgeben. ACHTUNG: Damit ist keine dynamisch Steuerung mehr möglich!", + "ZendureOutputLimit": "Ausgangsleistung", + "Percent": "%", + "Watt": "W", + "Minutes": "@:networkadmin.Minutes", + "Hours": "Stunden", + "Mode": "@:ntpinfo.Mode", + "ZendureOutputControl": "Steuerung der Leistungsabgabe", + "ZendureOutputModeExternal": "Extern gesteuert", + "ZendureOutputModeFixed": "Statische Einstellung", + "ZendureOutputModeSchedule": "Zeitgesteuert", + "ZendureSunriseOffset": "Offset @:ntpinfo.Sunrise", + "ZendureSunsetOffset": "Offset @:ntpinfo.Sunset", + "ZendureOutputLimitDay": "@:batteryadmin.ZendureOutputLimit (@:ntpinfo.Day)", + "ZendureOutputLimitNight": "@:batteryadmin.ZendureOutputLimit (@:ntpinfo.Night)", + "ZendureChargeThrough": "Vollladeeinstellungen", + "ZendureChargeThroughEnabled": "Vollladen", + "ZendureChargeThroughInterval": "Maximales Interval" }, "inverteradmin": { "InverterSettings": "Wechselrichter Einstellungen", @@ -1115,6 +1148,42 @@ "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", + "automatic": "Automatisch", + "alwaysoff": "Dauerhaft Ausgeschaltet", + "alwayson": "Dauerhaft Eingeschaltet", + "efficiency": "Wirkungsgrad", + "panels": "Solar Eingänge", + "settings": "Einstellungen", + "remainOutTime": "Verbleibende Entladezeit", + "remainInTime": "Verbleibende Ladezeit", + "unavail": "N/A", + "useableCapacity": "Nutzbare Kapazität", + "chargethrough": "Vollladezyklus" } } diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index e8b5249d4..21c1f4840 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -753,6 +753,7 @@ "ProviderMqtt": "Battery data from MQTT broker", "ProviderVictron": "Victron SmartShunt using VE.Direct interface", "ProviderPytesCan": "Pytes using CAN bus", + "ProviderZendureMqtt": "Zendure using local MQTT broker", "MqttConfiguration": "MQTT Settings", "MqttSocConfiguration": "SoC Settings", "MqttVoltageConfiguration": "Voltage Settings", @@ -778,7 +779,39 @@ "UseBatteryReportedDischargeCurrentLimit": "Use Battery-Reported limit", "BatteryReportedDischargeCurrentLimitInfo": "Hint: The lowest limit will be applied, with the battery-reported discharge current limit used only if an update was received in the last minute; otherwise, the previously specified limit will act as a fallback.", "MqttDischargeCurrentTopic": "Discharge Current Limit Value Topic", - "MqttAmperageUnit": "@:base.Unit" + "MqttAmperageUnit": "@:base.Unit", + "ZendureConfiguration": "Configuration", + "ZendureDeviceType": "Product Type", + "ZendureDeviceId": "Product ID", + "ZendureDeviceIdDescription": "The product ID has to be read manually via bluetooth", + "ZendureMinSoc": "Minimum SoC", + "ZendureMaxSoc": "Maximum SoC", + "ZendureBypassMode": "Bypass mode", + "ZendureBypassModeAutomatic": "Automatic", + "ZendureBypassModeAlwaysOff": "Always Off", + "ZendureBypassModeAlwaysOn": "Always On", + "ZendureMaxOutput": "Inverter input power", + "ZendureAutoShutdown": "Automatic shutdown", + "ZendureAutoShutdownDescription": "If no charge voltage is available, the battery managent system will be shutdown after reaching the minimum charge level", + "ZendureForceLimit": "Force Setpoint", + "ZendureForceLimitDescription": "Use fixed output power. WARNING: This prevents dynamic control!", + "ZendureOutputLimit": "Output power", + "Percent": "%", + "Watt": "W", + "Minutes": "Minutes", + "Hours": "Hours", + "Mode": "@:ntpinfo.Mode", + "ZendureOutputControl": "Output Control", + "ZendureOutputModeExternal": "Controled by Others", + "ZendureOutputModeFixed": "Static setup", + "ZendureOutputModeSchedule": "Schedule", + "ZendureSunriseOffset": "@:ntpinfo.Sunrise offset", + "ZendureSunsetOffset": "@:ntpinfo.Sunset offset", + "ZendureOutputLimitDay": "@:batteryadmin.ZendureOutputLimit (@:ntpinfo.Day)", + "ZendureOutputLimitNight": "@:batteryadmin.ZendureOutputLimit (@:ntpinfo.Night)", + "ZendureChargeThrough": "Charge Through Settings", + "ZendureChargeThroughEnabled": "Charge Through", + "ZendureChargeThroughInterval": "Maximum time without full charge" }, "inverteradmin": { "InverterSettings": "Inverter Settings", @@ -1119,6 +1152,42 @@ "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", + "automatic": "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", + "useableCapacity": "Useable capacity", + "chargethrough": "Charge through" } } diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 673909b0b..5ff3b84ad 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -588,6 +588,7 @@ "ProviderJbdBmsSerial": "Jiabaida (JBD) BMS using serial connection", "ProviderMqtt": "Battery data from MQTT broker", "ProviderVictron": "Victron SmartShunt using VE.Direct interface", + "ProviderZendureMqtt": "Zendure using local MQTT broker", "MqttSocConfiguration": "SoC Settings", "MqttVoltageConfiguration": "Voltage Settings", "MqttJsonPath": "Optional: JSON Path", @@ -608,7 +609,39 @@ "UseBatteryReportedDischargeCurrentLimit": "Use Battery-Reported limit", "BatteryReportedDischargeCurrentLimitInfo": "Hint: The lowest limit will be applied, with the battery-reported discharge current limit used only if an update was received in the last minute; otherwise, the previously specified limit will act as a fallback.", "MqttDischargeCurrentTopic": "Discharge Current Limit Value Topic", - "MqttAmperageUnit": "Unit" + "MqttAmperageUnit": "Unit", + "ZendureConfiguration": "Configuration", + "ZendureDeviceType": "Product Type", + "ZendureDeviceId": "Product ID", + "ZendureDeviceIdDescription": "The product ID has to be read manually via bluetooth", + "ZendureMinSoc": "Minimum SoC", + "ZendureMaxSoc": "Maximum SoC", + "ZendureBypassMode": "Bypass mode", + "ZendureBypassModeAutomatic": "Automatic", + "ZendureBypassModeAlwaysOff": "Always Off", + "ZendureBypassModeAlwaysOn": "Always On", + "ZendureMaxOutput": "Inverter input power", + "ZendureAutoShutdown": "Automatic shutdown", + "ZendureAutoShutdownDescription": "If no charge voltage is available, the battery managent system will be shutdown after reaching the minimum charge level", + "ZendureForceLimit": "Force Setpoint", + "ZendureForceLimitDescription": "Use fixed output power. WARNING: This prevents dynamic control!", + "ZendureOutputLimit": "Output power", + "Percent": "%", + "Watt": "W", + "Minutes": "@:networkadmin.Minutes", + "Hours": "Hours", + "Mode": "@:ntpinfo.Mode", + "ZendureOutputControl": "Output Control", + "ZendureOutputModeExternal": "Controled by Others", + "ZendureOutputModeFixed": "Static setup", + "ZendureOutputModeSchedule": "Schedule", + "ZendureSunriseOffset": "@:ntpinfo.Sunrise offset", + "ZendureSunsetOffset": "@:ntpinfo.Sunset offset", + "ZendureOutputLimitDay": "@:batteryadmin.ZendureOutputLimit (@:ntpinfo.Day)", + "ZendureOutputLimitNight": "@:batteryadmin.ZendureOutputLimit (@:ntpinfo.Night)", + "ZendureChargeThrough": "Charge Through Settings", + "ZendureChargeThroughEnabled": "Charge Through", + "ZendureChargeThroughInterval": "Maximum time without full charge" }, "inverteradmin": { "InverterSettings": "Paramètres des onduleurs", @@ -994,6 +1027,42 @@ "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", + "useableCapacity": "Useable capacity", + "chargethrough": "Charge through" } } diff --git a/webapp/src/types/BatteryConfig.ts b/webapp/src/types/BatteryConfig.ts index 67792a710..5228663da 100644 --- a/webapp/src/types/BatteryConfig.ts +++ b/webapp/src/types/BatteryConfig.ts @@ -17,4 +17,20 @@ export interface BatteryConfig { mqtt_discharge_current_topic: string; mqtt_discharge_current_json_path: string; mqtt_amperage_unit: number; + zendure_device_type: number; + zendure_device_id: string; + zendure_polling_interval: number; + zendure_soc_min: number; + zendure_soc_max: number; + zendure_bypass_mode: number; + zendure_max_output: number; + zendure_auto_shutdown: boolean; + zendure_output_limit: number; + zendure_output_control: number; + zendure_output_limit_day: number; + zendure_output_limit_night: number; + zendure_sunrise_offset: number; + zendure_sunset_offset: number; + zendure_charge_through_enable: boolean; + zendure_charge_through_interval: number; } diff --git a/webapp/src/views/BatteryAdminView.vue b/webapp/src/views/BatteryAdminView.vue index 57196ae1d..0bfe2efbf 100644 --- a/webapp/src/views/BatteryAdminView.vue +++ b/webapp/src/views/BatteryAdminView.vue @@ -242,6 +242,189 @@ + + @@ -280,6 +463,7 @@ export default defineComponent({ { key: 4, value: 'PytesCan' }, { key: 5, value: 'SBSCan' }, { key: 6, value: 'JbdBmsSerial' }, + { key: 7, value: 'ZendureMqtt' }, ], serialBmsInterfaceTypeList: [ { key: 0, value: 'Uart' }, @@ -295,6 +479,23 @@ export default defineComponent({ { key: 1, value: 'mA' }, { key: 0, value: 'A' }, ], + 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' }, + { key: 2, value: 'AlwaysOn' }, + ], + zendureOutputControlList: [ + { key: 0, value: 'External' }, + { key: 1, value: 'Fixed' }, + { key: 2, value: 'Schedule' }, + ], }; }, created() {