Merge remote-tracking branch 'tbnobody/OpenDTU/master'

This commit is contained in:
helgeerbe 2022-11-22 17:10:53 +01:00
commit 1fc0e76c41
23 changed files with 263 additions and 206 deletions

View File

@ -10,6 +10,10 @@ It was the goal to replace the original Hoymiles DTU (Telemetry Gateway) with th
## Screenshots
Several screenshots of the frontend can be found here: [Screenshots](docs/screenshots/README.md)
## Builds
Different builds from existing installations can be found here [Builds](docs/builds/README.md)
Like to show your own build? Just send me a Pull Request.
I extended the original OpenDTU software to support also Victron's Ve.Direct protocol on the same chip. Additional information about Ve.direct can be downloaded from https://www.victronenergy.com/support-and-downloads/technical-information.
Web-Live-Interface:

View File

@ -4,3 +4,4 @@ More detailed descriptions for some topics can be found here.
## [MQTT Topic Documentation](MQTT_Topics.md)
## [Web API Documentation](Web-API.md)
## [Builds](builds/README.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

19
docs/builds/README.md Normal file
View File

@ -0,0 +1,19 @@
# Builds using different boards
## ESP32 Dev Board
### Build by @Marc--
* Used build environment: generic
* Case: https://www.thingiverse.com/thing:5435911
![](large_display_PXL_20220715_145622277.jpg)
### Build by @cepresso
* Used build environment: generic
* Case: https://www.printables.com/de/model/293003-sol-opendtu-esp32-nrf24l01-case
![](sol.webp)
## LILYGO® TTGO T-Internet-POE
### Build by @fromCologne
* Used build environment: LilyGO_T_ETH_POE
* Board info: http://www.lilygo.cn/claprod_view.aspx?TypeId=21&Id=1344&FId=t28:21:28
* Case: https://www.thingiverse.com/thing:5244895
![](202654506-8a4ac4ef-c883-490e-8ee1-1e1f7fa34972.jpg)

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

BIN
docs/builds/sol.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@ -3,9 +3,8 @@
#include <Arduino.h>
#define CONFIG_FILENAME "/config.bin"
#define CONFIG_FILENAME_JSON "/config.json"
#define CONFIG_VERSION 0x00011600 // 0.1.22 // make sure to clean all after change
#define CONFIG_FILENAME "/config.json"
#define CONFIG_VERSION 0x00011700 // 0.1.23 // make sure to clean all after change
#define WIFI_MAX_SSID_STRLEN 31
#define WIFI_MAX_PASSWORD_STRLEN 64
@ -15,10 +14,9 @@
#define NTP_MAX_TIMEZONE_STRLEN 50
#define NTP_MAX_TIMEZONEDESCR_STRLEN 50
#define MQTT_MAX_HOSTNAME_OLD_STRLEN 31
#define MQTT_MAX_HOSTNAME_STRLEN 128
#define MQTT_MAX_USERNAME_STRLEN 32
#define MQTT_MAX_PASSWORD_STRLEN 32
#define MQTT_MAX_USERNAME_STRLEN 64
#define MQTT_MAX_PASSWORD_STRLEN 64
#define MQTT_MAX_TOPIC_STRLEN 32
#define MQTT_MAX_LWTVALUE_STRLEN 20
#define MQTT_MAX_ROOT_CA_CERT_STRLEN 2048
@ -27,12 +25,19 @@
#define INV_MAX_COUNT 10
#define INV_MAX_CHAN_COUNT 4
#define CHAN_MAX_NAME_STRLEN 31
#define JSON_BUFFER_SIZE 6144
struct CHANNEL_CONFIG_T {
uint16_t MaxChannelPower;
char Name[CHAN_MAX_NAME_STRLEN];
};
struct INVERTER_CONFIG_T {
uint64_t Serial;
char Name[INV_MAX_NAME_STRLEN + 1];
uint16_t MaxChannelPower[INV_MAX_CHAN_COUNT];
CHANNEL_CONFIG_T channel[INV_MAX_CHAN_COUNT];
};
struct CONFIG_T {
@ -54,7 +59,6 @@ struct CONFIG_T {
char Ntp_TimezoneDescr[NTP_MAX_TIMEZONEDESCR_STRLEN + 1];
bool Mqtt_Enabled;
char Mqtt_Hostname_Short[MQTT_MAX_HOSTNAME_OLD_STRLEN + 1]; // Deprecated but for config compatibility
uint Mqtt_Port;
char Mqtt_Username[MQTT_MAX_USERNAME_STRLEN + 1];
char Mqtt_Password[MQTT_MAX_PASSWORD_STRLEN + 1];
@ -98,9 +102,7 @@ public:
CONFIG_T& get();
INVERTER_CONFIG_T* getFreeInverterSlot();
private:
bool readJson();
INVERTER_CONFIG_T* getInverterConfig(uint64_t serial);
};
extern ConfigurationClass Configuration;

View File

@ -15,6 +15,7 @@ const devInfo_t devInfo[] = {
{ { 0x10, 0x10, 0x40, ALL }, 400, "HM-400" },
{ { 0x10, 0x11, 0x10, ALL }, 600, "HM-600" },
{ { 0x10, 0x11, 0x20, ALL }, 700, "HM-700" },
{ { 0x10, 0x11, 0x30, ALL }, 800, "HM-800" },
{ { 0x10, 0x11, 0x40, ALL }, 800, "HM-800" },
{ { 0x10, 0x12, 0x10, ALL }, 1200, "HM-1200" },
{ { 0x10, 0x12, 0x30, ALL }, 1500, "HM-1500" },

View File

@ -36,8 +36,8 @@ monitor_speed = 115200
upload_protocol = esptool
; Specify port here. Comment out (add ; in front of line) to use auto detection.
monitor_port = COM4
upload_port = COM4
monitor_port = COM5
upload_port = COM5
[env:generic]
@ -96,3 +96,21 @@ build_flags = ${env.build_flags}
-DHOYMILES_PIN_CE=14
-DHOYMILES_PIN_CS=15
-DOPENDTU_ETHERNET
[env:LilyGO_T_ETH_POE]
; http://www.lilygo.cn/claprod_view.aspx?TypeId=21&Id=1344&FId=t28:21:28
board = esp32dev
build_flags = ${env.build_flags}
-DHOYMILES_PIN_MISO=2
-DHOYMILES_PIN_MOSI=15
-DHOYMILES_PIN_SCLK=14
-DHOYMILES_PIN_IRQ=34
-DHOYMILES_PIN_CE=12
-DHOYMILES_PIN_CS=4
-DOPENDTU_ETHERNET
-DETH_CLK_MODE=ETH_CLOCK_GPIO17_OUT
-DETH_POWER_PIN=-1
-DETH_TYPE=ETH_PHY_LAN8720
-DETH_ADDR=0
-DETH_MDC_PIN=23
-DETH_MDIO_PIN=18

View File

@ -12,63 +12,11 @@ CONFIG_T config;
void ConfigurationClass::init()
{
memset(&config, 0x0, sizeof(config));
config.Cfg_SaveCount = 0;
config.Cfg_Version = CONFIG_VERSION;
// WiFi Settings
strlcpy(config.WiFi_Ssid, WIFI_SSID, sizeof(config.WiFi_Ssid));
strlcpy(config.WiFi_Password, WIFI_PASSWORD, sizeof(config.WiFi_Password));
config.WiFi_Dhcp = WIFI_DHCP;
strlcpy(config.WiFi_Hostname, APP_HOSTNAME, sizeof(config.WiFi_Hostname));
// NTP Settings
strlcpy(config.Ntp_Server, NTP_SERVER, sizeof(config.Ntp_Server));
strlcpy(config.Ntp_Timezone, NTP_TIMEZONE, sizeof(config.Ntp_Timezone));
strlcpy(config.Ntp_TimezoneDescr, NTP_TIMEZONEDESCR, sizeof(config.Ntp_TimezoneDescr));
// MqTT Settings
config.Mqtt_Enabled = MQTT_ENABLED;
strlcpy(config.Mqtt_Hostname, MQTT_HOST, sizeof(config.Mqtt_Hostname));
config.Mqtt_Port = MQTT_PORT;
strlcpy(config.Mqtt_Username, MQTT_USER, sizeof(config.Mqtt_Username));
strlcpy(config.Mqtt_Password, MQTT_PASSWORD, sizeof(config.Mqtt_Password));
strlcpy(config.Mqtt_Topic, MQTT_TOPIC, sizeof(config.Mqtt_Topic));
config.Mqtt_Retain = MQTT_RETAIN;
config.Mqtt_Tls = MQTT_TLS;
strlcpy(config.Mqtt_RootCaCert, MQTT_ROOT_CA_CERT, sizeof(config.Mqtt_RootCaCert));
strlcpy(config.Mqtt_LwtTopic, MQTT_LWT_TOPIC, sizeof(config.Mqtt_LwtTopic));
strlcpy(config.Mqtt_LwtValue_Online, MQTT_LWT_ONLINE, sizeof(config.Mqtt_LwtValue_Online));
strlcpy(config.Mqtt_LwtValue_Offline, MQTT_LWT_OFFLINE, sizeof(config.Mqtt_LwtValue_Offline));
config.Mqtt_PublishInterval = MQTT_PUBLISH_INTERVAL;
for (uint8_t i = 0; i < INV_MAX_COUNT; i++) {
config.Inverter[i].Serial = 0;
strlcpy(config.Inverter[i].Name, "", 0);
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
config.Inverter[0].MaxChannelPower[c] = 0;
}
}
config.Dtu_Serial = DTU_SERIAL;
config.Dtu_PollInterval = DTU_POLL_INTERVAL;
config.Dtu_PaLevel = DTU_PA_LEVEL;
config.Mqtt_Hass_Enabled = MQTT_HASS_ENABLED;
config.Mqtt_Hass_Expire = MQTT_HASS_EXPIRE;
config.Mqtt_Hass_Retain = MQTT_HASS_RETAIN;
strlcpy(config.Mqtt_Hass_Topic, MQTT_HASS_TOPIC, sizeof(config.Mqtt_Hass_Topic));
config.Mqtt_Hass_IndividualPanels = MQTT_HASS_INDIVIDUALPANELS;
strlcpy(config.Security_Password, ACCESS_POINT_PASSWORD, sizeof(config.Security_Password));
config.Vedirect_Enabled = VEDIRECT_ENABLED;
config.Vedirect_UpdatesOnly = VEDIRECT_UPDATESONLY;
config.Vedirect_PollInterval = VEDIRECT_POLL_INTERVAL;
}
bool ConfigurationClass::write()
{
File f = LittleFS.open(CONFIG_FILENAME_JSON, "w");
File f = LittleFS.open(CONFIG_FILENAME, "w");
if (!f) {
return false;
}
@ -136,9 +84,11 @@ bool ConfigurationClass::write()
inv["serial"] = config.Inverter[i].Serial;
inv["name"] = config.Inverter[i].Name;
JsonArray channels = inv.createNestedArray("channels");
JsonArray channel = inv.createNestedArray("channel");
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
channels.add(config.Inverter[i].MaxChannelPower[c]);
JsonObject chanData = channel.createNestedObject();
chanData["name"] = config.Inverter[i].channel[c].Name;
chanData["max_power"] = config.Inverter[i].channel[c].MaxChannelPower;
}
}
@ -159,30 +109,7 @@ bool ConfigurationClass::write()
bool ConfigurationClass::read()
{
if (!LittleFS.exists(CONFIG_FILENAME_JSON)) {
Serial.println("Converting binary config to json... ");
File f = LittleFS.open(CONFIG_FILENAME, "r");
if (!f) {
return false;
}
uint8_t* bytes = reinterpret_cast<uint8_t*>(&config);
for (unsigned int i = 0; i < sizeof(CONFIG_T); i++) {
bytes[i] = f.read();
}
f.close();
write();
Serial.println("done");
LittleFS.remove(CONFIG_FILENAME);
}
return readJson();
}
bool ConfigurationClass::readJson()
{
File f = LittleFS.open(CONFIG_FILENAME_JSON, "r", false);
if (!f) {
return false;
}
File f = LittleFS.open(CONFIG_FILENAME, "r", false);
DynamicJsonDocument doc(JSON_BUFFER_SIZE);
// Deserialize the JSON document
@ -282,9 +209,10 @@ bool ConfigurationClass::readJson()
config.Inverter[i].Serial = inv["serial"] | 0ULL;
strlcpy(config.Inverter[i].Name, inv["name"] | "", sizeof(config.Inverter[i].Name));
JsonArray channels = inv["channels"];
JsonArray channel = inv["channel"];
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
config.Inverter[i].MaxChannelPower[c] = channels[c];
config.Inverter[i].channel[c].MaxChannelPower = channel[c]["max_power"] | 0;
strlcpy(config.Inverter[i].channel[c].Name, channel[c]["name"] | "", sizeof(config.Inverter[i].channel[c].Name));
}
}
@ -299,81 +227,35 @@ bool ConfigurationClass::readJson()
void ConfigurationClass::migrate()
{
if (config.Cfg_Version < 0x00010400) {
strlcpy(config.Ntp_Server, NTP_SERVER, sizeof(config.Ntp_Server));
strlcpy(config.Ntp_Timezone, NTP_TIMEZONE, sizeof(config.Ntp_Timezone));
strlcpy(config.Ntp_TimezoneDescr, NTP_TIMEZONEDESCR, sizeof(config.Ntp_TimezoneDescr));
if (config.Cfg_Version < 0x00011700) {
File f = LittleFS.open(CONFIG_FILENAME, "r", false);
if (!f) {
Serial.println(F("Failed to open file, cancel migration"));
return;
}
if (config.Cfg_Version < 0x00010500) {
config.Mqtt_Enabled = MQTT_ENABLED;
strlcpy(config.Mqtt_Hostname, MQTT_HOST, sizeof(config.Mqtt_Hostname));
config.Mqtt_Port = MQTT_PORT;
strlcpy(config.Mqtt_Username, MQTT_USER, sizeof(config.Mqtt_Username));
strlcpy(config.Mqtt_Password, MQTT_PASSWORD, sizeof(config.Mqtt_Password));
strlcpy(config.Mqtt_Topic, MQTT_TOPIC, sizeof(config.Mqtt_Topic));
DynamicJsonDocument doc(JSON_BUFFER_SIZE);
// Deserialize the JSON document
DeserializationError error = deserializeJson(doc, f);
if (error) {
Serial.println(F("Failed to read file, cancel migration"));
return;
}
if (config.Cfg_Version < 0x00010600) {
config.Mqtt_Retain = MQTT_RETAIN;
}
if (config.Cfg_Version < 0x00010700) {
strlcpy(config.Mqtt_LwtTopic, MQTT_LWT_TOPIC, sizeof(config.Mqtt_LwtTopic));
strlcpy(config.Mqtt_LwtValue_Online, MQTT_LWT_ONLINE, sizeof(config.Mqtt_LwtValue_Online));
strlcpy(config.Mqtt_LwtValue_Offline, MQTT_LWT_OFFLINE, sizeof(config.Mqtt_LwtValue_Offline));
}
if (config.Cfg_Version < 0x00010800) {
JsonArray inverters = doc["inverters"];
for (uint8_t i = 0; i < INV_MAX_COUNT; i++) {
config.Inverter[i].Serial = 0;
strlcpy(config.Inverter[i].Name, "", 0);
JsonObject inv = inverters[i].as<JsonObject>();
JsonArray channels = inv["channels"];
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
config.Inverter[i].channel[c].MaxChannelPower = channels[c];
strlcpy(config.Inverter[i].channel[c].Name, "", sizeof(config.Inverter[i].channel[c].Name));
}
}
if (config.Cfg_Version < 0x00010900) {
config.Dtu_Serial = DTU_SERIAL;
config.Dtu_PollInterval = DTU_POLL_INTERVAL;
config.Dtu_PaLevel = DTU_PA_LEVEL;
}
if (config.Cfg_Version < 0x00011000) {
config.Mqtt_PublishInterval = MQTT_PUBLISH_INTERVAL;
}
if (config.Cfg_Version < 0x00011100) {
init(); // Config will be completly incompatible after this update
}
if (config.Cfg_Version < 0x00011200) {
config.Mqtt_Hass_Enabled = MQTT_HASS_ENABLED;
config.Mqtt_Hass_Retain = MQTT_HASS_RETAIN;
strlcpy(config.Mqtt_Hass_Topic, MQTT_HASS_TOPIC, sizeof(config.Mqtt_Hass_Topic));
config.Mqtt_Hass_IndividualPanels = MQTT_HASS_INDIVIDUALPANELS;
}
if (config.Cfg_Version < 0x00011300) {
config.Mqtt_Tls = MQTT_TLS;
strlcpy(config.Mqtt_RootCaCert, MQTT_ROOT_CA_CERT, sizeof(config.Mqtt_RootCaCert));
config.Vedirect_Enabled = VEDIRECT_ENABLED;
config.Vedirect_UpdatesOnly = VEDIRECT_UPDATESONLY;
config.Vedirect_PollInterval = VEDIRECT_POLL_INTERVAL;
}
if (config.Cfg_Version < 0x00011400) {
strlcpy(config.Mqtt_Hostname, config.Mqtt_Hostname_Short, sizeof(config.Mqtt_Hostname_Short));
}
if (config.Cfg_Version < 0x00011500) {
config.Mqtt_Hass_Expire = MQTT_HASS_EXPIRE;
}
if (config.Cfg_Version < 0x00011600) {
strlcpy(config.Security_Password, ACCESS_POINT_PASSWORD, sizeof(config.Security_Password));
}
config.Cfg_Version = CONFIG_VERSION;
write();
read();
}
CONFIG_T& ConfigurationClass::get()
@ -392,4 +274,15 @@ INVERTER_CONFIG_T* ConfigurationClass::getFreeInverterSlot()
return NULL;
}
INVERTER_CONFIG_T* ConfigurationClass::getInverterConfig(uint64_t serial)
{
for (uint8_t i = 0; i < INV_MAX_COUNT; i++) {
if (config.Inverter[i].Serial == serial) {
return &config.Inverter[i];
}
}
return NULL;
}
ConfigurationClass Configuration;

View File

@ -83,6 +83,12 @@ void MqttPublishingClass::loop()
// Loop all channels
for (uint8_t c = 0; c <= inv->Statistics()->getChannelCount(); c++) {
if (c > 0) {
INVERTER_CONFIG_T* inv_cfg = Configuration.getInverterConfig(inv->serial());
if (inv_cfg != nullptr) {
MqttSettings.publish(inv->serialString() + "/" + String(c) + "/name", inv_cfg->channel[c - 1].Name);
}
}
for (uint8_t f = 0; f < sizeof(_publishFields); f++) {
publishField(inv, c, _publishFields[f]);
}

View File

@ -37,7 +37,7 @@ void WebApiConfigClass::onConfigGet(AsyncWebServerRequest* request)
return;
}
request->send(LittleFS, CONFIG_FILENAME_JSON, String(), true);
request->send(LittleFS, CONFIG_FILENAME, String(), true);
}
void WebApiConfigClass::onConfigDelete(AsyncWebServerRequest* request)
@ -96,7 +96,7 @@ void WebApiConfigClass::onConfigDelete(AsyncWebServerRequest* request)
response->setLength();
request->send(response);
LittleFS.remove(CONFIG_FILENAME_JSON);
LittleFS.remove(CONFIG_FILENAME);
ESP.restart();
}
@ -127,7 +127,7 @@ void WebApiConfigClass::onConfigUpload(AsyncWebServerRequest* request, String fi
if (!index) {
// open the file on first call and store the file handle in the request object
request->_tempFile = LittleFS.open(CONFIG_FILENAME_JSON, "w");
request->_tempFile = LittleFS.open(CONFIG_FILENAME, "w");
}
if (len) {

View File

@ -62,8 +62,11 @@ void WebApiInverterClass::onInverterList(AsyncWebServerRequest* request)
max_channels = inv->Statistics()->getChannelCount();
}
JsonArray channel = obj.createNestedArray("channel");
for (uint8_t c = 0; c < max_channels; c++) {
obj[F("max_power")][c] = config.Inverter[i].MaxChannelPower[c];
JsonObject chanData = channel.createNestedObject();
chanData["name"] = config.Inverter[i].channel[c].Name;
chanData["max_power"] = config.Inverter[i].channel[c].MaxChannelPower;
}
}
}
@ -154,7 +157,7 @@ void WebApiInverterClass::onInverterAdd(AsyncWebServerRequest* request)
if (inv != nullptr) {
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
inv->Statistics()->setChannelMaxPower(c, inverter->MaxChannelPower[c]);
inv->Statistics()->setChannelMaxPower(c, inverter->channel[c].MaxChannelPower);
}
}
@ -197,7 +200,7 @@ void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request)
return;
}
if (!(root.containsKey("id") && root.containsKey("serial") && root.containsKey("name") && root.containsKey("max_power"))) {
if (!(root.containsKey("id") && root.containsKey("serial") && root.containsKey("name") && root.containsKey("channel"))) {
retMsg[F("message")] = F("Values are missing!");
response->setLength();
request->send(response);
@ -225,8 +228,8 @@ void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request)
return;
}
JsonArray maxPowerArray = root[F("max_power")].as<JsonArray>();
if (maxPowerArray.size() == 0 || maxPowerArray.size() > INV_MAX_CHAN_COUNT) {
JsonArray channelArray = root[F("channel")].as<JsonArray>();
if (channelArray.size() == 0 || channelArray.size() > INV_MAX_CHAN_COUNT) {
retMsg[F("message")] = F("Invalid amount of max channel setting given!");
response->setLength();
request->send(response);
@ -243,8 +246,9 @@ void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request)
strncpy(inverter.Name, root[F("name")].as<String>().c_str(), INV_MAX_NAME_STRLEN);
uint8_t arrayCount = 0;
for (JsonVariant maxPower : maxPowerArray) {
inverter.MaxChannelPower[arrayCount] = maxPower.as<uint16_t>();
for (JsonVariant channel : channelArray) {
inverter.channel[arrayCount].MaxChannelPower = channel[F("max_power")].as<uint16_t>();
strncpy(inverter.channel[arrayCount].Name, channel[F("name")] | "", sizeof(inverter.channel[arrayCount].Name));
arrayCount++;
}
@ -272,7 +276,7 @@ void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request)
if (inv != nullptr) {
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
inv->Statistics()->setChannelMaxPower(c, inverter.MaxChannelPower[c]);
inv->Statistics()->setChannelMaxPower(c, inverter.channel[c].MaxChannelPower);
}
}

View File

@ -5,6 +5,7 @@
#include "WebApi_ws_live.h"
#include "AsyncJson.h"
#include "Configuration.h"
#include "defaults.h"
WebApiWsLiveClass::WebApiWsLiveClass()
: _ws("/livedata")
@ -102,6 +103,12 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root)
// Loop all channels
for (uint8_t c = 0; c <= inv->Statistics()->getChannelCount(); c++) {
if (c > 0) {
INVERTER_CONFIG_T* inv_cfg = Configuration.getInverterConfig(inv->serial());
if (inv_cfg != nullptr) {
invObject[String(c)][F("name")]["u"] = inv_cfg->channel[c - 1].Name;
}
}
addField(invObject, i, inv, c, FLD_PAC);
addField(invObject, i, inv, c, FLD_UAC);
addField(invObject, i, inv, c, FLD_IAC);
@ -119,8 +126,10 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root)
addField(invObject, i, inv, c, FLD_PF);
addField(invObject, i, inv, c, FLD_PRA);
addField(invObject, i, inv, c, FLD_EFF);
if (c > 0 && inv->Statistics()->getChannelMaxPower(c - 1) > 0) {
addField(invObject, i, inv, c, FLD_IRR);
}
}
if (inv->Statistics()->hasChannelFieldValue(CH0, FLD_EVT_LOG)) {
invObject[F("events")] = inv->EventLog()->getEntryCount();
@ -142,6 +151,16 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root)
addTotalField(totalObj, "Power", totalPower, "W", 1);
addTotalField(totalObj, "YieldDay", totalYieldDay, "Wh", 0);
addTotalField(totalObj, "YieldTotal", totalYieldTotal, "kWh", 2);
JsonObject hintObj = root.createNestedObject("hints");
struct tm timeinfo;
hintObj[F("time_sync")] = !getLocalTime(&timeinfo, 5);
hintObj[F("radio_problem")] = (!Hoymiles.getRadio()->isConnected() || !Hoymiles.getRadio()->isPVariant());
if (!strcmp(Configuration.get().Security_Password, ACCESS_POINT_PASSWORD)) {
hintObj[F("default_password")] = true;
} else {
hintObj[F("default_password")] = false;
}
}
void WebApiWsLiveClass::addField(JsonObject& root, uint8_t idx, std::shared_ptr<InverterAbstract> inv, uint8_t channel, uint8_t fieldId, String topic)

View File

@ -119,7 +119,7 @@ void setup()
if (inv != nullptr) {
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
inv->Statistics()->setChannelMaxPower(c, config.Inverter[i].MaxChannelPower[c]);
inv->Statistics()->setChannelMaxPower(c, config.Inverter[i].channel[c].MaxChannelPower);
}
}
Serial.println(F(" done"));

View File

@ -27,12 +27,12 @@
"@vitejs/plugin-vue": "^3.2.0",
"@vue/eslint-config-typescript": "^11.0.2",
"@vue/tsconfig": "^0.1.3",
"eslint": "^8.27.0",
"eslint": "^8.28.0",
"eslint-plugin-vue": "^9.7.0",
"npm-run-all": "^4.1.5",
"sass": "^1.56.1",
"typescript": "^4.8.4",
"vite": "^3.2.3",
"typescript": "^4.9.3",
"vite": "^3.2.4",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-css-injected-by-js": "^2.1.1",
"vue-tsc": "^1.0.9"

View File

@ -0,0 +1,50 @@
<template>
<BootstrapAlert :show="hints.radio_problem" variant="danger">
<BIconBroadcast class="fs-4" /> Could not connect to a correct NRF24L01+ radio module. Please check the wiring.
</BootstrapAlert>
<BootstrapAlert :show="hints.time_sync" variant="danger">
<BIconClock class="fs-4" /> The clock has not yet been synchronised. Without a
correctly set clock, no requests are made to the inverter. This is normal shortly after the start. However,
after a longer runtime (>1 minute), it indicates that the NTP server is not accessible. <a
@click="gotoTimeSettings" href="#" class="alert-link">Please check your time
settings.</a>
</BootstrapAlert>
<BootstrapAlert :show="hints.default_password" variant="danger">
<BIconExclamationCircle class="fs-4" /> You are using the default password for the web interface and the
emergency access point. This is potentially insecure. <a @click="gotoPasswordSettings" href="#"
class="alert-link">Please change the password.</a>
</BootstrapAlert>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { Hints } from '@/types/LiveDataStatus';
import BootstrapAlert from '@/components/BootstrapAlert.vue';
import {
BIconClock,
BIconExclamationCircle,
BIconBroadcast,
} from 'bootstrap-icons-vue';
export default defineComponent({
components: {
BootstrapAlert,
BIconClock,
BIconExclamationCircle,
BIconBroadcast,
},
props: {
hints: { type: Object as PropType<Hints>, required: true },
},
methods: {
gotoTimeSettings() {
this.$router.push("/settings/ntp");
},
gotoPasswordSettings() {
this.$router.push("/settings/security");
}
}
});
</script>

View File

@ -1,6 +1,9 @@
<template>
<div class="card" :class="{ 'border-info': channelNumber == 0 }">
<div v-if="channelNumber >= 1" class="card-header">String {{ channelNumber }}</div>
<div v-if="channelNumber >= 1" class="card-header">
<template v-if="channelData.name.u != ''">{{ channelData.name.u }}</template>
<template v-else>String {{ channelNumber }}</template>
</div>
<div v-if="channelNumber == 0" class="card-header text-bg-info">Phase {{ channelNumber + 1 }}</div>
<div class="card-body">
<table class="table table-striped table-hover">
@ -13,7 +16,7 @@
</thead>
<tbody>
<tr v-for="(property, key) in channelData" :key="`prop-${key}`">
<template v-if="property">
<template v-if="key != 'name' && property">
<th scope="row">{{ key }}</th>
<td style="text-align: right">{{ formatNumber(property.v, property.d) }}</td>
<td>{{ property.u }}</td>

View File

@ -5,6 +5,7 @@ export interface ValueObject {
};
export interface InverterStatistics {
name: ValueObject,
Power?: ValueObject;
Voltage?: ValueObject;
Current?: ValueObject;
@ -37,7 +38,14 @@ export interface Total {
YieldTotal: ValueObject;
};
export interface Hints {
time_sync: boolean;
default_password: boolean;
radio_problem: boolean;
};
export interface LiveData {
inverters: Inverter[];
total: Total;
hints: Hints;
}

View File

@ -1,5 +1,6 @@
<template>
<BasePage :title="'Live Data'" :isLoading="dataLoading" :isWideScreen="true">
<HintView :hints="liveData.hints" />
<InverterTotalInfo :totalData="liveData.total" /><br />
<div class="row gy-3">
<div class="col-sm-3 col-md-2" :style="[inverterData.length == 1 ? { 'display': 'none' } : {}]">
@ -45,6 +46,9 @@
</div>
<div style="padding-right: 2em;">
Data Age: {{ inverter.data_age }} seconds
<template v-if="inverter.data_age > 300">
/ {{ calculateAbsoluteTime(inverter.data_age) }}
</template>
</div>
</div>
</div>
@ -334,6 +338,7 @@ import DevInfo from '@/components/DevInfo.vue';
import BootstrapAlert from '@/components/BootstrapAlert.vue';
import InverterChannelInfo from "@/components/InverterChannelInfo.vue";
import InverterTotalInfo from '@/components/InverterTotalInfo.vue';
import HintView from '@/components/HintView.vue';
import VedirectView from '@/views/VedirectView.vue';
import type { DevInfoStatus } from '@/types/DevInfoStatus';
import type { EventlogItems } from '@/types/EventlogStatus';
@ -348,6 +353,7 @@ export default defineComponent({
BasePage,
InverterChannelInfo,
InverterTotalInfo,
HintView,
EventLog,
DevInfo,
BootstrapAlert,
@ -664,6 +670,11 @@ export default defineComponent({
}
)
},
calculateAbsoluteTime(lastTime: number): string {
const userLocale = globalThis.navigator.language;
const date = new Date(Date.now() - lastTime * 1000);
return date.toLocaleString(userLocale)
}
},
});
</script>

View File

@ -73,25 +73,38 @@
<div class="modal-body">
<form>
<div class="mb-3">
<label for="inverter-serial" class="col-form-label">Serial:</label>
<label for="inverter-serial" class="col-form-label">Inverter Serial:</label>
<input v-model="selectedInverterData.serial" type="number" id="inverter-serial"
class="form-control" />
<label for="inverter-name" class="col-form-label">Name:</label>
<label for="inverter-name" class="col-form-label">Inverter Name:</label>
<input v-model="selectedInverterData.name" type="text" id="inverter-name"
class="form-control" maxlength="31" />
</div>
<div v-for="(max, index) in selectedInverterData.max_power" :key="`${index}`">
<div v-for="(max, index) in selectedInverterData.channel" :key="`${index}`">
<div class="row g-2">
<div class="col-md">
<label :for="`inverter-name_${index}`" class="col-form-label">Name string {{ index +1 }}:</label>
<div class="d-flex mb-2">
<div class="input-group">
<input type="text" class="form-control" :id="`inverter-name_${index}`" maxlength="31"
v-model="selectedInverterData.channel[index].name" />
</div>
</div>
</div>
<div class="col-md-5">
<label :for="`inverter-max_${index}`" class="col-form-label">Max power string {{ index +1 }}:</label>
<div class="d-flex mb-2">
<div class="input-group">
<input type="number" class="form-control" :id="`inverter-max_${index}`" min="0"
v-model="selectedInverterData.max_power[index]"
v-model="selectedInverterData.channel[index].max_power"
:aria-describedby="`inverter-maxDescription_${index} inverter-customizer`" />
<span class="input-group-text" :id="`inverter-maxDescription_${index}`">W<sup>*</sup></span>
</div>
</div>
</div>
</div>
</div>
<div :id="`inverter-customizer`" class="form-text">*) Input the kWp of the channel to
calculate irradiation.</div>
</form>
@ -139,12 +152,17 @@ import * as bootstrap from 'bootstrap';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader } from '@/utils/authentication';
declare interface Channel {
name: string;
max_power: number;
}
declare interface Inverter {
id: string;
serial: number;
name: string;
type: string;
max_power: number[];
channel: Array<Channel>;
}
declare interface AlertResponse {

View File

@ -55,7 +55,7 @@
<div class="row mb-3">
<label for="inputUsername" class="col-sm-2 col-form-label">Username:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputUsername" maxlength="32"
<input type="text" class="form-control" id="inputUsername" maxlength="64"
placeholder="Username, leave empty for anonymous connection"
v-model="mqttConfigList.mqtt_username" />
</div>
@ -64,7 +64,7 @@
<div class="row mb-3">
<label for="inputPassword" class="col-sm-2 col-form-label">Password:</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="inputPassword" maxlength="32"
<input type="password" class="form-control" id="inputPassword" maxlength="64"
placeholder="Password, leave empty for anonymous connection"
v-model="mqttConfigList.mqtt_password" />
</div>

View File

@ -924,10 +924,10 @@ eslint-visitor-keys@^3.3.0:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826"
integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
eslint@^8.27.0:
version "8.27.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.27.0.tgz#d547e2f7239994ad1faa4bb5d84e5d809db7cf64"
integrity sha512-0y1bfG2ho7mty+SiILVf9PfuRA49ek4Nc60Wmmu62QlobNR+CeXa4xXIJgcuwSQgZiWaPH+5BDsctpIW0PR/wQ==
eslint@^8.28.0:
version "8.28.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.28.0.tgz#81a680732634677cc890134bcdd9fdfea8e63d6e"
integrity sha512-S27Di+EVyMxcHiwDrFzk8dJYAaD+/5SoWKxL1ri/71CRHsnJnRDPNt2Kzj24+MT9FDupf4aqqyqPrvI8MvQ4VQ==
dependencies:
"@eslint/eslintrc" "^1.3.3"
"@humanwhocodes/config-array" "^0.11.6"
@ -2060,10 +2060,10 @@ type-fest@^0.20.2:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
typescript@^4.8.4:
version "4.8.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6"
integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==
typescript@^4.9.3:
version "4.9.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.3.tgz#3aea307c1746b8c384435d8ac36b8a2e580d85db"
integrity sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==
unbox-primitive@^1.0.2:
version "1.0.2"
@ -2114,10 +2114,10 @@ vite-plugin-css-injected-by-js@^2.1.1:
resolved "https://registry.yarnpkg.com/vite-plugin-css-injected-by-js/-/vite-plugin-css-injected-by-js-2.1.1.tgz#a79275241c61f1c8d55d228f5b2dded450a580e4"
integrity sha512-gjIG6iFWde32oRr/bK9CsfN+jdbura2y4GlDzeOiEm6py38ud8dXzFl9zwmHjOjZdr8XEgQ9TovzVGNzp47esw==
vite@^3.2.3:
version "3.2.3"
resolved "https://registry.yarnpkg.com/vite/-/vite-3.2.3.tgz#7a68d9ef73eff7ee6dc0718ad3507adfc86944a7"
integrity sha512-h8jl1TZ76eGs3o2dIBSsvXDLb1m/Ec1iej8ZMdz+PsaFUsftZeWe2CZOI3qogEsMNaywc17gu0q6cQDzh/weCQ==
vite@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/vite/-/vite-3.2.4.tgz#d8c7892dd4268064e04fffbe7d866207dd24166e"
integrity sha512-Z2X6SRAffOUYTa+sLy3NQ7nlHFU100xwanq1WDwqaiFiCe+25zdxP1TfCS5ojPV2oDDcXudHIoPnI1Z/66B7Yw==
dependencies:
esbuild "^0.15.9"
postcss "^8.4.18"