diff --git a/.gitignore b/.gitignore index 8d8cf763..c29e72f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ .vscode/ipch .vscode/settings.json platformio-device-monitor*.log +logs/device-monitor*.log platformio_override.ini .DS_Store diff --git a/docs/DeviceProfiles/wt32-eth01.json b/docs/DeviceProfiles/wt32-eth01.json index 971dec91..e8aee231 100644 --- a/docs/DeviceProfiles/wt32-eth01.json +++ b/docs/DeviceProfiles/wt32-eth01.json @@ -18,5 +18,30 @@ "type": 0, "clk_mode": 0 } + }, + { + "name": "WT32-ETH01 with SSD1306", + "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": 2, + "data": 5, + "clk": 17 + } } ] \ No newline at end of file diff --git a/include/Configuration.h b/include/Configuration.h index 6e98b34c..da90d226 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -40,6 +40,7 @@ struct CHANNEL_CONFIG_T { struct INVERTER_CONFIG_T { uint64_t Serial; char Name[INV_MAX_NAME_STRLEN + 1]; + uint8_t Order; bool Poll_Enable; bool Poll_Enable_Night; bool Command_Enable; @@ -66,6 +67,7 @@ struct CONFIG_T { char Ntp_TimezoneDescr[NTP_MAX_TIMEZONEDESCR_STRLEN + 1]; double Ntp_Longitude; double Ntp_Latitude; + uint8_t Ntp_SunsetType; bool Mqtt_Enabled; uint Mqtt_Port; @@ -109,6 +111,7 @@ struct CONFIG_T { bool Display_ScreenSaver; uint8_t Display_Rotation; uint8_t Display_Contrast; + uint8_t Display_Language; }; class ConfigurationClass { diff --git a/include/Datastore.h b/include/Datastore.h new file mode 100644 index 00000000..4f9545a0 --- /dev/null +++ b/include/Datastore.h @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include +#include + +class DatastoreClass { +public: + DatastoreClass(); + void init(); + void loop(); + + // Sum of yield total of all enabled inverters, a inverter which is just disabled at night is also included + float getTotalAcYieldTotalEnabled(); + + // Sum of yield day of all enabled inverters, a inverter which is just disabled at night is also included + float getTotalAcYieldDayEnabled(); + + // Sum of total AC power of all enabled inverters + float getTotalAcPowerEnabled(); + + // Sum of total DC power of all enabled inverters + float getTotalDcPowerEnabled(); + + // Sum of total DC power of all enabled inverters with maxStringPower set + float getTotalDcPowerIrradiation(); + + // Sum of total installed irradiation of all enabled inverters + float getTotalDcIrradiationInstalled(); + + // Percentage (1-100) of total irradiation + float getTotalDcIrradiation(); + + // Amount of relevant digits for yield total + unsigned int getTotalAcYieldTotalDigits(); + + // Amount of relevant digits for yield total + unsigned int getTotalAcYieldDayDigits(); + + // Amount of relevant digits for AC power + unsigned int getTotalAcPowerDigits(); + + // Amount of relevant digits for DC power + unsigned int getTotalDcPowerDigits(); + + // True, if at least one inverter is reachable + bool getIsAtLeastOneReachable(); + + // True if at least one inverter is producing + bool getIsAtLeastOneProducing(); + + // True if all enabled inverters are producing + bool getIsAllEnabledProducing(); + + // True if all enabled inverters are reachable + bool getIsAllEnabledReachable(); + +private: + TimeoutHelper _updateTimeout; + SemaphoreHandle_t _xSemaphore; + + float _totalAcYieldTotalEnabled = 0; + float _totalAcYieldDayEnabled = 0; + float _totalAcPowerEnabled = 0; + float _totalDcPowerEnabled = 0; + float _totalDcPowerIrradiation = 0; + float _totalDcIrradiationInstalled = 0; + float _totalDcIrradiation = 0; + unsigned int _totalAcYieldTotalDigits = 0; + unsigned int _totalAcYieldDayDigits = 0; + unsigned int _totalAcPowerDigits = 0; + unsigned int _totalDcPowerDigits = 0; + bool _isAtLeastOneReachable = false; + bool _isAtLeastOneProducing = false; + bool _isAllEnabledProducing = false; + bool _isAllEnabledReachable = false; +}; + +extern DatastoreClass Datastore; \ No newline at end of file diff --git a/include/Display_Graphic.h b/include/Display_Graphic.h index 18c1c3aa..ac0512df 100644 --- a/include/Display_Graphic.h +++ b/include/Display_Graphic.h @@ -20,6 +20,7 @@ public: void loop(); void setContrast(uint8_t contrast); void setOrientation(uint8_t rotation = DISPLAY_ROTATION); + void setLanguage(uint8_t language); void setStartupDisplay(); bool enablePowerSafe = true; @@ -33,6 +34,7 @@ private: U8G2* _display; DisplayType_t _display_type = DisplayType_t::None; + uint8_t _display_language = DISPLAY_LANGUAGE; uint8_t _mExtra; uint16_t _period = 1000; uint16_t _interval = 60000; // interval at which to power save (milliseconds) diff --git a/include/WebApi_errors.h b/include/WebApi_errors.h index e4ec7881..8107840d 100644 --- a/include/WebApi_errors.h +++ b/include/WebApi_errors.h @@ -28,6 +28,7 @@ enum WebApiError { InverterInvalidMaxChannel, InverterChanged, InverterDeleted, + InverterOrdered, LimitBase = 5000, LimitSerialZero, diff --git a/include/WebApi_inverter.h b/include/WebApi_inverter.h index c2298ec9..9f2b0673 100644 --- a/include/WebApi_inverter.h +++ b/include/WebApi_inverter.h @@ -13,6 +13,7 @@ private: void onInverterAdd(AsyncWebServerRequest* request); void onInverterEdit(AsyncWebServerRequest* request); void onInverterDelete(AsyncWebServerRequest* request); + void onInverterOrder(AsyncWebServerRequest* request); AsyncWebServer* _server; }; \ No newline at end of file diff --git a/include/defaults.h b/include/defaults.h index 70ab2a3b..7420dd19 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -25,6 +25,7 @@ #define NTP_TIMEZONEDESCR "Europe/Berlin" #define NTP_LONGITUDE 10.4515f #define NTP_LATITUDE 51.1657f +#define NTP_SUNSETTYPE 1 #define MQTT_ENABLED false #define MQTT_HOST "" @@ -91,4 +92,5 @@ #define DISPLAY_POWERSAFE true #define DISPLAY_SCREENSAVER true #define DISPLAY_ROTATION 2 -#define DISPLAY_CONTRAST 60 \ No newline at end of file +#define DISPLAY_CONTRAST 60 +#define DISPLAY_LANGUAGE 0 \ No newline at end of file diff --git a/lib/Hoymiles/src/HoymilesRadio_CMT.cpp b/lib/Hoymiles/src/HoymilesRadio_CMT.cpp index b083248c..4c3285f5 100644 --- a/lib/Hoymiles/src/HoymilesRadio_CMT.cpp +++ b/lib/Hoymiles/src/HoymilesRadio_CMT.cpp @@ -121,17 +121,25 @@ void HoymilesRadio_CMT::loop() if (!_rxBuffer.empty()) { fragment_t f = _rxBuffer.back(); if (checkFragmentCrc(&f)) { - std::shared_ptr inv = Hoymiles.getInverterByFragment(&f); - if (nullptr != inv) { - // Save packet in inverter rx buffer - Hoymiles.getMessageOutput()->printf("RX %.2f MHz --> ", getFrequencyFromChannel(f.channel)); - dumpBuf(f.fragment, f.len, false); - Hoymiles.getMessageOutput()->printf("| %d dBm\r\n", f.rssi); + serial_u dtuId = convertSerialToRadioId(_dtuSerial); - inv->addRxFragment(f.fragment, f.len); - } else { - Hoymiles.getMessageOutput()->println("Inverter Not found!"); + // The CMT RF module does not filter foreign packages by itself. + // Has to be done manually here. + if (memcmp(&f.fragment[5], &dtuId.b[1], 4) == 0) { + + std::shared_ptr inv = Hoymiles.getInverterByFragment(&f); + + if (nullptr != inv) { + // Save packet in inverter rx buffer + Hoymiles.getMessageOutput()->printf("RX %.2f MHz --> ", getFrequencyFromChannel(f.channel)); + dumpBuf(f.fragment, f.len, false); + Hoymiles.getMessageOutput()->printf("| %d dBm\r\n", f.rssi); + + inv->addRxFragment(f.fragment, f.len); + } else { + Hoymiles.getMessageOutput()->println("Inverter Not found!"); + } } } else { diff --git a/lib/Hoymiles/src/commands/MultiDataCommand.cpp b/lib/Hoymiles/src/commands/MultiDataCommand.cpp index c34ccd00..b25c9027 100644 --- a/lib/Hoymiles/src/commands/MultiDataCommand.cpp +++ b/lib/Hoymiles/src/commands/MultiDataCommand.cpp @@ -68,6 +68,11 @@ bool MultiDataCommand::handleResponse(InverterAbstract* inverter, fragment_t fra uint16_t crc = 0xffff, crcRcv = 0; for (uint8_t i = 0; i < max_fragment_id; i++) { + // Doublecheck if correct answer package + if (fragment[i].mainCmd != (_payload[0] | 0x80)) { + return false; + } + if (i == max_fragment_id - 1) { // Last packet crc = crc16(fragment[i].fragment, fragment[i].len - 2, crc); diff --git a/lib/Hoymiles/src/inverters/InverterAbstract.cpp b/lib/Hoymiles/src/inverters/InverterAbstract.cpp index 5830da0a..2383f5fc 100644 --- a/lib/Hoymiles/src/inverters/InverterAbstract.cpp +++ b/lib/Hoymiles/src/inverters/InverterAbstract.cpp @@ -7,7 +7,7 @@ #include "crc.h" #include -InverterAbstract::InverterAbstract(HoymilesRadio *radio, uint64_t serial) +InverterAbstract::InverterAbstract(HoymilesRadio* radio, uint64_t serial) { _serial.u64 = serial; _radio = radio; @@ -152,26 +152,32 @@ void InverterAbstract::addRxFragment(uint8_t fragment[], uint8_t len) } uint8_t fragmentCount = fragment[9]; - if (fragmentCount == 0) { - Hoymiles.getMessageOutput()->println("ERROR: fragment number zero received and ignored"); + + // Packets with 0x81 will be seen as 1 + uint8_t fragmentId = fragmentCount & 0b01111111; // fragmentId is 1 based + + if (fragmentId == 0) { + Hoymiles.getMessageOutput()->println("ERROR: fragment id zero received and ignored"); return; } - if ((fragmentCount & 0b01111111) < MAX_RF_FRAGMENT_COUNT) { - // Packets with 0x81 will be seen as 1 - memcpy(_rxFragmentBuffer[(fragmentCount & 0b01111111) - 1].fragment, &fragment[10], len - 11); - _rxFragmentBuffer[(fragmentCount & 0b01111111) - 1].len = len - 11; - _rxFragmentBuffer[(fragmentCount & 0b01111111) - 1].mainCmd = fragment[0]; - _rxFragmentBuffer[(fragmentCount & 0b01111111) - 1].wasReceived = true; + if (fragmentId >= MAX_RF_FRAGMENT_COUNT) { + Hoymiles.getMessageOutput()->printf("ERROR: fragment id %d is too large for buffer and ignored\r\n", fragmentId); + return; + } - if ((fragmentCount & 0b01111111) > _rxFragmentLastPacketId) { - _rxFragmentLastPacketId = fragmentCount & 0b01111111; - } + memcpy(_rxFragmentBuffer[fragmentId - 1].fragment, &fragment[10], len - 11); + _rxFragmentBuffer[fragmentId - 1].len = len - 11; + _rxFragmentBuffer[fragmentId - 1].mainCmd = fragment[0]; + _rxFragmentBuffer[fragmentId - 1].wasReceived = true; + + if (fragmentId > _rxFragmentLastPacketId) { + _rxFragmentLastPacketId = fragmentId; } // 0b10000000 == 0x80 if ((fragmentCount & 0b10000000) == 0b10000000) { - _rxFragmentMaxPacketId = fragmentCount & 0b01111111; + _rxFragmentMaxPacketId = fragmentId; } } diff --git a/lib/Hoymiles/src/parser/AlarmLogParser.cpp b/lib/Hoymiles/src/parser/AlarmLogParser.cpp index 1ac701be..3e1dc664 100644 --- a/lib/Hoymiles/src/parser/AlarmLogParser.cpp +++ b/lib/Hoymiles/src/parser/AlarmLogParser.cpp @@ -6,9 +6,10 @@ #include "../Hoymiles.h" #include -const std::array AlarmLogParser::_alarmMessages = {{ +const std::array AlarmLogParser::_alarmMessages = {{ { AlarmMessageType_t::ALL, 1, "Inverter start" }, { AlarmMessageType_t::ALL, 2, "DTU command failed" }, + { AlarmMessageType_t::ALL, 73, "Temperature >80°C" }, // https://github.com/tbnobody/OpenDTU/discussions/590#discussioncomment-6049750 { AlarmMessageType_t::ALL, 121, "Over temperature protection" }, { AlarmMessageType_t::ALL, 124, "Shut down by remote control" }, { AlarmMessageType_t::ALL, 125, "Grid configuration parameter error" }, diff --git a/lib/Hoymiles/src/parser/AlarmLogParser.h b/lib/Hoymiles/src/parser/AlarmLogParser.h index b57948be..0286db15 100644 --- a/lib/Hoymiles/src/parser/AlarmLogParser.h +++ b/lib/Hoymiles/src/parser/AlarmLogParser.h @@ -9,6 +9,8 @@ #define ALARM_LOG_ENTRY_SIZE 12 #define ALARM_LOG_PAYLOAD_SIZE (ALARM_LOG_ENTRY_COUNT * ALARM_LOG_ENTRY_SIZE + 4) +#define ALARM_MSG_COUNT 77 + struct AlarmLogEntry_t { uint16_t MessageId; String Message; @@ -50,5 +52,5 @@ private: AlarmMessageType_t _messageType = AlarmMessageType_t::ALL; - static const std::array _alarmMessages; + static const std::array _alarmMessages; }; \ No newline at end of file diff --git a/lib/Hoymiles/src/parser/DevInfoParser.cpp b/lib/Hoymiles/src/parser/DevInfoParser.cpp index 01245c35..0c1a10f5 100644 --- a/lib/Hoymiles/src/parser/DevInfoParser.cpp +++ b/lib/Hoymiles/src/parser/DevInfoParser.cpp @@ -29,9 +29,11 @@ const devInfo_t devInfo[] = { { { 0x10, 0x10, 0x10, 0x15 }, static_cast(300 * 0.7), "HM-300" }, // HM-300 factory limitted to 70% { { 0x10, 0x20, 0x21, ALL }, 350, "HMS-350" }, // 00 + { { 0x10, 0x10, 0x51, ALL }, 450, "HMS-450" }, // 01 { { 0x10, 0x10, 0x71, ALL }, 500, "HMS-500" }, // 02 { { 0x10, 0x21, 0x11, ALL }, 600, "HMS-600" }, // 01 - { { 0x10, 0x21, 0x41, ALL }, 800, "HMS-800" }, // 00 + { { 0x10, 0x21, 0x41, ALL }, 800, "HMS-800" }, // 00 + { { 0x10, 0x11, 0x51, ALL }, 900, "HMS-900" }, // 01 { { 0x10, 0x21, 0x71, ALL }, 1000, "HMS-1000" }, // 05 { { 0x10, 0x22, 0x41, ALL }, 1600, "HMS-1600" }, // 4 { { 0x10, 0x12, 0x51, ALL }, 1800, "HMS-1800" }, // 01 diff --git a/pio-scripts/create_factory_bin.py b/pio-scripts/create_factory_bin.py new file mode 100644 index 00000000..56f71c4b --- /dev/null +++ b/pio-scripts/create_factory_bin.py @@ -0,0 +1,77 @@ +# Part of ESPEasy build toolchain. +# +# Combines separate bin files with their respective offsets into a single file +# This single file must then be flashed to an ESP32 node with 0 offset. +# +# Original implementation: Bartłomiej Zimoń (@uzi18) +# Maintainer: Gijs Noorlander (@TD-er) +# +# Special thanks to @Jason2866 (Tasmota) for helping debug flashing to >4MB flash +# Thanks @jesserockz (esphome) for adapting to use esptool.py with merge_bin +# +# Typical layout of the generated file: +# Offset | File +# - 0x1000 | ~\.platformio\packages\framework-arduinoespressif32\tools\sdk\esp32\bin\bootloader_dout_40m.bin +# - 0x8000 | ~\ESPEasy\.pio\build\\partitions.bin +# - 0xe000 | ~\.platformio\packages\framework-arduinoespressif32\tools\partitions\boot_app0.bin +# - 0x10000 | ~\ESPEasy\.pio\build\/.bin + +Import("env") + +platform = env.PioPlatform() + +import sys +from os.path import join + +sys.path.append(join(platform.get_package_dir("tool-esptoolpy"))) +import esptool + +def esp32_create_combined_bin(source, target, env): + print("Generating combined binary for serial flashing") + + # The offset from begin of the file where the app0 partition starts + # This is defined in the partition .csv file + app_offset = 0x10000 + + new_file_name = env.subst("$BUILD_DIR/${PROGNAME}.factory.bin") + sections = env.subst(env.get("FLASH_EXTRA_IMAGES")) + firmware_name = env.subst("$BUILD_DIR/${PROGNAME}.bin") + chip = env.get("BOARD_MCU") + flash_size = env.BoardConfig().get("upload.flash_size") + flash_freq = env.BoardConfig().get("build.f_flash", '40m') + flash_freq = flash_freq.replace('000000L', 'm') + flash_mode = env.BoardConfig().get("build.flash_mode", "dio") + memory_type = env.BoardConfig().get("build.arduino.memory_type", "qio_qspi") + if flash_mode == "qio" or flash_mode == "qout": + flash_mode = "dio" + if memory_type == "opi_opi" or memory_type == "opi_qspi": + flash_mode = "dout" + cmd = [ + "--chip", + chip, + "merge_bin", + "-o", + new_file_name, + "--flash_mode", + flash_mode, + "--flash_freq", + flash_freq, + "--flash_size", + flash_size, + ] + + print(" Offset | File") + for section in sections: + sect_adr, sect_file = section.split(" ", 1) + print(f" - {sect_adr} | {sect_file}") + cmd += [sect_adr, sect_file] + + print(f" - {hex(app_offset)} | {firmware_name}") + cmd += [hex(app_offset), firmware_name] + + print('Using esptool.py arguments: %s' % ' '.join(cmd)) + + esptool.main(cmd) + + +env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_create_combined_bin) \ No newline at end of file diff --git a/platformio.ini b/platformio.ini index e3655940..382441db 100644 --- a/platformio.ini +++ b/platformio.ini @@ -15,7 +15,7 @@ extra_configs = [env] framework = arduino -platform = espressif32@6.1.0 +platform = espressif32@6.3.0 build_flags = -DCOMPONENT_EMBED_FILES=webapp_dist/index.html.gz:webapp_dist/zones.json.gz:webapp_dist/favicon.ico:webapp_dist/js/app.js.gz @@ -29,7 +29,7 @@ build_unflags = lib_deps = https://github.com/yubox-node-org/ESPAsyncWebServer bblanchon/ArduinoJson @ ^6.21.2 - https://github.com/bertmelis/espMqttClient.git#v1.4.2 + https://github.com/bertmelis/espMqttClient.git#v1.4.3 nrf24/RF24 @ ^1.4.5 olikraus/U8g2 @ ^2.34.17 buelowp/sunset @ ^1.1.7 @@ -37,6 +37,7 @@ lib_deps = extra_scripts = pre:pio-scripts/auto_firmware_version.py pre:pio-scripts/patch_apply.py + post:pio-scripts/create_factory_bin.py board_build.partitions = partitions_custom.csv board_build.filesystem = littlefs diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 21df4f53..867adeaf 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -46,6 +46,7 @@ bool ConfigurationClass::write() ntp["timezone_descr"] = config.Ntp_TimezoneDescr; ntp["latitude"] = config.Ntp_Latitude; ntp["longitude"] = config.Ntp_Longitude; + ntp["sunsettype"] = config.Ntp_SunsetType; JsonObject mqtt = doc.createNestedObject("mqtt"); mqtt["enabled"] = config.Mqtt_Enabled; @@ -95,12 +96,14 @@ bool ConfigurationClass::write() display["screensaver"] = config.Display_ScreenSaver; display["rotation"] = config.Display_Rotation; display["contrast"] = config.Display_Contrast; + display["language"] = config.Display_Language; JsonArray inverters = doc.createNestedArray("inverters"); for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { JsonObject inv = inverters.createNestedObject(); inv["serial"] = config.Inverter[i].Serial; inv["name"] = config.Inverter[i].Name; + inv["order"] = config.Inverter[i].Order; inv["poll_enable"] = config.Inverter[i].Poll_Enable; inv["poll_enable_night"] = config.Inverter[i].Poll_Enable_Night; inv["command_enable"] = config.Inverter[i].Command_Enable; @@ -188,6 +191,7 @@ bool ConfigurationClass::read() strlcpy(config.Ntp_TimezoneDescr, ntp["timezone_descr"] | NTP_TIMEZONEDESCR, sizeof(config.Ntp_TimezoneDescr)); config.Ntp_Latitude = ntp["latitude"] | NTP_LATITUDE; config.Ntp_Longitude = ntp["longitude"] | NTP_LONGITUDE; + config.Ntp_SunsetType = ntp["sunsettype"] | NTP_SUNSETTYPE; JsonObject mqtt = doc["mqtt"]; config.Mqtt_Enabled = mqtt["enabled"] | MQTT_ENABLED; @@ -237,12 +241,14 @@ bool ConfigurationClass::read() config.Display_ScreenSaver = display["screensaver"] | DISPLAY_SCREENSAVER; config.Display_Rotation = display["rotation"] | DISPLAY_ROTATION; config.Display_Contrast = display["contrast"] | DISPLAY_CONTRAST; + config.Display_Language = display["language"] | DISPLAY_LANGUAGE; JsonArray inverters = doc["inverters"]; for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { JsonObject inv = inverters[i].as(); config.Inverter[i].Serial = inv["serial"] | 0ULL; strlcpy(config.Inverter[i].Name, inv["name"] | "", sizeof(config.Inverter[i].Name)); + config.Inverter[i].Order = inv["order"] | 0; config.Inverter[i].Poll_Enable = inv["poll_enable"] | true; config.Inverter[i].Poll_Enable_Night = inv["poll_enable_night"] | true; diff --git a/src/Datastore.cpp b/src/Datastore.cpp new file mode 100644 index 00000000..880ff174 --- /dev/null +++ b/src/Datastore.cpp @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2023 Thomas Basler and others + */ +#include "Datastore.h" +#include "Configuration.h" +#include + +#define DAT_SEMAPHORE_TAKE() \ + do { \ + } while (xSemaphoreTake(_xSemaphore, portMAX_DELAY) != pdPASS) +#define DAT_SEMAPHORE_GIVE() xSemaphoreGive(_xSemaphore) + +DatastoreClass Datastore; + +DatastoreClass::DatastoreClass() +{ + _xSemaphore = xSemaphoreCreateMutex(); + DAT_SEMAPHORE_GIVE(); // release before first use +} + +void DatastoreClass::init() +{ + _updateTimeout.set(1000); +} + +void DatastoreClass::loop() +{ + if (Hoymiles.isAllRadioIdle() && _updateTimeout.occured()) { + + uint8_t isProducing = 0; + uint8_t isReachable = 0; + + DAT_SEMAPHORE_TAKE(); + + _totalAcYieldTotalEnabled = 0; + _totalAcYieldTotalDigits = 0; + + _totalAcYieldDayEnabled = 0; + _totalAcYieldDayDigits = 0; + + _totalAcPowerEnabled = 0; + _totalAcPowerDigits = 0; + + _totalDcPowerEnabled = 0; + _totalDcPowerDigits = 0; + + _totalDcPowerIrradiation = 0; + _totalDcIrradiationInstalled = 0; + + _isAllEnabledProducing = true; + _isAllEnabledReachable = true; + + for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) { + auto inv = Hoymiles.getInverterByPos(i); + if (inv == nullptr) { + continue; + } + + auto cfg = Configuration.getInverterConfig(inv->serial()); + if (cfg == nullptr) { + continue; + } + + if (inv->isProducing()) { + isProducing++; + } else { + if (inv->getEnablePolling()) { + _isAllEnabledProducing = false; + } + } + + if (inv->isReachable()) { + isReachable++; + } else { + if (inv->getEnablePolling()) { + _isAllEnabledReachable = false; + } + } + + for (auto& c : inv->Statistics()->getChannelsByType(TYPE_AC)) { + if (cfg->Poll_Enable) { + _totalAcYieldTotalEnabled += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_YT); + _totalAcYieldDayEnabled += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_YD); + + _totalAcYieldTotalDigits = max(_totalAcYieldTotalDigits, inv->Statistics()->getChannelFieldDigits(TYPE_AC, c, FLD_YT)); + _totalAcYieldDayDigits = max(_totalAcYieldDayDigits, inv->Statistics()->getChannelFieldDigits(TYPE_AC, c, FLD_YD)); + } + if (inv->getEnablePolling()) { + _totalAcPowerEnabled += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_PAC); + _totalAcPowerDigits = max(_totalAcPowerDigits, inv->Statistics()->getChannelFieldDigits(TYPE_AC, c, FLD_PAC)); + } + } + + for (auto& c : inv->Statistics()->getChannelsByType(TYPE_DC)) { + if (inv->getEnablePolling()) { + _totalDcPowerEnabled += inv->Statistics()->getChannelFieldValue(TYPE_DC, c, FLD_PDC); + _totalDcPowerDigits = max(_totalDcPowerDigits, inv->Statistics()->getChannelFieldDigits(TYPE_DC, c, FLD_PDC)); + + if (inv->Statistics()->getStringMaxPower(c) > 0) { + _totalDcPowerIrradiation += inv->Statistics()->getChannelFieldValue(TYPE_DC, c, FLD_PDC); + _totalDcIrradiationInstalled += inv->Statistics()->getStringMaxPower(c); + } + } + } + } + + _isAtLeastOneProducing = isProducing > 0; + _isAtLeastOneReachable = isReachable > 0; + + _totalDcIrradiation = _totalDcIrradiationInstalled > 0 ? _totalDcPowerIrradiation / _totalDcIrradiationInstalled * 100.0f : 0; + + DAT_SEMAPHORE_GIVE(); + + _updateTimeout.reset(); + } +} + +float DatastoreClass::getTotalAcYieldTotalEnabled() +{ + DAT_SEMAPHORE_TAKE(); + float retval = _totalAcYieldTotalEnabled; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +float DatastoreClass::getTotalAcYieldDayEnabled() +{ + DAT_SEMAPHORE_TAKE(); + float retval = _totalAcYieldDayEnabled; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +float DatastoreClass::getTotalAcPowerEnabled() +{ + DAT_SEMAPHORE_TAKE(); + float retval = _totalAcPowerEnabled; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +float DatastoreClass::getTotalDcPowerEnabled() +{ + DAT_SEMAPHORE_TAKE(); + float retval = _totalDcPowerEnabled; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +float DatastoreClass::getTotalDcPowerIrradiation() +{ + DAT_SEMAPHORE_TAKE(); + float retval = _totalDcPowerIrradiation; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +float DatastoreClass::getTotalDcIrradiationInstalled() +{ + DAT_SEMAPHORE_TAKE(); + float retval = _totalDcIrradiationInstalled; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +float DatastoreClass::getTotalDcIrradiation() +{ + DAT_SEMAPHORE_TAKE(); + float retval = _totalDcIrradiation; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +unsigned int DatastoreClass::getTotalAcYieldTotalDigits() +{ + DAT_SEMAPHORE_TAKE(); + unsigned int retval = _totalAcYieldTotalDigits; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +unsigned int DatastoreClass::getTotalAcYieldDayDigits() +{ + DAT_SEMAPHORE_TAKE(); + unsigned int retval = _totalAcYieldDayDigits; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +unsigned int DatastoreClass::getTotalAcPowerDigits() +{ + DAT_SEMAPHORE_TAKE(); + unsigned int retval = _totalAcPowerDigits; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +unsigned int DatastoreClass::getTotalDcPowerDigits() +{ + DAT_SEMAPHORE_TAKE(); + unsigned int retval = _totalDcPowerDigits; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +bool DatastoreClass::getIsAtLeastOneReachable() +{ + DAT_SEMAPHORE_TAKE(); + bool retval = _isAtLeastOneReachable; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +bool DatastoreClass::getIsAtLeastOneProducing() +{ + DAT_SEMAPHORE_TAKE(); + bool retval = _isAtLeastOneProducing; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +bool DatastoreClass::getIsAllEnabledProducing() +{ + DAT_SEMAPHORE_TAKE(); + bool retval = _isAllEnabledProducing; + DAT_SEMAPHORE_GIVE(); + return retval; +} + +bool DatastoreClass::getIsAllEnabledReachable() +{ + DAT_SEMAPHORE_TAKE(); + bool retval = _isAllEnabledReachable; + DAT_SEMAPHORE_GIVE(); + return retval; +} diff --git a/src/Display_Graphic.cpp b/src/Display_Graphic.cpp index eac532cc..972b3bc2 100644 --- a/src/Display_Graphic.cpp +++ b/src/Display_Graphic.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "Display_Graphic.h" -#include +#include "Datastore.h" #include #include #include @@ -11,6 +11,25 @@ std::map { DisplayType_t::SH1106, [](uint8_t reset, uint8_t clock, uint8_t data, uint8_t cs) { return new U8G2_SH1106_128X64_NONAME_F_HW_I2C(U8G2_R0, reset, clock, data); } }, }; +// Language defintion, respect order in languages[] and translation lists +#define I18N_LOCALE_EN 0 +#define I18N_LOCALE_DE 1 +#define I18N_LOCALE_FR 2 + +// Languages supported. Note: the order is important and must match locale_translations.h +const uint8_t languages[] = { + I18N_LOCALE_EN, + I18N_LOCALE_DE, + I18N_LOCALE_FR +}; + +static const char* const i18n_offline[] = { "Offline", "Offline", "Offline" }; +static const char* const i18n_current_power_w[] = { "%3.0f W", "%3.0f W", "%3.0f W" }; +static const char* const i18n_current_power_kw[] = { "%2.1f kW", "%2.1f kW", "%2.1f kW" }; +static const char* const i18n_yield_today_wh[] = { "today: %4.0f Wh", "Heute: %4.0f Wh", "auj.: %4.0f Wh" }; +static const char* const i18n_yield_total_kwh[] = { "total: %.1f kWh", "Ges.: %.1f kWh", "total: %.1f kWh" }; +static const char* const i18n_date_format[] = { "%m/%d/%Y %H:%M", "%d.%m.%Y %H:%M", "%d/%m/%Y %H:%M" }; + DisplayGraphicClass::DisplayGraphicClass() { } @@ -95,6 +114,11 @@ void DisplayGraphicClass::setOrientation(uint8_t rotation) calcLineHeights(); } +void DisplayGraphicClass::setLanguage(uint8_t language) +{ + _display_language = language < sizeof(languages) / sizeof(languages[0]) ? language : DISPLAY_LANGUAGE; +} + void DisplayGraphicClass::setStartupDisplay() { if (_display_type == DisplayType_t::None) { @@ -113,38 +137,16 @@ void DisplayGraphicClass::loop() } if ((millis() - _lastDisplayUpdate) > _period) { - float totalPower = 0; - float totalYieldDay = 0; - float totalYieldTotal = 0; - - uint8_t isprod = 0; - - for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) { - auto inv = Hoymiles.getInverterByPos(i); - if (inv == nullptr) { - continue; - } - - if (inv->isProducing()) { - isprod++; - } - - for (auto& c : inv->Statistics()->getChannelsByType(TYPE_AC)) { - totalPower += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_PAC); - totalYieldDay += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_YD); - totalYieldTotal += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_YT); - } - } _display->clearBuffer(); //=====> Actual Production ========== - if ((totalPower > 0) && (isprod > 0)) { + if (Datastore.getIsAtLeastOneReachable()) { _display->setPowerSave(false); - if (totalPower > 999) { - snprintf(_fmtText, sizeof(_fmtText), "%2.1f kW", (totalPower / 1000)); + if (Datastore.getTotalAcPowerEnabled() > 999) { + snprintf(_fmtText, sizeof(_fmtText), i18n_current_power_kw[_display_language], (Datastore.getTotalAcPowerEnabled() / 1000)); } else { - snprintf(_fmtText, sizeof(_fmtText), "%3.0f W", totalPower); + snprintf(_fmtText, sizeof(_fmtText), i18n_current_power_w[_display_language], Datastore.getTotalAcPowerEnabled()); } printText(_fmtText, 0); _previousMillis = millis(); @@ -153,7 +155,7 @@ void DisplayGraphicClass::loop() //=====> Offline =========== else { - printText("offline", 0); + printText(i18n_offline[_display_language], 0); // check if it's time to enter power saving mode if (millis() - _previousMillis >= (_interval * 2)) { _display->setPowerSave(enablePowerSafe); @@ -162,10 +164,10 @@ void DisplayGraphicClass::loop() //<======================= //=====> Today & Total Production ======= - snprintf(_fmtText, sizeof(_fmtText), "today: %4.0f Wh", totalYieldDay); + snprintf(_fmtText, sizeof(_fmtText), i18n_yield_today_wh[_display_language], Datastore.getTotalAcYieldDayEnabled()); printText(_fmtText, 1); - snprintf(_fmtText, sizeof(_fmtText), "total: %.1f kWh", totalYieldTotal); + snprintf(_fmtText, sizeof(_fmtText), i18n_yield_total_kwh[_display_language], Datastore.getTotalAcYieldTotalEnabled()); printText(_fmtText, 2); //<======================= @@ -175,7 +177,7 @@ void DisplayGraphicClass::loop() } else { // Get current time time_t now = time(nullptr); - strftime(_fmtText, sizeof(_fmtText), "%a %d.%m.%Y %H:%M", localtime(&now)); + strftime(_fmtText, sizeof(_fmtText), i18n_date_format[_display_language], localtime(&now)); printText(_fmtText, 3); } _display->sendBuffer(); diff --git a/src/Led_Single.cpp b/src/Led_Single.cpp index 5b6f5694..bd87ba1d 100644 --- a/src/Led_Single.cpp +++ b/src/Led_Single.cpp @@ -4,6 +4,7 @@ */ #include "Led_Single.h" #include "Configuration.h" +#include "Datastore.h" #include "MqttSettings.h" #include "NetworkSettings.h" #include "PinMapping.h" @@ -57,27 +58,11 @@ void LedSingleClass::loop() // Update inverter status _ledState[1] = LedState_t::Off; if (Hoymiles.getNumInverters()) { - bool allReachable = true; - bool allProducing = true; - for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) { - auto inv = Hoymiles.getInverterByPos(i); - if (inv == nullptr) { - continue; - } - if (inv->getEnablePolling()) { - if (!inv->isReachable()) { - allReachable = false; - } - if (!inv->isProducing()) { - allProducing = false; - } - } - } // set LED status - if (allReachable && allProducing) { + if (Datastore.getIsAllEnabledReachable() && Datastore.getIsAllEnabledProducing()) { _ledState[1] = LedState_t::On; } - if (allReachable && !allProducing) { + if (Datastore.getIsAllEnabledReachable() && !Datastore.getIsAllEnabledProducing()) { _ledState[1] = LedState_t::Blink; } } diff --git a/src/MqttHandleInverterTotal.cpp b/src/MqttHandleInverterTotal.cpp index add8ca68..55a66467 100644 --- a/src/MqttHandleInverterTotal.cpp +++ b/src/MqttHandleInverterTotal.cpp @@ -4,6 +4,7 @@ */ #include "MqttHandleInverterTotal.h" #include "Configuration.h" +#include "Datastore.h" #include "MqttSettings.h" #include #include "WebApi_database.h" @@ -23,56 +24,13 @@ void MqttHandleInverterTotalClass::loop() } if (_lastPublish.occured()) { - float totalAcPower = 0; - float totalDcPower = 0; - float totalDcPowerIrr = 0; - float totalDcPowerIrrInst = 0; - float totalAcYieldDay = 0; - float totalAcYieldTotal = 0; - uint8_t totalAcPowerDigits = 0; - uint8_t totalDcPowerDigits = 0; - uint8_t totalAcYieldDayDigits = 0; - uint8_t totalAcYieldTotalDigits = 0; - bool totalReachable = true; - - for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) { - auto inv = Hoymiles.getInverterByPos(i); - if (inv == nullptr || !inv->getEnablePolling()) { - continue; - } - - if (!inv->isReachable()) { - totalReachable = false; - } - - for (auto& c : inv->Statistics()->getChannelsByType(TYPE_AC)) { - totalAcPower += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_PAC); - totalAcPowerDigits = max(totalAcPowerDigits, inv->Statistics()->getChannelFieldDigits(TYPE_AC, c, FLD_PAC)); - - totalAcYieldDay += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_YD); - totalAcYieldDayDigits = max(totalAcYieldDayDigits, inv->Statistics()->getChannelFieldDigits(TYPE_AC, c, FLD_YD)); - - totalAcYieldTotal += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_YT); - totalAcYieldTotalDigits = max(totalAcYieldTotalDigits, inv->Statistics()->getChannelFieldDigits(TYPE_AC, c, FLD_YT)); - } - for (auto& c : inv->Statistics()->getChannelsByType(TYPE_DC)) { - totalDcPower += inv->Statistics()->getChannelFieldValue(TYPE_DC, c, FLD_PDC); - totalDcPowerDigits = max(totalDcPowerDigits, inv->Statistics()->getChannelFieldDigits(TYPE_DC, c, FLD_PDC)); - - if (inv->Statistics()->getStringMaxPower(c) > 0) { - totalDcPowerIrr += inv->Statistics()->getChannelFieldValue(TYPE_DC, c, FLD_PDC); - totalDcPowerIrrInst += inv->Statistics()->getStringMaxPower(c); - } - } - } - - MqttSettings.publish("ac/power", String(totalAcPower, static_cast(totalAcPowerDigits))); - MqttSettings.publish("ac/yieldtotal", String(totalAcYieldTotal, static_cast(totalAcYieldTotalDigits))); - MqttSettings.publish("ac/yieldday", String(totalAcYieldDay, static_cast(totalAcYieldDayDigits))); - MqttSettings.publish("ac/is_valid", String(totalReachable)); - MqttSettings.publish("dc/power", String(totalDcPower, static_cast(totalAcPowerDigits))); - MqttSettings.publish("dc/irradiation", String(totalDcPowerIrrInst > 0 ? totalDcPowerIrr / totalDcPowerIrrInst * 100.0f : 0, 3)); - MqttSettings.publish("dc/is_valid", String(totalReachable)); + MqttSettings.publish("ac/power", String(Datastore.getTotalAcPowerEnabled(), Datastore.getTotalAcPowerDigits())); + MqttSettings.publish("ac/yieldtotal", String(Datastore.getTotalAcYieldTotalEnabled(), Datastore.getTotalAcYieldTotalDigits())); + MqttSettings.publish("ac/yieldday", String(Datastore.getTotalAcYieldDayEnabled(), Datastore.getTotalAcYieldDayDigits())); + MqttSettings.publish("ac/is_valid", String(Datastore.getIsAllEnabledReachable())); + MqttSettings.publish("dc/power", String(Datastore.getTotalDcPowerEnabled(), Datastore.getTotalDcPowerDigits())); + MqttSettings.publish("dc/irradiation", String(Datastore.getTotalDcIrradiation(), 3)); + MqttSettings.publish("dc/is_valid", String(Datastore.getIsAllEnabledReachable())); _lastPublish.set(Configuration.get().Mqtt_PublishInterval * 1000); diff --git a/src/SunPosition.cpp b/src/SunPosition.cpp index 1485eaa4..1cfe1d04 100644 --- a/src/SunPosition.cpp +++ b/src/SunPosition.cpp @@ -45,8 +45,25 @@ void SunPositionClass::updateSunData() } _sun.setCurrentDate(1900 + timeinfo.tm_year, timeinfo.tm_mon + 1, timeinfo.tm_mday); - _sunriseMinutes = static_cast(_sun.calcCustomSunrise(SunSet::SUNSET_NAUTICAL)); - _sunsetMinutes = static_cast(_sun.calcCustomSunset(SunSet::SUNSET_NAUTICAL)); + + double sunset_type; + switch (config.Ntp_SunsetType) { + case 0: + sunset_type = SunSet::SUNSET_OFFICIAL; + break; + case 2: + sunset_type = SunSet::SUNSET_CIVIL; + break; + case 3: + sunset_type = SunSet::SUNSET_ASTONOMICAL; + break; + default: + sunset_type = SunSet::SUNSET_NAUTICAL; + break; + } + + _sunriseMinutes = static_cast(_sun.calcCustomSunrise(sunset_type)); + _sunsetMinutes = static_cast(_sun.calcCustomSunset(sunset_type)); uint minutesPastMidnight = timeinfo.tm_hour * 60 + timeinfo.tm_min; _isDayPeriod = (minutesPastMidnight >= _sunriseMinutes) && (minutesPastMidnight < _sunsetMinutes); diff --git a/src/WebApi_device.cpp b/src/WebApi_device.cpp index 0903bee2..e64f80ab 100644 --- a/src/WebApi_device.cpp +++ b/src/WebApi_device.cpp @@ -80,6 +80,7 @@ void WebApiDeviceClass::onDeviceAdminGet(AsyncWebServerRequest* request) display["power_safe"] = config.Display_PowerSafe; display["screensaver"] = config.Display_ScreenSaver; display["contrast"] = config.Display_Contrast; + display["language"] = config.Display_Language; response->setLength(); request->send(response); @@ -149,11 +150,13 @@ void WebApiDeviceClass::onDeviceAdminPost(AsyncWebServerRequest* request) config.Display_PowerSafe = root["display"]["power_safe"].as(); config.Display_ScreenSaver = root["display"]["screensaver"].as(); config.Display_Contrast = root["display"]["contrast"].as(); + config.Display_Language = root["display"]["language"].as(); Display.setOrientation(config.Display_Rotation); Display.enablePowerSafe = config.Display_PowerSafe; Display.enableScreensaver = config.Display_ScreenSaver; Display.setContrast(config.Display_Contrast); + Display.setLanguage(config.Display_Language); Configuration.write(); diff --git a/src/WebApi_inverter.cpp b/src/WebApi_inverter.cpp index 3f42d3c4..495b5fac 100644 --- a/src/WebApi_inverter.cpp +++ b/src/WebApi_inverter.cpp @@ -21,6 +21,7 @@ void WebApiInverterClass::init(AsyncWebServer* server) _server->on("/api/inverter/add", HTTP_POST, std::bind(&WebApiInverterClass::onInverterAdd, this, _1)); _server->on("/api/inverter/edit", HTTP_POST, std::bind(&WebApiInverterClass::onInverterEdit, this, _1)); _server->on("/api/inverter/del", HTTP_POST, std::bind(&WebApiInverterClass::onInverterDelete, this, _1)); + _server->on("/api/inverter/order", HTTP_POST, std::bind(&WebApiInverterClass::onInverterOrder, this, _1)); } void WebApiInverterClass::loop() @@ -44,6 +45,7 @@ void WebApiInverterClass::onInverterList(AsyncWebServerRequest* request) JsonObject obj = data.createNestedObject(); obj["id"] = i; obj["name"] = String(config.Inverter[i].Name); + obj["order"] = config.Inverter[i].Order; // Inverter Serial is read as HEX char buffer[sizeof(uint64_t) * 8 + 1]; @@ -389,4 +391,73 @@ void WebApiInverterClass::onInverterDelete(AsyncWebServerRequest* request) request->send(response); MqttHandleHass.forceUpdate(); +} + +void WebApiInverterClass::onInverterOrder(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + JsonObject retMsg = response->getRoot(); + retMsg["type"] = "warning"; + + if (!request->hasParam("data", true)) { + retMsg["message"] = "No values found!"; + retMsg["code"] = WebApiError::GenericNoValueFound; + response->setLength(); + request->send(response); + return; + } + + String json = request->getParam("data", true)->value(); + + if (json.length() > 1024) { + retMsg["message"] = "Data too large!"; + retMsg["code"] = WebApiError::GenericDataTooLarge; + response->setLength(); + request->send(response); + return; + } + + DynamicJsonDocument root(1024); + DeserializationError error = deserializeJson(root, json); + + if (error) { + retMsg["message"] = "Failed to parse data!"; + retMsg["code"] = WebApiError::GenericParseError; + response->setLength(); + request->send(response); + return; + } + + if (!(root.containsKey("order"))) { + retMsg["message"] = "Values are missing!"; + retMsg["code"] = WebApiError::GenericValueMissing; + response->setLength(); + request->send(response); + return; + } + + // The order array contains list or id in the right order + JsonArray orderArray = root["order"].as(); + uint8_t order = 0; + for(JsonVariant id : orderArray) { + uint8_t inverter_id = id.as(); + if (inverter_id < INV_MAX_COUNT) { + INVERTER_CONFIG_T& inverter = Configuration.get().Inverter[inverter_id]; + inverter.Order = order; + } + order++; + } + + Configuration.write(); + + retMsg["type"] = "success"; + retMsg["message"] = "Inverter order saved!"; + retMsg["code"] = WebApiError::InverterOrdered; + + response->setLength(); + request->send(response); } \ No newline at end of file diff --git a/src/WebApi_ntp.cpp b/src/WebApi_ntp.cpp index e5927123..4fa1d103 100644 --- a/src/WebApi_ntp.cpp +++ b/src/WebApi_ntp.cpp @@ -81,6 +81,7 @@ void WebApiNtpClass::onNtpAdminGet(AsyncWebServerRequest* request) root["ntp_timezone_descr"] = config.Ntp_TimezoneDescr; root["longitude"] = config.Ntp_Longitude; root["latitude"] = config.Ntp_Latitude; + root["sunsettype"] = config.Ntp_SunsetType; response->setLength(); request->send(response); @@ -125,7 +126,7 @@ void WebApiNtpClass::onNtpAdminPost(AsyncWebServerRequest* request) return; } - if (!(root.containsKey("ntp_server") && root.containsKey("ntp_timezone") && root.containsKey("longitude") && root.containsKey("latitude"))) { + if (!(root.containsKey("ntp_server") && root.containsKey("ntp_timezone") && root.containsKey("longitude") && root.containsKey("latitude") && root.containsKey("sunsettype"))) { retMsg["message"] = "Values are missing!"; retMsg["code"] = WebApiError::GenericValueMissing; response->setLength(); @@ -166,6 +167,7 @@ void WebApiNtpClass::onNtpAdminPost(AsyncWebServerRequest* request) strlcpy(config.Ntp_TimezoneDescr, root["ntp_timezone_descr"].as().c_str(), sizeof(config.Ntp_TimezoneDescr)); config.Ntp_Latitude = root["latitude"].as(); config.Ntp_Longitude = root["longitude"].as(); + config.Ntp_SunsetType = root["sunsettype"].as(); Configuration.write(); retMsg["type"] = "success"; diff --git a/src/WebApi_ws_live.cpp b/src/WebApi_ws_live.cpp index b9919ebc..74db2bea 100644 --- a/src/WebApi_ws_live.cpp +++ b/src/WebApi_ws_live.cpp @@ -4,6 +4,7 @@ */ #include "WebApi_ws_live.h" #include "Configuration.h" +#include "Datastore.h" #include "MessageOutput.h" #include "WebApi.h" #include "defaults.h" @@ -90,10 +91,6 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root) { JsonArray invArray = root.createNestedArray("inverters"); - float totalPower = 0; - float totalYieldDay = 0; - float totalYieldTotal = 0; - // Loop all inverters for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) { auto inv = Hoymiles.getInverterByPos(i); @@ -102,9 +99,14 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root) } JsonObject invObject = invArray.createNestedObject(); + INVERTER_CONFIG_T* inv_cfg = Configuration.getInverterConfig(inv->serial()); + if (inv_cfg == nullptr) { + continue; + } invObject["serial"] = inv->serialString(); invObject["name"] = inv->name(); + invObject["order"] = inv_cfg->Order; invObject["data_age"] = (millis() - inv->Statistics()->getLastUpdate()) / 1000; invObject["poll_enabled"] = inv->getEnablePolling(); invObject["reachable"] = inv->isReachable(); @@ -121,10 +123,7 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root) JsonObject chanTypeObj = invObject.createNestedObject(inv->Statistics()->getChannelTypeName(t)); for (auto& c : inv->Statistics()->getChannelsByType(t)) { if (t == TYPE_DC) { - INVERTER_CONFIG_T* inv_cfg = Configuration.getInverterConfig(inv->serial()); - if (inv_cfg != nullptr) { - chanTypeObj[String(static_cast(c))]["name"]["u"] = inv_cfg->channel[c].Name; - } + chanTypeObj[String(static_cast(c))]["name"]["u"] = inv_cfg->channel[c].Name; } addField(chanTypeObj, i, inv, t, c, FLD_PAC); addField(chanTypeObj, i, inv, t, c, FLD_UAC); @@ -158,19 +157,12 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root) if (inv->Statistics()->getLastUpdate() > _newestInverterTimestamp) { _newestInverterTimestamp = inv->Statistics()->getLastUpdate(); } - - for (auto& c : inv->Statistics()->getChannelsByType(TYPE_AC)) { - totalPower += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_PAC); - totalYieldDay += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_YD); - totalYieldTotal += inv->Statistics()->getChannelFieldValue(TYPE_AC, c, FLD_YT); - } } JsonObject totalObj = root.createNestedObject("total"); - // todo: Fixed hard coded name, unit and digits - addTotalField(totalObj, "Power", totalPower, "W", 1); - addTotalField(totalObj, "YieldDay", totalYieldDay, "Wh", 0); - addTotalField(totalObj, "YieldTotal", totalYieldTotal, "kWh", 2); + addTotalField(totalObj, "Power", Datastore.getTotalAcPowerEnabled(), "W", Datastore.getTotalAcPowerDigits()); + addTotalField(totalObj, "YieldDay", Datastore.getTotalAcYieldDayEnabled(), "Wh", Datastore.getTotalAcYieldDayDigits()); + addTotalField(totalObj, "YieldTotal", Datastore.getTotalAcYieldTotalEnabled(), "kWh", Datastore.getTotalAcYieldTotalDigits()); JsonObject hintObj = root.createNestedObject("hints"); struct tm timeinfo; diff --git a/src/main.cpp b/src/main.cpp index aec7d0f3..d12a3572 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ * Copyright (C) 2022 Thomas Basler and others */ #include "Configuration.h" +#include "Datastore.h" #include "Display_Graphic.h" #include "InverterSettings.h" #include "Led_Single.h" @@ -119,6 +120,7 @@ void setup() Display.enablePowerSafe = config.Display_PowerSafe; Display.enableScreensaver = config.Display_ScreenSaver; Display.setContrast(config.Display_Contrast); + Display.setLanguage(config.Display_Language); Display.setStartupDisplay(); MessageOutput.println("done"); @@ -141,6 +143,8 @@ void setup() MessageOutput.println("done"); InverterSettings.init(); + + Datastore.init(); } void loop() @@ -149,11 +153,14 @@ void loop() yield(); InverterSettings.loop(); yield(); + Datastore.loop(); + yield(); MqttHandleDtu.loop(); yield(); MqttHandleInverter.loop(); yield(); MqttHandleInverterTotal.loop(); + yield(); MqttHandleHass.loop(); yield(); WebApi.loop(); diff --git a/webapp/package.json b/webapp/package.json index fd52900c..0dc3cb52 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -11,34 +11,36 @@ "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" }, "dependencies": { - "@popperjs/core": "^2.11.7", - "bootstrap": "^5.3.0-alpha3", + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.0", "bootstrap-icons-vue": "^1.10.3", "mitt": "^3.0.0", + "sortablejs": "^1.15.0", "spark-md5": "^3.0.2", - "vue": "^3.2.47", + "vue": "^3.3.4", "vue-i18n": "^9.2.2", - "vue-router": "^4.1.6" + "vue-router": "^4.2.2" }, "devDependencies": { - "@intlify/unplugin-vue-i18n": "^0.10.0", - "@rushstack/eslint-patch": "^1.2.0", - "@tsconfig/node18": "^2.0.0", + "@intlify/unplugin-vue-i18n": "^0.11.0", + "@rushstack/eslint-patch": "^1.3.0", + "@tsconfig/node18": "^2.0.1", "@types/bootstrap": "^5.2.6", - "@types/node": "^18.16.6", + "@types/node": "^20.2.5", + "@types/sortablejs": "^1.15.1", "@types/spark-md5": "^3.0.2", - "@vitejs/plugin-vue": "^4.2.1", + "@vitejs/plugin-vue": "^4.2.3", "@vue/eslint-config-typescript": "^11.0.3", - "@vue/tsconfig": "^0.3.2", - "eslint": "^8.40.0", - "eslint-plugin-vue": "^9.11.1", + "@vue/tsconfig": "^0.4.0", + "eslint": "^8.41.0", + "eslint-plugin-vue": "^9.14.1", "npm-run-all": "^4.1.5", "sass": "^1.62.1", - "terser": "^5.17.3", + "terser": "^5.17.6", "typescript": "^5.0.4", - "vite": "^4.3.5", + "vite": "^4.3.9", "vite-plugin-compression": "^0.5.1", "vite-plugin-css-injected-by-js": "^3.1.1", - "vue-tsc": "^1.6.4" + "vue-tsc": "^1.6.5" } } diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 871b3240..d392b865 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -51,6 +51,7 @@ "4006": "Ungültige Anzahl an Kanalwerten übergeben!", "4007": "Wechselrichter geändert!", "4008": "Wechselrichter gelöscht!", + "4009": "Wechselrichter Reihenfolge gespeichert!", "5001": "@:apiresponse.2001", "5002": "Das Limit muss zwischen 1 und {max} sein!", "5003": "Ungültiten Typ angegeben!", @@ -243,8 +244,8 @@ "Synced": "synchronisiert", "NotSynced": "nicht synchronisiert", "LocalTime": "Lokale Uhrzeit", - "Sunrise": "Nautische Morgendämmerung", - "Sunset": "Nautische Abenddämmerung", + "Sunrise": "Morgendämmerung", + "Sunset": "Abenddämmerung", "Mode": "Modus", "Day": "Tag", "Night": "Nacht" @@ -359,6 +360,12 @@ "LocationConfiguration": "Standortkonfiguration", "Longitude": "Längengrad:", "Latitude": "Breitengrad:", + "SunSetType": "Dämmerungstyp:", + "SunSetTypeHint": "Beeinflusst die Tag/Nacht Berechnung. Es kann bis zu einer Minute dauern bis der neue Typ angewendet wurde.", + "OFFICIAL": "Standard Dämmerung (90.8°)", + "NAUTICAL": "Nautische Dämmerung (102°)", + "CIVIL": "Bürgerliche Dämmerung (96°)", + "ASTONOMICAL": "Astronomische Dämmerung (108°)", "Save": "@:dtuadmin.Save", "ManualTimeSynchronization": "Manuelle Zeitsynchronization", "CurrentOpenDtuTime": "Aktuelle OpenDTU-Zeit:", @@ -433,6 +440,7 @@ "StatusHint": "Hinweis: Der Wechselrichter wird über seinen DC-Eingang mit Strom versorgt. Wenn keine Sonne scheint, ist der Wechselrichter aus. Es können trotzdem Anfragen gesendet werden.", "Type": "Typ", "Action": "Aktion", + "SaveOrder": "Reihenfolge speichern", "DeleteInverter": "Wechselrichter löschen", "EditInverter": "Wechselrichter bearbeiten", "InverterSerial": "Wechselrichter Seriennummer:", @@ -530,6 +538,10 @@ "Rotation": "Rotation:", "rot0": "Keine Rotation", "rot90": "90 Grad Drehung", + "DisplayLanguage": "Displaysprache:", + "en": "Englisch", + "de": "Deutsch", + "fr": "Französisch", "rot180": "180 Grad Drehung", "rot270": "270 Grad Drehung", "Save": "@:dtuadmin.Save" diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 130d948d..407c2d3d 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -51,6 +51,7 @@ "4006": "Invalid amount of max channel setting given!", "4007": "Inverter changed!", "4008": "Inverter deleted!", + "4009": "Inverter order saved!", "5001": "@:apiresponse.2001", "5002": "Limit must between 1 and {max}!", "5003": "Invalid type specified!", @@ -243,8 +244,8 @@ "Synced": "synced", "NotSynced": "not synced", "LocalTime": "Local Time", - "Sunrise": "Nautical Sunrise", - "Sunset": "Nautical Sunset", + "Sunrise": "Sunrise", + "Sunset": "Sunset", "Mode": "Mode", "Day": "Day", "Night": "Night" @@ -359,6 +360,12 @@ "LocationConfiguration": "Location Configuration", "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°)", "Save": "@:dtuadmin.Save", "ManualTimeSynchronization": "Manual Time Synchronization", "CurrentOpenDtuTime": "Current OpenDTU Time:", @@ -433,6 +440,7 @@ "StatusHint": "Hint: The inverter is powered by its DC input. If there is no sun, the inverter is off. Requests can still be sent.", "Type": "Type", "Action": "Action", + "SaveOrder": "Save order", "DeleteInverter": "Delete inverter", "EditInverter": "Edit inverter", "InverterSerial": "Inverter Serial:", @@ -532,6 +540,10 @@ "rot90": "90 degree rotation", "rot180": "180 degree rotation", "rot270": "270 degree rotation", + "DisplayLanguage": "Display language:", + "en": "English", + "de": "German", + "fr": "French", "Save": "@:dtuadmin.Save" }, "pininfo": { diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index eca44fae..4a9b2a7c 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -51,6 +51,7 @@ "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 !", @@ -243,8 +244,8 @@ "Synced": "synchronisée", "NotSynced": "pas synchronisée", "LocalTime": "Heure locale", - "Sunrise": "Nautical Sunrise", - "Sunset": "Nautical Sunset", + "Sunrise": "Sunrise", + "Sunset": "Sunset", "Mode": "Mode", "Day": "Day", "Night": "Night" @@ -359,6 +360,12 @@ "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°)", "Save": "@:dtuadmin.Save", "ManualTimeSynchronization": "Synchronisation manuelle de l'heure", "CurrentOpenDtuTime": "Heure actuelle de l'OpenDTU", @@ -433,6 +440,7 @@ "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", "InverterSerial": "Numéro de série de l'onduleur", @@ -532,6 +540,10 @@ "rot90": "90 degree rotation", "rot180": "180 degree rotation", "rot270": "270 degree rotation", + "DisplayLanguage": "Langue d'affichage", + "en": "Anglais", + "de": "Allemand", + "fr": "Français", "Save": "@:dtuadmin.Save" }, "pininfo": { diff --git a/webapp/src/types/DeviceConfig.ts b/webapp/src/types/DeviceConfig.ts index 8bd87f70..8b77c5b7 100644 --- a/webapp/src/types/DeviceConfig.ts +++ b/webapp/src/types/DeviceConfig.ts @@ -5,6 +5,7 @@ export interface Display { power_safe: boolean; screensaver: boolean; contrast: number; + language: number; } export interface DeviceConfig { diff --git a/webapp/src/types/LiveDataStatus.ts b/webapp/src/types/LiveDataStatus.ts index 66910afb..c3e56825 100644 --- a/webapp/src/types/LiveDataStatus.ts +++ b/webapp/src/types/LiveDataStatus.ts @@ -23,6 +23,7 @@ export interface InverterStatistics { export interface Inverter { serial: number; name: string; + order: number; data_age: number; poll_enabled: boolean; reachable: boolean; diff --git a/webapp/src/types/NtpConfig.ts b/webapp/src/types/NtpConfig.ts index 172ffa09..a87c806a 100644 --- a/webapp/src/types/NtpConfig.ts +++ b/webapp/src/types/NtpConfig.ts @@ -4,4 +4,5 @@ export interface NtpConfig { ntp_timezone_descr: string; latitude: number; longitude: number; + sunsettype: number; } \ No newline at end of file diff --git a/webapp/src/views/DeviceAdminView.vue b/webapp/src/views/DeviceAdminView.vue index 574105e9..090bcfb7 100644 --- a/webapp/src/views/DeviceAdminView.vue +++ b/webapp/src/views/DeviceAdminView.vue @@ -56,6 +56,19 @@ v-model="deviceConfigList.display.screensaver" type="checkbox" :tooltip="$t('deviceadmin.ScreensaverHint')" /> +
+ +
+ +
+
+