From 7b0bac32dd2931906bcd93d5e03d0c5480458008 Mon Sep 17 00:00:00 2001 From: vaterlangen Date: Wed, 9 Oct 2024 20:25:28 +0200 Subject: [PATCH] implemented charge through for battery housekeeping --- include/BatteryStats.h | 30 ++- include/Configuration.h | 2 + include/Utils.h | 2 + include/ZendureBattery.h | 245 ++++++++++------- include/defaults.h | 2 + src/BatteryStats.cpp | 66 +++-- src/Configuration.cpp | 4 + src/MqttHandleBatteryHass.cpp | 25 +- src/Utils.cpp | 14 + src/ZendureBattery.cpp | 361 +++++++++++++++++++------- webapp/src/locales/de.json | 15 +- webapp/src/locales/en.json | 14 +- webapp/src/locales/fr.json | 13 +- webapp/src/types/BatteryConfig.ts | 2 + webapp/src/views/BatteryAdminView.vue | 21 +- 15 files changed, 600 insertions(+), 216 deletions(-) diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 8a6c1cb7a..09bcb37c2 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -8,6 +8,7 @@ #include "JkBmsDataPoints.h" #include "VeDirectShuntController.h" #include +#include // mandatory interface for all kinds of batteries class BatteryStats { @@ -381,18 +382,20 @@ class ZendureBatteryStats : public BatteryStats { 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(AB1000(serial)); return std::make_shared(PackStats(serial, "AB1000", 960)); } if (serial.startsWith("CO4H")){ - // return std::make_shared(AB2000(serial)); 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; @@ -406,13 +409,13 @@ class ZendureBatteryStats : public BatteryStats { void setFwVersion(String&& version) { _fwversion = std::move(version); } private: - String _serial = ""; - String _name = "UNKOWN"; + String _serial = String(); + String _name = String("UNKOWN"); uint16_t _capacity = 0; uint8_t _cellCount = 15; - String _fwversion = ""; - String _hwversion = ""; + String _fwversion = String(); + String _hwversion = String(); uint16_t _cell_voltage_min = 0; uint16_t _cell_voltage_max = 0; @@ -420,6 +423,9 @@ class ZendureBatteryStats : public BatteryStats { 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; @@ -435,6 +441,8 @@ class ZendureBatteryStats : public BatteryStats { void mqttPublish() const; void getLiveViewData(JsonVariant& root) const; + std::map> getPackDataList() const { return _packData; } + bool supportsAlarmsAndWarnings() const final { return false; } protected: @@ -442,7 +450,7 @@ class ZendureBatteryStats : public BatteryStats { std::optional > addPackData(size_t index, String serial); uint16_t getCapacity() const { return _capacity; }; - uint16_t getAvailableCapacity() const { return getCapacity() * (static_cast(_soc_max - _soc_min) / 100.0); }; + uint16_t getUseableCapacity() const { return _capacity_avail * (static_cast(_soc_max - _soc_min) / 100.0); }; private: void setHwVersion(String&& version) { @@ -467,7 +475,7 @@ class ZendureBatteryStats : public BatteryStats { _device = std::move(device); } - String _device = "Unkown"; + String _device = String("Unkown"); std::map> _packData = std::map >(); @@ -486,6 +494,7 @@ class ZendureBatteryStats : public BatteryStats { float _efficiency = 0.0; uint16_t _capacity = 0; + uint16_t _capacity_avail = 0; uint16_t _charge_power = 0; uint16_t _discharge_power = 0; @@ -505,4 +514,9 @@ class ZendureBatteryStats : public BatteryStats { 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; }; diff --git a/include/Configuration.h b/include/Configuration.h index da098256b..9d7b7356d 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -167,6 +167,8 @@ struct BATTERY_CONFIG_T { 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 3058a0d8f..8cc200a59 100644 --- a/include/Utils.h +++ b/include/Utils.h @@ -23,4 +23,6 @@ class Utils { 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/ZendureBattery.h b/include/ZendureBattery.h index 5e7d0b1ec..00a25a96e 100644 --- a/include/ZendureBattery.h +++ b/include/ZendureBattery.h @@ -2,6 +2,7 @@ #include #include "Battery.h" +#include "MessageOutput.h" #include // DeviceIDs for compatible Solarflow devices @@ -14,100 +15,142 @@ #define ZENDURE_MAX_PACKS 4U #define ZENDURE_REMAINING_TIME_OVERFLOW 59940U -#define ZENDURE_LOG_OFFSET_SOC 0 // [%] -#define ZENDURE_LOG_OFFSET_PACKNUM 1 // [1] -#define ZENDURE_LOG_OFFSET_PACK_SOC(pack) (2+(pack)-1) // [d%] -#define ZENDURE_LOG_OFFSET_PACK_VOLTAGE(pack) (6+(pack)-1) // [cV] -#define ZENDURE_LOG_OFFSET_PACK_CURRENT(pack) (10+(pack)-1) // [dA] -#define ZENDURE_LOG_OFFSET_PACK_CELL_MIN(pack) (14+(pack)-1) // [cV] -#define ZENDURE_LOG_OFFSET_PACK_CELL_MAX(pack) (18+(pack)-1) // [cV] -#define ZENDURE_LOG_OFFSET_PACK_UNKOWN_1(pack) (22+(pack)-1) // ? => always (0 | 0 | 0 |0) -#define ZENDURE_LOG_OFFSET_PACK_UNKOWN_2(pack) (26+(pack)-1) // ? => always (0 | 0 | 0 |0) -#define ZENDURE_LOG_OFFSET_PACK_UNKOWN_3(pack) (30+(pack)-1) // ? => always (8449 | 257 | 0 | 0) -#define ZENDURE_LOG_OFFSET_PACK_TEMPERATURE(pack) (34+(pack)-1) // [°C] -#define ZENDURE_LOG_OFFSET_PACK_UNKOWN_5(pack) (38+(pack)-1) // ? => always (1340 | 99 | 0 | 0) -#define ZENDURE_LOG_OFFSET_VOLTAGE 42 // [dV] -#define ZENDURE_LOG_OFFSET_SOLAR_POWER_MPPT_2 43 // [W] -#define ZENDURE_LOG_OFFSET_SOLAR_POWER_MPPT_1 44 // [W] -#define ZENDURE_LOG_OFFSET_OUTPUT_POWER 45 // [W] -#define ZENDURE_LOG_OFFSET_UNKOWN_05 46 // ? => 1, 413 -#define ZENDURE_LOG_OFFSET_DISCHARGE_POWER 47 // [W] -#define ZENDURE_LOG_OFFSET_CHARGE_POWER 48 // [W] -#define ZENDURE_LOG_OFFSET_OUTPUT_POWER_LIMIT 49 // [cA] -#define ZENDURE_LOG_OFFSET_UNKOWN_08 50 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_09 51 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_10 52 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_11 53 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_12 54 // ? => always 0 -#define ZENDURE_LOG_OFFSET_BYPASS_MODE 55 // [0=Auto | 1=AlwaysOff | 2=AlwaysOn] -#define ZENDURE_LOG_OFFSET_UNKOWN_14 56 // ? => always 3 -#define ZENDURE_LOG_OFFSET_UNKOWN_15 57 // ? Some kind of bitmask => e.g. 813969441 -#define ZENDURE_LOG_OFFSET_UNKOWN_16 58 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_17 59 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_18 60 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_19 61 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_20 62 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_21 63 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_22 64 // ? => always 1 -#define ZENDURE_LOG_OFFSET_UNKOWN_23 65 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_24 66 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_25 67 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_26 68 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_27 69 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_28 70 // ? => always 1 -#define ZENDURE_LOG_OFFSET_UNKOWN_29 71 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_30 72 // ? some counter => 258, 263, 25 -#define ZENDURE_LOG_OFFSET_UNKOWN_31 73 // ? some counter => 309, 293, 23 -#define ZENDURE_LOG_OFFSET_UNKOWN_32 74 // ? => always 1 -#define ZENDURE_LOG_OFFSET_UNKOWN_33 75 // ? => always 1 -#define ZENDURE_LOG_OFFSET_UNKOWN_34 76 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_35 77 // ? => always 0 or 1 -#define ZENDURE_LOG_OFFSET_UNKOWN_36 78 // ? => always 0 or 1 -#define ZENDURE_LOG_OFFSET_UNKOWN_37 79 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_38 80 // ? => always 1 or 0 -#define ZENDURE_LOG_OFFSET_AUTO_RECOVER 81 // [bool] -#define ZENDURE_LOG_OFFSET_UNKOWN_40 82 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_41 83 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_42 84 // ? => always 0 -#define ZENDURE_LOG_OFFSET_MIN_SOC 85 // [%] -#define ZENDURE_LOG_OFFSET_UNKOWN_44 86 // State 0 == Idle | 1 == Discharge -#define ZENDURE_LOG_OFFSET_UNKOWN_45 87 // ? => always 512 -#define ZENDURE_LOG_OFFSET_UNKOWN_46 88 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_47 89 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_48 90 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_49 91 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_50 92 // ? => always 0 -#define ZENDURE_LOG_OFFSET_UNKOWN_51 93 // ? => always 0 +#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" + +#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_REPORT_PACK_DATE "packData" #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_PACK_STATE "packState" #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_PACK_FW_VERSION "softVersion" #define ZENDURE_REPORT_MASTER_FW_VERSION "masterSoftVersion" #define ZENDURE_REPORT_MASTER_HW_VERSION "masterhaerVersion" -#define ZENDURE_REPORT_SERIAL "sn" -#define ZENDURE_REPORT_STATE "state" +#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_SMART_MODE "smartMode" #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" + +#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 + +#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_ALIVE_MS (5 * 60 * 1000) + + + +#define ZENDURE_REPORT_PACK_DATE "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} + class ZendureBattery : public BatteryProvider { public: ZendureBattery() = default; @@ -121,14 +164,38 @@ class ZendureBattery : public BatteryProvider { 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: 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; + }; bool _verboseLogging = false; @@ -137,29 +204,30 @@ class ZendureBattery : public BatteryProvider { uint64_t _nextUpdate; #endif - uint32_t _rateFullUpdateMs; - uint64_t _nextFullUpdate; + uint32_t _rateFullUpdateMs = 0; + uint64_t _nextFullUpdate = 0; - uint32_t _rateTimesyncMs; - uint64_t _nextTimesync; + uint32_t _rateTimesyncMs = 0; + uint64_t _nextTimesync = 0; - uint32_t _rateSunCalcMs; - uint64_t _nextSunCalc; + uint32_t _rateSunCalcMs = 0; + uint64_t _nextSunCalc = 0; uint32_t _messageCounter = 0; - String _deviceId; + String _deviceId = String(); - String _baseTopic; - String _topicLog; - String _topicReadReply; - String _topicReport; - String _topicRead; - String _topicWrite; - String _topicTimesync; + 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 _payloadFullUpdate; + String _payloadSettings = String(); + String _payloadFullUpdate = String(); #ifndef ZENDURE_NO_REDUCED_UPDATE String _payloadUpdate; #endif @@ -176,6 +244,9 @@ class ZendureBattery : public BatteryProvider { 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); diff --git a/include/defaults.h b/include/defaults.h index a3f0e2ee2..0de13f5d1 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -169,6 +169,8 @@ #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 diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index d8873cd62..d5cd4ee97 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -11,7 +11,7 @@ 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; @@ -729,13 +729,38 @@ std::optional > ZendureBatterySt return pack; } -void ZendureBatteryStats::getLiveViewData(JsonVariant& root) const { - BatteryStats::getLiveViewData(root); +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"); +} - auto bool2str = [](bool value) -> std::string { - return 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); + } +} + +void ZendureBatteryStats::getLiveViewData(JsonVariant& root) const { + BatteryStats::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); @@ -753,12 +778,15 @@ void ZendureBatteryStats::getLiveViewData(JsonVariant& root) const { 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", getAvailableCapacity(), "Wh", 0); + addLiveViewInSection(root, section, "availableCapacity", _capacity_avail, "Wh", 0); + addLiveViewInSection(root, section, "useableCapacity", getUseableCapacity(), "Wh", 0); addLiveViewTextInSection(root, section, "state", stateToString(_state)); - addLiveViewTextInSection(root, section, "heatState", bool2str(_heat_state)); - addLiveViewTextInSection(root, section, "bypassState", bool2str(_bypass_state)); - addLiveViewInSection(root, section, "batteries", _num_batteries, "", 0); + 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); @@ -769,10 +797,10 @@ void ZendureBatteryStats::getLiveViewData(JsonVariant& root) const { addLiveViewInSection(root, section, "inputLimit", _output_limit, "W", 0); addLiveViewInSection(root, section, "minSoC", _soc_min, "%", 1); addLiveViewInSection(root, section, "maxSoC", _soc_max, "%", 1); - addLiveViewTextInSection(root, section, "autoRecover", bool2str(_auto_recover)); - addLiveViewTextInSection(root, section, "autoShutdown", bool2str(_auto_shutdown)); + addLiveViewBooleanInSection(root, section, "autoRecover", _auto_recover); + addLiveViewBooleanInSection(root, section, "autoShutdown", _auto_shutdown); addLiveViewTextInSection(root, section, "bypassMode", bypassModeToString(_bypass_mode)); - addLiveViewTextInSection(root, section, "buzzer", bool2str(_buzzer)); + addLiveViewBooleanInSection(root, section, "buzzer", _buzzer); // values go into the "Solar Panels" card of the web application section = "panels"; @@ -794,7 +822,9 @@ void ZendureBatteryStats::getLiveViewData(JsonVariant& root) const { addLiveViewInSection(root, section, "power", value->_power, "W", 0); addLiveViewInSection(root, section, "current", value->_current, "A", 2); addLiveViewInSection(root, section, "SoC", value->_soc_level, "%", 1); - addLiveViewInSection(root, section, "capacity", value->getCapacity(), "Wh", 0); + 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); } } @@ -825,11 +855,12 @@ void ZendureBatteryStats::mqttPublish() const { MqttSettings.publish("battery/" + sn + "/voltage", String(value->_voltage_total)); MqttSettings.publish("battery/" + sn + "/power", String(value->_power)); MqttSettings.publish("battery/" + sn + "/current", String(value->_current)); - MqttSettings.publish("battery/" + sn + "/stateOfCharge", String(value->_soc_level)); + MqttSettings.publish("battery/" + sn + "/stateOfCharge", String(value->_soc_level, 1)); + MqttSettings.publish("battery/" + sn + "/stateOfHealth", String(value->_state_of_health, 1)); MqttSettings.publish("battery/" + sn + "/state", String(static_cast(value->_state))); MqttSettings.publish("battery/" + sn + "/serial", value->getSerial()); MqttSettings.publish("battery/" + sn + "/name", value->getName()); - MqttSettings.publish("battery/" + sn + "/capacity", String(value->getCapacity())); + MqttSettings.publish("battery/" + sn + "/capacity", String(value->_capacity)); } MqttSettings.publish("battery/solarPowerMppt1", String(_solar_power_1)); @@ -837,6 +868,9 @@ void ZendureBatteryStats::mqttPublish() const { 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)); diff --git a/src/Configuration.cpp b/src/Configuration.cpp index c1160748a..c05c5bc17 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -106,6 +106,8 @@ void ConfigurationClass::serializeBatteryConfig(BatteryConfig const& source, Jso 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; } bool ConfigurationClass::write() @@ -415,6 +417,8 @@ void ConfigurationClass::deserializeBatteryConfig(JsonObject const& source, Batt 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; } bool ConfigurationClass::read() diff --git a/src/MqttHandleBatteryHass.cpp b/src/MqttHandleBatteryHass.cpp index a28520a3c..87c7e9fef 100644 --- a/src/MqttHandleBatteryHass.cpp +++ b/src/MqttHandleBatteryHass.cpp @@ -227,9 +227,28 @@ void MqttHandleBatteryHassClass::loop() publishSensor("State", NULL, "state"); publishSensor("Number of Batterie Packs", "mdi:counter", "numPacks"); publishSensor("Efficiency", NULL, "efficiency", NULL, "measurement", "%"); - - // ToDo: Include data points for packs - // for (const auto& [sn, value] : _packData){ + publishSensor("Last Full Charge", "mdi:timelapse", "lastFullCharge", NULL, NULL, "h"); + + // auto stats = std::reinterpret_pointer_cast(Battery.getStats()); + // if (stats) + // { + // for (const auto& [i, value] : stats->getPackDataList()){ + // if (!value){ + // continue; + // } + // auto id = String(i) + "/"; + // publishSensor("Cell Min Voltage", NULL, String(id + "CellMinMilliVolt").c_str(), "voltage", "measurement", "mV"); + // publishSensor("Cell Average Voltage", NULL, String(id + "CellAvgMilliVolt").c_str(), "voltage", "measurement", "mV"); + // publishSensor("Cell Max Voltage", NULL, String(id + "CellMaxMilliVolt").c_str(), "voltage", "measurement", "mV"); + // publishSensor("Cell Voltage Diff", "mdi:battery-alert", String(id + "CellDiffMilliVolt").c_str(), "voltage", "measurement", "mV"); + // publishSensor("Cell Max Temperature", NULL, String(id + "CellMaxTemperature").c_str(), "temperature", "measurement", "°C"); + // publishSensor("Power", NULL, String(id + "power").c_str(), "power", "measurement", "W"); + // publishSensor("Voltage", NULL, String(id + "voltage").c_str(), "voltage", "measurement", "V"); + // publishSensor("Current", NULL, String(id + "current").c_str(), "current", "measurement", "A"); + // publishSensor("State Of Charge", NULL, String(id + "stateOfCharge").c_str(), NULL, "measurement", "%"); + // publishSensor("State Of Health", NULL, String(id + "stateOfHealth").c_str(), NULL, "measurement", "%"); + // publishSensor("State", NULL, "state"); + // } // } publishSensor("Solar Power MPPT 1", "mdi:solar-power", "solarPowerMppt1", "power", "measurement", "W"); diff --git a/src/Utils.cpp b/src/Utils.cpp index e8926a973..66585e086 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -256,3 +256,17 @@ template std::optional Utils::getJsonElement(JsonObjectConst root, char 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/ZendureBattery.cpp b/src/ZendureBattery.cpp index d2ecee174..40858515c 100644 --- a/src/ZendureBattery.cpp +++ b/src/ZendureBattery.cpp @@ -12,43 +12,47 @@ bool ZendureBattery::init(bool verboseLogging) _verboseLogging = verboseLogging; auto const& config = Configuration.get(); - String deviceType; - String deviceName; - - switch (config.Battery.ZendureDeviceType){ - case 0: - deviceType = ZENDURE_HUB1200; - deviceName = String("HUB 1200"); - break; - case 1: - deviceType = ZENDURE_HUB2000; - deviceName = String("HUB 2000"); - break; - case 2: - deviceType = ZENDURE_AIO2400; - deviceName = String("AIO 2400"); - break; - case 3: - deviceType = ZENDURE_ACE1500; - deviceName = String("Ace 1500"); - break; - case 4: - deviceType = ZENDURE_HYPER2000; - deviceName = String("Hyper 2000"); - break; - default: - MessageOutput.printf("ZendureBattery: Invalid device type!"); + 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; - } + } - if (strlen(config.Battery.ZendureDeviceId) != 8) { - MessageOutput.printf("ZendureBattery: Invalid device id!"); - return false; + // setup static device info + MessageOutput.printf("ZendureBattery: Device name '%s'\r\n", deviceName.c_str()); + _stats->setDevice(std::move(deviceName)); } - // setup static device info - _stats->setDevice(std::move(deviceName)); - // store device ID as we will need them for checking when receiving messages _deviceId = config.Battery.ZendureDeviceId; @@ -56,6 +60,17 @@ bool ZendureBattery::init(bool verboseLogging) _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(&ZendureBattery::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*/, @@ -64,9 +79,7 @@ bool ZendureBattery::init(bool verboseLogging) std::placeholders::_3, std::placeholders::_4, std::placeholders::_5, std::placeholders::_6) ); - if (_verboseLogging) { - MessageOutput.printf("ZendureBattery: Subscribed to '%s' for status readings\r\n", _topicLog.c_str()); - } + log("Subscribed to '%s' for status readings", _topicLog.c_str()); // subscribe for report messages _topicReport = _baseTopic + "properties/report"; @@ -76,9 +89,7 @@ bool ZendureBattery::init(bool verboseLogging) std::placeholders::_3, std::placeholders::_4, std::placeholders::_5, std::placeholders::_6) ); - if (_verboseLogging) { - MessageOutput.printf("ZendureBattery: Subscribed to '%s' for status readings\r\n", _topicReport.c_str()); - } + log("Subscribed to '%s' for status readings", _topicReport.c_str()); // subscribe for timesync messages _topicTimesync = _baseTopic + "time-sync"; @@ -88,9 +99,7 @@ bool ZendureBattery::init(bool verboseLogging) std::placeholders::_3, std::placeholders::_4, std::placeholders::_5, std::placeholders::_6) ); - if (_verboseLogging) { - MessageOutput.printf("ZendureBattery: Subscribed to '%s' for timesync requests\r\n", _topicTimesync.c_str()); - } + log("Subscribed to '%s' for timesync requests", _topicTimesync.c_str()); #ifndef ZENDURE_NO_REDUCED_UPDATE // subscribe for read messages @@ -101,9 +110,7 @@ bool ZendureBattery::init(bool verboseLogging) std::placeholders::_3, std::placeholders::_4, std::placeholders::_5, std::placeholders::_6) ); - if (_verboseLogging) { - MessageOutput.printf("ZendureBattery: Subscribed to '%s' for status readings\r\n", _topicReadReply.c_str()); - } + log("Subscribed to '%s' for status readings\r\n", _topicReadReply.c_str()); _rateUpdateMs = min(static_cast(config.Battery.ZendurePollingInterval * 100), 10U * 1000); _nextUpdate = 0; @@ -111,10 +118,10 @@ bool ZendureBattery::init(bool verboseLogging) _rateFullUpdateMs = config.Battery.ZendurePollingInterval * 1000; _nextFullUpdate = 0; - _rateTimesyncMs = 3600 * 1000; + _rateTimesyncMs = ZENDURE_SECONDS_TIMESYNC * 1000; _nextTimesync = 0; - _rateSunCalcMs = 60 * 1000; - _nextSunCalc = 0; + _rateSunCalcMs = ZENDURE_SECONDS_SUNPOSITION * 1000; + _nextSunCalc = millis() + _rateSunCalcMs / 2; // pre-generate the settings request JsonDocument root; @@ -125,8 +132,6 @@ bool ZendureBattery::init(bool verboseLogging) 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_MAX_SOC] = config.Battery.ZendureMaxSoC * 10; - prop[ZENDURE_REPORT_MIN_SOC] = config.Battery.ZendureMinSoC * 10; prop[ZENDURE_REPORT_SMART_MODE] = 0; // should be disabled serializeJson(root, _payloadSettings); @@ -146,7 +151,7 @@ bool ZendureBattery::init(bool verboseLogging) array.add(ZENDURE_REPORT_INPUT_LIMIT); array.add(ZENDURE_REPORT_OUTPUT_LIMIT); array.add(ZENDURE_REPORT_INVERSE_MAX_POWER); - array.add(ZENDURE_REPORT_PACK_STATE); + array.add(ZENDURE_REPORT_BATTERY_STATE); array.add(ZENDURE_REPORT_HEAT_STATE); array.add(ZENDURE_REPORT_AUTO_SHUTDOWN); array.add(ZENDURE_REPORT_BUZZER_SWITCH); @@ -155,6 +160,12 @@ bool ZendureBattery::init(bool verboseLogging) serializeJson(root, _payloadUpdate); #endif + // initial setup + if (!config.Battery.ZendureChargeThroughEnable){ + setChargeThrough(false); + } + setTargetSoCs(config.Battery.ZendureMinSoC, config.Battery.ZendureMaxSoC); + return true; } @@ -172,6 +183,10 @@ void ZendureBattery::deinit() MqttSettings.unsubscribe(_topicTimesync); _topicTimesync.clear(); } + if (!_topicPersistentSettings.isEmpty()) { + MqttSettings.unsubscribe(_topicPersistentSettings + "#"); + _topicPersistentSettings.clear(); + } #ifndef ZENDURE_NO_REDUCED_UPDATE if (!_topicReadReply.isEmpty()){ MqttSettings.unsubscribe(_topicReadReply); @@ -192,8 +207,11 @@ void ZendureBattery::loop() } // check if we run in schedule mode - if (config.Battery.ZendureOutputControl == ZendureBatteryOutputControl::ControlSchedule && ms >= _nextSunCalc){ + if (ms >= _nextSunCalc){ _nextSunCalc = ms + _rateSunCalcMs; + + calculateFullChargeAge(); + struct tm timeinfo_local; struct tm timeinfo_sun; if (getLocalTime(&timeinfo_local, 5)){ @@ -211,12 +229,37 @@ void ZendureBattery::loop() } if (sunrise && sunset) { - 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)); + // 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)); + } } } @@ -242,9 +285,6 @@ void ZendureBattery::loop() // update settings (will be skipped if unchanged) setInverterMax(config.Battery.ZendureMaxOutput); - if (config.Battery.ZendureOutputControl == ZendureBatteryOutputControl::ControlFixed){ - setOutputLimit(min(config.Battery.ZendureMaxOutput, config.Battery.ZendureOutputLimit)); - } // republish settings - just to be sure if (!_topicWrite.isEmpty() && !_payloadSettings.isEmpty()){ @@ -253,6 +293,45 @@ void ZendureBattery::loop() } } +void ZendureBattery::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 ZendureBattery::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 ZendureBattery::setTargetSoCs(const float soc_min, const float soc_max) +{ + //log("Enter 'setTargetSoCs': %d && %d | %f | %f ", !_topicWrite.isEmpty(), alive(), soc_min, soc_max); + if (!_topicWrite.isEmpty() && alive()) { + 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 ZendureBattery::calcOutputLimit(uint16_t limit) const { if (limit >= 100 || limit == 0){ @@ -266,14 +345,29 @@ uint16_t ZendureBattery::calcOutputLimit(uint16_t limit) const uint16_t ZendureBattery::setOutputLimit(uint16_t limit) const { - if (_topicWrite.isEmpty() || !_stats->updateAvailable(ZENDURE_ALIVE_MS)) { + auto const& config = Configuration.get(); + + if (_topicWrite.isEmpty() || !alive()) { return _stats->_output_limit; } + // enforce output limit during charge through + if (_stats->_charge_through_state.value_or(false)){ + limit = 0; + } + + // force static limit + if (config.Battery.ZendureOutputControl == ZendureBatteryOutputControl::ControlFixed){ + limit = config.Battery.ZendureOutputLimit; + } + + // force limit below max inverter limit + limit = min(config.Battery.ZendureMaxOutput, limit); + if (_stats->_output_limit != limit){ limit = calcOutputLimit(limit); publishProperty(_topicWrite, ZENDURE_REPORT_OUTPUT_LIMIT, String(limit)); - MessageOutput.printf("ZendureBattery: Adjusting outputlimit from %d W to %d W\r\n", _stats->_output_limit, limit); + log("Adjusting outputlimit from %d W to %d W", _stats->_output_limit, limit); } return limit; @@ -281,14 +375,14 @@ uint16_t ZendureBattery::setOutputLimit(uint16_t limit) const uint16_t ZendureBattery::setInverterMax(uint16_t limit) const { - if (_topicWrite.isEmpty() || !_stats->updateAvailable(ZENDURE_ALIVE_MS)) { + 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)); - MessageOutput.printf("ZendureBattery: Adjusting inverter max output from %d W to %d W\r\n", _stats->_inverse_max, limit); + log("Adjusting inverter max output from %d W to %d W", _stats->_inverse_max, limit); } return limit; @@ -298,7 +392,7 @@ void ZendureBattery::shutdown() const { if (!_topicWrite.isEmpty()) { publishProperty(_topicWrite, ZENDURE_REPORT_MASTER_SWITCH, "1"); - MessageOutput.printf("ZendureBattery: Shutting down HUB\r\n"); + log("Shutting down HUB"); } } @@ -307,16 +401,50 @@ void ZendureBattery::publishProperty(const String& topic, const String& property MqttSettings.publishGeneric(topic, "{\"properties\": {\"" + property + "\": " + value + "} }", false, 0); } +template +void ZendureBattery::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 ZendureBattery::timesync() { time_t now; - time(&now); - if (!_baseTopic.isEmpty() && now > 1577836800 /* epoch 2020-01-01T00:00:00 */) { + 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); - MessageOutput.printf("ZendureBattery: Timesync Reply\r\n"); + log("Timesync Reply"); } } +bool ZendureBattery::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 ZendureBattery::onMqttMessageRead(espMqttClientTypes::MessageProperties const& properties, char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) @@ -324,6 +452,29 @@ void ZendureBattery::onMqttMessageRead(espMqttClientTypes::MessageProperties con } #endif +void ZendureBattery::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 ZendureBattery::onMqttMessageTimesync(espMqttClientTypes::MessageProperties const& properties, char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) { @@ -339,15 +490,6 @@ void ZendureBattery::onMqttMessageReport(espMqttClientTypes::MessageProperties c std::string logValue = src.substr(0, 64); if (src.length() > logValue.length()) { logValue += "..."; } - auto log = [_verboseLogging=_verboseLogging](char const* format, auto&&... args) -> void { - if (_verboseLogging) { - MessageOutput.printf("ZendureBattery (Report): "); - MessageOutput.printf(format, args...); - MessageOutput.println(); - } - return; - }; - JsonDocument json; const DeserializationError error = deserializeJson(json, src); @@ -414,7 +556,7 @@ void ZendureBattery::onMqttMessageReport(espMqttClientTypes::MessageProperties c _stats->_inverse_max = *inverse_max; } - auto state = Utils::getJsonElement(*props, ZENDURE_REPORT_PACK_STATE); + auto state = Utils::getJsonElement(*props, ZENDURE_REPORT_BATTERY_STATE); if (state.has_value() && *state <= 2){ _stats->_state = static_cast(*state); } @@ -458,7 +600,7 @@ void ZendureBattery::onMqttMessageReport(espMqttClientTypes::MessageProperties c // get serial number related to index only if all packs given in message if (_stats->_num_batteries != 0 && (*packData).size() == _stats->_num_batteries){ for (size_t i = 0 ; i < _stats->_num_batteries ; i++){ - auto serial = Utils::getJsonElement((*packData)[i], ZENDURE_REPORT_SERIAL); + auto serial = Utils::getJsonElement((*packData)[i], ZENDURE_REPORT_PACK_SERIAL); if (serial.has_value()){ if (!_stats->addPackData(i+1, *serial).has_value()){ log("Invalid or unkown serial '%s' in '%s'", (*serial).c_str(), logValue.c_str()); @@ -472,9 +614,10 @@ void ZendureBattery::onMqttMessageReport(espMqttClientTypes::MessageProperties c // get additional data only if all packs were identified if (_stats->_packData.size() == _stats->_num_batteries){ for (auto packDataJson : *packData){ - auto serial = Utils::getJsonElement(packDataJson, ZENDURE_REPORT_SERIAL); - auto state = Utils::getJsonElement(packDataJson, ZENDURE_REPORT_STATE); + 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())){ @@ -493,6 +636,11 @@ void ZendureBattery::onMqttMessageReport(espMqttClientTypes::MessageProperties c (*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 @@ -512,15 +660,6 @@ void ZendureBattery::onMqttMessageLog(espMqttClientTypes::MessageProperties cons std::string logValue = src.substr(0, 64); if (src.length() > logValue.length()) { logValue += "..."; } - auto log = [_verboseLogging=_verboseLogging](char const* format, auto&&... args) -> void { - if (_verboseLogging) { - MessageOutput.printf("ZendureBattery (Log): "); - MessageOutput.printf(format, args...); - MessageOutput.println(); - } - return; - }; - JsonDocument json; const DeserializationError error = deserializeJson(json, src); @@ -544,14 +683,14 @@ void ZendureBattery::onMqttMessageLog(espMqttClientTypes::MessageProperties cons return log("Invalid or missing 'v' in '%s'", logValue.c_str()); } - auto data = Utils::getJsonElement(obj, "log", 2); + 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_REPORT_SERIAL)); + _stats->setSerial(Utils::getJsonElement(*data, ZENDURE_LOG_SERIAL)); - auto params = Utils::getJsonElement(*data, "params", 1); + auto params = Utils::getJsonElement(*data, ZENDURE_LOG_PARAMS, 1); if (!params.has_value()){ return log("Unable to find 'params' in '%s'", logValue.c_str()); } @@ -570,6 +709,7 @@ void ZendureBattery::onMqttMessageLog(espMqttClientTypes::MessageProperties cons 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; @@ -596,7 +736,8 @@ void ZendureBattery::onMqttMessageLog(espMqttClientTypes::MessageProperties cons (*pack)->_power = static_cast((*pack)->_current * (*pack)->_voltage_total); (*pack)->_lastUpdate = ms; - capacity += (*pack)->getCapacity(); + capacity_avail += (*pack)->_capacity_avail; + capacity += (*pack)->_capacity; cellAvg += cavg; power += (*pack)->_power; } @@ -612,7 +753,8 @@ void ZendureBattery::onMqttMessageLog(espMqttClientTypes::MessageProperties cons } _stats->_num_batteries = num; - _stats->setSoC(static_cast(soc) / 10.0 / num, 2, ms); + setSoC(static_cast(soc) / 10.0 / num, ms); + //_stats->setSoC(static_cast(soc) / 10.0 / num, 2, ms); //_stats->setVoltage(static_cast(voltage) / 1000 / num, ms); _stats->setVoltage(v[ZENDURE_LOG_OFFSET_VOLTAGE].as() / 10.0, ms); _stats->setCurrent(static_cast(current) / 10.0, 1, ms); @@ -620,6 +762,9 @@ void ZendureBattery::onMqttMessageLog(espMqttClientTypes::MessageProperties cons 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()); @@ -677,3 +822,31 @@ void ZendureBattery::calculateEfficiency() _stats->_efficiency = efficiency * 100; } } + +void ZendureBattery::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 ZendureBattery::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); + } +} diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 136889078..0942139db 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -726,12 +726,19 @@ "Percent": "%", "Watt": "W", "Minutes": "@:networkadmin.Minutes", + "Hours": "Stunden", "Mode": "@:ntpinfo.Mode", - "ZendureOutputControl": "Ausgabesteuerung", + "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)" + "ZendureOutputLimitNight": "@:batteryadmin.ZendureOutputLimit (@:ntpinfo.Night)", + "ZendureChargeThrough": "Vollladeeinstellungen", + "ZendureChargeThroughEnabled": "Vollladen", + "ZendureChargeThroughInterval": "Maximales Interval" }, "inverteradmin": { "InverterSettings": "Wechselrichter Einstellungen", @@ -1070,6 +1077,8 @@ "settings": "Einstellungen", "remainOutTime": "Verbleibende Entladezeit", "remainInTime": "Verbleibende Ladezeit", - "unavail": "N/A" + "unavail": "N/A", + "useableCapacity": "Nutzbare Kapazität", + "chargethrough": "Vollladezyklus" } } diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index f46e78d3c..3b9f835d3 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -729,12 +729,20 @@ "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)" + "ZendureOutputLimitNight": "@:batteryadmin.ZendureOutputLimit (@:ntpinfo.Night)", + "ZendureChargeThrough": "Charge Through Settings", + "ZendureChargeThroughEnabled": "Charge Through", + "ZendureChargeThroughInterval": "Maximum time without full charge" + }, "inverteradmin": { "InverterSettings": "Inverter Settings", @@ -1074,6 +1082,8 @@ "settings": "Settings", "remainOutTime": "Remaining discharge time", "remainInTime": "Remaining charge time", - "unavail": "N/A" + "unavail": "N/A", + "useableCapacity": "Useable capacity", + "chargethrough": "Charge through" } } diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index bd7c83ace..df4c3a276 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -648,12 +648,19 @@ "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)" + "ZendureOutputLimitNight": "@:batteryadmin.ZendureOutputLimit (@:ntpinfo.Night)", + "ZendureChargeThrough": "Charge Through Settings", + "ZendureChargeThroughEnabled": "Charge Through", + "ZendureChargeThroughInterval": "Maximum time without full charge" }, "inverteradmin": { "InverterSettings": "Paramètres des onduleurs", @@ -1024,6 +1031,8 @@ "settings": "Settings", "remainOutTime": "Remaining discharge time", "remainInTime": "Remaining charge time", - "unavail": "N/A" + "unavail": "N/A", + "useableCapacity": "Useable capacity", + "chargethrough": "Charge through" } } diff --git a/webapp/src/types/BatteryConfig.ts b/webapp/src/types/BatteryConfig.ts index 42c21e6db..2c669adca 100644 --- a/webapp/src/types/BatteryConfig.ts +++ b/webapp/src/types/BatteryConfig.ts @@ -29,4 +29,6 @@ export interface BatteryConfig { 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 3a7ffb69e..74072a4f7 100644 --- a/webapp/src/views/BatteryAdminView.vue +++ b/webapp/src/views/BatteryAdminView.vue @@ -290,6 +290,25 @@ /> + + + + +