Merge remote-tracking branch 'tbnobody/OpenDTU/master'
@ -195,7 +195,8 @@ After the successful upload, the OpenDTU immediately restarts into the new firmw
|
||||
A documentation of all available MQTT Topics can be found here: [MQTT Documentation](docs/MQTT_Topics.md)
|
||||
|
||||
## Available cases
|
||||
* [https://www.thingiverse.com/thing:5435911](https://www.thingiverse.com/thing:5435911)
|
||||
* <https://www.thingiverse.com/thing:5435911>
|
||||
* <https://www.printables.com/model/293003-sol-opendtu-esp32-nrf24l01-case>
|
||||
|
||||
## Building
|
||||
* Building the WebApp
|
||||
|
||||
@ -68,4 +68,5 @@ cmd topics are used to set values. Status topics are updated from values set in
|
||||
| [serial]/cmd/limit_persistent_absolute | W | Set the inverter limit as a absolute value. The value will survive the night without power. The updated value will show up in the web GUI and limit_relative topic after around 4 minutes. | Watt (W) |
|
||||
| [serial]/cmd/limit_nonpersistent_relative | W | Set the inverter limit as a percentage of total production capability. The value will reset to the last persistent value at night without power. The updated value will show up in the web GUI and limit_relative topic immediatly. | % |
|
||||
| [serial]/cmd/limit_nonpersistent_absolute | W | Set the inverter limit as a absolute value. The value will reset to the last persistent value at night without power. The updated value will show up in the web GUI and limit_relative topic after around 4 minutes. | Watt (W) |
|
||||
| [serial]/cmd/power | W | Turn the inverter on (1) or off (0) | 0 or 1 |
|
||||
| [serial]/cmd/power | W | Turn the inverter on (1) or off (0) | 0 or 1 |
|
||||
| [serial]/cmd/restart | W | Restarts the inverters (also resets YieldDay) | 1 |
|
||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
BIN
docs/screenshots/15_LimitSettings.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/screenshots/16_PowerSettings.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/screenshots/17_InverterInfo.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
@ -4,7 +4,8 @@
|
||||
#include <Arduino.h>
|
||||
|
||||
#define CONFIG_FILENAME "/config.bin"
|
||||
#define CONFIG_VERSION 0x00011500 // 0.1.21 // make sure to clean all after change
|
||||
#define CONFIG_FILENAME_JSON "/config.json"
|
||||
#define CONFIG_VERSION 0x00011600 // 0.1.22 // make sure to clean all after change
|
||||
|
||||
#define WIFI_MAX_SSID_STRLEN 31
|
||||
#define WIFI_MAX_PASSWORD_STRLEN 64
|
||||
@ -26,6 +27,8 @@
|
||||
#define INV_MAX_COUNT 10
|
||||
#define INV_MAX_CHAN_COUNT 4
|
||||
|
||||
#define JSON_BUFFER_SIZE 6144
|
||||
|
||||
struct INVERTER_CONFIG_T {
|
||||
uint64_t Serial;
|
||||
char Name[INV_MAX_NAME_STRLEN + 1];
|
||||
@ -82,6 +85,8 @@ struct CONFIG_T {
|
||||
char Mqtt_Hostname[MQTT_MAX_HOSTNAME_STRLEN + 1];
|
||||
|
||||
bool Mqtt_Hass_Expire;
|
||||
|
||||
char Security_Password[WIFI_MAX_PASSWORD_STRLEN + 1];
|
||||
};
|
||||
|
||||
class ConfigurationClass {
|
||||
@ -93,6 +98,9 @@ public:
|
||||
CONFIG_T& get();
|
||||
|
||||
INVERTER_CONFIG_T* getFreeInverterSlot();
|
||||
|
||||
private:
|
||||
bool readJson();
|
||||
};
|
||||
|
||||
extern ConfigurationClass Configuration;
|
||||
@ -12,6 +12,7 @@
|
||||
#include "WebApi_network.h"
|
||||
#include "WebApi_ntp.h"
|
||||
#include "WebApi_power.h"
|
||||
#include "WebApi_security.h"
|
||||
#include "WebApi_sysstatus.h"
|
||||
#include "WebApi_webapp.h"
|
||||
#include "WebApi_ws_live.h"
|
||||
@ -40,6 +41,7 @@ private:
|
||||
WebApiNetworkClass _webApiNetwork;
|
||||
WebApiNtpClass _webApiNtp;
|
||||
WebApiPowerClass _webApiPower;
|
||||
WebApiSecurityClass _webApiSecurity;
|
||||
WebApiSysstatusClass _webApiSysstatus;
|
||||
WebApiWebappClass _webApiWebapp;
|
||||
WebApiWsLiveClass _webApiWsLive;
|
||||
|
||||
16
include/WebApi_security.h
Normal file
@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <ESPAsyncWebServer.h>
|
||||
|
||||
class WebApiSecurityClass {
|
||||
public:
|
||||
void init(AsyncWebServer* server);
|
||||
void loop();
|
||||
|
||||
private:
|
||||
void onPasswordGet(AsyncWebServerRequest* request);
|
||||
void onPasswordPost(AsyncWebServerRequest* request);
|
||||
|
||||
AsyncWebServer* _server;
|
||||
};
|
||||
@ -169,6 +169,11 @@ bool HoymilesRadio::isConnected()
|
||||
return _radio->isChipConnected();
|
||||
}
|
||||
|
||||
bool HoymilesRadio::isPVariant()
|
||||
{
|
||||
return _radio->isPVariant();
|
||||
}
|
||||
|
||||
void HoymilesRadio::openReadingPipe()
|
||||
{
|
||||
serial_u s;
|
||||
|
||||
@ -47,6 +47,7 @@ public:
|
||||
|
||||
bool isIdle();
|
||||
bool isConnected();
|
||||
bool isPVariant();
|
||||
|
||||
template <typename T>
|
||||
T* enqueCommand()
|
||||
|
||||
@ -97,6 +97,10 @@ bool HM_Abstract::sendSystemConfigParaRequest(HoymilesRadio* radio)
|
||||
|
||||
bool HM_Abstract::sendActivePowerControlRequest(HoymilesRadio* radio, float limit, PowerLimitControlType type)
|
||||
{
|
||||
if (type == PowerLimitControlType::RelativNonPersistent || type == PowerLimitControlType::RelativPersistent) {
|
||||
limit = min<float>(100, limit);
|
||||
}
|
||||
|
||||
_activePowerControlLimit = limit;
|
||||
_activePowerControlType = type;
|
||||
|
||||
@ -115,7 +119,11 @@ bool HM_Abstract::resendActivePowerControlRequest(HoymilesRadio* radio)
|
||||
|
||||
bool HM_Abstract::sendPowerControlRequest(HoymilesRadio* radio, bool turnOn)
|
||||
{
|
||||
_powerState = turnOn;
|
||||
if (turnOn) {
|
||||
_powerState = 1;
|
||||
} else {
|
||||
_powerState = 0;
|
||||
}
|
||||
|
||||
PowerControlCommand* cmd = radio->enqueCommand<PowerControlCommand>();
|
||||
cmd->setPowerOn(turnOn);
|
||||
@ -125,7 +133,33 @@ bool HM_Abstract::sendPowerControlRequest(HoymilesRadio* radio, bool turnOn)
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HM_Abstract::sendRestartControlRequest(HoymilesRadio* radio)
|
||||
{
|
||||
_powerState = 2;
|
||||
|
||||
PowerControlCommand* cmd = radio->enqueCommand<PowerControlCommand>();
|
||||
cmd->setRestart();
|
||||
cmd->setTargetAddress(serial());
|
||||
PowerCommand()->setLastPowerCommandSuccess(CMD_PENDING);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HM_Abstract::resendPowerControlRequest(HoymilesRadio* radio)
|
||||
{
|
||||
return sendPowerControlRequest(radio, _powerState);
|
||||
switch (_powerState) {
|
||||
case 0:
|
||||
return sendPowerControlRequest(radio, false);
|
||||
break;
|
||||
case 1:
|
||||
return sendPowerControlRequest(radio, true);
|
||||
break;
|
||||
case 2:
|
||||
return sendRestartControlRequest(radio);
|
||||
break;
|
||||
|
||||
default:
|
||||
return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ public:
|
||||
bool sendActivePowerControlRequest(HoymilesRadio* radio, float limit, PowerLimitControlType type);
|
||||
bool resendActivePowerControlRequest(HoymilesRadio* radio);
|
||||
bool sendPowerControlRequest(HoymilesRadio* radio, bool turnOn);
|
||||
bool sendRestartControlRequest(HoymilesRadio* radio);
|
||||
bool resendPowerControlRequest(HoymilesRadio* radio);
|
||||
|
||||
private:
|
||||
@ -19,5 +20,5 @@ private:
|
||||
float _activePowerControlLimit = 0;
|
||||
PowerLimitControlType _activePowerControlType = PowerLimitControlType::AbsolutNonPersistent;
|
||||
|
||||
bool _powerState = true;
|
||||
uint8_t _powerState = 1;
|
||||
};
|
||||
@ -53,6 +53,7 @@ public:
|
||||
virtual bool sendActivePowerControlRequest(HoymilesRadio* radio, float limit, PowerLimitControlType type) = 0;
|
||||
virtual bool resendActivePowerControlRequest(HoymilesRadio* radio) = 0;
|
||||
virtual bool sendPowerControlRequest(HoymilesRadio* radio, bool turnOn) = 0;
|
||||
virtual bool sendRestartControlRequest(HoymilesRadio* radio) = 0;
|
||||
virtual bool resendPowerControlRequest(HoymilesRadio* radio) = 0;
|
||||
|
||||
AlarmLogParser* EventLog();
|
||||
|
||||
@ -19,7 +19,7 @@ void SystemConfigParaParser::appendFragment(uint8_t offset, uint8_t* payload, ui
|
||||
|
||||
float SystemConfigParaParser::getLimitPercent()
|
||||
{
|
||||
return ((((uint16_t)_payload[2]) << 8) | _payload[3]) / 10;
|
||||
return ((((uint16_t)_payload[2]) << 8) | _payload[3]) / 10.0;
|
||||
}
|
||||
|
||||
void SystemConfigParaParser::setLimitPercent(float value)
|
||||
|
||||
@ -31,7 +31,7 @@ extra_scripts =
|
||||
|
||||
board_build.partitions = partitions_custom.csv
|
||||
board_build.filesystem = littlefs
|
||||
monitor_filters = time, colorize, log2file, esp32_exception_decoder
|
||||
monitor_filters = esp32_exception_decoder, time, log2file, colorize
|
||||
monitor_speed = 115200
|
||||
upload_protocol = esptool
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
*/
|
||||
#include "Configuration.h"
|
||||
#include "defaults.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <LittleFS.h>
|
||||
|
||||
CONFIG_T config;
|
||||
@ -58,6 +59,8 @@ void ConfigurationClass::init()
|
||||
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;
|
||||
@ -65,29 +68,221 @@ void ConfigurationClass::init()
|
||||
|
||||
bool ConfigurationClass::write()
|
||||
{
|
||||
File f = LittleFS.open(CONFIG_FILENAME, "w");
|
||||
File f = LittleFS.open(CONFIG_FILENAME_JSON, "w");
|
||||
if (!f) {
|
||||
return false;
|
||||
}
|
||||
config.Cfg_SaveCount++;
|
||||
uint8_t* bytes = reinterpret_cast<uint8_t*>(&config);
|
||||
for (unsigned int i = 0; i < sizeof(CONFIG_T); i++) {
|
||||
f.write(bytes[i]);
|
||||
|
||||
DynamicJsonDocument doc(JSON_BUFFER_SIZE);
|
||||
|
||||
JsonObject cfg = doc.createNestedObject("cfg");
|
||||
cfg["version"] = config.Cfg_Version;
|
||||
cfg["save_count"] = config.Cfg_SaveCount;
|
||||
|
||||
JsonObject wifi = doc.createNestedObject("wifi");
|
||||
wifi["ssid"] = config.WiFi_Ssid;
|
||||
wifi["password"] = config.WiFi_Password;
|
||||
wifi["ip"] = IPAddress(config.WiFi_Ip).toString();
|
||||
wifi["netmask"] = IPAddress(config.WiFi_Netmask).toString();
|
||||
wifi["gateway"] = IPAddress(config.WiFi_Gateway).toString();
|
||||
wifi["dns1"] = IPAddress(config.WiFi_Dns1).toString();
|
||||
wifi["dns2"] = IPAddress(config.WiFi_Dns2).toString();
|
||||
wifi["dhcp"] = config.WiFi_Dhcp;
|
||||
wifi["hostname"] = config.WiFi_Hostname;
|
||||
|
||||
JsonObject ntp = doc.createNestedObject("ntp");
|
||||
ntp["server"] = config.Ntp_Server;
|
||||
ntp["timezone"] = config.Ntp_Timezone;
|
||||
ntp["timezone_descr"] = config.Ntp_TimezoneDescr;
|
||||
|
||||
JsonObject mqtt = doc.createNestedObject("mqtt");
|
||||
mqtt["enabled"] = config.Mqtt_Enabled;
|
||||
mqtt["hostname"] = config.Mqtt_Hostname;
|
||||
mqtt["port"] = config.Mqtt_Port;
|
||||
mqtt["username"] = config.Mqtt_Username;
|
||||
mqtt["password"] = config.Mqtt_Password;
|
||||
mqtt["topic"] = config.Mqtt_Topic;
|
||||
mqtt["retain"] = config.Mqtt_Retain;
|
||||
mqtt["publish_invterval"] = config.Mqtt_PublishInterval;
|
||||
|
||||
JsonObject mqtt_lwt = mqtt.createNestedObject("lwt");
|
||||
mqtt_lwt["topic"] = config.Mqtt_LwtTopic;
|
||||
mqtt_lwt["value_online"] = config.Mqtt_LwtValue_Online;
|
||||
mqtt_lwt["value_offline"] = config.Mqtt_LwtValue_Offline;
|
||||
|
||||
JsonObject mqtt_tls = mqtt.createNestedObject("tls");
|
||||
mqtt_tls["enabled"] = config.Mqtt_Tls;
|
||||
mqtt_tls["root_ca_cert"] = config.Mqtt_RootCaCert;
|
||||
|
||||
JsonObject mqtt_hass = mqtt.createNestedObject("hass");
|
||||
mqtt_hass["enabled"] = config.Mqtt_Hass_Enabled;
|
||||
mqtt_hass["retain"] = config.Mqtt_Hass_Retain;
|
||||
mqtt_hass["topic"] = config.Mqtt_Hass_Topic;
|
||||
mqtt_hass["individual_panels"] = config.Mqtt_Hass_IndividualPanels;
|
||||
mqtt_hass["expire"] = config.Mqtt_Hass_Expire;
|
||||
|
||||
JsonObject dtu = doc.createNestedObject("dtu");
|
||||
dtu["serial"] = config.Dtu_Serial;
|
||||
dtu["poll_interval"] = config.Dtu_PollInterval;
|
||||
dtu["pa_level"] = config.Dtu_PaLevel;
|
||||
|
||||
JsonObject security = doc.createNestedObject("security");
|
||||
security["password"] = config.Security_Password;
|
||||
|
||||
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;
|
||||
|
||||
JsonArray channels = inv.createNestedArray("channels");
|
||||
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
|
||||
channels.add(config.Inverter[i].MaxChannelPower[c]);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize JSON to file
|
||||
if (serializeJson(doc, f) == 0) {
|
||||
Serial.println("Failed to write file");
|
||||
return false;
|
||||
}
|
||||
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConfigurationClass::read()
|
||||
{
|
||||
File f = LittleFS.open(CONFIG_FILENAME, "r");
|
||||
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;
|
||||
}
|
||||
uint8_t* bytes = reinterpret_cast<uint8_t*>(&config);
|
||||
for (unsigned int i = 0; i < sizeof(CONFIG_T); i++) {
|
||||
bytes[i] = f.read();
|
||||
|
||||
DynamicJsonDocument doc(JSON_BUFFER_SIZE);
|
||||
// Deserialize the JSON document
|
||||
DeserializationError error = deserializeJson(doc, f);
|
||||
if (error) {
|
||||
Serial.println(F("Failed to read file, using default configuration"));
|
||||
}
|
||||
|
||||
JsonObject cfg = doc["cfg"];
|
||||
config.Cfg_Version = cfg["version"] | CONFIG_VERSION;
|
||||
config.Cfg_SaveCount = cfg["save_count"] | 0;
|
||||
|
||||
JsonObject wifi = doc["wifi"];
|
||||
strlcpy(config.WiFi_Ssid, wifi["ssid"] | WIFI_SSID, sizeof(config.WiFi_Ssid));
|
||||
strlcpy(config.WiFi_Password, wifi["password"] | WIFI_PASSWORD, sizeof(config.WiFi_Password));
|
||||
strlcpy(config.WiFi_Hostname, wifi["hostname"] | APP_HOSTNAME, sizeof(config.WiFi_Hostname));
|
||||
|
||||
IPAddress wifi_ip;
|
||||
wifi_ip.fromString(wifi["ip"] | "");
|
||||
config.WiFi_Ip[0] = wifi_ip[0];
|
||||
config.WiFi_Ip[1] = wifi_ip[1];
|
||||
config.WiFi_Ip[2] = wifi_ip[2];
|
||||
config.WiFi_Ip[3] = wifi_ip[3];
|
||||
|
||||
IPAddress wifi_netmask;
|
||||
wifi_netmask.fromString(wifi["netmask"] | "");
|
||||
config.WiFi_Netmask[0] = wifi_netmask[0];
|
||||
config.WiFi_Netmask[1] = wifi_netmask[1];
|
||||
config.WiFi_Netmask[2] = wifi_netmask[2];
|
||||
config.WiFi_Netmask[3] = wifi_netmask[3];
|
||||
|
||||
IPAddress wifi_gateway;
|
||||
wifi_gateway.fromString(wifi["gateway"] | "");
|
||||
config.WiFi_Gateway[0] = wifi_gateway[0];
|
||||
config.WiFi_Gateway[1] = wifi_gateway[1];
|
||||
config.WiFi_Gateway[2] = wifi_gateway[2];
|
||||
config.WiFi_Gateway[3] = wifi_gateway[3];
|
||||
|
||||
IPAddress wifi_dns1;
|
||||
wifi_dns1.fromString(wifi["dns1"] | "");
|
||||
config.WiFi_Dns1[0] = wifi_dns1[0];
|
||||
config.WiFi_Dns1[1] = wifi_dns1[1];
|
||||
config.WiFi_Dns1[2] = wifi_dns1[2];
|
||||
config.WiFi_Dns1[3] = wifi_dns1[3];
|
||||
|
||||
IPAddress wifi_dns2;
|
||||
wifi_dns2.fromString(wifi["dns2"] | "");
|
||||
config.WiFi_Dns2[0] = wifi_dns2[0];
|
||||
config.WiFi_Dns2[1] = wifi_dns2[1];
|
||||
config.WiFi_Dns2[2] = wifi_dns2[2];
|
||||
config.WiFi_Dns2[3] = wifi_dns2[3];
|
||||
|
||||
config.WiFi_Dhcp = wifi["dhcp"] | WIFI_DHCP;
|
||||
|
||||
JsonObject ntp = doc["ntp"];
|
||||
strlcpy(config.Ntp_Server, ntp["server"] | NTP_SERVER, sizeof(config.Ntp_Server));
|
||||
strlcpy(config.Ntp_Timezone, ntp["timezone"] | NTP_TIMEZONE, sizeof(config.Ntp_Timezone));
|
||||
strlcpy(config.Ntp_TimezoneDescr, ntp["timezone_descr"] | NTP_TIMEZONEDESCR, sizeof(config.Ntp_TimezoneDescr));
|
||||
|
||||
JsonObject mqtt = doc["mqtt"];
|
||||
config.Mqtt_Enabled = mqtt["enabled"] | MQTT_ENABLED;
|
||||
strlcpy(config.Mqtt_Hostname, mqtt["hostname"] | MQTT_HOST, sizeof(config.Mqtt_Hostname));
|
||||
config.Mqtt_Port = mqtt["port"] | MQTT_PORT;
|
||||
strlcpy(config.Mqtt_Username, mqtt["username"] | MQTT_USER, sizeof(config.Mqtt_Username));
|
||||
strlcpy(config.Mqtt_Password, mqtt["password"] | MQTT_PASSWORD, sizeof(config.Mqtt_Password));
|
||||
strlcpy(config.Mqtt_Topic, mqtt["topic"] | MQTT_TOPIC, sizeof(config.Mqtt_Topic));
|
||||
config.Mqtt_Retain = mqtt["retain"] | MQTT_RETAIN;
|
||||
config.Mqtt_PublishInterval = mqtt["publish_invterval"] | MQTT_PUBLISH_INTERVAL;
|
||||
|
||||
JsonObject mqtt_lwt = mqtt["lwt"];
|
||||
strlcpy(config.Mqtt_LwtTopic, mqtt_lwt["topic"] | MQTT_LWT_TOPIC, sizeof(config.Mqtt_LwtTopic));
|
||||
strlcpy(config.Mqtt_LwtValue_Online, mqtt_lwt["value_online"] | MQTT_LWT_ONLINE, sizeof(config.Mqtt_LwtValue_Online));
|
||||
strlcpy(config.Mqtt_LwtValue_Offline, mqtt_lwt["value_offline"] | MQTT_LWT_OFFLINE, sizeof(config.Mqtt_LwtValue_Offline));
|
||||
|
||||
JsonObject mqtt_tls = mqtt["tls"];
|
||||
config.Mqtt_Tls = mqtt_tls["enabled"] | MQTT_TLS;
|
||||
strlcpy(config.Mqtt_RootCaCert, mqtt_tls["root_ca_cert"] | MQTT_ROOT_CA_CERT, sizeof(config.Mqtt_RootCaCert));
|
||||
|
||||
JsonObject mqtt_hass = mqtt["hass"];
|
||||
config.Mqtt_Hass_Enabled = mqtt_hass["enabled"] | MQTT_HASS_ENABLED;
|
||||
config.Mqtt_Hass_Retain = mqtt_hass["retain"] | MQTT_HASS_RETAIN;
|
||||
config.Mqtt_Hass_Expire = mqtt_hass["expire"] | MQTT_HASS_EXPIRE;
|
||||
config.Mqtt_Hass_IndividualPanels = mqtt_hass["individual_panels"] | MQTT_HASS_INDIVIDUALPANELS;
|
||||
strlcpy(config.Mqtt_Hass_Topic, mqtt_hass["topic"] | MQTT_HASS_TOPIC, sizeof(config.Mqtt_Hass_Topic));
|
||||
|
||||
JsonObject dtu = doc["dtu"];
|
||||
config.Dtu_Serial = dtu["serial"] | DTU_SERIAL;
|
||||
config.Dtu_PollInterval = dtu["poll_interval"] | DTU_POLL_INTERVAL;
|
||||
config.Dtu_PaLevel = dtu["pa_level"] | DTU_PA_LEVEL;
|
||||
|
||||
JsonObject security = doc["security"];
|
||||
strlcpy(config.Security_Password, security["password"] | ACCESS_POINT_PASSWORD, sizeof(config.Security_Password));
|
||||
|
||||
JsonArray inverters = doc["inverters"];
|
||||
for (uint8_t i = 0; i < INV_MAX_COUNT; i++) {
|
||||
JsonObject inv = inverters[i].as<JsonObject>();
|
||||
config.Inverter[i].Serial = inv["serial"] | 0ULL;
|
||||
strlcpy(config.Inverter[i].Name, inv["name"] | "", sizeof(config.Inverter[i].Name));
|
||||
|
||||
JsonArray channels = inv["channels"];
|
||||
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
|
||||
config.Inverter[i].MaxChannelPower[c] = channels[c];
|
||||
}
|
||||
}
|
||||
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
@ -163,6 +358,10 @@ void ConfigurationClass::migrate()
|
||||
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();
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
#define TOPIC_SUB_LIMIT_NONPERSISTENT_RELATIVE "limit_nonpersistent_relative"
|
||||
#define TOPIC_SUB_LIMIT_NONPERSISTENT_ABSOLUTE "limit_nonpersistent_absolute"
|
||||
#define TOPIC_SUB_POWER "power"
|
||||
#define TOPIC_SUB_RESTART "restart"
|
||||
|
||||
MqttSettingsClass::MqttSettingsClass()
|
||||
{
|
||||
@ -48,6 +49,7 @@ void MqttSettingsClass::onMqttConnect(bool sessionPresent)
|
||||
mqttClient->subscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_NONPERSISTENT_RELATIVE).c_str(), 0);
|
||||
mqttClient->subscribe(String(topic + "+/cmd/" + TOPIC_SUB_LIMIT_NONPERSISTENT_ABSOLUTE).c_str(), 0);
|
||||
mqttClient->subscribe(String(topic + "+/cmd/" + TOPIC_SUB_POWER).c_str(), 0);
|
||||
mqttClient->subscribe(String(topic + "+/cmd/" + TOPIC_SUB_RESTART).c_str(), 0);
|
||||
}
|
||||
|
||||
void MqttSettingsClass::onMqttDisconnect(espMqttClientTypes::DisconnectReason reason)
|
||||
@ -127,7 +129,6 @@ void MqttSettingsClass::onMqttMessage(const espMqttClientTypes::MessagePropertie
|
||||
|
||||
if (!strcmp(setting, TOPIC_SUB_LIMIT_PERSISTENT_RELATIVE)) {
|
||||
// Set inverter limit relative persistent
|
||||
payload_val = min<uint32_t>(100, payload_val);
|
||||
Serial.printf("Limit Persistent: %d %%\n", payload_val);
|
||||
inv->sendActivePowerControlRequest(Hoymiles.getRadio(), payload_val, PowerLimitControlType::RelativPersistent);
|
||||
|
||||
@ -138,7 +139,6 @@ void MqttSettingsClass::onMqttMessage(const espMqttClientTypes::MessagePropertie
|
||||
|
||||
} else if (!strcmp(setting, TOPIC_SUB_LIMIT_NONPERSISTENT_RELATIVE)) {
|
||||
// Set inverter limit relative non persistent
|
||||
payload_val = min<uint32_t>(100, payload_val);
|
||||
Serial.printf("Limit Non-Persistent: %d %%\n", payload_val);
|
||||
if (!properties.retain) {
|
||||
inv->sendActivePowerControlRequest(Hoymiles.getRadio(), payload_val, PowerLimitControlType::RelativNonPersistent);
|
||||
@ -155,10 +155,19 @@ void MqttSettingsClass::onMqttMessage(const espMqttClientTypes::MessagePropertie
|
||||
Serial.println("Ignored because retained");
|
||||
}
|
||||
|
||||
} else if(!strcmp(setting, TOPIC_SUB_POWER)) {
|
||||
} else if (!strcmp(setting, TOPIC_SUB_POWER)) {
|
||||
// Turn inverter on or off
|
||||
Serial.printf("Set inverter power to: %d\n", payload_val);
|
||||
inv->sendPowerControlRequest(Hoymiles.getRadio(), payload_val > 0);
|
||||
|
||||
} else if (!strcmp(setting, TOPIC_SUB_RESTART)) {
|
||||
// Restart inverter
|
||||
Serial.printf("Restart inverter\n");
|
||||
if (!properties.retain && payload_val == 1) {
|
||||
inv->sendRestartControlRequest(Hoymiles.getRadio());
|
||||
} else {
|
||||
Serial.println("Ignored because retained");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -115,7 +115,7 @@ void NetworkSettingsClass::setupMode()
|
||||
WiFi.mode(WIFI_AP_STA);
|
||||
String ssidString = getApName();
|
||||
WiFi.softAPConfig(apIp, apIp, apNetmask);
|
||||
WiFi.softAP((const char*)ssidString.c_str(), ACCESS_POINT_PASSWORD);
|
||||
WiFi.softAP((const char*)ssidString.c_str(), Configuration.get().Security_Password);
|
||||
dnsServer->setErrorReplyCode(DNSReplyCode::NoError);
|
||||
dnsServer->start(DNS_PORT, "*", WiFi.softAPIP());
|
||||
dnsServerStatus = true;
|
||||
|
||||
@ -28,6 +28,7 @@ void WebApiClass::init()
|
||||
_webApiNetwork.init(&_server);
|
||||
_webApiNtp.init(&_server);
|
||||
_webApiPower.init(&_server);
|
||||
_webApiSecurity.init(&_server);
|
||||
_webApiSysstatus.init(&_server);
|
||||
_webApiWebapp.init(&_server);
|
||||
_webApiWsLive.init(&_server);
|
||||
@ -50,6 +51,7 @@ void WebApiClass::loop()
|
||||
_webApiNetwork.loop();
|
||||
_webApiNtp.loop();
|
||||
_webApiPower.loop();
|
||||
_webApiSecurity.loop();
|
||||
_webApiSysstatus.loop();
|
||||
_webApiWebapp.loop();
|
||||
_webApiWsLive.loop();
|
||||
|
||||
@ -32,7 +32,7 @@ void WebApiConfigClass::loop()
|
||||
|
||||
void WebApiConfigClass::onConfigGet(AsyncWebServerRequest* request)
|
||||
{
|
||||
request->send(LittleFS, CONFIG_FILENAME, String(), true);
|
||||
request->send(LittleFS, CONFIG_FILENAME_JSON, String(), true);
|
||||
}
|
||||
|
||||
void WebApiConfigClass::onConfigDelete(AsyncWebServerRequest* request)
|
||||
@ -87,7 +87,7 @@ void WebApiConfigClass::onConfigDelete(AsyncWebServerRequest* request)
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
|
||||
LittleFS.remove(CONFIG_FILENAME);
|
||||
LittleFS.remove(CONFIG_FILENAME_JSON);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
@ -110,7 +110,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, "w");
|
||||
request->_tempFile = LittleFS.open(CONFIG_FILENAME_JSON, "w");
|
||||
}
|
||||
|
||||
if (len) {
|
||||
|
||||
@ -36,6 +36,7 @@ void WebApiLimitClass::onLimitStatus(AsyncWebServerRequest* request)
|
||||
((uint32_t)(inv->serial() & 0xFFFFFFFF)));
|
||||
|
||||
root[buffer]["limit_relative"] = inv->SystemConfigPara()->getLimitPercent();
|
||||
root[buffer]["max_power"] = inv->DevInfo()->getMaxPower();
|
||||
|
||||
LastCommandSuccess status = inv->SystemConfigPara()->getLastLimitCommandSuccess();
|
||||
String limitStatus = "Unknown";
|
||||
|
||||
@ -170,6 +170,13 @@ void WebApiMqttClass::onMqttAdminPost(AsyncWebServerRequest* request)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root[F("mqtt_topic")].as<String>().endsWith("/")) {
|
||||
retMsg[F("message")] = F("Topic must end with slash (/)!");
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (root[F("mqtt_port")].as<uint>() == 0 || root[F("mqtt_port")].as<uint>() > 65535) {
|
||||
retMsg[F("message")] = F("Port must be a number between 1 and 65535!");
|
||||
response->setLength();
|
||||
|
||||
@ -151,11 +151,13 @@ void WebApiNetworkClass::onNetworkAdminPost(AsyncWebServerRequest* request)
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
if (root[F("ssid")].as<String>().length() == 0 || root[F("ssid")].as<String>().length() > WIFI_MAX_SSID_STRLEN) {
|
||||
retMsg[F("message")] = F("SSID must between 1 and " STR(WIFI_MAX_SSID_STRLEN) " characters long!");
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
return;
|
||||
if (NetworkSettings.NetworkMode() == network_mode::WiFi) {
|
||||
if (root[F("ssid")].as<String>().length() == 0 || root[F("ssid")].as<String>().length() > WIFI_MAX_SSID_STRLEN) {
|
||||
retMsg[F("message")] = F("SSID must between 1 and " STR(WIFI_MAX_SSID_STRLEN) " characters long!");
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (root[F("password")].as<String>().length() > WIFI_MAX_PASSWORD_STRLEN - 1) {
|
||||
retMsg[F("message")] = F("Password must not be longer than " STR(WIFI_MAX_PASSWORD_STRLEN) " characters long!");
|
||||
|
||||
@ -39,11 +39,9 @@ void WebApiPowerClass::onPowerStatus(AsyncWebServerRequest* request)
|
||||
String limitStatus = "Unknown";
|
||||
if (status == LastCommandSuccess::CMD_OK) {
|
||||
limitStatus = "Ok";
|
||||
}
|
||||
else if (status == LastCommandSuccess::CMD_NOK) {
|
||||
} else if (status == LastCommandSuccess::CMD_NOK) {
|
||||
limitStatus = "Failure";
|
||||
}
|
||||
else if (status == LastCommandSuccess::CMD_PENDING) {
|
||||
} else if (status == LastCommandSuccess::CMD_PENDING) {
|
||||
limitStatus = "Pending";
|
||||
}
|
||||
root[buffer]["power_set_status"] = limitStatus;
|
||||
@ -86,7 +84,7 @@ void WebApiPowerClass::onPowerPost(AsyncWebServerRequest* request)
|
||||
}
|
||||
|
||||
if (!(root.containsKey("serial")
|
||||
&& root.containsKey("power"))) {
|
||||
&& (root.containsKey("power") || root.containsKey("restart")))) {
|
||||
retMsg[F("message")] = F("Values are missing!");
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
@ -101,8 +99,6 @@ void WebApiPowerClass::onPowerPost(AsyncWebServerRequest* request)
|
||||
}
|
||||
|
||||
uint64_t serial = strtoll(root[F("serial")].as<String>().c_str(), NULL, 16);
|
||||
uint16_t power = root[F("power")].as<bool>();
|
||||
|
||||
auto inv = Hoymiles.getInverterBySerial(serial);
|
||||
if (inv == nullptr) {
|
||||
retMsg[F("message")] = F("Invalid inverter specified!");
|
||||
@ -111,7 +107,14 @@ void WebApiPowerClass::onPowerPost(AsyncWebServerRequest* request)
|
||||
return;
|
||||
}
|
||||
|
||||
inv->sendPowerControlRequest(Hoymiles.getRadio(), power);
|
||||
if (root.containsKey("power")) {
|
||||
uint16_t power = root[F("power")].as<bool>();
|
||||
inv->sendPowerControlRequest(Hoymiles.getRadio(), power);
|
||||
} else {
|
||||
if (root[F("restart")].as<bool>()) {
|
||||
inv->sendRestartControlRequest(Hoymiles.getRadio());
|
||||
}
|
||||
}
|
||||
|
||||
retMsg[F("type")] = F("success");
|
||||
retMsg[F("message")] = F("Settings saved!");
|
||||
|
||||
92
src/WebApi_security.cpp
Normal file
@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
/*
|
||||
* Copyright (C) 2022 Thomas Basler and others
|
||||
*/
|
||||
#include "WebApi_security.h"
|
||||
#include "ArduinoJson.h"
|
||||
#include "AsyncJson.h"
|
||||
#include "Configuration.h"
|
||||
#include "helper.h"
|
||||
|
||||
void WebApiSecurityClass::init(AsyncWebServer* server)
|
||||
{
|
||||
using std::placeholders::_1;
|
||||
|
||||
_server = server;
|
||||
|
||||
_server->on("/api/security/password", HTTP_GET, std::bind(&WebApiSecurityClass::onPasswordGet, this, _1));
|
||||
_server->on("/api/security/password", HTTP_POST, std::bind(&WebApiSecurityClass::onPasswordPost, this, _1));
|
||||
}
|
||||
|
||||
void WebApiSecurityClass::loop()
|
||||
{
|
||||
}
|
||||
|
||||
void WebApiSecurityClass::onPasswordGet(AsyncWebServerRequest* request)
|
||||
{
|
||||
AsyncJsonResponse* response = new AsyncJsonResponse();
|
||||
JsonObject root = response->getRoot();
|
||||
const CONFIG_T& config = Configuration.get();
|
||||
|
||||
root[F("password")] = config.Security_Password;
|
||||
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
void WebApiSecurityClass::onPasswordPost(AsyncWebServerRequest* request)
|
||||
{
|
||||
AsyncJsonResponse* response = new AsyncJsonResponse();
|
||||
JsonObject retMsg = response->getRoot();
|
||||
retMsg[F("type")] = F("warning");
|
||||
|
||||
if (!request->hasParam("data", true)) {
|
||||
retMsg[F("message")] = F("No values found!");
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
|
||||
String json = request->getParam("data", true)->value();
|
||||
|
||||
if (json.length() > 1024) {
|
||||
retMsg[F("message")] = F("Data too large!");
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
|
||||
DynamicJsonDocument root(1024);
|
||||
DeserializationError error = deserializeJson(root, json);
|
||||
|
||||
if (error) {
|
||||
retMsg[F("message")] = F("Failed to parse data!");
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root.containsKey("password")) {
|
||||
retMsg[F("message")] = F("Values are missing!");
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (root[F("password")].as<String>().length() < 8 || root[F("password")].as<String>().length() > WIFI_MAX_PASSWORD_STRLEN) {
|
||||
retMsg[F("message")] = F("Password must between 8 and " STR(WIFI_MAX_PASSWORD_STRLEN) " characters long!");
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
|
||||
CONFIG_T& config = Configuration.get();
|
||||
strlcpy(config.Security_Password, root[F("password")].as<String>().c_str(), sizeof(config.Security_Password));
|
||||
Configuration.write();
|
||||
|
||||
retMsg[F("type")] = F("success");
|
||||
retMsg[F("message")] = F("Settings saved!");
|
||||
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
}
|
||||
@ -66,6 +66,7 @@ void WebApiSysstatusClass::onSystemStatus(AsyncWebServerRequest* request)
|
||||
root[F("uptime")] = esp_timer_get_time() / 1000000;
|
||||
|
||||
root[F("radio_connected")] = Hoymiles.getRadio()->isConnected();
|
||||
root[F("radio_pvariant")] = Hoymiles.getRadio()->isPVariant();
|
||||
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
|
||||
@ -87,6 +87,12 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root)
|
||||
root[i][F("data_age")] = (millis() - inv->Statistics()->getLastUpdate()) / 1000;
|
||||
root[i][F("reachable")] = inv->isReachable();
|
||||
root[i][F("producing")] = inv->isProducing();
|
||||
root[i][F("limit_relative")] = inv->SystemConfigPara()->getLimitPercent();
|
||||
if (inv->DevInfo()->getMaxPower() > 0) {
|
||||
root[i][F("limit_absolute")] = inv->SystemConfigPara()->getLimitPercent() * inv->DevInfo()->getMaxPower() / 100.0;
|
||||
} else {
|
||||
root[i][F("limit_absolute")] = -1;
|
||||
}
|
||||
|
||||
// Loop all channels
|
||||
for (uint8_t c = 0; c <= inv->Statistics()->getChannelCount(); c++) {
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
"@babel/core": "^7.19.3",
|
||||
"@babel/eslint-parser": "^7.19.1",
|
||||
"@types/bootstrap": "^5.2.5",
|
||||
"@types/node": "^18.8.2",
|
||||
"@types/node": "^18.8.3",
|
||||
"@types/spark-md5": "^3.0.2",
|
||||
"@typescript-eslint/parser": "^5.38.1",
|
||||
"@vue/cli-plugin-babel": "~5.0.8",
|
||||
@ -30,7 +30,7 @@
|
||||
"@vue/cli-plugin-typescript": "^5.0.8",
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"@vue/eslint-config-typescript": "^11.0.2",
|
||||
"eslint": "^8.24.0",
|
||||
"eslint": "^8.25.0",
|
||||
"eslint-plugin-vue": "^9.6.0",
|
||||
"typescript": "^4.8.4",
|
||||
"vue-cli-plugin-compression": "~2.0.0"
|
||||
|
||||
@ -98,4 +98,24 @@
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import {
|
||||
BIconInfoCircle,
|
||||
BIconActivity,
|
||||
BIconBug,
|
||||
BIconChat
|
||||
} from 'bootstrap-icons-vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
BIconInfoCircle,
|
||||
BIconActivity,
|
||||
BIconBug,
|
||||
BIconChat,
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
@ -55,7 +55,7 @@
|
||||
|
||||
<div v-else-if="!uploading">
|
||||
<div class="form-group pt-2 mt-3">
|
||||
<input class="form-control" type="file" ref="file" accept=".bin" @change="uploadConfig" />
|
||||
<input class="form-control" type="file" ref="file" accept=".json" @change="uploadConfig" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -116,11 +116,19 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import {
|
||||
BIconExclamationCircleFill,
|
||||
BIconArrowLeft,
|
||||
BIconCheckCircle
|
||||
} from 'bootstrap-icons-vue';
|
||||
import * as bootstrap from 'bootstrap';
|
||||
import BootstrapAlert from "@/components/partials/BootstrapAlert.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
BIconExclamationCircleFill,
|
||||
BIconArrowLeft,
|
||||
BIconCheckCircle,
|
||||
BootstrapAlert,
|
||||
},
|
||||
data() {
|
||||
@ -175,7 +183,7 @@ export default defineComponent({
|
||||
downloadConfig() {
|
||||
const link = document.createElement('a')
|
||||
link.href = "/api/config/get"
|
||||
link.download = 'config.bin'
|
||||
link.download = 'config.json'
|
||||
link.click()
|
||||
},
|
||||
uploadConfig(event: Event | null) {
|
||||
|
||||
@ -74,8 +74,20 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import SparkMD5 from "spark-md5";
|
||||
import {
|
||||
BIconExclamationCircleFill,
|
||||
BIconArrowLeft,
|
||||
BIconArrowRepeat,
|
||||
BIconCheckCircle
|
||||
} from 'bootstrap-icons-vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
BIconExclamationCircleFill,
|
||||
BIconArrowLeft,
|
||||
BIconArrowRepeat,
|
||||
BIconCheckCircle,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
@ -122,7 +134,7 @@ export default defineComponent({
|
||||
this.uploading = true;
|
||||
const formData = new FormData();
|
||||
if (event !== null) {
|
||||
const target= event.target as HTMLInputElement;
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files !== null) {
|
||||
this.file = target.files[0];
|
||||
}
|
||||
|
||||
@ -38,11 +38,26 @@
|
||||
'bg-warning': inverter.reachable && !inverter.producing,
|
||||
'bg-primary': inverter.reachable && inverter.producing,
|
||||
}">
|
||||
{{ inverter.name }} (Inverter Serial Number:
|
||||
{{ inverter.serial }}) (Data Age:
|
||||
{{ inverter.data_age }} seconds)
|
||||
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="p-2 flex-grow-1">
|
||||
<div class="d-flex flex-wrap">
|
||||
<div style="padding-right: 2em;">
|
||||
{{ inverter.name }}
|
||||
</div>
|
||||
<div style="padding-right: 2em;">
|
||||
Serial Number: {{ inverter.serial }}
|
||||
</div>
|
||||
<div style="padding-right: 2em;">
|
||||
Current Limit: <template v-if="inverter.limit_absolute > -1"> {{
|
||||
inverter.limit_absolute.toFixed(0) }}W | </template>{{
|
||||
inverter.limit_relative.toFixed(0)
|
||||
}}%
|
||||
</div>
|
||||
<div style="padding-right: 2em;">
|
||||
Data Age: {{ inverter.data_age }} seconds
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-toolbar p-2" role="toolbar">
|
||||
<div class="btn-group me-2" role="group">
|
||||
<button type="button" class="btn btn-sm btn-danger"
|
||||
@click="onShowLimitSettings(inverter.serial)"
|
||||
@ -175,13 +190,22 @@
|
||||
<div class="row mb-3">
|
||||
<label for="inputCurrentLimit" class="col-sm-3 col-form-label">Current
|
||||
Limit:</label>
|
||||
<div class="col-sm-9">
|
||||
<div class="col-sm-4">
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" id="inputCurrentLimit"
|
||||
aria-describedby="currentLimitType" v-model="currentLimit" disabled />
|
||||
<span class="input-group-text" id="currentLimitType">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-4" v-if="maxPower > 0">
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" id="inputCurrentLimitAbsolute"
|
||||
aria-describedby="currentLimitTypeAbsolute"
|
||||
v-model="currentLimitAbsolute" disabled />
|
||||
<span class="input-group-text" id="currentLimitTypeAbsolute">W</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3 align-items-center">
|
||||
@ -282,6 +306,9 @@
|
||||
<button type="button" class="btn btn-danger" @click="onSetPowerSettings(false)">
|
||||
<BIconToggleOff class="fs-4" /> Turn Off
|
||||
</button>
|
||||
<button type="button" class="btn btn-warning" @click="onSetPowerSettings(true, true)">
|
||||
<BIconArrowCounterclockwise class="fs-4" /> Restart
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -298,11 +325,23 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import InverterChannelInfo from "@/components/partials/InverterChannelInfo.vue";
|
||||
import * as bootstrap from 'bootstrap';
|
||||
import {
|
||||
BIconXCircleFill,
|
||||
BIconExclamationCircleFill,
|
||||
BIconCheckCircleFill,
|
||||
BIconSpeedometer,
|
||||
BIconPower,
|
||||
BIconCpu,
|
||||
BIconJournalText,
|
||||
BIconToggleOn,
|
||||
BIconToggleOff,
|
||||
BIconArrowCounterclockwise
|
||||
} from 'bootstrap-icons-vue';
|
||||
import EventLog from '@/components/partials/EventLog.vue';
|
||||
import DevInfo from '@/components/partials/DevInfo.vue';
|
||||
import BootstrapAlert from '@/components/partials/BootstrapAlert.vue';
|
||||
import InverterChannelInfo from "@/components/partials/InverterChannelInfo.vue";
|
||||
import VedirectView from '@/components/partials/VedirectView.vue';
|
||||
|
||||
declare interface Inverter {
|
||||
@ -310,6 +349,8 @@ declare interface Inverter {
|
||||
name: string,
|
||||
reachable: boolean,
|
||||
producing: boolean,
|
||||
limit_relative: 0,
|
||||
limit_absolute: 0,
|
||||
data_age: 0,
|
||||
events: 0
|
||||
}
|
||||
@ -320,6 +361,16 @@ export default defineComponent({
|
||||
EventLog,
|
||||
DevInfo,
|
||||
BootstrapAlert,
|
||||
BIconXCircleFill,
|
||||
BIconExclamationCircleFill,
|
||||
BIconCheckCircleFill,
|
||||
BIconSpeedometer,
|
||||
BIconPower,
|
||||
BIconCpu,
|
||||
BIconJournalText,
|
||||
BIconToggleOn,
|
||||
BIconToggleOff,
|
||||
BIconArrowCounterclockwise,
|
||||
VedirectView
|
||||
},
|
||||
data() {
|
||||
@ -342,7 +393,9 @@ export default defineComponent({
|
||||
limitSettingLoading: true,
|
||||
|
||||
currentLimit: 0,
|
||||
currentLimitAbsolute: 0,
|
||||
successCommandLimit: "",
|
||||
maxPower: 0,
|
||||
targetLimit: 0,
|
||||
targetLimitMin: 10,
|
||||
targetLimitMax: 100,
|
||||
@ -495,7 +548,11 @@ export default defineComponent({
|
||||
fetch("/api/limit/status")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
this.currentLimit = data[serial].limit_relative;
|
||||
this.maxPower = data[serial].max_power;
|
||||
this.currentLimit = Number((data[serial].limit_relative).toFixed(1));
|
||||
if (this.maxPower > 0) {
|
||||
this.currentLimitAbsolute = Number((this.currentLimit * this.maxPower / 100).toFixed(1));
|
||||
}
|
||||
this.successCommandLimit = data[serial].limit_set_status;
|
||||
this.limitSettingSerial = serial;
|
||||
this.limitSettingLoading = false;
|
||||
@ -572,11 +629,20 @@ export default defineComponent({
|
||||
this.showAlertPower = false;
|
||||
},
|
||||
|
||||
onSetPowerSettings(turnOn: boolean) {
|
||||
const data = {
|
||||
serial: this.powerSettingSerial,
|
||||
power: turnOn,
|
||||
};
|
||||
onSetPowerSettings(turnOn: boolean, restart = false) {
|
||||
let data = {};
|
||||
if (restart) {
|
||||
data = {
|
||||
serial: this.powerSettingSerial,
|
||||
restart: true,
|
||||
};
|
||||
} else {
|
||||
data = {
|
||||
serial: this.powerSettingSerial,
|
||||
power: turnOn,
|
||||
};
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("data", JSON.stringify(data));
|
||||
|
||||
|
||||
@ -96,7 +96,7 @@
|
||||
|
||||
<div class="mb-3" v-for="(max, index) in editInverterData.max_power" :key="`${index}`">
|
||||
<label :for="`inverter-max_${index}`" class="col-form-label">Max power string {{ index +
|
||||
1
|
||||
1
|
||||
}}:</label>
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" :id="`inverter-max_${index}`" min="0"
|
||||
@ -146,6 +146,10 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import {
|
||||
BIconTrash,
|
||||
BIconPencil
|
||||
} from 'bootstrap-icons-vue';
|
||||
import * as bootstrap from 'bootstrap';
|
||||
import BootstrapAlert from "@/components/partials/BootstrapAlert.vue";
|
||||
|
||||
@ -160,6 +164,8 @@ declare interface Inverter {
|
||||
export default defineComponent({
|
||||
components: {
|
||||
BootstrapAlert,
|
||||
BIconTrash,
|
||||
BIconPencil,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -184,7 +190,7 @@ export default defineComponent({
|
||||
this.getInverters();
|
||||
},
|
||||
computed: {
|
||||
sortedInverters() {
|
||||
sortedInverters(): Inverter[] {
|
||||
return this.inverters.slice().sort((a, b) => {
|
||||
return a.serial - b.serial;
|
||||
});
|
||||
|
||||
@ -30,6 +30,10 @@
|
||||
<router-link @click="onClick" class="dropdown-item" to="/settings/inverter">Inverter Settings
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link @click="onClick" class="dropdown-item" to="/settings/security">Security Settings
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link @click="onClick" class="dropdown-item" to="/settings/dtu">DTU Settings</router-link>
|
||||
</li>
|
||||
|
||||
123
webapp/src/components/SecurityAdminView.vue
Normal file
@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="container-xxl" role="main">
|
||||
<div class="page-header">
|
||||
<h1>Security Settings</h1>
|
||||
</div>
|
||||
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
|
||||
{{ alertMessage }}
|
||||
</BootstrapAlert>
|
||||
|
||||
<div class="text-center" v-if="dataLoading">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!dataLoading">
|
||||
<form @submit="savePasswordConfig">
|
||||
<div class="card">
|
||||
<div class="card-header text-white bg-primary">Admin password</div>
|
||||
<div class="card-body">
|
||||
<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="64"
|
||||
placeholder="Password" v-model="password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="inputPasswordRepeat" class="col-sm-2 col-form-label">Repeat Password:</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" id="inputPasswordRepeat" maxlength="64"
|
||||
placeholder="Password" v-model="passwordRepeat" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-secondary" role="alert">
|
||||
<b>Hint:</b>
|
||||
The administrator password is used to connect to the device when in AP mode.
|
||||
It must be 8..64 characters.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mb-3">Save</button>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import BootstrapAlert from "@/components/partials/BootstrapAlert.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
BootstrapAlert,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dataLoading: true,
|
||||
alertMessage: "",
|
||||
alertType: "info",
|
||||
showAlert: false,
|
||||
|
||||
password: "",
|
||||
passwordRepeat: "",
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.getPasswordConfig();
|
||||
},
|
||||
methods: {
|
||||
getPasswordConfig() {
|
||||
this.dataLoading = true;
|
||||
fetch("/api/security/password")
|
||||
.then((response) => response.json())
|
||||
.then(
|
||||
(data) => {
|
||||
this.password = data["password"];
|
||||
this.passwordRepeat = this.password;
|
||||
this.dataLoading = false;
|
||||
}
|
||||
);
|
||||
},
|
||||
savePasswordConfig(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.password != this.passwordRepeat) {
|
||||
this.alertMessage = "Passwords are not equal";
|
||||
this.alertType = "warning";
|
||||
this.showAlert = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const data = {
|
||||
password: this.password
|
||||
}
|
||||
formData.append("data", JSON.stringify(data));
|
||||
|
||||
fetch("/api/security/password", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then(function (response) {
|
||||
if (response.status != 200) {
|
||||
throw response.status;
|
||||
} else {
|
||||
return response.json();
|
||||
}
|
||||
})
|
||||
.then(
|
||||
(response) => {
|
||||
this.alertMessage = response.message;
|
||||
this.alertType = response.type;
|
||||
this.showAlert = true;
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@ -67,6 +67,7 @@ export default defineComponent({
|
||||
sketch_used: 0,
|
||||
// RadioInfo
|
||||
radio_connected: false,
|
||||
radio_pvariant: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
<td>Model</td>
|
||||
<td v-if="devInfoList.hw_model_name != ''">{{ devInfoList.hw_model_name }}</td>
|
||||
<td v-else>Unknown model! Please report the "Hardware Part Number" and model (e.g. HM-350) as an issue
|
||||
<a href="https://github.com/tbnobody/OpenDTU/issues" target="_blank">here</a>.</td>
|
||||
<a href="https://github.com/tbnobody/OpenDTU/issues" target="_blank">here</a>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Bootloader Version</td>
|
||||
@ -38,6 +39,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { BIconInfoSquare } from 'bootstrap-icons-vue';
|
||||
import BootstrapAlert from '@/components/partials/BootstrapAlert.vue';
|
||||
|
||||
declare interface DevInfoData {
|
||||
@ -52,6 +54,7 @@ declare interface DevInfoData {
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
BIconInfoSquare,
|
||||
BootstrapAlert,
|
||||
},
|
||||
props: {
|
||||
|
||||
@ -52,9 +52,6 @@ export default defineComponent({
|
||||
const dMins = minutes > 9 ? minutes : "0" + minutes;
|
||||
const dSecs = seconds > 9 ? seconds : "0" + seconds;
|
||||
|
||||
if (days > 0) {
|
||||
return days + " " + dHours + ":" + dMins + ":" + dSecs;
|
||||
}
|
||||
return dHours + ":" + dMins + ":" + dSecs;
|
||||
};
|
||||
},
|
||||
|
||||
@ -17,6 +17,18 @@
|
||||
<span v-else>not connected</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Chip Type</th>
|
||||
<td class="badge" :class="{
|
||||
'bg-danger': radio_connected && !radio_pvariant,
|
||||
'bg-success': radio_connected && radio_pvariant,
|
||||
'bg-secondary': !radio_connected,
|
||||
}">
|
||||
<span v-if="radio_connected && radio_pvariant">nRF24L01+</span>
|
||||
<span v-else-if="radio_connected && !radio_pvariant">nRF24L01</span>
|
||||
<span v-else>Unknown</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -30,6 +42,7 @@ import { defineComponent } from 'vue';
|
||||
export default defineComponent({
|
||||
props: {
|
||||
radio_connected: { type: Boolean, required: true },
|
||||
radio_pvariant: { type: Boolean, required: true },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { BootstrapIconsPlugin } from 'bootstrap-icons-vue';
|
||||
|
||||
import "bootstrap/dist/css/bootstrap.min.css"
|
||||
import "bootstrap"
|
||||
|
||||
createApp(App).use(router).use(BootstrapIconsPlugin).mount('#app')
|
||||
createApp(App).use(router).mount('#app')
|
||||
|
||||
@ -14,6 +14,7 @@ import FirmwareUpgradeView from '@/components/FirmwareUpgradeView.vue'
|
||||
import ConfigAdminView from '@/components/ConfigAdminView.vue'
|
||||
import VedirectAdminView from '@/components/VedirectAdminView.vue'
|
||||
import VedirectInfoView from '@/components/VedirectInfoView.vue'
|
||||
import SecurityAdminView from '@/components/SecurityAdminView.vue'
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
@ -90,6 +91,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
path: '/settings/config',
|
||||
name: 'Config Management',
|
||||
component: ConfigAdminView
|
||||
},
|
||||
{
|
||||
path: '/settings/security',
|
||||
name: 'Security',
|
||||
component: SecurityAdminView
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@ -1164,10 +1164,10 @@
|
||||
"@babel/helper-validator-identifier" "^7.19.1"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@eslint/eslintrc@^1.3.2":
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.2.tgz#58b69582f3b7271d8fa67fe5251767a5b38ea356"
|
||||
integrity sha512-AXYd23w1S/bv3fTs3Lz0vjiYemS08jWkI3hYyS9I1ry+0f+Yjs1wm+sU0BS8qDOPrBIkp4qHYC16I8uVtpLajQ==
|
||||
"@eslint/eslintrc@^1.3.3":
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.3.tgz#2b044ab39fdfa75b4688184f9e573ce3c5b0ff95"
|
||||
integrity sha512-uj3pT6Mg+3t39fvLrj8iuCIJ38zKO9FpGtJ4BBJebJhEwjoT+KLVNCcHT5QC9NGRIEi7fZ0ZR8YRb884auB4Lg==
|
||||
dependencies:
|
||||
ajv "^6.12.4"
|
||||
debug "^4.3.2"
|
||||
@ -1200,11 +1200,6 @@
|
||||
debug "^4.1.1"
|
||||
minimatch "^3.0.4"
|
||||
|
||||
"@humanwhocodes/gitignore-to-minimatch@^1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d"
|
||||
integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA==
|
||||
|
||||
"@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"
|
||||
@ -1469,10 +1464,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.4.tgz#fd26723a8a3f8f46729812a7f9b4fc2d1608ed39"
|
||||
integrity sha512-I4BD3L+6AWiUobfxZ49DlU43gtI+FTHSv9pE2Zekg6KjMpre4ByusaljW3vYSLJrvQ1ck1hUaeVu8HVlY3vzHg==
|
||||
|
||||
"@types/node@^18.8.2":
|
||||
version "18.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.2.tgz#17d42c6322d917764dd3d2d3a10d7884925de067"
|
||||
integrity sha512-cRMwIgdDN43GO4xMWAfJAecYn8wV4JbsOGHNfNUIDiuYkUYAR5ec4Rj7IO2SAhFPEfpPtLtUTbbny/TCT7aDwA==
|
||||
"@types/node@^18.8.3":
|
||||
version "18.8.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.3.tgz#ce750ab4017effa51aed6a7230651778d54e327c"
|
||||
integrity sha512-0os9vz6BpGwxGe9LOhgP/ncvYN5Tx1fNcd2TM3rD/aCGBkysb+ZWpXEocG24h6ZzOi13+VB8HndAQFezsSOw1w==
|
||||
|
||||
"@types/normalize-package-data@^2.4.0":
|
||||
version "2.4.1"
|
||||
@ -3410,14 +3405,13 @@ eslint-webpack-plugin@^3.1.0:
|
||||
normalize-path "^3.0.0"
|
||||
schema-utils "^3.1.1"
|
||||
|
||||
eslint@^8.24.0:
|
||||
version "8.24.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.24.0.tgz#489516c927a5da11b3979dbfb2679394523383c8"
|
||||
integrity sha512-dWFaPhGhTAiPcCgm3f6LI2MBWbogMnTJzFBbhXVRQDJPkr9pGZvVjlVfXd+vyDcWPA2Ic9L2AXPIQM0+vk/cSQ==
|
||||
eslint@^8.25.0:
|
||||
version "8.25.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.25.0.tgz#00eb962f50962165d0c4ee3327708315eaa8058b"
|
||||
integrity sha512-DVlJOZ4Pn50zcKW5bYH7GQK/9MsoQG2d5eDH0ebEkE8PbgzTTmtt/VTH9GGJ4BfeZCpBLqFfvsjX35UacUL83A==
|
||||
dependencies:
|
||||
"@eslint/eslintrc" "^1.3.2"
|
||||
"@eslint/eslintrc" "^1.3.3"
|
||||
"@humanwhocodes/config-array" "^0.10.5"
|
||||
"@humanwhocodes/gitignore-to-minimatch" "^1.0.2"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
ajv "^6.10.0"
|
||||
chalk "^4.0.0"
|
||||
|
||||