From a11cc82782803191816d9a72aead1d532334391d Mon Sep 17 00:00:00 2001 From: "Stefan Schultheis, OE1SCS" Date: Tue, 7 May 2024 13:58:27 +0200 Subject: [PATCH 001/140] Typos de.json translation --- webapp/src/locales/de.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index ad184d1ce..83f640e9d 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -65,8 +65,8 @@ "4009": "Wechselrichter Reihenfolge gespeichert!", "5001": "@:apiresponse.2001", "5002": "Das Limit muss zwischen 1 und {max} sein!", - "5003": "Ungültiten Typ angegeben!", - "5004": "Ungültigen Inverter angegeben!", + "5003": "Ungültiger Typ angegeben!", + "5004": "Ungültiger Inverter angegeben!", "6001": "Neustart durchgeführt!", "6002": "Neustart abgebrochen!", "7001": "MQTT-Server muss zwischen 1 und {max} Zeichen lang sein!", @@ -143,7 +143,7 @@ "LoadingInverter": "Warte auf Daten... (kann bis zu 10 Sekunden dauern)" }, "eventlog": { - "Start": "Begin", + "Start": "Beginn", "Stop": "Ende", "Id": "ID", "Message": "Meldung" From 6358b1ebee985172b16594744512171e484c9529 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Wed, 15 May 2024 18:10:10 +0200 Subject: [PATCH 002/140] Update espressif32 from 6.6.0 to 6.7.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index afd6a67f8..0508370d3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -19,7 +19,7 @@ extra_configs = custom_ci_action = generic,generic_esp32,generic_esp32s3,generic_esp32s3_usb framework = arduino -platform = espressif32@6.6.0 +platform = espressif32@6.7.0 build_flags = -DPIOENV=\"$PIOENV\" From 6a7bed0ecf314364cd9be9649db5903fb73d1170 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Thu, 16 May 2024 19:54:09 +0200 Subject: [PATCH 003/140] Code Refactoring: Add inverter reference to each command Instead of just adding the target_address to a command this patch adds a reference to the whole inverter instance --- lib/Hoymiles/src/HoymilesRadio.h | 4 +-- .../commands/ActivePowerControlCommand.cpp | 8 ++--- .../src/commands/ActivePowerControlCommand.h | 4 +-- .../src/commands/AlarmDataCommand.cpp | 8 ++--- lib/Hoymiles/src/commands/AlarmDataCommand.h | 4 +-- .../src/commands/ChannelChangeCommand.cpp | 4 +-- .../src/commands/ChannelChangeCommand.h | 2 +- lib/Hoymiles/src/commands/CommandAbstract.cpp | 9 ++++-- lib/Hoymiles/src/commands/CommandAbstract.h | 8 +++-- .../src/commands/DevControlCommand.cpp | 8 ++--- lib/Hoymiles/src/commands/DevControlCommand.h | 4 +-- .../src/commands/DevInfoAllCommand.cpp | 8 ++--- lib/Hoymiles/src/commands/DevInfoAllCommand.h | 4 +-- .../src/commands/DevInfoSimpleCommand.cpp | 8 ++--- .../src/commands/DevInfoSimpleCommand.h | 4 +-- .../src/commands/GridOnProFilePara.cpp | 8 ++--- lib/Hoymiles/src/commands/GridOnProFilePara.h | 4 +-- .../src/commands/MultiDataCommand.cpp | 8 ++--- lib/Hoymiles/src/commands/MultiDataCommand.h | 4 +-- lib/Hoymiles/src/commands/ParaSetCommand.cpp | 8 ++--- lib/Hoymiles/src/commands/ParaSetCommand.h | 4 +-- .../src/commands/PowerControlCommand.cpp | 8 ++--- .../src/commands/PowerControlCommand.h | 4 +-- .../src/commands/RealTimeRunDataCommand.cpp | 8 ++--- .../src/commands/RealTimeRunDataCommand.h | 4 +-- .../src/commands/RequestFrameCommand.cpp | 8 ++--- .../src/commands/RequestFrameCommand.h | 4 +-- .../src/commands/SingleDataCommand.cpp | 6 ++-- lib/Hoymiles/src/commands/SingleDataCommand.h | 4 +-- .../src/commands/SystemConfigParaCommand.cpp | 8 ++--- .../src/commands/SystemConfigParaCommand.h | 4 +-- lib/Hoymiles/src/inverters/HMS_Abstract.cpp | 3 +- lib/Hoymiles/src/inverters/HMT_Abstract.cpp | 3 +- lib/Hoymiles/src/inverters/HM_Abstract.cpp | 29 +++++++------------ 34 files changed, 105 insertions(+), 111 deletions(-) diff --git a/lib/Hoymiles/src/HoymilesRadio.h b/lib/Hoymiles/src/HoymilesRadio.h index cb2a947cd..296b479bb 100644 --- a/lib/Hoymiles/src/HoymilesRadio.h +++ b/lib/Hoymiles/src/HoymilesRadio.h @@ -22,9 +22,9 @@ class HoymilesRadio { } template - std::shared_ptr prepareCommand() + std::shared_ptr prepareCommand(InverterAbstract* inv) { - return std::make_shared(); + return std::make_shared(inv); } protected: diff --git a/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp b/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp index 95af23cfc..f1f670b34 100644 --- a/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp +++ b/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -25,8 +25,8 @@ ID Target Addr Source Addr Cmd SCmd ? Limit Type CRC16 CRC8 #define CRC_SIZE 6 -ActivePowerControlCommand::ActivePowerControlCommand(const uint64_t target_address, const uint64_t router_address) - : DevControlCommand(target_address, router_address) +ActivePowerControlCommand::ActivePowerControlCommand(InverterAbstract* inv, const uint64_t router_address) + : DevControlCommand(inv, router_address) { _payload[10] = 0x0b; _payload[11] = 0x00; @@ -97,4 +97,4 @@ PowerLimitControlType ActivePowerControlCommand::getType() void ActivePowerControlCommand::gotTimeout(InverterAbstract& inverter) { inverter.SystemConfigPara()->setLastLimitCommandSuccess(CMD_NOK); -} \ No newline at end of file +} diff --git a/lib/Hoymiles/src/commands/ActivePowerControlCommand.h b/lib/Hoymiles/src/commands/ActivePowerControlCommand.h index b7831fb86..78176796f 100644 --- a/lib/Hoymiles/src/commands/ActivePowerControlCommand.h +++ b/lib/Hoymiles/src/commands/ActivePowerControlCommand.h @@ -12,7 +12,7 @@ typedef enum { // ToDo: to be verified by field tests class ActivePowerControlCommand : public DevControlCommand { public: - explicit ActivePowerControlCommand(const uint64_t target_address = 0, const uint64_t router_address = 0); + explicit ActivePowerControlCommand(InverterAbstract* inv, const uint64_t router_address = 0); virtual String getCommandName() const; @@ -22,4 +22,4 @@ class ActivePowerControlCommand : public DevControlCommand { void setActivePowerLimit(const float limit, const PowerLimitControlType type = RelativNonPersistent); float getLimit() const; PowerLimitControlType getType(); -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/commands/AlarmDataCommand.cpp b/lib/Hoymiles/src/commands/AlarmDataCommand.cpp index 143a6cd56..350d0f925 100644 --- a/lib/Hoymiles/src/commands/AlarmDataCommand.cpp +++ b/lib/Hoymiles/src/commands/AlarmDataCommand.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -23,8 +23,8 @@ ID Target Addr Source Addr Idx DT ? Time Gap AlarmId Pa #include "AlarmDataCommand.h" #include "inverters/InverterAbstract.h" -AlarmDataCommand::AlarmDataCommand(const uint64_t target_address, const uint64_t router_address, const time_t time) - : MultiDataCommand(target_address, router_address) +AlarmDataCommand::AlarmDataCommand(InverterAbstract* inv, const uint64_t router_address, const time_t time) + : MultiDataCommand(inv, router_address) { setTime(time); setDataType(0x11); @@ -60,4 +60,4 @@ bool AlarmDataCommand::handleResponse(InverterAbstract& inverter, const fragment void AlarmDataCommand::gotTimeout(InverterAbstract& inverter) { inverter.EventLog()->setLastAlarmRequestSuccess(CMD_NOK); -} \ No newline at end of file +} diff --git a/lib/Hoymiles/src/commands/AlarmDataCommand.h b/lib/Hoymiles/src/commands/AlarmDataCommand.h index abdfc5f83..f5248b1ff 100644 --- a/lib/Hoymiles/src/commands/AlarmDataCommand.h +++ b/lib/Hoymiles/src/commands/AlarmDataCommand.h @@ -5,10 +5,10 @@ class AlarmDataCommand : public MultiDataCommand { public: - explicit AlarmDataCommand(const uint64_t target_address = 0, const uint64_t router_address = 0, const time_t time = 0); + explicit AlarmDataCommand(InverterAbstract* inv, const uint64_t router_address = 0, const time_t time = 0); virtual String getCommandName() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); virtual void gotTimeout(InverterAbstract& inverter); -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/commands/ChannelChangeCommand.cpp b/lib/Hoymiles/src/commands/ChannelChangeCommand.cpp index 301785098..f044d9d50 100644 --- a/lib/Hoymiles/src/commands/ChannelChangeCommand.cpp +++ b/lib/Hoymiles/src/commands/ChannelChangeCommand.cpp @@ -19,8 +19,8 @@ ID Target Addr Source Addr ? ? ? CH ? CRC8 */ #include "ChannelChangeCommand.h" -ChannelChangeCommand::ChannelChangeCommand(const uint64_t target_address, const uint64_t router_address, const uint8_t channel) - : CommandAbstract(target_address, router_address) +ChannelChangeCommand::ChannelChangeCommand(InverterAbstract* inv, const uint64_t router_address, const uint8_t channel) + : CommandAbstract(inv, router_address) { _payload[0] = 0x56; _payload[13] = 0x14; diff --git a/lib/Hoymiles/src/commands/ChannelChangeCommand.h b/lib/Hoymiles/src/commands/ChannelChangeCommand.h index 44a4c9ebd..2b4a5aad4 100644 --- a/lib/Hoymiles/src/commands/ChannelChangeCommand.h +++ b/lib/Hoymiles/src/commands/ChannelChangeCommand.h @@ -6,7 +6,7 @@ class ChannelChangeCommand : public CommandAbstract { public: - explicit ChannelChangeCommand(const uint64_t target_address = 0, const uint64_t router_address = 0, const uint8_t channel = 0); + explicit ChannelChangeCommand(InverterAbstract* inv, const uint64_t router_address = 0, const uint8_t channel = 0); virtual String getCommandName() const; diff --git a/lib/Hoymiles/src/commands/CommandAbstract.cpp b/lib/Hoymiles/src/commands/CommandAbstract.cpp index dafe2b175..15102fbbb 100644 --- a/lib/Hoymiles/src/commands/CommandAbstract.cpp +++ b/lib/Hoymiles/src/commands/CommandAbstract.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -29,13 +29,16 @@ Source Address: 80 12 23 04 #include "CommandAbstract.h" #include "crc.h" #include +#include "../inverters/InverterAbstract.h" -CommandAbstract::CommandAbstract(const uint64_t target_address, const uint64_t router_address) +CommandAbstract::CommandAbstract(InverterAbstract* inv, const uint64_t router_address) { memset(_payload, 0, RF_LEN); _payload_size = 0; - setTargetAddress(target_address); + _inv = inv; + + setTargetAddress(_inv->serial()); setRouterAddress(router_address); setSendCount(0); setTimeout(0); diff --git a/lib/Hoymiles/src/commands/CommandAbstract.h b/lib/Hoymiles/src/commands/CommandAbstract.h index 677fc0d12..b543ed668 100644 --- a/lib/Hoymiles/src/commands/CommandAbstract.h +++ b/lib/Hoymiles/src/commands/CommandAbstract.h @@ -13,7 +13,7 @@ class InverterAbstract; class CommandAbstract { public: - explicit CommandAbstract(const uint64_t target_address = 0, const uint64_t router_address = 0); + explicit CommandAbstract(InverterAbstract* inv, const uint64_t router_address = 0); virtual ~CommandAbstract() {}; const uint8_t* getDataPayload(); @@ -21,7 +21,6 @@ class CommandAbstract { uint8_t getDataSize() const; - void setTargetAddress(const uint64_t address); uint64_t getTargetAddress() const; void setRouterAddress(const uint64_t address); @@ -56,6 +55,9 @@ class CommandAbstract { uint64_t _targetAddress; uint64_t _routerAddress; + InverterAbstract* _inv; + private: + void setTargetAddress(const uint64_t address); static void convertSerialToPacketId(uint8_t buffer[], const uint64_t serial); -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/commands/DevControlCommand.cpp b/lib/Hoymiles/src/commands/DevControlCommand.cpp index a5e7d2b60..abf5f529f 100644 --- a/lib/Hoymiles/src/commands/DevControlCommand.cpp +++ b/lib/Hoymiles/src/commands/DevControlCommand.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -23,8 +23,8 @@ ID Target Addr Source Addr Cmd Payload CRC16 CRC8 #include "DevControlCommand.h" #include "crc.h" -DevControlCommand::DevControlCommand(const uint64_t target_address, const uint64_t router_address) - : CommandAbstract(target_address, router_address) +DevControlCommand::DevControlCommand(InverterAbstract* inv, const uint64_t router_address) + : CommandAbstract(inv, router_address) { _payload[0] = 0x51; _payload[9] = 0x81; @@ -48,4 +48,4 @@ bool DevControlCommand::handleResponse(InverterAbstract& inverter, const fragmen } return true; -} \ No newline at end of file +} diff --git a/lib/Hoymiles/src/commands/DevControlCommand.h b/lib/Hoymiles/src/commands/DevControlCommand.h index c24bc60b2..1fb1361dc 100644 --- a/lib/Hoymiles/src/commands/DevControlCommand.h +++ b/lib/Hoymiles/src/commands/DevControlCommand.h @@ -5,10 +5,10 @@ class DevControlCommand : public CommandAbstract { public: - explicit DevControlCommand(const uint64_t target_address = 0, const uint64_t router_address = 0); + explicit DevControlCommand(InverterAbstract* inv, const uint64_t router_address = 0); virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); protected: void udpateCRC(const uint8_t len); -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/commands/DevInfoAllCommand.cpp b/lib/Hoymiles/src/commands/DevInfoAllCommand.cpp index c7bd80272..be544886c 100644 --- a/lib/Hoymiles/src/commands/DevInfoAllCommand.cpp +++ b/lib/Hoymiles/src/commands/DevInfoAllCommand.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -21,8 +21,8 @@ ID Target Addr Source Addr Idx DT ? Time Gap Pa #include "DevInfoAllCommand.h" #include "inverters/InverterAbstract.h" -DevInfoAllCommand::DevInfoAllCommand(const uint64_t target_address, const uint64_t router_address, const time_t time) - : MultiDataCommand(target_address, router_address) +DevInfoAllCommand::DevInfoAllCommand(InverterAbstract* inv, const uint64_t router_address, const time_t time) + : MultiDataCommand(inv, router_address) { setTime(time); setDataType(0x01); @@ -52,4 +52,4 @@ bool DevInfoAllCommand::handleResponse(InverterAbstract& inverter, const fragmen inverter.DevInfo()->endAppendFragment(); inverter.DevInfo()->setLastUpdateAll(millis()); return true; -} \ No newline at end of file +} diff --git a/lib/Hoymiles/src/commands/DevInfoAllCommand.h b/lib/Hoymiles/src/commands/DevInfoAllCommand.h index 3facffa7c..4f76783fa 100644 --- a/lib/Hoymiles/src/commands/DevInfoAllCommand.h +++ b/lib/Hoymiles/src/commands/DevInfoAllCommand.h @@ -5,9 +5,9 @@ class DevInfoAllCommand : public MultiDataCommand { public: - explicit DevInfoAllCommand(const uint64_t target_address = 0, const uint64_t router_address = 0, const time_t time = 0); + explicit DevInfoAllCommand(InverterAbstract* inv, const uint64_t router_address = 0, const time_t time = 0); virtual String getCommandName() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/commands/DevInfoSimpleCommand.cpp b/lib/Hoymiles/src/commands/DevInfoSimpleCommand.cpp index 2afaae4bd..e58996537 100644 --- a/lib/Hoymiles/src/commands/DevInfoSimpleCommand.cpp +++ b/lib/Hoymiles/src/commands/DevInfoSimpleCommand.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -21,8 +21,8 @@ ID Target Addr Source Addr Idx DT ? Time Gap Pa #include "DevInfoSimpleCommand.h" #include "inverters/InverterAbstract.h" -DevInfoSimpleCommand::DevInfoSimpleCommand(const uint64_t target_address, const uint64_t router_address, const time_t time) - : MultiDataCommand(target_address, router_address) +DevInfoSimpleCommand::DevInfoSimpleCommand(InverterAbstract* inv, const uint64_t router_address, const time_t time) + : MultiDataCommand(inv, router_address) { setTime(time); setDataType(0x00); @@ -52,4 +52,4 @@ bool DevInfoSimpleCommand::handleResponse(InverterAbstract& inverter, const frag inverter.DevInfo()->endAppendFragment(); inverter.DevInfo()->setLastUpdateSimple(millis()); return true; -} \ No newline at end of file +} diff --git a/lib/Hoymiles/src/commands/DevInfoSimpleCommand.h b/lib/Hoymiles/src/commands/DevInfoSimpleCommand.h index 66a7301a9..5cd548cca 100644 --- a/lib/Hoymiles/src/commands/DevInfoSimpleCommand.h +++ b/lib/Hoymiles/src/commands/DevInfoSimpleCommand.h @@ -5,9 +5,9 @@ class DevInfoSimpleCommand : public MultiDataCommand { public: - explicit DevInfoSimpleCommand(const uint64_t target_address = 0, const uint64_t router_address = 0, const time_t time = 0); + explicit DevInfoSimpleCommand(InverterAbstract* inv, const uint64_t router_address = 0, const time_t time = 0); virtual String getCommandName() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/commands/GridOnProFilePara.cpp b/lib/Hoymiles/src/commands/GridOnProFilePara.cpp index c98c7e5a5..d7d64e139 100644 --- a/lib/Hoymiles/src/commands/GridOnProFilePara.cpp +++ b/lib/Hoymiles/src/commands/GridOnProFilePara.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -22,8 +22,8 @@ ID Target Addr Source Addr Idx DT ? Time Gap Pa #include "Hoymiles.h" #include "inverters/InverterAbstract.h" -GridOnProFilePara::GridOnProFilePara(const uint64_t target_address, const uint64_t router_address, const time_t time) - : MultiDataCommand(target_address, router_address) +GridOnProFilePara::GridOnProFilePara(InverterAbstract* inv, const uint64_t router_address, const time_t time) + : MultiDataCommand(inv, router_address) { setTime(time); setDataType(0x02); @@ -53,4 +53,4 @@ bool GridOnProFilePara::handleResponse(InverterAbstract& inverter, const fragmen inverter.GridProfile()->endAppendFragment(); inverter.GridProfile()->setLastUpdate(millis()); return true; -} \ No newline at end of file +} diff --git a/lib/Hoymiles/src/commands/GridOnProFilePara.h b/lib/Hoymiles/src/commands/GridOnProFilePara.h index 382ebcbb1..79df22329 100644 --- a/lib/Hoymiles/src/commands/GridOnProFilePara.h +++ b/lib/Hoymiles/src/commands/GridOnProFilePara.h @@ -5,9 +5,9 @@ class GridOnProFilePara : public MultiDataCommand { public: - explicit GridOnProFilePara(const uint64_t target_address = 0, const uint64_t router_address = 0, const time_t time = 0); + explicit GridOnProFilePara(InverterAbstract* inv, const uint64_t router_address = 0, const time_t time = 0); virtual String getCommandName() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/commands/MultiDataCommand.cpp b/lib/Hoymiles/src/commands/MultiDataCommand.cpp index bbd320916..ff8a40041 100644 --- a/lib/Hoymiles/src/commands/MultiDataCommand.cpp +++ b/lib/Hoymiles/src/commands/MultiDataCommand.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -28,8 +28,9 @@ ID Target Addr Source Addr Idx DT ? Time Gap Pa #include "MultiDataCommand.h" #include "crc.h" -MultiDataCommand::MultiDataCommand(const uint64_t target_address, const uint64_t router_address, const uint8_t data_type, const time_t time) - : CommandAbstract(target_address, router_address) +MultiDataCommand::MultiDataCommand(InverterAbstract* inv, const uint64_t router_address, const uint8_t data_type, const time_t time) + : CommandAbstract(inv, router_address) + , _cmdRequestFrame(inv) { _payload[0] = 0x15; _payload[9] = 0x80; @@ -79,7 +80,6 @@ time_t MultiDataCommand::getTime() const CommandAbstract* MultiDataCommand::getRequestFrameCommand(const uint8_t frame_no) { - _cmdRequestFrame.setTargetAddress(getTargetAddress()); _cmdRequestFrame.setFrameNo(frame_no); return &_cmdRequestFrame; diff --git a/lib/Hoymiles/src/commands/MultiDataCommand.h b/lib/Hoymiles/src/commands/MultiDataCommand.h index 821074745..4fddd098e 100644 --- a/lib/Hoymiles/src/commands/MultiDataCommand.h +++ b/lib/Hoymiles/src/commands/MultiDataCommand.h @@ -7,7 +7,7 @@ class MultiDataCommand : public CommandAbstract { public: - explicit MultiDataCommand(const uint64_t target_address = 0, const uint64_t router_address = 0, const uint8_t data_type = 0, const time_t time = 0); + explicit MultiDataCommand(InverterAbstract* inv, const uint64_t router_address = 0, const uint8_t data_type = 0, const time_t time = 0); void setTime(const time_t time); time_t getTime() const; @@ -23,4 +23,4 @@ class MultiDataCommand : public CommandAbstract { static uint8_t getTotalFragmentSize(const fragment_t fragment[], const uint8_t max_fragment_id); RequestFrameCommand _cmdRequestFrame; -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/commands/ParaSetCommand.cpp b/lib/Hoymiles/src/commands/ParaSetCommand.cpp index a33749450..8b71867bd 100644 --- a/lib/Hoymiles/src/commands/ParaSetCommand.cpp +++ b/lib/Hoymiles/src/commands/ParaSetCommand.cpp @@ -1,11 +1,11 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ #include "ParaSetCommand.h" -ParaSetCommand::ParaSetCommand(const uint64_t target_address, const uint64_t router_address) - : CommandAbstract(target_address, router_address) +ParaSetCommand::ParaSetCommand(InverterAbstract* inv, const uint64_t router_address) + : CommandAbstract(inv, router_address) { _payload[0] = 0x52; -} \ No newline at end of file +} diff --git a/lib/Hoymiles/src/commands/ParaSetCommand.h b/lib/Hoymiles/src/commands/ParaSetCommand.h index 424d0e373..224aba390 100644 --- a/lib/Hoymiles/src/commands/ParaSetCommand.h +++ b/lib/Hoymiles/src/commands/ParaSetCommand.h @@ -5,5 +5,5 @@ class ParaSetCommand : public CommandAbstract { public: - explicit ParaSetCommand(const uint64_t target_address = 0, const uint64_t router_address = 0); -}; \ No newline at end of file + explicit ParaSetCommand(InverterAbstract* inv, const uint64_t router_address = 0); +}; diff --git a/lib/Hoymiles/src/commands/PowerControlCommand.cpp b/lib/Hoymiles/src/commands/PowerControlCommand.cpp index fbf12db80..b8340841c 100644 --- a/lib/Hoymiles/src/commands/PowerControlCommand.cpp +++ b/lib/Hoymiles/src/commands/PowerControlCommand.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -26,8 +26,8 @@ ID Target Addr Source Addr Cmd SCmd ? CRC16 CRC8 #define CRC_SIZE 2 -PowerControlCommand::PowerControlCommand(const uint64_t target_address, const uint64_t router_address) - : DevControlCommand(target_address, router_address) +PowerControlCommand::PowerControlCommand(InverterAbstract* inv, const uint64_t router_address) + : DevControlCommand(inv, router_address) { _payload[10] = 0x00; // TurnOn _payload[11] = 0x00; @@ -76,4 +76,4 @@ void PowerControlCommand::setRestart() _payload[10] = 0x02; // Restart udpateCRC(CRC_SIZE); // 2 byte crc -} \ No newline at end of file +} diff --git a/lib/Hoymiles/src/commands/PowerControlCommand.h b/lib/Hoymiles/src/commands/PowerControlCommand.h index 8b9f11ac4..e792f3b3c 100644 --- a/lib/Hoymiles/src/commands/PowerControlCommand.h +++ b/lib/Hoymiles/src/commands/PowerControlCommand.h @@ -5,7 +5,7 @@ class PowerControlCommand : public DevControlCommand { public: - explicit PowerControlCommand(const uint64_t target_address = 0, const uint64_t router_address = 0); + explicit PowerControlCommand(InverterAbstract* inv, const uint64_t router_address = 0); virtual String getCommandName() const; @@ -14,4 +14,4 @@ class PowerControlCommand : public DevControlCommand { void setPowerOn(const bool state); void setRestart(); -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp index 5f04c948b..bc800b992 100644 --- a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp +++ b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -22,8 +22,8 @@ ID Target Addr Source Addr Idx DT ? Time Gap Pa #include "Hoymiles.h" #include "inverters/InverterAbstract.h" -RealTimeRunDataCommand::RealTimeRunDataCommand(const uint64_t target_address, const uint64_t router_address, const time_t time) - : MultiDataCommand(target_address, router_address) +RealTimeRunDataCommand::RealTimeRunDataCommand(InverterAbstract* inv, const uint64_t router_address, const time_t time) + : MultiDataCommand(inv, router_address) { setTime(time); setDataType(0x0b); @@ -71,4 +71,4 @@ bool RealTimeRunDataCommand::handleResponse(InverterAbstract& inverter, const fr void RealTimeRunDataCommand::gotTimeout(InverterAbstract& inverter) { inverter.Statistics()->incrementRxFailureCount(); -} \ No newline at end of file +} diff --git a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h index 7a0eeec14..82c4f3774 100644 --- a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h +++ b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h @@ -5,10 +5,10 @@ class RealTimeRunDataCommand : public MultiDataCommand { public: - explicit RealTimeRunDataCommand(const uint64_t target_address = 0, const uint64_t router_address = 0, const time_t time = 0); + explicit RealTimeRunDataCommand(InverterAbstract* inv, const uint64_t router_address = 0, const time_t time = 0); virtual String getCommandName() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); virtual void gotTimeout(InverterAbstract& inverter); -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/commands/RequestFrameCommand.cpp b/lib/Hoymiles/src/commands/RequestFrameCommand.cpp index 68c4977f7..deffe63b3 100644 --- a/lib/Hoymiles/src/commands/RequestFrameCommand.cpp +++ b/lib/Hoymiles/src/commands/RequestFrameCommand.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -22,8 +22,8 @@ ID Target Addr Source Addr Frm CRC8 */ #include "RequestFrameCommand.h" -RequestFrameCommand::RequestFrameCommand(const uint64_t target_address, const uint64_t router_address, uint8_t frame_no) - : SingleDataCommand(target_address, router_address) +RequestFrameCommand::RequestFrameCommand(InverterAbstract* inv, const uint64_t router_address, uint8_t frame_no) + : SingleDataCommand(inv, router_address) { if (frame_no > 127) { frame_no = 0; @@ -50,4 +50,4 @@ uint8_t RequestFrameCommand::getFrameNo() const bool RequestFrameCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) { return true; -} \ No newline at end of file +} diff --git a/lib/Hoymiles/src/commands/RequestFrameCommand.h b/lib/Hoymiles/src/commands/RequestFrameCommand.h index 92663b708..577ab9d07 100644 --- a/lib/Hoymiles/src/commands/RequestFrameCommand.h +++ b/lib/Hoymiles/src/commands/RequestFrameCommand.h @@ -5,7 +5,7 @@ class RequestFrameCommand : public SingleDataCommand { public: - explicit RequestFrameCommand(const uint64_t target_address = 0, const uint64_t router_address = 0, uint8_t frame_no = 0); + explicit RequestFrameCommand(InverterAbstract* inv, const uint64_t router_address = 0, uint8_t frame_no = 0); virtual String getCommandName() const; @@ -13,4 +13,4 @@ class RequestFrameCommand : public SingleDataCommand { uint8_t getFrameNo() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/commands/SingleDataCommand.cpp b/lib/Hoymiles/src/commands/SingleDataCommand.cpp index 4f775146d..3b648814a 100644 --- a/lib/Hoymiles/src/commands/SingleDataCommand.cpp +++ b/lib/Hoymiles/src/commands/SingleDataCommand.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -19,8 +19,8 @@ ID Target Addr Source Addr CRC8 */ #include "SingleDataCommand.h" -SingleDataCommand::SingleDataCommand(const uint64_t target_address, const uint64_t router_address) - : CommandAbstract(target_address, router_address) +SingleDataCommand::SingleDataCommand(InverterAbstract* inv, const uint64_t router_address) + : CommandAbstract(inv, router_address) { _payload[0] = 0x15; setTimeout(100); diff --git a/lib/Hoymiles/src/commands/SingleDataCommand.h b/lib/Hoymiles/src/commands/SingleDataCommand.h index d05151691..39f3c480c 100644 --- a/lib/Hoymiles/src/commands/SingleDataCommand.h +++ b/lib/Hoymiles/src/commands/SingleDataCommand.h @@ -5,5 +5,5 @@ class SingleDataCommand : public CommandAbstract { public: - explicit SingleDataCommand(const uint64_t target_address = 0, const uint64_t router_address = 0); -}; \ No newline at end of file + explicit SingleDataCommand(InverterAbstract* inv, const uint64_t router_address = 0); +}; diff --git a/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp b/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp index 0c8e7ded7..7149394cf 100644 --- a/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp +++ b/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ /* @@ -22,8 +22,8 @@ ID Target Addr Source Addr Idx DT ? Time Gap Pa #include "Hoymiles.h" #include "inverters/InverterAbstract.h" -SystemConfigParaCommand::SystemConfigParaCommand(const uint64_t target_address, const uint64_t router_address, const time_t time) - : MultiDataCommand(target_address, router_address) +SystemConfigParaCommand::SystemConfigParaCommand(InverterAbstract* inv, const uint64_t router_address, const time_t time) + : MultiDataCommand(inv, router_address) { setTime(time); setDataType(0x05); @@ -71,4 +71,4 @@ bool SystemConfigParaCommand::handleResponse(InverterAbstract& inverter, const f void SystemConfigParaCommand::gotTimeout(InverterAbstract& inverter) { inverter.SystemConfigPara()->setLastLimitRequestSuccess(CMD_NOK); -} \ No newline at end of file +} diff --git a/lib/Hoymiles/src/commands/SystemConfigParaCommand.h b/lib/Hoymiles/src/commands/SystemConfigParaCommand.h index e2480a973..ee6b3b95b 100644 --- a/lib/Hoymiles/src/commands/SystemConfigParaCommand.h +++ b/lib/Hoymiles/src/commands/SystemConfigParaCommand.h @@ -5,10 +5,10 @@ class SystemConfigParaCommand : public MultiDataCommand { public: - explicit SystemConfigParaCommand(const uint64_t target_address = 0, const uint64_t router_address = 0, const time_t time = 0); + explicit SystemConfigParaCommand(InverterAbstract* inv, const uint64_t router_address = 0, const time_t time = 0); virtual String getCommandName() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); virtual void gotTimeout(InverterAbstract& inverter); -}; \ No newline at end of file +}; diff --git a/lib/Hoymiles/src/inverters/HMS_Abstract.cpp b/lib/Hoymiles/src/inverters/HMS_Abstract.cpp index ffa7d7219..4fc64b036 100644 --- a/lib/Hoymiles/src/inverters/HMS_Abstract.cpp +++ b/lib/Hoymiles/src/inverters/HMS_Abstract.cpp @@ -18,10 +18,9 @@ bool HMS_Abstract::sendChangeChannelRequest() return false; } - auto cmdChannel = _radio->prepareCommand(); + auto cmdChannel = _radio->prepareCommand(this); cmdChannel->setCountryMode(Hoymiles.getRadioCmt()->getCountryMode()); cmdChannel->setChannel(Hoymiles.getRadioCmt()->getChannelFromFrequency(Hoymiles.getRadioCmt()->getInverterTargetFrequency())); - cmdChannel->setTargetAddress(serial()); _radio->enqueCommand(cmdChannel); return true; diff --git a/lib/Hoymiles/src/inverters/HMT_Abstract.cpp b/lib/Hoymiles/src/inverters/HMT_Abstract.cpp index 5c232b900..50c895cc6 100644 --- a/lib/Hoymiles/src/inverters/HMT_Abstract.cpp +++ b/lib/Hoymiles/src/inverters/HMT_Abstract.cpp @@ -20,10 +20,9 @@ bool HMT_Abstract::sendChangeChannelRequest() return false; } - auto cmdChannel = _radio->prepareCommand(); + auto cmdChannel = _radio->prepareCommand(this); cmdChannel->setCountryMode(Hoymiles.getRadioCmt()->getCountryMode()); cmdChannel->setChannel(Hoymiles.getRadioCmt()->getChannelFromFrequency(Hoymiles.getRadioCmt()->getInverterTargetFrequency())); - cmdChannel->setTargetAddress(serial()); _radio->enqueCommand(cmdChannel); return true; diff --git a/lib/Hoymiles/src/inverters/HM_Abstract.cpp b/lib/Hoymiles/src/inverters/HM_Abstract.cpp index 62f5af297..45efc99db 100644 --- a/lib/Hoymiles/src/inverters/HM_Abstract.cpp +++ b/lib/Hoymiles/src/inverters/HM_Abstract.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ #include "HM_Abstract.h" #include "HoymilesRadio.h" @@ -30,9 +30,8 @@ bool HM_Abstract::sendStatsRequest() time_t now; time(&now); - auto cmd = _radio->prepareCommand(); + auto cmd = _radio->prepareCommand(this); cmd->setTime(now); - cmd->setTargetAddress(serial()); _radio->enqueCommand(cmd); return true; @@ -62,9 +61,8 @@ bool HM_Abstract::sendAlarmLogRequest(const bool force) time_t now; time(&now); - auto cmd = _radio->prepareCommand(); + auto cmd = _radio->prepareCommand(this); cmd->setTime(now); - cmd->setTargetAddress(serial()); EventLog()->setLastAlarmRequestSuccess(CMD_PENDING); _radio->enqueCommand(cmd); @@ -85,14 +83,12 @@ bool HM_Abstract::sendDevInfoRequest() time_t now; time(&now); - auto cmdAll = _radio->prepareCommand(); + auto cmdAll = _radio->prepareCommand(this); cmdAll->setTime(now); - cmdAll->setTargetAddress(serial()); _radio->enqueCommand(cmdAll); - auto cmdSimple = _radio->prepareCommand(); + auto cmdSimple = _radio->prepareCommand(this); cmdSimple->setTime(now); - cmdSimple->setTargetAddress(serial()); _radio->enqueCommand(cmdSimple); return true; @@ -112,9 +108,8 @@ bool HM_Abstract::sendSystemConfigParaRequest() time_t now; time(&now); - auto cmd = _radio->prepareCommand(); + auto cmd = _radio->prepareCommand(this); cmd->setTime(now); - cmd->setTargetAddress(serial()); SystemConfigPara()->setLastLimitRequestSuccess(CMD_PENDING); _radio->enqueCommand(cmd); @@ -134,9 +129,8 @@ bool HM_Abstract::sendActivePowerControlRequest(float limit, const PowerLimitCon _activePowerControlLimit = limit; _activePowerControlType = type; - auto cmd = _radio->prepareCommand(); + auto cmd = _radio->prepareCommand(this); cmd->setActivePowerLimit(limit, type); - cmd->setTargetAddress(serial()); SystemConfigPara()->setLastLimitCommandSuccess(CMD_PENDING); _radio->enqueCommand(cmd); @@ -160,9 +154,8 @@ bool HM_Abstract::sendPowerControlRequest(const bool turnOn) _powerState = 0; } - auto cmd = _radio->prepareCommand(); + auto cmd = _radio->prepareCommand(this); cmd->setPowerOn(turnOn); - cmd->setTargetAddress(serial()); PowerCommand()->setLastPowerCommandSuccess(CMD_PENDING); _radio->enqueCommand(cmd); @@ -177,9 +170,8 @@ bool HM_Abstract::sendRestartControlRequest() _powerState = 2; - auto cmd = _radio->prepareCommand(); + auto cmd = _radio->prepareCommand(this); cmd->setRestart(); - cmd->setTargetAddress(serial()); PowerCommand()->setLastPowerCommandSuccess(CMD_PENDING); _radio->enqueCommand(cmd); @@ -219,9 +211,8 @@ bool HM_Abstract::sendGridOnProFileParaRequest() time_t now; time(&now); - auto cmd = _radio->prepareCommand(); + auto cmd = _radio->prepareCommand(this); cmd->setTime(now); - cmd->setTargetAddress(serial()); _radio->enqueCommand(cmd); return true; From 6d6d62bb770101afbe0cb5a6f7bee905dec5b735 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Thu, 16 May 2024 19:55:01 +0200 Subject: [PATCH 004/140] Code Refactoring: Use internal inverter instance in gotTimeout method --- .../src/commands/ActivePowerControlCommand.cpp | 4 ++-- lib/Hoymiles/src/commands/ActivePowerControlCommand.h | 2 +- lib/Hoymiles/src/commands/AlarmDataCommand.cpp | 4 ++-- lib/Hoymiles/src/commands/AlarmDataCommand.h | 2 +- lib/Hoymiles/src/commands/CommandAbstract.cpp | 2 +- lib/Hoymiles/src/commands/CommandAbstract.h | 2 +- lib/Hoymiles/src/commands/PowerControlCommand.cpp | 4 ++-- lib/Hoymiles/src/commands/PowerControlCommand.h | 2 +- lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp | 4 ++-- lib/Hoymiles/src/commands/RealTimeRunDataCommand.h | 2 +- lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp | 4 ++-- lib/Hoymiles/src/commands/SystemConfigParaCommand.h | 2 +- lib/Hoymiles/src/inverters/InverterAbstract.cpp | 10 +++++----- 13 files changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp b/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp index f1f670b34..417c65b00 100644 --- a/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp +++ b/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp @@ -94,7 +94,7 @@ PowerLimitControlType ActivePowerControlCommand::getType() return (PowerLimitControlType)(((uint16_t)_payload[14] << 8) | _payload[15]); } -void ActivePowerControlCommand::gotTimeout(InverterAbstract& inverter) +void ActivePowerControlCommand::gotTimeout() { - inverter.SystemConfigPara()->setLastLimitCommandSuccess(CMD_NOK); + _inv->SystemConfigPara()->setLastLimitCommandSuccess(CMD_NOK); } diff --git a/lib/Hoymiles/src/commands/ActivePowerControlCommand.h b/lib/Hoymiles/src/commands/ActivePowerControlCommand.h index 78176796f..b4ef0a7be 100644 --- a/lib/Hoymiles/src/commands/ActivePowerControlCommand.h +++ b/lib/Hoymiles/src/commands/ActivePowerControlCommand.h @@ -17,7 +17,7 @@ class ActivePowerControlCommand : public DevControlCommand { virtual String getCommandName() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); - virtual void gotTimeout(InverterAbstract& inverter); + virtual void gotTimeout(); void setActivePowerLimit(const float limit, const PowerLimitControlType type = RelativNonPersistent); float getLimit() const; diff --git a/lib/Hoymiles/src/commands/AlarmDataCommand.cpp b/lib/Hoymiles/src/commands/AlarmDataCommand.cpp index 350d0f925..bbe35e297 100644 --- a/lib/Hoymiles/src/commands/AlarmDataCommand.cpp +++ b/lib/Hoymiles/src/commands/AlarmDataCommand.cpp @@ -57,7 +57,7 @@ bool AlarmDataCommand::handleResponse(InverterAbstract& inverter, const fragment return true; } -void AlarmDataCommand::gotTimeout(InverterAbstract& inverter) +void AlarmDataCommand::gotTimeout() { - inverter.EventLog()->setLastAlarmRequestSuccess(CMD_NOK); + _inv->EventLog()->setLastAlarmRequestSuccess(CMD_NOK); } diff --git a/lib/Hoymiles/src/commands/AlarmDataCommand.h b/lib/Hoymiles/src/commands/AlarmDataCommand.h index f5248b1ff..5b939d53a 100644 --- a/lib/Hoymiles/src/commands/AlarmDataCommand.h +++ b/lib/Hoymiles/src/commands/AlarmDataCommand.h @@ -10,5 +10,5 @@ class AlarmDataCommand : public MultiDataCommand { virtual String getCommandName() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); - virtual void gotTimeout(InverterAbstract& inverter); + virtual void gotTimeout(); }; diff --git a/lib/Hoymiles/src/commands/CommandAbstract.cpp b/lib/Hoymiles/src/commands/CommandAbstract.cpp index 15102fbbb..16a7857e1 100644 --- a/lib/Hoymiles/src/commands/CommandAbstract.cpp +++ b/lib/Hoymiles/src/commands/CommandAbstract.cpp @@ -125,7 +125,7 @@ void CommandAbstract::convertSerialToPacketId(uint8_t buffer[], const uint64_t s buffer[0] = s.b[3]; } -void CommandAbstract::gotTimeout(InverterAbstract& inverter) +void CommandAbstract::gotTimeout() { } diff --git a/lib/Hoymiles/src/commands/CommandAbstract.h b/lib/Hoymiles/src/commands/CommandAbstract.h index b543ed668..a9f816807 100644 --- a/lib/Hoymiles/src/commands/CommandAbstract.h +++ b/lib/Hoymiles/src/commands/CommandAbstract.h @@ -38,7 +38,7 @@ class CommandAbstract { virtual CommandAbstract* getRequestFrameCommand(const uint8_t frame_no); virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) = 0; - virtual void gotTimeout(InverterAbstract& inverter); + virtual void gotTimeout(); // Sets the amount how often the specific command is resent if all fragments where missing virtual uint8_t getMaxResendCount() const; diff --git a/lib/Hoymiles/src/commands/PowerControlCommand.cpp b/lib/Hoymiles/src/commands/PowerControlCommand.cpp index b8340841c..cfa61d5e1 100644 --- a/lib/Hoymiles/src/commands/PowerControlCommand.cpp +++ b/lib/Hoymiles/src/commands/PowerControlCommand.cpp @@ -55,9 +55,9 @@ bool PowerControlCommand::handleResponse(InverterAbstract& inverter, const fragm return true; } -void PowerControlCommand::gotTimeout(InverterAbstract& inverter) +void PowerControlCommand::gotTimeout() { - inverter.PowerCommand()->setLastPowerCommandSuccess(CMD_NOK); + _inv->PowerCommand()->setLastPowerCommandSuccess(CMD_NOK); } void PowerControlCommand::setPowerOn(const bool state) diff --git a/lib/Hoymiles/src/commands/PowerControlCommand.h b/lib/Hoymiles/src/commands/PowerControlCommand.h index e792f3b3c..a7bf019b3 100644 --- a/lib/Hoymiles/src/commands/PowerControlCommand.h +++ b/lib/Hoymiles/src/commands/PowerControlCommand.h @@ -10,7 +10,7 @@ class PowerControlCommand : public DevControlCommand { virtual String getCommandName() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); - virtual void gotTimeout(InverterAbstract& inverter); + virtual void gotTimeout(); void setPowerOn(const bool state); void setRestart(); diff --git a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp index bc800b992..2d6919dd5 100644 --- a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp +++ b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp @@ -68,7 +68,7 @@ bool RealTimeRunDataCommand::handleResponse(InverterAbstract& inverter, const fr return true; } -void RealTimeRunDataCommand::gotTimeout(InverterAbstract& inverter) +void RealTimeRunDataCommand::gotTimeout() { - inverter.Statistics()->incrementRxFailureCount(); + _inv->Statistics()->incrementRxFailureCount(); } diff --git a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h index 82c4f3774..e41bdbf26 100644 --- a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h +++ b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h @@ -10,5 +10,5 @@ class RealTimeRunDataCommand : public MultiDataCommand { virtual String getCommandName() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); - virtual void gotTimeout(InverterAbstract& inverter); + virtual void gotTimeout(); }; diff --git a/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp b/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp index 7149394cf..9e3f89a57 100644 --- a/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp +++ b/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp @@ -68,7 +68,7 @@ bool SystemConfigParaCommand::handleResponse(InverterAbstract& inverter, const f return true; } -void SystemConfigParaCommand::gotTimeout(InverterAbstract& inverter) +void SystemConfigParaCommand::gotTimeout() { - inverter.SystemConfigPara()->setLastLimitRequestSuccess(CMD_NOK); + _inv->SystemConfigPara()->setLastLimitRequestSuccess(CMD_NOK); } diff --git a/lib/Hoymiles/src/commands/SystemConfigParaCommand.h b/lib/Hoymiles/src/commands/SystemConfigParaCommand.h index ee6b3b95b..f33e84812 100644 --- a/lib/Hoymiles/src/commands/SystemConfigParaCommand.h +++ b/lib/Hoymiles/src/commands/SystemConfigParaCommand.h @@ -10,5 +10,5 @@ class SystemConfigParaCommand : public MultiDataCommand { virtual String getCommandName() const; virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); - virtual void gotTimeout(InverterAbstract& inverter); + virtual void gotTimeout(); }; diff --git a/lib/Hoymiles/src/inverters/InverterAbstract.cpp b/lib/Hoymiles/src/inverters/InverterAbstract.cpp index d80d0e531..6f7571988 100644 --- a/lib/Hoymiles/src/inverters/InverterAbstract.cpp +++ b/lib/Hoymiles/src/inverters/InverterAbstract.cpp @@ -226,7 +226,7 @@ uint8_t InverterAbstract::verifyAllFragments(CommandAbstract& cmd) if (cmd.getSendCount() <= cmd.getMaxResendCount()) { return FRAGMENT_ALL_MISSING_RESEND; } else { - cmd.gotTimeout(*this); + cmd.gotTimeout(); return FRAGMENT_ALL_MISSING_TIMEOUT; } } @@ -237,7 +237,7 @@ uint8_t InverterAbstract::verifyAllFragments(CommandAbstract& cmd) if (_rxFragmentRetransmitCnt++ < cmd.getMaxRetransmitCount()) { return _rxFragmentLastPacketId + 1; } else { - cmd.gotTimeout(*this); + cmd.gotTimeout(); return FRAGMENT_RETRANSMIT_TIMEOUT; } } @@ -249,16 +249,16 @@ uint8_t InverterAbstract::verifyAllFragments(CommandAbstract& cmd) if (_rxFragmentRetransmitCnt++ < cmd.getMaxRetransmitCount()) { return i + 1; } else { - cmd.gotTimeout(*this); + cmd.gotTimeout(); return FRAGMENT_RETRANSMIT_TIMEOUT; } } } if (!cmd.handleResponse(*this, _rxFragmentBuffer, _rxFragmentMaxPacketId)) { - cmd.gotTimeout(*this); + cmd.gotTimeout(); return FRAGMENT_HANDLE_ERROR; } return FRAGMENT_OK; -} \ No newline at end of file +} From 90711ddd76d3306e91accecda0bf6bfcd750db4d Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Thu, 16 May 2024 19:58:20 +0200 Subject: [PATCH 005/140] Code Refactoring: Use internal inverter instance in handleResponse method --- .../src/commands/ActivePowerControlCommand.cpp | 14 +++++++------- .../src/commands/ActivePowerControlCommand.h | 2 +- lib/Hoymiles/src/commands/AlarmDataCommand.cpp | 16 ++++++++-------- lib/Hoymiles/src/commands/AlarmDataCommand.h | 2 +- .../src/commands/ChannelChangeCommand.cpp | 2 +- .../src/commands/ChannelChangeCommand.h | 2 +- lib/Hoymiles/src/commands/CommandAbstract.h | 2 +- .../src/commands/DevControlCommand.cpp | 2 +- lib/Hoymiles/src/commands/DevControlCommand.h | 2 +- .../src/commands/DevInfoAllCommand.cpp | 14 +++++++------- lib/Hoymiles/src/commands/DevInfoAllCommand.h | 2 +- .../src/commands/DevInfoSimpleCommand.cpp | 14 +++++++------- .../src/commands/DevInfoSimpleCommand.h | 2 +- .../src/commands/GridOnProFilePara.cpp | 14 +++++++------- lib/Hoymiles/src/commands/GridOnProFilePara.h | 2 +- lib/Hoymiles/src/commands/MultiDataCommand.cpp | 2 +- lib/Hoymiles/src/commands/MultiDataCommand.h | 2 +- .../src/commands/PowerControlCommand.cpp | 8 ++++---- .../src/commands/PowerControlCommand.h | 2 +- .../src/commands/RealTimeRunDataCommand.cpp | 18 +++++++++--------- .../src/commands/RealTimeRunDataCommand.h | 2 +- .../src/commands/RequestFrameCommand.cpp | 2 +- .../src/commands/RequestFrameCommand.h | 2 +- .../src/commands/SystemConfigParaCommand.cpp | 18 +++++++++--------- .../src/commands/SystemConfigParaCommand.h | 2 +- .../src/inverters/InverterAbstract.cpp | 2 +- 26 files changed, 76 insertions(+), 76 deletions(-) diff --git a/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp b/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp index 417c65b00..b9e8eea26 100644 --- a/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp +++ b/lib/Hoymiles/src/commands/ActivePowerControlCommand.cpp @@ -62,24 +62,24 @@ void ActivePowerControlCommand::setActivePowerLimit(const float limit, const Pow udpateCRC(CRC_SIZE); } -bool ActivePowerControlCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool ActivePowerControlCommand::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { - if (!DevControlCommand::handleResponse(inverter, fragment, max_fragment_id)) { + if (!DevControlCommand::handleResponse(fragment, max_fragment_id)) { return false; } if ((getType() == PowerLimitControlType::RelativNonPersistent) || (getType() == PowerLimitControlType::RelativPersistent)) { - inverter.SystemConfigPara()->setLimitPercent(getLimit()); + _inv->SystemConfigPara()->setLimitPercent(getLimit()); } else { - const uint16_t max_power = inverter.DevInfo()->getMaxPower(); + const uint16_t max_power = _inv->DevInfo()->getMaxPower(); if (max_power > 0) { - inverter.SystemConfigPara()->setLimitPercent(static_cast(getLimit()) / max_power * 100); + _inv->SystemConfigPara()->setLimitPercent(static_cast(getLimit()) / max_power * 100); } else { // TODO(tbnobody): Not implemented yet because we only can publish the percentage value } } - inverter.SystemConfigPara()->setLastUpdateCommand(millis()); - inverter.SystemConfigPara()->setLastLimitCommandSuccess(CMD_OK); + _inv->SystemConfigPara()->setLastUpdateCommand(millis()); + _inv->SystemConfigPara()->setLastLimitCommandSuccess(CMD_OK); return true; } diff --git a/lib/Hoymiles/src/commands/ActivePowerControlCommand.h b/lib/Hoymiles/src/commands/ActivePowerControlCommand.h index b4ef0a7be..375b278bb 100644 --- a/lib/Hoymiles/src/commands/ActivePowerControlCommand.h +++ b/lib/Hoymiles/src/commands/ActivePowerControlCommand.h @@ -16,7 +16,7 @@ class ActivePowerControlCommand : public DevControlCommand { virtual String getCommandName() const; - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); virtual void gotTimeout(); void setActivePowerLimit(const float limit, const PowerLimitControlType type = RelativNonPersistent); diff --git a/lib/Hoymiles/src/commands/AlarmDataCommand.cpp b/lib/Hoymiles/src/commands/AlarmDataCommand.cpp index bbe35e297..98a97d0b1 100644 --- a/lib/Hoymiles/src/commands/AlarmDataCommand.cpp +++ b/lib/Hoymiles/src/commands/AlarmDataCommand.cpp @@ -36,24 +36,24 @@ String AlarmDataCommand::getCommandName() const return "AlarmData"; } -bool AlarmDataCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool AlarmDataCommand::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { // Check CRC of whole payload - if (!MultiDataCommand::handleResponse(inverter, fragment, max_fragment_id)) { + if (!MultiDataCommand::handleResponse(fragment, max_fragment_id)) { return false; } // Move all fragments into target buffer uint8_t offs = 0; - inverter.EventLog()->beginAppendFragment(); - inverter.EventLog()->clearBuffer(); + _inv->EventLog()->beginAppendFragment(); + _inv->EventLog()->clearBuffer(); for (uint8_t i = 0; i < max_fragment_id; i++) { - inverter.EventLog()->appendFragment(offs, fragment[i].fragment, fragment[i].len); + _inv->EventLog()->appendFragment(offs, fragment[i].fragment, fragment[i].len); offs += (fragment[i].len); } - inverter.EventLog()->endAppendFragment(); - inverter.EventLog()->setLastAlarmRequestSuccess(CMD_OK); - inverter.EventLog()->setLastUpdate(millis()); + _inv->EventLog()->endAppendFragment(); + _inv->EventLog()->setLastAlarmRequestSuccess(CMD_OK); + _inv->EventLog()->setLastUpdate(millis()); return true; } diff --git a/lib/Hoymiles/src/commands/AlarmDataCommand.h b/lib/Hoymiles/src/commands/AlarmDataCommand.h index 5b939d53a..ef8404c30 100644 --- a/lib/Hoymiles/src/commands/AlarmDataCommand.h +++ b/lib/Hoymiles/src/commands/AlarmDataCommand.h @@ -9,6 +9,6 @@ class AlarmDataCommand : public MultiDataCommand { virtual String getCommandName() const; - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); virtual void gotTimeout(); }; diff --git a/lib/Hoymiles/src/commands/ChannelChangeCommand.cpp b/lib/Hoymiles/src/commands/ChannelChangeCommand.cpp index f044d9d50..ad89f2d5f 100644 --- a/lib/Hoymiles/src/commands/ChannelChangeCommand.cpp +++ b/lib/Hoymiles/src/commands/ChannelChangeCommand.cpp @@ -67,7 +67,7 @@ void ChannelChangeCommand::setCountryMode(const CountryModeId_t mode) } } -bool ChannelChangeCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool ChannelChangeCommand::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { return true; } diff --git a/lib/Hoymiles/src/commands/ChannelChangeCommand.h b/lib/Hoymiles/src/commands/ChannelChangeCommand.h index 2b4a5aad4..70b5f64c7 100644 --- a/lib/Hoymiles/src/commands/ChannelChangeCommand.h +++ b/lib/Hoymiles/src/commands/ChannelChangeCommand.h @@ -15,7 +15,7 @@ class ChannelChangeCommand : public CommandAbstract { void setCountryMode(const CountryModeId_t mode); - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); virtual uint8_t getMaxResendCount(); }; diff --git a/lib/Hoymiles/src/commands/CommandAbstract.h b/lib/Hoymiles/src/commands/CommandAbstract.h index a9f816807..c93cb3416 100644 --- a/lib/Hoymiles/src/commands/CommandAbstract.h +++ b/lib/Hoymiles/src/commands/CommandAbstract.h @@ -37,7 +37,7 @@ class CommandAbstract { virtual CommandAbstract* getRequestFrameCommand(const uint8_t frame_no); - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) = 0; + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) = 0; virtual void gotTimeout(); // Sets the amount how often the specific command is resent if all fragments where missing diff --git a/lib/Hoymiles/src/commands/DevControlCommand.cpp b/lib/Hoymiles/src/commands/DevControlCommand.cpp index abf5f529f..b73f74f0c 100644 --- a/lib/Hoymiles/src/commands/DevControlCommand.cpp +++ b/lib/Hoymiles/src/commands/DevControlCommand.cpp @@ -39,7 +39,7 @@ void DevControlCommand::udpateCRC(const uint8_t len) _payload[10 + len + 1] = (uint8_t)(crc); } -bool DevControlCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool DevControlCommand::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { for (uint8_t i = 0; i < max_fragment_id; i++) { if (fragment[i].mainCmd != (_payload[0] | 0x80)) { diff --git a/lib/Hoymiles/src/commands/DevControlCommand.h b/lib/Hoymiles/src/commands/DevControlCommand.h index 1fb1361dc..7e7637edc 100644 --- a/lib/Hoymiles/src/commands/DevControlCommand.h +++ b/lib/Hoymiles/src/commands/DevControlCommand.h @@ -7,7 +7,7 @@ class DevControlCommand : public CommandAbstract { public: explicit DevControlCommand(InverterAbstract* inv, const uint64_t router_address = 0); - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); protected: void udpateCRC(const uint8_t len); diff --git a/lib/Hoymiles/src/commands/DevInfoAllCommand.cpp b/lib/Hoymiles/src/commands/DevInfoAllCommand.cpp index be544886c..8a258ac20 100644 --- a/lib/Hoymiles/src/commands/DevInfoAllCommand.cpp +++ b/lib/Hoymiles/src/commands/DevInfoAllCommand.cpp @@ -34,22 +34,22 @@ String DevInfoAllCommand::getCommandName() const return "DevInfoAll"; } -bool DevInfoAllCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool DevInfoAllCommand::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { // Check CRC of whole payload - if (!MultiDataCommand::handleResponse(inverter, fragment, max_fragment_id)) { + if (!MultiDataCommand::handleResponse(fragment, max_fragment_id)) { return false; } // Move all fragments into target buffer uint8_t offs = 0; - inverter.DevInfo()->beginAppendFragment(); - inverter.DevInfo()->clearBufferAll(); + _inv->DevInfo()->beginAppendFragment(); + _inv->DevInfo()->clearBufferAll(); for (uint8_t i = 0; i < max_fragment_id; i++) { - inverter.DevInfo()->appendFragmentAll(offs, fragment[i].fragment, fragment[i].len); + _inv->DevInfo()->appendFragmentAll(offs, fragment[i].fragment, fragment[i].len); offs += (fragment[i].len); } - inverter.DevInfo()->endAppendFragment(); - inverter.DevInfo()->setLastUpdateAll(millis()); + _inv->DevInfo()->endAppendFragment(); + _inv->DevInfo()->setLastUpdateAll(millis()); return true; } diff --git a/lib/Hoymiles/src/commands/DevInfoAllCommand.h b/lib/Hoymiles/src/commands/DevInfoAllCommand.h index 4f76783fa..8ddfd8341 100644 --- a/lib/Hoymiles/src/commands/DevInfoAllCommand.h +++ b/lib/Hoymiles/src/commands/DevInfoAllCommand.h @@ -9,5 +9,5 @@ class DevInfoAllCommand : public MultiDataCommand { virtual String getCommandName() const; - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); }; diff --git a/lib/Hoymiles/src/commands/DevInfoSimpleCommand.cpp b/lib/Hoymiles/src/commands/DevInfoSimpleCommand.cpp index e58996537..d134a0ac1 100644 --- a/lib/Hoymiles/src/commands/DevInfoSimpleCommand.cpp +++ b/lib/Hoymiles/src/commands/DevInfoSimpleCommand.cpp @@ -34,22 +34,22 @@ String DevInfoSimpleCommand::getCommandName() const return "DevInfoSimple"; } -bool DevInfoSimpleCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool DevInfoSimpleCommand::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { // Check CRC of whole payload - if (!MultiDataCommand::handleResponse(inverter, fragment, max_fragment_id)) { + if (!MultiDataCommand::handleResponse(fragment, max_fragment_id)) { return false; } // Move all fragments into target buffer uint8_t offs = 0; - inverter.DevInfo()->beginAppendFragment(); - inverter.DevInfo()->clearBufferSimple(); + _inv->DevInfo()->beginAppendFragment(); + _inv->DevInfo()->clearBufferSimple(); for (uint8_t i = 0; i < max_fragment_id; i++) { - inverter.DevInfo()->appendFragmentSimple(offs, fragment[i].fragment, fragment[i].len); + _inv->DevInfo()->appendFragmentSimple(offs, fragment[i].fragment, fragment[i].len); offs += (fragment[i].len); } - inverter.DevInfo()->endAppendFragment(); - inverter.DevInfo()->setLastUpdateSimple(millis()); + _inv->DevInfo()->endAppendFragment(); + _inv->DevInfo()->setLastUpdateSimple(millis()); return true; } diff --git a/lib/Hoymiles/src/commands/DevInfoSimpleCommand.h b/lib/Hoymiles/src/commands/DevInfoSimpleCommand.h index 5cd548cca..927f1eab9 100644 --- a/lib/Hoymiles/src/commands/DevInfoSimpleCommand.h +++ b/lib/Hoymiles/src/commands/DevInfoSimpleCommand.h @@ -9,5 +9,5 @@ class DevInfoSimpleCommand : public MultiDataCommand { virtual String getCommandName() const; - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); }; diff --git a/lib/Hoymiles/src/commands/GridOnProFilePara.cpp b/lib/Hoymiles/src/commands/GridOnProFilePara.cpp index d7d64e139..779303773 100644 --- a/lib/Hoymiles/src/commands/GridOnProFilePara.cpp +++ b/lib/Hoymiles/src/commands/GridOnProFilePara.cpp @@ -35,22 +35,22 @@ String GridOnProFilePara::getCommandName() const return "GridOnProFilePara"; } -bool GridOnProFilePara::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool GridOnProFilePara::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { // Check CRC of whole payload - if (!MultiDataCommand::handleResponse(inverter, fragment, max_fragment_id)) { + if (!MultiDataCommand::handleResponse(fragment, max_fragment_id)) { return false; } // Move all fragments into target buffer uint8_t offs = 0; - inverter.GridProfile()->beginAppendFragment(); - inverter.GridProfile()->clearBuffer(); + _inv->GridProfile()->beginAppendFragment(); + _inv->GridProfile()->clearBuffer(); for (uint8_t i = 0; i < max_fragment_id; i++) { - inverter.GridProfile()->appendFragment(offs, fragment[i].fragment, fragment[i].len); + _inv->GridProfile()->appendFragment(offs, fragment[i].fragment, fragment[i].len); offs += (fragment[i].len); } - inverter.GridProfile()->endAppendFragment(); - inverter.GridProfile()->setLastUpdate(millis()); + _inv->GridProfile()->endAppendFragment(); + _inv->GridProfile()->setLastUpdate(millis()); return true; } diff --git a/lib/Hoymiles/src/commands/GridOnProFilePara.h b/lib/Hoymiles/src/commands/GridOnProFilePara.h index 79df22329..b2380c75e 100644 --- a/lib/Hoymiles/src/commands/GridOnProFilePara.h +++ b/lib/Hoymiles/src/commands/GridOnProFilePara.h @@ -9,5 +9,5 @@ class GridOnProFilePara : public MultiDataCommand { virtual String getCommandName() const; - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); }; diff --git a/lib/Hoymiles/src/commands/MultiDataCommand.cpp b/lib/Hoymiles/src/commands/MultiDataCommand.cpp index ff8a40041..0e7bf51f1 100644 --- a/lib/Hoymiles/src/commands/MultiDataCommand.cpp +++ b/lib/Hoymiles/src/commands/MultiDataCommand.cpp @@ -85,7 +85,7 @@ CommandAbstract* MultiDataCommand::getRequestFrameCommand(const uint8_t frame_no return &_cmdRequestFrame; } -bool MultiDataCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool MultiDataCommand::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { // All fragments are available --> Check CRC uint16_t crc = 0xffff, crcRcv = 0; diff --git a/lib/Hoymiles/src/commands/MultiDataCommand.h b/lib/Hoymiles/src/commands/MultiDataCommand.h index 4fddd098e..5693287fa 100644 --- a/lib/Hoymiles/src/commands/MultiDataCommand.h +++ b/lib/Hoymiles/src/commands/MultiDataCommand.h @@ -14,7 +14,7 @@ class MultiDataCommand : public CommandAbstract { CommandAbstract* getRequestFrameCommand(const uint8_t frame_no); - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); protected: void setDataType(const uint8_t data_type); diff --git a/lib/Hoymiles/src/commands/PowerControlCommand.cpp b/lib/Hoymiles/src/commands/PowerControlCommand.cpp index cfa61d5e1..927c33303 100644 --- a/lib/Hoymiles/src/commands/PowerControlCommand.cpp +++ b/lib/Hoymiles/src/commands/PowerControlCommand.cpp @@ -44,14 +44,14 @@ String PowerControlCommand::getCommandName() const return "PowerControl"; } -bool PowerControlCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool PowerControlCommand::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { - if (!DevControlCommand::handleResponse(inverter, fragment, max_fragment_id)) { + if (!DevControlCommand::handleResponse(fragment, max_fragment_id)) { return false; } - inverter.PowerCommand()->setLastUpdateCommand(millis()); - inverter.PowerCommand()->setLastPowerCommandSuccess(CMD_OK); + _inv->PowerCommand()->setLastUpdateCommand(millis()); + _inv->PowerCommand()->setLastPowerCommandSuccess(CMD_OK); return true; } diff --git a/lib/Hoymiles/src/commands/PowerControlCommand.h b/lib/Hoymiles/src/commands/PowerControlCommand.h index a7bf019b3..d40c356db 100644 --- a/lib/Hoymiles/src/commands/PowerControlCommand.h +++ b/lib/Hoymiles/src/commands/PowerControlCommand.h @@ -9,7 +9,7 @@ class PowerControlCommand : public DevControlCommand { virtual String getCommandName() const; - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); virtual void gotTimeout(); void setPowerOn(const bool state); diff --git a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp index 2d6919dd5..b1396a4dd 100644 --- a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp +++ b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.cpp @@ -35,10 +35,10 @@ String RealTimeRunDataCommand::getCommandName() const return "RealTimeRunData"; } -bool RealTimeRunDataCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool RealTimeRunDataCommand::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { // Check CRC of whole payload - if (!MultiDataCommand::handleResponse(inverter, fragment, max_fragment_id)) { + if (!MultiDataCommand::handleResponse(fragment, max_fragment_id)) { return false; } @@ -46,7 +46,7 @@ bool RealTimeRunDataCommand::handleResponse(InverterAbstract& inverter, const fr // In case of low power in the inverter it occours that some incomplete fragments // with a valid CRC are received. const uint8_t fragmentsSize = getTotalFragmentSize(fragment, max_fragment_id); - const uint8_t expectedSize = inverter.Statistics()->getExpectedByteCount(); + const uint8_t expectedSize = _inv->Statistics()->getExpectedByteCount(); if (fragmentsSize < expectedSize) { Hoymiles.getMessageOutput()->printf("ERROR in %s: Received fragment size: %d, min expected size: %d\r\n", getCommandName().c_str(), fragmentsSize, expectedSize); @@ -56,15 +56,15 @@ bool RealTimeRunDataCommand::handleResponse(InverterAbstract& inverter, const fr // Move all fragments into target buffer uint8_t offs = 0; - inverter.Statistics()->beginAppendFragment(); - inverter.Statistics()->clearBuffer(); + _inv->Statistics()->beginAppendFragment(); + _inv->Statistics()->clearBuffer(); for (uint8_t i = 0; i < max_fragment_id; i++) { - inverter.Statistics()->appendFragment(offs, fragment[i].fragment, fragment[i].len); + _inv->Statistics()->appendFragment(offs, fragment[i].fragment, fragment[i].len); offs += (fragment[i].len); } - inverter.Statistics()->endAppendFragment(); - inverter.Statistics()->resetRxFailureCount(); - inverter.Statistics()->setLastUpdate(millis()); + _inv->Statistics()->endAppendFragment(); + _inv->Statistics()->resetRxFailureCount(); + _inv->Statistics()->setLastUpdate(millis()); return true; } diff --git a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h index e41bdbf26..9341247f6 100644 --- a/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h +++ b/lib/Hoymiles/src/commands/RealTimeRunDataCommand.h @@ -9,6 +9,6 @@ class RealTimeRunDataCommand : public MultiDataCommand { virtual String getCommandName() const; - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); virtual void gotTimeout(); }; diff --git a/lib/Hoymiles/src/commands/RequestFrameCommand.cpp b/lib/Hoymiles/src/commands/RequestFrameCommand.cpp index deffe63b3..0abb52356 100644 --- a/lib/Hoymiles/src/commands/RequestFrameCommand.cpp +++ b/lib/Hoymiles/src/commands/RequestFrameCommand.cpp @@ -47,7 +47,7 @@ uint8_t RequestFrameCommand::getFrameNo() const return _payload[9] & (~0x80); } -bool RequestFrameCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool RequestFrameCommand::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { return true; } diff --git a/lib/Hoymiles/src/commands/RequestFrameCommand.h b/lib/Hoymiles/src/commands/RequestFrameCommand.h index 577ab9d07..2924e69bb 100644 --- a/lib/Hoymiles/src/commands/RequestFrameCommand.h +++ b/lib/Hoymiles/src/commands/RequestFrameCommand.h @@ -12,5 +12,5 @@ class RequestFrameCommand : public SingleDataCommand { void setFrameNo(const uint8_t frame_no); uint8_t getFrameNo() const; - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); }; diff --git a/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp b/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp index 9e3f89a57..0c142afc8 100644 --- a/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp +++ b/lib/Hoymiles/src/commands/SystemConfigParaCommand.cpp @@ -35,10 +35,10 @@ String SystemConfigParaCommand::getCommandName() const return "SystemConfigPara"; } -bool SystemConfigParaCommand::handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id) +bool SystemConfigParaCommand::handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id) { // Check CRC of whole payload - if (!MultiDataCommand::handleResponse(inverter, fragment, max_fragment_id)) { + if (!MultiDataCommand::handleResponse(fragment, max_fragment_id)) { return false; } @@ -46,7 +46,7 @@ bool SystemConfigParaCommand::handleResponse(InverterAbstract& inverter, const f // In case of low power in the inverter it occours that some incomplete fragments // with a valid CRC are received. const uint8_t fragmentsSize = getTotalFragmentSize(fragment, max_fragment_id); - const uint8_t expectedSize = inverter.SystemConfigPara()->getExpectedByteCount(); + const uint8_t expectedSize = _inv->SystemConfigPara()->getExpectedByteCount(); if (fragmentsSize < expectedSize) { Hoymiles.getMessageOutput()->printf("ERROR in %s: Received fragment size: %d, min expected size: %d\r\n", getCommandName().c_str(), fragmentsSize, expectedSize); @@ -56,15 +56,15 @@ bool SystemConfigParaCommand::handleResponse(InverterAbstract& inverter, const f // Move all fragments into target buffer uint8_t offs = 0; - inverter.SystemConfigPara()->beginAppendFragment(); - inverter.SystemConfigPara()->clearBuffer(); + _inv->SystemConfigPara()->beginAppendFragment(); + _inv->SystemConfigPara()->clearBuffer(); for (uint8_t i = 0; i < max_fragment_id; i++) { - inverter.SystemConfigPara()->appendFragment(offs, fragment[i].fragment, fragment[i].len); + _inv->SystemConfigPara()->appendFragment(offs, fragment[i].fragment, fragment[i].len); offs += (fragment[i].len); } - inverter.SystemConfigPara()->endAppendFragment(); - inverter.SystemConfigPara()->setLastUpdateRequest(millis()); - inverter.SystemConfigPara()->setLastLimitRequestSuccess(CMD_OK); + _inv->SystemConfigPara()->endAppendFragment(); + _inv->SystemConfigPara()->setLastUpdateRequest(millis()); + _inv->SystemConfigPara()->setLastLimitRequestSuccess(CMD_OK); return true; } diff --git a/lib/Hoymiles/src/commands/SystemConfigParaCommand.h b/lib/Hoymiles/src/commands/SystemConfigParaCommand.h index f33e84812..147f18dae 100644 --- a/lib/Hoymiles/src/commands/SystemConfigParaCommand.h +++ b/lib/Hoymiles/src/commands/SystemConfigParaCommand.h @@ -9,6 +9,6 @@ class SystemConfigParaCommand : public MultiDataCommand { virtual String getCommandName() const; - virtual bool handleResponse(InverterAbstract& inverter, const fragment_t fragment[], const uint8_t max_fragment_id); + virtual bool handleResponse(const fragment_t fragment[], const uint8_t max_fragment_id); virtual void gotTimeout(); }; diff --git a/lib/Hoymiles/src/inverters/InverterAbstract.cpp b/lib/Hoymiles/src/inverters/InverterAbstract.cpp index 6f7571988..17a0d4e78 100644 --- a/lib/Hoymiles/src/inverters/InverterAbstract.cpp +++ b/lib/Hoymiles/src/inverters/InverterAbstract.cpp @@ -255,7 +255,7 @@ uint8_t InverterAbstract::verifyAllFragments(CommandAbstract& cmd) } } - if (!cmd.handleResponse(*this, _rxFragmentBuffer, _rxFragmentMaxPacketId)) { + if (!cmd.handleResponse(_rxFragmentBuffer, _rxFragmentMaxPacketId)) { cmd.gotTimeout(); return FRAGMENT_HANDLE_ERROR; } From 918c3449da4dec639b9bfbb9846153b272a8ec4f Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Mon, 20 May 2024 17:56:59 +0200 Subject: [PATCH 006/140] Fix #2000: MQTT subscriptions where not updated if MQTT base was changed --- include/MqttHandleInverter.h | 3 +++ src/MqttHandleInverter.cpp | 44 ++++++++++++++++++++++++------------ src/WebApi_mqtt.cpp | 11 +++++++-- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/include/MqttHandleInverter.h b/include/MqttHandleInverter.h index 446f30afa..7c86a8098 100644 --- a/include/MqttHandleInverter.h +++ b/include/MqttHandleInverter.h @@ -13,6 +13,9 @@ class MqttHandleInverterClass { static String getTopic(std::shared_ptr inv, const ChannelType_t type, const ChannelNum_t channel, const FieldId_t fieldId); + void subscribeTopics(); + void unsubscribeTopics(); + private: void loop(); void publishField(std::shared_ptr inv, const ChannelType_t type, const ChannelNum_t channel, const FieldId_t fieldId); diff --git a/src/MqttHandleInverter.cpp b/src/MqttHandleInverter.cpp index de2778d10..624033e1c 100644 --- a/src/MqttHandleInverter.cpp +++ b/src/MqttHandleInverter.cpp @@ -25,20 +25,7 @@ MqttHandleInverterClass::MqttHandleInverterClass() void MqttHandleInverterClass::init(Scheduler& scheduler) { - using std::placeholders::_1; - using std::placeholders::_2; - using std::placeholders::_3; - using std::placeholders::_4; - using std::placeholders::_5; - using std::placeholders::_6; - - const String topic = MqttSettings.getPrefix(); - MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_PERSISTENT_RELATIVE), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); - MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_PERSISTENT_ABSOLUTE), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); - MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_NONPERSISTENT_RELATIVE), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); - MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_NONPERSISTENT_ABSOLUTE), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); - MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_POWER), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); - MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_RESTART), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); + subscribeTopics(); scheduler.addTask(_loopTask); _loopTask.setInterval(Configuration.get().Mqtt.PublishInterval * TASK_SECOND); @@ -247,3 +234,32 @@ void MqttHandleInverterClass::onMqttMessage(const espMqttClientTypes::MessagePro } } } + +void MqttHandleInverterClass::subscribeTopics() +{ + using std::placeholders::_1; + using std::placeholders::_2; + using std::placeholders::_3; + using std::placeholders::_4; + using std::placeholders::_5; + using std::placeholders::_6; + + const String topic = MqttSettings.getPrefix(); + MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_PERSISTENT_RELATIVE), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); + MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_PERSISTENT_ABSOLUTE), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); + MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_NONPERSISTENT_RELATIVE), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); + MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_NONPERSISTENT_ABSOLUTE), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); + MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_POWER), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); + MqttSettings.subscribe(String(topic + "+/cmd/" + TOPIC_SUB_RESTART), 0, std::bind(&MqttHandleInverterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); +} + +void MqttHandleInverterClass::unsubscribeTopics() +{ + const String topic = MqttSettings.getPrefix(); + MqttSettings.unsubscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_PERSISTENT_RELATIVE)); + MqttSettings.unsubscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_PERSISTENT_ABSOLUTE)); + MqttSettings.unsubscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_NONPERSISTENT_RELATIVE)); + MqttSettings.unsubscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_NONPERSISTENT_ABSOLUTE)); + MqttSettings.unsubscribe(String(topic + "+/cmd/" + TOPIC_SUB_POWER)); + MqttSettings.unsubscribe(String(topic + "+/cmd/" + TOPIC_SUB_RESTART)); +} diff --git a/src/WebApi_mqtt.cpp b/src/WebApi_mqtt.cpp index 1795b7aae..9d1a7dbe7 100644 --- a/src/WebApi_mqtt.cpp +++ b/src/WebApi_mqtt.cpp @@ -5,6 +5,7 @@ #include "WebApi_mqtt.h" #include "Configuration.h" #include "MqttHandleHass.h" +#include "MqttHandleInverter.h" #include "MqttSettings.h" #include "WebApi.h" #include "WebApi_errors.h" @@ -76,7 +77,7 @@ void WebApiMqttClass::onMqttAdminGet(AsyncWebServerRequest* request) root["mqtt_client_cert"] = config.Mqtt.Tls.ClientCert; root["mqtt_client_key"] = config.Mqtt.Tls.ClientKey; root["mqtt_lwt_topic"] = config.Mqtt.Lwt.Topic; - root["mqtt_lwt_online"] = config.Mqtt.Lwt.Value_Online;; + root["mqtt_lwt_online"] = config.Mqtt.Lwt.Value_Online; root["mqtt_lwt_offline"] = config.Mqtt.Lwt.Value_Offline; root["mqtt_lwt_qos"] = config.Mqtt.Lwt.Qos; root["mqtt_publish_interval"] = config.Mqtt.PublishInterval; @@ -272,7 +273,6 @@ void WebApiMqttClass::onMqttAdminPost(AsyncWebServerRequest* request) strlcpy(config.Mqtt.Hostname, root["mqtt_hostname"].as().c_str(), sizeof(config.Mqtt.Hostname)); strlcpy(config.Mqtt.Username, root["mqtt_username"].as().c_str(), sizeof(config.Mqtt.Username)); strlcpy(config.Mqtt.Password, root["mqtt_password"].as().c_str(), sizeof(config.Mqtt.Password)); - strlcpy(config.Mqtt.Topic, root["mqtt_topic"].as().c_str(), sizeof(config.Mqtt.Topic)); strlcpy(config.Mqtt.Lwt.Topic, root["mqtt_lwt_topic"].as().c_str(), sizeof(config.Mqtt.Lwt.Topic)); strlcpy(config.Mqtt.Lwt.Value_Online, root["mqtt_lwt_online"].as().c_str(), sizeof(config.Mqtt.Lwt.Value_Online)); strlcpy(config.Mqtt.Lwt.Value_Offline, root["mqtt_lwt_offline"].as().c_str(), sizeof(config.Mqtt.Lwt.Value_Offline)); @@ -285,6 +285,13 @@ void WebApiMqttClass::onMqttAdminPost(AsyncWebServerRequest* request) config.Mqtt.Hass.IndividualPanels = root["mqtt_hass_individualpanels"].as(); strlcpy(config.Mqtt.Hass.Topic, root["mqtt_hass_topic"].as().c_str(), sizeof(config.Mqtt.Hass.Topic)); + // Check if base topic was changed + if (strcmp(config.Mqtt.Topic, root["mqtt_topic"].as().c_str())) { + MqttHandleInverter.unsubscribeTopics(); + strlcpy(config.Mqtt.Topic, root["mqtt_topic"].as().c_str(), sizeof(config.Mqtt.Topic)); + MqttHandleInverter.subscribeTopics(); + } + WebApi.writeConfig(retMsg); WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); From 4972892d9ac9b9cbdad344d18f2f36bd3518e4f2 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Mon, 27 May 2024 21:44:11 +0200 Subject: [PATCH 007/140] Feature: show ESP32 flash memory size in system info --- src/WebApi_sysstatus.cpp | 1 + webapp/src/components/HardwareInfo.vue | 7 +++++++ webapp/src/locales/de.json | 5 ++++- webapp/src/locales/en.json | 5 ++++- webapp/src/locales/fr.json | 5 ++++- webapp/src/types/SystemStatus.ts | 1 + 6 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/WebApi_sysstatus.cpp b/src/WebApi_sysstatus.cpp index 9b317f943..782e1f27d 100644 --- a/src/WebApi_sysstatus.cpp +++ b/src/WebApi_sysstatus.cpp @@ -48,6 +48,7 @@ void WebApiSysstatusClass::onSystemStatus(AsyncWebServerRequest* request) root["chiprevision"] = ESP.getChipRevision(); root["chipmodel"] = ESP.getChipModel(); root["chipcores"] = ESP.getChipCores(); + root["flashsize"] = ESP.getFlashChipSize(); String reason; reason = ResetReason::get_reset_reason_verbose(0); diff --git a/webapp/src/components/HardwareInfo.vue b/webapp/src/components/HardwareInfo.vue index 34672132c..c7f342fb8 100644 --- a/webapp/src/components/HardwareInfo.vue +++ b/webapp/src/components/HardwareInfo.vue @@ -19,6 +19,13 @@ {{ $t('hardwareinfo.CpuFrequency') }} {{ systemStatus.cpufreq }} {{ $t('hardwareinfo.Mhz') }} + + {{ $t('hardwareinfo.FlashSize') }} + + {{ systemStatus.flashsize }} {{ $t('hardwareinfo.Bytes') }} + ({{ systemStatus.flashsize / 1024 / 1024 }} {{ $t('hardwareinfo.MegaBytes') }}) + + diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index ad184d1ce..8d9b0ed7b 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -202,7 +202,10 @@ "ChipRevision": "Chip-Revision", "ChipCores": "Chip-Kerne", "CpuFrequency": "CPU-Frequenz", - "Mhz": "MHz" + "Mhz": "MHz", + "FlashSize": "Flash-Speichergröße", + "Bytes": "Bytes", + "MegaBytes": "MB" }, "memoryinfo": { "MemoryInformation": "Speicherinformationen", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 4179227ae..dec201258 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -202,7 +202,10 @@ "ChipRevision": "Chip Revision", "ChipCores": "Chip Cores", "CpuFrequency": "CPU Frequency", - "Mhz": "MHz" + "Mhz": "MHz", + "FlashSize": "Flash Memory Size", + "Bytes": "Bytes", + "MegaBytes": "MB" }, "memoryinfo": { "MemoryInformation": "Memory Information", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index c60a552af..9995fd63d 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -202,7 +202,10 @@ "ChipRevision": "Révision de la puce", "ChipCores": "Nombre de cœurs", "CpuFrequency": "Fréquence du CPU", - "Mhz": "MHz" + "Mhz": "MHz", + "FlashSize": "Taille de la mémoire flash", + "Bytes": "octets", + "MegaBytes": "Mo" }, "memoryinfo": { "MemoryInformation": "Informations sur la mémoire", diff --git a/webapp/src/types/SystemStatus.ts b/webapp/src/types/SystemStatus.ts index 8bacf6964..0a35ec0f7 100644 --- a/webapp/src/types/SystemStatus.ts +++ b/webapp/src/types/SystemStatus.ts @@ -4,6 +4,7 @@ export interface SystemStatus { chiprevision: number; chipcores: number; cpufreq: number; + flashsize: number; // FirmwareInfo hostname: string; sdkversion: string; From 5761e9facf48bea8dce186f5192b57adc690991d Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Tue, 28 May 2024 21:32:46 +0200 Subject: [PATCH 008/140] webapp: Locale update for "screensaver" Fix #2010 --- webapp/src/locales/de.json | 4 ++-- webapp/src/locales/en.json | 4 ++-- webapp/src/locales/fr.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index f32afae53..f64e23627 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -593,9 +593,9 @@ "DefaultProfile": "(Standardeinstellungen)", "ProfileHint": "Ihr Gerät reagiert möglicherweise nicht mehr, wenn Sie ein inkompatibles Profil wählen. In diesem Fall müssen Sie eine Löschung über das serielle Interface durchführen.", "Display": "Display", - "PowerSafe": "Stromsparen aktivieren:", + "PowerSafe": "Ausschalten wenn kein Inverter erreichbar:", "PowerSafeHint": "Schaltet das Display aus, wenn kein Wechselrichter Strom erzeugt", - "Screensaver": "Bildschirmschoner aktivieren:", + "Screensaver": "OLED-Schutz gegen Einbrennen:", "ScreensaverHint": "Bewegt die Ausgabe bei jeder Aktualisierung um ein Einbrennen zu verhindern (v. a. für OLED-Displays nützlich)", "DiagramMode": "Diagramm Modus:", "off": "Deaktiviert", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index dec201258..8d03c6bc6 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -593,9 +593,9 @@ "DefaultProfile": "(Default settings)", "ProfileHint": "Your device may stop responding if you select an incompatible profile. In this case, you must perform a deletion via the serial interface.", "Display": "Display", - "PowerSafe": "Enable Power Save:", + "PowerSafe": "Switch off if no solar:", "PowerSafeHint": "Turn off the display if no inverter is producing.", - "Screensaver": "Enable Screensaver:", + "Screensaver": "OLED Anti burn-in:", "ScreensaverHint": "Move the display a little bit on each update to prevent burn-in. (Useful especially for OLED displays)", "DiagramMode": "Diagram mode:", "off": "Off", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 9995fd63d..37b621616 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -593,9 +593,9 @@ "DefaultProfile": "(Réglages par défaut)", "ProfileHint": "Votre appareil peut cesser de répondre si vous sélectionnez un profil incompatible. Dans ce cas, vous devez effectuer une suppression via l'interface série.", "Display": "Affichage", - "PowerSafe": "Activer l'économiseur d'énergie", + "PowerSafe": "Economiseur d'énergie", "PowerSafeHint": "Eteindre l'écran si aucun onduleur n'est en production.", - "Screensaver": "Activer l'écran de veille", + "Screensaver": "OLED Anti burn-in", "ScreensaverHint": "Déplacez un peu l'écran à chaque mise à jour pour éviter le phénomène de brûlure. (Utile surtout pour les écrans OLED)", "DiagramMode": "Diagram mode:", "off": "Off", From e211dd5be266ad6e0035f1e4b78599f1ecbe9cce Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Tue, 28 May 2024 21:37:20 +0200 Subject: [PATCH 009/140] Add proper formatting for flashsize output --- webapp/src/components/HardwareInfo.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/HardwareInfo.vue b/webapp/src/components/HardwareInfo.vue index c7f342fb8..ed8a67918 100644 --- a/webapp/src/components/HardwareInfo.vue +++ b/webapp/src/components/HardwareInfo.vue @@ -22,8 +22,8 @@ {{ $t('hardwareinfo.FlashSize') }} - {{ systemStatus.flashsize }} {{ $t('hardwareinfo.Bytes') }} - ({{ systemStatus.flashsize / 1024 / 1024 }} {{ $t('hardwareinfo.MegaBytes') }}) + {{ $n(systemStatus.flashsize) }} {{ $t('hardwareinfo.Bytes') }} + ({{ $n(systemStatus.flashsize / 1024 / 1024) }} {{ $t('hardwareinfo.MegaBytes') }}) From 6ce474053e280182f1f41327e9dd42d055171ee0 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Tue, 28 May 2024 23:24:08 +0200 Subject: [PATCH 010/140] Feature: Show MCU temperature in system info --- lib/CpuTemperature/src/CpuTemperature.cpp | 51 +++++++++++++++++++++++ lib/CpuTemperature/src/CpuTemperature.h | 14 +++++++ src/WebApi_sysstatus.cpp | 4 +- webapp/src/components/HardwareInfo.vue | 4 ++ webapp/src/locales/de.json | 2 + webapp/src/locales/en.json | 2 + webapp/src/locales/fr.json | 2 + webapp/src/types/SystemStatus.ts | 1 + 8 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 lib/CpuTemperature/src/CpuTemperature.cpp create mode 100644 lib/CpuTemperature/src/CpuTemperature.h diff --git a/lib/CpuTemperature/src/CpuTemperature.cpp b/lib/CpuTemperature/src/CpuTemperature.cpp new file mode 100644 index 000000000..60e3fc7b4 --- /dev/null +++ b/lib/CpuTemperature/src/CpuTemperature.cpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2024 Thomas Basler and others + */ + +#include "CpuTemperature.h" +#include + +#if defined(CONFIG_IDF_TARGET_ESP32) +// there is no official API available on the original ESP32 +extern "C" { +uint8_t temprature_sens_read(); +} +#elif defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32S3) +#include "driver/temp_sensor.h" +#endif + +CpuTemperatureClass CpuTemperature; + +float CpuTemperatureClass::read() +{ + std::lock_guard lock(_mutex); + + float temperature = NAN; + bool success = false; + +#if defined(CONFIG_IDF_TARGET_ESP32) + uint8_t raw = temprature_sens_read(); + ESP_LOGV(TAG, "Raw temperature value: %d", raw); + temperature = (raw - 32) / 1.8f; + success = (raw != 128); +#elif defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32S3) + temp_sensor_config_t tsens = TSENS_CONFIG_DEFAULT(); + temp_sensor_set_config(tsens); + temp_sensor_start(); +#if defined(CONFIG_IDF_TARGET_ESP32S3) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(4, 4, 3)) +#error \ + "ESP32-S3 internal temperature sensor requires ESP IDF V4.4.3 or higher. See https://github.com/esphome/issues/issues/4271" +#endif + esp_err_t result = temp_sensor_read_celsius(&temperature); + temp_sensor_stop(); + success = (result == ESP_OK); +#endif + + if (success && std::isfinite(temperature)) { + return temperature; + } else { + ESP_LOGD(TAG, "Ignoring invalid temperature (success=%d, value=%.1f)", success, temperature); + return NAN; + } +} diff --git a/lib/CpuTemperature/src/CpuTemperature.h b/lib/CpuTemperature/src/CpuTemperature.h new file mode 100644 index 000000000..06199c825 --- /dev/null +++ b/lib/CpuTemperature/src/CpuTemperature.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include + +class CpuTemperatureClass { +public: + float read(); + +private: + std::mutex _mutex; +}; + +extern CpuTemperatureClass CpuTemperature; diff --git a/src/WebApi_sysstatus.cpp b/src/WebApi_sysstatus.cpp index 782e1f27d..646922a60 100644 --- a/src/WebApi_sysstatus.cpp +++ b/src/WebApi_sysstatus.cpp @@ -7,11 +7,12 @@ #include "NetworkSettings.h" #include "PinMapping.h" #include "WebApi.h" +#include "__compiled_constants.h" #include +#include #include #include #include -#include "__compiled_constants.h" void WebApiSysstatusClass::init(AsyncWebServer& server, Scheduler& scheduler) { @@ -33,6 +34,7 @@ void WebApiSysstatusClass::onSystemStatus(AsyncWebServerRequest* request) root["sdkversion"] = ESP.getSdkVersion(); root["cpufreq"] = ESP.getCpuFreqMHz(); + root["cputemp"] = CpuTemperature.read(); root["heap_total"] = ESP.getHeapSize(); root["heap_used"] = ESP.getHeapSize() - ESP.getFreeHeap(); diff --git a/webapp/src/components/HardwareInfo.vue b/webapp/src/components/HardwareInfo.vue index ed8a67918..a28c4a158 100644 --- a/webapp/src/components/HardwareInfo.vue +++ b/webapp/src/components/HardwareInfo.vue @@ -19,6 +19,10 @@ {{ $t('hardwareinfo.CpuFrequency') }} {{ systemStatus.cpufreq }} {{ $t('hardwareinfo.Mhz') }} + + {{ $t('hardwareinfo.CpuTemperature') }} + {{ $n(systemStatus.cputemp, 'decimalNoDigits') }} {{ $t('hardwareinfo.DegreeC') }} + {{ $t('hardwareinfo.FlashSize') }} diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index f64e23627..7b3f89212 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -203,6 +203,8 @@ "ChipCores": "Chip-Kerne", "CpuFrequency": "CPU-Frequenz", "Mhz": "MHz", + "CpuTemperature": "CPU Temperatur", + "DegreeC": "°C", "FlashSize": "Flash-Speichergröße", "Bytes": "Bytes", "MegaBytes": "MB" diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 8d03c6bc6..2f9f64a70 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -203,6 +203,8 @@ "ChipCores": "Chip Cores", "CpuFrequency": "CPU Frequency", "Mhz": "MHz", + "CpuTemperature": "CPU Temperature", + "DegreeC": "°C", "FlashSize": "Flash Memory Size", "Bytes": "Bytes", "MegaBytes": "MB" diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 37b621616..676046b66 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -203,6 +203,8 @@ "ChipCores": "Nombre de cœurs", "CpuFrequency": "Fréquence du CPU", "Mhz": "MHz", + "CpuTemperature": "CPU Temperature", + "DegreeC": "°C", "FlashSize": "Taille de la mémoire flash", "Bytes": "octets", "MegaBytes": "Mo" diff --git a/webapp/src/types/SystemStatus.ts b/webapp/src/types/SystemStatus.ts index 0a35ec0f7..1ee726e65 100644 --- a/webapp/src/types/SystemStatus.ts +++ b/webapp/src/types/SystemStatus.ts @@ -4,6 +4,7 @@ export interface SystemStatus { chiprevision: number; chipcores: number; cpufreq: number; + cputemp: number; flashsize: number; // FirmwareInfo hostname: string; From df80953b5efc4e8815482766a440ac250af533a5 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Tue, 28 May 2024 23:38:17 +0200 Subject: [PATCH 011/140] Use correct units in hardware info --- webapp/src/components/HardwareInfo.vue | 6 +++--- webapp/src/locales/de.json | 4 +--- webapp/src/locales/en.json | 4 +--- webapp/src/locales/fr.json | 4 +--- webapp/src/locales/index.ts | 29 +++++++++++++++++++++++++- 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/webapp/src/components/HardwareInfo.vue b/webapp/src/components/HardwareInfo.vue index a28c4a158..ec95c98c7 100644 --- a/webapp/src/components/HardwareInfo.vue +++ b/webapp/src/components/HardwareInfo.vue @@ -21,13 +21,13 @@ {{ $t('hardwareinfo.CpuTemperature') }} - {{ $n(systemStatus.cputemp, 'decimalNoDigits') }} {{ $t('hardwareinfo.DegreeC') }} + {{ $n(systemStatus.cputemp, 'celsius') }} {{ $t('hardwareinfo.FlashSize') }} - {{ $n(systemStatus.flashsize) }} {{ $t('hardwareinfo.Bytes') }} - ({{ $n(systemStatus.flashsize / 1024 / 1024) }} {{ $t('hardwareinfo.MegaBytes') }}) + {{ $n(systemStatus.flashsize, 'byte') }} + ({{ $n(systemStatus.flashsize / 1024 / 1024, 'megabyte') }}) diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 7b3f89212..df4d6c21d 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -205,9 +205,7 @@ "Mhz": "MHz", "CpuTemperature": "CPU Temperatur", "DegreeC": "°C", - "FlashSize": "Flash-Speichergröße", - "Bytes": "Bytes", - "MegaBytes": "MB" + "FlashSize": "Flash-Speichergröße" }, "memoryinfo": { "MemoryInformation": "Speicherinformationen", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 2f9f64a70..c5424aa73 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -205,9 +205,7 @@ "Mhz": "MHz", "CpuTemperature": "CPU Temperature", "DegreeC": "°C", - "FlashSize": "Flash Memory Size", - "Bytes": "Bytes", - "MegaBytes": "MB" + "FlashSize": "Flash Memory Size" }, "memoryinfo": { "MemoryInformation": "Memory Information", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 676046b66..71fe2145c 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -205,9 +205,7 @@ "Mhz": "MHz", "CpuTemperature": "CPU Temperature", "DegreeC": "°C", - "FlashSize": "Taille de la mémoire flash", - "Bytes": "octets", - "MegaBytes": "Mo" + "FlashSize": "Taille de la mémoire flash" }, "memoryinfo": { "MemoryInformation": "Informations sur la mémoire", diff --git a/webapp/src/locales/index.ts b/webapp/src/locales/index.ts index 57589c7f6..95ca69e98 100644 --- a/webapp/src/locales/index.ts +++ b/webapp/src/locales/index.ts @@ -62,9 +62,18 @@ export const numberFormats: I18nOptions["numberFormats"] = { percent: { style: 'percent', }, + byte: { + style: 'unit', unit: 'byte', + }, kilobyte: { style: 'unit', unit: 'kilobyte', }, + megabyte: { + style: 'unit', unit: 'megabyte', + }, + celsius: { + style: 'unit', unit: 'celsius', maximumFractionDigits: 1, + }, }, [Locales.DE]: { decimal: { @@ -79,9 +88,18 @@ export const numberFormats: I18nOptions["numberFormats"] = { percent: { style: 'percent', }, + byte: { + style: 'unit', unit: 'byte', + }, kilobyte: { style: 'unit', unit: 'kilobyte', }, + megabyte: { + style: 'unit', unit: 'megabyte', + }, + celsius: { + style: 'unit', unit: 'celsius', maximumFractionDigits: 1, + }, }, [Locales.FR]: { decimal: { @@ -96,10 +114,19 @@ export const numberFormats: I18nOptions["numberFormats"] = { percent: { style: 'percent', }, + byte: { + style: 'unit', unit: 'byte', + }, kilobyte: { style: 'unit', unit: 'kilobyte', }, + megabyte: { + style: 'unit', unit: 'megabyte', + }, + celsius: { + style: 'unit', unit: 'celsius', maximumFractionDigits: 1, + }, }, }; -export const defaultLocale = Locales.EN; \ No newline at end of file +export const defaultLocale = Locales.EN; From ea4e7b77f57ec0768d5b864261638ef08118cd94 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Wed, 29 May 2024 21:09:37 +0200 Subject: [PATCH 012/140] webapp: Remove duplicated code --- webapp/src/locales/index.ts | 91 ++++--------------------------------- 1 file changed, 9 insertions(+), 82 deletions(-) diff --git a/webapp/src/locales/index.ts b/webapp/src/locales/index.ts index 95ca69e98..7ea42cf46 100644 --- a/webapp/src/locales/index.ts +++ b/webapp/src/locales/index.ts @@ -12,30 +12,11 @@ export const LOCALES = [ { value: Locales.FR, caption: 'Français' }, ] -export const dateTimeFormats: I18nOptions["datetimeFormats"] = { - [Locales.EN]: { - 'datetime': { - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour12: false - } - }, - [Locales.DE]: { - 'datetime': { - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour12: false - } - }, - [Locales.FR]: { +export const dateTimeFormats: I18nOptions["datetimeFormats"] = {}; +export const numberFormats: I18nOptions["numberFormats"] = {}; + +LOCALES.forEach((locale) => { + dateTimeFormats[locale.value] = { 'datetime': { hour: 'numeric', minute: 'numeric', @@ -45,37 +26,9 @@ export const dateTimeFormats: I18nOptions["datetimeFormats"] = { day: 'numeric', hour12: false } - } -}; + }; -export const numberFormats: I18nOptions["numberFormats"] = { - [Locales.EN]: { - decimal: { - style: 'decimal', - }, - decimalNoDigits: { - style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: 0 - }, - decimalTwoDigits: { - style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 - }, - percent: { - style: 'percent', - }, - byte: { - style: 'unit', unit: 'byte', - }, - kilobyte: { - style: 'unit', unit: 'kilobyte', - }, - megabyte: { - style: 'unit', unit: 'megabyte', - }, - celsius: { - style: 'unit', unit: 'celsius', maximumFractionDigits: 1, - }, - }, - [Locales.DE]: { + numberFormats[locale.value] = { decimal: { style: 'decimal', }, @@ -100,33 +53,7 @@ export const numberFormats: I18nOptions["numberFormats"] = { celsius: { style: 'unit', unit: 'celsius', maximumFractionDigits: 1, }, - }, - [Locales.FR]: { - decimal: { - style: 'decimal', - }, - decimalNoDigits: { - style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: 0 - }, - decimalTwoDigits: { - style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 - }, - percent: { - style: 'percent', - }, - byte: { - style: 'unit', unit: 'byte', - }, - kilobyte: { - style: 'unit', unit: 'kilobyte', - }, - megabyte: { - style: 'unit', unit: 'megabyte', - }, - celsius: { - style: 'unit', unit: 'celsius', maximumFractionDigits: 1, - }, - }, -}; + }; +}); export const defaultLocale = Locales.EN; From 33bfde34b22d7b803645ad3dbd9fd9c17a5aada0 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Wed, 29 May 2024 22:48:27 +0200 Subject: [PATCH 013/140] Added some missing names to grid profile parser --- lib/Hoymiles/src/parser/GridProfileParser.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Hoymiles/src/parser/GridProfileParser.cpp b/lib/Hoymiles/src/parser/GridProfileParser.cpp index a7b912a9e..7c08d51b0 100644 --- a/lib/Hoymiles/src/parser/GridProfileParser.cpp +++ b/lib/Hoymiles/src/parser/GridProfileParser.cpp @@ -11,10 +11,10 @@ const std::array GridProfileParser::_profileTypes = { { { 0x02, 0x00, "US - NA_IEEE1547_240V" }, { 0x03, 0x00, "DE - DE_VDE4105_2018" }, - { 0x03, 0x01, "XX - unknown" }, + { 0x03, 0x01, "DE - DE_VDE4105_2011" }, { 0x0a, 0x00, "XX - EN 50549-1:2019" }, { 0x0c, 0x00, "AT - AT_TOR_Erzeuger_default" }, - { 0x0d, 0x04, "FR -" }, + { 0x0d, 0x04, "XX - NF_EN_50549-1:2019" }, { 0x10, 0x00, "ES - ES_RD1699" }, { 0x12, 0x00, "PL - EU_EN50438" }, { 0x29, 0x00, "NL - NL_NEN-EN50549-1_2019" }, From 72492c267f8bb2c53dc3a000a7bd37a83482045d Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Thu, 30 May 2024 00:05:14 +0200 Subject: [PATCH 014/140] webapp: Remove no more required locale --- webapp/src/locales/de.json | 3 +-- webapp/src/locales/en.json | 1 - webapp/src/locales/fr.json | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index df4d6c21d..e4b8f62df 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -203,8 +203,7 @@ "ChipCores": "Chip-Kerne", "CpuFrequency": "CPU-Frequenz", "Mhz": "MHz", - "CpuTemperature": "CPU Temperatur", - "DegreeC": "°C", + "CpuTemperature": "CPU-Temperatur", "FlashSize": "Flash-Speichergröße" }, "memoryinfo": { diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index c5424aa73..5eb9c4b7c 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -204,7 +204,6 @@ "CpuFrequency": "CPU Frequency", "Mhz": "MHz", "CpuTemperature": "CPU Temperature", - "DegreeC": "°C", "FlashSize": "Flash Memory Size" }, "memoryinfo": { diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 71fe2145c..5b9673f9e 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -204,7 +204,6 @@ "CpuFrequency": "Fréquence du CPU", "Mhz": "MHz", "CpuTemperature": "CPU Temperature", - "DegreeC": "°C", "FlashSize": "Taille de la mémoire flash" }, "memoryinfo": { From 4fea9d81a88adec8e6bbeb368bbff97737317684 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Thu, 30 May 2024 00:08:55 +0200 Subject: [PATCH 015/140] Upgrade espMqttClient from 1.6.0 to 1.7.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 0508370d3..b96ecaf52 100644 --- a/platformio.ini +++ b/platformio.ini @@ -40,7 +40,7 @@ build_unflags = lib_deps = mathieucarbou/ESP Async WebServer @ 2.9.5 bblanchon/ArduinoJson @ 7.0.4 - https://github.com/bertmelis/espMqttClient.git#v1.6.0 + https://github.com/bertmelis/espMqttClient.git#v1.7.0 nrf24/RF24 @ 1.4.8 olikraus/U8g2 @ 2.35.19 buelowp/sunset @ 1.1.7 From 5af7e67de7735a5da30aaa8ff09fdff5a2697a47 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Thu, 30 May 2024 00:11:57 +0200 Subject: [PATCH 016/140] Upgrade ESP Async WebServer from 2.9.5 to 2.10.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index b96ecaf52..e846f49ad 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,7 +38,7 @@ build_unflags = -std=gnu++11 lib_deps = - mathieucarbou/ESP Async WebServer @ 2.9.5 + mathieucarbou/ESP Async WebServer @ 2.10.0 bblanchon/ArduinoJson @ 7.0.4 https://github.com/bertmelis/espMqttClient.git#v1.7.0 nrf24/RF24 @ 1.4.8 From 3a4f70dc75b25ddf08342542e819858bed423471 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Thu, 30 May 2024 23:27:29 +0200 Subject: [PATCH 017/140] Added parser documentation --- lib/Hoymiles/src/parser/AlarmLogParser.cpp | 22 ++++++++++++++- lib/Hoymiles/src/parser/DevInfoParser.cpp | 27 ++++++++++++++++++- lib/Hoymiles/src/parser/GridProfileParser.cpp | 17 ++++++++++++ .../src/parser/SystemConfigParaParser.cpp | 16 ++++++++++- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/lib/Hoymiles/src/parser/AlarmLogParser.cpp b/lib/Hoymiles/src/parser/AlarmLogParser.cpp index 652159002..e08baf052 100644 --- a/lib/Hoymiles/src/parser/AlarmLogParser.cpp +++ b/lib/Hoymiles/src/parser/AlarmLogParser.cpp @@ -1,7 +1,27 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022-2023 Thomas Basler and others + * Copyright (C) 2022-2024 Thomas Basler and others */ + +/* +This parser is used to parse the response of 'AlarmDataCommand'. + +Data structure: +* wcode: + * right 8 bit: Event ID + * bit 13: Start time = PM (12h has to be added to start time) + * bit 12: End time = PM (12h has to be added to start time) +* Start: 12h based start time of the event (PM indicator in wcode) +* End: 12h based start time of the event (PM indicator in wcode) + +00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 + 00 01 02 03 04 05 06 07 08 09 10 11 + |<-------------- First log entry -------------->| |<->| +----------------------------------------------------------------------------------------------------------------------------- +95 80 14 82 66 80 14 33 28 01 00 01 80 01 00 01 91 EA 91 EA 00 00 00 00 00 8F 65 -- -- -- -- -- +^^ ^^^^^^^^^^^ ^^^^^^^^^^^ ^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^ ^^ ^^ ^^ ^^^^^ ^^ +ID Source Addr Target Addr Idx ? wcode ? Start End ? ? ? ? wcode CRC8 +*/ #include "AlarmLogParser.h" #include "../Hoymiles.h" #include diff --git a/lib/Hoymiles/src/parser/DevInfoParser.cpp b/lib/Hoymiles/src/parser/DevInfoParser.cpp index 26e3c9d4f..c34ecb3f0 100644 --- a/lib/Hoymiles/src/parser/DevInfoParser.cpp +++ b/lib/Hoymiles/src/parser/DevInfoParser.cpp @@ -1,7 +1,32 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022 - 2023 Thomas Basler and others + * Copyright (C) 2022 - 2024 Thomas Basler and others */ + +/* +This parser is used to parse the response of 'DevInfoAllCommand' and 'DevInfoSimpleCommand'. +It contains version information of the hardware and firmware. It can also be used to determine +the exact inverter type. + +Data structure (DevInfoAllCommand): + +00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 + 00 01 02 03 04 05 06 07 08 09 10 11 12 13 +------------------------------------------------------------------------------------------------------------------------------------------------- +95 80 14 82 66 80 14 33 28 81 27 1C 07 E5 04 01 07 2D 00 01 00 00 00 00 DF DD 1E -- -- -- -- -- +^^ ^^^^^^^^^^^ ^^^^^^^^^^^ ^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^ +ID Source Addr Target Addr Idx FW Version FW Year FW Month/Date FW Hour/Minute Bootloader ? ? CRC16 CRC8 + + +Data structure (DevInfoSimpleCommand): + +00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 + 00 01 02 03 04 05 06 07 08 09 10 11 12 13 +------------------------------------------------------------------------------------------------------------------------------------------------- +95 80 14 82 66 80 14 33 28 81 27 1C 10 12 71 01 01 00 0A 00 20 01 00 00 E5 F8 95 +^^ ^^^^^^^^^^^ ^^^^^^^^^^^ ^^ ^^^^^ ^^^^^^^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^ +ID Source Addr Target Addr Idx FW Version HW Part No. HW Version ? ? ? CRC16 CRC8 +*/ #include "DevInfoParser.h" #include "../Hoymiles.h" #include diff --git a/lib/Hoymiles/src/parser/GridProfileParser.cpp b/lib/Hoymiles/src/parser/GridProfileParser.cpp index 7c08d51b0..74fcf6531 100644 --- a/lib/Hoymiles/src/parser/GridProfileParser.cpp +++ b/lib/Hoymiles/src/parser/GridProfileParser.cpp @@ -2,6 +2,23 @@ /* * Copyright (C) 2023 - 2024 Thomas Basler and others */ + +/* +This parser is used to parse the response of 'GridOnProFilePara'. +It contains the whole grid profile of the inverter. + +Data structure: + +00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 + 00 01 02 03 04 05 06 07 08 09 10 11 12 13 + |<---------- Returns till the end of the payload ---------->| +--------------------------------------------------------------------------------------------------------------------------------------------------------------- +95 80 14 82 66 80 14 33 28 01 0A 00 20 01 00 0C 08 FC 07 A3 00 0F 09 E2 00 1E E6 -- -- -- -- -- +^^ ^^^^^^^^^^^ ^^^^^^^^^^^ ^^ ^^^^^ ^^^^^ ^^ ^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^ +ID Source Addr Target Addr Idx Profile ID Profile Version Section ID Section Version Value Value Value Value CRC16 CRC8 + +The number of values depends on the respective section and its version. After the last value of a section follows the next section id. +*/ #include "GridProfileParser.h" #include "../Hoymiles.h" #include diff --git a/lib/Hoymiles/src/parser/SystemConfigParaParser.cpp b/lib/Hoymiles/src/parser/SystemConfigParaParser.cpp index e866e8749..346b5d468 100644 --- a/lib/Hoymiles/src/parser/SystemConfigParaParser.cpp +++ b/lib/Hoymiles/src/parser/SystemConfigParaParser.cpp @@ -1,7 +1,21 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022 - 2023 Thomas Basler and others + * Copyright (C) 2022 - 2024 Thomas Basler and others */ + +/* +This parser is used to parse the response of 'SystemConfigParaCommand'. +It contains the set inverter limit. + +Data structure: + +00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 + 00 01 02 03 04 05 06 07 08 09 10 11 12 13 +--------------------------------------------------------------------------------------------------------------------------------- +95 80 14 82 66 80 14 33 28 81 00 01 03 E8 00 00 03 E8 00 00 00 00 00 00 3C F8 2E -- -- -- -- -- +^^ ^^^^^^^^^^^ ^^^^^^^^^^^ ^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^ +ID Source Addr Target Addr Idx ? Limit percent ? ? ? ? ? CRC16 CRC8 +*/ #include "SystemConfigParaParser.h" #include "../Hoymiles.h" #include From 6e607f7f67a535078a337681a249a82b316876c7 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Fri, 31 May 2024 00:07:28 +0200 Subject: [PATCH 018/140] Feature: Add option to clear eventlog at midnight --- include/Configuration.h | 1 + lib/Hoymiles/src/Hoymiles.cpp | 3 +++ lib/Hoymiles/src/inverters/InverterAbstract.cpp | 10 ++++++++++ lib/Hoymiles/src/inverters/InverterAbstract.h | 6 +++++- src/Configuration.cpp | 2 ++ src/InverterSettings.cpp | 1 + src/WebApi_inverter.cpp | 3 +++ webapp/src/locales/de.json | 1 + webapp/src/locales/en.json | 1 + webapp/src/locales/fr.json | 1 + webapp/src/types/InverterConfig.ts | 1 + webapp/src/views/InverterAdminView.vue | 2 ++ 12 files changed, 31 insertions(+), 1 deletion(-) diff --git a/include/Configuration.h b/include/Configuration.h index e13b558aa..0ad2cb397 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -47,6 +47,7 @@ struct INVERTER_CONFIG_T { uint8_t ReachableThreshold; bool ZeroRuntimeDataIfUnrechable; bool ZeroYieldDayOnMidnight; + bool ClearEventlogOnMidnight; bool YieldDayCorrection; CHANNEL_CONFIG_T channel[INV_MAX_CHAN_COUNT]; }; diff --git a/lib/Hoymiles/src/Hoymiles.cpp b/lib/Hoymiles/src/Hoymiles.cpp index b14585905..1416a73ab 100644 --- a/lib/Hoymiles/src/Hoymiles.cpp +++ b/lib/Hoymiles/src/Hoymiles.cpp @@ -141,6 +141,9 @@ void HoymilesClass::loop() if (inv->getZeroYieldDayOnMidnight()) { inv->Statistics()->zeroDailyData(); } + if (inv->getClearEventlogOnMidnight()) { + inv->EventLog()->clearBuffer(); + } } lastWeekDay = currentWeekDay; diff --git a/lib/Hoymiles/src/inverters/InverterAbstract.cpp b/lib/Hoymiles/src/inverters/InverterAbstract.cpp index 17a0d4e78..68d611836 100644 --- a/lib/Hoymiles/src/inverters/InverterAbstract.cpp +++ b/lib/Hoymiles/src/inverters/InverterAbstract.cpp @@ -127,6 +127,16 @@ bool InverterAbstract::getZeroYieldDayOnMidnight() const return _zeroYieldDayOnMidnight; } +void InverterAbstract::setClearEventlogOnMidnight(const bool enabled) +{ + _clearEventlogOnMidnight = enabled; +} + +bool InverterAbstract::getClearEventlogOnMidnight() const +{ + return _clearEventlogOnMidnight; +} + bool InverterAbstract::sendChangeChannelRequest() { return false; diff --git a/lib/Hoymiles/src/inverters/InverterAbstract.h b/lib/Hoymiles/src/inverters/InverterAbstract.h index 3d9929d7b..2a51079ba 100644 --- a/lib/Hoymiles/src/inverters/InverterAbstract.h +++ b/lib/Hoymiles/src/inverters/InverterAbstract.h @@ -58,6 +58,9 @@ class InverterAbstract { void setZeroYieldDayOnMidnight(const bool enabled); bool getZeroYieldDayOnMidnight() const; + void setClearEventlogOnMidnight(const bool enabled); + bool getClearEventlogOnMidnight() const; + void clearRxFragmentBuffer(); void addRxFragment(const uint8_t fragment[], const uint8_t len); uint8_t verifyAllFragments(CommandAbstract& cmd); @@ -102,6 +105,7 @@ class InverterAbstract { bool _zeroValuesIfUnreachable = false; bool _zeroYieldDayOnMidnight = false; + bool _clearEventlogOnMidnight = false; std::unique_ptr _alarmLogParser; std::unique_ptr _devInfoParser; @@ -109,4 +113,4 @@ class InverterAbstract { std::unique_ptr _powerCommandParser; std::unique_ptr _statisticsParser; std::unique_ptr _systemConfigParaParser; -}; \ No newline at end of file +}; diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 8e8030745..780abb8f4 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -128,6 +128,7 @@ bool ConfigurationClass::write() inv["reachable_threshold"] = config.Inverter[i].ReachableThreshold; inv["zero_runtime"] = config.Inverter[i].ZeroRuntimeDataIfUnrechable; inv["zero_day"] = config.Inverter[i].ZeroYieldDayOnMidnight; + inv["clear_eventlog"] = config.Inverter[i].ClearEventlogOnMidnight; inv["yieldday_correction"] = config.Inverter[i].YieldDayCorrection; JsonArray channel = inv["channel"].to(); @@ -302,6 +303,7 @@ bool ConfigurationClass::read() config.Inverter[i].ReachableThreshold = inv["reachable_threshold"] | REACHABLE_THRESHOLD; config.Inverter[i].ZeroRuntimeDataIfUnrechable = inv["zero_runtime"] | false; config.Inverter[i].ZeroYieldDayOnMidnight = inv["zero_day"] | false; + config.Inverter[i].ClearEventlogOnMidnight = inv["clear_eventlog"] | false; config.Inverter[i].YieldDayCorrection = inv["yieldday_correction"] | false; JsonArray channel = inv["channel"]; diff --git a/src/InverterSettings.cpp b/src/InverterSettings.cpp index c08585e2b..0e903187d 100644 --- a/src/InverterSettings.cpp +++ b/src/InverterSettings.cpp @@ -82,6 +82,7 @@ void InverterSettingsClass::init(Scheduler& scheduler) inv->setReachableThreshold(config.Inverter[i].ReachableThreshold); inv->setZeroValuesIfUnreachable(config.Inverter[i].ZeroRuntimeDataIfUnrechable); inv->setZeroYieldDayOnMidnight(config.Inverter[i].ZeroYieldDayOnMidnight); + inv->setClearEventlogOnMidnight(config.Inverter[i].ClearEventlogOnMidnight); inv->Statistics()->setYieldDayCorrection(config.Inverter[i].YieldDayCorrection); for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) { inv->Statistics()->setStringMaxPower(c, config.Inverter[i].channel[c].MaxChannelPower); diff --git a/src/WebApi_inverter.cpp b/src/WebApi_inverter.cpp index 2d9a56344..0a38d6340 100644 --- a/src/WebApi_inverter.cpp +++ b/src/WebApi_inverter.cpp @@ -55,6 +55,7 @@ void WebApiInverterClass::onInverterList(AsyncWebServerRequest* request) obj["reachable_threshold"] = config.Inverter[i].ReachableThreshold; obj["zero_runtime"] = config.Inverter[i].ZeroRuntimeDataIfUnrechable; obj["zero_day"] = config.Inverter[i].ZeroYieldDayOnMidnight; + obj["clear_eventlog"] = config.Inverter[i].ClearEventlogOnMidnight; obj["yieldday_correction"] = config.Inverter[i].YieldDayCorrection; auto inv = Hoymiles.getInverterBySerial(config.Inverter[i].Serial); @@ -225,6 +226,7 @@ void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request) inverter.ReachableThreshold = root["reachable_threshold"] | REACHABLE_THRESHOLD; inverter.ZeroRuntimeDataIfUnrechable = root["zero_runtime"] | false; inverter.ZeroYieldDayOnMidnight = root["zero_day"] | false; + inverter.ClearEventlogOnMidnight = root["clear_eventlog"] | false; inverter.YieldDayCorrection = root["yieldday_correction"] | false; arrayCount++; @@ -254,6 +256,7 @@ void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request) inv->setReachableThreshold(inverter.ReachableThreshold); inv->setZeroValuesIfUnreachable(inverter.ZeroRuntimeDataIfUnrechable); inv->setZeroYieldDayOnMidnight(inverter.ZeroYieldDayOnMidnight); + inv->setClearEventlogOnMidnight(inverter.ClearEventlogOnMidnight); inv->Statistics()->setYieldDayCorrection(inverter.YieldDayCorrection); for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) { inv->Statistics()->setStringMaxPower(c, inverter.channel[c].MaxChannelPower); diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index e4b8f62df..8b132271f 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -515,6 +515,7 @@ "ZeroRuntimeHint": "Nulle Laufzeit Daten (keine Ertragsdaten), wenn der Wechselrichter nicht erreichbar ist.", "ZeroDay": "Nulle Tagesertrag um Mitternacht", "ZeroDayHint": "Das funktioniert nur wenn der Wechselrichter nicht erreichbar ist. Wenn Daten aus dem Wechselrichter gelesen werden, werden deren Werte verwendet. (Ein Reset erfolgt nur beim Neustarten)", + "ClearEventlog": "Lösche Ereignisanzeige um Mitternacht", "Cancel": "@:base.Cancel", "Save": "@:base.Save", "DeleteMsg": "Soll der Wechselrichter \"{name}\" mit der Seriennummer {serial} wirklich gelöscht werden?", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 5eb9c4b7c..c918e5375 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -515,6 +515,7 @@ "ZeroRuntimeHint": "Zero runtime data (no yield data) if inverter becomes unreachable.", "ZeroDay": "Zero daily yield at midnight", "ZeroDayHint": "This only works if the inverter is unreachable. If data is read from the inverter, it's values will be used. (Reset only occours on power cycle)", + "ClearEventlog": "Clear Eventlog at midnight", "Cancel": "@:base.Cancel", "Save": "@:base.Save", "DeleteMsg": "Are you sure you want to delete the inverter \"{name}\" with serial number {serial}?", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 5b9673f9e..acd1280f7 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -515,6 +515,7 @@ "ZeroRuntimeHint": "Zero runtime data (no yield data) if inverter becomes unreachable.", "ZeroDay": "Zero daily yield at midnight", "ZeroDayHint": "This only works if the inverter is unreachable. If data is read from the inverter, it's values will be used. (Reset only occours on power cycle)", + "ClearEventlog": "Clear Eventlog at midnight", "Cancel": "@:base.Cancel", "Save": "@:base.Save", "DeleteMsg": "Êtes-vous sûr de vouloir supprimer l'onduleur \"{name}\" avec le numéro de série \"{serial}\" ?", diff --git a/webapp/src/types/InverterConfig.ts b/webapp/src/types/InverterConfig.ts index da7fa43c4..ba268c4f6 100644 --- a/webapp/src/types/InverterConfig.ts +++ b/webapp/src/types/InverterConfig.ts @@ -17,6 +17,7 @@ export interface Inverter { reachable_threshold: number; zero_runtime: boolean; zero_day: boolean; + clear_eventlog: boolean; yieldday_correction: boolean; channel: Array; } diff --git a/webapp/src/views/InverterAdminView.vue b/webapp/src/views/InverterAdminView.vue index 9216f592f..5953a7a2d 100644 --- a/webapp/src/views/InverterAdminView.vue +++ b/webapp/src/views/InverterAdminView.vue @@ -176,6 +176,8 @@ + + From 8e8c46384933cc12d25d89bab8a48573dc6ef92d Mon Sep 17 00:00:00 2001 From: Stefan Oberhumer Date: Thu, 30 May 2024 23:10:51 +0200 Subject: [PATCH 019/140] NFC: Includes list: Remove unneeded PinMapping.h --- src/Utils.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Utils.cpp b/src/Utils.cpp index 6bedd2cbd..6abe4dd19 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -1,12 +1,12 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2022 - 2023 Thomas Basler and others + * Copyright (C) 2022 - 2024 Thomas Basler and others */ + #include "Utils.h" #include "Display_Graphic.h" #include "Led_Single.h" #include "MessageOutput.h" -#include "PinMapping.h" #include #include From b27a4765079c23c5060fe478a95a0f294aab9803 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Fri, 31 May 2024 00:56:15 +0200 Subject: [PATCH 020/140] Fix: Apply inverter settings only once and not for each channel --- src/WebApi_inverter.cpp | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/WebApi_inverter.cpp b/src/WebApi_inverter.cpp index 0a38d6340..5a8585f70 100644 --- a/src/WebApi_inverter.cpp +++ b/src/WebApi_inverter.cpp @@ -214,21 +214,21 @@ void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request) inverter.Serial = new_serial; strncpy(inverter.Name, root["name"].as().c_str(), INV_MAX_NAME_STRLEN); + inverter.Poll_Enable = root["poll_enable"] | true; + inverter.Poll_Enable_Night = root["poll_enable_night"] | true; + inverter.Command_Enable = root["command_enable"] | true; + inverter.Command_Enable_Night = root["command_enable_night"] | true; + inverter.ReachableThreshold = root["reachable_threshold"] | REACHABLE_THRESHOLD; + inverter.ZeroRuntimeDataIfUnrechable = root["zero_runtime"] | false; + inverter.ZeroYieldDayOnMidnight = root["zero_day"] | false; + inverter.ClearEventlogOnMidnight = root["clear_eventlog"] | false; + inverter.YieldDayCorrection = root["yieldday_correction"] | false; + uint8_t arrayCount = 0; for (JsonVariant channel : channelArray) { inverter.channel[arrayCount].MaxChannelPower = channel["max_power"].as(); inverter.channel[arrayCount].YieldTotalOffset = channel["yield_total_offset"].as(); strncpy(inverter.channel[arrayCount].Name, channel["name"] | "", sizeof(inverter.channel[arrayCount].Name)); - inverter.Poll_Enable = root["poll_enable"] | true; - inverter.Poll_Enable_Night = root["poll_enable_night"] | true; - inverter.Command_Enable = root["command_enable"] | true; - inverter.Command_Enable_Night = root["command_enable_night"] | true; - inverter.ReachableThreshold = root["reachable_threshold"] | REACHABLE_THRESHOLD; - inverter.ZeroRuntimeDataIfUnrechable = root["zero_runtime"] | false; - inverter.ZeroYieldDayOnMidnight = root["zero_day"] | false; - inverter.ClearEventlogOnMidnight = root["clear_eventlog"] | false; - inverter.YieldDayCorrection = root["yieldday_correction"] | false; - arrayCount++; } From cffa7a2b2c774812150e0215b6de1fabba452713 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Fri, 31 May 2024 01:01:47 +0200 Subject: [PATCH 021/140] Remove no more required async_tcp patch --- patches/async_tcp/event_queue_size.patch | 26 ------------------------ platformio.ini | 2 +- 2 files changed, 1 insertion(+), 27 deletions(-) delete mode 100644 patches/async_tcp/event_queue_size.patch diff --git a/patches/async_tcp/event_queue_size.patch b/patches/async_tcp/event_queue_size.patch deleted file mode 100644 index 1280d46a8..000000000 --- a/patches/async_tcp/event_queue_size.patch +++ /dev/null @@ -1,26 +0,0 @@ -diff --color -ruN a/.pio/libdeps/$$$env$$$/AsyncTCP-esphome/src/AsyncTCP.cpp b/.pio/libdeps/$$$env$$$/AsyncTCP-esphome/src/AsyncTCP.cpp ---- a/.pio/libdeps/$$$env$$$/AsyncTCP-esphome/src/AsyncTCP.cpp -+++ b/.pio/libdeps/$$$env$$$/AsyncTCP-esphome/src/AsyncTCP.cpp -@@ -97,7 +97,7 @@ - - static inline bool _init_async_event_queue(){ - if(!_async_queue){ -- _async_queue = xQueueCreate(32, sizeof(lwip_event_packet_t *)); -+ _async_queue = xQueueCreate(CONFIG_ASYNC_TCP_EVENT_QUEUE_SIZE, sizeof(lwip_event_packet_t *)); - if(!_async_queue){ - return false; - } -diff --color -ruN a/.pio/libdeps/$$$env$$$/AsyncTCP-esphome/src/AsyncTCP.h b/.pio/libdeps/$$$env$$$/AsyncTCP-esphome/src/AsyncTCP.h ---- a/.pio/libdeps/$$$env$$$/AsyncTCP-esphome/src/AsyncTCP.h -+++ b/.pio/libdeps/$$$env$$$/AsyncTCP-esphome/src/AsyncTCP.h -@@ -53,6 +53,10 @@ - #define CONFIG_ASYNC_TCP_STACK_SIZE 8192 * 2 - #endif - -+#ifndef CONFIG_ASYNC_TCP_EVENT_QUEUE_SIZE -+#define CONFIG_ASYNC_TCP_EVENT_QUEUE_SIZE 32 -+#endif -+ - class AsyncClient; - - #define ASYNC_MAX_ACK_TIME 5000 diff --git a/platformio.ini b/platformio.ini index e846f49ad..636855646 100644 --- a/platformio.ini +++ b/platformio.ini @@ -61,7 +61,7 @@ board_build.embed_files = webapp_dist/js/app.js.gz webapp_dist/site.webmanifest -custom_patches = async_tcp +custom_patches = monitor_filters = esp32_exception_decoder, time, log2file, colorize monitor_speed = 115200 From 7548fceb4844f4d22d9da2fc87a0585c4c420e42 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Sat, 1 Jun 2024 20:35:19 +0200 Subject: [PATCH 022/140] check FW bin file size when creating factory.bin --- pio-scripts/create_factory_bin.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pio-scripts/create_factory_bin.py b/pio-scripts/create_factory_bin.py index 56f71c4b1..d394998b2 100644 --- a/pio-scripts/create_factory_bin.py +++ b/pio-scripts/create_factory_bin.py @@ -21,7 +21,7 @@ platform = env.PioPlatform() import sys -from os.path import join +from os.path import join, getsize sys.path.append(join(platform.get_package_dir("tool-esptoolpy"))) import esptool @@ -60,6 +60,14 @@ def esp32_create_combined_bin(source, target, env): flash_size, ] + # platformio estimates the amount of flash used to store the firmware. this + # estimate is not accurate. we perform a final check on the firmware bin + # size by comparing it against the respective partition size. + max_size = env.BoardConfig().get("upload.maximum_size", 1) + fw_size = getsize(firmware_name) + if (fw_size > max_size): + raise Exception("firmware binary too large: %d > %d" % (fw_size, max_size)) + print(" Offset | File") for section in sections: sect_adr, sect_file = section.split(" ", 1) From b1a8f04617cc9af3fe9432ed892cda8f7a28ed9f Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Sun, 2 Jun 2024 13:56:13 +0200 Subject: [PATCH 023/140] Fix: Wrong divider in gridprofile RVHF Fixes #2021 The result may look wrong for some profiles (e.g. 502 Hz) but it seems to be correct as the Hoymiles parser also outputs 502 Hz. See #1606 --- lib/Hoymiles/src/parser/GridProfileParser.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Hoymiles/src/parser/GridProfileParser.cpp b/lib/Hoymiles/src/parser/GridProfileParser.cpp index 74fcf6531..489565e19 100644 --- a/lib/Hoymiles/src/parser/GridProfileParser.cpp +++ b/lib/Hoymiles/src/parser/GridProfileParser.cpp @@ -99,7 +99,7 @@ constexpr frozen::map itemDefinition { 0x1f, make_value("Start of Frequency Watt Droop (Fstart)", "Hz", 100) }, { 0x20, make_value("FW Droop Slope (Kpower_Freq)", "Pn%/Hz", 10) }, { 0x21, make_value("Recovery Ramp Rate (RRR)", "Pn%/s", 100) }, - { 0x22, make_value("Recovery High Frequency (RVHF)", "Hz", 100) }, + { 0x22, make_value("Recovery High Frequency (RVHF)", "Hz", 10) }, { 0x23, make_value("Recovery Low Frequency (RVLF)", "Hz", 100) }, { 0x24, make_value("VW Function Activated", "bool", 1) }, { 0x25, make_value("Start of Voltage Watt Droop (Vstart)", "V", 10) }, From b2515753c172f98e00909fbeed26cd5635c72119 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Sun, 2 Jun 2024 14:13:32 +0200 Subject: [PATCH 024/140] Upgrade ESP Async WebServer from 2.10.0 to 2.10.3 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 636855646..e16f5c5d3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,7 +38,7 @@ build_unflags = -std=gnu++11 lib_deps = - mathieucarbou/ESP Async WebServer @ 2.10.0 + mathieucarbou/ESP Async WebServer @ 2.10.3 bblanchon/ArduinoJson @ 7.0.4 https://github.com/bertmelis/espMqttClient.git#v1.7.0 nrf24/RF24 @ 1.4.8 From d940932d3c845dc28612a7bbde051a6545d51a47 Mon Sep 17 00:00:00 2001 From: nexulm Date: Tue, 4 Jun 2024 11:28:03 +0200 Subject: [PATCH 025/140] Update wt32-eth01.json SH1106 (I2C = Type3) support for joy-it 128x64 1,3" OLED SBC-OLED01.3 display added --- docs/DeviceProfiles/wt32-eth01.json | 30 ++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/DeviceProfiles/wt32-eth01.json b/docs/DeviceProfiles/wt32-eth01.json index 8af112832..b7f5cbdd4 100644 --- a/docs/DeviceProfiles/wt32-eth01.json +++ b/docs/DeviceProfiles/wt32-eth01.json @@ -22,6 +22,34 @@ "clk_mode": 0 } }, + { + "name": "WT32-ETH01 with SH1106", + "links": [ + {"name": "Datasheet", "url": "http://www.wireless-tag.com/portfolio/wt32-eth01/"} + ], + "nrf24": { + "miso": 4, + "mosi": 2, + "clk": 32, + "irq": 33, + "en": 14, + "cs": 15 + }, + "eth": { + "enabled": true, + "phy_addr": 1, + "power": 16, + "mdc": 23, + "mdio": 18, + "type": 0, + "clk_mode": 0 + }, + "display": { + "type": 3, + "data": 5, + "clk": 17 + } + }, { "name": "WT32-ETH01 with SSD1306", "links": [ @@ -78,4 +106,4 @@ "clk": 17 } } -] \ No newline at end of file +] From 8ef28e27b428e8bce8474ef8d1813f77e813ff04 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Sat, 8 Jun 2024 11:11:31 +0200 Subject: [PATCH 026/140] webapp: update dependencies --- webapp/package.json | 18 +-- webapp/yarn.lock | 292 ++++++++++++++++++++++---------------------- 2 files changed, 158 insertions(+), 152 deletions(-) diff --git a/webapp/package.json b/webapp/package.json index e758594da..e588a99ea 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -18,7 +18,7 @@ "mitt": "^3.0.1", "sortablejs": "^1.15.2", "spark-md5": "^3.0.2", - "vue": "^3.4.26", + "vue": "^3.4.27", "vue-i18n": "^9.13.1", "vue-router": "^4.3.2" }, @@ -26,23 +26,23 @@ "@intlify/unplugin-vue-i18n": "^4.0.0", "@tsconfig/node18": "^18.2.4", "@types/bootstrap": "^5.2.10", - "@types/node": "^20.12.10", + "@types/node": "^20.14.2", "@types/pulltorefreshjs": "^0.1.7", "@types/sortablejs": "^1.15.8", "@types/spark-md5": "^3.0.4", - "@vitejs/plugin-vue": "^5.0.4", + "@vitejs/plugin-vue": "^5.0.5", "@vue/eslint-config-typescript": "^13.0.0", "@vue/tsconfig": "^0.5.1", - "eslint": "^9.2.0", - "eslint-plugin-vue": "^9.25.0", + "eslint": "^9.4.0", + "eslint-plugin-vue": "^9.26.0", "npm-run-all": "^4.1.5", "pulltorefreshjs": "^0.1.22", - "sass": "^1.76.0", - "terser": "^5.31.0", + "sass": "^1.77.4", + "terser": "^5.31.1", "typescript": "^5.4.5", - "vite": "^5.2.11", + "vite": "^5.2.13", "vite-plugin-compression": "^0.5.1", "vite-plugin-css-injected-by-js": "^3.5.1", - "vue-tsc": "^2.0.16" + "vue-tsc": "^2.0.20" } } diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 57f29ff91..0684166d4 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -161,10 +161,19 @@ resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.6.2.tgz#1816b5f6948029c5eaacb0703b850ee0cb37d8f8" integrity sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw== -"@eslint/eslintrc@^3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.0.2.tgz#36180f8e85bf34d2fe3ccc2261e8e204a411ab4e" - integrity sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg== +"@eslint/config-array@^0.15.1": + version "0.15.1" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.15.1.tgz#1fa78b422d98f4e7979f2211a1fde137e26c7d61" + integrity sha512-K4gzNq+yymn/EVsXYmf+SBcBro8MTf+aXJZUphM96CdzUEr+ClGDvAbpmaEK+cGVigVXIgs9gNmvHAlrzzY5JQ== + dependencies: + "@eslint/object-schema" "^2.1.3" + debug "^4.3.1" + minimatch "^3.0.5" + +"@eslint/eslintrc@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.1.0.tgz#dbd3482bfd91efa663cbe7aa1f506839868207b6" + integrity sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ== dependencies: ajv "^6.12.4" debug "^4.3.2" @@ -176,34 +185,25 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@9.2.0": - version "9.2.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.2.0.tgz#b0a9123e8e91a3d9a2eed3a04a6ed44fdab639aa" - integrity sha512-ESiIudvhoYni+MdsI8oD7skpprZ89qKocwRM2KEvhhBJ9nl5MRh7BXU5GTod7Mdygq+AUl+QzId6iWJKR/wABA== +"@eslint/js@9.4.0": + version "9.4.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.4.0.tgz#96a2edd37ec0551ce5f9540705be23951c008a0c" + integrity sha512-fdI7VJjP3Rvc70lC4xkFXHB0fiPeojiL1PxVG6t1ZvXQrarj893PweuBTujxDUFk0Fxj4R7PIIAZ/aiiyZPZcg== -"@humanwhocodes/config-array@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" - integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== - dependencies: - "@humanwhocodes/object-schema" "^2.0.3" - debug "^4.3.1" - minimatch "^3.0.5" +"@eslint/object-schema@^2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.3.tgz#e65ae80ee2927b4fd8c5c26b15ecacc2b2a6cc2a" + integrity sha512-HAbhAYKfsAC2EkTqve00ibWIZlaU74Z1EHwAjYr4PXF0YU2VEA1zSIKSSpKszRLRWwHzzRZXvK632u+uXzvsvw== "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/object-schema@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" - integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== - -"@humanwhocodes/retry@^0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.2.3.tgz#c9aa036d1afa643f1250e83150f39efb3a15a631" - integrity sha512-X38nUbachlb01YMlvPFojKoiXq+LzZvuSce70KPMPdeM1Rj03k4dR7lDslhbqXn3Ang4EU3+EAmwEAsbrjHW3g== +"@humanwhocodes/retry@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.3.0.tgz#6d86b8cb322660f03d3f0aa94b99bdd8e172d570" + integrity sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew== "@intlify/bundle-utils@^8.0.0": version "8.0.0" @@ -449,10 +449,10 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== -"@types/node@^20.12.10": - version "20.12.10" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.10.tgz#8f0c3f12b0f075eee1fe20c1afb417e9765bef76" - integrity sha512-Eem5pH9pmWBHoGAT8Dr5fdc5rYA+4NAovdM4EktRPVAAiJhmWWfQrA0cFhAbOsQdSfIHjAud6YdkbL69+zSKjw== +"@types/node@^20.14.2": + version "20.14.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18" + integrity sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q== dependencies: undici-types "~5.26.4" @@ -562,32 +562,33 @@ "@typescript-eslint/types" "7.2.0" eslint-visitor-keys "^3.4.1" -"@vitejs/plugin-vue@^5.0.4": - version "5.0.4" - resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz#508d6a0f2440f86945835d903fcc0d95d1bb8a37" - integrity sha512-WS3hevEszI6CEVEx28F8RjTX97k3KsrcY6kvTg7+Whm5y3oYvcqzVeGCU3hxSAn4uY2CLCkeokkGKpoctccilQ== +"@vitejs/plugin-vue@^5.0.5": + version "5.0.5" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.5.tgz#e3dc11e427d4b818b7e3202766ad156e3d5e2eaa" + integrity sha512-LOjm7XeIimLBZyzinBQ6OSm3UBCNVCpLkxGC0oWmm2YPzVZoxMsdvNVimLTBzpAnR9hl/yn1SHGuRfe6/Td9rQ== -"@volar/language-core@2.2.1", "@volar/language-core@~2.2.0": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.2.1.tgz#bb4a28f93cd8598a2e2ca1c811ae113a848b5529" - integrity sha512-iHJAZKcYldZgyS8gx6DfIZApViVBeqbf6iPhqoZpG5A6F4zsZiFldKfwaKaBA3/wnOTWE2i8VUbXywI1WywCPg== +"@volar/language-core@2.3.0-alpha.14", "@volar/language-core@~2.3.0-alpha.14": + version "2.3.0-alpha.14" + resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.3.0-alpha.14.tgz#944acd09c096cc53c63605d6e97f87e37973ffe2" + integrity sha512-80HmdD27fPHs+EB9s5RIdRFdvKil2xXMbsKSPYcPFOLP3iysOJ/i9OKnG83Rhgn6rTLJdfM97WOdx/dsBwJtag== dependencies: - "@volar/source-map" "2.2.1" + "@volar/source-map" "2.3.0-alpha.14" -"@volar/source-map@2.2.1": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.2.1.tgz#d75b0c38659d3ea7e780d4251ac2b9436845ab97" - integrity sha512-w1Bgpguhbp7YTr7VUFu6gb4iAZjeEPsOX4zpgiuvlldbzvIWDWy4t0jVifsIsxZ99HAu+c3swiME7wt+GeNqhA== +"@volar/source-map@2.3.0-alpha.14": + version "2.3.0-alpha.14" + resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.3.0-alpha.14.tgz#5502f0edd16b692049f7e5232074be8af71ba469" + integrity sha512-la0CSIfo593WRga2r9STkCtObECX/3xZs4cQKlygU8G13zCtYP8uOQc/jgBQEQK3ne50i7X4Z0ZRLj9ht8+Ppg== dependencies: muggle-string "^0.4.0" -"@volar/typescript@~2.2.0": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.2.1.tgz#21585b46cd61c9d63715642ee10418b144b12321" - integrity sha512-Z/tqluR7Hz5/5dCqQp7wo9C/6tSv/IYl+tTzgzUt2NjTq95bKSsuO4E+V06D0c+3aP9x5S9jggLqw451hpnc6Q== +"@volar/typescript@~2.3.0-alpha.14": + version "2.3.0-alpha.14" + resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.3.0-alpha.14.tgz#65a7ea478b558deaa47dfe85498420dbca24df47" + integrity sha512-YnaivvHu/HlVgFRUFPh3X42GXYawSIXPvkIGND/RZXJ1iyrj9CB/UEtsMUV55TOULbfJyc92F2EpOMn/lMyqwA== dependencies: - "@volar/language-core" "2.2.1" + "@volar/language-core" "2.3.0-alpha.14" path-browserify "^1.0.1" + vscode-uri "^3.0.8" "@vue/compiler-core@3.2.47": version "3.2.47" @@ -610,13 +611,13 @@ estree-walker "^2.0.2" source-map-js "^1.0.2" -"@vue/compiler-core@3.4.26": - version "3.4.26" - resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.4.26.tgz#d507886520e83a6f8339ed55ed0b2b5d84b44b73" - integrity sha512-N9Vil6Hvw7NaiyFUFBPXrAyETIGlQ8KcFMkyk6hW1Cl6NvoqvP+Y8p1Eqvx+UdqsnrnI9+HMUEJegzia3mhXmQ== +"@vue/compiler-core@3.4.27": + version "3.4.27" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.4.27.tgz#e69060f4b61429fe57976aa5872cfa21389e4d91" + integrity sha512-E+RyqY24KnyDXsCuQrI+mlcdW3ALND6U7Gqa/+bVwbcpcR3BRRIckFoz7Qyd4TTlnugtwuI7YgjbvsLmxb+yvg== dependencies: "@babel/parser" "^7.24.4" - "@vue/shared" "3.4.26" + "@vue/shared" "3.4.27" entities "^4.5.0" estree-walker "^2.0.2" source-map-js "^1.2.0" @@ -629,13 +630,13 @@ "@vue/compiler-core" "3.2.47" "@vue/shared" "3.2.47" -"@vue/compiler-dom@3.4.26": - version "3.4.26" - resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.4.26.tgz#acc7b788b48152d087d4bb9e655b795e3dbec554" - integrity sha512-4CWbR5vR9fMg23YqFOhr6t6WB1Fjt62d6xdFPyj8pxrYub7d+OgZaObMsoxaF9yBUHPMiPFK303v61PwAuGvZA== +"@vue/compiler-dom@3.4.27": + version "3.4.27" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.4.27.tgz#d51d35f40d00ce235d7afc6ad8b09dfd92b1cc1c" + integrity sha512-kUTvochG/oVgE1w5ViSr3KUBh9X7CWirebA3bezTbB5ZKBQZwR2Mwj9uoSKRMFcz4gSMzzLXBPD6KpCLb9nvWw== dependencies: - "@vue/compiler-core" "3.4.26" - "@vue/shared" "3.4.26" + "@vue/compiler-core" "3.4.27" + "@vue/shared" "3.4.27" "@vue/compiler-dom@^3.4.0": version "3.4.21" @@ -645,16 +646,16 @@ "@vue/compiler-core" "3.4.21" "@vue/shared" "3.4.21" -"@vue/compiler-sfc@3.4.26": - version "3.4.26" - resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.4.26.tgz#c679f206829954c3c078d8a9be76d0098b8377ae" - integrity sha512-It1dp+FAOCgluYSVYlDn5DtZBxk1NCiJJfu2mlQqa/b+k8GL6NG/3/zRbJnHdhV2VhxFghaDq5L4K+1dakW6cw== +"@vue/compiler-sfc@3.4.27": + version "3.4.27" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.4.27.tgz#399cac1b75c6737bf5440dc9cf3c385bb2959701" + integrity sha512-nDwntUEADssW8e0rrmE0+OrONwmRlegDA1pD6QhVeXxjIytV03yDqTey9SBDiALsvAd5U4ZrEKbMyVXhX6mCGA== dependencies: "@babel/parser" "^7.24.4" - "@vue/compiler-core" "3.4.26" - "@vue/compiler-dom" "3.4.26" - "@vue/compiler-ssr" "3.4.26" - "@vue/shared" "3.4.26" + "@vue/compiler-core" "3.4.27" + "@vue/compiler-dom" "3.4.27" + "@vue/compiler-ssr" "3.4.27" + "@vue/shared" "3.4.27" estree-walker "^2.0.2" magic-string "^0.30.10" postcss "^8.4.38" @@ -684,13 +685,13 @@ "@vue/compiler-dom" "3.2.47" "@vue/shared" "3.2.47" -"@vue/compiler-ssr@3.4.26": - version "3.4.26" - resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.4.26.tgz#22842d8adfff972d87bb798b8d496111f7f814b5" - integrity sha512-FNwLfk7LlEPRY/g+nw2VqiDKcnDTVdCfBREekF8X74cPLiWHUX6oldktf/Vx28yh4STNy7t+/yuLoMBBF7YDiQ== +"@vue/compiler-ssr@3.4.27": + version "3.4.27" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.4.27.tgz#2a8ecfef1cf448b09be633901a9c020360472e3d" + integrity sha512-CVRzSJIltzMG5FcidsW0jKNQnNRYC8bT21VegyMMtHmhW3UOI7knmUehzswXLrExDLE6lQCZdrhD4ogI7c+vuw== dependencies: - "@vue/compiler-dom" "3.4.26" - "@vue/shared" "3.4.26" + "@vue/compiler-dom" "3.4.27" + "@vue/shared" "3.4.27" "@vue/devtools-api@^6.5.0": version "6.5.0" @@ -711,12 +712,12 @@ "@typescript-eslint/parser" "^7.1.1" vue-eslint-parser "^9.3.1" -"@vue/language-core@2.0.16": - version "2.0.16" - resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-2.0.16.tgz#c059228e6a0a17b4505421da0e5747a4a04facbe" - integrity sha512-Bc2sexRH99pznOph8mLw2BlRZ9edm7tW51kcBXgx8adAoOcZUWJj3UNSsdQ6H9Y8meGz7BoazVrVo/jUukIsPw== +"@vue/language-core@2.0.20": + version "2.0.20" + resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-2.0.20.tgz#32cbd60deada2e83f96614301af6b1e81adac3bf" + integrity sha512-PudZnVVhZV9++4xndha6K8G1P+pa5WB4H926IK6Pn82EKD+7MEnBJ858t+cI5jpXqx1X/72+NfzRrgsocN5LrA== dependencies: - "@volar/language-core" "~2.2.0" + "@volar/language-core" "~2.3.0-alpha.14" "@vue/compiler-dom" "^3.4.0" "@vue/shared" "^3.4.0" computeds "^0.0.1" @@ -735,37 +736,37 @@ estree-walker "^2.0.2" magic-string "^0.25.7" -"@vue/reactivity@3.4.26": - version "3.4.26" - resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.4.26.tgz#1191f543809d4c93e5b3e842ba83022350a3f205" - integrity sha512-E/ynEAu/pw0yotJeLdvZEsp5Olmxt+9/WqzvKff0gE67tw73gmbx6tRkiagE/eH0UCubzSlGRebCbidB1CpqZQ== +"@vue/reactivity@3.4.27": + version "3.4.27" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.4.27.tgz#6ece72331bf719953f5eaa95ec60b2b8d49e3791" + integrity sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA== dependencies: - "@vue/shared" "3.4.26" + "@vue/shared" "3.4.27" -"@vue/runtime-core@3.4.26": - version "3.4.26" - resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.4.26.tgz#51ee971cb700370a67e5a510c4a84eff7491d658" - integrity sha512-AFJDLpZvhT4ujUgZSIL9pdNcO23qVFh7zWCsNdGQBw8ecLNxOOnPcK9wTTIYCmBJnuPHpukOwo62a2PPivihqw== +"@vue/runtime-core@3.4.27": + version "3.4.27" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.4.27.tgz#1b6e1d71e4604ba7442dd25ed22e4a1fc6adbbda" + integrity sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA== dependencies: - "@vue/reactivity" "3.4.26" - "@vue/shared" "3.4.26" + "@vue/reactivity" "3.4.27" + "@vue/shared" "3.4.27" -"@vue/runtime-dom@3.4.26": - version "3.4.26" - resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.4.26.tgz#179aa7c8dc964112e6d096bc8ec5f361111009a1" - integrity sha512-UftYA2hUXR2UOZD/Fc3IndZuCOOJgFxJsWOxDkhfVcwLbsfh2CdXE2tG4jWxBZuDAs9J9PzRTUFt1PgydEtItw== +"@vue/runtime-dom@3.4.27": + version "3.4.27" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.4.27.tgz#fe8d1ce9bbe8921d5dd0ad5c10df0e04ef7a5ee7" + integrity sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q== dependencies: - "@vue/runtime-core" "3.4.26" - "@vue/shared" "3.4.26" + "@vue/runtime-core" "3.4.27" + "@vue/shared" "3.4.27" csstype "^3.1.3" -"@vue/server-renderer@3.4.26": - version "3.4.26" - resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.4.26.tgz#6d0c6b0366bfe0232579aea00e3ff6784e5a1c60" - integrity sha512-xoGAqSjYDPGAeRWxeoYwqJFD/gw7mpgzOvSxEmjWaFO2rE6qpbD1PC172YRpvKhrihkyHJkNDADFXTfCyVGhKw== +"@vue/server-renderer@3.4.27": + version "3.4.27" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.4.27.tgz#3306176f37e648ba665f97dda3ce705687be63d2" + integrity sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA== dependencies: - "@vue/compiler-ssr" "3.4.26" - "@vue/shared" "3.4.26" + "@vue/compiler-ssr" "3.4.27" + "@vue/shared" "3.4.27" "@vue/shared@3.2.47": version "3.2.47" @@ -777,10 +778,10 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.4.21.tgz#de526a9059d0a599f0b429af7037cd0c3ed7d5a1" integrity sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g== -"@vue/shared@3.4.26": - version "3.4.26" - resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.4.26.tgz#f17854fb1faf889854aed4b23b60e86a8cab6403" - integrity sha512-Fg4zwR0GNnjzodMt3KRy2AWGMKQXByl56+4HjN87soxLNU9P5xcJkstAlIeEF3cU6UYOzmJl1tV0dVPGIljCnQ== +"@vue/shared@3.4.27": + version "3.4.27" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.4.27.tgz#f05e3cd107d157354bb4ae7a7b5fc9cf73c63b50" + integrity sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA== "@vue/tsconfig@^0.5.1": version "0.5.1" @@ -1158,10 +1159,10 @@ escodegen@^2.1.0: optionalDependencies: source-map "~0.6.1" -eslint-plugin-vue@^9.25.0: - version "9.25.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-9.25.0.tgz#615cb7bb6d0e2140d21840b9aa51dce69e803e7a" - integrity sha512-tDWlx14bVe6Bs+Nnh3IGrD+hb11kf2nukfm6jLsmJIhmiRQ1SUaksvwY9U5MvPB0pcrg0QK0xapQkfITs3RKOA== +eslint-plugin-vue@^9.26.0: + version "9.26.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-9.26.0.tgz#bf7f5cce62c8f878059b91edae44d22974133af5" + integrity sha512-eTvlxXgd4ijE1cdur850G6KalZqk65k1JKoOI2d1kT3hr8sPD07j1q98FRFdNnpxBELGPWxZmInxeHGF/GxtqQ== dependencies: "@eslint-community/eslint-utils" "^4.4.0" globals "^13.24.0" @@ -1208,18 +1209,18 @@ eslint-visitor-keys@^4.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz#e3adc021aa038a2a8e0b2f8b0ce8f66b9483b1fb" integrity sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw== -eslint@^9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.2.0.tgz#0700ebc99528753315d78090876911d3cdbf19fe" - integrity sha512-0n/I88vZpCOzO+PQpt0lbsqmn9AsnsJAQseIqhZFI8ibQT0U1AkEKRxA3EVMos0BoHSXDQvCXY25TUjB5tr8Og== +eslint@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.4.0.tgz#79150c3610ae606eb131f1d648d5f43b3d45f3cd" + integrity sha512-sjc7Y8cUD1IlwYcTS9qPSvGjAC8Ne9LctpxKKu3x/1IC9bnOg98Zy6GxEJUfr1NojMgVPlyANXYns8oE2c1TAA== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.6.1" - "@eslint/eslintrc" "^3.0.2" - "@eslint/js" "9.2.0" - "@humanwhocodes/config-array" "^0.13.0" + "@eslint/config-array" "^0.15.1" + "@eslint/eslintrc" "^3.1.0" + "@eslint/js" "9.4.0" "@humanwhocodes/module-importer" "^1.0.1" - "@humanwhocodes/retry" "^0.2.3" + "@humanwhocodes/retry" "^0.3.0" "@nodelib/fs.walk" "^1.2.8" ajv "^6.12.4" chalk "^4.0.0" @@ -2201,10 +2202,10 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" -sass@^1.76.0: - version "1.76.0" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.76.0.tgz#fe15909500735ac154f0dc7386d656b62b03987d" - integrity sha512-nc3LeqvF2FNW5xGF1zxZifdW3ffIz5aBb7I7tSvOoNu7z1RQ6pFt9MBuiPtjgaI62YWrM/txjWlOCFiGtf2xpw== +sass@^1.77.4: + version "1.77.4" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.4.tgz#92059c7bfc56b827c56eb116778d157ec017a5cd" + integrity sha512-vcF3Ckow6g939GMA4PeU7b2K/9FALXk2KF9J87txdHzXbUF9XRQRwSxcAs/fGaTnJeBFd7UoV22j3lzMLdM0Pw== dependencies: chokidar ">=3.0.0 <4.0.0" immutable "^4.0.0" @@ -2406,10 +2407,10 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -terser@^5.31.0: - version "5.31.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.0.tgz#06eef86f17007dbad4593f11a574c7f5eb02c6a1" - integrity sha512-Q1JFAoUKE5IMfI4Z/lkE/E6+SwgzO+x4tq4v1AyBLRj8VSYvRO6A/rQrPg1yud4g0En9EKI1TvFRF2tQFcoUkg== +terser@^5.31.1: + version "5.31.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.1.tgz#735de3c987dd671e95190e6b98cfe2f07f3cf0d4" + integrity sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.8.2" @@ -2519,10 +2520,10 @@ vite-plugin-css-injected-by-js@^3.5.1: resolved "https://registry.yarnpkg.com/vite-plugin-css-injected-by-js/-/vite-plugin-css-injected-by-js-3.5.1.tgz#b9c568c21b131d08e31aa6d368ee39c9d6c1b6c1" integrity sha512-9ioqwDuEBxW55gNoWFEDhfLTrVKXEEZgl5adhWmmqa88EQGKfTmexy4v1Rh0pAS6RhKQs2bUYQArprB32JpUZQ== -vite@^5.2.11: - version "5.2.11" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.11.tgz#726ec05555431735853417c3c0bfb36003ca0cbd" - integrity sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ== +vite@^5.2.13: + version "5.2.13" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.13.tgz#945ababcbe3d837ae2479c29f661cd20bc5e1a80" + integrity sha512-SSq1noJfY9pR3I1TUENL3rQYDQCFqgD+lM6fTRAM8Nv6Lsg5hDLaXkjETVeBt+7vZBCMoibD+6IWnT2mJ+Zb/A== dependencies: esbuild "^0.20.1" postcss "^8.4.38" @@ -2530,6 +2531,11 @@ vite@^5.2.11: optionalDependencies: fsevents "~2.3.3" +vscode-uri@^3.0.8: + version "3.0.8" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" + integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== + vue-eslint-parser@^9.3.1: version "9.3.1" resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.3.1.tgz#429955e041ae5371df5f9e37ebc29ba046496182" @@ -2580,25 +2586,25 @@ vue-template-compiler@^2.7.14: de-indent "^1.0.2" he "^1.2.0" -vue-tsc@^2.0.16: - version "2.0.16" - resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-2.0.16.tgz#ba82c4cdac283e8e39e30e817c8c1c967e528358" - integrity sha512-/gHAWJa216PeEhfxtAToIbxdWgw01wuQzo48ZUqMYVEyNqDp+OYV9xMO5HaPS2P3Ls0+EsjguMZLY4cGobX4Ew== +vue-tsc@^2.0.20: + version "2.0.20" + resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-2.0.20.tgz#31327f2f63d414a00816bc39eda9df19b16225b9" + integrity sha512-FlyW/vtf9mfUfOSMnPma2USaWwdZQKCHSTgtJwlt6q471ZaVzx9Wy4UiSCFW4bQHjExMzwmjbCbkYoYdiNFv0w== dependencies: - "@volar/typescript" "~2.2.0" - "@vue/language-core" "2.0.16" + "@volar/typescript" "~2.3.0-alpha.14" + "@vue/language-core" "2.0.20" semver "^7.5.4" -vue@^3.4.26: - version "3.4.26" - resolved "https://registry.yarnpkg.com/vue/-/vue-3.4.26.tgz#936c97e37672c737705d7bdfa62c31af18742269" - integrity sha512-bUIq/p+VB+0xrJubaemrfhk1/FiW9iX+pDV+62I/XJ6EkspAO9/DXEjbDFoe8pIfOZBqfk45i9BMc41ptP/uRg== +vue@^3.4.27: + version "3.4.27" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.4.27.tgz#40b7d929d3e53f427f7f5945386234d2854cc2a1" + integrity sha512-8s/56uK6r01r1icG/aEOHqyMVxd1bkYcSe9j8HcKtr/xTOFWvnzIVTehNW+5Yt89f+DLBe4A569pnZLS5HzAMA== dependencies: - "@vue/compiler-dom" "3.4.26" - "@vue/compiler-sfc" "3.4.26" - "@vue/runtime-dom" "3.4.26" - "@vue/server-renderer" "3.4.26" - "@vue/shared" "3.4.26" + "@vue/compiler-dom" "3.4.27" + "@vue/compiler-sfc" "3.4.27" + "@vue/runtime-dom" "3.4.27" + "@vue/server-renderer" "3.4.27" + "@vue/shared" "3.4.27" webpack-sources@^3.2.3: version "3.2.3" From c960602c62ede18c94c72c72581359a7223f243c Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Sat, 8 Jun 2024 11:16:06 +0200 Subject: [PATCH 027/140] Upgrade ESP Async WebServer from 2.10.3 to 2.10.5 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index e16f5c5d3..e6f73464b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,7 +38,7 @@ build_unflags = -std=gnu++11 lib_deps = - mathieucarbou/ESP Async WebServer @ 2.10.3 + mathieucarbou/ESP Async WebServer @ 2.10.5 bblanchon/ArduinoJson @ 7.0.4 https://github.com/bertmelis/espMqttClient.git#v1.7.0 nrf24/RF24 @ 1.4.8 From b5398a429755d0f3cd22909492d6b63e6440a1cb Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Sat, 8 Jun 2024 11:25:34 +0200 Subject: [PATCH 028/140] Changed issue template to make clear that issues are bugs that affect all users --- .github/ISSUE_TEMPLATE/bug_report.yml | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 112e2b22e..544684f46 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -5,11 +5,18 @@ body: - type: markdown attributes: value: > - ### ✋ **This is bug tracker, not a support forum** + ### ⚠️ Please remember: issues are for *bugs* + That is, something you believe affects every single user of OpenDTU, not just you. If you're not sure, start with one of the other options below. + - type: markdown + attributes: + value: | + #### Have a question? 👉 [Start a new discussion](https://github.com/tbnobody/OpenDTU/discussions/new) or [ask in chat](https://discord.gg/WzhxEY62mB). - If something isn't working right, you have questions or need help, [**get in touch on the Discussions**](https://github.com/tbnobody/OpenDTU/discussions). + #### Before opening an issue, please double check: - Please quickly search existing issues first before submitting a bug. + - [Documentation](https://www.opendtu.solar). + - [The FAQs](https://www.opendtu.solar/firmware/faq/). + - [Existing issues and discussions](https://github.com/tbnobody/OpenDTU/search?q=&type=issues). - type: textarea id: what-happened attributes: @@ -65,4 +72,15 @@ body: Links? References? Anything that will give us more context about the issue you are encountering! Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. validations: - required: false \ No newline at end of file + required: false + - type: checkboxes + id: required-checks + attributes: + label: Please confirm the following + options: + - label: I believe this issue is a bug that affects all users of OpenDTU, not something specific to my installation. + required: true + - label: I have already searched for relevant existing issues and discussions before opening this report. + required: true + - label: I have updated the title field above with a concise description. + required: true From a2b568923c88370db31df98677fd33a663ea97cf Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Sun, 9 Jun 2024 14:54:03 +0200 Subject: [PATCH 029/140] Upgrade ESP Async WebServer from 2.10.5 to 2.10.6 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index e6f73464b..d4d01ecdc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,7 +38,7 @@ build_unflags = -std=gnu++11 lib_deps = - mathieucarbou/ESP Async WebServer @ 2.10.5 + mathieucarbou/ESP Async WebServer @ 2.10.6 bblanchon/ArduinoJson @ 7.0.4 https://github.com/bertmelis/espMqttClient.git#v1.7.0 nrf24/RF24 @ 1.4.8 From 417df65b92d4c3ae93fb318ca0d3d67a40c9fbd7 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Mon, 10 Jun 2024 21:40:55 +0200 Subject: [PATCH 030/140] webapp: update dependencies --- webapp/package.json | 4 ++-- webapp/yarn.lock | 58 ++++++++++++++++++++++----------------------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/webapp/package.json b/webapp/package.json index e588a99ea..bb66b4ac1 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -20,7 +20,7 @@ "spark-md5": "^3.0.2", "vue": "^3.4.27", "vue-i18n": "^9.13.1", - "vue-router": "^4.3.2" + "vue-router": "^4.3.3" }, "devDependencies": { "@intlify/unplugin-vue-i18n": "^4.0.0", @@ -43,6 +43,6 @@ "vite": "^5.2.13", "vite-plugin-compression": "^0.5.1", "vite-plugin-css-injected-by-js": "^3.5.1", - "vue-tsc": "^2.0.20" + "vue-tsc": "^2.0.21" } } diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 0684166d4..bfe79588d 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -567,26 +567,26 @@ resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.5.tgz#e3dc11e427d4b818b7e3202766ad156e3d5e2eaa" integrity sha512-LOjm7XeIimLBZyzinBQ6OSm3UBCNVCpLkxGC0oWmm2YPzVZoxMsdvNVimLTBzpAnR9hl/yn1SHGuRfe6/Td9rQ== -"@volar/language-core@2.3.0-alpha.14", "@volar/language-core@~2.3.0-alpha.14": - version "2.3.0-alpha.14" - resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.3.0-alpha.14.tgz#944acd09c096cc53c63605d6e97f87e37973ffe2" - integrity sha512-80HmdD27fPHs+EB9s5RIdRFdvKil2xXMbsKSPYcPFOLP3iysOJ/i9OKnG83Rhgn6rTLJdfM97WOdx/dsBwJtag== +"@volar/language-core@2.3.0", "@volar/language-core@~2.3.0-alpha.15": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.3.0.tgz#ffb9b64c8b19d7f45b1fdcd9ae9d98d94bad7179" + integrity sha512-pvhL24WUh3VDnv7Yw5N1sjhPtdx7q9g+Wl3tggmnkMcyK8GcCNElF2zHiKznryn0DiUGk+eez/p2qQhz+puuHw== dependencies: - "@volar/source-map" "2.3.0-alpha.14" + "@volar/source-map" "2.3.0" -"@volar/source-map@2.3.0-alpha.14": - version "2.3.0-alpha.14" - resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.3.0-alpha.14.tgz#5502f0edd16b692049f7e5232074be8af71ba469" - integrity sha512-la0CSIfo593WRga2r9STkCtObECX/3xZs4cQKlygU8G13zCtYP8uOQc/jgBQEQK3ne50i7X4Z0ZRLj9ht8+Ppg== +"@volar/source-map@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.3.0.tgz#faf4df8f10ca40788f03c35eed3e2b7848110cc9" + integrity sha512-G/228aZjAOGhDjhlyZ++nDbKrS9uk+5DMaEstjvzglaAw7nqtDyhnQAsYzUg6BMP9BtwZ59RIw5HGePrutn00Q== dependencies: muggle-string "^0.4.0" -"@volar/typescript@~2.3.0-alpha.14": - version "2.3.0-alpha.14" - resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.3.0-alpha.14.tgz#65a7ea478b558deaa47dfe85498420dbca24df47" - integrity sha512-YnaivvHu/HlVgFRUFPh3X42GXYawSIXPvkIGND/RZXJ1iyrj9CB/UEtsMUV55TOULbfJyc92F2EpOMn/lMyqwA== +"@volar/typescript@~2.3.0-alpha.15": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.3.0.tgz#00306942e95e2e22fed8daf73ec386cd72601ecf" + integrity sha512-PtUwMM87WsKVeLJN33GSTUjBexlKfKgouWlOUIv7pjrOnTwhXHZNSmpc312xgXdTjQPpToK6KXSIcKu9sBQ5LQ== dependencies: - "@volar/language-core" "2.3.0-alpha.14" + "@volar/language-core" "2.3.0" path-browserify "^1.0.1" vscode-uri "^3.0.8" @@ -712,12 +712,12 @@ "@typescript-eslint/parser" "^7.1.1" vue-eslint-parser "^9.3.1" -"@vue/language-core@2.0.20": - version "2.0.20" - resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-2.0.20.tgz#32cbd60deada2e83f96614301af6b1e81adac3bf" - integrity sha512-PudZnVVhZV9++4xndha6K8G1P+pa5WB4H926IK6Pn82EKD+7MEnBJ858t+cI5jpXqx1X/72+NfzRrgsocN5LrA== +"@vue/language-core@2.0.21": + version "2.0.21" + resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-2.0.21.tgz#882667d0c9f07bc884f163e75eed666234df77fe" + integrity sha512-vjs6KwnCK++kIXT+eI63BGpJHfHNVJcUCr3RnvJsccT3vbJnZV5IhHR2puEkoOkIbDdp0Gqi1wEnv3hEd3WsxQ== dependencies: - "@volar/language-core" "~2.3.0-alpha.14" + "@volar/language-core" "~2.3.0-alpha.15" "@vue/compiler-dom" "^3.4.0" "@vue/shared" "^3.4.0" computeds "^0.0.1" @@ -2571,10 +2571,10 @@ vue-i18n@^9.13.1: "@intlify/shared" "9.13.1" "@vue/devtools-api" "^6.5.0" -vue-router@^4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.3.2.tgz#08096c7765dacc6832f58e35f7a081a8b34116a7" - integrity sha512-hKQJ1vDAZ5LVkKEnHhmm1f9pMiWIBNGF5AwU67PdH7TyXCj/a4hTccuUuYCAMgJK6rO/NVYtQIEN3yL8CECa7Q== +vue-router@^4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.3.3.tgz#7505509d429a36694b12ba1f6530016c5ce5f6bf" + integrity sha512-8Q+u+WP4N2SXY38FDcF2H1dUEbYVHVPtPCPZj/GTZx8RCbiB8AtJP9+YIxn4Vs0svMTNQcLIzka4GH7Utkx9xQ== dependencies: "@vue/devtools-api" "^6.5.1" @@ -2586,13 +2586,13 @@ vue-template-compiler@^2.7.14: de-indent "^1.0.2" he "^1.2.0" -vue-tsc@^2.0.20: - version "2.0.20" - resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-2.0.20.tgz#31327f2f63d414a00816bc39eda9df19b16225b9" - integrity sha512-FlyW/vtf9mfUfOSMnPma2USaWwdZQKCHSTgtJwlt6q471ZaVzx9Wy4UiSCFW4bQHjExMzwmjbCbkYoYdiNFv0w== +vue-tsc@^2.0.21: + version "2.0.21" + resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-2.0.21.tgz#c574a2c20e8a5e5643af546c6051319cdf983239" + integrity sha512-E6x1p1HaHES6Doy8pqtm7kQern79zRtIewkf9fiv7Y43Zo4AFDS5hKi+iHi2RwEhqRmuiwliB1LCEFEGwvxQnw== dependencies: - "@volar/typescript" "~2.3.0-alpha.14" - "@vue/language-core" "2.0.20" + "@volar/typescript" "~2.3.0-alpha.15" + "@vue/language-core" "2.0.21" semver "^7.5.4" vue@^3.4.27: From 119b7b18e65449cda497b4ca4b9c43e991236f50 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Mon, 10 Jun 2024 21:44:33 +0200 Subject: [PATCH 031/140] Upgrade ESP Async WebServer from 2.10.6 to 2.10.8 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index d4d01ecdc..987f8b4db 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,7 +38,7 @@ build_unflags = -std=gnu++11 lib_deps = - mathieucarbou/ESP Async WebServer @ 2.10.6 + mathieucarbou/ESP Async WebServer @ 2.10.8 bblanchon/ArduinoJson @ 7.0.4 https://github.com/bertmelis/espMqttClient.git#v1.7.0 nrf24/RF24 @ 1.4.8 From c144b6830632426db8718e5a19b7bc2611602321 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Mon, 10 Jun 2024 21:45:56 +0200 Subject: [PATCH 032/140] webapp: add app.js.gz --- webapp_dist/js/app.js.gz | Bin 183063 -> 183333 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/webapp_dist/js/app.js.gz b/webapp_dist/js/app.js.gz index bac1af299854822221d6f39df5c8d65f0cdd8692..e85a52eb7f0882446d96c8ca33904f7caa0a3a88 100644 GIT binary patch delta 152689 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 Date: Mon, 10 Jun 2024 22:31:15 +0200 Subject: [PATCH 033/140] avoid using pkg_resources This is deprecated in python 3.12. Also, improve file handling --- pio-scripts/auto_firmware_version.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/pio-scripts/auto_firmware_version.py b/pio-scripts/auto_firmware_version.py index f169f6174..26e1bd65a 100644 --- a/pio-scripts/auto_firmware_version.py +++ b/pio-scripts/auto_firmware_version.py @@ -3,33 +3,27 @@ # Copyright (C) 2022 Thomas Basler and others # import os -import pkg_resources Import("env") -required_pkgs = {'dulwich'} -installed_pkgs = {pkg.key for pkg in pkg_resources.working_set} -missing_pkgs = required_pkgs - installed_pkgs - -if missing_pkgs: +try: + from dulwich import porcelain +except ModuleNotFoundError: env.Execute('"$PYTHONEXE" -m pip install dulwich') - -from dulwich import porcelain + from dulwich import porcelain def updateFileIfChanged(filename, content): mustUpdate = True try: - fp = open(filename, "rb") - if fp.read() == content: - mustUpdate = False - fp.close() + with open(filename, "rb") as fp: + if fp.read() == content: + mustUpdate = False except: pass if mustUpdate: - fp = open(filename, "wb") - fp.write(content) - fp.close() + with open(filename, "wb") as fp: + fp.write(content) return mustUpdate From 1f6fdb7fc0be7b493273c6b21422f970cb1d0c3d Mon Sep 17 00:00:00 2001 From: cerise21 <40767429+cerise21@users.noreply.github.com> Date: Sat, 15 Jun 2024 20:21:42 +0200 Subject: [PATCH 034/140] Feature: Set/obtain DPL upper power limit via MQTT --- include/MqttHandlePowerLimiter.h | 3 ++- src/MqttHandlePowerLimiter.cpp | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/include/MqttHandlePowerLimiter.h b/include/MqttHandlePowerLimiter.h index fa7ef12cc..fd35b5798 100644 --- a/include/MqttHandlePowerLimiter.h +++ b/include/MqttHandlePowerLimiter.h @@ -23,7 +23,8 @@ class MqttHandlePowerLimiterClass { VoltageStartThreshold, VoltageStopThreshold, FullSolarPassThroughStartVoltage, - FullSolarPassThroughStopVoltage + FullSolarPassThroughStopVoltage, + UpperPowerLimit }; void onMqttCmd(MqttPowerLimiterCommand command, const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total); diff --git a/src/MqttHandlePowerLimiter.cpp b/src/MqttHandlePowerLimiter.cpp index 95f90db2f..dd1c96dd3 100644 --- a/src/MqttHandlePowerLimiter.cpp +++ b/src/MqttHandlePowerLimiter.cpp @@ -44,6 +44,7 @@ void MqttHandlePowerLimiterClass::init(Scheduler& scheduler) subscribe("threshold/voltage/full_solar_passthrough_start", MqttPowerLimiterCommand::FullSolarPassThroughStartVoltage); subscribe("threshold/voltage/full_solar_passthrough_stop", MqttPowerLimiterCommand::FullSolarPassThroughStopVoltage); subscribe("mode", MqttPowerLimiterCommand::Mode); + subscribe("upper_power_limit", MqttPowerLimiterCommand::UpperPowerLimit); _lastPublish = millis(); } @@ -75,6 +76,8 @@ void MqttHandlePowerLimiterClass::loop() auto val = static_cast(PowerLimiter.getMode()); MqttSettings.publish("powerlimiter/status/mode", String(val)); + + MqttSettings.publish("powerlimiter/status/upper_power_limit", String(config.PowerLimiter.UpperPowerLimit)); MqttSettings.publish("powerlimiter/status/inverter_update_timeouts", String(PowerLimiter.getInverterUpdateTimeouts())); @@ -174,6 +177,11 @@ void MqttHandlePowerLimiterClass::onMqttCmd(MqttPowerLimiterCommand command, con MessageOutput.printf("Setting full solar passthrough stop voltage to: %.2f V\r\n", payload_val); config.PowerLimiter.FullSolarPassThroughStopVoltage = payload_val; break; + case MqttPowerLimiterCommand::UpperPowerLimit: + if (config.PowerLimiter.UpperPowerLimit == intValue) { return; } + MessageOutput.printf("Setting upper power limit to: %d W\r\n", intValue); + config.PowerLimiter.UpperPowerLimit = intValue; + break; } // not reached if the value did not change From 95e560bdc7f099a08978c50863c841f9ec0c5087 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Mon, 17 Jun 2024 17:32:26 +0200 Subject: [PATCH 035/140] fix: optimize margins in live view all total cards at the top of the live view go into the same row. bootstrap will line-break after every third column/card, as the row is row-cols-3. the battery icon to the right of the project name in the header shall have marging to said project name. the rows in the live view now use class mt-0 to counteract bootstraps negative margin for individual rows. --- webapp/src/components/BatteryView.vue | 2 +- webapp/src/components/HuaweiView.vue | 2 +- webapp/src/components/InverterTotalInfo.vue | 26 +++++++-------------- webapp/src/components/NavBar.vue | 2 +- webapp/src/components/VedirectView.vue | 4 ++-- webapp/src/views/HomeView.vue | 4 ++-- 6 files changed, 16 insertions(+), 24 deletions(-) diff --git a/webapp/src/components/BatteryView.vue b/webapp/src/components/BatteryView.vue index 929669197..171e5bd8a 100644 --- a/webapp/src/components/BatteryView.vue +++ b/webapp/src/components/BatteryView.vue @@ -6,7 +6,7 @@
-
+
+
-
+

{{ $n(totalVeData.total.YieldTotal.v, 'decimal', { @@ -12,7 +11,7 @@

-
+

{{ $n(totalVeData.total.YieldDay.v, 'decimal', { @@ -23,7 +22,7 @@

-
+

{{ $n(totalVeData.total.Power.v, 'decimal', { @@ -34,9 +33,6 @@

-
-
-

@@ -70,10 +66,7 @@

-
-
-
-
+

{{ $n(totalBattData.soc.v, 'decimal', { @@ -83,8 +76,8 @@ {{ totalBattData.soc.u }}

-
-
+
+

{{ $n(powerMeterData.Power.v, 'decimal', { @@ -94,8 +87,8 @@ {{powerMeterData.Power.u }}

-
-
+
+

{{ $n(huaweiData.Power.v, 'decimal', { @@ -105,9 +98,8 @@ {{huaweiData.Power.u }}

-
+
-
\ No newline at end of file diff --git a/webapp/src/components/HuaweiView.vue b/webapp/src/components/HuaweiView.vue index c9e1d3964..c4659a3f2 100644 --- a/webapp/src/components/HuaweiView.vue +++ b/webapp/src/components/HuaweiView.vue @@ -1,358 +1,358 @@ - - - \ No newline at end of file diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index c91fe3a25..d93471d2e 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -1,943 +1,943 @@ -{ - "menu": { - "LiveView": "Direct", - "Settings": "Paramètres", - "NetworkSettings": "Réseau", - "NTPSettings": "Heure locale", - "MQTTSettings": "MQTT", - "InverterSettings": "Onduleurs", - "SecuritySettings": "Sécurité", - "DTUSettings": "DTU", - "DeviceManager": "Périphériques", - "VedirectSettings": "VE.Direct", - "PowerMeterSettings": "Power Meter", - "BatterySettings": "Battery", - "AcChargerSettings": "AC Charger", - "ConfigManagement": "Gestion de la configuration", - "FirmwareUpgrade": "Mise à jour du firmware", - "DeviceReboot": "Redémarrage de l'appareil", - "Info": "Informations", - "System": "Système", - "Network": "Réseau", - "NTP": "NTP", - "MQTT": "MQTT", - "Console": "Console", - "Vedirect": "VE.Direct", - "About": "A propos", - "Logout": "Déconnexion", - "Login": "Connexion" - }, - "base": { - "Yes": "Oui", - "No": "Non", - "VerboseLogging": "Journalisation Détaillée", - "Seconds": "Secondes", - "Loading": "Chargement...", - "Reload": "Reload", - "Cancel": "Annuler", - "Save": "Sauvegarder", - "Refreshing": "Refreshing", - "Pull": "Pull down to refresh", - "Release": "Release to refresh", - "Close": "Fermer" - }, - "Error": { - "Oops": "Oops!" - }, - "localeswitcher": { - "Dark": "Sombre", - "Light": "Clair", - "Auto": "Auto" - }, - "apiresponse": { - "1001": "Paramètres enregistrés !", - "1002": "Aucune valeur trouvée !", - "1003": "Données trop importantes !", - "1004": "Échec de l'analyse des données !", - "1005": "Certaines valeurs sont manquantes !", - "1006": "Write failed!", - "2001": "Le numéro de série ne peut pas être nul !", - "2002": "L'intervalle de sondage doit être supérieur à zéro !", - "2003": "Réglage du niveau de puissance invalide !", - "2004": "The frequency must be set between {min} and {max} kHz and must be a multiple of 250kHz!", - "2005": "Invalid country selection !", - "3001": "Rien n'a été supprimé !", - "3002": "Configuration réinitialisée. Redémarrage maintenant...", - "4001": "@:apiresponse.2001", - "4002": "Le nom doit comporter entre 1 et {max} caractères !", - "4003": "Seulement {max} onduleurs sont supportés !", - "4004": "Onduleur créé !", - "4005": "Identifiant spécifié invalide !", - "4006": "Réglage du montant maximal de canaux invalide !", - "4007": "Onduleur modifié !", - "4008": "Onduleur supprimé !", - "4009": "Inverter order saved!", - "5001": "@:apiresponse.2001", - "5002": "La limite doit être comprise entre 1 et {max} !", - "5003": "Type spécifié invalide !", - "5004": "Onduleur spécifié invalide !", - "6001": "Redémarrage déclenché !", - "6002": "Redémarrage annulé !", - "7001": "Le nom du serveur MQTT doit comporter entre 1 et {max} caractères !", - "7002": "Le nom d'utilisateur ne doit pas comporter plus de {max} caractères !", - "7003": "Le mot de passe ne doit pas comporter plus de {max} caractères !", - "7004": "Le sujet ne doit pas comporter plus de {max} caractères !", - "7005": "Le sujet ne doit pas contenir d'espace !", - "7006": "Le sujet doit se terminer par une barre oblique (/) !", - "7007": "Le port doit être un nombre entre 1 et 65535 !", - "7008": "Le certificat ne doit pas comporter plus de {max} caractères !", - "7009": "Le sujet LWT ne doit pas comporter plus de {max} caractères !", - "7010": "Le sujet LWT ne doit pas contenir de caractères d'espacement !", - "7011": "La valeur LWT en ligne ne doit pas dépasser {max} caractères !", - "7012": "La valeur LWT hors ligne ne doit pas dépasser {max} caractères !", - "7013": "L'intervalle de publication doit être un nombre compris entre {min} et {max} !", - "7014": "Le sujet Hass ne doit pas dépasser {max} caractères !", - "7015": "Le sujet Hass ne doit pas contenir d'espace !", - "7016": "LWT QOS ne doit pas être supérieur à {max}!", - "8001": "L'adresse IP n'est pas valide !", - "8002": "Le masque de réseau n'est pas valide !", - "8003": "La passerelle n'est pas valide !", - "8004": "L'adresse IP du serveur DNS primaire n'est pas valide !", - "8005": "L'adresse IP du serveur DNS secondaire n'est pas valide !", - "8006": "La valeur du délai d'attente du point d'accès administratif n'est pas valide !", - "9001": "Le serveur NTP doit avoir une longueur comprise entre 1 et {max} caractères !", - "9002": "Le fuseau horaire doit comporter entre 1 et {max} caractères !", - "9003": "La description du fuseau horaire doit comporter entre 1 et {max} caractères !", - "9004": "L'année doit être un nombre compris entre {min} et {max} !", - "9005": "Le mois doit être un nombre compris entre {min} et {max} !", - "9006": "Le jour doit être un nombre compris entre {min} et {max} !", - "9007": "Les heures doivent être un nombre compris entre {min} et {max} !", - "9008": "Les minutes doivent être un nombre compris entre {min} et {max} !", - "9009": "Les secondes doivent être un nombre compris entre {min} et {max} !", - "9010": "Heure mise à jour !", - "10001": "Le mot de passe doit comporter entre 8 et {max} caractères !", - "10002": "Authentification réussie !", - "11001": "@:apiresponse.2001", - "11002": "@:apiresponse:5004", - "12001": "Le profil doit comporter entre 1 et {max} caractères !" - }, - "home": { - "LiveData": "Données en direct", - "SerialNumber": "Numéro de série : ", - "CurrentLimit": "Limite de courant : ", - "DataAge": "Âge des données : ", - "Seconds": "{val} secondes", - "ShowSetInverterLimit": "Afficher / Régler la limite de l'onduleur", - "TurnOnOff": "Allumer / Eteindre l'onduleur", - "ShowInverterInfo": "Afficher les informations sur l'onduleur", - "ShowEventlog": "Afficher le journal des événements", - "UnreadMessages": "messages non lus", - "Loading": "@:base.Loading", - "EventLog": "Journal des événements", - "InverterInfo": "Informations sur l'onduleur", - "LimitSettings": "Paramètres de la limite", - "LastLimitSetStatus": "Statut de la dernière limite fixée", - "SetLimit": "Fixer la limite", - "Relative": "Relative (%)", - "Absolute": "Absolue (W)", - "LimitHint": "Astuce : Si vous définissez la limite en valeur absolue, l'affichage de la valeur actuelle ne sera mis à jour qu'après environ 4 minutes.", - "SetPersistent": "Fixer une limite persistante", - "SetNonPersistent": "Fixer une limite non persistante", - "PowerSettings": "Paramètres d'alimentation", - "LastPowerSetStatus": "État du dernier réglage de l'alimentation", - "TurnOn": "Allumer", - "TurnOff": "Eteindre", - "Restart": "Redémarrer", - "Failure": "Échec", - "Pending": "En attente", - "Ok": "OK", - "Unknown": "Inconnu", - "ShowGridProfile": "Show Grid Profile", - "GridProfile": "Grid Profile", - "LoadingInverter": "Waiting for data... (can take up to 10 seconds)" - }, - "vedirecthome": { - "SerialNumber": "Numéro de série", - "FirmwareVersion": "Version du Firmware", - "DataAge": "Âge des données", - "Seconds": "{val} secondes", - "Property": "Property", - "Value": "Value", - "Unit": "Unit", - "section_device": "Device Info", - "device": { - "LOAD": "Load output state", - "CS": "State of operation", - "MPPT": "Tracker operation mode", - "OR": "Off reason", - "ERR": "Error code", - "HSDS": "Day sequence number (0..364)", - "MpptTemperature": "Charge controller temperature" - }, - "section_output": "Output (Battery)", - "output": { - "P": "Power (calculated)", - "V": "Voltage", - "I": "Current", - "E": "Efficiency (calculated)" - }, - "section_input": "Input (Solar Panels)", - "input": { - "NetworkPower": "VE.Smart network total power", - "PPV": "Power", - "VPV": "Voltage", - "IPV": "Current (calculated)", - "YieldToday": "Yield today", - "YieldYesterday": "Yield yesterday", - "YieldTotal": "Yield total (user resettable counter)", - "MaximumPowerToday": "Maximum power today", - "MaximumPowerYesterday": "Maximum power yesterday" - }, - "PowerLimiterState": "Power limiter state [off (charging), solar passthrough, on battery]" - }, - "vedirecthome": { - "SerialNumber": "Serial Number: ", - "FirmwareNumber": "Firmware Number: ", - "DataAge": "Data Age: ", - "Seconds": "{val} seconds", - "DeviceInfo": "Device Info", - "Property": "Property", - "Value": "Value", - "Unit": "Unit", - "LoadOutputState": "Load output state", - "StateOfOperation": "State of operation", - "TrackerOperationMode": "Tracker operation mode", - "OffReason": "Off reason", - "ErrorCode": "Error code", - "DaySequenceNumber": "Day sequence number (0..364)", - "Battery": "Output (Battery)", - "output": { - "P": "Power (calculated)", - "V": "Voltage", - "I": "Current", - "E": "Efficiency (calculated)" - }, - "Panel": "Input (Solar Panels)", - "input": { - "PPV": "Power", - "VPV": "Voltage", - "IPV": "Current (calculated)", - "YieldToday": "Yield today", - "YieldYesterday": "Yield yesterday", - "YieldTotal": "Yield total (user resettable counter)", - "MaximumPowerToday": "Maximum power today", - "MaximumPowerYesterday": "Maximum power yesterday" - }, - "PowerLimiterState": "Power limiter state [off (charging), solar passthrough, on battery]" - }, - "eventlog": { - "Start": "Départ", - "Stop": "Arrêt", - "Id": "ID", - "Message": "Message" - }, - "devinfo": { - "NoInfo": "Aucune information disponible", - "NoInfoLong": "N'a pas reçu de données valides de l'onduleur jusqu'à présent. J'essaie toujours...", - "UnknownModel": "Modèle inconnu ! Veuillez signaler le \"Numéro d'article matériel\" et le modèle (par exemple, HM-350) comme un problème ici.", - "Serial": "Serial", - "ProdYear": "Production Year", - "ProdWeek": "Production Week", - "Model": "Modèle", - "DetectedMaxPower": "Puissance maximale détectée", - "BootloaderVersion": "Version du bootloader", - "FirmwareVersion": "Version du firmware", - "FirmwareBuildDate": "Date de création du firmware", - "HardwarePartNumber": "Numéro d'article matériel", - "HardwareVersion": "Version du matériel" - }, - "gridprofile": { - "NoInfo": "@:devinfo.NoInfo", - "NoInfoLong": "@:devinfo.NoInfoLong", - "Name": "Name", - "Version": "Version", - "Enabled": "@:wifistationinfo.Enabled", - "Disabled": "@:wifistationinfo.Disabled", - "GridprofileSupport": "Support the development", - "GridprofileSupportLong": "Please see
here for further information." - }, - "systeminfo": { - "SystemInfo": "Informations sur le système", - "VersionError": "Erreur de récupération des informations de version", - "VersionNew": "Nouvelle version disponible ! Montrer les changements !", - "VersionOk": "À jour !" - }, - "firmwareinfo": { - "FirmwareInformation": "Informations sur le firmware", - "Hostname": "Nom d'hôte", - "SdkVersion": "Version du SDK", - "ConfigVersion": "Version de la configuration", - "FirmwareVersion": "Version du firmware / Hash Git", - "PioEnv": "PIO Environment", - "FirmwareVersionHint": "Cliquez ici pour afficher des informations sur votre version actuelle", - "FirmwareUpdate": "Mise à jour du firmware", - "FirmwareUpdateHint": "Cliquez ici pour voir les changements entre votre version et la dernière version", - "FrmwareUpdateAllow": "En activant le contrôle de mise à jour, une demande est envoyée à GitHub.com à chaque fois que la page est consultée afin de récupérer la dernière version disponible. Si tu n'es pas d'accord, laisse cette fonction désactivée.", - "ResetReason0": "Raison de la réinitialisation CPU 0", - "ResetReason1": "Raison de la réinitialisation CPU 1", - "ConfigSaveCount": "Nombre d'enregistrements de la configuration", - "Uptime": "Durée de fonctionnement", - "UptimeValue": "0 jour {time} | 1 jour {time} | {count} jours {time}" - }, - "hardwareinfo": { - "HardwareInformation": "Informations sur le matériel", - "ChipModel": "Modèle de puce", - "ChipRevision": "Révision de la puce", - "ChipCores": "Nombre de cœurs", - "CpuFrequency": "Fréquence du CPU", - "Mhz": "MHz", - "CpuTemperature": "CPU Temperature", - "FlashSize": "Taille de la mémoire flash" - }, - "memoryinfo": { - "MemoryInformation": "Informations sur la mémoire", - "Type": "Type", - "Usage": "Utilisation", - "Free": "Libre", - "Used": "Utilisée", - "Size": "Taille", - "Heap": "Heap", - "PsRam": "PSRAM", - "LittleFs": "LittleFs", - "Sketch": "Sketch" - }, - "heapdetails": { - "HeapDetails": "Heap Details", - "TotalFree": "Total free", - "LargestFreeBlock": "Biggest contiguous free block", - "MaxUsage": "Maximum usage since start", - "Fragmentation": "Level of fragmentation" - }, - "radioinfo": { - "RadioInformation": "Informations sur la radio", - "Status": "{module} Statut", - "ChipStatus": "{module} État de la puce", - "ChipType": "{module} Type de puce", - "Connected": "connectée", - "NotConnected": "non connectée", - "Configured": "configurée", - "NotConfigured": "non configurée", - "Unknown": "Inconnue" - }, - "networkinfo": { - "NetworkInformation": "Informations sur le réseau" - }, - "wifistationinfo": { - "WifiStationInfo": "Informations sur le WiFi (Station)", - "Status": "Statut", - "Enabled": "activé", - "Disabled": "désactivé", - "Ssid": "SSID", - "Bssid": "BSSID", - "Quality": "Qualité", - "Rssi": "RSSI" - }, - "wifiapinfo": { - "WifiApInfo": "Informations sur le WiFi (Point d'accès)", - "Status": "@:wifistationinfo.Status", - "Enabled": "@:wifistationinfo.Enabled", - "Disabled": "@:wifistationinfo.Disabled", - "Ssid": "@:wifistationinfo.Ssid", - "Stations": "# Stations" - }, - "interfacenetworkinfo": { - "NetworkInterface": "Interface réseau ({iface})", - "Hostname": "@:firmwareinfo.Hostname", - "IpAddress": "Adresse IP", - "Netmask": "Masque de réseau", - "DefaultGateway": "Passerelle par défaut", - "Dns": "DNS {num}", - "MacAddress": "Addresse MAC" - }, - "interfaceapinfo": { - "NetworkInterface": "Interface réseau (Point d'accès)", - "IpAddress": "@:interfacenetworkinfo.IpAddress", - "MacAddress": "@:interfacenetworkinfo.MacAddress" - }, - "ntpinfo": { - "NtpInformation": "Informations sur le NTP", - "ConfigurationSummary": "Résumé de la configuration", - "Server": "Serveur", - "Timezone": "Fuseau horaire", - "TimezoneDescription": "Description du fuseau horaire", - "CurrentTime": "Heure actuelle", - "Status": "Statut", - "Synced": "synchronisée", - "NotSynced": "pas synchronisée", - "LocalTime": "Heure locale", - "Sunrise": "Lever du soleil", - "Sunset": "Coucher du soleil", - "NotAvailable": "Not Available", - "Mode": "Mode", - "Day": "Jour", - "Night": "Nuit" - }, - "mqttinfo": { - "MqttInformation": "MQTT Information", - "ConfigurationSummary": "@:ntpinfo.ConfigurationSummary", - "Status": "@:ntpinfo.Status", - "Enabled": "Activé", - "Disabled": "Désactivé", - "Server": "@:ntpinfo.Server", - "Port": "Port", - "Username": "Nom d'utilisateur", - "BaseTopic": "Sujet de base", - "PublishInterval": "Intervalle de publication", - "Seconds": "{sec} secondes", - "CleanSession": "CleanSession Flag", - "Retain": "Conserver", - "Tls": "TLS", - "RootCertifcateInfo": "Informations sur le certificat de l'autorité de certification racine", - "TlsCertLogin": "Connexion avec un certificat TLS", - "ClientCertifcateInfo": "Informations sur le certificat du client", - "HassSummary": "Résumé de la configuration de la découverte automatique du MQTT de Home Assistant", - "Expire": "Expiration", - "IndividualPanels": "Panneaux individuels", - "RuntimeSummary": "Résumé du temps de fonctionnement", - "ConnectionStatus": "État de la connexion", - "Connected": "connecté", - "Disconnected": "déconnecté" - }, - "vedirectinfo": { - "VedirectInformation" : "VE.Direct Info", - "ConfigurationSummary": "@:ntpinfo.ConfigurationSummary", - "Status": "@:ntpinfo.Status", - "Enabled": "@:mqttinfo.Enabled", - "Disabled": "@:mqttinfo.Disabled", - "VerboseLogging": "@:base.VerboseLogging", - "UpdatesOnly": "@:vedirectadmin.UpdatesOnly", - "UpdatesEnabled": "@:mqttinfo.Enabled", - "UpdatesDisabled": "@:mqttinfo.Disabled" - }, - "console": { - "Console": "Console", - "VirtualDebugConsole": "Console de débogage", - "EnableAutoScroll": "Activer le défilement automatique", - "ClearConsole": "Vider la console", - "CopyToClipboard": "Copier dans le presse-papiers" - }, - "inverterchannelinfo": { - "String": "Ligne {num}", - "Phase": "Phase {num}", - "General": "General" - }, - "invertertotalinfo": { - "InverterTotalYieldTotal": "Onduleurs rendement total", - "InverterTotalYieldDay": "Onduleurs rendement du jour", - "InverterTotalPower": "Onduleurs puissance de l'installation", - "MpptTotalYieldTotal": "MPPT rendement total", - "MpptTotalYieldDay": "MPPT rendement du jour", - "MpptTotalPower": "MPPT puissance de l'installation", - "BatterySoc": "State of charge", - "HomePower": "Grid Power", - "HuaweiPower": "Huawei AC Power" - }, - "inverterchannelproperty": { - "Power": "Puissance", - "Voltage": "Tension", - "Current": "Courant", - "Power DC": "Puissance continue", - "YieldDay": "Rendement du jour", - "YieldTotal": "Rendement total", - "Frequency": "Fréquence", - "Temperature": "Température", - "PowerFactor": "Facteur de puissance", - "ReactivePower": "Puissance réactive", - "Efficiency": "Efficacité", - "Irradiation": "Irradiation" - }, - "maintenancereboot": { - "DeviceReboot": "Redémarrage de l'appareil", - "PerformReboot": "Effectuer un redémarrage", - "Reboot": "Redémarrer !", - "Cancel": "@:base.Cancel", - "RebootOpenDTU": "Redémarrer OpenDTU", - "RebootQuestion": "Voulez-vous vraiment redémarrer l'appareil ?", - "RebootHint": "Astuce : Normalement, il n'est pas nécessaire de procéder à un redémarrage manuel. OpenDTU effectue automatiquement tout redémarrage nécessaire (par exemple, après une mise à jour du firmware). Les paramètres sont également adoptés sans redémarrage. Si vous devez redémarrer en raison d'une erreur, veuillez envisager de la signaler à l'adresse suivante Github." - }, - "dtuadmin": { - "DtuSettings": "Paramètres du DTU", - "DtuConfiguration": "Configuration du DTU", - "Serial": "Numéro de série", - "SerialHint": "L'onduleur et le DTU ont tous deux un numéro de série. Le numéro de série du DTU est généré de manière aléatoire lors du premier démarrage et ne doit normalement pas être modifié.", - "PollInterval": "Intervalle de sondage", - "VerboseLogging": "@:base.VerboseLogging", - "Seconds": "Secondes", - "NrfPaLevel": "NRF24 Niveau de puissance d'émission", - "CmtPaLevel": "CMT2300A Niveau de puissance d'émission", - "NrfPaLevelHint": "Used for HM-Inverters. Assurez-vous que votre alimentation est suffisamment stable avant d'augmenter la puissance d'émission.", - "CmtPaLevelHint": "Used for HMS/HMT-Inverters. Assurez-vous que votre alimentation est suffisamment stable avant d'augmenter la puissance d'émission.", - "CmtCountry": "CMT2300A Region/Country:", - "CmtCountryHint": "Each country has different frequency allocations.", - "country_0": "Europe ({min}MHz - {max}MHz)", - "country_1": "North America ({min}MHz - {max}MHz)", - "country_2": "Brazil ({min}MHz - {max}MHz)", - "CmtFrequency": "CMT2300A Frequency:", - "CmtFrequencyHint": "Make sure to only use frequencies that are allowed in the respective country! After a frequency change, it can take up to 15min until a connection is established.", - "CmtFrequencyWarning": "The selected frequency is outside the allowed range in your selected region/country. Make sure that this selection does not violate any local regulations.", - "MHz": "{mhz} MHz", - "dBm": "{dbm} dBm", - "Min": "Minimum ({db} dBm)", - "Low": "Bas ({db} dBm)", - "High": "Haut ({db} dBm)", - "Max": "Maximum ({db} dBm)" - }, - "securityadmin": { - "SecuritySettings": "Paramètres de sécurité", - "AdminPassword": "Mot de passe administrateur", - "Password": "Mot de passe", - "RepeatPassword": "Répéter le mot de passe", - "PasswordHint": "Astuce : Le mot de passe administrateur est utilisé pour accéder à cette interface web (utilisateur 'admin'), mais aussi pour se connecter à l'appareil en mode AP. Il doit comporter de 8 à 64 caractères.", - "Permissions": "Autorisations", - "ReadOnly": "Autoriser l'accès en lecture seule à l'interface web sans mot de passe" - }, - "ntpadmin": { - "NtpSettings": "Paramètres NTP", - "NtpConfiguration": "Configuration du protocole NTP", - "TimeServer": "Serveur horaire", - "TimeServerHint": "La valeur par défaut convient tant que OpenDTU a un accès direct à Internet.", - "Timezone": "Fuseau horaire", - "TimezoneConfig": "Configuration du fuseau horaire", - "LocationConfiguration": "Géolocalisation", - "Longitude": "Longitude", - "Latitude": "Latitude", - "SunSetType": "Sunset type", - "SunSetTypeHint": "Affects the day/night calculation. It can take up to one minute until the new type will be applied.", - "OFFICIAL": "Standard dawn (90.8°)", - "NAUTICAL": "Nautical dawn (102°)", - "CIVIL": "Civil dawn (96°)", - "ASTONOMICAL": "Astronomical dawn (108°)", - "ManualTimeSynchronization": "Synchronisation manuelle de l'heure", - "CurrentOpenDtuTime": "Heure actuelle de l'OpenDTU", - "CurrentLocalTime": "Heure locale actuelle", - "SynchronizeTime": "Synchroniser l'heure", - "SynchronizeTimeHint": "Astuce : Vous pouvez utiliser la synchronisation horaire manuelle pour définir l'heure actuelle d'OpenDTU si aucun serveur NTP n'est disponible. Mais attention, en cas de mise sous tension, l'heure est perdue. Notez également que la précision de l'heure sera faussée, car elle ne peut pas être resynchronisée régulièrement et le microcontrôleur ESP32 ne dispose pas d'une horloge temps réel." - }, - "networkadmin": { - "NetworkSettings": "Paramètres réseau", - "WifiConfiguration": "Configuration du réseau WiFi", - "WifiSsid": "SSID", - "WifiPassword": "Mot de passe", - "Hostname": "Nom d'hôte", - "HostnameHint": "Astuce : Le texte %06X sera remplacé par les 6 derniers chiffres de l'ESP ChipID au format hexadécimal.", - "EnableDhcp": "Activer le DHCP", - "StaticIpConfiguration": "Configuration de l'IP statique", - "IpAddress": "Adresse IP", - "Netmask": "Masque de réseau", - "DefaultGateway": "Passerelle par défaut", - "Dns": "Serveur DNS {num}", - "AdminAp": "Configuration du réseau WiFi (Point d'accès)", - "ApTimeout": "Délai d'attente du point d'accès", - "ApTimeoutHint": "Durée pendant laquelle le point d'accès reste ouvert. Une valeur de 0 signifie infini.", - "Minutes": "minutes", - "EnableMdns": "Activer mDNS", - "MdnsSettings": "mDNS Settings" - }, - "mqttadmin": { - "MqttSettings": "Paramètres MQTT", - "MqttConfiguration": "Configuration du système MQTT", - "EnableMqtt": "Activer le MQTT", - "VerboseLogging": "@:base.VerboseLogging", - "EnableHass": "Activer la découverte automatique du MQTT de Home Assistant", - "MqttBrokerParameter": "Paramètre du Broker MQTT", - "Hostname": "Nom d'hôte", - "HostnameHint": "Nom d'hôte ou adresse IP", - "Port": "Port", - "Username": "Nom d'utilisateur", - "UsernameHint": "Nom d'utilisateur, laisser vide pour une connexion anonyme", - "Password": "Mot de passe:", - "PasswordHint": "Mot de passe, laissez vide pour une connexion anonyme", - "BaseTopic": "Sujet de base", - "BaseTopicHint": "Sujet de base, qui sera ajouté en préambule à tous les sujets publiés (par exemple, inverter/).", - "PublishInterval": "Intervalle de publication", - "Seconds": "secondes", - "CleanSession": "Enable CleanSession flag", - "EnableRetain": "Activation du maintien", - "EnableTls": "Activer le TLS", - "RootCa": "Certificat CA-Root (par défaut Letsencrypt)", - "TlsCertLoginEnable": "Activer la connexion par certificat TLS", - "ClientCert": "Certificat client TLS:", - "ClientKey": "Clé client TLS:", - "LwtParameters": "Paramètres LWT", - "LwtTopic": "Sujet LWT", - "LwtTopicHint": "Sujet LWT, sera ajouté comme sujet de base", - "LwtOnline": "Message en ligne de LWT", - "LwtOnlineHint": "Message qui sera publié sur le sujet LWT lorsqu'il sera en ligne", - "LwtOffline": "Message hors ligne de LWT", - "LwtOfflineHint": "Message qui sera publié sur le sujet LWT lorsqu'il sera hors ligne", - "LwtQos": "QoS (Quality of Service):", - "QOS0": "0 (Au maximum une fois)", - "QOS1": "1 (Au moins une fois)", - "QOS2": "2 (Exactement une fois)", - "HassParameters": "Paramètres de découverte automatique MQTT de Home Assistant", - "HassPrefixTopic": "Préfixe du sujet", - "HassPrefixTopicHint": "Le préfixe de découverte du sujet", - "HassRetain": "Activer du maintien", - "HassExpire": "Activer l'expiration", - "HassIndividual": "Panneaux individuels" - }, - "vedirectadmin": { - "VedirectSettings": "VE.Direct Settings", - "VedirectConfiguration": "VE.Direct Configuration", - "EnableVedirect": "Enable VE.Direct", - "VedirectParameter": "VE.Direct Parameter", - "VerboseLogging": "@:base.VerboseLogging", - "UpdatesOnly": "Publish values to MQTT only when they change" - }, - "batteryadmin": { - "BatterySettings": "Battery Settings", - "BatteryConfiguration": "General Interface Settings", - "EnableBattery": "Enable Interface", - "VerboseLogging": "@:base.VerboseLogging", - "Provider": "Data Provider", - "ProviderPylontechCan": "Pylontech using CAN bus", - "ProviderJkBmsSerial": "Jikong (JK) BMS using serial connection", - "ProviderMqtt": "Battery data from MQTT broker", - "ProviderVictron": "Victron SmartShunt using VE.Direct interface", - "MqttConfiguration": "MQTT Settings", - "MqttSocTopic": "SoC value topic", - "MqttVoltageTopic": "Voltage value topic", - "JkBmsConfiguration": "JK BMS Settings", - "JkBmsInterface": "Interface Type", - "JkBmsInterfaceUart": "TTL-UART on MCU", - "JkBmsInterfaceTransceiver": "RS-485 Transceiver on MCU", - "PollingInterval": "Polling Interval", - "Seconds": "@:base.Seconds" - }, - "inverteradmin": { - "InverterSettings": "Paramètres des onduleurs", - "AddInverter": "Ajouter un nouvel onduleur", - "Serial": "Numéro de série", - "Name": "Nom", - "Add": "Ajouter", - "AddHint": " Astuce : Vous pouvez définir des paramètres supplémentaires après avoir créé l'onduleur. Utilisez l'icône du stylo dans la liste des onduleurs.", - "InverterList": "Liste des onduleurs", - "Status": "État", - "Send": "Envoyer", - "Receive": "Recevoir", - "StatusHint": "Astuce : L'onduleur est alimenté par son entrée courant continu. S'il n'y a pas de soleil, l'onduleur est éteint, mais les requêtes peuvent toujours être envoyées.", - "Type": "Type", - "Action": "Action", - "SaveOrder": "Save order", - "DeleteInverter": "Supprimer l'onduleur", - "EditInverter": "Modifier l'onduleur", - "General": "Général", - "String": "Ligne", - "Advanced": "Advanced", - "InverterSerial": "Numéro de série de l'onduleur", - "InverterName": "Nom de l'onduleur :", - "InverterNameHint": "Ici, vous pouvez spécifier un nom personnalisé pour votre onduleur.", - "InverterStatus": "Recevoir / Envoyer", - "PollEnable": "Interroger les données de l'onduleur", - "PollEnableNight": "Interroger les données de l'onduleur la nuit", - "CommandEnable": "Envoyer des commandes", - "CommandEnableNight": "Envoyer des commandes la nuit", - "StringName": "Nom de la ligne {num}:", - "StringNameHint": "Ici, vous pouvez spécifier un nom personnalisé pour le port respectif de votre onduleur.", - "StringMaxPower": "Puissance maximale de la ligne {num}:", - "StringMaxPowerHint": "Entrez la puissance maximale des panneaux solaires connectés.", - "StringYtOffset": "Décalage du rendement total de la ligne {num} :", - "StringYtOffsetHint": "Ce décalage est appliqué à la valeur de rendement total lue sur le variateur. Il peut être utilisé pour mettre le rendement total du variateur à zéro si un variateur usagé est utilisé.", - "InverterHint": "*) Entrez le Wp du canal pour calculer l'irradiation.", - "ReachableThreshold": "Reachable Threshold:", - "ReachableThresholdHint": "Defines how many requests are allowed to fail until the inverter is treated is not reachable.", - "ZeroRuntime": "Zero runtime data", - "ZeroRuntimeHint": "Zero runtime data (no yield data) if inverter becomes unreachable.", - "ZeroDay": "Zero daily yield at midnight", - "ZeroDayHint": "This only works if the inverter is unreachable. If data is read from the inverter, it's values will be used. (Reset only occours on power cycle)", - "ClearEventlog": "Clear Eventlog at midnight", - "Cancel": "@:base.Cancel", - "Save": "@:base.Save", - "DeleteMsg": "Êtes-vous sûr de vouloir supprimer l'onduleur \"{name}\" avec le numéro de série \"{serial}\" ?", - "Delete": "Supprimer", - "YieldDayCorrection": "Yield Day Correction", - "YieldDayCorrectionHint": "Sum up daily yield even if the inverter is restarted. Value will be reset at midnight" - }, - "configadmin": { - "ConfigManagement": "Gestion de la configuration", - "BackupHeader": "Sauvegarder le fichier de configuration", - "BackupConfig": "Fichier de configuration", - "Backup": "Sauvegarder", - "Restore": "Restaurer", - "NoFileSelected": "Aucun fichier sélectionné", - "RestoreHeader": "Restaurer le fichier de configuration", - "Back": "Retour", - "UploadSuccess": "Succès du téléversement", - "RestoreHint": "Note : Cette opération remplace le fichier de configuration par la configuration restaurée et redémarre OpenDTU pour appliquer tous les paramètres.", - "ResetHeader": "Effectuer une réinitialisation d'usine", - "FactoryResetButton": "Restaurer les paramètres d'usine", - "ResetHint": "Note : Cliquez sur \"Restaurer les paramètres d'usine\" pour restaurer et initialiser les paramètres d'usine par défaut et redémarrer.", - "FactoryReset": "Remise à zéro", - "ResetMsg": "Êtes-vous sûr de vouloir supprimer la configuration actuelle et réinitialiser tous les paramètres à leurs valeurs par défaut ?", - "ResetConfirm": "Remise à zéro !", - "Cancel": "@:base.Cancel" - }, - "powerlimiteradmin": { - "PowerLimiterSettings": "Dynamic Power Limiter Settings", - "ConfigAlertMessage": "One or more prerequisites for operating the Dynamic Power Limiter are not met.", - "ConfigHints": "Configuration Notes", - "ConfigHintRequirement": "Required", - "ConfigHintOptional": "Optional", - "ConfigHintsIntro": "The following notes regarding the Dynamic Power Limiter (DPL) configuration shall be considered:", - "ConfigHintPowerMeterDisabled": "Without a power meter interface, the inverter limit the DPL will configure equals the configured base load (exception: (full) solar-passthrough).", - "ConfigHintNoInverter": "At least one inverter must be configured prior to setting up the DPL.", - "ConfigHintInverterCommunication": "Polling data from and sending commands to the target inverter must be enabled.", - "ConfigHintNoChargeController": "The solar-passthrough feature can only be used if the VE.Direct interface is configured.", - "ConfigHintNoBatteryInterface": "SoC-based thresholds can only be used if a battery communication interface is configured.", - "General": "General", - "Enable": "Enable", - "VerboseLogging": "@:base.VerboseLogging", - "SolarPassthrough": "Solar-Passthrough", - "EnableSolarPassthrough": "Enable Solar-Passthrough", - "SolarPassthroughLosses": "(Full) Solar-Passthrough Losses", - "SolarPassthroughLossesInfo": "Hint: Line losses are to be expected when transferring energy from the solar charge controller to the inverter. These losses can be taken into account to prevent the battery from gradually discharging in (full) solar-passthrough mode. The power limit to be set on the inverter is additionally reduced by this factor after taking its efficiency into account.", - "BatteryDischargeAtNight": "Use battery at night even if only partially charged", - "SolarpassthroughInfo": "This feature allows to use the available current solar power directly. The solar power, as reported by the MPPT charge controller, is set as the inverter's limit, even if the battery is currently charging. This avoids storing energy unnecessarily, which would be lossy.", - "InverterSettings": "Inverter", - "Inverter": "Target Inverter", - "SelectInverter": "Select an inverter...", - "InverterChannelId": "Input used for voltage measurements", - "TargetPowerConsumption": "Target Grid Consumption", - "TargetPowerConsumptionHint": "Grid power consumption the limiter tries to achieve. Value may be negative.", - "TargetPowerConsumptionHysteresis": "Hysteresis", - "TargetPowerConsumptionHysteresisHint": "Only send a newly calculated power limit to the inverter if the absolute difference to the last reported power limit exceeds this amount.", - "LowerPowerLimit": "Minimum Power Limit", - "LowerPowerLimitHint": "This value must be selected so that stable operation is possible at this limit. If the inverter could only be operated with a lower limit, it is put into standby instead.", - "BaseLoadLimit": "Base Load", - "BaseLoadLimitHint": "Relevant for operation without power meter or when the power meter fails. As long as the other conditions allow (in particular battery charge), this limit is set on the inverter.", - "UpperPowerLimit": "Maximum Power Limit", - "UpperPowerLimitHint": "The inverter is always set such that no more than this output power is achieved. This value must be selected to comply with the current carrying capacity of the AC connection cables.", - "SocThresholds": "Battery State of Charge (SoC) Thresholds", - "IgnoreSoc": "Ignore Battery SoC", - "StartThreshold": "Start Threshold for Battery Discharging", - "StopThreshold": "Stop Threshold for Battery Discharging", - "FullSolarPassthroughStartThreshold": "Full Solar-Passthrough Start Threshold", - "FullSolarPassthroughStartThresholdHint": "Inverter power is set equal to Victron MPPT power (minus efficiency factors) while above this threshold. Use this if you want to supply excess power to the grid when the battery is full.", - "VoltageSolarPassthroughStopThreshold": "Full Solar-Passthrough Stop Threshold", - "VoltageLoadCorrectionFactor": "Load correction factor", - "BatterySocInfo": "Hint: The battery SoC (State of Charge) values are only used if the battery communication interface reported SoC updates in the last minute. Otherwise the voltage thresholds will be used as fallback.", - "InverterIsBehindPowerMeter": "PowerMeter reading includes inverter output", - "InverterIsBehindPowerMeterHint": "Enable this option if the power meter reading is reduced by the inverter's output when it produces power. This is typically true.", - "InverterIsSolarPowered": "Inverter is powered by solar modules", - "VoltageThresholds": "Battery Voltage Thresholds", - "VoltageLoadCorrectionInfo": "Hint: When the battery is discharged, its voltage drops. The voltage drop scales with the discharge current. In order to not stop the inverter too early (stop threshold), this load correction factor can be specified to calculate the battery voltage if it was idle. Corrected voltage = DC Voltage + (Current power * correction factor)." - }, - "login": { - "Login": "Connexion", - "SystemLogin": "Connexion au système", - "Username": "Nom d'utilisateur", - "UsernameRequired": "Le nom d'utilisateur est requis", - "Password": "Mot de passe", - "PasswordRequired": "Le mot de passe est requis", - "LoginButton": "Connexion" - }, - "firmwareupgrade": { - "FirmwareUpgrade": "Mise à jour du firmware", - "Loading": "@:base.Loading", - "OtaError": "Erreur OTA", - "Back": "Retour", - "Retry": "Réessayer", - "OtaStatus": "Statut OTA", - "OtaSuccess": "Le téléchargement du firmware a réussi. L'appareil a été redémarré automatiquement. Lorsque l'appareil est à nouveau accessible, l'interface est automatiquement rechargée.", - "FirmwareUpload": "Téléversement du firmware", - "UploadProgress": "Progression du téléversement" - }, - "about": { - "AboutOpendtu": "À propos d'OpenDTU-OnBattery", - "Documentation": "Documentation", - "DocumentationBody": "The firmware and hardware documentation of the upstream project can be found here: https://www.opendtu.solar
Additional information, especially regarding OpenDTU-OnBattery-specific features, can be accessed at the Github Wiki.", - "ProjectOrigin": "Origine du projet", - "ProjectOriginBody1": "Ce projet a été démarré suite à cette discussion (Mikrocontroller.net).", - "ProjectOriginBody2": "Le protocole Hoymiles a été décrypté grâce aux efforts volontaires de nombreux participants. OpenDTU, entre autres, a été développé sur la base de ce travail. Le projet est sous licence Open Source (GNU General Public License version 2).", - "ProjectOriginBody3": "Le logiciel a été développé au mieux de nos connaissances et de nos convictions. Néanmoins, aucune responsabilité ne peut être acceptée en cas de dysfonctionnement ou de perte de garantie de l'onduleur.", - "ProjectOriginBody4": "OpenDTU est disponible gratuitement. Si vous avez payé pour le logiciel, vous avez probablement été arnaqué.", - "NewsUpdates": "Actualités et mises à jour", - "NewsUpdatesBody": "Les nouvelles mises à jour peuvent être trouvées sur Github.", - "ErrorReporting": "Rapport d'erreurs", - "ErrorReportingBody": "Veuillez signaler les problèmes en utilisant la fonction fournie par Github.", - "Discussion": "Discussion", - "DiscussionBody": "Discutez avec nous sur Discord ou sur Github." - }, - "hints": { - "RadioProblem": "Impossible de se connecter à un module radio configuré.. Veuillez vérifier le câblage.", - "TimeSync": "L'horloge n'a pas encore été synchronisée. Sans une horloge correctement réglée, aucune demande n'est adressée à l'onduleur. Ceci est normal peu de temps après le démarrage. Cependant, après un temps de fonctionnement plus long (>1 minute), cela indique que le serveur NTP n'est pas accessible.", - "TimeSyncLink": "Veuillez vérifier vos paramètres horaires.", - "DefaultPassword": "Vous utilisez le mot de passe par défaut pour l'interface Web et le point d'accès d'urgence. Ceci est potentiellement non sécurisé.", - "DefaultPasswordLink": "Merci de changer le mot de passe." - }, - "deviceadmin": { - "DeviceManager": "Gestionnaire de périphériques", - "ParseError": "Erreur d'analyse dans 'pin_mapping.json': {error}", - "PinAssignment": "Paramètres de connexion", - "SelectedProfile": "Profil sélectionné", - "DefaultProfile": "(Réglages par défaut)", - "ProfileHint": "Votre appareil peut cesser de répondre si vous sélectionnez un profil incompatible. Dans ce cas, vous devez effectuer une suppression via l'interface série.", - "Display": "Affichage", - "PowerSafe": "Economiseur d'énergie", - "PowerSafeHint": "Eteindre l'écran si aucun onduleur n'est en production.", - "Screensaver": "OLED Anti burn-in", - "ScreensaverHint": "Déplacez un peu l'écran à chaque mise à jour pour éviter le phénomène de brûlure. (Utile surtout pour les écrans OLED)", - "DiagramMode": "Diagram mode:", - "off": "Off", - "small": "Small", - "fullscreen": "Fullscreen", - "DiagramDuration": "Diagram duration:", - "DiagramDurationHint": "The time period which is shown in the diagram.", - "Seconds": "Seconds", - "Contrast": "Contraste ({contrast}):", - "Rotation": "Rotation:", - "rot0": "Pas de rotation", - "rot90": "Rotation de 90 degrés", - "rot180": "Rotation de 180 degrés", - "rot270": "Rotation de 270 degrés", - "DisplayLanguage": "Langue d'affichage", - "en": "Anglais", - "de": "Allemand", - "fr": "Français", - "Leds": "LEDs", - "EqualBrightness": "Même luminosité:", - "LedBrightness": "LED {led} luminosité ({brightness}):" - }, - "pininfo": { - "PinOverview": "Vue d'ensemble des connexions", - "Category": "Catégorie", - "Name": "Nom", - "ValueSelected": "Sélectionné", - "ValueActive": "Activé" - }, - "inputserial": { - "format_hoymiles": "Hoymiles serial number format", - "format_converted": "Already converted serial number", - "format_herf_valid": "E-Star HERF format (will be saved converted): {serial}", - "format_herf_invalid": "E-Star HERF format: Invalid checksum", - "format_unknown": "Unknown format" - }, - "huawei": { - "DataAge": "Data Age: ", - "Seconds": " {val} seconds", - "Input": "Input", - "Output": "Output", - "Property": "Property", - "Value": "Value", - "Unit": "Unit", - "input_voltage": "Input voltage", - "input_current": "Input current", - "input_power": "Input power", - "input_temp": "Input temperature", - "efficiency": "Efficiency", - "output_voltage": "Output voltage", - "output_current": "Output current", - "max_output_current": "Maximum output current", - "output_power": "Output power", - "output_temp": "Output temperature", - "ShowSetLimit": "Show / Set Huawei Limit", - "LimitSettings": "Limit Settings", - "SetOffline": "Set limit, CAN bus not connected", - "SetOnline": "Set limit, CAN bus connected", - "LimitHint": "Hint: CAN bus not connected voltage limit is 48V-58.5V.", - "Close": "close", - "SetVoltageLimit": "Voltage limit:", - "SetCurrentLimit": "Current limit:", - "CurrentLimit": "Current limit:" - }, - "acchargeradmin": { - "ChargerSettings": "AC Charger Settings", - "Configuration": "AC Charger Configuration", - "EnableHuawei": "Enable Huawei R4850G2 on CAN Bus Interface", - "VerboseLogging": "@:base.VerboseLogging", - "CanControllerFrequency": "CAN controller quarz frequency", - "EnableAutoPower": "Automatic power control", - "EnableBatterySoCLimits": "Use SoC data of a connected battery", - "Limits": "Limits", - "BatterySoCLimits": "Battery SoC Limits", - "VoltageLimit": "Charge Voltage limit", - "enableVoltageLimit": "Re-enable voltage limit", - "stopVoltageLimitHint": "Maximum charger voltage. Equals battery charge voltage limit. Used for automatic power control and when emergency charging", - "enableVoltageLimitHint": "Automatic power control is disabled if the output voltage is higher then this value and if the output power drops below the minimum output power limit (set below).\nAutomatic power control is re-enabled if the battery voltage drops below the value set in this field.", - "upperPowerLimitHint": "Maximum output power. Used for automatic power control and when emergency charging", - "lowerPowerLimit": "Minimum output power", - "upperPowerLimit": "Maximum output power", - "StopBatterySoCThreshold": "Stop charging at SoC", - "StopBatterySoCThresholdHint": "To prolong the battery's lifespan, charging can be stopped at a certain SoC level.\nHint: In order to keep the SoC reading accurate, some LiFePO cells must be charged to full capacity regularly.", - "Seconds": "@:base.Seconds", - "EnableEmergencyCharge": "Emergency charge. Battery charged with maximum power if requested by Battery BMS", - "targetPowerConsumption": "Target power consumption", - "targetPowerConsumptionHint": "Postitive values use grid power to charge the battery. Negative values result in early shutdown" - }, - "battery": { - "battery": "Battery", - "FwVersion": "Firmware Version", - "HwVersion": "Hardware Version", - "DataAge": "Data Age: ", - "Seconds": " {val} seconds", - "status": "Status", - "Property": "Property", - "yes": "@:base.Yes", - "no": "@:base.No", - "Value": "Value", - "Unit": "Unit", - "SoC": "State of Charge", - "stateOfHealth": "State of Health", - "voltage": "Voltage", - "current": "Current", - "power": "Power", - "temperature": "Temperature", - "bmsTemp": "BMS temperature", - "chargeVoltage": "Requested charge voltage", - "chargeCurrentLimitation": "Charge current limit", - "dischargeCurrentLimitation": "Discharge current limit", - "chargeEnabled": "Charging possible", - "dischargeEnabled": "Discharging possible", - "chargeImmediately": "Immediate charging requested", - "cells": "Cells", - "batOneTemp": "Battery temperature 1", - "batTwoTemp": "Battery temperature 2", - "cellMinVoltage": "Minimum cell voltage", - "cellAvgVoltage": "Average cell voltage", - "cellMaxVoltage": "Maximum cell voltage", - "cellDiffVoltage": "Cell voltage difference", - "balancingActive": "Balancing active", - "issues": "Issues", - "noIssues": "No Issues", - "issueName": "Name", - "issueType": "Type", - "alarm": "Alarm", - "warning": "Warning", - "JkBmsIssueLowCapacity": "Low Capacity", - "JkBmsIssueBmsOvertemperature": "BMS overtemperature", - "JkBmsIssueChargingOvervoltage": "Overvoltage (sum of all cells)", - "JkBmsIssueDischargeUndervoltage": "Undervoltage (sum of all cells)", - "JkBmsIssueBatteryOvertemperature": "Battery overtemperature", - "JkBmsIssueChargingOvercurrent": "Overcurrent (Charging)", - "JkBmsIssueDischargeOvercurrent": "Overcurrent (Discharging)", - "JkBmsIssueCellVoltageDifference": "Cell voltage difference too high", - "JkBmsIssueBatteryBoxOvertemperature": "Battery (box?) overtemperature", - "JkBmsIssueBatteryUndertemperature": "Battery undertemperature", - "JkBmsIssueCellOvervoltage": "Overvoltage (single cell)", - "JkBmsIssueCellUndervoltage": "Undervoltage (single cell)", - "JkBmsIssueAProtect": "AProtect (meaning?)", - "JkBmsIssueBProtect": "BProtect (meaning?)", - "highCurrentDischarge": "High current (discharge)", - "overCurrentDischarge": "Overcurrent (discharge)", - "highCurrentCharge": "High current (charge)", - "overCurrentCharge": "Overcurrent (charge)", - "lowTemperature": "Low temperature", - "underTemperature": "Undertemperature", - "highTemperature": "High temperature", - "overTemperature": "Overtemperature", - "lowVoltage": "Low voltage", - "lowSOC": "Low state of charge", - "underVoltage": "Undervoltage", - "highVoltage": "High voltage", - "overVoltage": "Overvoltage", - "bmsInternal": "BMS internal", - "chargeCycles": "Charge cycles", - "chargedEnergy": "Charged energy", - "dischargedEnergy": "Discharged energy", - "instantaneousPower": "Instantaneous Power", - "consumedAmpHours": "Consumed Amp Hours", - "midpointVoltage": "Midpoint Voltage", - "midpointDeviation": "Midpoint Deviation", - "lastFullCharge": "Last full Charge" - } -} +{ + "menu": { + "LiveView": "Direct", + "Settings": "Paramètres", + "NetworkSettings": "Réseau", + "NTPSettings": "Heure locale", + "MQTTSettings": "MQTT", + "InverterSettings": "Onduleurs", + "SecuritySettings": "Sécurité", + "DTUSettings": "DTU", + "DeviceManager": "Périphériques", + "VedirectSettings": "VE.Direct", + "PowerMeterSettings": "Power Meter", + "BatterySettings": "Battery", + "AcChargerSettings": "AC Charger", + "ConfigManagement": "Gestion de la configuration", + "FirmwareUpgrade": "Mise à jour du firmware", + "DeviceReboot": "Redémarrage de l'appareil", + "Info": "Informations", + "System": "Système", + "Network": "Réseau", + "NTP": "NTP", + "MQTT": "MQTT", + "Console": "Console", + "Vedirect": "VE.Direct", + "About": "A propos", + "Logout": "Déconnexion", + "Login": "Connexion" + }, + "base": { + "Yes": "Oui", + "No": "Non", + "VerboseLogging": "Journalisation Détaillée", + "Seconds": "Secondes", + "Loading": "Chargement...", + "Reload": "Reload", + "Cancel": "Annuler", + "Save": "Sauvegarder", + "Refreshing": "Refreshing", + "Pull": "Pull down to refresh", + "Release": "Release to refresh", + "Close": "Fermer" + }, + "Error": { + "Oops": "Oops!" + }, + "localeswitcher": { + "Dark": "Sombre", + "Light": "Clair", + "Auto": "Auto" + }, + "apiresponse": { + "1001": "Paramètres enregistrés !", + "1002": "Aucune valeur trouvée !", + "1003": "Données trop importantes !", + "1004": "Échec de l'analyse des données !", + "1005": "Certaines valeurs sont manquantes !", + "1006": "Write failed!", + "2001": "Le numéro de série ne peut pas être nul !", + "2002": "L'intervalle de sondage doit être supérieur à zéro !", + "2003": "Réglage du niveau de puissance invalide !", + "2004": "The frequency must be set between {min} and {max} kHz and must be a multiple of 250kHz!", + "2005": "Invalid country selection !", + "3001": "Rien n'a été supprimé !", + "3002": "Configuration réinitialisée. Redémarrage maintenant...", + "4001": "@:apiresponse.2001", + "4002": "Le nom doit comporter entre 1 et {max} caractères !", + "4003": "Seulement {max} onduleurs sont supportés !", + "4004": "Onduleur créé !", + "4005": "Identifiant spécifié invalide !", + "4006": "Réglage du montant maximal de canaux invalide !", + "4007": "Onduleur modifié !", + "4008": "Onduleur supprimé !", + "4009": "Inverter order saved!", + "5001": "@:apiresponse.2001", + "5002": "La limite doit être comprise entre 1 et {max} !", + "5003": "Type spécifié invalide !", + "5004": "Onduleur spécifié invalide !", + "6001": "Redémarrage déclenché !", + "6002": "Redémarrage annulé !", + "7001": "Le nom du serveur MQTT doit comporter entre 1 et {max} caractères !", + "7002": "Le nom d'utilisateur ne doit pas comporter plus de {max} caractères !", + "7003": "Le mot de passe ne doit pas comporter plus de {max} caractères !", + "7004": "Le sujet ne doit pas comporter plus de {max} caractères !", + "7005": "Le sujet ne doit pas contenir d'espace !", + "7006": "Le sujet doit se terminer par une barre oblique (/) !", + "7007": "Le port doit être un nombre entre 1 et 65535 !", + "7008": "Le certificat ne doit pas comporter plus de {max} caractères !", + "7009": "Le sujet LWT ne doit pas comporter plus de {max} caractères !", + "7010": "Le sujet LWT ne doit pas contenir de caractères d'espacement !", + "7011": "La valeur LWT en ligne ne doit pas dépasser {max} caractères !", + "7012": "La valeur LWT hors ligne ne doit pas dépasser {max} caractères !", + "7013": "L'intervalle de publication doit être un nombre compris entre {min} et {max} !", + "7014": "Le sujet Hass ne doit pas dépasser {max} caractères !", + "7015": "Le sujet Hass ne doit pas contenir d'espace !", + "7016": "LWT QOS ne doit pas être supérieur à {max}!", + "8001": "L'adresse IP n'est pas valide !", + "8002": "Le masque de réseau n'est pas valide !", + "8003": "La passerelle n'est pas valide !", + "8004": "L'adresse IP du serveur DNS primaire n'est pas valide !", + "8005": "L'adresse IP du serveur DNS secondaire n'est pas valide !", + "8006": "La valeur du délai d'attente du point d'accès administratif n'est pas valide !", + "9001": "Le serveur NTP doit avoir une longueur comprise entre 1 et {max} caractères !", + "9002": "Le fuseau horaire doit comporter entre 1 et {max} caractères !", + "9003": "La description du fuseau horaire doit comporter entre 1 et {max} caractères !", + "9004": "L'année doit être un nombre compris entre {min} et {max} !", + "9005": "Le mois doit être un nombre compris entre {min} et {max} !", + "9006": "Le jour doit être un nombre compris entre {min} et {max} !", + "9007": "Les heures doivent être un nombre compris entre {min} et {max} !", + "9008": "Les minutes doivent être un nombre compris entre {min} et {max} !", + "9009": "Les secondes doivent être un nombre compris entre {min} et {max} !", + "9010": "Heure mise à jour !", + "10001": "Le mot de passe doit comporter entre 8 et {max} caractères !", + "10002": "Authentification réussie !", + "11001": "@:apiresponse.2001", + "11002": "@:apiresponse:5004", + "12001": "Le profil doit comporter entre 1 et {max} caractères !" + }, + "home": { + "LiveData": "Données en direct", + "SerialNumber": "Numéro de série : ", + "CurrentLimit": "Limite de courant : ", + "DataAge": "Âge des données : ", + "Seconds": "{val} secondes", + "ShowSetInverterLimit": "Afficher / Régler la limite de l'onduleur", + "TurnOnOff": "Allumer / Eteindre l'onduleur", + "ShowInverterInfo": "Afficher les informations sur l'onduleur", + "ShowEventlog": "Afficher le journal des événements", + "UnreadMessages": "messages non lus", + "Loading": "@:base.Loading", + "EventLog": "Journal des événements", + "InverterInfo": "Informations sur l'onduleur", + "LimitSettings": "Paramètres de la limite", + "LastLimitSetStatus": "Statut de la dernière limite fixée", + "SetLimit": "Fixer la limite", + "Relative": "Relative (%)", + "Absolute": "Absolue (W)", + "LimitHint": "Astuce : Si vous définissez la limite en valeur absolue, l'affichage de la valeur actuelle ne sera mis à jour qu'après environ 4 minutes.", + "SetPersistent": "Fixer une limite persistante", + "SetNonPersistent": "Fixer une limite non persistante", + "PowerSettings": "Paramètres d'alimentation", + "LastPowerSetStatus": "État du dernier réglage de l'alimentation", + "TurnOn": "Allumer", + "TurnOff": "Eteindre", + "Restart": "Redémarrer", + "Failure": "Échec", + "Pending": "En attente", + "Ok": "OK", + "Unknown": "Inconnu", + "ShowGridProfile": "Show Grid Profile", + "GridProfile": "Grid Profile", + "LoadingInverter": "Waiting for data... (can take up to 10 seconds)" + }, + "vedirecthome": { + "SerialNumber": "Numéro de série", + "FirmwareVersion": "Version du Firmware", + "DataAge": "Âge des données", + "Seconds": "{val} secondes", + "Property": "Property", + "Value": "Value", + "Unit": "Unit", + "section_device": "Device Info", + "device": { + "LOAD": "Load output state", + "CS": "State of operation", + "MPPT": "Tracker operation mode", + "OR": "Off reason", + "ERR": "Error code", + "HSDS": "Day sequence number (0..364)", + "MpptTemperature": "Charge controller temperature" + }, + "section_output": "Output (Battery)", + "output": { + "P": "Power (calculated)", + "V": "Voltage", + "I": "Current", + "E": "Efficiency (calculated)" + }, + "section_input": "Input (Solar Panels)", + "input": { + "NetworkPower": "VE.Smart network total power", + "PPV": "Power", + "VPV": "Voltage", + "IPV": "Current (calculated)", + "YieldToday": "Yield today", + "YieldYesterday": "Yield yesterday", + "YieldTotal": "Yield total (user resettable counter)", + "MaximumPowerToday": "Maximum power today", + "MaximumPowerYesterday": "Maximum power yesterday" + }, + "PowerLimiterState": "Power limiter state [off (charging), solar passthrough, on battery]" + }, + "vedirecthome": { + "SerialNumber": "Serial Number: ", + "FirmwareNumber": "Firmware Number: ", + "DataAge": "Data Age: ", + "Seconds": "{val} seconds", + "DeviceInfo": "Device Info", + "Property": "Property", + "Value": "Value", + "Unit": "Unit", + "LoadOutputState": "Load output state", + "StateOfOperation": "State of operation", + "TrackerOperationMode": "Tracker operation mode", + "OffReason": "Off reason", + "ErrorCode": "Error code", + "DaySequenceNumber": "Day sequence number (0..364)", + "Battery": "Output (Battery)", + "output": { + "P": "Power (calculated)", + "V": "Voltage", + "I": "Current", + "E": "Efficiency (calculated)" + }, + "Panel": "Input (Solar Panels)", + "input": { + "PPV": "Power", + "VPV": "Voltage", + "IPV": "Current (calculated)", + "YieldToday": "Yield today", + "YieldYesterday": "Yield yesterday", + "YieldTotal": "Yield total (user resettable counter)", + "MaximumPowerToday": "Maximum power today", + "MaximumPowerYesterday": "Maximum power yesterday" + }, + "PowerLimiterState": "Power limiter state [off (charging), solar passthrough, on battery]" + }, + "eventlog": { + "Start": "Départ", + "Stop": "Arrêt", + "Id": "ID", + "Message": "Message" + }, + "devinfo": { + "NoInfo": "Aucune information disponible", + "NoInfoLong": "N'a pas reçu de données valides de l'onduleur jusqu'à présent. J'essaie toujours...", + "UnknownModel": "Modèle inconnu ! Veuillez signaler le \"Numéro d'article matériel\" et le modèle (par exemple, HM-350) comme un problème ici.", + "Serial": "Serial", + "ProdYear": "Production Year", + "ProdWeek": "Production Week", + "Model": "Modèle", + "DetectedMaxPower": "Puissance maximale détectée", + "BootloaderVersion": "Version du bootloader", + "FirmwareVersion": "Version du firmware", + "FirmwareBuildDate": "Date de création du firmware", + "HardwarePartNumber": "Numéro d'article matériel", + "HardwareVersion": "Version du matériel" + }, + "gridprofile": { + "NoInfo": "@:devinfo.NoInfo", + "NoInfoLong": "@:devinfo.NoInfoLong", + "Name": "Name", + "Version": "Version", + "Enabled": "@:wifistationinfo.Enabled", + "Disabled": "@:wifistationinfo.Disabled", + "GridprofileSupport": "Support the development", + "GridprofileSupportLong": "Please see here for further information." + }, + "systeminfo": { + "SystemInfo": "Informations sur le système", + "VersionError": "Erreur de récupération des informations de version", + "VersionNew": "Nouvelle version disponible ! Montrer les changements !", + "VersionOk": "À jour !" + }, + "firmwareinfo": { + "FirmwareInformation": "Informations sur le firmware", + "Hostname": "Nom d'hôte", + "SdkVersion": "Version du SDK", + "ConfigVersion": "Version de la configuration", + "FirmwareVersion": "Version du firmware / Hash Git", + "PioEnv": "PIO Environment", + "FirmwareVersionHint": "Cliquez ici pour afficher des informations sur votre version actuelle", + "FirmwareUpdate": "Mise à jour du firmware", + "FirmwareUpdateHint": "Cliquez ici pour voir les changements entre votre version et la dernière version", + "FrmwareUpdateAllow": "En activant le contrôle de mise à jour, une demande est envoyée à GitHub.com à chaque fois que la page est consultée afin de récupérer la dernière version disponible. Si tu n'es pas d'accord, laisse cette fonction désactivée.", + "ResetReason0": "Raison de la réinitialisation CPU 0", + "ResetReason1": "Raison de la réinitialisation CPU 1", + "ConfigSaveCount": "Nombre d'enregistrements de la configuration", + "Uptime": "Durée de fonctionnement", + "UptimeValue": "0 jour {time} | 1 jour {time} | {count} jours {time}" + }, + "hardwareinfo": { + "HardwareInformation": "Informations sur le matériel", + "ChipModel": "Modèle de puce", + "ChipRevision": "Révision de la puce", + "ChipCores": "Nombre de cœurs", + "CpuFrequency": "Fréquence du CPU", + "Mhz": "MHz", + "CpuTemperature": "CPU Temperature", + "FlashSize": "Taille de la mémoire flash" + }, + "memoryinfo": { + "MemoryInformation": "Informations sur la mémoire", + "Type": "Type", + "Usage": "Utilisation", + "Free": "Libre", + "Used": "Utilisée", + "Size": "Taille", + "Heap": "Heap", + "PsRam": "PSRAM", + "LittleFs": "LittleFs", + "Sketch": "Sketch" + }, + "heapdetails": { + "HeapDetails": "Heap Details", + "TotalFree": "Total free", + "LargestFreeBlock": "Biggest contiguous free block", + "MaxUsage": "Maximum usage since start", + "Fragmentation": "Level of fragmentation" + }, + "radioinfo": { + "RadioInformation": "Informations sur la radio", + "Status": "{module} Statut", + "ChipStatus": "{module} État de la puce", + "ChipType": "{module} Type de puce", + "Connected": "connectée", + "NotConnected": "non connectée", + "Configured": "configurée", + "NotConfigured": "non configurée", + "Unknown": "Inconnue" + }, + "networkinfo": { + "NetworkInformation": "Informations sur le réseau" + }, + "wifistationinfo": { + "WifiStationInfo": "Informations sur le WiFi (Station)", + "Status": "Statut", + "Enabled": "activé", + "Disabled": "désactivé", + "Ssid": "SSID", + "Bssid": "BSSID", + "Quality": "Qualité", + "Rssi": "RSSI" + }, + "wifiapinfo": { + "WifiApInfo": "Informations sur le WiFi (Point d'accès)", + "Status": "@:wifistationinfo.Status", + "Enabled": "@:wifistationinfo.Enabled", + "Disabled": "@:wifistationinfo.Disabled", + "Ssid": "@:wifistationinfo.Ssid", + "Stations": "# Stations" + }, + "interfacenetworkinfo": { + "NetworkInterface": "Interface réseau ({iface})", + "Hostname": "@:firmwareinfo.Hostname", + "IpAddress": "Adresse IP", + "Netmask": "Masque de réseau", + "DefaultGateway": "Passerelle par défaut", + "Dns": "DNS {num}", + "MacAddress": "Addresse MAC" + }, + "interfaceapinfo": { + "NetworkInterface": "Interface réseau (Point d'accès)", + "IpAddress": "@:interfacenetworkinfo.IpAddress", + "MacAddress": "@:interfacenetworkinfo.MacAddress" + }, + "ntpinfo": { + "NtpInformation": "Informations sur le NTP", + "ConfigurationSummary": "Résumé de la configuration", + "Server": "Serveur", + "Timezone": "Fuseau horaire", + "TimezoneDescription": "Description du fuseau horaire", + "CurrentTime": "Heure actuelle", + "Status": "Statut", + "Synced": "synchronisée", + "NotSynced": "pas synchronisée", + "LocalTime": "Heure locale", + "Sunrise": "Lever du soleil", + "Sunset": "Coucher du soleil", + "NotAvailable": "Not Available", + "Mode": "Mode", + "Day": "Jour", + "Night": "Nuit" + }, + "mqttinfo": { + "MqttInformation": "MQTT Information", + "ConfigurationSummary": "@:ntpinfo.ConfigurationSummary", + "Status": "@:ntpinfo.Status", + "Enabled": "Activé", + "Disabled": "Désactivé", + "Server": "@:ntpinfo.Server", + "Port": "Port", + "Username": "Nom d'utilisateur", + "BaseTopic": "Sujet de base", + "PublishInterval": "Intervalle de publication", + "Seconds": "{sec} secondes", + "CleanSession": "CleanSession Flag", + "Retain": "Conserver", + "Tls": "TLS", + "RootCertifcateInfo": "Informations sur le certificat de l'autorité de certification racine", + "TlsCertLogin": "Connexion avec un certificat TLS", + "ClientCertifcateInfo": "Informations sur le certificat du client", + "HassSummary": "Résumé de la configuration de la découverte automatique du MQTT de Home Assistant", + "Expire": "Expiration", + "IndividualPanels": "Panneaux individuels", + "RuntimeSummary": "Résumé du temps de fonctionnement", + "ConnectionStatus": "État de la connexion", + "Connected": "connecté", + "Disconnected": "déconnecté" + }, + "vedirectinfo": { + "VedirectInformation" : "VE.Direct Info", + "ConfigurationSummary": "@:ntpinfo.ConfigurationSummary", + "Status": "@:ntpinfo.Status", + "Enabled": "@:mqttinfo.Enabled", + "Disabled": "@:mqttinfo.Disabled", + "VerboseLogging": "@:base.VerboseLogging", + "UpdatesOnly": "@:vedirectadmin.UpdatesOnly", + "UpdatesEnabled": "@:mqttinfo.Enabled", + "UpdatesDisabled": "@:mqttinfo.Disabled" + }, + "console": { + "Console": "Console", + "VirtualDebugConsole": "Console de débogage", + "EnableAutoScroll": "Activer le défilement automatique", + "ClearConsole": "Vider la console", + "CopyToClipboard": "Copier dans le presse-papiers" + }, + "inverterchannelinfo": { + "String": "Ligne {num}", + "Phase": "Phase {num}", + "General": "General" + }, + "invertertotalinfo": { + "InverterTotalYieldTotal": "Onduleurs rendement total", + "InverterTotalYieldDay": "Onduleurs rendement du jour", + "InverterTotalPower": "Onduleurs puissance de l'installation", + "MpptTotalYieldTotal": "MPPT rendement total", + "MpptTotalYieldDay": "MPPT rendement du jour", + "MpptTotalPower": "MPPT puissance de l'installation", + "BatterySoc": "State of charge", + "HomePower": "Grid Power", + "HuaweiPower": "Huawei AC Power" + }, + "inverterchannelproperty": { + "Power": "Puissance", + "Voltage": "Tension", + "Current": "Courant", + "Power DC": "Puissance continue", + "YieldDay": "Rendement du jour", + "YieldTotal": "Rendement total", + "Frequency": "Fréquence", + "Temperature": "Température", + "PowerFactor": "Facteur de puissance", + "ReactivePower": "Puissance réactive", + "Efficiency": "Efficacité", + "Irradiation": "Irradiation" + }, + "maintenancereboot": { + "DeviceReboot": "Redémarrage de l'appareil", + "PerformReboot": "Effectuer un redémarrage", + "Reboot": "Redémarrer !", + "Cancel": "@:base.Cancel", + "RebootOpenDTU": "Redémarrer OpenDTU", + "RebootQuestion": "Voulez-vous vraiment redémarrer l'appareil ?", + "RebootHint": "Astuce : Normalement, il n'est pas nécessaire de procéder à un redémarrage manuel. OpenDTU effectue automatiquement tout redémarrage nécessaire (par exemple, après une mise à jour du firmware). Les paramètres sont également adoptés sans redémarrage. Si vous devez redémarrer en raison d'une erreur, veuillez envisager de la signaler à l'adresse suivante Github." + }, + "dtuadmin": { + "DtuSettings": "Paramètres du DTU", + "DtuConfiguration": "Configuration du DTU", + "Serial": "Numéro de série", + "SerialHint": "L'onduleur et le DTU ont tous deux un numéro de série. Le numéro de série du DTU est généré de manière aléatoire lors du premier démarrage et ne doit normalement pas être modifié.", + "PollInterval": "Intervalle de sondage", + "VerboseLogging": "@:base.VerboseLogging", + "Seconds": "Secondes", + "NrfPaLevel": "NRF24 Niveau de puissance d'émission", + "CmtPaLevel": "CMT2300A Niveau de puissance d'émission", + "NrfPaLevelHint": "Used for HM-Inverters. Assurez-vous que votre alimentation est suffisamment stable avant d'augmenter la puissance d'émission.", + "CmtPaLevelHint": "Used for HMS/HMT-Inverters. Assurez-vous que votre alimentation est suffisamment stable avant d'augmenter la puissance d'émission.", + "CmtCountry": "CMT2300A Region/Country:", + "CmtCountryHint": "Each country has different frequency allocations.", + "country_0": "Europe ({min}MHz - {max}MHz)", + "country_1": "North America ({min}MHz - {max}MHz)", + "country_2": "Brazil ({min}MHz - {max}MHz)", + "CmtFrequency": "CMT2300A Frequency:", + "CmtFrequencyHint": "Make sure to only use frequencies that are allowed in the respective country! After a frequency change, it can take up to 15min until a connection is established.", + "CmtFrequencyWarning": "The selected frequency is outside the allowed range in your selected region/country. Make sure that this selection does not violate any local regulations.", + "MHz": "{mhz} MHz", + "dBm": "{dbm} dBm", + "Min": "Minimum ({db} dBm)", + "Low": "Bas ({db} dBm)", + "High": "Haut ({db} dBm)", + "Max": "Maximum ({db} dBm)" + }, + "securityadmin": { + "SecuritySettings": "Paramètres de sécurité", + "AdminPassword": "Mot de passe administrateur", + "Password": "Mot de passe", + "RepeatPassword": "Répéter le mot de passe", + "PasswordHint": "Astuce : Le mot de passe administrateur est utilisé pour accéder à cette interface web (utilisateur 'admin'), mais aussi pour se connecter à l'appareil en mode AP. Il doit comporter de 8 à 64 caractères.", + "Permissions": "Autorisations", + "ReadOnly": "Autoriser l'accès en lecture seule à l'interface web sans mot de passe" + }, + "ntpadmin": { + "NtpSettings": "Paramètres NTP", + "NtpConfiguration": "Configuration du protocole NTP", + "TimeServer": "Serveur horaire", + "TimeServerHint": "La valeur par défaut convient tant que OpenDTU a un accès direct à Internet.", + "Timezone": "Fuseau horaire", + "TimezoneConfig": "Configuration du fuseau horaire", + "LocationConfiguration": "Géolocalisation", + "Longitude": "Longitude", + "Latitude": "Latitude", + "SunSetType": "Sunset type", + "SunSetTypeHint": "Affects the day/night calculation. It can take up to one minute until the new type will be applied.", + "OFFICIAL": "Standard dawn (90.8°)", + "NAUTICAL": "Nautical dawn (102°)", + "CIVIL": "Civil dawn (96°)", + "ASTONOMICAL": "Astronomical dawn (108°)", + "ManualTimeSynchronization": "Synchronisation manuelle de l'heure", + "CurrentOpenDtuTime": "Heure actuelle de l'OpenDTU", + "CurrentLocalTime": "Heure locale actuelle", + "SynchronizeTime": "Synchroniser l'heure", + "SynchronizeTimeHint": "Astuce : Vous pouvez utiliser la synchronisation horaire manuelle pour définir l'heure actuelle d'OpenDTU si aucun serveur NTP n'est disponible. Mais attention, en cas de mise sous tension, l'heure est perdue. Notez également que la précision de l'heure sera faussée, car elle ne peut pas être resynchronisée régulièrement et le microcontrôleur ESP32 ne dispose pas d'une horloge temps réel." + }, + "networkadmin": { + "NetworkSettings": "Paramètres réseau", + "WifiConfiguration": "Configuration du réseau WiFi", + "WifiSsid": "SSID", + "WifiPassword": "Mot de passe", + "Hostname": "Nom d'hôte", + "HostnameHint": "Astuce : Le texte %06X sera remplacé par les 6 derniers chiffres de l'ESP ChipID au format hexadécimal.", + "EnableDhcp": "Activer le DHCP", + "StaticIpConfiguration": "Configuration de l'IP statique", + "IpAddress": "Adresse IP", + "Netmask": "Masque de réseau", + "DefaultGateway": "Passerelle par défaut", + "Dns": "Serveur DNS {num}", + "AdminAp": "Configuration du réseau WiFi (Point d'accès)", + "ApTimeout": "Délai d'attente du point d'accès", + "ApTimeoutHint": "Durée pendant laquelle le point d'accès reste ouvert. Une valeur de 0 signifie infini.", + "Minutes": "minutes", + "EnableMdns": "Activer mDNS", + "MdnsSettings": "mDNS Settings" + }, + "mqttadmin": { + "MqttSettings": "Paramètres MQTT", + "MqttConfiguration": "Configuration du système MQTT", + "EnableMqtt": "Activer le MQTT", + "VerboseLogging": "@:base.VerboseLogging", + "EnableHass": "Activer la découverte automatique du MQTT de Home Assistant", + "MqttBrokerParameter": "Paramètre du Broker MQTT", + "Hostname": "Nom d'hôte", + "HostnameHint": "Nom d'hôte ou adresse IP", + "Port": "Port", + "Username": "Nom d'utilisateur", + "UsernameHint": "Nom d'utilisateur, laisser vide pour une connexion anonyme", + "Password": "Mot de passe:", + "PasswordHint": "Mot de passe, laissez vide pour une connexion anonyme", + "BaseTopic": "Sujet de base", + "BaseTopicHint": "Sujet de base, qui sera ajouté en préambule à tous les sujets publiés (par exemple, inverter/).", + "PublishInterval": "Intervalle de publication", + "Seconds": "secondes", + "CleanSession": "Enable CleanSession flag", + "EnableRetain": "Activation du maintien", + "EnableTls": "Activer le TLS", + "RootCa": "Certificat CA-Root (par défaut Letsencrypt)", + "TlsCertLoginEnable": "Activer la connexion par certificat TLS", + "ClientCert": "Certificat client TLS:", + "ClientKey": "Clé client TLS:", + "LwtParameters": "Paramètres LWT", + "LwtTopic": "Sujet LWT", + "LwtTopicHint": "Sujet LWT, sera ajouté comme sujet de base", + "LwtOnline": "Message en ligne de LWT", + "LwtOnlineHint": "Message qui sera publié sur le sujet LWT lorsqu'il sera en ligne", + "LwtOffline": "Message hors ligne de LWT", + "LwtOfflineHint": "Message qui sera publié sur le sujet LWT lorsqu'il sera hors ligne", + "LwtQos": "QoS (Quality of Service):", + "QOS0": "0 (Au maximum une fois)", + "QOS1": "1 (Au moins une fois)", + "QOS2": "2 (Exactement une fois)", + "HassParameters": "Paramètres de découverte automatique MQTT de Home Assistant", + "HassPrefixTopic": "Préfixe du sujet", + "HassPrefixTopicHint": "Le préfixe de découverte du sujet", + "HassRetain": "Activer du maintien", + "HassExpire": "Activer l'expiration", + "HassIndividual": "Panneaux individuels" + }, + "vedirectadmin": { + "VedirectSettings": "VE.Direct Settings", + "VedirectConfiguration": "VE.Direct Configuration", + "EnableVedirect": "Enable VE.Direct", + "VedirectParameter": "VE.Direct Parameter", + "VerboseLogging": "@:base.VerboseLogging", + "UpdatesOnly": "Publish values to MQTT only when they change" + }, + "batteryadmin": { + "BatterySettings": "Battery Settings", + "BatteryConfiguration": "General Interface Settings", + "EnableBattery": "Enable Interface", + "VerboseLogging": "@:base.VerboseLogging", + "Provider": "Data Provider", + "ProviderPylontechCan": "Pylontech using CAN bus", + "ProviderJkBmsSerial": "Jikong (JK) BMS using serial connection", + "ProviderMqtt": "Battery data from MQTT broker", + "ProviderVictron": "Victron SmartShunt using VE.Direct interface", + "MqttConfiguration": "MQTT Settings", + "MqttSocTopic": "SoC value topic", + "MqttVoltageTopic": "Voltage value topic", + "JkBmsConfiguration": "JK BMS Settings", + "JkBmsInterface": "Interface Type", + "JkBmsInterfaceUart": "TTL-UART on MCU", + "JkBmsInterfaceTransceiver": "RS-485 Transceiver on MCU", + "PollingInterval": "Polling Interval", + "Seconds": "@:base.Seconds" + }, + "inverteradmin": { + "InverterSettings": "Paramètres des onduleurs", + "AddInverter": "Ajouter un nouvel onduleur", + "Serial": "Numéro de série", + "Name": "Nom", + "Add": "Ajouter", + "AddHint": " Astuce : Vous pouvez définir des paramètres supplémentaires après avoir créé l'onduleur. Utilisez l'icône du stylo dans la liste des onduleurs.", + "InverterList": "Liste des onduleurs", + "Status": "État", + "Send": "Envoyer", + "Receive": "Recevoir", + "StatusHint": "Astuce : L'onduleur est alimenté par son entrée courant continu. S'il n'y a pas de soleil, l'onduleur est éteint, mais les requêtes peuvent toujours être envoyées.", + "Type": "Type", + "Action": "Action", + "SaveOrder": "Save order", + "DeleteInverter": "Supprimer l'onduleur", + "EditInverter": "Modifier l'onduleur", + "General": "Général", + "String": "Ligne", + "Advanced": "Advanced", + "InverterSerial": "Numéro de série de l'onduleur", + "InverterName": "Nom de l'onduleur :", + "InverterNameHint": "Ici, vous pouvez spécifier un nom personnalisé pour votre onduleur.", + "InverterStatus": "Recevoir / Envoyer", + "PollEnable": "Interroger les données de l'onduleur", + "PollEnableNight": "Interroger les données de l'onduleur la nuit", + "CommandEnable": "Envoyer des commandes", + "CommandEnableNight": "Envoyer des commandes la nuit", + "StringName": "Nom de la ligne {num}:", + "StringNameHint": "Ici, vous pouvez spécifier un nom personnalisé pour le port respectif de votre onduleur.", + "StringMaxPower": "Puissance maximale de la ligne {num}:", + "StringMaxPowerHint": "Entrez la puissance maximale des panneaux solaires connectés.", + "StringYtOffset": "Décalage du rendement total de la ligne {num} :", + "StringYtOffsetHint": "Ce décalage est appliqué à la valeur de rendement total lue sur le variateur. Il peut être utilisé pour mettre le rendement total du variateur à zéro si un variateur usagé est utilisé.", + "InverterHint": "*) Entrez le Wp du canal pour calculer l'irradiation.", + "ReachableThreshold": "Reachable Threshold:", + "ReachableThresholdHint": "Defines how many requests are allowed to fail until the inverter is treated is not reachable.", + "ZeroRuntime": "Zero runtime data", + "ZeroRuntimeHint": "Zero runtime data (no yield data) if inverter becomes unreachable.", + "ZeroDay": "Zero daily yield at midnight", + "ZeroDayHint": "This only works if the inverter is unreachable. If data is read from the inverter, it's values will be used. (Reset only occours on power cycle)", + "ClearEventlog": "Clear Eventlog at midnight", + "Cancel": "@:base.Cancel", + "Save": "@:base.Save", + "DeleteMsg": "Êtes-vous sûr de vouloir supprimer l'onduleur \"{name}\" avec le numéro de série \"{serial}\" ?", + "Delete": "Supprimer", + "YieldDayCorrection": "Yield Day Correction", + "YieldDayCorrectionHint": "Sum up daily yield even if the inverter is restarted. Value will be reset at midnight" + }, + "configadmin": { + "ConfigManagement": "Gestion de la configuration", + "BackupHeader": "Sauvegarder le fichier de configuration", + "BackupConfig": "Fichier de configuration", + "Backup": "Sauvegarder", + "Restore": "Restaurer", + "NoFileSelected": "Aucun fichier sélectionné", + "RestoreHeader": "Restaurer le fichier de configuration", + "Back": "Retour", + "UploadSuccess": "Succès du téléversement", + "RestoreHint": "Note : Cette opération remplace le fichier de configuration par la configuration restaurée et redémarre OpenDTU pour appliquer tous les paramètres.", + "ResetHeader": "Effectuer une réinitialisation d'usine", + "FactoryResetButton": "Restaurer les paramètres d'usine", + "ResetHint": "Note : Cliquez sur \"Restaurer les paramètres d'usine\" pour restaurer et initialiser les paramètres d'usine par défaut et redémarrer.", + "FactoryReset": "Remise à zéro", + "ResetMsg": "Êtes-vous sûr de vouloir supprimer la configuration actuelle et réinitialiser tous les paramètres à leurs valeurs par défaut ?", + "ResetConfirm": "Remise à zéro !", + "Cancel": "@:base.Cancel" + }, + "powerlimiteradmin": { + "PowerLimiterSettings": "Dynamic Power Limiter Settings", + "ConfigAlertMessage": "One or more prerequisites for operating the Dynamic Power Limiter are not met.", + "ConfigHints": "Configuration Notes", + "ConfigHintRequirement": "Required", + "ConfigHintOptional": "Optional", + "ConfigHintsIntro": "The following notes regarding the Dynamic Power Limiter (DPL) configuration shall be considered:", + "ConfigHintPowerMeterDisabled": "Without a power meter interface, the inverter limit the DPL will configure equals the configured base load (exception: (full) solar-passthrough).", + "ConfigHintNoInverter": "At least one inverter must be configured prior to setting up the DPL.", + "ConfigHintInverterCommunication": "Polling data from and sending commands to the target inverter must be enabled.", + "ConfigHintNoChargeController": "The solar-passthrough feature can only be used if the VE.Direct interface is configured.", + "ConfigHintNoBatteryInterface": "SoC-based thresholds can only be used if a battery communication interface is configured.", + "General": "General", + "Enable": "Enable", + "VerboseLogging": "@:base.VerboseLogging", + "SolarPassthrough": "Solar-Passthrough", + "EnableSolarPassthrough": "Enable Solar-Passthrough", + "SolarPassthroughLosses": "(Full) Solar-Passthrough Losses", + "SolarPassthroughLossesInfo": "Hint: Line losses are to be expected when transferring energy from the solar charge controller to the inverter. These losses can be taken into account to prevent the battery from gradually discharging in (full) solar-passthrough mode. The power limit to be set on the inverter is additionally reduced by this factor after taking its efficiency into account.", + "BatteryDischargeAtNight": "Use battery at night even if only partially charged", + "SolarpassthroughInfo": "This feature allows to use the available current solar power directly. The solar power, as reported by the MPPT charge controller, is set as the inverter's limit, even if the battery is currently charging. This avoids storing energy unnecessarily, which would be lossy.", + "InverterSettings": "Inverter", + "Inverter": "Target Inverter", + "SelectInverter": "Select an inverter...", + "InverterChannelId": "Input used for voltage measurements", + "TargetPowerConsumption": "Target Grid Consumption", + "TargetPowerConsumptionHint": "Grid power consumption the limiter tries to achieve. Value may be negative.", + "TargetPowerConsumptionHysteresis": "Hysteresis", + "TargetPowerConsumptionHysteresisHint": "Only send a newly calculated power limit to the inverter if the absolute difference to the last reported power limit exceeds this amount.", + "LowerPowerLimit": "Minimum Power Limit", + "LowerPowerLimitHint": "This value must be selected so that stable operation is possible at this limit. If the inverter could only be operated with a lower limit, it is put into standby instead.", + "BaseLoadLimit": "Base Load", + "BaseLoadLimitHint": "Relevant for operation without power meter or when the power meter fails. As long as the other conditions allow (in particular battery charge), this limit is set on the inverter.", + "UpperPowerLimit": "Maximum Power Limit", + "UpperPowerLimitHint": "The inverter is always set such that no more than this output power is achieved. This value must be selected to comply with the current carrying capacity of the AC connection cables.", + "SocThresholds": "Battery State of Charge (SoC) Thresholds", + "IgnoreSoc": "Ignore Battery SoC", + "StartThreshold": "Start Threshold for Battery Discharging", + "StopThreshold": "Stop Threshold for Battery Discharging", + "FullSolarPassthroughStartThreshold": "Full Solar-Passthrough Start Threshold", + "FullSolarPassthroughStartThresholdHint": "Inverter power is set equal to Victron MPPT power (minus efficiency factors) while above this threshold. Use this if you want to supply excess power to the grid when the battery is full.", + "VoltageSolarPassthroughStopThreshold": "Full Solar-Passthrough Stop Threshold", + "VoltageLoadCorrectionFactor": "Load correction factor", + "BatterySocInfo": "Hint: The battery SoC (State of Charge) values are only used if the battery communication interface reported SoC updates in the last minute. Otherwise the voltage thresholds will be used as fallback.", + "InverterIsBehindPowerMeter": "PowerMeter reading includes inverter output", + "InverterIsBehindPowerMeterHint": "Enable this option if the power meter reading is reduced by the inverter's output when it produces power. This is typically true.", + "InverterIsSolarPowered": "Inverter is powered by solar modules", + "VoltageThresholds": "Battery Voltage Thresholds", + "VoltageLoadCorrectionInfo": "Hint: When the battery is discharged, its voltage drops. The voltage drop scales with the discharge current. In order to not stop the inverter too early (stop threshold), this load correction factor can be specified to calculate the battery voltage if it was idle. Corrected voltage = DC Voltage + (Current power * correction factor)." + }, + "login": { + "Login": "Connexion", + "SystemLogin": "Connexion au système", + "Username": "Nom d'utilisateur", + "UsernameRequired": "Le nom d'utilisateur est requis", + "Password": "Mot de passe", + "PasswordRequired": "Le mot de passe est requis", + "LoginButton": "Connexion" + }, + "firmwareupgrade": { + "FirmwareUpgrade": "Mise à jour du firmware", + "Loading": "@:base.Loading", + "OtaError": "Erreur OTA", + "Back": "Retour", + "Retry": "Réessayer", + "OtaStatus": "Statut OTA", + "OtaSuccess": "Le téléchargement du firmware a réussi. L'appareil a été redémarré automatiquement. Lorsque l'appareil est à nouveau accessible, l'interface est automatiquement rechargée.", + "FirmwareUpload": "Téléversement du firmware", + "UploadProgress": "Progression du téléversement" + }, + "about": { + "AboutOpendtu": "À propos d'OpenDTU-OnBattery", + "Documentation": "Documentation", + "DocumentationBody": "The firmware and hardware documentation of the upstream project can be found here: https://www.opendtu.solar
Additional information, especially regarding OpenDTU-OnBattery-specific features, can be accessed at the Github Wiki.", + "ProjectOrigin": "Origine du projet", + "ProjectOriginBody1": "Ce projet a été démarré suite à cette discussion (Mikrocontroller.net).", + "ProjectOriginBody2": "Le protocole Hoymiles a été décrypté grâce aux efforts volontaires de nombreux participants. OpenDTU, entre autres, a été développé sur la base de ce travail. Le projet est sous licence Open Source (GNU General Public License version 2).", + "ProjectOriginBody3": "Le logiciel a été développé au mieux de nos connaissances et de nos convictions. Néanmoins, aucune responsabilité ne peut être acceptée en cas de dysfonctionnement ou de perte de garantie de l'onduleur.", + "ProjectOriginBody4": "OpenDTU est disponible gratuitement. Si vous avez payé pour le logiciel, vous avez probablement été arnaqué.", + "NewsUpdates": "Actualités et mises à jour", + "NewsUpdatesBody": "Les nouvelles mises à jour peuvent être trouvées sur Github.", + "ErrorReporting": "Rapport d'erreurs", + "ErrorReportingBody": "Veuillez signaler les problèmes en utilisant la fonction fournie par Github.", + "Discussion": "Discussion", + "DiscussionBody": "Discutez avec nous sur Discord ou sur Github." + }, + "hints": { + "RadioProblem": "Impossible de se connecter à un module radio configuré.. Veuillez vérifier le câblage.", + "TimeSync": "L'horloge n'a pas encore été synchronisée. Sans une horloge correctement réglée, aucune demande n'est adressée à l'onduleur. Ceci est normal peu de temps après le démarrage. Cependant, après un temps de fonctionnement plus long (>1 minute), cela indique que le serveur NTP n'est pas accessible.", + "TimeSyncLink": "Veuillez vérifier vos paramètres horaires.", + "DefaultPassword": "Vous utilisez le mot de passe par défaut pour l'interface Web et le point d'accès d'urgence. Ceci est potentiellement non sécurisé.", + "DefaultPasswordLink": "Merci de changer le mot de passe." + }, + "deviceadmin": { + "DeviceManager": "Gestionnaire de périphériques", + "ParseError": "Erreur d'analyse dans 'pin_mapping.json': {error}", + "PinAssignment": "Paramètres de connexion", + "SelectedProfile": "Profil sélectionné", + "DefaultProfile": "(Réglages par défaut)", + "ProfileHint": "Votre appareil peut cesser de répondre si vous sélectionnez un profil incompatible. Dans ce cas, vous devez effectuer une suppression via l'interface série.", + "Display": "Affichage", + "PowerSafe": "Economiseur d'énergie", + "PowerSafeHint": "Eteindre l'écran si aucun onduleur n'est en production.", + "Screensaver": "OLED Anti burn-in", + "ScreensaverHint": "Déplacez un peu l'écran à chaque mise à jour pour éviter le phénomène de brûlure. (Utile surtout pour les écrans OLED)", + "DiagramMode": "Diagram mode:", + "off": "Off", + "small": "Small", + "fullscreen": "Fullscreen", + "DiagramDuration": "Diagram duration:", + "DiagramDurationHint": "The time period which is shown in the diagram.", + "Seconds": "Seconds", + "Contrast": "Contraste ({contrast}):", + "Rotation": "Rotation:", + "rot0": "Pas de rotation", + "rot90": "Rotation de 90 degrés", + "rot180": "Rotation de 180 degrés", + "rot270": "Rotation de 270 degrés", + "DisplayLanguage": "Langue d'affichage", + "en": "Anglais", + "de": "Allemand", + "fr": "Français", + "Leds": "LEDs", + "EqualBrightness": "Même luminosité:", + "LedBrightness": "LED {led} luminosité ({brightness}):" + }, + "pininfo": { + "PinOverview": "Vue d'ensemble des connexions", + "Category": "Catégorie", + "Name": "Nom", + "ValueSelected": "Sélectionné", + "ValueActive": "Activé" + }, + "inputserial": { + "format_hoymiles": "Hoymiles serial number format", + "format_converted": "Already converted serial number", + "format_herf_valid": "E-Star HERF format (will be saved converted): {serial}", + "format_herf_invalid": "E-Star HERF format: Invalid checksum", + "format_unknown": "Unknown format" + }, + "huawei": { + "DataAge": "Data Age: ", + "Seconds": " {val} seconds", + "Input": "Input", + "Output": "Output", + "Property": "Property", + "Value": "Value", + "Unit": "Unit", + "input_voltage": "Input voltage", + "input_current": "Input current", + "input_power": "Input power", + "input_temp": "Input temperature", + "efficiency": "Efficiency", + "output_voltage": "Output voltage", + "output_current": "Output current", + "max_output_current": "Maximum output current", + "output_power": "Output power", + "output_temp": "Output temperature", + "ShowSetLimit": "Show / Set Huawei Limit", + "LimitSettings": "Limit Settings", + "SetOffline": "Set limit, CAN bus not connected", + "SetOnline": "Set limit, CAN bus connected", + "LimitHint": "Hint: CAN bus not connected voltage limit is 48V-58.5V.", + "Close": "close", + "SetVoltageLimit": "Voltage limit:", + "SetCurrentLimit": "Current limit:", + "CurrentLimit": "Current limit:" + }, + "acchargeradmin": { + "ChargerSettings": "AC Charger Settings", + "Configuration": "AC Charger Configuration", + "EnableHuawei": "Enable Huawei R4850G2 on CAN Bus Interface", + "VerboseLogging": "@:base.VerboseLogging", + "CanControllerFrequency": "CAN controller quarz frequency", + "EnableAutoPower": "Automatic power control", + "EnableBatterySoCLimits": "Use SoC data of a connected battery", + "Limits": "Limits", + "BatterySoCLimits": "Battery SoC Limits", + "VoltageLimit": "Charge Voltage limit", + "enableVoltageLimit": "Re-enable voltage limit", + "stopVoltageLimitHint": "Maximum charger voltage. Equals battery charge voltage limit. Used for automatic power control and when emergency charging", + "enableVoltageLimitHint": "Automatic power control is disabled if the output voltage is higher then this value and if the output power drops below the minimum output power limit (set below).\nAutomatic power control is re-enabled if the battery voltage drops below the value set in this field.", + "upperPowerLimitHint": "Maximum output power. Used for automatic power control and when emergency charging", + "lowerPowerLimit": "Minimum output power", + "upperPowerLimit": "Maximum output power", + "StopBatterySoCThreshold": "Stop charging at SoC", + "StopBatterySoCThresholdHint": "To prolong the battery's lifespan, charging can be stopped at a certain SoC level.\nHint: In order to keep the SoC reading accurate, some LiFePO cells must be charged to full capacity regularly.", + "Seconds": "@:base.Seconds", + "EnableEmergencyCharge": "Emergency charge. Battery charged with maximum power if requested by Battery BMS", + "targetPowerConsumption": "Target power consumption", + "targetPowerConsumptionHint": "Postitive values use grid power to charge the battery. Negative values result in early shutdown" + }, + "battery": { + "battery": "Battery", + "FwVersion": "Firmware Version", + "HwVersion": "Hardware Version", + "DataAge": "Data Age: ", + "Seconds": " {val} seconds", + "status": "Status", + "Property": "Property", + "yes": "@:base.Yes", + "no": "@:base.No", + "Value": "Value", + "Unit": "Unit", + "SoC": "State of Charge", + "stateOfHealth": "State of Health", + "voltage": "Voltage", + "current": "Current", + "power": "Power", + "temperature": "Temperature", + "bmsTemp": "BMS temperature", + "chargeVoltage": "Requested charge voltage", + "chargeCurrentLimitation": "Charge current limit", + "dischargeCurrentLimitation": "Discharge current limit", + "chargeEnabled": "Charging possible", + "dischargeEnabled": "Discharging possible", + "chargeImmediately": "Immediate charging requested", + "cells": "Cells", + "batOneTemp": "Battery temperature 1", + "batTwoTemp": "Battery temperature 2", + "cellMinVoltage": "Minimum cell voltage", + "cellAvgVoltage": "Average cell voltage", + "cellMaxVoltage": "Maximum cell voltage", + "cellDiffVoltage": "Cell voltage difference", + "balancingActive": "Balancing active", + "issues": "Issues", + "noIssues": "No Issues", + "issueName": "Name", + "issueType": "Type", + "alarm": "Alarm", + "warning": "Warning", + "JkBmsIssueLowCapacity": "Low Capacity", + "JkBmsIssueBmsOvertemperature": "BMS overtemperature", + "JkBmsIssueChargingOvervoltage": "Overvoltage (sum of all cells)", + "JkBmsIssueDischargeUndervoltage": "Undervoltage (sum of all cells)", + "JkBmsIssueBatteryOvertemperature": "Battery overtemperature", + "JkBmsIssueChargingOvercurrent": "Overcurrent (Charging)", + "JkBmsIssueDischargeOvercurrent": "Overcurrent (Discharging)", + "JkBmsIssueCellVoltageDifference": "Cell voltage difference too high", + "JkBmsIssueBatteryBoxOvertemperature": "Battery (box?) overtemperature", + "JkBmsIssueBatteryUndertemperature": "Battery undertemperature", + "JkBmsIssueCellOvervoltage": "Overvoltage (single cell)", + "JkBmsIssueCellUndervoltage": "Undervoltage (single cell)", + "JkBmsIssueAProtect": "AProtect (meaning?)", + "JkBmsIssueBProtect": "BProtect (meaning?)", + "highCurrentDischarge": "High current (discharge)", + "overCurrentDischarge": "Overcurrent (discharge)", + "highCurrentCharge": "High current (charge)", + "overCurrentCharge": "Overcurrent (charge)", + "lowTemperature": "Low temperature", + "underTemperature": "Undertemperature", + "highTemperature": "High temperature", + "overTemperature": "Overtemperature", + "lowVoltage": "Low voltage", + "lowSOC": "Low state of charge", + "underVoltage": "Undervoltage", + "highVoltage": "High voltage", + "overVoltage": "Overvoltage", + "bmsInternal": "BMS internal", + "chargeCycles": "Charge cycles", + "chargedEnergy": "Charged energy", + "dischargedEnergy": "Discharged energy", + "instantaneousPower": "Instantaneous Power", + "consumedAmpHours": "Consumed Amp Hours", + "midpointVoltage": "Midpoint Voltage", + "midpointDeviation": "Midpoint Deviation", + "lastFullCharge": "Last full Charge" + } +} diff --git a/webapp/src/types/AcChargerConfig.ts b/webapp/src/types/AcChargerConfig.ts index 80625d8e1..b06f0236b 100644 --- a/webapp/src/types/AcChargerConfig.ts +++ b/webapp/src/types/AcChargerConfig.ts @@ -1,14 +1,14 @@ -export interface AcChargerConfig { - enabled: boolean; - verbose_logging: boolean; - can_controller_frequency: number; - auto_power_enabled: boolean; - auto_power_batterysoc_limits_enabled: boolean; - voltage_limit: number; - enable_voltage_limit: number; - lower_power_limit: number; - upper_power_limit: number; - emergency_charge_enabled: boolean; - stop_batterysoc_threshold: number; - target_power_consumption: number; -} +export interface AcChargerConfig { + enabled: boolean; + verbose_logging: boolean; + can_controller_frequency: number; + auto_power_enabled: boolean; + auto_power_batterysoc_limits_enabled: boolean; + voltage_limit: number; + enable_voltage_limit: number; + lower_power_limit: number; + upper_power_limit: number; + emergency_charge_enabled: boolean; + stop_batterysoc_threshold: number; + target_power_consumption: number; +} diff --git a/webapp/src/types/BatteryDataStatus.ts b/webapp/src/types/BatteryDataStatus.ts index 0143725d8..e905f7372 100644 --- a/webapp/src/types/BatteryDataStatus.ts +++ b/webapp/src/types/BatteryDataStatus.ts @@ -1,12 +1,12 @@ -import type { ValueObject } from '@/types/LiveDataStatus'; - -type BatteryData = (ValueObject | string)[]; - -export interface Battery { - manufacturer: string; - fwversion: string; - hwversion: string; - data_age: number; - values: BatteryData[]; - issues: number[]; +import type { ValueObject } from '@/types/LiveDataStatus'; + +type BatteryData = (ValueObject | string)[]; + +export interface Battery { + manufacturer: string; + fwversion: string; + hwversion: string; + data_age: number; + values: BatteryData[]; + issues: number[]; } \ No newline at end of file diff --git a/webapp/src/types/HuaweiDataStatus.ts b/webapp/src/types/HuaweiDataStatus.ts index ce7b8aabd..b349c207f 100644 --- a/webapp/src/types/HuaweiDataStatus.ts +++ b/webapp/src/types/HuaweiDataStatus.ts @@ -1,18 +1,18 @@ -import type { ValueObject } from '@/types/LiveDataStatus'; - -// Huawei -export interface Huawei { - data_age: 0; - input_voltage: ValueObject; - input_frequency: ValueObject; - input_current: ValueObject; - input_power: ValueObject; - input_temp: ValueObject; - efficiency: ValueObject; - output_voltage: ValueObject; - output_current: ValueObject; - max_output_current: ValueObject; - output_power: ValueObject; - output_temp: ValueObject; - amp_hour: ValueObject; +import type { ValueObject } from '@/types/LiveDataStatus'; + +// Huawei +export interface Huawei { + data_age: 0; + input_voltage: ValueObject; + input_frequency: ValueObject; + input_current: ValueObject; + input_power: ValueObject; + input_temp: ValueObject; + efficiency: ValueObject; + output_voltage: ValueObject; + output_current: ValueObject; + max_output_current: ValueObject; + output_power: ValueObject; + output_temp: ValueObject; + amp_hour: ValueObject; } \ No newline at end of file diff --git a/webapp/src/types/HuaweiLimitConfig.ts b/webapp/src/types/HuaweiLimitConfig.ts index f839d83c3..3ce15dabb 100644 --- a/webapp/src/types/HuaweiLimitConfig.ts +++ b/webapp/src/types/HuaweiLimitConfig.ts @@ -1,7 +1,7 @@ -export interface HuaweiLimitConfig { - voltage: number; - voltage_valid: boolean; - current: number; - current_valid: boolean; - online: boolean; +export interface HuaweiLimitConfig { + voltage: number; + voltage_valid: boolean; + current: number; + current_valid: boolean; + online: boolean; } \ No newline at end of file From 8ec1695d1beff874097cbae9840cb06693c78870 Mon Sep 17 00:00:00 2001 From: Marvin Carstensen Date: Tue, 23 Apr 2024 19:17:01 +0200 Subject: [PATCH 045/140] Feature: support Tibber bridge as power meter interface --- include/Configuration.h | 9 ++ include/PowerMeter.h | 13 +- include/TibberPowerMeter.h | 23 +++ include/WebApi_powermeter.h | 2 + src/Configuration.cpp | 12 ++ src/PowerMeter.cpp | 9 ++ src/TibberPowerMeter.cpp | 188 +++++++++++++++++++++++ src/WebApi_powermeter.cpp | 85 ++++++++++ webapp/src/locales/de.json | 4 +- webapp/src/locales/en.json | 4 +- webapp/src/types/PowerMeterConfig.ts | 8 + webapp/src/views/PowerMeterAdminView.vue | 68 +++++++- 12 files changed, 416 insertions(+), 9 deletions(-) create mode 100644 include/TibberPowerMeter.h create mode 100644 src/TibberPowerMeter.cpp diff --git a/include/Configuration.h b/include/Configuration.h index cd810d84b..b0e286217 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -78,6 +78,14 @@ struct POWERMETER_HTTP_PHASE_CONFIG_T { }; using PowerMeterHttpConfig = struct POWERMETER_HTTP_PHASE_CONFIG_T; +struct POWERMETER_TIBBER_CONFIG_T { + char Url[POWERMETER_MAX_HTTP_URL_STRLEN + 1]; + char Username[POWERMETER_MAX_USERNAME_STRLEN + 1]; + char Password[POWERMETER_MAX_USERNAME_STRLEN + 1]; + uint16_t Timeout; +}; +using PowerMeterTibberConfig = struct POWERMETER_TIBBER_CONFIG_T; + struct CONFIG_T { struct { uint32_t Version; @@ -200,6 +208,7 @@ struct CONFIG_T { uint32_t HttpInterval; bool HttpIndividualRequests; PowerMeterHttpConfig Http_Phase[POWERMETER_MAX_PHASES]; + PowerMeterTibberConfig Tibber; } PowerMeter; struct { diff --git a/include/PowerMeter.h b/include/PowerMeter.h index 5b3d8f31f..2d2c8749f 100644 --- a/include/PowerMeter.h +++ b/include/PowerMeter.h @@ -26,13 +26,19 @@ class PowerMeterClass { SDM3PH = 2, HTTP = 3, SML = 4, - SMAHM2 = 5 + SMAHM2 = 5, + TIBBER = 6 }; void init(Scheduler& scheduler); float getPowerTotal(bool forceUpdate = true); uint32_t getLastPowerMeterUpdate(); bool isDataValid(); + const std::list smlHandlerList{ + {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_powerMeter1Power}, + {{0x01, 0x00, 0x01, 0x08, 0x00, 0xff}, &smlOBISWh, &_powerMeterImport}, + {{0x01, 0x00, 0x02, 0x08, 0x00, 0xff}, &smlOBISWh, &_powerMeterExport} + }; private: void loop(); void mqtt(); @@ -68,11 +74,6 @@ class PowerMeterClass { void readPowerMeter(); bool smlReadLoop(); - const std::list smlHandlerList{ - {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_powerMeter1Power}, - {{0x01, 0x00, 0x01, 0x08, 0x00, 0xff}, &smlOBISWh, &_powerMeterImport}, - {{0x01, 0x00, 0x02, 0x08, 0x00, 0xff}, &smlOBISWh, &_powerMeterExport} - }; }; extern PowerMeterClass PowerMeter; diff --git a/include/TibberPowerMeter.h b/include/TibberPowerMeter.h new file mode 100644 index 000000000..84639ab7d --- /dev/null +++ b/include/TibberPowerMeter.h @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include +#include +#include "Configuration.h" + +class TibberPowerMeterClass { +public: + bool updateValues(); + char tibberPowerMeterError[256]; + bool query(PowerMeterTibberConfig const& config); + +private: + HTTPClient httpClient; + String httpResponse; + bool httpRequest(WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterTibberConfig const& config); + bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t, String& _base64Authorization); + void prepareRequest(uint32_t timeout); +}; + +extern TibberPowerMeterClass TibberPowerMeter; diff --git a/include/WebApi_powermeter.h b/include/WebApi_powermeter.h index 7e873b1c1..12e5afae6 100644 --- a/include/WebApi_powermeter.h +++ b/include/WebApi_powermeter.h @@ -15,7 +15,9 @@ class WebApiPowerMeterClass { void onAdminGet(AsyncWebServerRequest* request); void onAdminPost(AsyncWebServerRequest* request); void decodeJsonPhaseConfig(JsonObject const& json, PowerMeterHttpConfig& config) const; + void decodeJsonTibberConfig(JsonObject const& json, PowerMeterTibberConfig& config) const; void onTestHttpRequest(AsyncWebServerRequest* request); + void onTestTibberRequest(AsyncWebServerRequest* request); AsyncWebServer* _server; }; diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 9adda28d0..0749a1a2d 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -159,6 +159,12 @@ bool ConfigurationClass::write() powermeter["sdmaddress"] = config.PowerMeter.SdmAddress; powermeter["http_individual_requests"] = config.PowerMeter.HttpIndividualRequests; + JsonObject tibber = powermeter["tibber"].to(); + tibber["url"] = config.PowerMeter.Tibber.Url; + tibber["username"] = config.PowerMeter.Tibber.Username; + tibber["password"] = config.PowerMeter.Tibber.Password; + tibber["timeout"] = config.PowerMeter.Tibber.Timeout; + JsonArray powermeter_http_phases = powermeter["http_phases"].to(); for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { JsonObject powermeter_phase = powermeter_http_phases.add(); @@ -420,6 +426,12 @@ bool ConfigurationClass::read() config.PowerMeter.SdmAddress = powermeter["sdmaddress"] | POWERMETER_SDMADDRESS; config.PowerMeter.HttpIndividualRequests = powermeter["http_individual_requests"] | false; + JsonObject tibber = powermeter["tibber"]; + strlcpy(config.PowerMeter.Tibber.Url, tibber["url"] | "", sizeof(config.PowerMeter.Tibber.Url)); + strlcpy(config.PowerMeter.Tibber.Username, tibber["username"] | "", sizeof(config.PowerMeter.Tibber.Username)); + strlcpy(config.PowerMeter.Tibber.Password, tibber["password"] | "", sizeof(config.PowerMeter.Tibber.Password)); + config.PowerMeter.Tibber.Timeout = tibber["timeout"] | POWERMETER_HTTP_TIMEOUT; + JsonArray powermeter_http_phases = powermeter["http_phases"]; for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { JsonObject powermeter_phase = powermeter_http_phases[i].as(); diff --git a/src/PowerMeter.cpp b/src/PowerMeter.cpp index 56582d005..e1e2d4327 100644 --- a/src/PowerMeter.cpp +++ b/src/PowerMeter.cpp @@ -6,6 +6,7 @@ #include "Configuration.h" #include "PinMapping.h" #include "HttpPowerMeter.h" +#include "TibberPowerMeter.h" #include "MqttSettings.h" #include "NetworkSettings.h" #include "MessageOutput.h" @@ -96,6 +97,9 @@ void PowerMeterClass::init(Scheduler& scheduler) case Source::SMAHM2: SMA_HM.init(scheduler, config.PowerMeter.VerboseLogging); break; + + case Source::TIBBER: + break; } } @@ -274,6 +278,11 @@ void PowerMeterClass::readPowerMeter() _powerMeter3Power = SMA_HM.getPowerL3(); _lastPowerMeterUpdate = millis(); } + else if (configuredSource == Source::TIBBER) { + if (TibberPowerMeter.updateValues()) { + _lastPowerMeterUpdate = millis(); + } + } } bool PowerMeterClass::smlReadLoop() diff --git a/src/TibberPowerMeter.cpp b/src/TibberPowerMeter.cpp new file mode 100644 index 000000000..d7889c22f --- /dev/null +++ b/src/TibberPowerMeter.cpp @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "Configuration.h" +#include "TibberPowerMeter.h" +#include "MessageOutput.h" +#include +#include +#include +#include + +bool TibberPowerMeterClass::updateValues() +{ + auto const& config = Configuration.get(); + + auto const& tibberConfig = config.PowerMeter.Tibber; + + if (!query(tibberConfig)) { + MessageOutput.printf("[TibberPowerMeter] Getting the power of tibber failed.\r\n"); + MessageOutput.printf("%s\r\n", tibberPowerMeterError); + return false; + } + + return true; +} + +bool TibberPowerMeterClass::query(PowerMeterTibberConfig const& config) +{ + //hostByName in WiFiGeneric fails to resolve local names. issue described in + //https://github.com/espressif/arduino-esp32/issues/3822 + //and in depth analyzed in https://github.com/espressif/esp-idf/issues/2507#issuecomment-761836300 + //in conclusion: we cannot rely on httpClient.begin(*wifiClient, url) to resolve IP adresses. + //have to do it manually here. Feels Hacky... + String protocol; + String host; + String uri; + String base64Authorization; + uint16_t port; + extractUrlComponents(config.Url, protocol, host, uri, port, base64Authorization); + + IPAddress ipaddr((uint32_t)0); + //first check if "host" is already an IP adress + if (!ipaddr.fromString(host)) + { + //"host"" is not an IP address so try to resolve the IP adress + //first try locally via mDNS, then via DNS. WiFiGeneric::hostByName() will spam the console if done the otherway around. + const bool mdnsEnabled = Configuration.get().Mdns.Enabled; + if (!mdnsEnabled) { + snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via DNS, try to enable mDNS in Network Settings"), host.c_str()); + //ensure we try resolving via DNS even if mDNS is disabled + if(!WiFiGenericClass::hostByName(host.c_str(), ipaddr)){ + snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via DNS"), host.c_str()); + } + } + else + { + ipaddr = MDNS.queryHost(host); + if (ipaddr == INADDR_NONE){ + snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via mDNS"), host.c_str()); + //when we cannot find local server via mDNS, try resolving via DNS + if(!WiFiGenericClass::hostByName(host.c_str(), ipaddr)){ + snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via DNS"), host.c_str()); + } + } + } + } + + // secureWifiClient MUST be created before HTTPClient + // see discussion: https://github.com/helgeerbe/OpenDTU-OnBattery/issues/381 + std::unique_ptr wifiClient; + + bool https = protocol == "https"; + if (https) { + auto secureWifiClient = std::make_unique(); + secureWifiClient->setInsecure(); + wifiClient = std::move(secureWifiClient); + } else { + wifiClient = std::make_unique(); + } + + return httpRequest(*wifiClient, ipaddr.toString(), port, uri, https, config); +} + +bool TibberPowerMeterClass::httpRequest(WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterTibberConfig const& config) +{ + if(!httpClient.begin(wifiClient, host, port, uri, https)){ + snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("httpClient.begin() failed for %s://%s"), (https ? "https" : "http"), host.c_str()); + return false; + } + + prepareRequest(config.Timeout); + + String authString = config.Username; + authString += ":"; + authString += config.Password; + String auth = "Basic "; + auth.concat(base64::encode(authString)); + httpClient.addHeader("Authorization", auth); + + int httpCode = httpClient.GET(); + + if (httpCode <= 0) { + snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("HTTP Error %s"), httpClient.errorToString(httpCode).c_str()); + return false; + } + + if (httpCode != HTTP_CODE_OK) { + snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Bad HTTP code: %d"), httpCode); + return false; + } + + while (httpClient.getStream().available()) { + double readVal = 0; + unsigned char smlCurrentChar = httpClient.getStream().read(); + sml_states_t smlCurrentState = smlState(smlCurrentChar); + if (smlCurrentState == SML_LISTEND) { + for (auto& handler: PowerMeter.smlHandlerList) { + if (smlOBISCheck(handler.OBIS)) { + handler.Fn(readVal); + *handler.Arg = readVal; + } + } + } + } + httpClient.end(); + + return true; +} + +//extract url component as done by httpClient::begin(String url, const char* expectedProtocol) https://github.com/espressif/arduino-esp32/blob/da6325dd7e8e152094b19fe63190907f38ef1ff0/libraries/HTTPClient/src/HTTPClient.cpp#L250 +bool TibberPowerMeterClass::extractUrlComponents(String url, String& _protocol, String& _host, String& _uri, uint16_t& _port, String& _base64Authorization) +{ + // check for : (http: or https: + int index = url.indexOf(':'); + if(index < 0) { + snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("failed to parse protocol")); + return false; + } + + _protocol = url.substring(0, index); + + //initialize port to default values for http or https. + //port will be overwritten below in case port is explicitly defined + _port = (_protocol == "https" ? 443 : 80); + + url.remove(0, (index + 3)); // remove http:// or https:// + + index = url.indexOf('/'); + if (index == -1) { + index = url.length(); + url += '/'; + } + String host = url.substring(0, index); + url.remove(0, index); // remove host part + + // get Authorization + index = host.indexOf('@'); + if(index >= 0) { + // auth info + String auth = host.substring(0, index); + host.remove(0, index + 1); // remove auth part including @ + _base64Authorization = base64::encode(auth); + } + + // get port + index = host.indexOf(':'); + String the_host; + if(index >= 0) { + the_host = host.substring(0, index); // hostname + host.remove(0, (index + 1)); // remove hostname + : + _port = host.toInt(); // get port + } else { + the_host = host; + } + + _host = the_host; + _uri = url; + return true; +} + +void TibberPowerMeterClass::prepareRequest(uint32_t timeout) { + httpClient.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + httpClient.setUserAgent("OpenDTU-OnBattery"); + httpClient.setConnectTimeout(timeout); + httpClient.setTimeout(timeout); + httpClient.addHeader("Content-Type", "application/json"); + httpClient.addHeader("Accept", "application/json"); +} + +TibberPowerMeterClass TibberPowerMeter; diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index 8ca492b01..74472bc05 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -13,6 +13,7 @@ #include "PowerLimiter.h" #include "PowerMeter.h" #include "HttpPowerMeter.h" +#include "TibberPowerMeter.h" #include "WebApi.h" #include "helper.h" @@ -26,6 +27,7 @@ void WebApiPowerMeterClass::init(AsyncWebServer& server, Scheduler& scheduler) _server->on("/api/powermeter/config", HTTP_GET, std::bind(&WebApiPowerMeterClass::onAdminGet, this, _1)); _server->on("/api/powermeter/config", HTTP_POST, std::bind(&WebApiPowerMeterClass::onAdminPost, this, _1)); _server->on("/api/powermeter/testhttprequest", HTTP_POST, std::bind(&WebApiPowerMeterClass::onTestHttpRequest, this, _1)); + _server->on("/api/powermeter/testtibberrequest", HTTP_POST, std::bind(&WebApiPowerMeterClass::onTestTibberRequest, this, _1)); } void WebApiPowerMeterClass::decodeJsonPhaseConfig(JsonObject const& json, PowerMeterHttpConfig& config) const @@ -43,6 +45,14 @@ void WebApiPowerMeterClass::decodeJsonPhaseConfig(JsonObject const& json, PowerM config.SignInverted = json["sign_inverted"].as(); } +void WebApiPowerMeterClass::decodeJsonTibberConfig(JsonObject const& json, PowerMeterTibberConfig& config) const +{ + strlcpy(config.Url, json["url"].as().c_str(), sizeof(config.Url)); + strlcpy(config.Username, json["username"].as().c_str(), sizeof(config.Username)); + strlcpy(config.Password, json["password"].as().c_str(), sizeof(config.Password)); + config.Timeout = json["timeout"].as(); +} + void WebApiPowerMeterClass::onStatus(AsyncWebServerRequest* request) { AsyncJsonResponse* response = new AsyncJsonResponse(); @@ -60,6 +70,12 @@ void WebApiPowerMeterClass::onStatus(AsyncWebServerRequest* request) root["sdmaddress"] = config.PowerMeter.SdmAddress; root["http_individual_requests"] = config.PowerMeter.HttpIndividualRequests; + auto tibber = root["tibber"].to(); + tibber["url"] = String(config.PowerMeter.Tibber.Url); + tibber["username"] = String(config.PowerMeter.Tibber.Username); + tibber["password"] = String(config.PowerMeter.Tibber.Password); + tibber["timeout"] = config.PowerMeter.Tibber.Timeout; + auto httpPhases = root["http_phases"].to(); for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { @@ -158,6 +174,34 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) } } + if (static_cast(root["source"].as()) == PowerMeterClass::Source::TIBBER) { + JsonObject tibber = root["tibber"]; + + if (!tibber.containsKey("url") + || (!tibber["url"].as().startsWith("http://") + && !tibber["url"].as().startsWith("https://"))) { + retMsg["message"] = "URL must either start with http:// or https://!"; + response->setLength(); + request->send(response); + return; + } + + if ((tibber["username"].as().length() == 0 || tibber["password"].as().length() == 0)) { + retMsg["message"] = "Username or password must not be empty!"; + response->setLength(); + request->send(response); + return; + } + + if (!tibber.containsKey("timeout") + || tibber["timeout"].as() <= 0) { + retMsg["message"] = "Timeout must be greater than 0 ms!"; + response->setLength(); + request->send(response); + return; + } + } + CONFIG_T& config = Configuration.get(); config.PowerMeter.Enabled = root["enabled"].as(); config.PowerMeter.VerboseLogging = root["verbose_logging"].as(); @@ -170,6 +214,8 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) config.PowerMeter.SdmAddress = root["sdmaddress"].as(); config.PowerMeter.HttpIndividualRequests = root["http_individual_requests"].as(); + decodeJsonTibberConfig(root["tibber"].as(), config.PowerMeter.Tibber); + JsonArray http_phases = root["http_phases"]; for (uint8_t i = 0; i < http_phases.size(); i++) { decodeJsonPhaseConfig(http_phases[i].as(), config.PowerMeter.Http_Phase[i]); @@ -228,3 +274,42 @@ void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request) asyncJsonResponse->setLength(); request->send(asyncJsonResponse); } + +void WebApiPowerMeterClass::onTestTibberRequest(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + AsyncJsonResponse* asyncJsonResponse = new AsyncJsonResponse(); + JsonDocument root; + if (!WebApi.parseRequestData(request, asyncJsonResponse, root)) { + return; + } + + auto& retMsg = asyncJsonResponse->getRoot(); + + if (!root.containsKey("url") || !root.containsKey("username") || !root.containsKey("password") + || !root.containsKey("timeout")) { + retMsg["message"] = "Missing fields!"; + asyncJsonResponse->setLength(); + request->send(asyncJsonResponse); + return; + } + + + char response[256]; + + PowerMeterTibberConfig tibberConfig; + decodeJsonTibberConfig(root.as(), tibberConfig); + if (TibberPowerMeter.query(tibberConfig)) { + retMsg["type"] = "success"; + snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", PowerMeter.getPowerTotal()); + } else { + snprintf_P(response, sizeof(response), "%s", TibberPowerMeter.tibberPowerMeterError); + } + + retMsg["message"] = response; + asyncJsonResponse->setLength(); + request->send(asyncJsonResponse); +} diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 120cd55cd..db78e1378 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -563,6 +563,7 @@ "typeHTTP": "HTTP(S) + JSON", "typeSML": "SML (OBIS 16.7.0)", "typeSMAHM2": "SMA Homemanager 2.0", + "typeTIBBER": "Tibber Pulse (via Tibber Bridge)", "MqttTopicPowerMeter1": "MQTT topic - Stromzähler #1", "MqttTopicPowerMeter2": "MQTT topic - Stromzähler #2 (Optional)", "MqttTopicPowerMeter3": "MQTT topic - Stromzähler #3 (Optional)", @@ -587,7 +588,8 @@ "httpSignInverted": "Vorzeichen umkehren", "httpSignInvertedHint": "Positive Werte werden als Leistungsabnahme aus dem Netz interpretiert. Diese Option muss aktiviert werden, wenn das Vorzeichen des Wertes die gegenteilige Bedeutung hat.", "httpTimeout": "Timeout", - "testHttpRequest": "Testen" + "testHttpRequest": "Testen", + "TIBBER": "Tibber Pulse (via Tibber Bridge) - Konfiguration" }, "powerlimiteradmin": { "PowerLimiterSettings": "Dynamic Power Limiter Einstellungen", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 61fa972bf..2e3dde417 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -565,6 +565,7 @@ "typeHTTP": "HTTP(s) + JSON", "typeSML": "SML (OBIS 16.7.0)", "typeSMAHM2": "SMA Homemanager 2.0", + "typeTIBBER": "Tibber Pulse (via Tibber Bridge)", "MqttTopicPowerMeter1": "MQTT topic - Power meter #1", "MqttTopicPowerMeter2": "MQTT topic - Power meter #2", "MqttTopicPowerMeter3": "MQTT topic - Power meter #3", @@ -593,7 +594,8 @@ "httpSignInvertedHint": "Is is expected that positive values denote power usage from the grid. Check this option if the sign of this value has the opposite meaning.", "httpTimeout": "Timeout", "testHttpRequest": "Run test", - "milliSeconds": "ms" + "milliSeconds": "ms", + "TIBBER": "Tibber Pulse (via Tibber Bridge) - Configuration" }, "powerlimiteradmin": { "PowerLimiterSettings": "Dynamic Power Limiter Settings", diff --git a/webapp/src/types/PowerMeterConfig.ts b/webapp/src/types/PowerMeterConfig.ts index a8ceb4f78..972389294 100644 --- a/webapp/src/types/PowerMeterConfig.ts +++ b/webapp/src/types/PowerMeterConfig.ts @@ -13,6 +13,13 @@ export interface PowerMeterHttpPhaseConfig { sign_inverted: boolean; } +export interface PowerMeterTibberConfig { + url: string; + username: string; + password: string; + timeout: number; +} + export interface PowerMeterConfig { enabled: boolean; verbose_logging: boolean; @@ -25,4 +32,5 @@ export interface PowerMeterConfig { sdmaddress: number; http_individual_requests: boolean; http_phases: Array; + tibber: PowerMeterTibberConfig; } diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index ffe0868ce..aff340171 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -220,6 +220,44 @@
+ +
+ + + + + + + + + + +
+ +
+ + + {{ testTibberRequestAlert.message }} + +
+
@@ -257,6 +295,7 @@ export default defineComponent({ { key: 3, value: this.$t('powermeteradmin.typeHTTP') }, { key: 4, value: this.$t('powermeteradmin.typeSML') }, { key: 5, value: this.$t('powermeteradmin.typeSMAHM2') }, + { key: 6, value: this.$t('powermeteradmin.typeTIBBER') }, ], powerMeterAuthList: [ { key: 0, value: "None" }, @@ -266,7 +305,8 @@ export default defineComponent({ alertMessage: "", alertType: "info", showAlert: false, - testHttpRequestAlert: [{message: "", type: "", show: false}] as { message: string; type: string; show: boolean; }[] + testHttpRequestAlert: [{message: "", type: "", show: false}] as { message: string; type: string; show: boolean; }[], + testTibberRequestAlert: {message: "", type: "", show: false} as { message: string; type: string; show: boolean; } }; }, created() { @@ -347,6 +387,32 @@ export default defineComponent({ } ) }, + testTibberRequest() { + this.testTibberRequestAlert = { + message: "Sending Tibber request...", + type: "info", + show: true, + }; + + const formData = new FormData(); + formData.append("data", JSON.stringify(this.powerMeterConfigList.tibber)); + + fetch("/api/powermeter/testtibberrequest", { + method: "POST", + headers: authHeader(), + body: formData, + }) + .then((response) => handleResponse(response, this.$emitter, this.$router)) + .then( + (response) => { + this.testTibberRequestAlert = { + message: response.message, + type: response.type, + show: true, + }; + } + ) + }, }, }); From 2397e5cdf5bba4108dc968f8476701a9bc814ad9 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Wed, 8 May 2024 11:08:04 +0200 Subject: [PATCH 046/140] powermeter refactor: split providers into their own classes it is important to separate the capabilities of each power meter provider into their own class/source file, as the providers work fundamentally different and their implementations must not be intermangled, which made maintenance and improvements a nightmare in the past. --- include/PowerMeter.h | 72 +--- ...{HttpPowerMeter.h => PowerMeterHttpJson.h} | 21 +- ...TibberPowerMeter.h => PowerMeterHttpSml.h} | 29 +- include/PowerMeterMqtt.h | 28 ++ include/PowerMeterProvider.h | 46 +++ include/PowerMeterSerialSdm.h | 33 ++ include/PowerMeterSerialSml.h | 39 +++ ...SMA_HM.h => PowerMeterUdpSmaHomeManager.h} | 20 +- src/Display_Graphic.cpp | 2 +- src/Huawei_can.cpp | 4 +- src/PowerLimiter.cpp | 2 +- src/PowerMeter.cpp | 319 +++--------------- ...pPowerMeter.cpp => PowerMeterHttpJson.cpp} | 86 +++-- ...erPowerMeter.cpp => PowerMeterHttpSml.cpp} | 44 ++- src/PowerMeterMqtt.cpp | 74 ++++ src/PowerMeterProvider.cpp | 22 ++ src/PowerMeterSerialSdm.cpp | 113 +++++++ src/PowerMeterSerialSml.cpp | 68 ++++ ...HM.cpp => PowerMeterUdpSmaHomeManager.cpp} | 58 ++-- src/WebApi_powermeter.cpp | 23 +- src/WebApi_ws_live.cpp | 4 +- 21 files changed, 659 insertions(+), 448 deletions(-) rename include/{HttpPowerMeter.h => PowerMeterHttpJson.h} (75%) rename include/{TibberPowerMeter.h => PowerMeterHttpSml.h} (51%) create mode 100644 include/PowerMeterMqtt.h create mode 100644 include/PowerMeterProvider.h create mode 100644 include/PowerMeterSerialSdm.h create mode 100644 include/PowerMeterSerialSml.h rename include/{SMA_HM.h => PowerMeterUdpSmaHomeManager.h} (53%) rename src/{HttpPowerMeter.cpp => PowerMeterHttpJson.cpp} (84%) rename src/{TibberPowerMeter.cpp => PowerMeterHttpSml.cpp} (84%) create mode 100644 src/PowerMeterMqtt.cpp create mode 100644 src/PowerMeterProvider.cpp create mode 100644 src/PowerMeterSerialSdm.cpp create mode 100644 src/PowerMeterSerialSml.cpp rename src/{SMA_HM.cpp => PowerMeterUdpSmaHomeManager.cpp} (76%) diff --git a/include/PowerMeter.h b/include/PowerMeter.h index 2d2c8749f..44c99d062 100644 --- a/include/PowerMeter.h +++ b/include/PowerMeter.h @@ -1,79 +1,27 @@ // SPDX-License-Identifier: GPL-2.0-or-later #pragma once -#include "Configuration.h" -#include -#include -#include -#include -#include -#include "SDM.h" -#include "sml.h" +#include "PowerMeterProvider.h" #include -#include - -typedef struct { - const unsigned char OBIS[6]; - void (*Fn)(double&); - float* Arg; -} OBISHandler; +#include +#include class PowerMeterClass { public: - enum class Source : unsigned { - MQTT = 0, - SDM1PH = 1, - SDM3PH = 2, - HTTP = 3, - SML = 4, - SMAHM2 = 5, - TIBBER = 6 - }; void init(Scheduler& scheduler); - float getPowerTotal(bool forceUpdate = true); - uint32_t getLastPowerMeterUpdate(); - bool isDataValid(); - const std::list smlHandlerList{ - {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_powerMeter1Power}, - {{0x01, 0x00, 0x01, 0x08, 0x00, 0xff}, &smlOBISWh, &_powerMeterImport}, - {{0x01, 0x00, 0x02, 0x08, 0x00, 0xff}, &smlOBISWh, &_powerMeterExport} - }; + void updateSettings(); + + float getPowerTotal() const; + uint32_t getLastUpdate() const; + bool isDataValid() const; + private: void loop(); - void mqtt(); - - void onMqttMessage(const espMqttClientTypes::MessageProperties& properties, - const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total); Task _loopTask; - - bool _verboseLogging = true; - uint32_t _lastPowerMeterCheck; - // Used in Power limiter for safety check - uint32_t _lastPowerMeterUpdate; - - float _powerMeter1Power = 0.0; - float _powerMeter2Power = 0.0; - float _powerMeter3Power = 0.0; - float _powerMeter1Voltage = 0.0; - float _powerMeter2Voltage = 0.0; - float _powerMeter3Voltage = 0.0; - float _powerMeterImport = 0.0; - float _powerMeterExport = 0.0; - - std::map _mqttSubscriptions; - mutable std::mutex _mutex; - - static char constexpr _sdmSerialPortOwner[] = "SDM power meter"; - std::unique_ptr _upSdmSerial = nullptr; - std::unique_ptr _upSdm = nullptr; - std::unique_ptr _upSmlSerial = nullptr; - - void readPowerMeter(); - - bool smlReadLoop(); + std::unique_ptr _upProvider = nullptr; }; extern PowerMeterClass PowerMeter; diff --git a/include/HttpPowerMeter.h b/include/PowerMeterHttpJson.h similarity index 75% rename from include/HttpPowerMeter.h rename to include/PowerMeterHttpJson.h index 8f703bba2..9e5482100 100644 --- a/include/HttpPowerMeter.h +++ b/include/PowerMeterHttpJson.h @@ -5,22 +5,29 @@ #include #include #include "Configuration.h" +#include "PowerMeterProvider.h" using Auth_t = PowerMeterHttpConfig::Auth; using Unit_t = PowerMeterHttpConfig::Unit; -class HttpPowerMeterClass { +class PowerMeterHttpJson : public PowerMeterProvider { public: - void init(); - bool updateValues(); - float getPower(int8_t phase); - char httpPowerMeterError[256]; + bool init() final { return true; } + void deinit() final { } + void loop() final; + float getPowerTotal() const final; + void doMqttPublish() const final; + bool queryPhase(int phase, PowerMeterHttpConfig const& config); + char httpPowerMeterError[256]; private: - float power[POWERMETER_MAX_PHASES]; + uint32_t _lastPoll; + std::array _cache; + std::array _powerValues; HTTPClient httpClient; String httpResponse; + bool httpRequest(int phase, WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpConfig const& config); bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t, String& _base64Authorization); String extractParam(String& authReq, const String& param, const char delimit); @@ -30,5 +37,3 @@ class HttpPowerMeterClass { void prepareRequest(uint32_t timeout, const char* httpHeader, const char* httpValue); String sha256(const String& data); }; - -extern HttpPowerMeterClass HttpPowerMeter; diff --git a/include/TibberPowerMeter.h b/include/PowerMeterHttpSml.h similarity index 51% rename from include/TibberPowerMeter.h rename to include/PowerMeterHttpSml.h index 84639ab7d..31b44244c 100644 --- a/include/TibberPowerMeter.h +++ b/include/PowerMeterHttpSml.h @@ -1,23 +1,46 @@ // SPDX-License-Identifier: GPL-2.0-or-later #pragma once +#include +#include #include #include #include #include "Configuration.h" +#include "PowerMeterProvider.h" +#include "sml.h" -class TibberPowerMeterClass { +class PowerMeterHttpSml : public PowerMeterProvider { public: + bool init() final { return true; } + void deinit() final { } + void loop() final; + float getPowerTotal() const final; + void doMqttPublish() const final; bool updateValues(); char tibberPowerMeterError[256]; bool query(PowerMeterTibberConfig const& config); private: + mutable std::mutex _mutex; + + uint32_t _lastPoll = 0; + + float _activePower = 0.0; + + typedef struct { + const unsigned char OBIS[6]; + void (*Fn)(double&); + float* Arg; + } OBISHandler; + + const std::list smlHandlerList{ + {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_activePower} + }; + HTTPClient httpClient; String httpResponse; bool httpRequest(WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterTibberConfig const& config); bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t, String& _base64Authorization); void prepareRequest(uint32_t timeout); }; - -extern TibberPowerMeterClass TibberPowerMeter; diff --git a/include/PowerMeterMqtt.h b/include/PowerMeterMqtt.h new file mode 100644 index 000000000..5dd01d2c4 --- /dev/null +++ b/include/PowerMeterMqtt.h @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include "PowerMeterProvider.h" +#include +#include +#include + +class PowerMeterMqtt : public PowerMeterProvider { +public: + bool init() final; + void deinit() final; + void loop() final { } + float getPowerTotal() const final; + void doMqttPublish() const final; + +private: + void onMqttMessage(const espMqttClientTypes::MessageProperties& properties, + const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total); + + float _powerValueOne = 0; + float _powerValueTwo = 0; + float _powerValueThree = 0; + + std::map _mqttSubscriptions; + + mutable std::mutex _mutex; +}; diff --git a/include/PowerMeterProvider.h b/include/PowerMeterProvider.h new file mode 100644 index 000000000..9fb74c78e --- /dev/null +++ b/include/PowerMeterProvider.h @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include "Configuration.h" + +class PowerMeterProvider { +public: + virtual ~PowerMeterProvider() { } + + enum class Type : unsigned { + MQTT = 0, + SDM1PH = 1, + SDM3PH = 2, + HTTP = 3, + SML = 4, + SMAHM2 = 5, + TIBBER = 6 + }; + + // returns true if the provider is ready for use, false otherwise + virtual bool init() = 0; + + virtual void deinit() = 0; + virtual void loop() = 0; + virtual float getPowerTotal() const = 0; + + uint32_t getLastUpdate() const { return _lastUpdate; } + bool isDataValid() const; + void mqttLoop() const; + +protected: + PowerMeterProvider() { + auto const& config = Configuration.get(); + _verboseLogging = config.PowerMeter.VerboseLogging; + } + + void gotUpdate() { _lastUpdate = millis(); } + + bool _verboseLogging; + +private: + virtual void doMqttPublish() const = 0; + + uint32_t _lastUpdate = 0; + mutable uint32_t _lastMqttPublish = 0; +}; diff --git a/include/PowerMeterSerialSdm.h b/include/PowerMeterSerialSdm.h new file mode 100644 index 000000000..7e01c8f70 --- /dev/null +++ b/include/PowerMeterSerialSdm.h @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include "PowerMeterProvider.h" +#include "SDM.h" + +class PowerMeterSerialSdm : public PowerMeterProvider { +public: + bool init() final; + void deinit() final; + void loop() final; + float getPowerTotal() const final; + void doMqttPublish() const final; + +private: + uint32_t _lastPoll; + + float _phase1Power = 0.0; + float _phase2Power = 0.0; + float _phase3Power = 0.0; + float _phase1Voltage = 0.0; + float _phase2Voltage = 0.0; + float _phase3Voltage = 0.0; + float _energyImport = 0.0; + float _energyExport = 0.0; + + mutable std::mutex _mutex; + + static char constexpr _sdmSerialPortOwner[] = "SDM power meter"; + std::unique_ptr _upSdmSerial = nullptr; + std::unique_ptr _upSdm = nullptr; +}; diff --git a/include/PowerMeterSerialSml.h b/include/PowerMeterSerialSml.h new file mode 100644 index 000000000..31a904843 --- /dev/null +++ b/include/PowerMeterSerialSml.h @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include "PowerMeterProvider.h" +#include "Configuration.h" +#include "sml.h" +#include +#include +#include + +class PowerMeterSerialSml : public PowerMeterProvider { +public: + bool init() final; + void deinit() final; + void loop() final; + float getPowerTotal() const final { return _activePower; } + void doMqttPublish() const final; + +private: + float _activePower = 0.0; + float _energyImport = 0.0; + float _energyExport = 0.0; + + mutable std::mutex _mutex; + + std::unique_ptr _upSmlSerial = nullptr; + + typedef struct { + const unsigned char OBIS[6]; + void (*Fn)(double&); + float* Arg; + } OBISHandler; + + const std::list smlHandlerList{ + {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_activePower}, + {{0x01, 0x00, 0x01, 0x08, 0x00, 0xff}, &smlOBISWh, &_energyImport}, + {{0x01, 0x00, 0x02, 0x08, 0x00, 0xff}, &smlOBISWh, &_energyExport} + }; +}; diff --git a/include/SMA_HM.h b/include/PowerMeterUdpSmaHomeManager.h similarity index 53% rename from include/SMA_HM.h rename to include/PowerMeterUdpSmaHomeManager.h index e5600902b..34e47f4d5 100644 --- a/include/SMA_HM.h +++ b/include/PowerMeterUdpSmaHomeManager.h @@ -5,17 +5,15 @@ #pragma once #include -#include +#include "PowerMeterProvider.h" -class SMA_HMClass { +class PowerMeterUdpSmaHomeManager : public PowerMeterProvider { public: - void init(Scheduler& scheduler, bool verboseLogging); - void loop(); - void event1(); - float getPowerTotal() const { return _powerMeterPower; } - float getPowerL1() const { return _powerMeterL1; } - float getPowerL2() const { return _powerMeterL2; } - float getPowerL3() const { return _powerMeterL3; } + bool init() final; + void deinit() final; + void loop() final; + float getPowerTotal() const final { return _powerMeterPower; } + void doMqttPublish() const final; private: void Soutput(int kanal, int index, int art, int tarif, @@ -23,14 +21,10 @@ class SMA_HMClass { uint8_t* decodeGroup(uint8_t* offset, uint16_t grouplen); - bool _verboseLogging = false; float _powerMeterPower = 0.0; float _powerMeterL1 = 0.0; float _powerMeterL2 = 0.0; float _powerMeterL3 = 0.0; uint32_t _previousMillis = 0; uint32_t _serial = 0; - Task _loopTask; }; - -extern SMA_HMClass SMA_HM; diff --git a/src/Display_Graphic.cpp b/src/Display_Graphic.cpp index 1b08aff82..540554f2b 100644 --- a/src/Display_Graphic.cpp +++ b/src/Display_Graphic.cpp @@ -294,7 +294,7 @@ void DisplayGraphicClass::loop() _display->drawBox(0, y, _display->getDisplayWidth(), lineHeight); _display->setDrawColor(1); - auto acPower = PowerMeter.getPowerTotal(false); + auto acPower = PowerMeter.getPowerTotal(); if (acPower > 999) { snprintf(_fmtText, sizeof(_fmtText), i18n_meter_power_kw[_display_language], (acPower / 1000)); } else { diff --git a/src/Huawei_can.cpp b/src/Huawei_can.cpp index 5f378602e..aef57a198 100644 --- a/src/Huawei_can.cpp +++ b/src/Huawei_can.cpp @@ -371,12 +371,12 @@ void HuaweiCanClass::loop() } } - if (PowerMeter.getLastPowerMeterUpdate() > _lastPowerMeterUpdateReceivedMillis && + if (PowerMeter.getLastUpdate() > _lastPowerMeterUpdateReceivedMillis && _autoPowerEnabledCounter > 0) { // We have received a new PowerMeter value. Also we're _autoPowerEnabled // So we're good to calculate a new limit - _lastPowerMeterUpdateReceivedMillis = PowerMeter.getLastPowerMeterUpdate(); + _lastPowerMeterUpdateReceivedMillis = PowerMeter.getLastUpdate(); // Calculate new power limit float newPowerLimit = -1 * round(PowerMeter.getPowerTotal()); diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index d9e280488..5851f3278 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -201,7 +201,7 @@ void PowerLimiterClass::loop() // arrives. this can be the case for readings provided by networked meter // readers, where a packet needs to travel through the network for some // time after the actual measurement was done by the reader. - if (PowerMeter.isDataValid() && PowerMeter.getLastPowerMeterUpdate() <= (*_oInverterStatsMillis + 2000)) { + if (PowerMeter.isDataValid() && PowerMeter.getLastUpdate() <= (*_oInverterStatsMillis + 2000)) { return announceStatus(Status::PowerMeterPending); } diff --git a/src/PowerMeter.cpp b/src/PowerMeter.cpp index e1e2d4327..8212529b0 100644 --- a/src/PowerMeter.cpp +++ b/src/PowerMeter.cpp @@ -1,18 +1,12 @@ // SPDX-License-Identifier: GPL-2.0-or-later -/* - * Copyright (C) 2022 Thomas Basler and others - */ #include "PowerMeter.h" #include "Configuration.h" -#include "PinMapping.h" -#include "HttpPowerMeter.h" -#include "TibberPowerMeter.h" -#include "MqttSettings.h" -#include "NetworkSettings.h" -#include "MessageOutput.h" -#include "SerialPortManager.h" -#include -#include +#include "PowerMeterHttpJson.h" +#include "PowerMeterHttpSml.h" +#include "PowerMeterMqtt.h" +#include "PowerMeterSerialSdm.h" +#include "PowerMeterSerialSml.h" +#include "PowerMeterUdpSmaHomeManager.h" PowerMeterClass PowerMeter; @@ -23,285 +17,74 @@ void PowerMeterClass::init(Scheduler& scheduler) _loopTask.setIterations(TASK_FOREVER); _loopTask.enable(); - _lastPowerMeterCheck = 0; - _lastPowerMeterUpdate = 0; - - for (auto const& s: _mqttSubscriptions) { MqttSettings.unsubscribe(s.first); } - _mqttSubscriptions.clear(); - - CONFIG_T& config = Configuration.get(); - - if (!config.PowerMeter.Enabled) { - return; - } - - const PinMapping_t& pin = PinMapping.get(); - MessageOutput.printf("[PowerMeter] rx = %d, tx = %d, dere = %d\r\n", - pin.powermeter_rx, pin.powermeter_tx, pin.powermeter_dere); - - switch(static_cast(config.PowerMeter.Source)) { - case Source::MQTT: { - auto subscribe = [this](char const* topic, float* target) { - if (strlen(topic) == 0) { return; } - MqttSettings.subscribe(topic, 0, - std::bind(&PowerMeterClass::onMqttMessage, - this, std::placeholders::_1, std::placeholders::_2, - std::placeholders::_3, std::placeholders::_4, - std::placeholders::_5, std::placeholders::_6) - ); - _mqttSubscriptions.try_emplace(topic, target); - }; - - subscribe(config.PowerMeter.MqttTopicPowerMeter1, &_powerMeter1Power); - subscribe(config.PowerMeter.MqttTopicPowerMeter2, &_powerMeter2Power); - subscribe(config.PowerMeter.MqttTopicPowerMeter3, &_powerMeter3Power); - break; - } - - case Source::SDM1PH: - case Source::SDM3PH: { - if (pin.powermeter_rx < 0 || pin.powermeter_tx < 0) { - MessageOutput.println("[PowerMeter] invalid pin config for SDM power meter (RX and TX pins must be defined)"); - return; - } + updateSettings(); +} - auto oHwSerialPort = SerialPortManager.allocatePort(_sdmSerialPortOwner); - if (!oHwSerialPort) { return; } +void PowerMeterClass::updateSettings() +{ + std::lock_guard l(_mutex); - _upSdmSerial = std::make_unique(*oHwSerialPort); - _upSdmSerial->end(); // make sure the UART will be re-initialized - _upSdm = std::make_unique(*_upSdmSerial, 9600, pin.powermeter_dere, - SERIAL_8N1, pin.powermeter_rx, pin.powermeter_tx); - _upSdm->begin(); - break; + if (_upProvider) { + _upProvider->deinit(); + _upProvider = nullptr; } - case Source::HTTP: - HttpPowerMeter.init(); - break; - - case Source::SML: - if (pin.powermeter_rx < 0) { - MessageOutput.println("[PowerMeter] invalid pin config for SML power meter (RX pin must be defined)"); - return; - } - - pinMode(pin.powermeter_rx, INPUT); - _upSmlSerial = std::make_unique(); - _upSmlSerial->begin(9600, SWSERIAL_8N1, pin.powermeter_rx, -1, false, 128, 95); - _upSmlSerial->enableRx(true); - _upSmlSerial->enableTx(false); - _upSmlSerial->flush(); - break; + auto const& config = Configuration.get(); - case Source::SMAHM2: - SMA_HM.init(scheduler, config.PowerMeter.VerboseLogging); - break; + if (!config.PowerMeter.Enabled) { return; } - case Source::TIBBER: - break; + switch(static_cast(config.PowerMeter.Source)) { + case PowerMeterProvider::Type::MQTT: + _upProvider = std::make_unique(); + break; + case PowerMeterProvider::Type::SDM1PH: + case PowerMeterProvider::Type::SDM3PH: + _upProvider = std::make_unique(); + break; + case PowerMeterProvider::Type::HTTP: + _upProvider = std::make_unique(); + break; + case PowerMeterProvider::Type::SML: + _upProvider = std::make_unique(); + break; + case PowerMeterProvider::Type::SMAHM2: + _upProvider = std::make_unique(); + break; + case PowerMeterProvider::Type::TIBBER: + _upProvider = std::make_unique(); + break; } -} - -void PowerMeterClass::onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) -{ - for (auto const& subscription: _mqttSubscriptions) { - if (subscription.first != topic) { continue; } - - std::string value(reinterpret_cast(payload), len); - try { - *subscription.second = std::stof(value); - } - catch(std::invalid_argument const& e) { - MessageOutput.printf("PowerMeterClass: cannot parse payload of topic '%s' as float: %s\r\n", - topic, value.c_str()); - return; - } - if (_verboseLogging) { - MessageOutput.printf("PowerMeterClass: Updated from '%s', TotalPower: %5.2f\r\n", - topic, getPowerTotal()); - } - - _lastPowerMeterUpdate = millis(); + if (!_upProvider->init()) { + _upProvider = nullptr; } } -float PowerMeterClass::getPowerTotal(bool forceUpdate) +float PowerMeterClass::getPowerTotal() const { - if (forceUpdate) { - CONFIG_T& config = Configuration.get(); - if (config.PowerMeter.Enabled - && (millis() - _lastPowerMeterUpdate) > (1000)) { - readPowerMeter(); - } - } - std::lock_guard l(_mutex); - return _powerMeter1Power + _powerMeter2Power + _powerMeter3Power; + if (!_upProvider) { return 0.0; } + return _upProvider->getPowerTotal(); } -uint32_t PowerMeterClass::getLastPowerMeterUpdate() +uint32_t PowerMeterClass::getLastUpdate() const { std::lock_guard l(_mutex); - return _lastPowerMeterUpdate; + if (!_upProvider) { return 0; } + return _upProvider->getLastUpdate(); } -bool PowerMeterClass::isDataValid() +bool PowerMeterClass::isDataValid() const { - auto const& config = Configuration.get(); - std::lock_guard l(_mutex); - - bool valid = config.PowerMeter.Enabled && - _lastPowerMeterUpdate > 0 && - ((millis() - _lastPowerMeterUpdate) < (30 * 1000)); - - // reset if timed out to avoid glitch once - // (millis() - _lastPowerMeterUpdate) overflows - if (!valid) { _lastPowerMeterUpdate = 0; } - - return valid; -} - -void PowerMeterClass::mqtt() -{ - if (!MqttSettings.getConnected()) { return; } - - String topic = "powermeter"; - auto totalPower = getPowerTotal(); - - std::lock_guard l(_mutex); - MqttSettings.publish(topic + "/power1", String(_powerMeter1Power)); - MqttSettings.publish(topic + "/power2", String(_powerMeter2Power)); - MqttSettings.publish(topic + "/power3", String(_powerMeter3Power)); - MqttSettings.publish(topic + "/powertotal", String(totalPower)); - MqttSettings.publish(topic + "/voltage1", String(_powerMeter1Voltage)); - MqttSettings.publish(topic + "/voltage2", String(_powerMeter2Voltage)); - MqttSettings.publish(topic + "/voltage3", String(_powerMeter3Voltage)); - MqttSettings.publish(topic + "/import", String(_powerMeterImport)); - MqttSettings.publish(topic + "/export", String(_powerMeterExport)); + if (!_upProvider) { return false; } + return _upProvider->isDataValid(); } void PowerMeterClass::loop() { - CONFIG_T const& config = Configuration.get(); - _verboseLogging = config.PowerMeter.VerboseLogging; - - if (!config.PowerMeter.Enabled) { return; } - - if (static_cast(config.PowerMeter.Source) == Source::SML && - nullptr != _upSmlSerial) { - if (!smlReadLoop()) { return; } - _lastPowerMeterUpdate = millis(); - } - - if ((millis() - _lastPowerMeterCheck) < (config.PowerMeter.Interval * 1000)) { - return; - } - - readPowerMeter(); - - MessageOutput.printf("PowerMeterClass: TotalPower: %5.2f\r\n", getPowerTotal()); - - mqtt(); - - _lastPowerMeterCheck = millis(); -} - -void PowerMeterClass::readPowerMeter() -{ - CONFIG_T& config = Configuration.get(); - - uint8_t _address = config.PowerMeter.SdmAddress; - Source configuredSource = static_cast(config.PowerMeter.Source); - - if (configuredSource == Source::SDM1PH) { - if (!_upSdm) { return; } - - // this takes a "very long" time as each readVal() is a synchronous - // exchange of serial messages. cache the values and write later. - auto phase1Power = _upSdm->readVal(SDM_PHASE_1_POWER, _address); - auto phase1Voltage = _upSdm->readVal(SDM_PHASE_1_VOLTAGE, _address); - auto energyImport = _upSdm->readVal(SDM_IMPORT_ACTIVE_ENERGY, _address); - auto energyExport = _upSdm->readVal(SDM_EXPORT_ACTIVE_ENERGY, _address); - - std::lock_guard l(_mutex); - _powerMeter1Power = static_cast(phase1Power); - _powerMeter2Power = 0; - _powerMeter3Power = 0; - _powerMeter1Voltage = static_cast(phase1Voltage); - _powerMeter2Voltage = 0; - _powerMeter3Voltage = 0; - _powerMeterImport = static_cast(energyImport); - _powerMeterExport = static_cast(energyExport); - _lastPowerMeterUpdate = millis(); - } - else if (configuredSource == Source::SDM3PH) { - if (!_upSdm) { return; } - - // this takes a "very long" time as each readVal() is a synchronous - // exchange of serial messages. cache the values and write later. - auto phase1Power = _upSdm->readVal(SDM_PHASE_1_POWER, _address); - auto phase2Power = _upSdm->readVal(SDM_PHASE_2_POWER, _address); - auto phase3Power = _upSdm->readVal(SDM_PHASE_3_POWER, _address); - auto phase1Voltage = _upSdm->readVal(SDM_PHASE_1_VOLTAGE, _address); - auto phase2Voltage = _upSdm->readVal(SDM_PHASE_2_VOLTAGE, _address); - auto phase3Voltage = _upSdm->readVal(SDM_PHASE_3_VOLTAGE, _address); - auto energyImport = _upSdm->readVal(SDM_IMPORT_ACTIVE_ENERGY, _address); - auto energyExport = _upSdm->readVal(SDM_EXPORT_ACTIVE_ENERGY, _address); - - std::lock_guard l(_mutex); - _powerMeter1Power = static_cast(phase1Power); - _powerMeter2Power = static_cast(phase2Power); - _powerMeter3Power = static_cast(phase3Power); - _powerMeter1Voltage = static_cast(phase1Voltage); - _powerMeter2Voltage = static_cast(phase2Voltage); - _powerMeter3Voltage = static_cast(phase3Voltage); - _powerMeterImport = static_cast(energyImport); - _powerMeterExport = static_cast(energyExport); - _lastPowerMeterUpdate = millis(); - } - else if (configuredSource == Source::HTTP) { - if (HttpPowerMeter.updateValues()) { - std::lock_guard l(_mutex); - _powerMeter1Power = HttpPowerMeter.getPower(1); - _powerMeter2Power = HttpPowerMeter.getPower(2); - _powerMeter3Power = HttpPowerMeter.getPower(3); - _lastPowerMeterUpdate = millis(); - } - } - else if (configuredSource == Source::SMAHM2) { - std::lock_guard l(_mutex); - _powerMeter1Power = SMA_HM.getPowerL1(); - _powerMeter2Power = SMA_HM.getPowerL2(); - _powerMeter3Power = SMA_HM.getPowerL3(); - _lastPowerMeterUpdate = millis(); - } - else if (configuredSource == Source::TIBBER) { - if (TibberPowerMeter.updateValues()) { - _lastPowerMeterUpdate = millis(); - } - } -} - -bool PowerMeterClass::smlReadLoop() -{ - while (_upSmlSerial->available()) { - double readVal = 0; - unsigned char smlCurrentChar = _upSmlSerial->read(); - sml_states_t smlCurrentState = smlState(smlCurrentChar); - if (smlCurrentState == SML_LISTEND) { - for (auto& handler: smlHandlerList) { - if (smlOBISCheck(handler.OBIS)) { - handler.Fn(readVal); - *handler.Arg = readVal; - } - } - } else if (smlCurrentState == SML_FINAL) { - return true; - } - } - - return false; + std::lock_guard lock(_mutex); + if (!_upProvider) { return; } + _upProvider->loop(); + _upProvider->mqttLoop(); } diff --git a/src/HttpPowerMeter.cpp b/src/PowerMeterHttpJson.cpp similarity index 84% rename from src/HttpPowerMeter.cpp rename to src/PowerMeterHttpJson.cpp index e3033cbb9..bf13ca259 100644 --- a/src/HttpPowerMeter.cpp +++ b/src/PowerMeterHttpJson.cpp @@ -1,7 +1,8 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "Configuration.h" -#include "HttpPowerMeter.h" +#include "PowerMeterHttpJson.h" #include "MessageOutput.h" +#include "MqttSettings.h" #include #include #include "mbedtls/sha256.h" @@ -9,48 +10,63 @@ #include #include -void HttpPowerMeterClass::init() -{ -} - -float HttpPowerMeterClass::getPower(int8_t phase) -{ - if (phase < 1 || phase > POWERMETER_MAX_PHASES) { return 0.0; } - - return power[phase - 1]; -} - -bool HttpPowerMeterClass::updateValues() +void PowerMeterHttpJson::loop() { auto const& config = Configuration.get(); + if ((millis() - _lastPoll) < (config.PowerMeter.Interval * 1000)) { + return; + } + + _lastPoll = millis(); for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { auto const& phaseConfig = config.PowerMeter.Http_Phase[i]; if (!phaseConfig.Enabled) { - power[i] = 0.0; + _cache[i] = 0.0; continue; } if (i == 0 || config.PowerMeter.HttpIndividualRequests) { if (!queryPhase(i, phaseConfig)) { - MessageOutput.printf("[HttpPowerMeter] Getting the power of phase %d failed.\r\n", i + 1); + MessageOutput.printf("[PowerMeterHttpJson] Getting HTTP response for phase %d failed.\r\n", i + 1); MessageOutput.printf("%s\r\n", httpPowerMeterError); - return false; + return; } continue; } if(!tryGetFloatValueForPhase(i, phaseConfig.JsonPath, phaseConfig.PowerUnit, phaseConfig.SignInverted)) { - MessageOutput.printf("[HttpPowerMeter] Getting the power of phase %d (from JSON fetched with Phase 1 config) failed.\r\n", i + 1); + MessageOutput.printf("[PowerMeterHttpJson] Reading power of phase %d (from JSON fetched with Phase 1 config) failed.\r\n", i + 1); MessageOutput.printf("%s\r\n", httpPowerMeterError); - return false; + return; } } - return true; + + gotUpdate(); + + _powerValues = _cache; +} + +float PowerMeterHttpJson::getPowerTotal() const +{ + float sum = 0.0; + for (auto v: _powerValues) { sum += v; } + return sum; } -bool HttpPowerMeterClass::queryPhase(int phase, PowerMeterHttpConfig const& config) +void PowerMeterHttpJson::doMqttPublish() const +{ + String topic = "powermeter"; + auto power = getPowerTotal(); + + MqttSettings.publish(topic + "/power1", String(_powerValues[0])); + MqttSettings.publish(topic + "/power2", String(_powerValues[1])); + MqttSettings.publish(topic + "/power3", String(_powerValues[2])); + MqttSettings.publish(topic + "/powertotal", String(power)); +} + +bool PowerMeterHttpJson::queryPhase(int phase, PowerMeterHttpConfig const& config) { //hostByName in WiFiGeneric fails to resolve local names. issue described in //https://github.com/espressif/arduino-esp32/issues/3822 @@ -107,7 +123,7 @@ bool HttpPowerMeterClass::queryPhase(int phase, PowerMeterHttpConfig const& conf return httpRequest(phase, *wifiClient, ipaddr.toString(), port, uri, https, config); } -bool HttpPowerMeterClass::httpRequest(int phase, WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpConfig const& config) +bool PowerMeterHttpJson::httpRequest(int phase, WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpConfig const& config) { if(!httpClient.begin(wifiClient, host, port, uri, https)){ snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("httpClient.begin() failed for %s://%s"), (https ? "https" : "http"), host.c_str()); @@ -163,13 +179,13 @@ bool HttpPowerMeterClass::httpRequest(int phase, WiFiClient &wifiClient, const S return tryGetFloatValueForPhase(phase, config.JsonPath, config.PowerUnit, config.SignInverted); } -String HttpPowerMeterClass::extractParam(String& authReq, const String& param, const char delimit) { +String PowerMeterHttpJson::extractParam(String& authReq, const String& param, const char delimit) { int _begin = authReq.indexOf(param); if (_begin == -1) { return ""; } return authReq.substring(_begin + param.length(), authReq.indexOf(delimit, _begin + param.length())); } -String HttpPowerMeterClass::getcNonce(const int len) { +String PowerMeterHttpJson::getcNonce(const int len) { static const char alphanum[] = "0123456789" "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz"; @@ -180,7 +196,7 @@ String HttpPowerMeterClass::getcNonce(const int len) { return s; } -String HttpPowerMeterClass::getDigestAuth(String& authReq, const String& username, const String& password, const String& method, const String& uri, unsigned int counter) { +String PowerMeterHttpJson::getDigestAuth(String& authReq, const String& username, const String& password, const String& method, const String& uri, unsigned int counter) { // extracting required parameters for RFC 2617 Digest String realm = extractParam(authReq, "realm=\"", '"'); String nonce = extractParam(authReq, "nonce=\"", '"'); @@ -218,13 +234,13 @@ String HttpPowerMeterClass::getDigestAuth(String& authReq, const String& usernam return authorization; } -bool HttpPowerMeterClass::tryGetFloatValueForPhase(int phase, String jsonPath, Unit_t unit, bool signInverted) +bool PowerMeterHttpJson::tryGetFloatValueForPhase(int phase, String jsonPath, Unit_t unit, bool signInverted) { JsonDocument root; const DeserializationError error = deserializeJson(root, httpResponse); if (error) { snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), - PSTR("[HttpPowerMeter] Unable to parse server response as JSON")); + PSTR("[PowerMeterHttpJson] Unable to parse server response as JSON")); return false; } @@ -286,32 +302,32 @@ bool HttpPowerMeterClass::tryGetFloatValueForPhase(int phase, String jsonPath, U if (!value.is()) { snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), - PSTR("[HttpPowerMeter] not a float: '%s'"), + PSTR("[PowerMeterHttpJson] not a float: '%s'"), value.as().c_str()); return false; } // this value is supposed to be in Watts and positive if energy is consumed. - power[phase] = value.as(); + _cache[phase] = value.as(); switch (unit) { case Unit_t::MilliWatts: - power[phase] /= 1000; + _cache[phase] /= 1000; break; case Unit_t::KiloWatts: - power[phase] *= 1000; + _cache[phase] *= 1000; break; default: break; } - if (signInverted) { power[phase] *= -1; } + if (signInverted) { _cache[phase] *= -1; } return true; } //extract url component as done by httpClient::begin(String url, const char* expectedProtocol) https://github.com/espressif/arduino-esp32/blob/da6325dd7e8e152094b19fe63190907f38ef1ff0/libraries/HTTPClient/src/HTTPClient.cpp#L250 -bool HttpPowerMeterClass::extractUrlComponents(String url, String& _protocol, String& _host, String& _uri, uint16_t& _port, String& _base64Authorization) +bool PowerMeterHttpJson::extractUrlComponents(String url, String& _protocol, String& _host, String& _uri, uint16_t& _port, String& _base64Authorization) { // check for : (http: or https: int index = url.indexOf(':'); @@ -361,7 +377,7 @@ bool HttpPowerMeterClass::extractUrlComponents(String url, String& _protocol, St return true; } -String HttpPowerMeterClass::sha256(const String& data) { +String PowerMeterHttpJson::sha256(const String& data) { uint8_t hash[32]; mbedtls_sha256_context ctx; @@ -379,7 +395,7 @@ String HttpPowerMeterClass::sha256(const String& data) { return res; } -void HttpPowerMeterClass::prepareRequest(uint32_t timeout, const char* httpHeader, const char* httpValue) { +void PowerMeterHttpJson::prepareRequest(uint32_t timeout, const char* httpHeader, const char* httpValue) { httpClient.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); httpClient.setUserAgent("OpenDTU-OnBattery"); httpClient.setConnectTimeout(timeout); @@ -391,5 +407,3 @@ void HttpPowerMeterClass::prepareRequest(uint32_t timeout, const char* httpHeade httpClient.addHeader(httpHeader, httpValue); } } - -HttpPowerMeterClass HttpPowerMeter; diff --git a/src/TibberPowerMeter.cpp b/src/PowerMeterHttpSml.cpp similarity index 84% rename from src/TibberPowerMeter.cpp rename to src/PowerMeterHttpSml.cpp index d7889c22f..e7462fdfe 100644 --- a/src/TibberPowerMeter.cpp +++ b/src/PowerMeterHttpSml.cpp @@ -1,28 +1,44 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "Configuration.h" -#include "TibberPowerMeter.h" +#include "PowerMeterHttpSml.h" #include "MessageOutput.h" +#include "MqttSettings.h" #include #include #include -#include -bool TibberPowerMeterClass::updateValues() +float PowerMeterHttpSml::getPowerTotal() const +{ + std::lock_guard l(_mutex); + return _activePower; +} + +void PowerMeterHttpSml::doMqttPublish() const +{ + String topic = "powermeter"; + + std::lock_guard l(_mutex); + MqttSettings.publish(topic + "/powertotal", String(_activePower)); +} + +void PowerMeterHttpSml::loop() { auto const& config = Configuration.get(); + if ((millis() - _lastPoll) < (config.PowerMeter.Interval * 1000)) { + return; + } + + _lastPoll = millis(); auto const& tibberConfig = config.PowerMeter.Tibber; if (!query(tibberConfig)) { - MessageOutput.printf("[TibberPowerMeter] Getting the power of tibber failed.\r\n"); + MessageOutput.printf("[PowerMeterHttpSml] Getting the power value failed.\r\n"); MessageOutput.printf("%s\r\n", tibberPowerMeterError); - return false; } - - return true; } -bool TibberPowerMeterClass::query(PowerMeterTibberConfig const& config) +bool PowerMeterHttpSml::query(PowerMeterTibberConfig const& config) { //hostByName in WiFiGeneric fails to resolve local names. issue described in //https://github.com/espressif/arduino-esp32/issues/3822 @@ -79,7 +95,7 @@ bool TibberPowerMeterClass::query(PowerMeterTibberConfig const& config) return httpRequest(*wifiClient, ipaddr.toString(), port, uri, https, config); } -bool TibberPowerMeterClass::httpRequest(WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterTibberConfig const& config) +bool PowerMeterHttpSml::httpRequest(WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterTibberConfig const& config) { if(!httpClient.begin(wifiClient, host, port, uri, https)){ snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("httpClient.begin() failed for %s://%s"), (https ? "https" : "http"), host.c_str()); @@ -112,10 +128,12 @@ bool TibberPowerMeterClass::httpRequest(WiFiClient &wifiClient, const String& ho unsigned char smlCurrentChar = httpClient.getStream().read(); sml_states_t smlCurrentState = smlState(smlCurrentChar); if (smlCurrentState == SML_LISTEND) { - for (auto& handler: PowerMeter.smlHandlerList) { + for (auto& handler: smlHandlerList) { if (smlOBISCheck(handler.OBIS)) { + std::lock_guard l(_mutex); handler.Fn(readVal); *handler.Arg = readVal; + gotUpdate(); } } } @@ -126,7 +144,7 @@ bool TibberPowerMeterClass::httpRequest(WiFiClient &wifiClient, const String& ho } //extract url component as done by httpClient::begin(String url, const char* expectedProtocol) https://github.com/espressif/arduino-esp32/blob/da6325dd7e8e152094b19fe63190907f38ef1ff0/libraries/HTTPClient/src/HTTPClient.cpp#L250 -bool TibberPowerMeterClass::extractUrlComponents(String url, String& _protocol, String& _host, String& _uri, uint16_t& _port, String& _base64Authorization) +bool PowerMeterHttpSml::extractUrlComponents(String url, String& _protocol, String& _host, String& _uri, uint16_t& _port, String& _base64Authorization) { // check for : (http: or https: int index = url.indexOf(':'); @@ -176,7 +194,7 @@ bool TibberPowerMeterClass::extractUrlComponents(String url, String& _protocol, return true; } -void TibberPowerMeterClass::prepareRequest(uint32_t timeout) { +void PowerMeterHttpSml::prepareRequest(uint32_t timeout) { httpClient.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); httpClient.setUserAgent("OpenDTU-OnBattery"); httpClient.setConnectTimeout(timeout); @@ -184,5 +202,3 @@ void TibberPowerMeterClass::prepareRequest(uint32_t timeout) { httpClient.addHeader("Content-Type", "application/json"); httpClient.addHeader("Accept", "application/json"); } - -TibberPowerMeterClass TibberPowerMeter; diff --git a/src/PowerMeterMqtt.cpp b/src/PowerMeterMqtt.cpp new file mode 100644 index 000000000..9a470ccfa --- /dev/null +++ b/src/PowerMeterMqtt.cpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "PowerMeterMqtt.h" +#include "Configuration.h" +#include "MqttSettings.h" +#include "MessageOutput.h" + +bool PowerMeterMqtt::init() +{ + auto subscribe = [this](char const* topic, float* target) { + if (strlen(topic) == 0) { return; } + MqttSettings.subscribe(topic, 0, + std::bind(&PowerMeterMqtt::onMqttMessage, + this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6) + ); + _mqttSubscriptions.try_emplace(topic, target); + }; + + auto const& config = Configuration.get(); + subscribe(config.PowerMeter.MqttTopicPowerMeter1, &_powerValueOne); + subscribe(config.PowerMeter.MqttTopicPowerMeter2, &_powerValueTwo); + subscribe(config.PowerMeter.MqttTopicPowerMeter3, &_powerValueThree); + + return _mqttSubscriptions.size() > 0; +} + +void PowerMeterMqtt::deinit() +{ + for (auto const& s: _mqttSubscriptions) { MqttSettings.unsubscribe(s.first); } + _mqttSubscriptions.clear(); +} + +void PowerMeterMqtt::onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) +{ + for (auto const& subscription: _mqttSubscriptions) { + if (subscription.first != topic) { continue; } + + std::string value(reinterpret_cast(payload), len); + try { + *subscription.second = std::stof(value); + } + catch(std::invalid_argument const& e) { + MessageOutput.printf("[PowerMeterMqtt] cannot parse payload of topic '%s' as float: %s\r\n", + topic, value.c_str()); + return; + } + + if (_verboseLogging) { + MessageOutput.printf("[PowerMeterMqtt] Updated from '%s', TotalPower: %5.2f\r\n", + topic, getPowerTotal()); + } + + gotUpdate(); + } +} + +float PowerMeterMqtt::getPowerTotal() const +{ + std::lock_guard l(_mutex); + return _powerValueOne + _powerValueTwo + _powerValueThree; +} + +void PowerMeterMqtt::doMqttPublish() const +{ + String topic = "powermeter"; + auto totalPower = getPowerTotal(); + + std::lock_guard l(_mutex); + MqttSettings.publish(topic + "/power1", String(_powerValueOne)); + MqttSettings.publish(topic + "/power2", String(_powerValueTwo)); + MqttSettings.publish(topic + "/power3", String(_powerValueThree)); + MqttSettings.publish(topic + "/powertotal", String(totalPower)); +} diff --git a/src/PowerMeterProvider.cpp b/src/PowerMeterProvider.cpp new file mode 100644 index 000000000..8becdebd9 --- /dev/null +++ b/src/PowerMeterProvider.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "PowerMeterProvider.h" +#include "MqttSettings.h" + +bool PowerMeterProvider::isDataValid() const +{ + return _lastUpdate > 0 && ((millis() - _lastUpdate) < (30 * 1000)); +} + +void PowerMeterProvider::mqttLoop() const +{ + if (!MqttSettings.getConnected()) { return; } + + if (!isDataValid()) { return; } + + auto constexpr halfOfAllMillis = std::numeric_limits::max() / 2; + if ((_lastUpdate - _lastMqttPublish) > halfOfAllMillis) { return; } + + doMqttPublish(); + + _lastMqttPublish = millis(); +} diff --git a/src/PowerMeterSerialSdm.cpp b/src/PowerMeterSerialSdm.cpp new file mode 100644 index 000000000..d08cac194 --- /dev/null +++ b/src/PowerMeterSerialSdm.cpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "PowerMeterSerialSdm.h" +#include "Configuration.h" +#include "PinMapping.h" +#include "MessageOutput.h" +#include "MqttSettings.h" +#include "SerialPortManager.h" + +void PowerMeterSerialSdm::deinit() +{ + if (_upSdmSerial) { + _upSdmSerial->end(); + _upSdmSerial = nullptr; + } +} + +bool PowerMeterSerialSdm::init() +{ + const PinMapping_t& pin = PinMapping.get(); + + MessageOutput.printf("[PowerMeterSerialSdm] rx = %d, tx = %d, dere = %d\r\n", + pin.powermeter_rx, pin.powermeter_tx, pin.powermeter_dere); + + if (pin.powermeter_rx < 0 || pin.powermeter_tx < 0) { + MessageOutput.println("[PowerMeterSerialSdm] invalid pin config for SDM " + "power meter (RX and TX pins must be defined)"); + return false; + } + + auto oHwSerialPort = SerialPortManager.allocatePort(_sdmSerialPortOwner); + if (!oHwSerialPort) { return false; } + + _upSdmSerial = std::make_unique(*oHwSerialPort); + _upSdmSerial->end(); // make sure the UART will be re-initialized + _upSdm = std::make_unique(*_upSdmSerial, 9600, pin.powermeter_dere, + SERIAL_8N1, pin.powermeter_rx, pin.powermeter_tx); + _upSdm->begin(); + + return true; +} + +float PowerMeterSerialSdm::getPowerTotal() const +{ + std::lock_guard l(_mutex); + return _phase1Power + _phase2Power + _phase3Power; +} + +void PowerMeterSerialSdm::doMqttPublish() const +{ + String topic = "powermeter"; + auto power = getPowerTotal(); + + std::lock_guard l(_mutex); + MqttSettings.publish(topic + "/power1", String(_phase1Power)); + MqttSettings.publish(topic + "/power2", String(_phase2Power)); + MqttSettings.publish(topic + "/power3", String(_phase3Power)); + MqttSettings.publish(topic + "/powertotal", String(power)); + MqttSettings.publish(topic + "/voltage1", String(_phase1Voltage)); + MqttSettings.publish(topic + "/voltage2", String(_phase2Voltage)); + MqttSettings.publish(topic + "/voltage3", String(_phase3Voltage)); + MqttSettings.publish(topic + "/import", String(_energyImport)); + MqttSettings.publish(topic + "/export", String(_energyExport)); +} + +void PowerMeterSerialSdm::loop() +{ + if (!_upSdm) { return; } + + auto const& config = Configuration.get(); + + if ((millis() - _lastPoll) < (config.PowerMeter.Interval * 1000)) { + return; + } + + uint8_t addr = config.PowerMeter.SdmAddress; + + // reading takes a "very long" time as each readVal() is a synchronous + // exchange of serial messages. cache the values and write later to + // enforce consistent values. + float phase1Power = _upSdm->readVal(SDM_PHASE_1_POWER, addr); + float phase2Power = 0.0; + float phase3Power = 0.0; + float phase1Voltage = _upSdm->readVal(SDM_PHASE_1_VOLTAGE, addr); + float phase2Voltage = 0.0; + float phase3Voltage = 0.0; + float energyImport = _upSdm->readVal(SDM_IMPORT_ACTIVE_ENERGY, addr); + float energyExport = _upSdm->readVal(SDM_EXPORT_ACTIVE_ENERGY, addr); + + if (static_cast(config.PowerMeter.Source) == PowerMeterProvider::Type::SDM3PH) { + phase2Power = _upSdm->readVal(SDM_PHASE_2_POWER, addr); + phase3Power = _upSdm->readVal(SDM_PHASE_3_POWER, addr); + phase2Voltage = _upSdm->readVal(SDM_PHASE_2_VOLTAGE, addr); + phase3Voltage = _upSdm->readVal(SDM_PHASE_3_VOLTAGE, addr); + } + + { + std::lock_guard l(_mutex); + _phase1Power = static_cast(phase1Power); + _phase2Power = static_cast(phase2Power); + _phase3Power = static_cast(phase3Power); + _phase1Voltage = static_cast(phase1Voltage); + _phase2Voltage = static_cast(phase2Voltage); + _phase3Voltage = static_cast(phase3Voltage); + _energyImport = static_cast(energyImport); + _energyExport = static_cast(energyExport); + } + + gotUpdate(); + + MessageOutput.printf("[PowerMeterSerialSdm] TotalPower: %5.2f\r\n", getPowerTotal()); + + _lastPoll = millis(); +} diff --git a/src/PowerMeterSerialSml.cpp b/src/PowerMeterSerialSml.cpp new file mode 100644 index 000000000..047925141 --- /dev/null +++ b/src/PowerMeterSerialSml.cpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "PowerMeterSerialSml.h" +#include "Configuration.h" +#include "PinMapping.h" +#include "MessageOutput.h" +#include "MqttSettings.h" + +bool PowerMeterSerialSml::init() +{ + const PinMapping_t& pin = PinMapping.get(); + + MessageOutput.printf("[PowerMeterSerialSml] rx = %d\r\n", pin.powermeter_rx); + + if (pin.powermeter_rx < 0) { + MessageOutput.println("[PowerMeterSerialSml] invalid pin config " + "for serial SML power meter (RX pin must be defined)"); + return false; + } + + pinMode(pin.powermeter_rx, INPUT); + _upSmlSerial = std::make_unique(); + _upSmlSerial->begin(9600, SWSERIAL_8N1, pin.powermeter_rx, -1, false, 128, 95); + _upSmlSerial->enableRx(true); + _upSmlSerial->enableTx(false); + _upSmlSerial->flush(); + + return true; +} + +void PowerMeterSerialSml::deinit() +{ + if (!_upSmlSerial) { return; } + _upSmlSerial->end(); +} + +void PowerMeterSerialSml::doMqttPublish() const +{ + String topic = "powermeter"; + + std::lock_guard l(_mutex); + MqttSettings.publish(topic + "/powertotal", String(_activePower)); + MqttSettings.publish(topic + "/import", String(_energyImport)); + MqttSettings.publish(topic + "/export", String(_energyExport)); +} + +void PowerMeterSerialSml::loop() +{ + if (!_upSmlSerial) { return; } + + while (_upSmlSerial->available()) { + double readVal = 0; + unsigned char smlCurrentChar = _upSmlSerial->read(); + sml_states_t smlCurrentState = smlState(smlCurrentChar); + if (smlCurrentState == SML_LISTEND) { + for (auto& handler: smlHandlerList) { + if (smlOBISCheck(handler.OBIS)) { + handler.Fn(readVal); + std::lock_guard l(_mutex); + *handler.Arg = readVal; + } + } + } else if (smlCurrentState == SML_FINAL) { + gotUpdate(); + } + } + + MessageOutput.printf("[PowerMeterSerialSml]: TotalPower: %5.2f\r\n", getPowerTotal()); +} diff --git a/src/SMA_HM.cpp b/src/PowerMeterUdpSmaHomeManager.cpp similarity index 76% rename from src/SMA_HM.cpp rename to src/PowerMeterUdpSmaHomeManager.cpp index 7a3a9fe2e..b79ae3d94 100644 --- a/src/SMA_HM.cpp +++ b/src/PowerMeterUdpSmaHomeManager.cpp @@ -2,51 +2,50 @@ /* * Copyright (C) 2024 Holger-Steffen Stapf */ -#include "SMA_HM.h" +#include "PowerMeterUdpSmaHomeManager.h" #include -#include "Configuration.h" -#include "NetworkSettings.h" +#include "MqttSettings.h" #include #include "MessageOutput.h" -unsigned int multicastPort = 9522; // local port to listen on -IPAddress multicastIP(239, 12, 255, 254); -WiFiUDP SMAUdp; +static constexpr unsigned int multicastPort = 9522; // local port to listen on +static const IPAddress multicastIP(239, 12, 255, 254); +static WiFiUDP SMAUdp; constexpr uint32_t interval = 1000; -SMA_HMClass SMA_HM; - -void SMA_HMClass::Soutput(int kanal, int index, int art, int tarif, +void PowerMeterUdpSmaHomeManager::Soutput(int kanal, int index, int art, int tarif, char const* name, float value, uint32_t timestamp) { if (!_verboseLogging) { return; } - MessageOutput.printf("SMA_HM: %s = %.1f (timestamp %d)\r\n", + MessageOutput.printf("[PowerMeterUdpSmaHomeManager] %s = %.1f (timestamp %d)\r\n", name, value, timestamp); } -void SMA_HMClass::init(Scheduler& scheduler, bool verboseLogging) +bool PowerMeterUdpSmaHomeManager::init() { - _verboseLogging = verboseLogging; - scheduler.addTask(_loopTask); - _loopTask.setCallback(std::bind(&SMA_HMClass::loop, this)); - _loopTask.setIterations(TASK_FOREVER); - _loopTask.enable(); SMAUdp.begin(multicastPort); SMAUdp.beginMulticast(multicastIP, multicastPort); + return true; } -void SMA_HMClass::loop() +void PowerMeterUdpSmaHomeManager::deinit() { - uint32_t currentMillis = millis(); - if (currentMillis - _previousMillis >= interval) { - _previousMillis = currentMillis; - event1(); - } + SMAUdp.stop(); +} + +void PowerMeterUdpSmaHomeManager::doMqttPublish() const +{ + String topic = "powermeter"; + + MqttSettings.publish(topic + "/powertotal", String(_powerMeterPower)); + MqttSettings.publish(topic + "/power1", String(_powerMeterL1)); + MqttSettings.publish(topic + "/power2", String(_powerMeterL2)); + MqttSettings.publish(topic + "/power3", String(_powerMeterL3)); } -uint8_t* SMA_HMClass::decodeGroup(uint8_t* offset, uint16_t grouplen) +uint8_t* PowerMeterUdpSmaHomeManager::decodeGroup(uint8_t* offset, uint16_t grouplen) { float Pbezug = 0; float BezugL1 = 0; @@ -149,7 +148,7 @@ uint8_t* SMA_HMClass::decodeGroup(uint8_t* offset, uint16_t grouplen) continue; } - MessageOutput.printf("SMA_HM: Skipped unknown measurement: %d %d %d %d\r\n", + MessageOutput.printf("[PowerMeterUdpSmaHomeManager] Skipped unknown measurement: %d %d %d %d\r\n", kanal, index, art, tarif); offset += art; } @@ -157,15 +156,20 @@ uint8_t* SMA_HMClass::decodeGroup(uint8_t* offset, uint16_t grouplen) return offset; } -void SMA_HMClass::event1() +void PowerMeterUdpSmaHomeManager::loop() { + uint32_t currentMillis = millis(); + if (currentMillis - _previousMillis < interval) { return; } + + _previousMillis = currentMillis; + int packetSize = SMAUdp.parsePacket(); if (!packetSize) { return; } uint8_t buffer[1024]; int rSize = SMAUdp.read(buffer, 1024); if (buffer[0] != 'S' || buffer[1] != 'M' || buffer[2] != 'A') { - MessageOutput.println("SMA_HM: Not an SMA packet?"); + MessageOutput.println("[PowerMeterUdpSmaHomeManager] Not an SMA packet?"); return; } @@ -196,7 +200,7 @@ void SMA_HMClass::event1() continue; } - MessageOutput.printf("SMA_HM: Unhandled group 0x%04x with length %d\r\n", + MessageOutput.printf("[PowerMeterUdpSmaHomeManager] Unhandled group 0x%04x with length %d\r\n", grouptag, grouplen); offset += grouplen; } while (grouplen > 0 && offset + 4 < buffer + rSize); diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index 74472bc05..c6cf373fb 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -12,8 +12,8 @@ #include "MqttSettings.h" #include "PowerLimiter.h" #include "PowerMeter.h" -#include "HttpPowerMeter.h" -#include "TibberPowerMeter.h" +#include "PowerMeterHttpJson.h" +#include "PowerMeterHttpSml.h" #include "WebApi.h" #include "helper.h" @@ -128,7 +128,7 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) return; } - if (static_cast(root["source"].as()) == PowerMeterClass::Source::HTTP) { + if (static_cast(root["source"].as()) == PowerMeterProvider::Type::HTTP) { JsonArray http_phases = root["http_phases"]; for (uint8_t i = 0; i < http_phases.size(); i++) { JsonObject phase = http_phases[i].as(); @@ -174,7 +174,7 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) } } - if (static_cast(root["source"].as()) == PowerMeterClass::Source::TIBBER) { + if (static_cast(root["source"].as()) == PowerMeterProvider::Type::TIBBER) { JsonObject tibber = root["tibber"]; if (!tibber.containsKey("url") @@ -260,14 +260,14 @@ void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request) char response[256]; - int phase = 0;//"absuing" index 0 of the float power[3] in HttpPowerMeter to store the result PowerMeterHttpConfig phaseConfig; decodeJsonPhaseConfig(root.as(), phaseConfig); - if (HttpPowerMeter.queryPhase(phase, phaseConfig)) { + auto upMeter = std::make_unique(); + if (upMeter->queryPhase(0/*phase*/, phaseConfig)) { retMsg["type"] = "success"; - snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", HttpPowerMeter.getPower(phase + 1)); + snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", upMeter->getPowerTotal()); } else { - snprintf_P(response, sizeof(response), "%s", HttpPowerMeter.httpPowerMeterError); + snprintf_P(response, sizeof(response), "%s", upMeter->httpPowerMeterError); } retMsg["message"] = response; @@ -302,11 +302,12 @@ void WebApiPowerMeterClass::onTestTibberRequest(AsyncWebServerRequest* request) PowerMeterTibberConfig tibberConfig; decodeJsonTibberConfig(root.as(), tibberConfig); - if (TibberPowerMeter.query(tibberConfig)) { + auto upMeter = std::make_unique(); + if (upMeter->query(tibberConfig)) { retMsg["type"] = "success"; - snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", PowerMeter.getPowerTotal()); + snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", upMeter->getPowerTotal()); } else { - snprintf_P(response, sizeof(response), "%s", TibberPowerMeter.tibberPowerMeterError); + snprintf_P(response, sizeof(response), "%s", upMeter->tibberPowerMeterError); } retMsg["message"] = response; diff --git a/src/WebApi_ws_live.cpp b/src/WebApi_ws_live.cpp index ab54d479f..85781bce4 100644 --- a/src/WebApi_ws_live.cpp +++ b/src/WebApi_ws_live.cpp @@ -98,12 +98,12 @@ void WebApiWsLiveClass::generateOnBatteryJsonResponse(JsonVariant& root, bool al if (!all) { _lastPublishBattery = millis(); } } - if (all || (PowerMeter.getLastPowerMeterUpdate() - _lastPublishPowerMeter) < halfOfAllMillis) { + if (all || (PowerMeter.getLastUpdate() - _lastPublishPowerMeter) < halfOfAllMillis) { auto powerMeterObj = root["power_meter"].to(); powerMeterObj["enabled"] = config.PowerMeter.Enabled; if (config.PowerMeter.Enabled) { - addTotalField(powerMeterObj, "Power", PowerMeter.getPowerTotal(false), "W", 1); + addTotalField(powerMeterObj, "Power", PowerMeter.getPowerTotal(), "W", 1); } if (!all) { _lastPublishPowerMeter = millis(); } From 33683d26c88e27456f96320bc2ff8924964558d8 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Wed, 8 May 2024 11:15:36 +0200 Subject: [PATCH 047/140] powermeter refactor: rename providers in enum the enum values did not change, but their name (only relevant in the code) are now more expressive. --- include/PowerMeterProvider.h | 6 +++--- src/PowerMeter.cpp | 6 +++--- src/WebApi_powermeter.cpp | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/include/PowerMeterProvider.h b/include/PowerMeterProvider.h index 9fb74c78e..d3e02f805 100644 --- a/include/PowerMeterProvider.h +++ b/include/PowerMeterProvider.h @@ -11,10 +11,10 @@ class PowerMeterProvider { MQTT = 0, SDM1PH = 1, SDM3PH = 2, - HTTP = 3, - SML = 4, + HTTP_JSON = 3, + SERIAL_SML = 4, SMAHM2 = 5, - TIBBER = 6 + HTTP_SML = 6 }; // returns true if the provider is ready for use, false otherwise diff --git a/src/PowerMeter.cpp b/src/PowerMeter.cpp index 8212529b0..37c3ab34f 100644 --- a/src/PowerMeter.cpp +++ b/src/PowerMeter.cpp @@ -41,16 +41,16 @@ void PowerMeterClass::updateSettings() case PowerMeterProvider::Type::SDM3PH: _upProvider = std::make_unique(); break; - case PowerMeterProvider::Type::HTTP: + case PowerMeterProvider::Type::HTTP_JSON: _upProvider = std::make_unique(); break; - case PowerMeterProvider::Type::SML: + case PowerMeterProvider::Type::SERIAL_SML: _upProvider = std::make_unique(); break; case PowerMeterProvider::Type::SMAHM2: _upProvider = std::make_unique(); break; - case PowerMeterProvider::Type::TIBBER: + case PowerMeterProvider::Type::HTTP_SML: _upProvider = std::make_unique(); break; } diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index c6cf373fb..92b56d898 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -128,7 +128,7 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) return; } - if (static_cast(root["source"].as()) == PowerMeterProvider::Type::HTTP) { + if (static_cast(root["source"].as()) == PowerMeterProvider::Type::HTTP_JSON) { JsonArray http_phases = root["http_phases"]; for (uint8_t i = 0; i < http_phases.size(); i++) { JsonObject phase = http_phases[i].as(); @@ -174,7 +174,7 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) } } - if (static_cast(root["source"].as()) == PowerMeterProvider::Type::TIBBER) { + if (static_cast(root["source"].as()) == PowerMeterProvider::Type::HTTP_SML) { JsonObject tibber = root["tibber"]; if (!tibber.containsKey("url") From 5cd6334880f9ece2ca060e365862b1cdf2f9f065 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Wed, 8 May 2024 13:09:05 +0200 Subject: [PATCH 048/140] powermeter refactor: avoid reboot on settings change the current power meter provider will be de-initialized, and a new instance will be initialized with the new settings. --- src/WebApi_powermeter.cpp | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index 92b56d898..4a4b13e67 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -226,12 +226,7 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); - - // reboot requiered as per https://github.com/helgeerbe/OpenDTU-OnBattery/issues/565#issuecomment-1872552559 - yield(); - delay(1000); - yield(); - ESP.restart(); + PowerMeter.updateSettings(); } void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request) From d4c07836d9ef793418f16b61e19860d2dcbc9b39 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Wed, 8 May 2024 13:12:43 +0200 Subject: [PATCH 049/140] MQTT powermeter: avoid iterating subscriptions instead of iterating a map with subscriptions, we now bind the target variable to the callback, which is executed once a message is arrived. this way, the target variable is already linked to the respective topic when the callback is executed. lock the mutex when writing the variable, as the MQTT callback is executed in a different context (MQTT task) than the main loop task, which otherwise accesses the variables. --- include/PowerMeterMqtt.h | 10 +++++---- src/PowerMeterMqtt.cpp | 48 ++++++++++++++++++++-------------------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/include/PowerMeterMqtt.h b/include/PowerMeterMqtt.h index 5dd01d2c4..3786fca6d 100644 --- a/include/PowerMeterMqtt.h +++ b/include/PowerMeterMqtt.h @@ -3,7 +3,7 @@ #include "PowerMeterProvider.h" #include -#include +#include #include class PowerMeterMqtt : public PowerMeterProvider { @@ -15,14 +15,16 @@ class PowerMeterMqtt : public PowerMeterProvider { void doMqttPublish() const final; private: - void onMqttMessage(const espMqttClientTypes::MessageProperties& properties, - const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total); + using MsgProperties = espMqttClientTypes::MessageProperties; + void onMessage(MsgProperties const& properties, char const* topic, + uint8_t const* payload, size_t len, size_t index, + size_t total, float* targetVariable); float _powerValueOne = 0; float _powerValueTwo = 0; float _powerValueThree = 0; - std::map _mqttSubscriptions; + std::vector _mqttSubscriptions; mutable std::mutex _mutex; }; diff --git a/src/PowerMeterMqtt.cpp b/src/PowerMeterMqtt.cpp index 9a470ccfa..1a74f41dd 100644 --- a/src/PowerMeterMqtt.cpp +++ b/src/PowerMeterMqtt.cpp @@ -6,15 +6,16 @@ bool PowerMeterMqtt::init() { - auto subscribe = [this](char const* topic, float* target) { + auto subscribe = [this](char const* topic, float* targetVariable) { if (strlen(topic) == 0) { return; } MqttSettings.subscribe(topic, 0, - std::bind(&PowerMeterMqtt::onMqttMessage, + std::bind(&PowerMeterMqtt::onMessage, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4, - std::placeholders::_5, std::placeholders::_6) + std::placeholders::_5, std::placeholders::_6, + targetVariable) ); - _mqttSubscriptions.try_emplace(topic, target); + _mqttSubscriptions.push_back(topic); }; auto const& config = Configuration.get(); @@ -27,32 +28,31 @@ bool PowerMeterMqtt::init() void PowerMeterMqtt::deinit() { - for (auto const& s: _mqttSubscriptions) { MqttSettings.unsubscribe(s.first); } + for (auto const& t: _mqttSubscriptions) { MqttSettings.unsubscribe(t); } _mqttSubscriptions.clear(); } -void PowerMeterMqtt::onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) +void PowerMeterMqtt::onMessage(PowerMeterMqtt::MsgProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, + size_t total, float* targetVariable) { - for (auto const& subscription: _mqttSubscriptions) { - if (subscription.first != topic) { continue; } - - std::string value(reinterpret_cast(payload), len); - try { - *subscription.second = std::stof(value); - } - catch(std::invalid_argument const& e) { - MessageOutput.printf("[PowerMeterMqtt] cannot parse payload of topic '%s' as float: %s\r\n", - topic, value.c_str()); - return; - } - - if (_verboseLogging) { - MessageOutput.printf("[PowerMeterMqtt] Updated from '%s', TotalPower: %5.2f\r\n", - topic, getPowerTotal()); - } + std::string value(reinterpret_cast(payload), len); + try { + std::lock_guard l(_mutex); + *targetVariable = std::stof(value); + } + catch (std::invalid_argument const& e) { + MessageOutput.printf("[PowerMeterMqtt] cannot parse payload of topic " + "'%s' as float: %s\r\n", topic, value.c_str()); + return; + } - gotUpdate(); + if (_verboseLogging) { + MessageOutput.printf("[PowerMeterMqtt] Updated from '%s', TotalPower: %5.2f\r\n", + topic, getPowerTotal()); } + + gotUpdate(); } float PowerMeterMqtt::getPowerTotal() const From 9eb4f1714cff8b3b152c4267d7af05f127ae6ec8 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Wed, 8 May 2024 13:19:16 +0200 Subject: [PATCH 050/140] powermeter refactor: make timestamp of last update atomic the timestamp is potentially updated from a different thread, e.g., MQTT task, than the main loop, which typically reads that timestamp. --- include/PowerMeterProvider.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/include/PowerMeterProvider.h b/include/PowerMeterProvider.h index d3e02f805..8b9225aba 100644 --- a/include/PowerMeterProvider.h +++ b/include/PowerMeterProvider.h @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later #pragma once +#include #include "Configuration.h" class PowerMeterProvider { @@ -41,6 +42,9 @@ class PowerMeterProvider { private: virtual void doMqttPublish() const = 0; - uint32_t _lastUpdate = 0; + // gotUpdate() updates this variable potentially from a different thread + // than users that request to read this variable through getLastUpdate(). + std::atomic _lastUpdate = 0; + mutable uint32_t _lastMqttPublish = 0; }; From d99cfd5b31f96f0c97f44e9a41b569404c5e69b0 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Wed, 8 May 2024 13:50:58 +0200 Subject: [PATCH 051/140] powermeter refactor: publish values to MQTT in base class "powertotal" is always published and it is published by the base class directly. other values are still published by the derived classes, but use a base class method, which takes care that a common base topic is used in particular. --- include/PowerMeterProvider.h | 2 ++ src/PowerMeterHttpJson.cpp | 11 +++-------- src/PowerMeterHttpSml.cpp | 5 ----- src/PowerMeterMqtt.cpp | 10 +++------- src/PowerMeterProvider.cpp | 7 +++++++ src/PowerMeterSerialSdm.cpp | 21 ++++++++------------- src/PowerMeterSerialSml.cpp | 8 ++------ src/PowerMeterUdpSmaHomeManager.cpp | 10 +++------- 8 files changed, 28 insertions(+), 46 deletions(-) diff --git a/include/PowerMeterProvider.h b/include/PowerMeterProvider.h index 8b9225aba..177041352 100644 --- a/include/PowerMeterProvider.h +++ b/include/PowerMeterProvider.h @@ -37,6 +37,8 @@ class PowerMeterProvider { void gotUpdate() { _lastUpdate = millis(); } + void mqttPublish(String const& topic, float const& value) const; + bool _verboseLogging; private: diff --git a/src/PowerMeterHttpJson.cpp b/src/PowerMeterHttpJson.cpp index bf13ca259..c8d95bd3a 100644 --- a/src/PowerMeterHttpJson.cpp +++ b/src/PowerMeterHttpJson.cpp @@ -2,7 +2,6 @@ #include "Configuration.h" #include "PowerMeterHttpJson.h" #include "MessageOutput.h" -#include "MqttSettings.h" #include #include #include "mbedtls/sha256.h" @@ -57,13 +56,9 @@ float PowerMeterHttpJson::getPowerTotal() const void PowerMeterHttpJson::doMqttPublish() const { - String topic = "powermeter"; - auto power = getPowerTotal(); - - MqttSettings.publish(topic + "/power1", String(_powerValues[0])); - MqttSettings.publish(topic + "/power2", String(_powerValues[1])); - MqttSettings.publish(topic + "/power3", String(_powerValues[2])); - MqttSettings.publish(topic + "/powertotal", String(power)); + mqttPublish("power1", _powerValues[0]); + mqttPublish("power2", _powerValues[1]); + mqttPublish("power3", _powerValues[2]); } bool PowerMeterHttpJson::queryPhase(int phase, PowerMeterHttpConfig const& config) diff --git a/src/PowerMeterHttpSml.cpp b/src/PowerMeterHttpSml.cpp index e7462fdfe..f3d8e0c7c 100644 --- a/src/PowerMeterHttpSml.cpp +++ b/src/PowerMeterHttpSml.cpp @@ -2,7 +2,6 @@ #include "Configuration.h" #include "PowerMeterHttpSml.h" #include "MessageOutput.h" -#include "MqttSettings.h" #include #include #include @@ -15,10 +14,6 @@ float PowerMeterHttpSml::getPowerTotal() const void PowerMeterHttpSml::doMqttPublish() const { - String topic = "powermeter"; - - std::lock_guard l(_mutex); - MqttSettings.publish(topic + "/powertotal", String(_activePower)); } void PowerMeterHttpSml::loop() diff --git a/src/PowerMeterMqtt.cpp b/src/PowerMeterMqtt.cpp index 1a74f41dd..3204bf1fa 100644 --- a/src/PowerMeterMqtt.cpp +++ b/src/PowerMeterMqtt.cpp @@ -63,12 +63,8 @@ float PowerMeterMqtt::getPowerTotal() const void PowerMeterMqtt::doMqttPublish() const { - String topic = "powermeter"; - auto totalPower = getPowerTotal(); - std::lock_guard l(_mutex); - MqttSettings.publish(topic + "/power1", String(_powerValueOne)); - MqttSettings.publish(topic + "/power2", String(_powerValueTwo)); - MqttSettings.publish(topic + "/power3", String(_powerValueThree)); - MqttSettings.publish(topic + "/powertotal", String(totalPower)); + mqttPublish("power1", _powerValueOne); + mqttPublish("power2", _powerValueTwo); + mqttPublish("power3", _powerValueThree); } diff --git a/src/PowerMeterProvider.cpp b/src/PowerMeterProvider.cpp index 8becdebd9..d1c7d6282 100644 --- a/src/PowerMeterProvider.cpp +++ b/src/PowerMeterProvider.cpp @@ -7,6 +7,11 @@ bool PowerMeterProvider::isDataValid() const return _lastUpdate > 0 && ((millis() - _lastUpdate) < (30 * 1000)); } +void PowerMeterProvider::mqttPublish(String const& topic, float const& value) const +{ + MqttSettings.publish("powermeter/" + topic, String(value)); +} + void PowerMeterProvider::mqttLoop() const { if (!MqttSettings.getConnected()) { return; } @@ -16,6 +21,8 @@ void PowerMeterProvider::mqttLoop() const auto constexpr halfOfAllMillis = std::numeric_limits::max() / 2; if ((_lastUpdate - _lastMqttPublish) > halfOfAllMillis) { return; } + mqttPublish("powertotal", getPowerTotal()); + doMqttPublish(); _lastMqttPublish = millis(); diff --git a/src/PowerMeterSerialSdm.cpp b/src/PowerMeterSerialSdm.cpp index d08cac194..f040c1734 100644 --- a/src/PowerMeterSerialSdm.cpp +++ b/src/PowerMeterSerialSdm.cpp @@ -3,7 +3,6 @@ #include "Configuration.h" #include "PinMapping.h" #include "MessageOutput.h" -#include "MqttSettings.h" #include "SerialPortManager.h" void PowerMeterSerialSdm::deinit() @@ -47,19 +46,15 @@ float PowerMeterSerialSdm::getPowerTotal() const void PowerMeterSerialSdm::doMqttPublish() const { - String topic = "powermeter"; - auto power = getPowerTotal(); - std::lock_guard l(_mutex); - MqttSettings.publish(topic + "/power1", String(_phase1Power)); - MqttSettings.publish(topic + "/power2", String(_phase2Power)); - MqttSettings.publish(topic + "/power3", String(_phase3Power)); - MqttSettings.publish(topic + "/powertotal", String(power)); - MqttSettings.publish(topic + "/voltage1", String(_phase1Voltage)); - MqttSettings.publish(topic + "/voltage2", String(_phase2Voltage)); - MqttSettings.publish(topic + "/voltage3", String(_phase3Voltage)); - MqttSettings.publish(topic + "/import", String(_energyImport)); - MqttSettings.publish(topic + "/export", String(_energyExport)); + mqttPublish("power1", _phase1Power); + mqttPublish("power2", _phase2Power); + mqttPublish("power3", _phase3Power); + mqttPublish("voltage1", _phase1Voltage); + mqttPublish("voltage2", _phase2Voltage); + mqttPublish("voltage3", _phase3Voltage); + mqttPublish("import", _energyImport); + mqttPublish("export", _energyExport); } void PowerMeterSerialSdm::loop() diff --git a/src/PowerMeterSerialSml.cpp b/src/PowerMeterSerialSml.cpp index 047925141..9236dcd75 100644 --- a/src/PowerMeterSerialSml.cpp +++ b/src/PowerMeterSerialSml.cpp @@ -3,7 +3,6 @@ #include "Configuration.h" #include "PinMapping.h" #include "MessageOutput.h" -#include "MqttSettings.h" bool PowerMeterSerialSml::init() { @@ -35,12 +34,9 @@ void PowerMeterSerialSml::deinit() void PowerMeterSerialSml::doMqttPublish() const { - String topic = "powermeter"; - std::lock_guard l(_mutex); - MqttSettings.publish(topic + "/powertotal", String(_activePower)); - MqttSettings.publish(topic + "/import", String(_energyImport)); - MqttSettings.publish(topic + "/export", String(_energyExport)); + mqttPublish("import", _energyImport); + mqttPublish("export", _energyExport); } void PowerMeterSerialSml::loop() diff --git a/src/PowerMeterUdpSmaHomeManager.cpp b/src/PowerMeterUdpSmaHomeManager.cpp index b79ae3d94..1347bfb43 100644 --- a/src/PowerMeterUdpSmaHomeManager.cpp +++ b/src/PowerMeterUdpSmaHomeManager.cpp @@ -4,7 +4,6 @@ */ #include "PowerMeterUdpSmaHomeManager.h" #include -#include "MqttSettings.h" #include #include "MessageOutput.h" @@ -37,12 +36,9 @@ void PowerMeterUdpSmaHomeManager::deinit() void PowerMeterUdpSmaHomeManager::doMqttPublish() const { - String topic = "powermeter"; - - MqttSettings.publish(topic + "/powertotal", String(_powerMeterPower)); - MqttSettings.publish(topic + "/power1", String(_powerMeterL1)); - MqttSettings.publish(topic + "/power2", String(_powerMeterL2)); - MqttSettings.publish(topic + "/power3", String(_powerMeterL3)); + mqttPublish("power1", _powerMeterL1); + mqttPublish("power2", _powerMeterL2); + mqttPublish("power3", _powerMeterL3); } uint8_t* PowerMeterUdpSmaHomeManager::decodeGroup(uint8_t* offset, uint16_t grouplen) From 54c04aed6139626f5de896020cc927e2b46acd96 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Thu, 9 May 2024 14:28:14 +0200 Subject: [PATCH 052/140] SDM power meter: remove baud rate setting this setting was not used. the baud rate for the SDM is set to 9600 in the source code. until the baud rate being customizable is actually required by somebody, we remove the setting altogether. --- include/Configuration.h | 1 - include/defaults.h | 1 - src/Configuration.cpp | 2 -- src/WebApi_powermeter.cpp | 2 -- webapp/src/locales/de.json | 1 - webapp/src/locales/en.json | 1 - webapp/src/types/PowerMeterConfig.ts | 1 - webapp/src/views/PowerMeterAdminView.vue | 10 ---------- 8 files changed, 19 deletions(-) diff --git a/include/Configuration.h b/include/Configuration.h index b0e286217..3c0c41a17 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -203,7 +203,6 @@ struct CONFIG_T { char MqttTopicPowerMeter1[MQTT_MAX_TOPIC_STRLEN + 1]; char MqttTopicPowerMeter2[MQTT_MAX_TOPIC_STRLEN + 1]; char MqttTopicPowerMeter3[MQTT_MAX_TOPIC_STRLEN + 1]; - uint32_t SdmBaudrate; uint32_t SdmAddress; uint32_t HttpInterval; bool HttpIndividualRequests; diff --git a/include/defaults.h b/include/defaults.h index 32d1e54cd..865e595c7 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -117,7 +117,6 @@ #define POWERMETER_ENABLED false #define POWERMETER_INTERVAL 10 #define POWERMETER_SOURCE 2 -#define POWERMETER_SDMBAUDRATE 9600 #define POWERMETER_SDMADDRESS 1 #define POWERLIMITER_ENABLED false diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 0749a1a2d..e899de9b2 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -155,7 +155,6 @@ bool ConfigurationClass::write() powermeter["mqtt_topic_powermeter_1"] = config.PowerMeter.MqttTopicPowerMeter1; powermeter["mqtt_topic_powermeter_2"] = config.PowerMeter.MqttTopicPowerMeter2; powermeter["mqtt_topic_powermeter_3"] = config.PowerMeter.MqttTopicPowerMeter3; - powermeter["sdmbaudrate"] = config.PowerMeter.SdmBaudrate; powermeter["sdmaddress"] = config.PowerMeter.SdmAddress; powermeter["http_individual_requests"] = config.PowerMeter.HttpIndividualRequests; @@ -422,7 +421,6 @@ bool ConfigurationClass::read() strlcpy(config.PowerMeter.MqttTopicPowerMeter1, powermeter["mqtt_topic_powermeter_1"] | "", sizeof(config.PowerMeter.MqttTopicPowerMeter1)); strlcpy(config.PowerMeter.MqttTopicPowerMeter2, powermeter["mqtt_topic_powermeter_2"] | "", sizeof(config.PowerMeter.MqttTopicPowerMeter2)); strlcpy(config.PowerMeter.MqttTopicPowerMeter3, powermeter["mqtt_topic_powermeter_3"] | "", sizeof(config.PowerMeter.MqttTopicPowerMeter3)); - config.PowerMeter.SdmBaudrate = powermeter["sdmbaudrate"] | POWERMETER_SDMBAUDRATE; config.PowerMeter.SdmAddress = powermeter["sdmaddress"] | POWERMETER_SDMADDRESS; config.PowerMeter.HttpIndividualRequests = powermeter["http_individual_requests"] | false; diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index 4a4b13e67..0e519dbc5 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -66,7 +66,6 @@ void WebApiPowerMeterClass::onStatus(AsyncWebServerRequest* request) root["mqtt_topic_powermeter_1"] = config.PowerMeter.MqttTopicPowerMeter1; root["mqtt_topic_powermeter_2"] = config.PowerMeter.MqttTopicPowerMeter2; root["mqtt_topic_powermeter_3"] = config.PowerMeter.MqttTopicPowerMeter3; - root["sdmbaudrate"] = config.PowerMeter.SdmBaudrate; root["sdmaddress"] = config.PowerMeter.SdmAddress; root["http_individual_requests"] = config.PowerMeter.HttpIndividualRequests; @@ -210,7 +209,6 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) strlcpy(config.PowerMeter.MqttTopicPowerMeter1, root["mqtt_topic_powermeter_1"].as().c_str(), sizeof(config.PowerMeter.MqttTopicPowerMeter1)); strlcpy(config.PowerMeter.MqttTopicPowerMeter2, root["mqtt_topic_powermeter_2"].as().c_str(), sizeof(config.PowerMeter.MqttTopicPowerMeter2)); strlcpy(config.PowerMeter.MqttTopicPowerMeter3, root["mqtt_topic_powermeter_3"].as().c_str(), sizeof(config.PowerMeter.MqttTopicPowerMeter3)); - config.PowerMeter.SdmBaudrate = root["sdmbaudrate"].as(); config.PowerMeter.SdmAddress = root["sdmaddress"].as(); config.PowerMeter.HttpIndividualRequests = root["http_individual_requests"].as(); diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index db78e1378..c7021b990 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -568,7 +568,6 @@ "MqttTopicPowerMeter2": "MQTT topic - Stromzähler #2 (Optional)", "MqttTopicPowerMeter3": "MQTT topic - Stromzähler #3 (Optional)", "SDM": "SDM-Stromzähler Konfiguration", - "sdmbaudrate": "Baudrate", "sdmaddress": "Modbus Adresse", "HTTP": "HTTP(S) + JSON - Allgemeine Konfiguration", "httpIndividualRequests": "Individuelle HTTP requests pro Phase", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 2e3dde417..4b7a3959f 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -570,7 +570,6 @@ "MqttTopicPowerMeter2": "MQTT topic - Power meter #2", "MqttTopicPowerMeter3": "MQTT topic - Power meter #3", "SDM": "SDM-Power Meter Parameter", - "sdmbaudrate": "Baudrate", "sdmaddress": "Modbus Address", "HTTP": "HTTP(S) + Json - General configuration", "httpIndividualRequests": "Individual HTTP requests per phase", diff --git a/webapp/src/types/PowerMeterConfig.ts b/webapp/src/types/PowerMeterConfig.ts index 972389294..1675d8d31 100644 --- a/webapp/src/types/PowerMeterConfig.ts +++ b/webapp/src/types/PowerMeterConfig.ts @@ -28,7 +28,6 @@ export interface PowerMeterConfig { mqtt_topic_powermeter_1: string; mqtt_topic_powermeter_2: string; mqtt_topic_powermeter_3: string; - sdmbaudrate: number; sdmaddress: number; http_individual_requests: boolean; http_phases: Array; diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index aff340171..fd600069c 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -74,16 +74,6 @@ :text="$t('powermeteradmin.SDM')" textVariant="text-bg-primary" add-space> -
- -
-
- -
-
-
-
From 6e44a6d750fb767a10d94828be22574c2c5bd4f7 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Thu, 9 May 2024 21:15:37 +0200 Subject: [PATCH 053/140] powermeter refactor: allow destruction of httpClient make sure the wifiClient used by the httpClient lives longer than the httpClient, as it accesses the pointer to the wifiClient in its destructor. --- include/PowerMeterHttpJson.h | 8 +++-- include/PowerMeterHttpSml.h | 8 +++-- src/PowerMeterHttpJson.cpp | 66 ++++++++++++++++++++---------------- src/PowerMeterHttpSml.cpp | 49 ++++++++++++++------------ 4 files changed, 76 insertions(+), 55 deletions(-) diff --git a/include/PowerMeterHttpJson.h b/include/PowerMeterHttpJson.h index 9e5482100..4b7c86759 100644 --- a/include/PowerMeterHttpJson.h +++ b/include/PowerMeterHttpJson.h @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later #pragma once +#include #include #include #include @@ -12,6 +13,8 @@ using Unit_t = PowerMeterHttpConfig::Unit; class PowerMeterHttpJson : public PowerMeterProvider { public: + ~PowerMeterHttpJson(); + bool init() final { return true; } void deinit() final { } void loop() final; @@ -25,10 +28,11 @@ class PowerMeterHttpJson : public PowerMeterProvider { uint32_t _lastPoll; std::array _cache; std::array _powerValues; - HTTPClient httpClient; + std::unique_ptr wifiClient; + std::unique_ptr httpClient; String httpResponse; - bool httpRequest(int phase, WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpConfig const& config); + bool httpRequest(int phase, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpConfig const& config); bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t, String& _base64Authorization); String extractParam(String& authReq, const String& param, const char delimit); String getcNonce(const int len); diff --git a/include/PowerMeterHttpSml.h b/include/PowerMeterHttpSml.h index 31b44244c..2d100bd31 100644 --- a/include/PowerMeterHttpSml.h +++ b/include/PowerMeterHttpSml.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -12,6 +13,8 @@ class PowerMeterHttpSml : public PowerMeterProvider { public: + ~PowerMeterHttpSml(); + bool init() final { return true; } void deinit() final { } void loop() final; @@ -38,9 +41,10 @@ class PowerMeterHttpSml : public PowerMeterProvider { {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_activePower} }; - HTTPClient httpClient; + std::unique_ptr wifiClient; + std::unique_ptr httpClient; String httpResponse; - bool httpRequest(WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterTibberConfig const& config); + bool httpRequest(const String& host, uint16_t port, const String& uri, bool https, PowerMeterTibberConfig const& config); bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t, String& _base64Authorization); void prepareRequest(uint32_t timeout); }; diff --git a/src/PowerMeterHttpJson.cpp b/src/PowerMeterHttpJson.cpp index c8d95bd3a..ea9d5bee8 100644 --- a/src/PowerMeterHttpJson.cpp +++ b/src/PowerMeterHttpJson.cpp @@ -6,9 +6,17 @@ #include #include "mbedtls/sha256.h" #include -#include #include +PowerMeterHttpJson::~PowerMeterHttpJson() +{ + // the wifiClient instance must live longer than the httpClient instance, + // as the httpClient holds a pointer to the wifiClient and uses it in its + // destructor. + httpClient.reset(); + wifiClient.reset(); +} + void PowerMeterHttpJson::loop() { auto const& config = Configuration.get(); @@ -66,7 +74,7 @@ bool PowerMeterHttpJson::queryPhase(int phase, PowerMeterHttpConfig const& confi //hostByName in WiFiGeneric fails to resolve local names. issue described in //https://github.com/espressif/arduino-esp32/issues/3822 //and in depth analyzed in https://github.com/espressif/esp-idf/issues/2507#issuecomment-761836300 - //in conclusion: we cannot rely on httpClient.begin(*wifiClient, url) to resolve IP adresses. + //in conclusion: we cannot rely on httpClient->begin(*wifiClient, url) to resolve IP adresses. //have to do it manually here. Feels Hacky... String protocol; String host; @@ -102,10 +110,6 @@ bool PowerMeterHttpJson::queryPhase(int phase, PowerMeterHttpConfig const& confi } } - // secureWifiClient MUST be created before HTTPClient - // see discussion: https://github.com/helgeerbe/OpenDTU-OnBattery/issues/381 - std::unique_ptr wifiClient; - bool https = protocol == "https"; if (https) { auto secureWifiClient = std::make_unique(); @@ -115,49 +119,51 @@ bool PowerMeterHttpJson::queryPhase(int phase, PowerMeterHttpConfig const& confi wifiClient = std::make_unique(); } - return httpRequest(phase, *wifiClient, ipaddr.toString(), port, uri, https, config); + return httpRequest(phase, ipaddr.toString(), port, uri, https, config); } -bool PowerMeterHttpJson::httpRequest(int phase, WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpConfig const& config) +bool PowerMeterHttpJson::httpRequest(int phase, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpConfig const& config) { - if(!httpClient.begin(wifiClient, host, port, uri, https)){ - snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("httpClient.begin() failed for %s://%s"), (https ? "https" : "http"), host.c_str()); + if (!httpClient) { httpClient = std::make_unique(); } + + if(!httpClient->begin(*wifiClient, host, port, uri, https)){ + snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("httpClient->begin() failed for %s://%s"), (https ? "https" : "http"), host.c_str()); return false; } prepareRequest(config.Timeout, config.HeaderKey, config.HeaderValue); if (config.AuthType == Auth_t::Digest) { const char *headers[1] = {"WWW-Authenticate"}; - httpClient.collectHeaders(headers, 1); + httpClient->collectHeaders(headers, 1); } else if (config.AuthType == Auth_t::Basic) { String authString = config.Username; authString += ":"; authString += config.Password; String auth = "Basic "; auth.concat(base64::encode(authString)); - httpClient.addHeader("Authorization", auth); + httpClient->addHeader("Authorization", auth); } - int httpCode = httpClient.GET(); + int httpCode = httpClient->GET(); if (httpCode == HTTP_CODE_UNAUTHORIZED && config.AuthType == Auth_t::Digest) { // Handle authentication challenge - if (httpClient.hasHeader("WWW-Authenticate")) { - String authReq = httpClient.header("WWW-Authenticate"); + if (httpClient->hasHeader("WWW-Authenticate")) { + String authReq = httpClient->header("WWW-Authenticate"); String authorization = getDigestAuth(authReq, String(config.Username), String(config.Password), "GET", String(uri), 1); - httpClient.end(); - if(!httpClient.begin(wifiClient, host, port, uri, https)){ - snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("httpClient.begin() failed for %s://%s using digest auth"), (https ? "https" : "http"), host.c_str()); + httpClient->end(); + if(!httpClient->begin(*wifiClient, host, port, uri, https)){ + snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("httpClient->begin() failed for %s://%s using digest auth"), (https ? "https" : "http"), host.c_str()); return false; } prepareRequest(config.Timeout, config.HeaderKey, config.HeaderValue); - httpClient.addHeader("Authorization", authorization); - httpCode = httpClient.GET(); + httpClient->addHeader("Authorization", authorization); + httpCode = httpClient->GET(); } } if (httpCode <= 0) { - snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("HTTP Error %s"), httpClient.errorToString(httpCode).c_str()); + snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("HTTP Error %s"), httpClient->errorToString(httpCode).c_str()); return false; } @@ -166,8 +172,8 @@ bool PowerMeterHttpJson::httpRequest(int phase, WiFiClient &wifiClient, const St return false; } - httpResponse = httpClient.getString(); // very unfortunate that we cannot parse WifiClient stream directly - httpClient.end(); + httpResponse = httpClient->getString(); // very unfortunate that we cannot parse WifiClient stream directly + httpClient->end(); // TODO(schlimmchen): postpone calling tryGetFloatValueForPhase, as it // will be called twice for each phase when doing separate requests. @@ -391,14 +397,14 @@ String PowerMeterHttpJson::sha256(const String& data) { } void PowerMeterHttpJson::prepareRequest(uint32_t timeout, const char* httpHeader, const char* httpValue) { - httpClient.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); - httpClient.setUserAgent("OpenDTU-OnBattery"); - httpClient.setConnectTimeout(timeout); - httpClient.setTimeout(timeout); - httpClient.addHeader("Content-Type", "application/json"); - httpClient.addHeader("Accept", "application/json"); + httpClient->setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + httpClient->setUserAgent("OpenDTU-OnBattery"); + httpClient->setConnectTimeout(timeout); + httpClient->setTimeout(timeout); + httpClient->addHeader("Content-Type", "application/json"); + httpClient->addHeader("Accept", "application/json"); if (strlen(httpHeader) > 0) { - httpClient.addHeader(httpHeader, httpValue); + httpClient->addHeader(httpHeader, httpValue); } } diff --git a/src/PowerMeterHttpSml.cpp b/src/PowerMeterHttpSml.cpp index f3d8e0c7c..a753ed7b2 100644 --- a/src/PowerMeterHttpSml.cpp +++ b/src/PowerMeterHttpSml.cpp @@ -16,6 +16,15 @@ void PowerMeterHttpSml::doMqttPublish() const { } +PowerMeterHttpSml::~PowerMeterHttpSml() +{ + // the wifiClient instance must live longer than the httpClient instance, + // as the httpClient holds a pointer to the wifiClient and uses it in its + // destructor. + httpClient.reset(); + wifiClient.reset(); +} + void PowerMeterHttpSml::loop() { auto const& config = Configuration.get(); @@ -38,7 +47,7 @@ bool PowerMeterHttpSml::query(PowerMeterTibberConfig const& config) //hostByName in WiFiGeneric fails to resolve local names. issue described in //https://github.com/espressif/arduino-esp32/issues/3822 //and in depth analyzed in https://github.com/espressif/esp-idf/issues/2507#issuecomment-761836300 - //in conclusion: we cannot rely on httpClient.begin(*wifiClient, url) to resolve IP adresses. + //in conclusion: we cannot rely on httpClient->begin(*wifiClient, url) to resolve IP adresses. //have to do it manually here. Feels Hacky... String protocol; String host; @@ -74,10 +83,6 @@ bool PowerMeterHttpSml::query(PowerMeterTibberConfig const& config) } } - // secureWifiClient MUST be created before HTTPClient - // see discussion: https://github.com/helgeerbe/OpenDTU-OnBattery/issues/381 - std::unique_ptr wifiClient; - bool https = protocol == "https"; if (https) { auto secureWifiClient = std::make_unique(); @@ -87,13 +92,15 @@ bool PowerMeterHttpSml::query(PowerMeterTibberConfig const& config) wifiClient = std::make_unique(); } - return httpRequest(*wifiClient, ipaddr.toString(), port, uri, https, config); + return httpRequest(ipaddr.toString(), port, uri, https, config); } -bool PowerMeterHttpSml::httpRequest(WiFiClient &wifiClient, const String& host, uint16_t port, const String& uri, bool https, PowerMeterTibberConfig const& config) +bool PowerMeterHttpSml::httpRequest(const String& host, uint16_t port, const String& uri, bool https, PowerMeterTibberConfig const& config) { - if(!httpClient.begin(wifiClient, host, port, uri, https)){ - snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("httpClient.begin() failed for %s://%s"), (https ? "https" : "http"), host.c_str()); + if (!httpClient) { httpClient = std::make_unique(); } + + if(!httpClient->begin(*wifiClient, host, port, uri, https)){ + snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("httpClient->begin() failed for %s://%s"), (https ? "https" : "http"), host.c_str()); return false; } @@ -104,12 +111,12 @@ bool PowerMeterHttpSml::httpRequest(WiFiClient &wifiClient, const String& host, authString += config.Password; String auth = "Basic "; auth.concat(base64::encode(authString)); - httpClient.addHeader("Authorization", auth); + httpClient->addHeader("Authorization", auth); - int httpCode = httpClient.GET(); + int httpCode = httpClient->GET(); if (httpCode <= 0) { - snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("HTTP Error %s"), httpClient.errorToString(httpCode).c_str()); + snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("HTTP Error %s"), httpClient->errorToString(httpCode).c_str()); return false; } @@ -118,9 +125,9 @@ bool PowerMeterHttpSml::httpRequest(WiFiClient &wifiClient, const String& host, return false; } - while (httpClient.getStream().available()) { + while (httpClient->getStream().available()) { double readVal = 0; - unsigned char smlCurrentChar = httpClient.getStream().read(); + unsigned char smlCurrentChar = httpClient->getStream().read(); sml_states_t smlCurrentState = smlState(smlCurrentChar); if (smlCurrentState == SML_LISTEND) { for (auto& handler: smlHandlerList) { @@ -133,7 +140,7 @@ bool PowerMeterHttpSml::httpRequest(WiFiClient &wifiClient, const String& host, } } } - httpClient.end(); + httpClient->end(); return true; } @@ -190,10 +197,10 @@ bool PowerMeterHttpSml::extractUrlComponents(String url, String& _protocol, Stri } void PowerMeterHttpSml::prepareRequest(uint32_t timeout) { - httpClient.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); - httpClient.setUserAgent("OpenDTU-OnBattery"); - httpClient.setConnectTimeout(timeout); - httpClient.setTimeout(timeout); - httpClient.addHeader("Content-Type", "application/json"); - httpClient.addHeader("Accept", "application/json"); + httpClient->setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + httpClient->setUserAgent("OpenDTU-OnBattery"); + httpClient->setConnectTimeout(timeout); + httpClient->setTimeout(timeout); + httpClient->addHeader("Content-Type", "application/json"); + httpClient->addHeader("Accept", "application/json"); } From 6108d24795dfbf9f8015c87dcde7dea696beca28 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Thu, 9 May 2024 21:28:26 +0200 Subject: [PATCH 054/140] powermeter refactor: introduce PowerMeterSml this new class handles SML data. it uses the SML lib to decode values and manages those. this de-duplicates code as the class is applicable to all power meters that collect SML data. --- include/PowerMeterHttpSml.h | 23 ++------------------ include/PowerMeterSerialSml.h | 28 ++---------------------- include/PowerMeterSml.h | 40 +++++++++++++++++++++++++++++++++++ src/PowerMeterHttpSml.cpp | 27 +++-------------------- src/PowerMeterSerialSml.cpp | 23 +------------------- src/PowerMeterSml.cpp | 40 +++++++++++++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 93 deletions(-) create mode 100644 include/PowerMeterSml.h create mode 100644 src/PowerMeterSml.cpp diff --git a/include/PowerMeterHttpSml.h b/include/PowerMeterHttpSml.h index 2d100bd31..b27cd4492 100644 --- a/include/PowerMeterHttpSml.h +++ b/include/PowerMeterHttpSml.h @@ -1,46 +1,27 @@ // SPDX-License-Identifier: GPL-2.0-or-later #pragma once -#include -#include #include #include #include #include #include "Configuration.h" -#include "PowerMeterProvider.h" -#include "sml.h" +#include "PowerMeterSml.h" -class PowerMeterHttpSml : public PowerMeterProvider { +class PowerMeterHttpSml : public PowerMeterSml { public: ~PowerMeterHttpSml(); bool init() final { return true; } void deinit() final { } void loop() final; - float getPowerTotal() const final; - void doMqttPublish() const final; bool updateValues(); char tibberPowerMeterError[256]; bool query(PowerMeterTibberConfig const& config); private: - mutable std::mutex _mutex; - uint32_t _lastPoll = 0; - float _activePower = 0.0; - - typedef struct { - const unsigned char OBIS[6]; - void (*Fn)(double&); - float* Arg; - } OBISHandler; - - const std::list smlHandlerList{ - {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_activePower} - }; - std::unique_ptr wifiClient; std::unique_ptr httpClient; String httpResponse; diff --git a/include/PowerMeterSerialSml.h b/include/PowerMeterSerialSml.h index 31a904843..1dbf87d2a 100644 --- a/include/PowerMeterSerialSml.h +++ b/include/PowerMeterSerialSml.h @@ -1,39 +1,15 @@ // SPDX-License-Identifier: GPL-2.0-or-later #pragma once -#include "PowerMeterProvider.h" -#include "Configuration.h" -#include "sml.h" +#include "PowerMeterSml.h" #include -#include -#include -class PowerMeterSerialSml : public PowerMeterProvider { +class PowerMeterSerialSml : public PowerMeterSml { public: bool init() final; void deinit() final; void loop() final; - float getPowerTotal() const final { return _activePower; } - void doMqttPublish() const final; private: - float _activePower = 0.0; - float _energyImport = 0.0; - float _energyExport = 0.0; - - mutable std::mutex _mutex; - std::unique_ptr _upSmlSerial = nullptr; - - typedef struct { - const unsigned char OBIS[6]; - void (*Fn)(double&); - float* Arg; - } OBISHandler; - - const std::list smlHandlerList{ - {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_activePower}, - {{0x01, 0x00, 0x01, 0x08, 0x00, 0xff}, &smlOBISWh, &_energyImport}, - {{0x01, 0x00, 0x02, 0x08, 0x00, 0xff}, &smlOBISWh, &_energyExport} - }; }; diff --git a/include/PowerMeterSml.h b/include/PowerMeterSml.h new file mode 100644 index 000000000..e006f69f4 --- /dev/null +++ b/include/PowerMeterSml.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include +#include +#include +#include +#include "Configuration.h" +#include "PowerMeterProvider.h" +#include "sml.h" + +class PowerMeterSml : public PowerMeterProvider { +public: + float getPowerTotal() const final; + void doMqttPublish() const final; + +protected: + void processSmlByte(uint8_t byte); + +private: + mutable std::mutex _mutex; + + float _activePower = 0.0; + float _energyImport = 0.0; + float _energyExport = 0.0; + + typedef struct { + uint8_t const OBIS[6]; + void (*decoder)(double&); + float* target; + char const* name; + } OBISHandler; + + const std::list smlHandlerList{ + {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_activePower, "active power"}, + {{0x01, 0x00, 0x01, 0x08, 0x00, 0xff}, &smlOBISWh, &_energyImport, "energy import"}, + {{0x01, 0x00, 0x02, 0x08, 0x00, 0xff}, &smlOBISWh, &_energyExport, "energy export"} + }; +}; diff --git a/src/PowerMeterHttpSml.cpp b/src/PowerMeterHttpSml.cpp index a753ed7b2..5e0df2c29 100644 --- a/src/PowerMeterHttpSml.cpp +++ b/src/PowerMeterHttpSml.cpp @@ -6,16 +6,6 @@ #include #include -float PowerMeterHttpSml::getPowerTotal() const -{ - std::lock_guard l(_mutex); - return _activePower; -} - -void PowerMeterHttpSml::doMqttPublish() const -{ -} - PowerMeterHttpSml::~PowerMeterHttpSml() { // the wifiClient instance must live longer than the httpClient instance, @@ -125,20 +115,9 @@ bool PowerMeterHttpSml::httpRequest(const String& host, uint16_t port, const Str return false; } - while (httpClient->getStream().available()) { - double readVal = 0; - unsigned char smlCurrentChar = httpClient->getStream().read(); - sml_states_t smlCurrentState = smlState(smlCurrentChar); - if (smlCurrentState == SML_LISTEND) { - for (auto& handler: smlHandlerList) { - if (smlOBISCheck(handler.OBIS)) { - std::lock_guard l(_mutex); - handler.Fn(readVal); - *handler.Arg = readVal; - gotUpdate(); - } - } - } + auto& stream = httpClient->getStream(); + while (stream.available()) { + processSmlByte(stream.read()); } httpClient->end(); diff --git a/src/PowerMeterSerialSml.cpp b/src/PowerMeterSerialSml.cpp index 9236dcd75..96580dbe4 100644 --- a/src/PowerMeterSerialSml.cpp +++ b/src/PowerMeterSerialSml.cpp @@ -1,6 +1,5 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "PowerMeterSerialSml.h" -#include "Configuration.h" #include "PinMapping.h" #include "MessageOutput.h" @@ -32,32 +31,12 @@ void PowerMeterSerialSml::deinit() _upSmlSerial->end(); } -void PowerMeterSerialSml::doMqttPublish() const -{ - std::lock_guard l(_mutex); - mqttPublish("import", _energyImport); - mqttPublish("export", _energyExport); -} - void PowerMeterSerialSml::loop() { if (!_upSmlSerial) { return; } while (_upSmlSerial->available()) { - double readVal = 0; - unsigned char smlCurrentChar = _upSmlSerial->read(); - sml_states_t smlCurrentState = smlState(smlCurrentChar); - if (smlCurrentState == SML_LISTEND) { - for (auto& handler: smlHandlerList) { - if (smlOBISCheck(handler.OBIS)) { - handler.Fn(readVal); - std::lock_guard l(_mutex); - *handler.Arg = readVal; - } - } - } else if (smlCurrentState == SML_FINAL) { - gotUpdate(); - } + processSmlByte(_upSmlSerial->read()); } MessageOutput.printf("[PowerMeterSerialSml]: TotalPower: %5.2f\r\n", getPowerTotal()); diff --git a/src/PowerMeterSml.cpp b/src/PowerMeterSml.cpp new file mode 100644 index 000000000..f3d619b8f --- /dev/null +++ b/src/PowerMeterSml.cpp @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "PowerMeterSml.h" +#include "MessageOutput.h" + +float PowerMeterSml::getPowerTotal() const +{ + std::lock_guard l(_mutex); + return _activePower; +} + +void PowerMeterSml::doMqttPublish() const +{ + std::lock_guard l(_mutex); + mqttPublish("import", _energyImport); + mqttPublish("export", _energyExport); +} + +void PowerMeterSml::processSmlByte(uint8_t byte) +{ + switch (smlState(byte)) { + case SML_LISTEND: + for (auto& handler: smlHandlerList) { + if (!smlOBISCheck(handler.OBIS)) { continue; } + + double helper; + handler.decoder(helper); + + std::lock_guard l(_mutex); + *handler.target = helper; + gotUpdate(); + + if (!_verboseLogging) { continue; } + MessageOutput.printf("[PowerMeterSml] decoded %s to %.2f\r\n", + handler.name, helper); + } + break; + default: + break; + } +} From 75c07c17f22621c0aa465baf8f8de100a8bd94bd Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Thu, 9 May 2024 21:46:59 +0200 Subject: [PATCH 055/140] powermeter refactor: SML lib: replace double by float avoid additional conversions and avoid double for the fact that calculations on type double are implemented in software, whereas float is handled in hardware on ESP32. --- include/PowerMeterSml.h | 2 +- lib/SMLParser/sml.cpp | 10 +++++----- lib/SMLParser/sml.h | 9 ++++----- src/PowerMeterSml.cpp | 13 ++++++------- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/include/PowerMeterSml.h b/include/PowerMeterSml.h index e006f69f4..fa6a32151 100644 --- a/include/PowerMeterSml.h +++ b/include/PowerMeterSml.h @@ -27,7 +27,7 @@ class PowerMeterSml : public PowerMeterProvider { typedef struct { uint8_t const OBIS[6]; - void (*decoder)(double&); + void (*decoder)(float&); float* target; char const* name; } OBISHandler; diff --git a/lib/SMLParser/sml.cpp b/lib/SMLParser/sml.cpp index 7a378f639..f18925940 100644 --- a/lib/SMLParser/sml.cpp +++ b/lib/SMLParser/sml.cpp @@ -317,7 +317,7 @@ void smlOBISManufacturer(unsigned char *str, int maxSize) } } -void smlPow(double &val, signed char &scaler) +void smlPow(float &val, signed char &scaler) { if (scaler < 0) { while (scaler++) { @@ -372,7 +372,7 @@ void smlOBISByUnit(long long int &val, signed char &scaler, sml_units_t unit) } } -void smlOBISWh(double &wh) +void smlOBISWh(float &wh) { long long int val; smlOBISByUnit(val, sc, SML_WATT_HOUR); @@ -380,7 +380,7 @@ void smlOBISWh(double &wh) smlPow(wh, sc); } -void smlOBISW(double &w) +void smlOBISW(float &w) { long long int val; smlOBISByUnit(val, sc, SML_WATT); @@ -388,7 +388,7 @@ void smlOBISW(double &w) smlPow(w, sc); } -void smlOBISVolt(double &v) +void smlOBISVolt(float &v) { long long int val; smlOBISByUnit(val, sc, SML_VOLT); @@ -396,7 +396,7 @@ void smlOBISVolt(double &v) smlPow(v, sc); } -void smlOBISAmpere(double &a) +void smlOBISAmpere(float &a) { long long int val; smlOBISByUnit(val, sc, SML_AMPERE); diff --git a/lib/SMLParser/sml.h b/lib/SMLParser/sml.h index ac6405dff..b837addad 100644 --- a/lib/SMLParser/sml.h +++ b/lib/SMLParser/sml.h @@ -97,10 +97,9 @@ bool smlOBISCheck(const unsigned char *obis); void smlOBISManufacturer(unsigned char *str, int maxSize); void smlOBISByUnit(long long int &wh, signed char &scaler, sml_units_t unit); -// Be aware that double on Arduino UNO is just 32 bit -void smlOBISWh(double &wh); -void smlOBISW(double &w); -void smlOBISVolt(double &v); -void smlOBISAmpere(double &a); +void smlOBISWh(float &wh); +void smlOBISW(float &w); +void smlOBISVolt(float &v); +void smlOBISAmpere(float &a); #endif diff --git a/src/PowerMeterSml.cpp b/src/PowerMeterSml.cpp index f3d619b8f..0c2f68936 100644 --- a/src/PowerMeterSml.cpp +++ b/src/PowerMeterSml.cpp @@ -22,16 +22,15 @@ void PowerMeterSml::processSmlByte(uint8_t byte) for (auto& handler: smlHandlerList) { if (!smlOBISCheck(handler.OBIS)) { continue; } - double helper; - handler.decoder(helper); + gotUpdate(); std::lock_guard l(_mutex); - *handler.target = helper; - gotUpdate(); + handler.decoder(*handler.target); - if (!_verboseLogging) { continue; } - MessageOutput.printf("[PowerMeterSml] decoded %s to %.2f\r\n", - handler.name, helper); + if (_verboseLogging) { + MessageOutput.printf("[PowerMeterSml] decoded %s to %.2f\r\n", + handler.name, *handler.target); + } } break; default: From e78f5849c1e472d9477077d212f0ecc83397944b Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Thu, 9 May 2024 22:10:15 +0200 Subject: [PATCH 056/140] Feature: decode more OBIS values in SML power meters supersedes #951. --- include/PowerMeterSml.h | 22 ++++++++++++++++++++-- src/PowerMeterSml.cpp | 11 ++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/include/PowerMeterSml.h b/include/PowerMeterSml.h index fa6a32151..7a5999726 100644 --- a/include/PowerMeterSml.h +++ b/include/PowerMeterSml.h @@ -21,7 +21,16 @@ class PowerMeterSml : public PowerMeterProvider { private: mutable std::mutex _mutex; - float _activePower = 0.0; + float _activePowerTotal = 0.0; + float _activePowerL1 = 0.0; + float _activePowerL2 = 0.0; + float _activePowerL3 = 0.0; + float _voltageL1 = 0.0; + float _voltageL2 = 0.0; + float _voltageL3 = 0.0; + float _currentL1 = 0.0; + float _currentL2 = 0.0; + float _currentL3 = 0.0; float _energyImport = 0.0; float _energyExport = 0.0; @@ -33,7 +42,16 @@ class PowerMeterSml : public PowerMeterProvider { } OBISHandler; const std::list smlHandlerList{ - {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_activePower, "active power"}, + {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_activePowerTotal, "active power total"}, + {{0x01, 0x00, 0x24, 0x07, 0x00, 0xff}, &smlOBISW, &_activePowerL1, "active power L1"}, + {{0x01, 0x00, 0x38, 0x07, 0x00, 0xff}, &smlOBISW, &_activePowerL2, "active power L2"}, + {{0x01, 0x00, 0x4c, 0x07, 0x00, 0xff}, &smlOBISW, &_activePowerL3, "active power L3"}, + {{0x01, 0x00, 0x20, 0x07, 0x00, 0xff}, &smlOBISVolt, &_voltageL1, "voltage L1"}, + {{0x01, 0x00, 0x34, 0x07, 0x00, 0xff}, &smlOBISVolt, &_voltageL2, "voltage L2"}, + {{0x01, 0x00, 0x48, 0x07, 0x00, 0xff}, &smlOBISVolt, &_voltageL3, "voltage L3"}, + {{0x01, 0x00, 0x1f, 0x07, 0x00, 0xff}, &smlOBISAmpere, &_currentL1, "current L1"}, + {{0x01, 0x00, 0x33, 0x07, 0x00, 0xff}, &smlOBISAmpere, &_currentL2, "current L2"}, + {{0x01, 0x00, 0x47, 0x07, 0x00, 0xff}, &smlOBISAmpere, &_currentL3, "current L3"}, {{0x01, 0x00, 0x01, 0x08, 0x00, 0xff}, &smlOBISWh, &_energyImport, "energy import"}, {{0x01, 0x00, 0x02, 0x08, 0x00, 0xff}, &smlOBISWh, &_energyExport, "energy export"} }; diff --git a/src/PowerMeterSml.cpp b/src/PowerMeterSml.cpp index 0c2f68936..9f46fda2a 100644 --- a/src/PowerMeterSml.cpp +++ b/src/PowerMeterSml.cpp @@ -5,12 +5,21 @@ float PowerMeterSml::getPowerTotal() const { std::lock_guard l(_mutex); - return _activePower; + return _activePowerTotal; } void PowerMeterSml::doMqttPublish() const { std::lock_guard l(_mutex); + mqttPublish("power1", _activePowerL1); + mqttPublish("power2", _activePowerL2); + mqttPublish("power3", _activePowerL3); + mqttPublish("voltage1", _voltageL1); + mqttPublish("voltage2", _voltageL2); + mqttPublish("voltage3", _voltageL3); + mqttPublish("current1", _currentL1); + mqttPublish("current2", _currentL2); + mqttPublish("current3", _currentL3); mqttPublish("import", _energyImport); mqttPublish("export", _energyExport); } From 673b9f4fa8b83e30674994aa1a3293c2376e9722 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Thu, 9 May 2024 22:23:42 +0200 Subject: [PATCH 057/140] powermeter refactor: use destructors to de-initialize --- include/PowerMeterHttpJson.h | 1 - include/PowerMeterHttpSml.h | 1 - include/PowerMeterMqtt.h | 3 ++- include/PowerMeterProvider.h | 1 - include/PowerMeterSerialSdm.h | 3 ++- include/PowerMeterSerialSml.h | 3 ++- include/PowerMeterUdpSmaHomeManager.h | 3 ++- src/PowerMeter.cpp | 5 +---- src/PowerMeterMqtt.cpp | 2 +- src/PowerMeterSerialSdm.cpp | 2 +- src/PowerMeterSerialSml.cpp | 2 +- src/PowerMeterUdpSmaHomeManager.cpp | 2 +- 12 files changed, 13 insertions(+), 15 deletions(-) diff --git a/include/PowerMeterHttpJson.h b/include/PowerMeterHttpJson.h index 4b7c86759..08c113726 100644 --- a/include/PowerMeterHttpJson.h +++ b/include/PowerMeterHttpJson.h @@ -16,7 +16,6 @@ class PowerMeterHttpJson : public PowerMeterProvider { ~PowerMeterHttpJson(); bool init() final { return true; } - void deinit() final { } void loop() final; float getPowerTotal() const final; void doMqttPublish() const final; diff --git a/include/PowerMeterHttpSml.h b/include/PowerMeterHttpSml.h index b27cd4492..c6e46bdc5 100644 --- a/include/PowerMeterHttpSml.h +++ b/include/PowerMeterHttpSml.h @@ -13,7 +13,6 @@ class PowerMeterHttpSml : public PowerMeterSml { ~PowerMeterHttpSml(); bool init() final { return true; } - void deinit() final { } void loop() final; bool updateValues(); char tibberPowerMeterError[256]; diff --git a/include/PowerMeterMqtt.h b/include/PowerMeterMqtt.h index 3786fca6d..8880f4817 100644 --- a/include/PowerMeterMqtt.h +++ b/include/PowerMeterMqtt.h @@ -8,8 +8,9 @@ class PowerMeterMqtt : public PowerMeterProvider { public: + ~PowerMeterMqtt(); + bool init() final; - void deinit() final; void loop() final { } float getPowerTotal() const final; void doMqttPublish() const final; diff --git a/include/PowerMeterProvider.h b/include/PowerMeterProvider.h index 177041352..4cd1c888c 100644 --- a/include/PowerMeterProvider.h +++ b/include/PowerMeterProvider.h @@ -21,7 +21,6 @@ class PowerMeterProvider { // returns true if the provider is ready for use, false otherwise virtual bool init() = 0; - virtual void deinit() = 0; virtual void loop() = 0; virtual float getPowerTotal() const = 0; diff --git a/include/PowerMeterSerialSdm.h b/include/PowerMeterSerialSdm.h index 7e01c8f70..ff33bebdb 100644 --- a/include/PowerMeterSerialSdm.h +++ b/include/PowerMeterSerialSdm.h @@ -7,8 +7,9 @@ class PowerMeterSerialSdm : public PowerMeterProvider { public: + ~PowerMeterSerialSdm(); + bool init() final; - void deinit() final; void loop() final; float getPowerTotal() const final; void doMqttPublish() const final; diff --git a/include/PowerMeterSerialSml.h b/include/PowerMeterSerialSml.h index 1dbf87d2a..58e019611 100644 --- a/include/PowerMeterSerialSml.h +++ b/include/PowerMeterSerialSml.h @@ -6,8 +6,9 @@ class PowerMeterSerialSml : public PowerMeterSml { public: + ~PowerMeterSerialSml(); + bool init() final; - void deinit() final; void loop() final; private: diff --git a/include/PowerMeterUdpSmaHomeManager.h b/include/PowerMeterUdpSmaHomeManager.h index 34e47f4d5..5d4b3a8d3 100644 --- a/include/PowerMeterUdpSmaHomeManager.h +++ b/include/PowerMeterUdpSmaHomeManager.h @@ -9,8 +9,9 @@ class PowerMeterUdpSmaHomeManager : public PowerMeterProvider { public: + ~PowerMeterUdpSmaHomeManager(); + bool init() final; - void deinit() final; void loop() final; float getPowerTotal() const final { return _powerMeterPower; } void doMqttPublish() const final; diff --git a/src/PowerMeter.cpp b/src/PowerMeter.cpp index 37c3ab34f..472613186 100644 --- a/src/PowerMeter.cpp +++ b/src/PowerMeter.cpp @@ -24,10 +24,7 @@ void PowerMeterClass::updateSettings() { std::lock_guard l(_mutex); - if (_upProvider) { - _upProvider->deinit(); - _upProvider = nullptr; - } + if (_upProvider) { _upProvider.reset(); } auto const& config = Configuration.get(); diff --git a/src/PowerMeterMqtt.cpp b/src/PowerMeterMqtt.cpp index 3204bf1fa..48f6e4594 100644 --- a/src/PowerMeterMqtt.cpp +++ b/src/PowerMeterMqtt.cpp @@ -26,7 +26,7 @@ bool PowerMeterMqtt::init() return _mqttSubscriptions.size() > 0; } -void PowerMeterMqtt::deinit() +PowerMeterMqtt::~PowerMeterMqtt() { for (auto const& t: _mqttSubscriptions) { MqttSettings.unsubscribe(t); } _mqttSubscriptions.clear(); diff --git a/src/PowerMeterSerialSdm.cpp b/src/PowerMeterSerialSdm.cpp index f040c1734..1612bcbe1 100644 --- a/src/PowerMeterSerialSdm.cpp +++ b/src/PowerMeterSerialSdm.cpp @@ -5,7 +5,7 @@ #include "MessageOutput.h" #include "SerialPortManager.h" -void PowerMeterSerialSdm::deinit() +PowerMeterSerialSdm::~PowerMeterSerialSdm() { if (_upSdmSerial) { _upSdmSerial->end(); diff --git a/src/PowerMeterSerialSml.cpp b/src/PowerMeterSerialSml.cpp index 96580dbe4..5f5a64c18 100644 --- a/src/PowerMeterSerialSml.cpp +++ b/src/PowerMeterSerialSml.cpp @@ -25,7 +25,7 @@ bool PowerMeterSerialSml::init() return true; } -void PowerMeterSerialSml::deinit() +PowerMeterSerialSml::~PowerMeterSerialSml() { if (!_upSmlSerial) { return; } _upSmlSerial->end(); diff --git a/src/PowerMeterUdpSmaHomeManager.cpp b/src/PowerMeterUdpSmaHomeManager.cpp index 1347bfb43..2baa9c43a 100644 --- a/src/PowerMeterUdpSmaHomeManager.cpp +++ b/src/PowerMeterUdpSmaHomeManager.cpp @@ -29,7 +29,7 @@ bool PowerMeterUdpSmaHomeManager::init() return true; } -void PowerMeterUdpSmaHomeManager::deinit() +PowerMeterUdpSmaHomeManager::~PowerMeterUdpSmaHomeManager() { SMAUdp.stop(); } From ccba7d803668870dfcfdff10ea9271ff0c1ff12e Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Fri, 10 May 2024 14:50:02 +0200 Subject: [PATCH 058/140] move JSON path resolver to Utils class for re-use --- include/PowerMeterHttpJson.h | 1 + include/Utils.h | 5 +++ src/PowerMeterHttpJson.cpp | 74 +++------------------------------ src/Utils.cpp | 80 ++++++++++++++++++++++++++++++++++++ src/WebApi_powermeter.cpp | 2 +- 5 files changed, 93 insertions(+), 69 deletions(-) diff --git a/include/PowerMeterHttpJson.h b/include/PowerMeterHttpJson.h index 08c113726..692bd9826 100644 --- a/include/PowerMeterHttpJson.h +++ b/include/PowerMeterHttpJson.h @@ -22,6 +22,7 @@ class PowerMeterHttpJson : public PowerMeterProvider { bool queryPhase(int phase, PowerMeterHttpConfig const& config); char httpPowerMeterError[256]; + float getCached(size_t idx) { return _cache[idx]; } private: uint32_t _lastPoll; diff --git a/include/Utils.h b/include/Utils.h index f81e73180..a17b910a7 100644 --- a/include/Utils.h +++ b/include/Utils.h @@ -3,6 +3,7 @@ #include #include +#include class Utils { public: @@ -12,4 +13,8 @@ class Utils { static void restartDtu(); static bool checkJsonAlloc(const JsonDocument& doc, const char* function, const uint16_t line); static void removeAllFiles(); + + /* OpenDTU-OnBatter-specific utils go here: */ + template + static std::pair getJsonValueFromStringByPath(String const& jsonText, String const& path); }; diff --git a/src/PowerMeterHttpJson.cpp b/src/PowerMeterHttpJson.cpp index ea9d5bee8..73d1439e9 100644 --- a/src/PowerMeterHttpJson.cpp +++ b/src/PowerMeterHttpJson.cpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: GPL-2.0-or-later +#include "Utils.h" #include "Configuration.h" #include "PowerMeterHttpJson.h" #include "MessageOutput.h" @@ -237,79 +238,16 @@ String PowerMeterHttpJson::getDigestAuth(String& authReq, const String& username bool PowerMeterHttpJson::tryGetFloatValueForPhase(int phase, String jsonPath, Unit_t unit, bool signInverted) { - JsonDocument root; - const DeserializationError error = deserializeJson(root, httpResponse); - if (error) { + auto pathResolutionResult = Utils::getJsonValueFromStringByPath(httpResponse, jsonPath); + if (!pathResolutionResult.second.isEmpty()) { snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), - PSTR("[PowerMeterHttpJson] Unable to parse server response as JSON")); - return false; - } - - constexpr char delimiter = '/'; - int start = 0; - int end = jsonPath.indexOf(delimiter); - auto value = root.as(); - - auto getNext = [this, &value, &jsonPath, &start](String const& key) -> bool { - // handle double forward slashes and paths starting or ending with a slash - if (key.isEmpty()) { return true; } - - if (key[0] == '[' && key[key.length() - 1] == ']') { - if (!value.is()) { - snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), - PSTR("[HttpPowerMeter] Cannot access non-array JSON node " - "using array index '%s' (JSON path '%s', position %i)"), - key.c_str(), jsonPath.c_str(), start); - return false; - } - - auto idx = key.substring(1, key.length() - 1).toInt(); - value = value[idx]; - - if (value.isNull()) { - snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), - PSTR("[HttpPowerMeter] Unable to access JSON array " - "index %li (JSON path '%s', position %i)"), - idx, jsonPath.c_str(), start); - return false; - } - - return true; - } - - value = value[key]; - - if (value.isNull()) { - snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), - PSTR("[HttpPowerMeter] Unable to access JSON key " - "'%s' (JSON path '%s', position %i)"), - key.c_str(), jsonPath.c_str(), start); - return false; - } - - return true; - }; - - // NOTE: "Because ArduinoJson implements the Null Object Pattern, it is - // always safe to read the object: if the key doesn't exist, it returns an - // empty value." - while (end != -1) { - if (!getNext(jsonPath.substring(start, end))) { return false; } - start = end + 1; - end = jsonPath.indexOf(delimiter, start); - } - - if (!getNext(jsonPath.substring(start))) { return false; } - - if (!value.is()) { - snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), - PSTR("[PowerMeterHttpJson] not a float: '%s'"), - value.as().c_str()); + PSTR("[PowerMeterHttpJson] %s"), + pathResolutionResult.second.c_str()); return false; } // this value is supposed to be in Watts and positive if energy is consumed. - _cache[phase] = value.as(); + _cache[phase] = pathResolutionResult.first; switch (unit) { case Unit_t::MilliWatts: diff --git a/src/Utils.cpp b/src/Utils.cpp index 6abe4dd19..aa97455af 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -92,3 +92,83 @@ void Utils::removeAllFiles() file = root.getNextFileName(); } } + +/* OpenDTU-OnBatter-specific utils go here: */ +template +std::pair Utils::getJsonValueFromStringByPath(String const& jsonText, String const& path) +{ + JsonDocument root; + const DeserializationError error = deserializeJson(root, jsonText); + if (error) { + return { T(), "Unable to parse server response as JSON" }; + } + + size_t constexpr kErrBufferSize = 256; + char errBuffer[kErrBufferSize]; + constexpr char delimiter = '/'; + int start = 0; + int end = path.indexOf(delimiter); + auto value = root.as(); + + // NOTE: "Because ArduinoJson implements the Null Object Pattern, it is + // always safe to read the object: if the key doesn't exist, it returns an + // empty value." + auto getNext = [&](String const& key) -> bool { + // handle double forward slashes and paths starting or ending with a slash + if (key.isEmpty()) { return true; } + + if (key[0] == '[' && key[key.length() - 1] == ']') { + if (!value.is()) { + snprintf(errBuffer, kErrBufferSize, "Cannot access non-array " + "JSON node using array index '%s' (JSON path '%s', " + "position %i)", key.c_str(), path.c_str(), start); + return false; + } + + auto idx = key.substring(1, key.length() - 1).toInt(); + value = value[idx]; + + if (value.isNull()) { + snprintf(errBuffer, kErrBufferSize, "Unable to access JSON " + "array index %li (JSON path '%s', position %i)", + idx, path.c_str(), start); + return false; + } + + return true; + } + + value = value[key]; + + if (value.isNull()) { + snprintf(errBuffer, kErrBufferSize, "Unable to access JSON key " + "'%s' (JSON path '%s', position %i)", + key.c_str(), path.c_str(), start); + return false; + } + + return true; + }; + + while (end != -1) { + if (!getNext(path.substring(start, end))) { + return { T(), String(errBuffer) }; + } + start = end + 1; + end = path.indexOf(delimiter, start); + } + + if (!getNext(path.substring(start))) { + return { T(), String(errBuffer) }; + } + + if (!value.is()) { + snprintf(errBuffer, kErrBufferSize, "Value '%s' at JSON path '%s' is not " + "of the expected type", value.as().c_str(), path.c_str()); + return { T(), String(errBuffer) }; + } + + return { value.as(), "" }; +} + +template std::pair Utils::getJsonValueFromStringByPath(String const& jsonText, String const& path); diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index 0e519dbc5..bf60beea5 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -258,7 +258,7 @@ void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request) auto upMeter = std::make_unique(); if (upMeter->queryPhase(0/*phase*/, phaseConfig)) { retMsg["type"] = "success"; - snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", upMeter->getPowerTotal()); + snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", upMeter->getCached(0)); } else { snprintf_P(response, sizeof(response), "%s", upMeter->httpPowerMeterError); } From 297b149f8454d94ad4a870502580e1dc7042a3a5 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Sat, 11 May 2024 21:30:27 +0200 Subject: [PATCH 059/140] powermeter refactor: generalize HTTP request config the parameters to peform an HTTP request by the HTTP(S)+JSON power meter have been generalized by introducing a new config struct. this is now used for all values which the HTTP(S)+JSON power meter can retrieve, and also used by the HTTP+SML power meter implementation. we anticipate that other feature will use this config as well. generalizing also allows to share serialization and deserialization methods in the configuration handler and the web API handler, leading to de-duplication of code and reduced flash memory usage. a new web UI component is implemented to manage a set of HTTP request settings. --- include/Configuration.h | 68 ++++--- include/PowerMeterHttpJson.h | 12 +- include/PowerMeterHttpSml.h | 4 +- include/WebApi_powermeter.h | 6 +- include/defaults.h | 2 + src/Configuration.cpp | 120 +++++++---- src/PowerMeterHttpJson.cpp | 19 +- src/PowerMeterHttpSml.cpp | 8 +- src/WebApi_powermeter.cpp | 189 +++++++----------- webapp/src/components/HttpRequestSettings.vue | 77 +++++++ webapp/src/locales/de.json | 44 ++-- webapp/src/locales/en.json | 48 +++-- webapp/src/types/HttpRequestConfig.ts | 9 + webapp/src/types/PowerMeterConfig.ts | 23 +-- webapp/src/views/PowerMeterAdminView.vue | 162 +++++---------- 15 files changed, 415 insertions(+), 376 deletions(-) create mode 100644 webapp/src/components/HttpRequestSettings.vue create mode 100644 webapp/src/types/HttpRequestConfig.ts diff --git a/include/Configuration.h b/include/Configuration.h index 3c0c41a17..66fb48615 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -3,6 +3,7 @@ #include "PinMapping.h" #include +#include #define CONFIG_FILENAME "/config.json" #define CONFIG_VERSION 0x00011c00 // 0.1.28 // make sure to clean all after change @@ -30,14 +31,14 @@ #define DEV_MAX_MAPPING_NAME_STRLEN 63 -#define POWERMETER_MAX_PHASES 3 -#define POWERMETER_MAX_HTTP_URL_STRLEN 1024 -#define POWERMETER_MAX_USERNAME_STRLEN 64 -#define POWERMETER_MAX_PASSWORD_STRLEN 64 -#define POWERMETER_MAX_HTTP_HEADER_KEY_STRLEN 64 -#define POWERMETER_MAX_HTTP_HEADER_VALUE_STRLEN 256 -#define POWERMETER_MAX_HTTP_JSON_PATH_STRLEN 256 -#define POWERMETER_HTTP_TIMEOUT 1000 +#define HTTP_REQUEST_MAX_URL_STRLEN 1024 +#define HTTP_REQUEST_MAX_USERNAME_STRLEN 64 +#define HTTP_REQUEST_MAX_PASSWORD_STRLEN 64 +#define HTTP_REQUEST_MAX_HEADER_KEY_STRLEN 64 +#define HTTP_REQUEST_MAX_HEADER_VALUE_STRLEN 256 + +#define POWERMETER_HTTP_JSON_MAX_VALUES 3 +#define POWERMETER_HTTP_JSON_MAX_PATH_STRLEN 256 struct CHANNEL_CONFIG_T { uint16_t MaxChannelPower; @@ -61,30 +62,36 @@ struct INVERTER_CONFIG_T { CHANNEL_CONFIG_T channel[INV_MAX_CHAN_COUNT]; }; -struct POWERMETER_HTTP_PHASE_CONFIG_T { +struct HTTP_REQUEST_CONFIG_T { + char Url[HTTP_REQUEST_MAX_URL_STRLEN + 1]; + enum Auth { None, Basic, Digest }; - enum Unit { Watts = 0, MilliWatts = 1, KiloWatts = 2 }; - bool Enabled; - char Url[POWERMETER_MAX_HTTP_URL_STRLEN + 1]; Auth AuthType; - char Username[POWERMETER_MAX_USERNAME_STRLEN +1]; - char Password[POWERMETER_MAX_USERNAME_STRLEN +1]; - char HeaderKey[POWERMETER_MAX_HTTP_HEADER_KEY_STRLEN + 1]; - char HeaderValue[POWERMETER_MAX_HTTP_HEADER_VALUE_STRLEN + 1]; + + char Username[HTTP_REQUEST_MAX_USERNAME_STRLEN + 1]; + char Password[HTTP_REQUEST_MAX_PASSWORD_STRLEN + 1]; + char HeaderKey[HTTP_REQUEST_MAX_HEADER_KEY_STRLEN + 1]; + char HeaderValue[HTTP_REQUEST_MAX_HEADER_VALUE_STRLEN + 1]; uint16_t Timeout; - char JsonPath[POWERMETER_MAX_HTTP_JSON_PATH_STRLEN + 1]; +}; +using HttpRequestConfig = struct HTTP_REQUEST_CONFIG_T; + +struct POWERMETER_HTTP_JSON_CONFIG_T { + HttpRequestConfig HttpRequest; + bool Enabled; + char JsonPath[POWERMETER_HTTP_JSON_MAX_PATH_STRLEN + 1]; + + enum Unit { Watts = 0, MilliWatts = 1, KiloWatts = 2 }; Unit PowerUnit; + bool SignInverted; }; -using PowerMeterHttpConfig = struct POWERMETER_HTTP_PHASE_CONFIG_T; +using PowerMeterHttpJsonConfig = struct POWERMETER_HTTP_JSON_CONFIG_T; -struct POWERMETER_TIBBER_CONFIG_T { - char Url[POWERMETER_MAX_HTTP_URL_STRLEN + 1]; - char Username[POWERMETER_MAX_USERNAME_STRLEN + 1]; - char Password[POWERMETER_MAX_USERNAME_STRLEN + 1]; - uint16_t Timeout; +struct POWERMETER_HTTP_SML_CONFIG_T { + HttpRequestConfig HttpRequest; }; -using PowerMeterTibberConfig = struct POWERMETER_TIBBER_CONFIG_T; +using PowerMeterHttpSmlConfig = struct POWERMETER_HTTP_SML_CONFIG_T; struct CONFIG_T { struct { @@ -204,10 +211,9 @@ struct CONFIG_T { char MqttTopicPowerMeter2[MQTT_MAX_TOPIC_STRLEN + 1]; char MqttTopicPowerMeter3[MQTT_MAX_TOPIC_STRLEN + 1]; uint32_t SdmAddress; - uint32_t HttpInterval; bool HttpIndividualRequests; - PowerMeterHttpConfig Http_Phase[POWERMETER_MAX_PHASES]; - PowerMeterTibberConfig Tibber; + PowerMeterHttpJsonConfig HttpJson[POWERMETER_HTTP_JSON_MAX_VALUES]; + PowerMeterHttpSmlConfig HttpSml; } PowerMeter; struct { @@ -280,6 +286,14 @@ class ConfigurationClass { INVERTER_CONFIG_T* getFreeInverterSlot(); INVERTER_CONFIG_T* getInverterConfig(const uint64_t serial); void deleteInverterById(const uint8_t id); + + static void serializeHttpRequestConfig(HttpRequestConfig const& source, JsonObject& target); + static void serializePowerMeterHttpJsonConfig(PowerMeterHttpJsonConfig const& source, JsonObject& target); + static void serializePowerMeterHttpSmlConfig(PowerMeterHttpSmlConfig const& source, JsonObject& target); + + static void deserializeHttpRequestConfig(JsonObject const& source, HttpRequestConfig& target); + static void deserializePowerMeterHttpJsonConfig(JsonObject const& source, PowerMeterHttpJsonConfig& target); + static void deserializePowerMeterHttpSmlConfig(JsonObject const& source, PowerMeterHttpSmlConfig& target); }; extern ConfigurationClass Configuration; diff --git a/include/PowerMeterHttpJson.h b/include/PowerMeterHttpJson.h index 692bd9826..50bca8c5a 100644 --- a/include/PowerMeterHttpJson.h +++ b/include/PowerMeterHttpJson.h @@ -8,8 +8,8 @@ #include "Configuration.h" #include "PowerMeterProvider.h" -using Auth_t = PowerMeterHttpConfig::Auth; -using Unit_t = PowerMeterHttpConfig::Unit; +using Auth_t = HttpRequestConfig::Auth; +using Unit_t = PowerMeterHttpJsonConfig::Unit; class PowerMeterHttpJson : public PowerMeterProvider { public: @@ -20,19 +20,19 @@ class PowerMeterHttpJson : public PowerMeterProvider { float getPowerTotal() const final; void doMqttPublish() const final; - bool queryPhase(int phase, PowerMeterHttpConfig const& config); + bool queryValue(int phase, PowerMeterHttpJsonConfig const& config); char httpPowerMeterError[256]; float getCached(size_t idx) { return _cache[idx]; } private: uint32_t _lastPoll; - std::array _cache; - std::array _powerValues; + std::array _cache; + std::array _powerValues; std::unique_ptr wifiClient; std::unique_ptr httpClient; String httpResponse; - bool httpRequest(int phase, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpConfig const& config); + bool httpRequest(int phase, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpJsonConfig const& config); bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t, String& _base64Authorization); String extractParam(String& authReq, const String& param, const char delimit); String getcNonce(const int len); diff --git a/include/PowerMeterHttpSml.h b/include/PowerMeterHttpSml.h index c6e46bdc5..73bc882cd 100644 --- a/include/PowerMeterHttpSml.h +++ b/include/PowerMeterHttpSml.h @@ -16,7 +16,7 @@ class PowerMeterHttpSml : public PowerMeterSml { void loop() final; bool updateValues(); char tibberPowerMeterError[256]; - bool query(PowerMeterTibberConfig const& config); + bool query(HttpRequestConfig const& config); private: uint32_t _lastPoll = 0; @@ -24,7 +24,7 @@ class PowerMeterHttpSml : public PowerMeterSml { std::unique_ptr wifiClient; std::unique_ptr httpClient; String httpResponse; - bool httpRequest(const String& host, uint16_t port, const String& uri, bool https, PowerMeterTibberConfig const& config); + bool httpRequest(const String& host, uint16_t port, const String& uri, bool https, HttpRequestConfig const& config); bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t, String& _base64Authorization); void prepareRequest(uint32_t timeout); }; diff --git a/include/WebApi_powermeter.h b/include/WebApi_powermeter.h index 12e5afae6..3cfe2a2dc 100644 --- a/include/WebApi_powermeter.h +++ b/include/WebApi_powermeter.h @@ -14,10 +14,8 @@ class WebApiPowerMeterClass { void onStatus(AsyncWebServerRequest* request); void onAdminGet(AsyncWebServerRequest* request); void onAdminPost(AsyncWebServerRequest* request); - void decodeJsonPhaseConfig(JsonObject const& json, PowerMeterHttpConfig& config) const; - void decodeJsonTibberConfig(JsonObject const& json, PowerMeterTibberConfig& config) const; - void onTestHttpRequest(AsyncWebServerRequest* request); - void onTestTibberRequest(AsyncWebServerRequest* request); + void onTestHttpJsonRequest(AsyncWebServerRequest* request); + void onTestHttpSmlRequest(AsyncWebServerRequest* request); AsyncWebServer* _server; }; diff --git a/include/defaults.h b/include/defaults.h index 865e595c7..6b191cbd3 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -119,6 +119,8 @@ #define POWERMETER_SOURCE 2 #define POWERMETER_SDMADDRESS 1 +#define HTTP_REQUEST_TIMEOUT_MS 1000 + #define POWERLIMITER_ENABLED false #define POWERLIMITER_SOLAR_PASSTHROUGH_ENABLED true #define POWERLIMITER_SOLAR_PASSTHROUGH_LOSSES 3 diff --git a/src/Configuration.cpp b/src/Configuration.cpp index e899de9b2..ffe964597 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -6,7 +6,6 @@ #include "MessageOutput.h" #include "Utils.h" #include "defaults.h" -#include #include #include @@ -17,6 +16,33 @@ void ConfigurationClass::init() memset(&config, 0x0, sizeof(config)); } +void ConfigurationClass::serializeHttpRequestConfig(HttpRequestConfig const& source, JsonObject& target) +{ + JsonObject target_http_config = target["http_request"].to(); + target_http_config["url"] = source.Url; + target_http_config["auth_type"] = source.AuthType; + target_http_config["username"] = source.Username; + target_http_config["password"] = source.Password; + target_http_config["header_key"] = source.HeaderKey; + target_http_config["header_value"] = source.HeaderValue; + target_http_config["timeout"] = source.Timeout; +} + +void ConfigurationClass::serializePowerMeterHttpJsonConfig(PowerMeterHttpJsonConfig const& source, JsonObject& target) +{ + serializeHttpRequestConfig(source.HttpRequest, target); + + target["enabled"] = source.Enabled; + target["json_path"] = source.JsonPath; + target["unit"] = source.PowerUnit; + target["sign_inverted"] = source.SignInverted; +} + +void ConfigurationClass::serializePowerMeterHttpSmlConfig(PowerMeterHttpSmlConfig const& source, JsonObject& target) +{ + serializeHttpRequestConfig(source.HttpRequest, target); +} + bool ConfigurationClass::write() { File f = LittleFS.open(CONFIG_FILENAME, "w"); @@ -158,27 +184,14 @@ bool ConfigurationClass::write() powermeter["sdmaddress"] = config.PowerMeter.SdmAddress; powermeter["http_individual_requests"] = config.PowerMeter.HttpIndividualRequests; - JsonObject tibber = powermeter["tibber"].to(); - tibber["url"] = config.PowerMeter.Tibber.Url; - tibber["username"] = config.PowerMeter.Tibber.Username; - tibber["password"] = config.PowerMeter.Tibber.Password; - tibber["timeout"] = config.PowerMeter.Tibber.Timeout; - - JsonArray powermeter_http_phases = powermeter["http_phases"].to(); - for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { - JsonObject powermeter_phase = powermeter_http_phases.add(); - - powermeter_phase["enabled"] = config.PowerMeter.Http_Phase[i].Enabled; - powermeter_phase["url"] = config.PowerMeter.Http_Phase[i].Url; - powermeter_phase["auth_type"] = config.PowerMeter.Http_Phase[i].AuthType; - powermeter_phase["username"] = config.PowerMeter.Http_Phase[i].Username; - powermeter_phase["password"] = config.PowerMeter.Http_Phase[i].Password; - powermeter_phase["header_key"] = config.PowerMeter.Http_Phase[i].HeaderKey; - powermeter_phase["header_value"] = config.PowerMeter.Http_Phase[i].HeaderValue; - powermeter_phase["timeout"] = config.PowerMeter.Http_Phase[i].Timeout; - powermeter_phase["json_path"] = config.PowerMeter.Http_Phase[i].JsonPath; - powermeter_phase["unit"] = config.PowerMeter.Http_Phase[i].PowerUnit; - powermeter_phase["sign_inverted"] = config.PowerMeter.Http_Phase[i].SignInverted; + JsonObject powermeter_http_sml = powermeter["http_sml"].to(); + serializePowerMeterHttpSmlConfig(config.PowerMeter.HttpSml, powermeter_http_sml); + + JsonArray powermeter_http_json = powermeter["http_json"].to(); + for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) { + JsonObject powermeter_json_config = powermeter_http_json.add(); + serializePowerMeterHttpJsonConfig(config.PowerMeter.HttpJson[i], + powermeter_json_config); } JsonObject powerlimiter = doc["powerlimiter"].to(); @@ -246,6 +259,38 @@ bool ConfigurationClass::write() return true; } +void ConfigurationClass::deserializeHttpRequestConfig(JsonObject const& source, HttpRequestConfig& target) +{ + JsonObject source_http_config = source["http_request"]; + + // http request parameters of HTTP/JSON power meter were + // previously stored alongside other settings + if (source_http_config.isNull()) { source_http_config = source; } + + strlcpy(target.Url, source_http_config["url"] | "", sizeof(target.Url)); + target.AuthType = source_http_config["auth_type"] | HttpRequestConfig::Auth::None; + strlcpy(target.Username, source_http_config["username"] | "", sizeof(target.Username)); + strlcpy(target.Password, source_http_config["password"] | "", sizeof(target.Password)); + strlcpy(target.HeaderKey, source_http_config["header_key"] | "", sizeof(target.HeaderKey)); + strlcpy(target.HeaderValue, source_http_config["header_value"] | "", sizeof(target.HeaderValue)); + target.Timeout = source_http_config["timeout"] | HTTP_REQUEST_TIMEOUT_MS; +} + +void ConfigurationClass::deserializePowerMeterHttpJsonConfig(JsonObject const& source, PowerMeterHttpJsonConfig& target) +{ + deserializeHttpRequestConfig(source, target.HttpRequest); + + target.Enabled = source["enabled"] | false; + strlcpy(target.JsonPath, source["json_path"] | "", sizeof(target.JsonPath)); + target.PowerUnit = source["unit"] | PowerMeterHttpJsonConfig::Unit::Watts; + target.SignInverted = source["sign_inverted"] | false; +} + +void ConfigurationClass::deserializePowerMeterHttpSmlConfig(JsonObject const& source, PowerMeterHttpSmlConfig& target) +{ + deserializeHttpRequestConfig(source, target.HttpRequest); +} + bool ConfigurationClass::read() { File f = LittleFS.open(CONFIG_FILENAME, "r", false); @@ -424,27 +469,16 @@ bool ConfigurationClass::read() config.PowerMeter.SdmAddress = powermeter["sdmaddress"] | POWERMETER_SDMADDRESS; config.PowerMeter.HttpIndividualRequests = powermeter["http_individual_requests"] | false; - JsonObject tibber = powermeter["tibber"]; - strlcpy(config.PowerMeter.Tibber.Url, tibber["url"] | "", sizeof(config.PowerMeter.Tibber.Url)); - strlcpy(config.PowerMeter.Tibber.Username, tibber["username"] | "", sizeof(config.PowerMeter.Tibber.Username)); - strlcpy(config.PowerMeter.Tibber.Password, tibber["password"] | "", sizeof(config.PowerMeter.Tibber.Password)); - config.PowerMeter.Tibber.Timeout = tibber["timeout"] | POWERMETER_HTTP_TIMEOUT; - - JsonArray powermeter_http_phases = powermeter["http_phases"]; - for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { - JsonObject powermeter_phase = powermeter_http_phases[i].as(); - - config.PowerMeter.Http_Phase[i].Enabled = powermeter_phase["enabled"] | (i == 0); - strlcpy(config.PowerMeter.Http_Phase[i].Url, powermeter_phase["url"] | "", sizeof(config.PowerMeter.Http_Phase[i].Url)); - config.PowerMeter.Http_Phase[i].AuthType = powermeter_phase["auth_type"] | PowerMeterHttpConfig::Auth::None; - strlcpy(config.PowerMeter.Http_Phase[i].Username, powermeter_phase["username"] | "", sizeof(config.PowerMeter.Http_Phase[i].Username)); - strlcpy(config.PowerMeter.Http_Phase[i].Password, powermeter_phase["password"] | "", sizeof(config.PowerMeter.Http_Phase[i].Password)); - strlcpy(config.PowerMeter.Http_Phase[i].HeaderKey, powermeter_phase["header_key"] | "", sizeof(config.PowerMeter.Http_Phase[i].HeaderKey)); - strlcpy(config.PowerMeter.Http_Phase[i].HeaderValue, powermeter_phase["header_value"] | "", sizeof(config.PowerMeter.Http_Phase[i].HeaderValue)); - config.PowerMeter.Http_Phase[i].Timeout = powermeter_phase["timeout"] | POWERMETER_HTTP_TIMEOUT; - strlcpy(config.PowerMeter.Http_Phase[i].JsonPath, powermeter_phase["json_path"] | "", sizeof(config.PowerMeter.Http_Phase[i].JsonPath)); - config.PowerMeter.Http_Phase[i].PowerUnit = powermeter_phase["unit"] | PowerMeterHttpConfig::Unit::Watts; - config.PowerMeter.Http_Phase[i].SignInverted = powermeter_phase["sign_inverted"] | false; + JsonObject powermeter_sml = powermeter["http_sml"]; + deserializePowerMeterHttpSmlConfig(powermeter_sml, config.PowerMeter.HttpSml); + + JsonArray powermeter_http_json = powermeter["http_json"]; + if (powermeter_http_json.isNull()) { + powermeter_http_json = powermeter["http_phases"]; // http_phases is a legacy key + } + for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) { + JsonObject powermeter_json_config = powermeter_http_json[i].as(); + deserializePowerMeterHttpJsonConfig(powermeter_json_config, config.PowerMeter.HttpJson[i]); } JsonObject powerlimiter = doc["powerlimiter"]; diff --git a/src/PowerMeterHttpJson.cpp b/src/PowerMeterHttpJson.cpp index 73d1439e9..785775cc6 100644 --- a/src/PowerMeterHttpJson.cpp +++ b/src/PowerMeterHttpJson.cpp @@ -27,16 +27,16 @@ void PowerMeterHttpJson::loop() _lastPoll = millis(); - for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { - auto const& phaseConfig = config.PowerMeter.Http_Phase[i]; + for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) { + auto const& valueConfig = config.PowerMeter.HttpJson[i]; - if (!phaseConfig.Enabled) { + if (!valueConfig.Enabled) { _cache[i] = 0.0; continue; } if (i == 0 || config.PowerMeter.HttpIndividualRequests) { - if (!queryPhase(i, phaseConfig)) { + if (!queryValue(i, valueConfig)) { MessageOutput.printf("[PowerMeterHttpJson] Getting HTTP response for phase %d failed.\r\n", i + 1); MessageOutput.printf("%s\r\n", httpPowerMeterError); return; @@ -44,7 +44,7 @@ void PowerMeterHttpJson::loop() continue; } - if(!tryGetFloatValueForPhase(i, phaseConfig.JsonPath, phaseConfig.PowerUnit, phaseConfig.SignInverted)) { + if(!tryGetFloatValueForPhase(i, valueConfig.JsonPath, valueConfig.PowerUnit, valueConfig.SignInverted)) { MessageOutput.printf("[PowerMeterHttpJson] Reading power of phase %d (from JSON fetched with Phase 1 config) failed.\r\n", i + 1); MessageOutput.printf("%s\r\n", httpPowerMeterError); return; @@ -70,7 +70,7 @@ void PowerMeterHttpJson::doMqttPublish() const mqttPublish("power3", _powerValues[2]); } -bool PowerMeterHttpJson::queryPhase(int phase, PowerMeterHttpConfig const& config) +bool PowerMeterHttpJson::queryValue(int phase, PowerMeterHttpJsonConfig const& config) { //hostByName in WiFiGeneric fails to resolve local names. issue described in //https://github.com/espressif/arduino-esp32/issues/3822 @@ -82,7 +82,7 @@ bool PowerMeterHttpJson::queryPhase(int phase, PowerMeterHttpConfig const& confi String uri; String base64Authorization; uint16_t port; - extractUrlComponents(config.Url, protocol, host, uri, port, base64Authorization); + extractUrlComponents(config.HttpRequest.Url, protocol, host, uri, port, base64Authorization); IPAddress ipaddr((uint32_t)0); //first check if "host" is already an IP adress @@ -123,7 +123,7 @@ bool PowerMeterHttpJson::queryPhase(int phase, PowerMeterHttpConfig const& confi return httpRequest(phase, ipaddr.toString(), port, uri, https, config); } -bool PowerMeterHttpJson::httpRequest(int phase, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpConfig const& config) +bool PowerMeterHttpJson::httpRequest(int phase, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpJsonConfig const& powerMeterConfig) { if (!httpClient) { httpClient = std::make_unique(); } @@ -132,6 +132,7 @@ bool PowerMeterHttpJson::httpRequest(int phase, const String& host, uint16_t por return false; } + auto const& config = powerMeterConfig.HttpRequest; prepareRequest(config.Timeout, config.HeaderKey, config.HeaderValue); if (config.AuthType == Auth_t::Digest) { const char *headers[1] = {"WWW-Authenticate"}; @@ -178,7 +179,7 @@ bool PowerMeterHttpJson::httpRequest(int phase, const String& host, uint16_t por // TODO(schlimmchen): postpone calling tryGetFloatValueForPhase, as it // will be called twice for each phase when doing separate requests. - return tryGetFloatValueForPhase(phase, config.JsonPath, config.PowerUnit, config.SignInverted); + return tryGetFloatValueForPhase(phase, powerMeterConfig.JsonPath, powerMeterConfig.PowerUnit, powerMeterConfig.SignInverted); } String PowerMeterHttpJson::extractParam(String& authReq, const String& param, const char delimit) { diff --git a/src/PowerMeterHttpSml.cpp b/src/PowerMeterHttpSml.cpp index 5e0df2c29..17750bba6 100644 --- a/src/PowerMeterHttpSml.cpp +++ b/src/PowerMeterHttpSml.cpp @@ -24,15 +24,13 @@ void PowerMeterHttpSml::loop() _lastPoll = millis(); - auto const& tibberConfig = config.PowerMeter.Tibber; - - if (!query(tibberConfig)) { + if (!query(config.PowerMeter.HttpSml.HttpRequest)) { MessageOutput.printf("[PowerMeterHttpSml] Getting the power value failed.\r\n"); MessageOutput.printf("%s\r\n", tibberPowerMeterError); } } -bool PowerMeterHttpSml::query(PowerMeterTibberConfig const& config) +bool PowerMeterHttpSml::query(HttpRequestConfig const& config) { //hostByName in WiFiGeneric fails to resolve local names. issue described in //https://github.com/espressif/arduino-esp32/issues/3822 @@ -85,7 +83,7 @@ bool PowerMeterHttpSml::query(PowerMeterTibberConfig const& config) return httpRequest(ipaddr.toString(), port, uri, https, config); } -bool PowerMeterHttpSml::httpRequest(const String& host, uint16_t port, const String& uri, bool https, PowerMeterTibberConfig const& config) +bool PowerMeterHttpSml::httpRequest(const String& host, uint16_t port, const String& uri, bool https, HttpRequestConfig const& config) { if (!httpClient) { httpClient = std::make_unique(); } diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index bf60beea5..51f373535 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -26,31 +26,8 @@ void WebApiPowerMeterClass::init(AsyncWebServer& server, Scheduler& scheduler) _server->on("/api/powermeter/status", HTTP_GET, std::bind(&WebApiPowerMeterClass::onStatus, this, _1)); _server->on("/api/powermeter/config", HTTP_GET, std::bind(&WebApiPowerMeterClass::onAdminGet, this, _1)); _server->on("/api/powermeter/config", HTTP_POST, std::bind(&WebApiPowerMeterClass::onAdminPost, this, _1)); - _server->on("/api/powermeter/testhttprequest", HTTP_POST, std::bind(&WebApiPowerMeterClass::onTestHttpRequest, this, _1)); - _server->on("/api/powermeter/testtibberrequest", HTTP_POST, std::bind(&WebApiPowerMeterClass::onTestTibberRequest, this, _1)); -} - -void WebApiPowerMeterClass::decodeJsonPhaseConfig(JsonObject const& json, PowerMeterHttpConfig& config) const -{ - config.Enabled = json["enabled"].as(); - strlcpy(config.Url, json["url"].as().c_str(), sizeof(config.Url)); - config.AuthType = json["auth_type"].as(); - strlcpy(config.Username, json["username"].as().c_str(), sizeof(config.Username)); - strlcpy(config.Password, json["password"].as().c_str(), sizeof(config.Password)); - strlcpy(config.HeaderKey, json["header_key"].as().c_str(), sizeof(config.HeaderKey)); - strlcpy(config.HeaderValue, json["header_value"].as().c_str(), sizeof(config.HeaderValue)); - config.Timeout = json["timeout"].as(); - strlcpy(config.JsonPath, json["json_path"].as().c_str(), sizeof(config.JsonPath)); - config.PowerUnit = json["unit"].as(); - config.SignInverted = json["sign_inverted"].as(); -} - -void WebApiPowerMeterClass::decodeJsonTibberConfig(JsonObject const& json, PowerMeterTibberConfig& config) const -{ - strlcpy(config.Url, json["url"].as().c_str(), sizeof(config.Url)); - strlcpy(config.Username, json["username"].as().c_str(), sizeof(config.Username)); - strlcpy(config.Password, json["password"].as().c_str(), sizeof(config.Password)); - config.Timeout = json["timeout"].as(); + _server->on("/api/powermeter/testhttpjsonrequest", HTTP_POST, std::bind(&WebApiPowerMeterClass::onTestHttpJsonRequest, this, _1)); + _server->on("/api/powermeter/testhttpsmlrequest", HTTP_POST, std::bind(&WebApiPowerMeterClass::onTestHttpSmlRequest, this, _1)); } void WebApiPowerMeterClass::onStatus(AsyncWebServerRequest* request) @@ -69,29 +46,14 @@ void WebApiPowerMeterClass::onStatus(AsyncWebServerRequest* request) root["sdmaddress"] = config.PowerMeter.SdmAddress; root["http_individual_requests"] = config.PowerMeter.HttpIndividualRequests; - auto tibber = root["tibber"].to(); - tibber["url"] = String(config.PowerMeter.Tibber.Url); - tibber["username"] = String(config.PowerMeter.Tibber.Username); - tibber["password"] = String(config.PowerMeter.Tibber.Password); - tibber["timeout"] = config.PowerMeter.Tibber.Timeout; - - auto httpPhases = root["http_phases"].to(); - - for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { - auto phaseObject = httpPhases.add(); - - phaseObject["index"] = i + 1; - phaseObject["enabled"] = config.PowerMeter.Http_Phase[i].Enabled; - phaseObject["url"] = String(config.PowerMeter.Http_Phase[i].Url); - phaseObject["auth_type"]= config.PowerMeter.Http_Phase[i].AuthType; - phaseObject["username"] = String(config.PowerMeter.Http_Phase[i].Username); - phaseObject["password"] = String(config.PowerMeter.Http_Phase[i].Password); - phaseObject["header_key"] = String(config.PowerMeter.Http_Phase[i].HeaderKey); - phaseObject["header_value"] = String(config.PowerMeter.Http_Phase[i].HeaderValue); - phaseObject["timeout"] = config.PowerMeter.Http_Phase[i].Timeout; - phaseObject["json_path"] = String(config.PowerMeter.Http_Phase[i].JsonPath); - phaseObject["unit"] = config.PowerMeter.Http_Phase[i].PowerUnit; - phaseObject["sign_inverted"] = config.PowerMeter.Http_Phase[i].SignInverted; + auto httpSml = root["http_sml"].to(); + Configuration.serializePowerMeterHttpSmlConfig(config.PowerMeter.HttpSml, httpSml); + + auto httpJson = root["http_json"].to(); + for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) { + auto valueConfig = httpJson.add(); + valueConfig["index"] = i + 1; + Configuration.serializePowerMeterHttpJsonConfig(config.PowerMeter.HttpJson[i], valueConfig); } WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); @@ -127,44 +89,52 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) return; } + auto checkHttpConfig = [&](JsonObject const& cfg) -> bool { + if (!cfg.containsKey("url") + || (!cfg["url"].as().startsWith("http://") + && !cfg["url"].as().startsWith("https://"))) { + retMsg["message"] = "URL must either start with http:// or https://!"; + response->setLength(); + request->send(response); + return false; + } + + if ((cfg["auth_type"].as() != HttpRequestConfig::Auth::None) + && (cfg["username"].as().length() == 0 || cfg["password"].as().length() == 0)) { + retMsg["message"] = "Username or password must not be empty!"; + response->setLength(); + request->send(response); + return false; + } + + if (!cfg.containsKey("timeout") + || cfg["timeout"].as() <= 0) { + retMsg["message"] = "Timeout must be greater than 0 ms!"; + response->setLength(); + request->send(response); + return false; + } + + return true; + }; + if (static_cast(root["source"].as()) == PowerMeterProvider::Type::HTTP_JSON) { - JsonArray http_phases = root["http_phases"]; - for (uint8_t i = 0; i < http_phases.size(); i++) { - JsonObject phase = http_phases[i].as(); + JsonArray httpJson = root["http_json"]; + for (uint8_t i = 0; i < httpJson.size(); i++) { + JsonObject valueConfig = httpJson[i].as(); - if (i > 0 && !phase["enabled"].as()) { + if (i > 0 && !valueConfig["enabled"].as()) { continue; } - if (i == 0 || phase["http_individual_requests"].as()) { - if (!phase.containsKey("url") - || (!phase["url"].as().startsWith("http://") - && !phase["url"].as().startsWith("https://"))) { - retMsg["message"] = "URL must either start with http:// or https://!"; - response->setLength(); - request->send(response); - return; - } - - if ((phase["auth_type"].as() != PowerMeterHttpConfig::Auth::None) - && ( phase["username"].as().length() == 0 || phase["password"].as().length() == 0)) { - retMsg["message"] = "Username or password must not be empty!"; - response->setLength(); - request->send(response); - return; - } - - if (!phase.containsKey("timeout") - || phase["timeout"].as() <= 0) { - retMsg["message"] = "Timeout must be greater than 0 ms!"; - response->setLength(); - request->send(response); + if (i == 0 || valueConfig["http_individual_requests"].as()) { + if (!checkHttpConfig(valueConfig["http_request"].as())) { return; } } - if (!phase.containsKey("json_path") - || phase["json_path"].as().length() == 0) { + if (!valueConfig.containsKey("json_path") + || valueConfig["json_path"].as().length() == 0) { retMsg["message"] = "Json path must not be empty!"; response->setLength(); request->send(response); @@ -174,29 +144,8 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) } if (static_cast(root["source"].as()) == PowerMeterProvider::Type::HTTP_SML) { - JsonObject tibber = root["tibber"]; - - if (!tibber.containsKey("url") - || (!tibber["url"].as().startsWith("http://") - && !tibber["url"].as().startsWith("https://"))) { - retMsg["message"] = "URL must either start with http:// or https://!"; - response->setLength(); - request->send(response); - return; - } - - if ((tibber["username"].as().length() == 0 || tibber["password"].as().length() == 0)) { - retMsg["message"] = "Username or password must not be empty!"; - response->setLength(); - request->send(response); - return; - } - - if (!tibber.containsKey("timeout") - || tibber["timeout"].as() <= 0) { - retMsg["message"] = "Timeout must be greater than 0 ms!"; - response->setLength(); - request->send(response); + JsonObject httpSml = root["http_sml"]; + if (!checkHttpConfig(httpSml["http_request"].as())) { return; } } @@ -212,13 +161,15 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) config.PowerMeter.SdmAddress = root["sdmaddress"].as(); config.PowerMeter.HttpIndividualRequests = root["http_individual_requests"].as(); - decodeJsonTibberConfig(root["tibber"].as(), config.PowerMeter.Tibber); + Configuration.deserializePowerMeterHttpSmlConfig(root["http_sml"].as(), + config.PowerMeter.HttpSml); - JsonArray http_phases = root["http_phases"]; - for (uint8_t i = 0; i < http_phases.size(); i++) { - decodeJsonPhaseConfig(http_phases[i].as(), config.PowerMeter.Http_Phase[i]); + JsonArray httpJson = root["http_json"]; + for (uint8_t i = 0; i < httpJson.size(); i++) { + Configuration.deserializePowerMeterHttpJsonConfig(httpJson[i].as(), + config.PowerMeter.HttpJson[i]); } - config.PowerMeter.Http_Phase[0].Enabled = true; + config.PowerMeter.HttpJson[0].Enabled = true; WebApi.writeConfig(retMsg); @@ -227,7 +178,7 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) PowerMeter.updateSettings(); } -void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request) +void WebApiPowerMeterClass::onTestHttpJsonRequest(AsyncWebServerRequest* request) { if (!WebApi.checkCredentials(request)) { return; @@ -241,9 +192,15 @@ void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request) auto& retMsg = asyncJsonResponse->getRoot(); - if (!root.containsKey("url") || !root.containsKey("auth_type") || !root.containsKey("username") || !root.containsKey("password") - || !root.containsKey("header_key") || !root.containsKey("header_value") - || !root.containsKey("timeout") || !root.containsKey("json_path")) { + JsonObject requestConfig = root["http_request"]; + if (!requestConfig.containsKey("url") + || !requestConfig.containsKey("auth_type") + || !requestConfig.containsKey("username") + || !requestConfig.containsKey("password") + || !requestConfig.containsKey("header_key") + || !requestConfig.containsKey("header_value") + || !requestConfig.containsKey("timeout") + || !root.containsKey("json_path")) { retMsg["message"] = "Missing fields!"; asyncJsonResponse->setLength(); request->send(asyncJsonResponse); @@ -253,10 +210,10 @@ void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request) char response[256]; - PowerMeterHttpConfig phaseConfig; - decodeJsonPhaseConfig(root.as(), phaseConfig); + PowerMeterHttpJsonConfig httpJsonConfig; + Configuration.deserializePowerMeterHttpJsonConfig(root.as(), httpJsonConfig); auto upMeter = std::make_unique(); - if (upMeter->queryPhase(0/*phase*/, phaseConfig)) { + if (upMeter->queryValue(0/*value index*/, httpJsonConfig)) { retMsg["type"] = "success"; snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", upMeter->getCached(0)); } else { @@ -268,7 +225,7 @@ void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request) request->send(asyncJsonResponse); } -void WebApiPowerMeterClass::onTestTibberRequest(AsyncWebServerRequest* request) +void WebApiPowerMeterClass::onTestHttpSmlRequest(AsyncWebServerRequest* request) { if (!WebApi.checkCredentials(request)) { return; @@ -293,10 +250,10 @@ void WebApiPowerMeterClass::onTestTibberRequest(AsyncWebServerRequest* request) char response[256]; - PowerMeterTibberConfig tibberConfig; - decodeJsonTibberConfig(root.as(), tibberConfig); + PowerMeterHttpSmlConfig httpSmlConfig; + Configuration.deserializePowerMeterHttpSmlConfig(root.as(), httpSmlConfig); auto upMeter = std::make_unique(); - if (upMeter->query(tibberConfig)) { + if (upMeter->query(httpSmlConfig.HttpRequest)) { retMsg["type"] = "success"; snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", upMeter->getPowerTotal()); } else { diff --git a/webapp/src/components/HttpRequestSettings.vue b/webapp/src/components/HttpRequestSettings.vue new file mode 100644 index 000000000..7c922c1c7 --- /dev/null +++ b/webapp/src/components/HttpRequestSettings.vue @@ -0,0 +1,77 @@ + + + diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index c7021b990..a982a5630 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -558,37 +558,47 @@ "PowerMeterSource": "Stromzählertyp", "MQTT": "MQTT Konfiguration", "typeMQTT": "MQTT", - "typeSDM1ph": "SDM 1 phase (SDM120/220/230)", - "typeSDM3ph": "SDM 3 phase (SDM72/630)", - "typeHTTP": "HTTP(S) + JSON", - "typeSML": "SML (OBIS 16.7.0)", + "typeSDM1ph": "SDM mit 1 Phase (SDM120/220/230)", + "typeSDM3ph": "SDM mit 3 Phasen (SDM72/630)", + "typeHTTP_JSON": "HTTP(S) + JSON", + "typeSML": "SML/OBIS via serieller Verbindung (z.B. Hichi TTL)", "typeSMAHM2": "SMA Homemanager 2.0", - "typeTIBBER": "Tibber Pulse (via Tibber Bridge)", + "typeHTTP_SML": "HTTP(S) + SML (z.B. Tibber Pulse via Tibber Bridge)", "MqttTopicPowerMeter1": "MQTT topic - Stromzähler #1", "MqttTopicPowerMeter2": "MQTT topic - Stromzähler #2 (Optional)", "MqttTopicPowerMeter3": "MQTT topic - Stromzähler #3 (Optional)", "SDM": "SDM-Stromzähler Konfiguration", "sdmaddress": "Modbus Adresse", - "HTTP": "HTTP(S) + JSON - Allgemeine Konfiguration", - "httpIndividualRequests": "Individuelle HTTP requests pro Phase", + "HTTP_JSON": "HTTP(S) + JSON - Allgemeine Konfiguration", + "httpIndividualRequests": "Individuelle HTTP Anfragen pro Wert", "urlExamplesHeading": "Beispiele für URLs", "jsonPathExamplesHeading": "Beispiele für JSON Pfade", "jsonPathExamplesExplanation": "Die folgenden Pfade finden jeweils den Wert '123.4' im jeweiligen Beispiel-JSON.", - "httpUrlDescription": "Die URL muss mit http:// oder https:// beginnen. Manche Zeichen wie Leerzeichen und = müssen mit URL-Kodierung kodiert werden (%xx). Achtung: Ein Überprüfung von SSL Server Zertifikaten ist nicht implementiert (MITM-Attacken sind möglich)!.", - "httpPhase": "HTTP(S) + JSON Konfiguration - Phase {phaseNumber}", - "httpEnabled": "Phase aktiviert", - "httpUrl": "URL", - "httpHeaderKey": "Optional: HTTP request header - Key", - "httpHeaderKeyDescription": "Ein individueller HTTP request header kann hier definiert werden. Das kann z.B. verwendet werden um einen eigenen Authorization header mitzugeben.", - "httpHeaderValue": "Optional: HTTP request header - Wert", + "httpValue": "Konfiguration für Wert {valueNumber}", + "httpEnabled": "Wert aktiviert", "httpJsonPath": "JSON Pfad", "httpJsonPathDescription": "Anwendungsspezifischer JSON-Pfad um den Leistungswert in the HTTP(S) Antwort zu finden, z.B. 'power/total/watts' oder nur 'total'.", "httpUnit": "Einheit", "httpSignInverted": "Vorzeichen umkehren", "httpSignInvertedHint": "Positive Werte werden als Leistungsabnahme aus dem Netz interpretiert. Diese Option muss aktiviert werden, wenn das Vorzeichen des Wertes die gegenteilige Bedeutung hat.", - "httpTimeout": "Timeout", - "testHttpRequest": "Testen", - "TIBBER": "Tibber Pulse (via Tibber Bridge) - Konfiguration" + "testHttpJsonRequest": "Konfiguration testen (HTTP(S)-Anfrage senden)", + "testHttpSmlRequest": "Konfiguration testen (HTTP(S)-Anfrage senden)", + "HTTP_SML": "HTTP(S) + SML - Konfiguration" + }, + "httprequestsettings": { + "url": "URL", + "urlDescription": "Die URL muss mit 'http://' oder 'https://' beginnen. Zeichen wie Leerzeichen und = müssen mit URL-kodiert werden (%xx). Achtung: Eine Überprüfung von SSL-Server-Zertifikaten ist nicht implementiert (MITM-Attacken sind möglich)!.", + "authorization": "Authentifizierungsverfahren", + "authTypeNone": "Ohne", + "authTypeBasic": "Basic", + "authTypeDigest": "Digest", + "username": "Benutzername", + "password": "Passwort", + "headerKey": "HTTP Header - Name", + "headerKeyDescription": "Optional. Ein benutzerdefinierter HTTP header kann definiert werden. Nützlich um z.B. ein (zusätzlichen) Authentifizierungstoken zu übermitteln.", + "headerValue": "HTTP Header - Wert", + "timeout": "Zeitüberschreitung", + "milliSeconds": "ms" }, "powerlimiteradmin": { "PowerLimiterSettings": "Dynamic Power Limiter Einstellungen", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 4b7a3959f..30407c7dd 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -560,41 +560,47 @@ "PowerMeterSource": "Power Meter type", "MQTT": "MQTT Parameter", "typeMQTT": "MQTT", - "typeSDM1ph": "SDM 1 phase (SDM120/220/230)", - "typeSDM3ph": "SDM 3 phase (SDM72/630)", - "typeHTTP": "HTTP(s) + JSON", - "typeSML": "SML (OBIS 16.7.0)", + "typeSDM1ph": "SDM for 1 phase (SDM120/220/230)", + "typeSDM3ph": "SDM for 3 phases (SDM72/630)", + "typeHTTP_JSON": "HTTP(S) + JSON", + "typeSML": "SML/OBIS via serial connection (e.g. Hichi TTL)", "typeSMAHM2": "SMA Homemanager 2.0", - "typeTIBBER": "Tibber Pulse (via Tibber Bridge)", + "typeHTTP_SML": "HTTP(S) + SML (e.g. Tibber Pulse via Tibber Bridge)", "MqttTopicPowerMeter1": "MQTT topic - Power meter #1", "MqttTopicPowerMeter2": "MQTT topic - Power meter #2", "MqttTopicPowerMeter3": "MQTT topic - Power meter #3", "SDM": "SDM-Power Meter Parameter", "sdmaddress": "Modbus Address", - "HTTP": "HTTP(S) + Json - General configuration", - "httpIndividualRequests": "Individual HTTP requests per phase", + "HTTP": "HTTP(S) + JSON - General configuration", + "httpIndividualRequests": "Individual HTTP requests per value", "urlExamplesHeading": "URL Examples", "jsonPathExamplesHeading": "JSON Path Examples", "jsonPathExamplesExplanation": "The following paths each find the value '123.4' in the respective example JSON.", - "httpPhase": "HTTP(S) + Json configuration - Phase {phaseNumber}", - "httpEnabled": "Phase enabled", - "httpUrl": "URL", - "httpUrlDescription": "URL must start with http:// or https://. Some characters like spaces and = have to be encoded with URL encoding (%xx). Warning: SSL server certificate check is not implemented (MITM attacks are possible)!", - "httpAuthorization": "Authorization Type", - "httpUsername": "Username", - "httpPassword": "Password", - "httpHeaderKey": "Optional: HTTP request header - Key", - "httpHeaderKeyDescription": "A custom HTTP request header can be defined. Might be useful if you have to send something like a custom Authorization header.", - "httpHeaderValue": "Optional: HTTP request header - Value", + "httpValue": "Configuration for value {valueNumber}", + "httpEnabled": "Value enabled", "httpJsonPath": "JSON path", "httpJsonPathDescription": "Application specific JSON path to find the power value in the HTTP(S) response, e.g., 'power/total/watts' or simply 'total'.", "httpUnit": "Unit", "httpSignInverted": "Change Sign", "httpSignInvertedHint": "Is is expected that positive values denote power usage from the grid. Check this option if the sign of this value has the opposite meaning.", - "httpTimeout": "Timeout", - "testHttpRequest": "Run test", - "milliSeconds": "ms", - "TIBBER": "Tibber Pulse (via Tibber Bridge) - Configuration" + "testHttpJsonRequest": "Test configuration (send HTTP(S) request)", + "testHttpSmlRequest": "Test configuration (send HTTP(S) request)", + "HTTP_SML": "Configuration" + }, + "httprequestsettings": { + "url": "URL", + "urlDescription": "URL must start with 'http://' or 'https://'. Characters like spaces and '=' have to be URL-encoded (%xx). Warning: SSL server certificate check is not implemented (MITM attacks are possible)!", + "authorization": "Authorization Type", + "authTypeNone": "None", + "authTypeBasic": "Basic", + "authTypeDigest": "Digest", + "username": "Username", + "password": "Password", + "headerKey": "HTTP Header - Key", + "headerKeyDescription": "Optional. A custom HTTP header key-value pair can be defined. Useful, e.g., to send an (additional) authentication token.", + "headerValue": "HTTP Header - Value", + "timeout": "Timeout", + "milliSeconds": "ms" }, "powerlimiteradmin": { "PowerLimiterSettings": "Dynamic Power Limiter Settings", diff --git a/webapp/src/types/HttpRequestConfig.ts b/webapp/src/types/HttpRequestConfig.ts new file mode 100644 index 000000000..465924578 --- /dev/null +++ b/webapp/src/types/HttpRequestConfig.ts @@ -0,0 +1,9 @@ +export interface HttpRequestConfig { + url: string; + auth_type: number; + username: string; + password: string; + header_key: string; + header_value: string; + timeout: number; +} diff --git a/webapp/src/types/PowerMeterConfig.ts b/webapp/src/types/PowerMeterConfig.ts index 1675d8d31..fe9fa09ab 100644 --- a/webapp/src/types/PowerMeterConfig.ts +++ b/webapp/src/types/PowerMeterConfig.ts @@ -1,23 +1,16 @@ -export interface PowerMeterHttpPhaseConfig { +import type { HttpRequestConfig } from '@/types/HttpRequestConfig'; + +export interface PowerMeterHttpJsonConfig { index: number; + http_request: HttpRequestConfig; enabled: boolean; - url: string; - auth_type: number; - username: string; - password: string; - header_key: string; - header_value: string; json_path: string; - timeout: number; unit: number; sign_inverted: boolean; } -export interface PowerMeterTibberConfig { - url: string; - username: string; - password: string; - timeout: number; +export interface PowerMeterHttpSmlConfig { + http_request: HttpRequestConfig; } export interface PowerMeterConfig { @@ -30,6 +23,6 @@ export interface PowerMeterConfig { mqtt_topic_powermeter_3: string; sdmaddress: number; http_individual_requests: boolean; - http_phases: Array; - tibber: PowerMeterTibberConfig; + http_json: Array; + http_sml: PowerMeterHttpSmlConfig; } diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index fd600069c..a7dbae77a 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -114,66 +114,23 @@
-
-
- - -
- -
- -
-
-
- - - -
+
- - - - - -
+
- +
-
- - {{ testHttpRequestAlert[index].message }} + + {{ testHttpJsonRequestAlert[index].message }}
- - - - - - - - +
-
- - {{ testTibberRequestAlert.message }} + + {{ testHttpSmlRequestAlert.message }}
@@ -263,8 +201,9 @@ import BootstrapAlert from "@/components/BootstrapAlert.vue"; import CardElement from '@/components/CardElement.vue'; import FormFooter from '@/components/FormFooter.vue'; import InputElement from '@/components/InputElement.vue'; +import HttpRequestSettings from '@/components/HttpRequestSettings.vue'; import { handleResponse, authHeader } from '@/utils/authentication'; -import type { PowerMeterHttpPhaseConfig, PowerMeterConfig } from "@/types/PowerMeterConfig"; +import type { PowerMeterHttpJsonConfig, PowerMeterConfig } from "@/types/PowerMeterConfig"; export default defineComponent({ components: { @@ -272,6 +211,7 @@ export default defineComponent({ BootstrapAlert, CardElement, FormFooter, + HttpRequestSettings, InputElement }, data() { @@ -282,21 +222,21 @@ export default defineComponent({ { key: 0, value: this.$t('powermeteradmin.typeMQTT') }, { key: 1, value: this.$t('powermeteradmin.typeSDM1ph') }, { key: 2, value: this.$t('powermeteradmin.typeSDM3ph') }, - { key: 3, value: this.$t('powermeteradmin.typeHTTP') }, + { key: 3, value: this.$t('powermeteradmin.typeHTTP_JSON') }, { key: 4, value: this.$t('powermeteradmin.typeSML') }, { key: 5, value: this.$t('powermeteradmin.typeSMAHM2') }, - { key: 6, value: this.$t('powermeteradmin.typeTIBBER') }, + { key: 6, value: this.$t('powermeteradmin.typeHTTP_SML') }, ], - powerMeterAuthList: [ - { key: 0, value: "None" }, - { key: 1, value: "Basic" }, - { key: 2, value: "Digest" }, + unitTypeList: [ + { key: 1, value: "mW" }, + { key: 0, value: "W" }, + { key: 2, value: "kW" }, ], alertMessage: "", alertType: "info", showAlert: false, - testHttpRequestAlert: [{message: "", type: "", show: false}] as { message: string; type: string; show: boolean; }[], - testTibberRequestAlert: {message: "", type: "", show: false} as { message: string; type: string; show: boolean; } + testHttpJsonRequestAlert: [{message: "", type: "", show: false}] as { message: string; type: string; show: boolean; }[], + testHttpSmlRequestAlert: {message: "", type: "", show: false} as { message: string; type: string; show: boolean; } }; }, created() { @@ -311,8 +251,8 @@ export default defineComponent({ this.powerMeterConfigList = data; this.dataLoading = false; - for (let i = 0; i < this.powerMeterConfigList.http_phases.length; i++) { - this.testHttpRequestAlert.push({ + for (let i = 0; i < this.powerMeterConfigList.http_json.length; i++) { + this.testHttpJsonRequestAlert.push({ message: "", type: "", show: false, @@ -341,27 +281,27 @@ export default defineComponent({ } ); }, - testHttpRequest(index: number) { - let phaseConfig:PowerMeterHttpPhaseConfig; + testHttpJsonRequest(index: number) { + let valueConfig:PowerMeterHttpJsonConfig; if (this.powerMeterConfigList.http_individual_requests) { - phaseConfig = this.powerMeterConfigList.http_phases[index]; + valueConfig = this.powerMeterConfigList.http_json[index]; } else { - phaseConfig = { ...this.powerMeterConfigList.http_phases[0] }; - phaseConfig.index = this.powerMeterConfigList.http_phases[index].index; - phaseConfig.json_path = this.powerMeterConfigList.http_phases[index].json_path; + valueConfig = { ...this.powerMeterConfigList.http_json[0] }; + valueConfig.index = this.powerMeterConfigList.http_json[index].index; + valueConfig.json_path = this.powerMeterConfigList.http_json[index].json_path; } - this.testHttpRequestAlert[index] = { + this.testHttpJsonRequestAlert[index] = { message: "Sending HTTP request...", type: "info", show: true, }; const formData = new FormData(); - formData.append("data", JSON.stringify(phaseConfig)); + formData.append("data", JSON.stringify(valueConfig)); - fetch("/api/powermeter/testhttprequest", { + fetch("/api/powermeter/testhttpjsonrequest", { method: "POST", headers: authHeader(), body: formData, @@ -369,7 +309,7 @@ export default defineComponent({ .then((response) => handleResponse(response, this.$emitter, this.$router)) .then( (response) => { - this.testHttpRequestAlert[index] = { + this.testHttpJsonRequestAlert[index] = { message: response.message, type: response.type, show: true, @@ -377,17 +317,17 @@ export default defineComponent({ } ) }, - testTibberRequest() { - this.testTibberRequestAlert = { - message: "Sending Tibber request...", + testHttpSmlRequest() { + this.testHttpSmlRequestAlert = { + message: "Sending HTTP SML request...", type: "info", show: true, }; const formData = new FormData(); - formData.append("data", JSON.stringify(this.powerMeterConfigList.tibber)); + formData.append("data", JSON.stringify(this.powerMeterConfigList.http_sml)); - fetch("/api/powermeter/testtibberrequest", { + fetch("/api/powermeter/testhttpsmlrequest", { method: "POST", headers: authHeader(), body: formData, @@ -395,7 +335,7 @@ export default defineComponent({ .then((response) => handleResponse(response, this.$emitter, this.$router)) .then( (response) => { - this.testTibberRequestAlert = { + this.testHttpSmlRequestAlert = { message: response.message, type: response.type, show: true, From 6da90de765356e13751b2a3f60febcc7f3034d65 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Sun, 12 May 2024 22:12:15 +0200 Subject: [PATCH 060/140] remove extraction of basic auth params from URL the extractUrlComponents method did extract username and password from the URL and encoded it for basic authentication. however, the respective result string was never used. we only perform basic authentication if the auth type is "basic" and if username and password were supplied through the respective inputs. --- include/PowerMeterHttpJson.h | 2 +- src/PowerMeterHttpJson.cpp | 10 ++++------ webapp/src/views/PowerMeterAdminView.vue | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/include/PowerMeterHttpJson.h b/include/PowerMeterHttpJson.h index 50bca8c5a..e50fb78f0 100644 --- a/include/PowerMeterHttpJson.h +++ b/include/PowerMeterHttpJson.h @@ -33,7 +33,7 @@ class PowerMeterHttpJson : public PowerMeterProvider { String httpResponse; bool httpRequest(int phase, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpJsonConfig const& config); - bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t, String& _base64Authorization); + bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t); String extractParam(String& authReq, const String& param, const char delimit); String getcNonce(const int len); String getDigestAuth(String& authReq, const String& username, const String& password, const String& method, const String& uri, unsigned int counter); diff --git a/src/PowerMeterHttpJson.cpp b/src/PowerMeterHttpJson.cpp index 785775cc6..6faeb71c8 100644 --- a/src/PowerMeterHttpJson.cpp +++ b/src/PowerMeterHttpJson.cpp @@ -80,9 +80,8 @@ bool PowerMeterHttpJson::queryValue(int phase, PowerMeterHttpJsonConfig const& c String protocol; String host; String uri; - String base64Authorization; uint16_t port; - extractUrlComponents(config.HttpRequest.Url, protocol, host, uri, port, base64Authorization); + extractUrlComponents(config.HttpRequest.Url, protocol, host, uri, port); IPAddress ipaddr((uint32_t)0); //first check if "host" is already an IP adress @@ -267,7 +266,7 @@ bool PowerMeterHttpJson::tryGetFloatValueForPhase(int phase, String jsonPath, Un } //extract url component as done by httpClient::begin(String url, const char* expectedProtocol) https://github.com/espressif/arduino-esp32/blob/da6325dd7e8e152094b19fe63190907f38ef1ff0/libraries/HTTPClient/src/HTTPClient.cpp#L250 -bool PowerMeterHttpJson::extractUrlComponents(String url, String& _protocol, String& _host, String& _uri, uint16_t& _port, String& _base64Authorization) +bool PowerMeterHttpJson::extractUrlComponents(String url, String& _protocol, String& _host, String& _uri, uint16_t& _port) { // check for : (http: or https: int index = url.indexOf(':'); @@ -295,10 +294,9 @@ bool PowerMeterHttpJson::extractUrlComponents(String url, String& _protocol, Str // get Authorization index = host.indexOf('@'); if(index >= 0) { - // auth info - String auth = host.substring(0, index); + // basic authentication is only supported through setting username + // and password using the respective inputs, not embedded into the URL host.remove(0, index + 1); // remove auth part including @ - _base64Authorization = base64::encode(auth); } // get port diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index a7dbae77a..9c7877530 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -98,8 +98,8 @@ + -
- -
+ - - {{ testHttpJsonRequestAlert[index].message }} - +
+
+ + + {{ testHttpJsonRequestAlert.message }} +
@@ -203,7 +209,7 @@ import FormFooter from '@/components/FormFooter.vue'; import InputElement from '@/components/InputElement.vue'; import HttpRequestSettings from '@/components/HttpRequestSettings.vue'; import { handleResponse, authHeader } from '@/utils/authentication'; -import type { PowerMeterHttpJsonConfig, PowerMeterConfig } from "@/types/PowerMeterConfig"; +import type { PowerMeterConfig } from "@/types/PowerMeterConfig"; export default defineComponent({ components: { @@ -235,7 +241,7 @@ export default defineComponent({ alertMessage: "", alertType: "info", showAlert: false, - testHttpJsonRequestAlert: [{message: "", type: "", show: false}] as { message: string; type: string; show: boolean; }[], + testHttpJsonRequestAlert: {message: "", type: "", show: false} as { message: string; type: string; show: boolean; }, testHttpSmlRequestAlert: {message: "", type: "", show: false} as { message: string; type: string; show: boolean; } }; }, @@ -250,14 +256,6 @@ export default defineComponent({ .then((data) => { this.powerMeterConfigList = data; this.dataLoading = false; - - for (let i = 0; i < this.powerMeterConfigList.http_json.length; i++) { - this.testHttpJsonRequestAlert.push({ - message: "", - type: "", - show: false, - }); - } }); }, savePowerMeterConfig(e: Event) { @@ -281,25 +279,15 @@ export default defineComponent({ } ); }, - testHttpJsonRequest(index: number) { - let valueConfig:PowerMeterHttpJsonConfig; - - if (this.powerMeterConfigList.http_individual_requests) { - valueConfig = this.powerMeterConfigList.http_json[index]; - } else { - valueConfig = { ...this.powerMeterConfigList.http_json[0] }; - valueConfig.index = this.powerMeterConfigList.http_json[index].index; - valueConfig.json_path = this.powerMeterConfigList.http_json[index].json_path; - } - - this.testHttpJsonRequestAlert[index] = { + testHttpJsonRequest() { + this.testHttpJsonRequestAlert = { message: "Sending HTTP request...", type: "info", show: true, }; const formData = new FormData(); - formData.append("data", JSON.stringify(valueConfig)); + formData.append("data", JSON.stringify(this.powerMeterConfigList)); fetch("/api/powermeter/testhttpjsonrequest", { method: "POST", @@ -309,7 +297,7 @@ export default defineComponent({ .then((response) => handleResponse(response, this.$emitter, this.$router)) .then( (response) => { - this.testHttpJsonRequestAlert[index] = { + this.testHttpJsonRequestAlert = { message: response.message, type: response.type, show: true, From e1778eba76c3d4fbc85a578678f0ce89143b2d3b Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Fri, 17 May 2024 22:00:37 +0200 Subject: [PATCH 064/140] powermeter refactor: use HttpGetter in HTTP SML implementation --- include/PowerMeterHttpSml.h | 21 +-- src/PowerMeterHttpSml.cpp | 177 ++++------------------- src/WebApi_powermeter.cpp | 27 ++-- webapp/src/locales/de.json | 3 +- webapp/src/locales/en.json | 3 +- webapp/src/views/PowerMeterAdminView.vue | 28 ++-- 6 files changed, 70 insertions(+), 189 deletions(-) diff --git a/include/PowerMeterHttpSml.h b/include/PowerMeterHttpSml.h index 73bc882cd..b351674bf 100644 --- a/include/PowerMeterHttpSml.h +++ b/include/PowerMeterHttpSml.h @@ -4,27 +4,20 @@ #include #include #include -#include -#include "Configuration.h" +#include "HttpGetter.h" #include "PowerMeterSml.h" class PowerMeterHttpSml : public PowerMeterSml { public: - ~PowerMeterHttpSml(); - - bool init() final { return true; } + bool init() final; void loop() final; - bool updateValues(); - char tibberPowerMeterError[256]; - bool query(HttpRequestConfig const& config); + + // returns an empty string on success, + // returns an error message otherwise. + String poll(); private: uint32_t _lastPoll = 0; - std::unique_ptr wifiClient; - std::unique_ptr httpClient; - String httpResponse; - bool httpRequest(const String& host, uint16_t port, const String& uri, bool https, HttpRequestConfig const& config); - bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t, String& _base64Authorization); - void prepareRequest(uint32_t timeout); + std::unique_ptr _upHttpGetter; }; diff --git a/src/PowerMeterHttpSml.cpp b/src/PowerMeterHttpSml.cpp index 17750bba6..ecc1ef5f5 100644 --- a/src/PowerMeterHttpSml.cpp +++ b/src/PowerMeterHttpSml.cpp @@ -6,178 +6,59 @@ #include #include -PowerMeterHttpSml::~PowerMeterHttpSml() -{ - // the wifiClient instance must live longer than the httpClient instance, - // as the httpClient holds a pointer to the wifiClient and uses it in its - // destructor. - httpClient.reset(); - wifiClient.reset(); -} - -void PowerMeterHttpSml::loop() +bool PowerMeterHttpSml::init() { auto const& config = Configuration.get(); - if ((millis() - _lastPoll) < (config.PowerMeter.Interval * 1000)) { - return; - } - - _lastPoll = millis(); - if (!query(config.PowerMeter.HttpSml.HttpRequest)) { - MessageOutput.printf("[PowerMeterHttpSml] Getting the power value failed.\r\n"); - MessageOutput.printf("%s\r\n", tibberPowerMeterError); - } -} + _upHttpGetter = std::make_unique(config.PowerMeter.HttpSml.HttpRequest); -bool PowerMeterHttpSml::query(HttpRequestConfig const& config) -{ - //hostByName in WiFiGeneric fails to resolve local names. issue described in - //https://github.com/espressif/arduino-esp32/issues/3822 - //and in depth analyzed in https://github.com/espressif/esp-idf/issues/2507#issuecomment-761836300 - //in conclusion: we cannot rely on httpClient->begin(*wifiClient, url) to resolve IP adresses. - //have to do it manually here. Feels Hacky... - String protocol; - String host; - String uri; - String base64Authorization; - uint16_t port; - extractUrlComponents(config.Url, protocol, host, uri, port, base64Authorization); + if (_upHttpGetter->init()) { return true; } - IPAddress ipaddr((uint32_t)0); - //first check if "host" is already an IP adress - if (!ipaddr.fromString(host)) - { - //"host"" is not an IP address so try to resolve the IP adress - //first try locally via mDNS, then via DNS. WiFiGeneric::hostByName() will spam the console if done the otherway around. - const bool mdnsEnabled = Configuration.get().Mdns.Enabled; - if (!mdnsEnabled) { - snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via DNS, try to enable mDNS in Network Settings"), host.c_str()); - //ensure we try resolving via DNS even if mDNS is disabled - if(!WiFiGenericClass::hostByName(host.c_str(), ipaddr)){ - snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via DNS"), host.c_str()); - } - } - else - { - ipaddr = MDNS.queryHost(host); - if (ipaddr == INADDR_NONE){ - snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via mDNS"), host.c_str()); - //when we cannot find local server via mDNS, try resolving via DNS - if(!WiFiGenericClass::hostByName(host.c_str(), ipaddr)){ - snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via DNS"), host.c_str()); - } - } - } - } + MessageOutput.printf("[PowerMeterHttpSml] Initializing HTTP getter failed:\r\n"); + MessageOutput.printf("[PowerMeterHttpSml] %s\r\n", _upHttpGetter->getErrorText()); - bool https = protocol == "https"; - if (https) { - auto secureWifiClient = std::make_unique(); - secureWifiClient->setInsecure(); - wifiClient = std::move(secureWifiClient); - } else { - wifiClient = std::make_unique(); - } + _upHttpGetter = nullptr; - return httpRequest(ipaddr.toString(), port, uri, https, config); + return false; } -bool PowerMeterHttpSml::httpRequest(const String& host, uint16_t port, const String& uri, bool https, HttpRequestConfig const& config) +void PowerMeterHttpSml::loop() { - if (!httpClient) { httpClient = std::make_unique(); } - - if(!httpClient->begin(*wifiClient, host, port, uri, https)){ - snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("httpClient->begin() failed for %s://%s"), (https ? "https" : "http"), host.c_str()); - return false; - } - - prepareRequest(config.Timeout); - - String authString = config.Username; - authString += ":"; - authString += config.Password; - String auth = "Basic "; - auth.concat(base64::encode(authString)); - httpClient->addHeader("Authorization", auth); - - int httpCode = httpClient->GET(); - - if (httpCode <= 0) { - snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("HTTP Error %s"), httpClient->errorToString(httpCode).c_str()); - return false; + auto const& config = Configuration.get(); + if ((millis() - _lastPoll) < (config.PowerMeter.Interval * 1000)) { + return; } - if (httpCode != HTTP_CODE_OK) { - snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Bad HTTP code: %d"), httpCode); - return false; - } + _lastPoll = millis(); - auto& stream = httpClient->getStream(); - while (stream.available()) { - processSmlByte(stream.read()); + auto res = poll(); + if (!res.isEmpty()) { + MessageOutput.printf("[PowerMeterHttpJson] %s\r\n", res.c_str()); + return; } - httpClient->end(); - return true; + gotUpdate(); } -//extract url component as done by httpClient::begin(String url, const char* expectedProtocol) https://github.com/espressif/arduino-esp32/blob/da6325dd7e8e152094b19fe63190907f38ef1ff0/libraries/HTTPClient/src/HTTPClient.cpp#L250 -bool PowerMeterHttpSml::extractUrlComponents(String url, String& _protocol, String& _host, String& _uri, uint16_t& _port, String& _base64Authorization) +String PowerMeterHttpSml::poll() { - // check for : (http: or https: - int index = url.indexOf(':'); - if(index < 0) { - snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("failed to parse protocol")); - return false; + if (!_upHttpGetter) { + return "Initialization of HTTP request failed"; } - _protocol = url.substring(0, index); - - //initialize port to default values for http or https. - //port will be overwritten below in case port is explicitly defined - _port = (_protocol == "https" ? 443 : 80); - - url.remove(0, (index + 3)); // remove http:// or https:// - - index = url.indexOf('/'); - if (index == -1) { - index = url.length(); - url += '/'; + auto res = _upHttpGetter->performGetRequest(); + if (!res) { + return _upHttpGetter->getErrorText(); } - String host = url.substring(0, index); - url.remove(0, index); // remove host part - // get Authorization - index = host.indexOf('@'); - if(index >= 0) { - // auth info - String auth = host.substring(0, index); - host.remove(0, index + 1); // remove auth part including @ - _base64Authorization = base64::encode(auth); + auto pStream = res.getStream(); + if (!pStream) { + return "Programmer error: HTTP request yields no stream"; } - // get port - index = host.indexOf(':'); - String the_host; - if(index >= 0) { - the_host = host.substring(0, index); // hostname - host.remove(0, (index + 1)); // remove hostname + : - _port = host.toInt(); // get port - } else { - the_host = host; + while (pStream->available()) { + processSmlByte(pStream->read()); } - _host = the_host; - _uri = url; - return true; -} - -void PowerMeterHttpSml::prepareRequest(uint32_t timeout) { - httpClient->setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); - httpClient->setUserAgent("OpenDTU-OnBattery"); - httpClient->setConnectTimeout(timeout); - httpClient->setTimeout(timeout); - httpClient->addHeader("Content-Type", "application/json"); - httpClient->addHeader("Accept", "application/json"); + return ""; } diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index 0178eb902..ac9a44389 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -196,6 +196,7 @@ void WebApiPowerMeterClass::onTestHttpJsonRequest(AsyncWebServerRequest* request auto powerMeterConfig = std::make_unique(); powerMeterConfig->HttpIndividualRequests = root["http_individual_requests"].as(); + powerMeterConfig->VerboseLogging = true; JsonArray httpJson = root["http_json"]; for (uint8_t i = 0; i < httpJson.size(); i++) { Configuration.deserializePowerMeterHttpJsonConfig(httpJson[i].as(), @@ -240,25 +241,23 @@ void WebApiPowerMeterClass::onTestHttpSmlRequest(AsyncWebServerRequest* request) auto& retMsg = asyncJsonResponse->getRoot(); - if (!root.containsKey("url") || !root.containsKey("username") || !root.containsKey("password") - || !root.containsKey("timeout")) { - retMsg["message"] = "Missing fields!"; - asyncJsonResponse->setLength(); - request->send(asyncJsonResponse); - return; - } - - char response[256]; - PowerMeterHttpSmlConfig httpSmlConfig; - Configuration.deserializePowerMeterHttpSmlConfig(root.as(), httpSmlConfig); + auto powerMeterConfig = std::make_unique(); + Configuration.deserializePowerMeterHttpSmlConfig(root["http_sml"].as(), + powerMeterConfig->HttpSml); + powerMeterConfig->VerboseLogging = true; + auto backup = std::make_unique(Configuration.get().PowerMeter); + Configuration.get().PowerMeter = *powerMeterConfig; auto upMeter = std::make_unique(); - if (upMeter->query(httpSmlConfig.HttpRequest)) { + upMeter->init(); + auto res = upMeter->poll(); + Configuration.get().PowerMeter = *backup; + if (res.isEmpty()) { retMsg["type"] = "success"; - snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", upMeter->getPowerTotal()); + snprintf(response, sizeof(response), "Result: %5.2fW", upMeter->getPowerTotal()); } else { - snprintf_P(response, sizeof(response), "%s", upMeter->tibberPowerMeterError); + snprintf(response, sizeof(response), "%s", res.c_str()); } retMsg["message"] = response; diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index e7e3317f4..e1d604aa1 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -583,7 +583,8 @@ "httpSignInvertedHint": "Positive Werte werden als Leistungsabnahme aus dem Netz interpretiert. Diese Option muss aktiviert werden, wenn das Vorzeichen des Wertes die gegenteilige Bedeutung hat.", "testHttpJsonHeader": "Konfiguration testen", "testHttpJsonRequest": "HTTP(S)-Anfrage(n) senden und Antwort(en) verarbeiten", - "testHttpSmlRequest": "Konfiguration testen (HTTP(S)-Anfrage senden)", + "testHttpSmlHeader": "Konfiguration testen", + "testHttpSmlRequest": "HTTP(S)-Anfrage senden und Antwort verarbeiten", "HTTP_SML": "HTTP(S) + SML - Konfiguration" }, "httprequestsettings": { diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 218eef942..1fe86f365 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -585,7 +585,8 @@ "httpSignInvertedHint": "Is is expected that positive values denote power usage from the grid. Check this option if the sign of this value has the opposite meaning.", "testHttpJsonHeader": "Test Configuration", "testHttpJsonRequest": "Send HTTP(S) request(s) and process response(s)", - "testHttpSmlRequest": "Test configuration (send HTTP(S) request)", + "testHttpSmlHeader": "Test Configuration", + "testHttpSmlRequest": "Send HTTP(S) request and process response", "HTTP_SML": "Configuration" }, "httprequestsettings": { diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index fd2934f8b..7575afc33 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -180,16 +180,22 @@ add-space> + -
- -
+ - - {{ testHttpSmlRequestAlert.message }} - +
+ +
+ + + {{ testHttpSmlRequestAlert.message }} +
@@ -281,7 +287,7 @@ export default defineComponent({ }, testHttpJsonRequest() { this.testHttpJsonRequestAlert = { - message: "Sending HTTP request...", + message: "Triggering HTTP request...", type: "info", show: true, }; @@ -307,13 +313,13 @@ export default defineComponent({ }, testHttpSmlRequest() { this.testHttpSmlRequestAlert = { - message: "Sending HTTP SML request...", + message: "Triggering HTTP request...", type: "info", show: true, }; const formData = new FormData(); - formData.append("data", JSON.stringify(this.powerMeterConfigList.http_sml)); + formData.append("data", JSON.stringify(this.powerMeterConfigList)); fetch("/api/powermeter/testhttpsmlrequest", { method: "POST", From 3f2d9d38faa279e2d80aab106bbab3c22464e562 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Wed, 22 May 2024 22:27:49 +0200 Subject: [PATCH 065/140] powermeter refactor: fully structure settings per provider all power meter providers now have their own configuration struct defined. a respective method to serialize and deserialize the provider config is implemented for each provider. --- include/Configuration.h | 41 ++++-- include/PowerMeterHttpJson.h | 2 +- include/defaults.h | 2 +- src/Configuration.cpp | 151 +++++++++++++++++------ src/PowerMeterHttpJson.cpp | 8 +- src/PowerMeterHttpSml.cpp | 2 +- src/PowerMeterMqtt.cpp | 6 +- src/PowerMeterSerialSdm.cpp | 4 +- src/WebApi_powermeter.cpp | 66 +++++----- webapp/src/locales/de.json | 6 +- webapp/src/locales/en.json | 6 +- webapp/src/types/PowerMeterConfig.ts | 32 +++-- webapp/src/views/PowerMeterAdminView.vue | 47 ++----- 13 files changed, 227 insertions(+), 146 deletions(-) diff --git a/include/Configuration.h b/include/Configuration.h index 79bdd7365..1f185befe 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -37,6 +37,7 @@ #define HTTP_REQUEST_MAX_HEADER_KEY_STRLEN 64 #define HTTP_REQUEST_MAX_HEADER_VALUE_STRLEN 256 +#define POWERMETER_MQTT_MAX_VALUES 3 #define POWERMETER_HTTP_JSON_MAX_VALUES 3 #define POWERMETER_HTTP_JSON_MAX_PATH_STRLEN 256 @@ -76,7 +77,23 @@ struct HTTP_REQUEST_CONFIG_T { }; using HttpRequestConfig = struct HTTP_REQUEST_CONFIG_T; -struct POWERMETER_HTTP_JSON_CONFIG_T { +struct POWERMETER_MQTT_VALUE_T { + char Topic[MQTT_MAX_TOPIC_STRLEN + 1]; +}; +using PowerMeterMqttValue = struct POWERMETER_MQTT_VALUE_T; + +struct POWERMETER_MQTT_CONFIG_T { + PowerMeterMqttValue Values[POWERMETER_MQTT_MAX_VALUES]; +}; +using PowerMeterMqttConfig = struct POWERMETER_MQTT_CONFIG_T; + +struct POWERMETER_SERIAL_SDM_CONFIG_T { + uint32_t Address; + uint32_t PollingInterval; +}; +using PowerMeterSerialSdmConfig = struct POWERMETER_SERIAL_SDM_CONFIG_T; + +struct POWERMETER_HTTP_JSON_VALUE_T { HttpRequestConfig HttpRequest; bool Enabled; char JsonPath[POWERMETER_HTTP_JSON_MAX_PATH_STRLEN + 1]; @@ -86,9 +103,17 @@ struct POWERMETER_HTTP_JSON_CONFIG_T { bool SignInverted; }; +using PowerMeterHttpJsonValue = struct POWERMETER_HTTP_JSON_VALUE_T; + +struct POWERMETER_HTTP_JSON_CONFIG_T { + uint32_t PollingInterval; + bool IndividualRequests; + PowerMeterHttpJsonValue Values[POWERMETER_HTTP_JSON_MAX_VALUES]; +}; using PowerMeterHttpJsonConfig = struct POWERMETER_HTTP_JSON_CONFIG_T; struct POWERMETER_HTTP_SML_CONFIG_T { + uint32_t PollingInterval; HttpRequestConfig HttpRequest; }; using PowerMeterHttpSmlConfig = struct POWERMETER_HTTP_SML_CONFIG_T; @@ -205,14 +230,10 @@ struct CONFIG_T { struct PowerMeterConfig { bool Enabled; bool VerboseLogging; - uint32_t Interval; uint32_t Source; - char MqttTopicPowerMeter1[MQTT_MAX_TOPIC_STRLEN + 1]; - char MqttTopicPowerMeter2[MQTT_MAX_TOPIC_STRLEN + 1]; - char MqttTopicPowerMeter3[MQTT_MAX_TOPIC_STRLEN + 1]; - uint32_t SdmAddress; - bool HttpIndividualRequests; - PowerMeterHttpJsonConfig HttpJson[POWERMETER_HTTP_JSON_MAX_VALUES]; + PowerMeterMqttConfig Mqtt; + PowerMeterSerialSdmConfig SerialSdm; + PowerMeterHttpJsonConfig HttpJson; PowerMeterHttpSmlConfig HttpSml; } PowerMeter; @@ -288,10 +309,14 @@ class ConfigurationClass { void deleteInverterById(const uint8_t id); static void serializeHttpRequestConfig(HttpRequestConfig const& source, JsonObject& target); + static void serializePowerMeterMqttConfig(PowerMeterMqttConfig const& source, JsonObject& target); + static void serializePowerMeterSerialSdmConfig(PowerMeterSerialSdmConfig const& source, JsonObject& target); static void serializePowerMeterHttpJsonConfig(PowerMeterHttpJsonConfig const& source, JsonObject& target); static void serializePowerMeterHttpSmlConfig(PowerMeterHttpSmlConfig const& source, JsonObject& target); static void deserializeHttpRequestConfig(JsonObject const& source, HttpRequestConfig& target); + static void deserializePowerMeterMqttConfig(JsonObject const& source, PowerMeterMqttConfig& target); + static void deserializePowerMeterSerialSdmConfig(JsonObject const& source, PowerMeterSerialSdmConfig& target); static void deserializePowerMeterHttpJsonConfig(JsonObject const& source, PowerMeterHttpJsonConfig& target); static void deserializePowerMeterHttpSmlConfig(JsonObject const& source, PowerMeterHttpSmlConfig& target); }; diff --git a/include/PowerMeterHttpJson.h b/include/PowerMeterHttpJson.h index 7f81ff377..8810ca050 100644 --- a/include/PowerMeterHttpJson.h +++ b/include/PowerMeterHttpJson.h @@ -10,7 +10,7 @@ #include "PowerMeterProvider.h" using Auth_t = HttpRequestConfig::Auth; -using Unit_t = PowerMeterHttpJsonConfig::Unit; +using Unit_t = PowerMeterHttpJsonValue::Unit; class PowerMeterHttpJson : public PowerMeterProvider { public: diff --git a/include/defaults.h b/include/defaults.h index 6b191cbd3..0f83eaaf9 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -115,7 +115,7 @@ #define VEDIRECT_UPDATESONLY true #define POWERMETER_ENABLED false -#define POWERMETER_INTERVAL 10 +#define POWERMETER_POLLING_INTERVAL 10 #define POWERMETER_SOURCE 2 #define POWERMETER_SDMADDRESS 1 diff --git a/src/Configuration.cpp b/src/Configuration.cpp index ffe964597..e2067fb18 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -28,18 +28,45 @@ void ConfigurationClass::serializeHttpRequestConfig(HttpRequestConfig const& sou target_http_config["timeout"] = source.Timeout; } +void ConfigurationClass::serializePowerMeterMqttConfig(PowerMeterMqttConfig const& source, JsonObject& target) +{ + JsonArray values = target["values"].to(); + for (size_t i = 0; i < POWERMETER_MQTT_MAX_VALUES; ++i) { + JsonObject t = values.add(); + PowerMeterMqttValue const& s = source.Values[i]; + + t["topic"] = s.Topic; + } +} + +void ConfigurationClass::serializePowerMeterSerialSdmConfig(PowerMeterSerialSdmConfig const& source, JsonObject& target) +{ + target["address"] = source.Address; + target["polling_interval"] = source.PollingInterval; +} + void ConfigurationClass::serializePowerMeterHttpJsonConfig(PowerMeterHttpJsonConfig const& source, JsonObject& target) { - serializeHttpRequestConfig(source.HttpRequest, target); + target["polling_interval"] = source.PollingInterval; + target["individual_requests"] = source.IndividualRequests; + + JsonArray values = target["values"].to(); + for (size_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; ++i) { + JsonObject t = values.add(); + PowerMeterHttpJsonValue const& s = source.Values[i]; + + serializeHttpRequestConfig(s.HttpRequest, t); - target["enabled"] = source.Enabled; - target["json_path"] = source.JsonPath; - target["unit"] = source.PowerUnit; - target["sign_inverted"] = source.SignInverted; + t["enabled"] = s.Enabled; + t["json_path"] = s.JsonPath; + t["unit"] = s.PowerUnit; + t["sign_inverted"] = s.SignInverted; + } } void ConfigurationClass::serializePowerMeterHttpSmlConfig(PowerMeterHttpSmlConfig const& source, JsonObject& target) { + target["polling_interval"] = source.PollingInterval; serializeHttpRequestConfig(source.HttpRequest, target); } @@ -176,24 +203,20 @@ bool ConfigurationClass::write() JsonObject powermeter = doc["powermeter"].to(); powermeter["enabled"] = config.PowerMeter.Enabled; powermeter["verbose_logging"] = config.PowerMeter.VerboseLogging; - powermeter["interval"] = config.PowerMeter.Interval; powermeter["source"] = config.PowerMeter.Source; - powermeter["mqtt_topic_powermeter_1"] = config.PowerMeter.MqttTopicPowerMeter1; - powermeter["mqtt_topic_powermeter_2"] = config.PowerMeter.MqttTopicPowerMeter2; - powermeter["mqtt_topic_powermeter_3"] = config.PowerMeter.MqttTopicPowerMeter3; - powermeter["sdmaddress"] = config.PowerMeter.SdmAddress; - powermeter["http_individual_requests"] = config.PowerMeter.HttpIndividualRequests; + + JsonObject powermeter_mqtt = powermeter["mqtt"].to(); + serializePowerMeterMqttConfig(config.PowerMeter.Mqtt, powermeter_mqtt); + + JsonObject powermeter_serial_sdm = powermeter["serial_sdm"].to(); + serializePowerMeterSerialSdmConfig(config.PowerMeter.SerialSdm, powermeter_serial_sdm); + + JsonObject powermeter_http_json = powermeter["http_json"].to(); + serializePowerMeterHttpJsonConfig(config.PowerMeter.HttpJson, powermeter_http_json); JsonObject powermeter_http_sml = powermeter["http_sml"].to(); serializePowerMeterHttpSmlConfig(config.PowerMeter.HttpSml, powermeter_http_sml); - JsonArray powermeter_http_json = powermeter["http_json"].to(); - for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) { - JsonObject powermeter_json_config = powermeter_http_json.add(); - serializePowerMeterHttpJsonConfig(config.PowerMeter.HttpJson[i], - powermeter_json_config); - } - JsonObject powerlimiter = doc["powerlimiter"].to(); powerlimiter["enabled"] = config.PowerLimiter.Enabled; powerlimiter["verbose_logging"] = config.PowerLimiter.VerboseLogging; @@ -263,8 +286,8 @@ void ConfigurationClass::deserializeHttpRequestConfig(JsonObject const& source, { JsonObject source_http_config = source["http_request"]; - // http request parameters of HTTP/JSON power meter were - // previously stored alongside other settings + // http request parameters of HTTP/JSON power meter were previously stored + // alongside other settings. TODO(schlimmchen): remove in early 2025. if (source_http_config.isNull()) { source_http_config = source; } strlcpy(target.Url, source_http_config["url"] | "", sizeof(target.Url)); @@ -276,18 +299,43 @@ void ConfigurationClass::deserializeHttpRequestConfig(JsonObject const& source, target.Timeout = source_http_config["timeout"] | HTTP_REQUEST_TIMEOUT_MS; } +void ConfigurationClass::deserializePowerMeterMqttConfig(JsonObject const& source, PowerMeterMqttConfig& target) +{ + JsonArray s = source["values"].as(); + for (size_t i = 0; i < POWERMETER_MQTT_MAX_VALUES; ++i) { + PowerMeterMqttValue& t = target.Values[i]; + strlcpy(t.Topic, s[i]["topic"] | "", sizeof(t.Topic)); + } +} + +void ConfigurationClass::deserializePowerMeterSerialSdmConfig(JsonObject const& source, PowerMeterSerialSdmConfig& target) +{ + target.PollingInterval = source["polling_interval"] | POWERMETER_POLLING_INTERVAL; + target.Address = source["address"] | POWERMETER_SDMADDRESS; +} + void ConfigurationClass::deserializePowerMeterHttpJsonConfig(JsonObject const& source, PowerMeterHttpJsonConfig& target) { - deserializeHttpRequestConfig(source, target.HttpRequest); + target.PollingInterval = source["polling_interval"] | POWERMETER_POLLING_INTERVAL; + target.IndividualRequests = source["individual_requests"] | false; + + JsonArray values = source["values"].as(); + for (size_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; ++i) { + PowerMeterHttpJsonValue& t = target.Values[i]; + JsonObject s = values[i]; - target.Enabled = source["enabled"] | false; - strlcpy(target.JsonPath, source["json_path"] | "", sizeof(target.JsonPath)); - target.PowerUnit = source["unit"] | PowerMeterHttpJsonConfig::Unit::Watts; - target.SignInverted = source["sign_inverted"] | false; + deserializeHttpRequestConfig(s, t.HttpRequest); + + t.Enabled = s["enabled"] | false; + strlcpy(t.JsonPath, s["json_path"] | "", sizeof(t.JsonPath)); + t.PowerUnit = s["unit"] | PowerMeterHttpJsonValue::Unit::Watts; + t.SignInverted = s["sign_inverted"] | false; + } } void ConfigurationClass::deserializePowerMeterHttpSmlConfig(JsonObject const& source, PowerMeterHttpSmlConfig& target) { + target.PollingInterval = source["polling_interval"] | POWERMETER_POLLING_INTERVAL; deserializeHttpRequestConfig(source, target.HttpRequest); } @@ -461,24 +509,51 @@ bool ConfigurationClass::read() JsonObject powermeter = doc["powermeter"]; config.PowerMeter.Enabled = powermeter["enabled"] | POWERMETER_ENABLED; config.PowerMeter.VerboseLogging = powermeter["verbose_logging"] | VERBOSE_LOGGING; - config.PowerMeter.Interval = powermeter["interval"] | POWERMETER_INTERVAL; config.PowerMeter.Source = powermeter["source"] | POWERMETER_SOURCE; - strlcpy(config.PowerMeter.MqttTopicPowerMeter1, powermeter["mqtt_topic_powermeter_1"] | "", sizeof(config.PowerMeter.MqttTopicPowerMeter1)); - strlcpy(config.PowerMeter.MqttTopicPowerMeter2, powermeter["mqtt_topic_powermeter_2"] | "", sizeof(config.PowerMeter.MqttTopicPowerMeter2)); - strlcpy(config.PowerMeter.MqttTopicPowerMeter3, powermeter["mqtt_topic_powermeter_3"] | "", sizeof(config.PowerMeter.MqttTopicPowerMeter3)); - config.PowerMeter.SdmAddress = powermeter["sdmaddress"] | POWERMETER_SDMADDRESS; - config.PowerMeter.HttpIndividualRequests = powermeter["http_individual_requests"] | false; + + deserializePowerMeterMqttConfig(powermeter["mqtt"], config.PowerMeter.Mqtt); + + // process settings from legacy config if they are present + // TODO(schlimmchen): remove in early 2025. + if (!powermeter["mqtt_topic_powermeter_1"].isNull()) { + auto& values = config.PowerMeter.Mqtt.Values; + strlcpy(values[0].Topic, powermeter["mqtt_topic_powermeter_1"], sizeof(values[0].Topic)); + strlcpy(values[1].Topic, powermeter["mqtt_topic_powermeter_2"], sizeof(values[1].Topic)); + strlcpy(values[2].Topic, powermeter["mqtt_topic_powermeter_3"], sizeof(values[2].Topic)); + } + + deserializePowerMeterSerialSdmConfig(powermeter["serial_sdm"], config.PowerMeter.SerialSdm); + + // process settings from legacy config if they are present + // TODO(schlimmchen): remove in early 2025. + if (!powermeter["sdmaddress"].isNull()) { + config.PowerMeter.SerialSdm.Address = powermeter["sdmaddress"]; + } + + JsonObject powermeter_http_json = powermeter["http_json"]; + deserializePowerMeterHttpJsonConfig(powermeter_http_json, config.PowerMeter.HttpJson); JsonObject powermeter_sml = powermeter["http_sml"]; deserializePowerMeterHttpSmlConfig(powermeter_sml, config.PowerMeter.HttpSml); - JsonArray powermeter_http_json = powermeter["http_json"]; - if (powermeter_http_json.isNull()) { - powermeter_http_json = powermeter["http_phases"]; // http_phases is a legacy key - } - for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) { - JsonObject powermeter_json_config = powermeter_http_json[i].as(); - deserializePowerMeterHttpJsonConfig(powermeter_json_config, config.PowerMeter.HttpJson[i]); + // process settings from legacy config if they are present + // TODO(schlimmchen): remove in early 2025. + if (!powermeter["http_phases"].isNull()) { + auto& target = config.PowerMeter.HttpJson; + + for (size_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; ++i) { + PowerMeterHttpJsonValue& t = target.Values[i]; + JsonObject s = powermeter["http_phases"][i]; + + deserializeHttpRequestConfig(s, t.HttpRequest); + + t.Enabled = s["enabled"] | false; + strlcpy(t.JsonPath, s["json_path"] | "", sizeof(t.JsonPath)); + t.PowerUnit = s["unit"] | PowerMeterHttpJsonValue::Unit::Watts; + t.SignInverted = s["sign_inverted"] | false; + } + + target.IndividualRequests = powermeter["http_individual_requests"] | false; } JsonObject powerlimiter = doc["powerlimiter"]; diff --git a/src/PowerMeterHttpJson.cpp b/src/PowerMeterHttpJson.cpp index b1b530d7f..621670d31 100644 --- a/src/PowerMeterHttpJson.cpp +++ b/src/PowerMeterHttpJson.cpp @@ -14,11 +14,11 @@ bool PowerMeterHttpJson::init() auto const& config = Configuration.get(); for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) { - auto const& valueConfig = config.PowerMeter.HttpJson[i]; + auto const& valueConfig = config.PowerMeter.HttpJson.Values[i]; _httpGetters[i] = nullptr; - if (i == 0 || (config.PowerMeter.HttpIndividualRequests && valueConfig.Enabled)) { + if (i == 0 || (config.PowerMeter.HttpJson.IndividualRequests && valueConfig.Enabled)) { _httpGetters[i] = std::make_unique(valueConfig.HttpRequest); } @@ -41,7 +41,7 @@ bool PowerMeterHttpJson::init() void PowerMeterHttpJson::loop() { auto const& config = Configuration.get(); - if ((millis() - _lastPoll) < (config.PowerMeter.Interval * 1000)) { + if ((millis() - _lastPoll) < (config.PowerMeter.HttpJson.PollingInterval * 1000)) { return; } @@ -68,7 +68,7 @@ PowerMeterHttpJson::poll_result_t PowerMeterHttpJson::poll() }; for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) { - auto const& cfg = Configuration.get().PowerMeter.HttpJson[i]; + auto const& cfg = Configuration.get().PowerMeter.HttpJson.Values[i]; if (!cfg.Enabled) { cache[i] = 0.0; diff --git a/src/PowerMeterHttpSml.cpp b/src/PowerMeterHttpSml.cpp index ecc1ef5f5..e9a383893 100644 --- a/src/PowerMeterHttpSml.cpp +++ b/src/PowerMeterHttpSml.cpp @@ -25,7 +25,7 @@ bool PowerMeterHttpSml::init() void PowerMeterHttpSml::loop() { auto const& config = Configuration.get(); - if ((millis() - _lastPoll) < (config.PowerMeter.Interval * 1000)) { + if ((millis() - _lastPoll) < (config.PowerMeter.HttpSml.PollingInterval * 1000)) { return; } diff --git a/src/PowerMeterMqtt.cpp b/src/PowerMeterMqtt.cpp index 48f6e4594..9a4654b70 100644 --- a/src/PowerMeterMqtt.cpp +++ b/src/PowerMeterMqtt.cpp @@ -19,9 +19,9 @@ bool PowerMeterMqtt::init() }; auto const& config = Configuration.get(); - subscribe(config.PowerMeter.MqttTopicPowerMeter1, &_powerValueOne); - subscribe(config.PowerMeter.MqttTopicPowerMeter2, &_powerValueTwo); - subscribe(config.PowerMeter.MqttTopicPowerMeter3, &_powerValueThree); + subscribe(config.PowerMeter.Mqtt.Values[0].Topic, &_powerValueOne); + subscribe(config.PowerMeter.Mqtt.Values[1].Topic, &_powerValueTwo); + subscribe(config.PowerMeter.Mqtt.Values[2].Topic, &_powerValueThree); return _mqttSubscriptions.size() > 0; } diff --git a/src/PowerMeterSerialSdm.cpp b/src/PowerMeterSerialSdm.cpp index 1612bcbe1..a425f9868 100644 --- a/src/PowerMeterSerialSdm.cpp +++ b/src/PowerMeterSerialSdm.cpp @@ -63,11 +63,11 @@ void PowerMeterSerialSdm::loop() auto const& config = Configuration.get(); - if ((millis() - _lastPoll) < (config.PowerMeter.Interval * 1000)) { + if ((millis() - _lastPoll) < (config.PowerMeter.SerialSdm.PollingInterval * 1000)) { return; } - uint8_t addr = config.PowerMeter.SdmAddress; + uint8_t addr = config.PowerMeter.SerialSdm.Address; // reading takes a "very long" time as each readVal() is a synchronous // exchange of serial messages. cache the values and write later to diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index ac9a44389..0fea84ed7 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -39,23 +39,19 @@ void WebApiPowerMeterClass::onStatus(AsyncWebServerRequest* request) root["enabled"] = config.PowerMeter.Enabled; root["verbose_logging"] = config.PowerMeter.VerboseLogging; root["source"] = config.PowerMeter.Source; - root["interval"] = config.PowerMeter.Interval; - root["mqtt_topic_powermeter_1"] = config.PowerMeter.MqttTopicPowerMeter1; - root["mqtt_topic_powermeter_2"] = config.PowerMeter.MqttTopicPowerMeter2; - root["mqtt_topic_powermeter_3"] = config.PowerMeter.MqttTopicPowerMeter3; - root["sdmaddress"] = config.PowerMeter.SdmAddress; - root["http_individual_requests"] = config.PowerMeter.HttpIndividualRequests; + + auto mqtt = root["mqtt"].to(); + Configuration.serializePowerMeterMqttConfig(config.PowerMeter.Mqtt, mqtt); + + auto serialSdm = root["serial_sdm"].to(); + Configuration.serializePowerMeterSerialSdmConfig(config.PowerMeter.SerialSdm, serialSdm); + + auto httpJson = root["http_json"].to(); + Configuration.serializePowerMeterHttpJsonConfig(config.PowerMeter.HttpJson, httpJson); auto httpSml = root["http_sml"].to(); Configuration.serializePowerMeterHttpSmlConfig(config.PowerMeter.HttpSml, httpSml); - auto httpJson = root["http_json"].to(); - for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) { - auto valueConfig = httpJson.add(); - valueConfig["index"] = i + 1; - Configuration.serializePowerMeterHttpJsonConfig(config.PowerMeter.HttpJson[i], valueConfig); - } - WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); } @@ -119,15 +115,16 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) }; if (static_cast(root["source"].as()) == PowerMeterProvider::Type::HTTP_JSON) { - JsonArray httpJson = root["http_json"]; - for (uint8_t i = 0; i < httpJson.size(); i++) { - JsonObject valueConfig = httpJson[i].as(); + JsonObject httpJson = root["http_json"]; + JsonArray valueConfigs = httpJson["values"]; + for (uint8_t i = 0; i < valueConfigs.size(); i++) { + JsonObject valueConfig = valueConfigs[i].as(); if (i > 0 && !valueConfig["enabled"].as()) { continue; } - if (i == 0 || valueConfig["http_individual_requests"].as()) { + if (i == 0 || httpJson["individual_requests"].as()) { if (!checkHttpConfig(valueConfig["http_request"].as())) { return; } @@ -154,23 +151,20 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) config.PowerMeter.Enabled = root["enabled"].as(); config.PowerMeter.VerboseLogging = root["verbose_logging"].as(); config.PowerMeter.Source = root["source"].as(); - config.PowerMeter.Interval = root["interval"].as(); - strlcpy(config.PowerMeter.MqttTopicPowerMeter1, root["mqtt_topic_powermeter_1"].as().c_str(), sizeof(config.PowerMeter.MqttTopicPowerMeter1)); - strlcpy(config.PowerMeter.MqttTopicPowerMeter2, root["mqtt_topic_powermeter_2"].as().c_str(), sizeof(config.PowerMeter.MqttTopicPowerMeter2)); - strlcpy(config.PowerMeter.MqttTopicPowerMeter3, root["mqtt_topic_powermeter_3"].as().c_str(), sizeof(config.PowerMeter.MqttTopicPowerMeter3)); - config.PowerMeter.SdmAddress = root["sdmaddress"].as(); - config.PowerMeter.HttpIndividualRequests = root["http_individual_requests"].as(); + + Configuration.deserializePowerMeterMqttConfig(root["mqtt"].as(), + config.PowerMeter.Mqtt); + + Configuration.deserializePowerMeterSerialSdmConfig(root["serial_sdm"].as(), + config.PowerMeter.SerialSdm); + + Configuration.deserializePowerMeterHttpJsonConfig(root["http_json"].as(), + config.PowerMeter.HttpJson); + config.PowerMeter.HttpJson.Values[0].Enabled = true; Configuration.deserializePowerMeterHttpSmlConfig(root["http_sml"].as(), config.PowerMeter.HttpSml); - JsonArray httpJson = root["http_json"]; - for (uint8_t i = 0; i < httpJson.size(); i++) { - Configuration.deserializePowerMeterHttpJsonConfig(httpJson[i].as(), - config.PowerMeter.HttpJson[i]); - } - config.PowerMeter.HttpJson[0].Enabled = true; - WebApi.writeConfig(retMsg); WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); @@ -195,13 +189,11 @@ void WebApiPowerMeterClass::onTestHttpJsonRequest(AsyncWebServerRequest* request char response[256]; auto powerMeterConfig = std::make_unique(); - powerMeterConfig->HttpIndividualRequests = root["http_individual_requests"].as(); + JsonObject httpJson = root["http_json"]; + powerMeterConfig->HttpJson.IndividualRequests = httpJson["individual_requests"].as(); powerMeterConfig->VerboseLogging = true; - JsonArray httpJson = root["http_json"]; - for (uint8_t i = 0; i < httpJson.size(); i++) { - Configuration.deserializePowerMeterHttpJsonConfig(httpJson[i].as(), - powerMeterConfig->HttpJson[i]); - } + Configuration.deserializePowerMeterHttpJsonConfig(httpJson, + powerMeterConfig->HttpJson); auto backup = std::make_unique(Configuration.get().PowerMeter); Configuration.get().PowerMeter = *powerMeterConfig; auto upMeter = std::make_unique(); @@ -214,7 +206,7 @@ void WebApiPowerMeterClass::onTestHttpJsonRequest(AsyncWebServerRequest* request auto vals = std::get(res); auto pos = snprintf(response, sizeof(response), "Result: %5.2fW", vals[0]); for (size_t i = 1; i < POWERMETER_HTTP_JSON_MAX_VALUES; ++i) { - if (!powerMeterConfig->HttpJson[i].Enabled) { continue; } + if (!powerMeterConfig->HttpJson.Values[i].Enabled) { continue; } pos += snprintf(response + pos, sizeof(response) - pos, ", %5.2fW", vals[i]); } snprintf(response + pos, sizeof(response) - pos, ", Total: %5.2f", upMeter->getPowerTotal()); diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index e1d604aa1..25630a8de 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -556,7 +556,6 @@ "VerboseLogging": "@:base.VerboseLogging", "PowerMeterParameter": "Power Meter Parameter", "PowerMeterSource": "Stromzählertyp", - "MQTT": "MQTT Konfiguration", "typeMQTT": "MQTT", "typeSDM1ph": "SDM mit 1 Phase (SDM120/220/230)", "typeSDM3ph": "SDM mit 3 Phasen (SDM72/630)", @@ -564,9 +563,8 @@ "typeSML": "SML/OBIS via serieller Verbindung (z.B. Hichi TTL)", "typeSMAHM2": "SMA Homemanager 2.0", "typeHTTP_SML": "HTTP(S) + SML (z.B. Tibber Pulse via Tibber Bridge)", - "MqttTopicPowerMeter1": "MQTT topic - Stromzähler #1", - "MqttTopicPowerMeter2": "MQTT topic - Stromzähler #2 (Optional)", - "MqttTopicPowerMeter3": "MQTT topic - Stromzähler #3 (Optional)", + "MqttValue": "Konfiguration für Wert {valueNumber}", + "MqttTopic": "MQTT Topic", "SDM": "SDM-Stromzähler Konfiguration", "sdmaddress": "Modbus Adresse", "HTTP_JSON": "HTTP(S) + JSON - Allgemeine Konfiguration", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 1fe86f365..8b5f7d0e0 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -558,7 +558,6 @@ "VerboseLogging": "@:base.VerboseLogging", "PowerMeterParameter": "Power Meter Parameter", "PowerMeterSource": "Power Meter type", - "MQTT": "MQTT Parameter", "typeMQTT": "MQTT", "typeSDM1ph": "SDM for 1 phase (SDM120/220/230)", "typeSDM3ph": "SDM for 3 phases (SDM72/630)", @@ -566,9 +565,8 @@ "typeSML": "SML/OBIS via serial connection (e.g. Hichi TTL)", "typeSMAHM2": "SMA Homemanager 2.0", "typeHTTP_SML": "HTTP(S) + SML (e.g. Tibber Pulse via Tibber Bridge)", - "MqttTopicPowerMeter1": "MQTT topic - Power meter #1", - "MqttTopicPowerMeter2": "MQTT topic - Power meter #2", - "MqttTopicPowerMeter3": "MQTT topic - Power meter #3", + "MqttValue": "Configuration for value {valueNumber}", + "MqttTopic": "MQTT topic", "SDM": "SDM-Power Meter Parameter", "sdmaddress": "Modbus Address", "HTTP": "HTTP(S) + JSON - General configuration", diff --git a/webapp/src/types/PowerMeterConfig.ts b/webapp/src/types/PowerMeterConfig.ts index fe9fa09ab..07c4c7dee 100644 --- a/webapp/src/types/PowerMeterConfig.ts +++ b/webapp/src/types/PowerMeterConfig.ts @@ -1,7 +1,19 @@ import type { HttpRequestConfig } from '@/types/HttpRequestConfig'; -export interface PowerMeterHttpJsonConfig { - index: number; +export interface PowerMeterMqttValue { + topic: string; +} + +export interface PowerMeterMqttConfig { + values: Array; +} + +export interface PowerMeterSerialSdmConfig { + polling_interval: number; + address: number; +}; + +export interface PowerMeterHttpJsonValue { http_request: HttpRequestConfig; enabled: boolean; json_path: string; @@ -9,7 +21,14 @@ export interface PowerMeterHttpJsonConfig { sign_inverted: boolean; } +export interface PowerMeterHttpJsonConfig { + polling_interval: number; + individual_requests: boolean; + values: Array; +} + export interface PowerMeterHttpSmlConfig { + polling_interval: number; http_request: HttpRequestConfig; } @@ -18,11 +37,8 @@ export interface PowerMeterConfig { verbose_logging: boolean; source: number; interval: number; - mqtt_topic_powermeter_1: string; - mqtt_topic_powermeter_2: string; - mqtt_topic_powermeter_3: string; - sdmaddress: number; - http_individual_requests: boolean; - http_json: Array; + mqtt: PowerMeterMqttConfig; + serial_sdm: PowerMeterSerialSdmConfig; + http_json: PowerMeterHttpJsonConfig; http_sml: PowerMeterHttpSmlConfig; } diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index 7575afc33..c389af4d7 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -36,38 +36,15 @@
-
- -
-
- -
-
-
-
- -
-
- -
-
-
- -
- -
-
- -
-
-
+
+ placeholder="1" v-model="powerMeterConfigList.serial_sdm.address" />
@@ -90,7 +67,7 @@ textVariant="text-bg-primary" add-space> @@ -114,9 +91,9 @@
- + Date: Thu, 23 May 2024 20:08:16 +0200 Subject: [PATCH 066/140] powermeter refactor: instanciate power meters with config instead of reading the main config's powermeter struct(s), the individual power meters now are instanciated using a copy of their respective config. this allows to instanciate different power meters with different configs. as a first step, this simplifies instanciating power meters for test purposes. --- include/PowerMeterHttpJson.h | 5 +++++ include/PowerMeterHttpSml.h | 6 ++++++ include/PowerMeterMqtt.h | 6 ++++++ include/PowerMeterSerialSdm.h | 13 +++++++++++++ src/PowerMeter.cpp | 18 +++++++++++------- src/PowerMeterHttpJson.cpp | 12 ++++-------- src/PowerMeterHttpSml.cpp | 8 ++------ src/PowerMeterMqtt.cpp | 8 +++----- src/PowerMeterSerialSdm.cpp | 9 +++------ src/WebApi_powermeter.cpp | 28 +++++++++------------------- 10 files changed, 62 insertions(+), 51 deletions(-) diff --git a/include/PowerMeterHttpJson.h b/include/PowerMeterHttpJson.h index 8810ca050..afccb05ac 100644 --- a/include/PowerMeterHttpJson.h +++ b/include/PowerMeterHttpJson.h @@ -14,6 +14,9 @@ using Unit_t = PowerMeterHttpJsonValue::Unit; class PowerMeterHttpJson : public PowerMeterProvider { public: + explicit PowerMeterHttpJson(PowerMeterHttpJsonConfig const& cfg) + : _cfg(cfg) { } + bool init() final; void loop() final; float getPowerTotal() const final; @@ -24,6 +27,8 @@ class PowerMeterHttpJson : public PowerMeterProvider { poll_result_t poll(); private: + PowerMeterHttpJsonConfig const _cfg; + uint32_t _lastPoll; power_values_t _powerValues; diff --git a/include/PowerMeterHttpSml.h b/include/PowerMeterHttpSml.h index b351674bf..30444b6a4 100644 --- a/include/PowerMeterHttpSml.h +++ b/include/PowerMeterHttpSml.h @@ -5,10 +5,14 @@ #include #include #include "HttpGetter.h" +#include "Configuration.h" #include "PowerMeterSml.h" class PowerMeterHttpSml : public PowerMeterSml { public: + explicit PowerMeterHttpSml(PowerMeterHttpSmlConfig const& cfg) + : _cfg(cfg) { } + bool init() final; void loop() final; @@ -17,6 +21,8 @@ class PowerMeterHttpSml : public PowerMeterSml { String poll(); private: + PowerMeterHttpSmlConfig const _cfg; + uint32_t _lastPoll = 0; std::unique_ptr _upHttpGetter; diff --git a/include/PowerMeterMqtt.h b/include/PowerMeterMqtt.h index 8880f4817..392571878 100644 --- a/include/PowerMeterMqtt.h +++ b/include/PowerMeterMqtt.h @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later #pragma once +#include "Configuration.h" #include "PowerMeterProvider.h" #include #include @@ -8,6 +9,9 @@ class PowerMeterMqtt : public PowerMeterProvider { public: + explicit PowerMeterMqtt(PowerMeterMqttConfig const& cfg) + : _cfg(cfg) { } + ~PowerMeterMqtt(); bool init() final; @@ -21,6 +25,8 @@ class PowerMeterMqtt : public PowerMeterProvider { uint8_t const* payload, size_t len, size_t index, size_t total, float* targetVariable); + PowerMeterMqttConfig const _cfg; + float _powerValueOne = 0; float _powerValueTwo = 0; float _powerValueThree = 0; diff --git a/include/PowerMeterSerialSdm.h b/include/PowerMeterSerialSdm.h index ff33bebdb..e98b286ee 100644 --- a/include/PowerMeterSerialSdm.h +++ b/include/PowerMeterSerialSdm.h @@ -2,11 +2,21 @@ #pragma once #include +#include "Configuration.h" #include "PowerMeterProvider.h" #include "SDM.h" class PowerMeterSerialSdm : public PowerMeterProvider { public: + enum class Phases { + One, + Three + }; + + PowerMeterSerialSdm(Phases phases, PowerMeterSerialSdmConfig const& cfg) + : _phases(phases) + , _cfg(cfg) { } + ~PowerMeterSerialSdm(); bool init() final; @@ -15,6 +25,9 @@ class PowerMeterSerialSdm : public PowerMeterProvider { void doMqttPublish() const final; private: + Phases _phases; + PowerMeterSerialSdmConfig const _cfg; + uint32_t _lastPoll; float _phase1Power = 0.0; diff --git a/src/PowerMeter.cpp b/src/PowerMeter.cpp index 472613186..3787dfe75 100644 --- a/src/PowerMeter.cpp +++ b/src/PowerMeter.cpp @@ -26,20 +26,24 @@ void PowerMeterClass::updateSettings() if (_upProvider) { _upProvider.reset(); } - auto const& config = Configuration.get(); + auto const& pmcfg = Configuration.get().PowerMeter; - if (!config.PowerMeter.Enabled) { return; } + if (!pmcfg.Enabled) { return; } - switch(static_cast(config.PowerMeter.Source)) { + switch(static_cast(pmcfg.Source)) { case PowerMeterProvider::Type::MQTT: - _upProvider = std::make_unique(); + _upProvider = std::make_unique(pmcfg.Mqtt); break; case PowerMeterProvider::Type::SDM1PH: + _upProvider = std::make_unique( + PowerMeterSerialSdm::Phases::One, pmcfg.SerialSdm); + break; case PowerMeterProvider::Type::SDM3PH: - _upProvider = std::make_unique(); + _upProvider = std::make_unique( + PowerMeterSerialSdm::Phases::Three, pmcfg.SerialSdm); break; case PowerMeterProvider::Type::HTTP_JSON: - _upProvider = std::make_unique(); + _upProvider = std::make_unique(pmcfg.HttpJson); break; case PowerMeterProvider::Type::SERIAL_SML: _upProvider = std::make_unique(); @@ -48,7 +52,7 @@ void PowerMeterClass::updateSettings() _upProvider = std::make_unique(); break; case PowerMeterProvider::Type::HTTP_SML: - _upProvider = std::make_unique(); + _upProvider = std::make_unique(pmcfg.HttpSml); break; } diff --git a/src/PowerMeterHttpJson.cpp b/src/PowerMeterHttpJson.cpp index 621670d31..822f2eb21 100644 --- a/src/PowerMeterHttpJson.cpp +++ b/src/PowerMeterHttpJson.cpp @@ -1,6 +1,5 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "Utils.h" -#include "Configuration.h" #include "PowerMeterHttpJson.h" #include "MessageOutput.h" #include @@ -11,14 +10,12 @@ bool PowerMeterHttpJson::init() { - auto const& config = Configuration.get(); - for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) { - auto const& valueConfig = config.PowerMeter.HttpJson.Values[i]; + auto const& valueConfig = _cfg.Values[i]; _httpGetters[i] = nullptr; - if (i == 0 || (config.PowerMeter.HttpJson.IndividualRequests && valueConfig.Enabled)) { + if (i == 0 || (_cfg.IndividualRequests && valueConfig.Enabled)) { _httpGetters[i] = std::make_unique(valueConfig.HttpRequest); } @@ -40,8 +37,7 @@ bool PowerMeterHttpJson::init() void PowerMeterHttpJson::loop() { - auto const& config = Configuration.get(); - if ((millis() - _lastPoll) < (config.PowerMeter.HttpJson.PollingInterval * 1000)) { + if ((millis() - _lastPoll) < (_cfg.PollingInterval * 1000)) { return; } @@ -68,7 +64,7 @@ PowerMeterHttpJson::poll_result_t PowerMeterHttpJson::poll() }; for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) { - auto const& cfg = Configuration.get().PowerMeter.HttpJson.Values[i]; + auto const& cfg = _cfg.Values[i]; if (!cfg.Enabled) { cache[i] = 0.0; diff --git a/src/PowerMeterHttpSml.cpp b/src/PowerMeterHttpSml.cpp index e9a383893..7d64f442e 100644 --- a/src/PowerMeterHttpSml.cpp +++ b/src/PowerMeterHttpSml.cpp @@ -1,5 +1,4 @@ // SPDX-License-Identifier: GPL-2.0-or-later -#include "Configuration.h" #include "PowerMeterHttpSml.h" #include "MessageOutput.h" #include @@ -8,9 +7,7 @@ bool PowerMeterHttpSml::init() { - auto const& config = Configuration.get(); - - _upHttpGetter = std::make_unique(config.PowerMeter.HttpSml.HttpRequest); + _upHttpGetter = std::make_unique(_cfg.HttpRequest); if (_upHttpGetter->init()) { return true; } @@ -24,8 +21,7 @@ bool PowerMeterHttpSml::init() void PowerMeterHttpSml::loop() { - auto const& config = Configuration.get(); - if ((millis() - _lastPoll) < (config.PowerMeter.HttpSml.PollingInterval * 1000)) { + if ((millis() - _lastPoll) < (_cfg.PollingInterval * 1000)) { return; } diff --git a/src/PowerMeterMqtt.cpp b/src/PowerMeterMqtt.cpp index 9a4654b70..1ce9fbb07 100644 --- a/src/PowerMeterMqtt.cpp +++ b/src/PowerMeterMqtt.cpp @@ -1,6 +1,5 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "PowerMeterMqtt.h" -#include "Configuration.h" #include "MqttSettings.h" #include "MessageOutput.h" @@ -18,10 +17,9 @@ bool PowerMeterMqtt::init() _mqttSubscriptions.push_back(topic); }; - auto const& config = Configuration.get(); - subscribe(config.PowerMeter.Mqtt.Values[0].Topic, &_powerValueOne); - subscribe(config.PowerMeter.Mqtt.Values[1].Topic, &_powerValueTwo); - subscribe(config.PowerMeter.Mqtt.Values[2].Topic, &_powerValueThree); + subscribe(_cfg.Values[0].Topic, &_powerValueOne); + subscribe(_cfg.Values[1].Topic, &_powerValueTwo); + subscribe(_cfg.Values[2].Topic, &_powerValueThree); return _mqttSubscriptions.size() > 0; } diff --git a/src/PowerMeterSerialSdm.cpp b/src/PowerMeterSerialSdm.cpp index a425f9868..f5552aaaa 100644 --- a/src/PowerMeterSerialSdm.cpp +++ b/src/PowerMeterSerialSdm.cpp @@ -1,6 +1,5 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "PowerMeterSerialSdm.h" -#include "Configuration.h" #include "PinMapping.h" #include "MessageOutput.h" #include "SerialPortManager.h" @@ -61,13 +60,11 @@ void PowerMeterSerialSdm::loop() { if (!_upSdm) { return; } - auto const& config = Configuration.get(); - - if ((millis() - _lastPoll) < (config.PowerMeter.SerialSdm.PollingInterval * 1000)) { + if ((millis() - _lastPoll) < (_cfg.PollingInterval * 1000)) { return; } - uint8_t addr = config.PowerMeter.SerialSdm.Address; + uint8_t addr = _cfg.Address; // reading takes a "very long" time as each readVal() is a synchronous // exchange of serial messages. cache the values and write later to @@ -81,7 +78,7 @@ void PowerMeterSerialSdm::loop() float energyImport = _upSdm->readVal(SDM_IMPORT_ACTIVE_ENERGY, addr); float energyExport = _upSdm->readVal(SDM_EXPORT_ACTIVE_ENERGY, addr); - if (static_cast(config.PowerMeter.Source) == PowerMeterProvider::Type::SDM3PH) { + if (_phases == Phases::Three) { phase2Power = _upSdm->readVal(SDM_PHASE_2_POWER, addr); phase3Power = _upSdm->readVal(SDM_PHASE_3_POWER, addr); phase2Voltage = _upSdm->readVal(SDM_PHASE_2_VOLTAGE, addr); diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index 0fea84ed7..b0e2bbcd2 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -188,25 +188,19 @@ void WebApiPowerMeterClass::onTestHttpJsonRequest(AsyncWebServerRequest* request char response[256]; - auto powerMeterConfig = std::make_unique(); - JsonObject httpJson = root["http_json"]; - powerMeterConfig->HttpJson.IndividualRequests = httpJson["individual_requests"].as(); - powerMeterConfig->VerboseLogging = true; - Configuration.deserializePowerMeterHttpJsonConfig(httpJson, - powerMeterConfig->HttpJson); - auto backup = std::make_unique(Configuration.get().PowerMeter); - Configuration.get().PowerMeter = *powerMeterConfig; - auto upMeter = std::make_unique(); + auto powerMeterConfig = std::make_unique(); + Configuration.deserializePowerMeterHttpJsonConfig(root["http_json"].as(), + *powerMeterConfig); + auto upMeter = std::make_unique(*powerMeterConfig); upMeter->init(); auto res = upMeter->poll(); - Configuration.get().PowerMeter = *backup; using values_t = PowerMeterHttpJson::power_values_t; if (std::holds_alternative(res)) { retMsg["type"] = "success"; auto vals = std::get(res); auto pos = snprintf(response, sizeof(response), "Result: %5.2fW", vals[0]); - for (size_t i = 1; i < POWERMETER_HTTP_JSON_MAX_VALUES; ++i) { - if (!powerMeterConfig->HttpJson.Values[i].Enabled) { continue; } + for (size_t i = 1; i < vals.size(); ++i) { + if (!powerMeterConfig->Values[i].Enabled) { continue; } pos += snprintf(response + pos, sizeof(response) - pos, ", %5.2fW", vals[i]); } snprintf(response + pos, sizeof(response) - pos, ", Total: %5.2f", upMeter->getPowerTotal()); @@ -235,16 +229,12 @@ void WebApiPowerMeterClass::onTestHttpSmlRequest(AsyncWebServerRequest* request) char response[256]; - auto powerMeterConfig = std::make_unique(); + auto powerMeterConfig = std::make_unique(); Configuration.deserializePowerMeterHttpSmlConfig(root["http_sml"].as(), - powerMeterConfig->HttpSml); - powerMeterConfig->VerboseLogging = true; - auto backup = std::make_unique(Configuration.get().PowerMeter); - Configuration.get().PowerMeter = *powerMeterConfig; - auto upMeter = std::make_unique(); + *powerMeterConfig); + auto upMeter = std::make_unique(*powerMeterConfig); upMeter->init(); auto res = upMeter->poll(); - Configuration.get().PowerMeter = *backup; if (res.isEmpty()) { retMsg["type"] = "success"; snprintf(response, sizeof(response), "Result: %5.2fW", upMeter->getPowerTotal()); From a2a9debd025dc7d1598f95b072173e94b0751218 Mon Sep 17 00:00:00 2001 From: Bernhard Kirchen Date: Thu, 23 May 2024 20:26:39 +0200 Subject: [PATCH 067/140] Feature: make power meter polling intervals configurable this change makes the respective setting accessible from the web UI. --- include/PowerMeterHttpJson.h | 1 + include/PowerMeterHttpSml.h | 1 + include/PowerMeterProvider.h | 2 +- include/PowerMeterSerialSdm.h | 1 + src/PowerMeterHttpJson.cpp | 6 ++++++ src/PowerMeterHttpSml.cpp | 6 ++++++ src/PowerMeterSerialSdm.cpp | 6 ++++++ webapp/src/locales/de.json | 5 +++-- webapp/src/locales/en.json | 5 +++-- webapp/src/views/PowerMeterAdminView.vue | 23 +++++++++++++++++++++++ 10 files changed, 51 insertions(+), 5 deletions(-) diff --git a/include/PowerMeterHttpJson.h b/include/PowerMeterHttpJson.h index afccb05ac..110517c9e 100644 --- a/include/PowerMeterHttpJson.h +++ b/include/PowerMeterHttpJson.h @@ -20,6 +20,7 @@ class PowerMeterHttpJson : public PowerMeterProvider { bool init() final; void loop() final; float getPowerTotal() const final; + bool isDataValid() const final; void doMqttPublish() const final; using power_values_t = std::array; diff --git a/include/PowerMeterHttpSml.h b/include/PowerMeterHttpSml.h index 30444b6a4..eb201a1ba 100644 --- a/include/PowerMeterHttpSml.h +++ b/include/PowerMeterHttpSml.h @@ -15,6 +15,7 @@ class PowerMeterHttpSml : public PowerMeterSml { bool init() final; void loop() final; + bool isDataValid() const final; // returns an empty string on success, // returns an error message otherwise. diff --git a/include/PowerMeterProvider.h b/include/PowerMeterProvider.h index 4cd1c888c..0ca7bcdf5 100644 --- a/include/PowerMeterProvider.h +++ b/include/PowerMeterProvider.h @@ -23,9 +23,9 @@ class PowerMeterProvider { virtual void loop() = 0; virtual float getPowerTotal() const = 0; + virtual bool isDataValid() const; uint32_t getLastUpdate() const { return _lastUpdate; } - bool isDataValid() const; void mqttLoop() const; protected: diff --git a/include/PowerMeterSerialSdm.h b/include/PowerMeterSerialSdm.h index e98b286ee..56a52e6e0 100644 --- a/include/PowerMeterSerialSdm.h +++ b/include/PowerMeterSerialSdm.h @@ -22,6 +22,7 @@ class PowerMeterSerialSdm : public PowerMeterProvider { bool init() final; void loop() final; float getPowerTotal() const final; + bool isDataValid() const final; void doMqttPublish() const final; private: diff --git a/src/PowerMeterHttpJson.cpp b/src/PowerMeterHttpJson.cpp index 822f2eb21..c199c41c5 100644 --- a/src/PowerMeterHttpJson.cpp +++ b/src/PowerMeterHttpJson.cpp @@ -124,6 +124,12 @@ float PowerMeterHttpJson::getPowerTotal() const return sum; } +bool PowerMeterHttpJson::isDataValid() const +{ + uint32_t age = millis() - getLastUpdate(); + return getLastUpdate() > 0 && (age < (3 * _cfg.PollingInterval * 1000)); +} + void PowerMeterHttpJson::doMqttPublish() const { mqttPublish("power1", _powerValues[0]); diff --git a/src/PowerMeterHttpSml.cpp b/src/PowerMeterHttpSml.cpp index 7d64f442e..799355c6e 100644 --- a/src/PowerMeterHttpSml.cpp +++ b/src/PowerMeterHttpSml.cpp @@ -36,6 +36,12 @@ void PowerMeterHttpSml::loop() gotUpdate(); } +bool PowerMeterHttpSml::isDataValid() const +{ + uint32_t age = millis() - getLastUpdate(); + return getLastUpdate() > 0 && (age < (3 * _cfg.PollingInterval * 1000)); +} + String PowerMeterHttpSml::poll() { if (!_upHttpGetter) { diff --git a/src/PowerMeterSerialSdm.cpp b/src/PowerMeterSerialSdm.cpp index f5552aaaa..3c9941f64 100644 --- a/src/PowerMeterSerialSdm.cpp +++ b/src/PowerMeterSerialSdm.cpp @@ -43,6 +43,12 @@ float PowerMeterSerialSdm::getPowerTotal() const return _phase1Power + _phase2Power + _phase3Power; } +bool PowerMeterSerialSdm::isDataValid() const +{ + uint32_t age = millis() - getLastUpdate(); + return getLastUpdate() > 0 && (age < (3 * _cfg.PollingInterval * 1000)); +} + void PowerMeterSerialSdm::doMqttPublish() const { std::lock_guard l(_mutex); diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 25630a8de..2d668aa0a 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -554,8 +554,9 @@ "PowerMeterConfiguration": "Stromzähler Konfiguration", "PowerMeterEnable": "Aktiviere Stromzähler", "VerboseLogging": "@:base.VerboseLogging", - "PowerMeterParameter": "Power Meter Parameter", "PowerMeterSource": "Stromzählertyp", + "pollingInterval": "Abfrageintervall", + "seconds": "@:base.Seconds", "typeMQTT": "MQTT", "typeSDM1ph": "SDM mit 1 Phase (SDM120/220/230)", "typeSDM3ph": "SDM mit 3 Phasen (SDM72/630)", @@ -598,7 +599,7 @@ "headerKeyDescription": "Optional. Ein benutzerdefinierter HTTP header kann definiert werden. Nützlich um z.B. ein (zusätzlichen) Authentifizierungstoken zu übermitteln.", "headerValue": "HTTP Header - Wert", "timeout": "Zeitüberschreitung", - "milliSeconds": "ms" + "milliSeconds": "Millisekunden" }, "powerlimiteradmin": { "PowerLimiterSettings": "Dynamic Power Limiter Einstellungen", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 8b5f7d0e0..66328d823 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -556,8 +556,9 @@ "PowerMeterConfiguration": "Power Meter Configuration", "PowerMeterEnable": "Enable Power Meter", "VerboseLogging": "@:base.VerboseLogging", - "PowerMeterParameter": "Power Meter Parameter", "PowerMeterSource": "Power Meter type", + "pollingInterval": "Polling Interval", + "seconds": "@:base.Seconds", "typeMQTT": "MQTT", "typeSDM1ph": "SDM for 1 phase (SDM120/220/230)", "typeSDM3ph": "SDM for 3 phases (SDM72/630)", @@ -600,7 +601,7 @@ "headerKeyDescription": "Optional. A custom HTTP header key-value pair can be defined. Useful, e.g., to send an (additional) authentication token.", "headerValue": "HTTP Header - Value", "timeout": "Timeout", - "milliSeconds": "ms" + "milliSeconds": "Milliseconds" }, "powerlimiteradmin": { "PowerLimiterSettings": "Dynamic Power Limiter Settings", diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index c389af4d7..63535285f 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -51,6 +51,14 @@ :text="$t('powermeteradmin.SDM')" textVariant="text-bg-primary" add-space> + + +
@@ -70,6 +78,14 @@ v-model="powerMeterConfigList.http_json.individual_requests" type="checkbox" wide /> + +