From 41784511e9bfb0bd3d21f379046ca785bc457d6c Mon Sep 17 00:00:00 2001 From: Atle Johansen Date: Thu, 30 Apr 2020 18:56:21 +0200 Subject: [PATCH] Add config for direct MQTT messages to Domoticz --- src/AmsConfiguration.cpp | 206 ++++++++++++++++++++++++++++++++++++++- src/AmsConfiguration.h | 36 ++++++- src/AmsToMqttBridge.ino | 155 ++++++++++++++++++++++++++++- src/web/AmsWebServer.cpp | 65 +++++++++++- src/web/AmsWebServer.h | 1 + web/configdomoticz.html | 120 +++++++++++++++++++++++ web/configmeter.html | 3 + web/configmqtt.html | 4 + web/configsystem.html | 5 +- web/configweb.html | 3 + web/configwifi.html | 3 + web/index.html | 7 +- web/restartwait.html | 3 + 13 files changed, 602 insertions(+), 9 deletions(-) create mode 100644 web/configdomoticz.html diff --git a/src/AmsConfiguration.cpp b/src/AmsConfiguration.cpp index 971ccf5f..eec7a963 100644 --- a/src/AmsConfiguration.cpp +++ b/src/AmsConfiguration.cpp @@ -178,7 +178,6 @@ void AmsConfiguration::ackMqttChange() { mqttChanged = false; } - byte AmsConfiguration::getAuthSecurity() { return authSecurity; } @@ -264,7 +263,71 @@ int AmsConfiguration::getDebugLevel() { void AmsConfiguration::setDebugLevel(int debugLevel) { this->debugLevel = debugLevel; } +// +// Domoticz start +// +int AmsConfiguration::getDomoELIDX() { + return domoELIDX; +} +int AmsConfiguration::getDomoVL1IDX() { + return domoVL1IDX; +} +int AmsConfiguration::getDomoVL2IDX() { + return domoVL2IDX; +} +int AmsConfiguration::getDomoVL3IDX() { + return domoVL3IDX; +} +int AmsConfiguration::getDomoCL1IDX() { + return domoCL1IDX; +} +double AmsConfiguration::getDomoEnergy() { + return domoEnergy; +} +void AmsConfiguration::setDomoELIDX(int domoELIDX) { + domoChanged |= this->domoELIDX != domoELIDX; + this->domoELIDX = domoELIDX; +} +void AmsConfiguration::setDomoVL1IDX(int domoVL1IDX) { + domoChanged |= this->domoVL1IDX != domoVL1IDX; + this->domoVL1IDX = domoVL1IDX; +} +void AmsConfiguration::setDomoVL2IDX(int domoVL2IDX) { + domoChanged |= this->domoVL2IDX != domoVL2IDX; + this->domoVL2IDX = domoVL2IDX; +} +void AmsConfiguration::setDomoVL3IDX(int domoVL3IDX) { + domoChanged |= this->domoVL3IDX != domoVL3IDX; + this->domoVL3IDX = domoVL3IDX; +} +void AmsConfiguration::setDomoCL1IDX(int domoCL1IDX) { + domoChanged |= this->domoCL1IDX != domoCL1IDX; + this->domoCL1IDX = domoCL1IDX; +} +void AmsConfiguration::setDomoEnergy(double domoEnergy) { + domoChanged |= this->domoEnergy != domoEnergy; + this->domoEnergy = domoEnergy; +} +void AmsConfiguration::clearDomo() { + setDomoELIDX(0); + setDomoVL1IDX(0); + setDomoVL2IDX(0); + setDomoVL3IDX(0); + setDomoCL1IDX(0); + setDomoEnergy(-1.0); +} + +bool AmsConfiguration::isDomoChanged() { + return domoChanged; +} + +void AmsConfiguration::ackDomoChange() { + domoChanged = false; +} +// +// Domoticz end +// bool AmsConfiguration::hasConfig() { if(configVersion == 0) { @@ -278,6 +341,7 @@ bool AmsConfiguration::hasConfig() { case 75: case 80: case 81: + case 91: // domoticz (based on 81) return true; default: configVersion = 0; @@ -310,6 +374,9 @@ bool AmsConfiguration::load() { case 81: success = loadConfig81(address); break; + case 91: + success = loadConfig91(address); + break; } EEPROM.end(); return success; @@ -603,6 +670,128 @@ bool AmsConfiguration::loadConfig81(int address) { address += readInt(address, &i); setDebugLevel(i); + bool domo = false; + address += readBool(address, &domo); + + ackWifiChange(); + + return true; +} +// +// domoticz (based on 81) +// +bool AmsConfiguration::loadConfig91(int address) { + char* temp; + + address += readString(address, &temp); + setWifiSsid(temp); + address += readString(address, &temp); + setWifiPassword(temp); + + bool staticIp = false; + address += readBool(address, &staticIp); + if(staticIp) { + address += readString(address, &temp); + setWifiIp(temp); + address += readString(address, &temp); + setWifiGw(temp); + address += readString(address, &temp); + setWifiSubnet(temp); + address += readString(address, &temp); + setWifiDns1(temp); + address += readString(address, &temp); + setWifiDns2(temp); + } + address += readString(address, &temp); + setWifiHostname(temp); + bool mqtt = false; + address += readBool(address, &mqtt); + if(mqtt) { + address += readString(address, &temp); + setMqttHost(temp); + int port; + address += readInt(address, &port); + setMqttPort(port); + address += readString(address, &temp); + setMqttClientId(temp); + address += readString(address, &temp); + setMqttPublishTopic(temp); + address += readString(address, &temp); + setMqttSubscribeTopic(temp); + + bool secure = false; + address += readBool(address, &secure); + if (secure) + { + address += readString(address, &temp); + setMqttUser(temp); + address += readString(address, &temp); + setMqttPassword(temp); + } else { + setMqttUser(""); + setMqttPassword(""); + } + int payloadFormat; + address += readInt(address, &payloadFormat); + setMqttPayloadFormat(payloadFormat); + } else { + clearMqtt(); + } + + address += readByte(address, &authSecurity); + if (authSecurity > 0) { + address += readString(address, &temp); + setAuthUser(temp); + address += readString(address, &temp); + setAuthPassword(temp); + } else { + clearAuth(); + } + + int i; + address += readInt(address, &i); + setMeterType(i); + address += readInt(address, &i); + setDistributionSystem(i); + address += readInt(address, &i); + setMainFuse(i); + address += readInt(address, &i); + setProductionCapacity(i); + + bool debugTelnet = false; + address += readBool(address, &debugTelnet); + setDebugTelnet(debugTelnet); + bool debugSerial = false; + address += readBool(address, &debugSerial); + setDebugSerial(debugSerial); + address += readInt(address, &i); + setDebugLevel(i); + + bool domo = false; + address += readBool(address, &domo); + if(domo) { + int domoELIDX; + address += readInt(address, &domoELIDX); + setDomoELIDX(domoELIDX); + int domoVL1IDX; + address += readInt(address, &domoVL1IDX); + setDomoVL1IDX(domoVL1IDX); + int domoVL2IDX; + address += readInt(address, &domoVL2IDX); + setDomoVL2IDX(domoVL2IDX); + int domoVL3IDX; + address += readInt(address, &domoVL3IDX); + setDomoVL3IDX(domoVL3IDX); + int domoCL1IDX; + address += readInt(address, &domoCL1IDX); + setDomoCL1IDX(domoCL1IDX); + // address += readString(address, &temp); + // domoEnergy = String(temp).toDouble(); + // setDomoEnergy(domoEnergy); + } else { + clearDomo(); + } + ackWifiChange(); return true; @@ -661,7 +850,20 @@ bool AmsConfiguration::save() { address += saveBool(address, debugTelnet); address += saveBool(address, debugSerial); address += saveInt(address, debugLevel); - +// +// Domoticz +// + if(domoELIDX) { + address += saveBool(address, true); + address += saveInt(address, domoELIDX); + address += saveInt(address, domoVL1IDX); + address += saveInt(address, domoVL2IDX); + address += saveInt(address, domoVL3IDX); + address += saveInt(address, domoCL1IDX); + //address += saveString(address, String(domoEnergy).c_str()); + } else { + address += saveBool(address, false); + } bool success = EEPROM.commit(); EEPROM.end(); diff --git a/src/AmsConfiguration.h b/src/AmsConfiguration.h index c2991cb6..b5f50f65 100644 --- a/src/AmsConfiguration.h +++ b/src/AmsConfiguration.h @@ -78,7 +78,26 @@ public: void setDebugLevel(int debugLevel); void print(Print* debugger); - +// +// Domoticz +// + int getDomoELIDX(); + int getDomoVL1IDX(); + int getDomoVL2IDX(); + int getDomoVL3IDX(); + int getDomoCL1IDX(); + double getDomoEnergy(); + void setDomoELIDX(int domoELIDX); + void setDomoVL1IDX(int domoVL1IDX); + void setDomoVL2IDX(int domoVL2IDX); + void setDomoVL3IDX(int domoVL3IDX); + void setDomoCL1IDX(int domoCL1IDX); + void setDomoEnergy(double domoEnergy); + void clearDomo(); + + bool isDomoChanged(); + void ackDomoChange(); + protected: private: @@ -113,14 +132,27 @@ private: bool debugTelnet = false, debugSerial = false; int debugLevel = 3; +// +// Domoticz +// + int domoELIDX; + int domoVL1IDX; + int domoVL2IDX; + int domoVL3IDX; + int domoCL1IDX; + double domoEnergy = -1.0; + bool domoChanged; + const int EEPROM_SIZE = 512; - const int EEPROM_CHECK_SUM = 81; // Used to check if config is stored. Change if structure changes + const int EEPROM_CHECK_SUM = 91; // Used to check if config is stored. Change if structure changes const int EEPROM_CONFIG_ADDRESS = 0; bool loadConfig72(int address); bool loadConfig75(int address); bool loadConfig80(int address); bool loadConfig81(int address); + bool loadConfig91(int address); // domoticz + int saveString(int pAddress, const char* pString); int readString(int pAddress, char* pString[]); diff --git a/src/AmsToMqttBridge.ino b/src/AmsToMqttBridge.ino index aee17ecc..43c20bea 100644 --- a/src/AmsToMqttBridge.ino +++ b/src/AmsToMqttBridge.ino @@ -86,7 +86,7 @@ void setup() { debugI("Voltage: %.2fV", vcc); } -#if SELF_POWERED + #if SELF_POWERED if (vcc > 2.5 && vcc < 3.25) { // Only sleep if voltage is realistic and too low if(Debug.isActive(RemoteDebug::INFO)) { debugI("Voltage is too low, sleeping"); @@ -94,7 +94,7 @@ void setup() { } ESP.deepSleep(10000000); //Deep sleep to allow output cap to charge up } -#endif + #endif #if HAS_RGB_LED // Initialize RGB LED pins @@ -257,6 +257,9 @@ unsigned long lastSuccessfulRead = 0; unsigned long lastErrorBlink = 0; int lastError = 0; +// domoticz energy init +double energy = -1.0; + void loop() { Debug.handle(); unsigned long now = millis(); @@ -462,6 +465,154 @@ void readHanPort() { String msg; serializeJson(json, msg); mqtt.publish(config.getMqttPublishTopic(), msg.c_str()); + // + // Start DOMOTICZ + // + } else if(config.getMqttPayloadFormat() == 2) { + StaticJsonDocument<512> json; + hanToJson(json, data, hw, temperature); + if (Debug.isActive(RemoteDebug::INFO)) { + debugI("Sending data to MQTT"); + if (Debug.isActive(RemoteDebug::DEBUG)) { + serializeJsonPretty(json, Debug); + } + } + String msg; + serializeJson(json, msg); + mqtt.publish(config.getMqttPublishTopic(), msg.c_str()); // keep for now... + // + // Special MQTT messages for DOMOTIZ (https://www.domoticz.com/wiki/MQTT) + // -All messages should be published to topic "domoticz/in" + // + // message msg_PE : send active power and and cumulative energy consuption to virtual meter "Electricity (instant and counter)" + // + // /json.htm?type=command¶m=udevice&idx=IDX&nvalue=0&svalue=POWER;ENERGY + // + // MQTT sample message: {"command": "udevice", "idx" : IDX , "nvalue" : 0, "svalue" : "POWER;ENERGY"} + // IDX = id of your device (This number can be found in the devices tab in the column "IDX") + // POWER = current power (Watt) + // ENERGY = cumulative energy in Watt-hours (Wh) This is an incrementing counter. + // (if you choose as type "Energy read : Computed", this is just a "dummy" counter, not updatable because it's the result of DomoticZ calculs from POWER) + // + // message msg_V1 : send Voltage of L1 to virtual Voltage meter + // + // /json.htm?type=command¶m=udevice&idx=IDX&nvalue=0&svalue=VOLTAGE + // + // MQTT sample message: {"command": "udevice", "idx" : IDX , "nvalue" : 0, "svalue" : "VOLTAGE"} + // IDX = id of your device (This number can be found in the devices tab in the column "IDX") + // VOLTAGE = Voltage (V) + // + + int idx1 = config.getDomoELIDX(); + // TODO, this should be configurable.... + if (idx1 > 0) { + String PowerEnergy; + int p; + // double energy = config.getDomoEnergy(); + double tmp_energy; + StaticJsonDocument<200> json_PE; + p = json["data"]["P"].as(); + // cumulative energy is given only once pr hour. check if value is different from 0 and store last valid value on global variable. + tmp_energy = json["data"]["tPI"].as(); + if (tmp_energy > 1.0) energy = tmp_energy; + // power_unit: watt, energy_unit: watt*h. Stored as kwh, need watth + PowerEnergy = String((double) p/1.0) + ";" + String((double) energy*1000.0) ; + json_PE["command"] = "udevice"; + json_PE["idx"] = idx1; + json_PE["nvalue"] = 0; + json_PE["svalue"] = PowerEnergy; + // Stringify the json + String msg_PE; + serializeJson(json_PE, msg_PE); + // publish power data directly to domoticz/in, but only after first reading of total power, once an hour... . (otherwise total consumtion will be wrong.) + if (energy > 0.0 ) mqtt.publish("domoticz/in", msg_PE.c_str()); + } + int idxu1 =config.getDomoVL1IDX(); + if (idxu1 > 0){ + StaticJsonDocument<200> json_u1; + double u1; + // + // prepare message msg_u1 for virtual Voltage meter" + // + u1 = json["data"]["U1"].as(); + if (u1 > 0.1){ + json_u1["command"] = "udevice"; + json_u1["idx"] = idxu1; + json_u1["nvalue"] = 0; + json_u1["svalue"] = String(u1); + // Stringify the json + String msg_u1; + serializeJson(json_u1, msg_u1); + // publish power data directly to domoticz/in + mqtt.publish("domoticz/in", msg_u1.c_str()); + } + } + int idxu2 =config.getDomoVL2IDX(); + if (idxu2 > 0){ + StaticJsonDocument<200> json_u2; + double u2; + // + // prepare message msg_u2 for virtual Voltage meter" + // + u2 = json["data"]["U2"].as(); + if (u2 > 0.1){ + json_u2["command"] = "udevice"; + json_u2["idx"] = idxu2; + json_u2["nvalue"] = 0; + json_u2["svalue"] = String(u2); + // Stringify the json + String msg_u2; + serializeJson(json_u2, msg_u2); + // publish power data directly to domoticz/in + mqtt.publish("domoticz/in", msg_u2.c_str()); + } + } + int idxu3 =config.getDomoVL3IDX(); + if (idxu3 > 0){ + StaticJsonDocument<200> json_u3; + double u3; + // + // prepare message msg_u3 for virtual Voltage meter" + // + u3 = json["data"]["U3"].as(); + if (u3 > 0.1){ + json_u3["command"] = "udevice"; + json_u3["idx"] = idxu3; + json_u3["nvalue"] = 0; + json_u3["svalue"] = String(u3); + // Stringify the json + String msg_u3; + serializeJson(json_u3, msg_u3); + // publish power data directly to domoticz/in + mqtt.publish("domoticz/in", msg_u3.c_str()); + } + } + + int idxi1 =config.getDomoCL1IDX(); + if (idxi1 > 0){ + StaticJsonDocument<200> json_i1; + double i1, i2, i3; + String Ampere3; + // + // prepare message msg_i1 for virtual Current/Ampere 3phase mater" + // + i1 = json["data"]["I1"].as(); + i2 = json["data"]["I2"].as(); + i3 = json["data"]["I3"].as(); + Ampere3 = String(i1) + ";" + String(i2) + ";" + String(i3) ; + json_i1["command"] = "udevice"; + json_i1["idx"] = idxi1; + json_i1["nvalue"] = 0; + json_i1["svalue"] = Ampere3; + // Stringify the json + String msg_i1; + serializeJson(json_i1, msg_i1); + // publish power data directly to domoticz/in + if (i1 > 0.0) mqtt.publish("domoticz/in", msg_i1.c_str()); + } + // + // End DOMOTICZ + // } else if(config.getMqttPayloadFormat() == 1) { mqtt.publish(config.getMqttPublishTopic() + "/meter/dlms/timestamp", String(data.getPackageTimestamp())); switch(data.getListType()) { diff --git a/src/web/AmsWebServer.cpp b/src/web/AmsWebServer.cpp index b5c6d8c9..12e6015d 100644 --- a/src/web/AmsWebServer.cpp +++ b/src/web/AmsWebServer.cpp @@ -6,6 +6,7 @@ #include "root/configwifi_html.h" #include "root/configmqtt_html.h" #include "root/configweb_html.h" +#include "root/configdomoticz_html.h" #include "root/configsystem_html.h" #include "root/restartwait_html.h" #include "root/boot_css.h" @@ -26,6 +27,7 @@ void AmsWebServer::setup(AmsConfiguration* config, MQTTClient* mqtt) { server.on("/config-wifi", HTTP_GET, std::bind(&AmsWebServer::configWifiHtml, this)); server.on("/config-mqtt", HTTP_GET, std::bind(&AmsWebServer::configMqttHtml, this)); server.on("/config-web", HTTP_GET, std::bind(&AmsWebServer::configWebHtml, this)); + server.on("/config-domoticz",HTTP_GET, std::bind(&AmsWebServer::configDomoticzHtml, this)); server.on("/boot.css", HTTP_GET, std::bind(&AmsWebServer::bootCss, this)); server.on("/gaugemeter.js", HTTP_GET, std::bind(&AmsWebServer::gaugemeterJs, this)); server.on("/data.json", HTTP_GET, std::bind(&AmsWebServer::dataJson, this)); @@ -248,7 +250,7 @@ void AmsWebServer::configMqttHtml() { html.replace("${config.mqttUser}", config->getMqttUser()); html.replace("${config.mqttPassword}", config->getMqttPassword()); html.replace("${config.mqttPayloadFormat}", String(config->getMqttPayloadFormat())); - for(int i = 0; i<2; i++) { + for(int i = 0; i<3; i++) { html.replace("${config.mqttPayloadFormat" + String(i) + "}", config->getMqttPayloadFormat() == i ? "selected" : ""); } @@ -256,6 +258,40 @@ void AmsWebServer::configMqttHtml() { server.send(200, "text/html", html); } +void AmsWebServer::configDomoticzHtml() { + printD("Serving /config/domoticz.html over http..."); + + if(!checkSecurity(1)) + return; + + String html = String((const __FlashStringHelper*) CONFIGDOMOTICZ_HTML); + html.replace("${version}", VERSION); + + if(WiFi.getMode() != WIFI_AP) { + html.replace("boot.css", BOOTSTRAP_URL); + } + + server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + server.sendHeader("Pragma", "no-cache"); + + html.replace("${config.domo}", config->getDomoELIDX() == 0 ? "" : "checked"); + if(config->getDomoELIDX() > 0){ html.replace("${config.domoELIDX}", String(config->getDomoELIDX())); + } else { html.replace("${config.domoELIDX}", ""); } + if(config->getDomoVL1IDX() > 0){ html.replace("${config.domoVL1IDX}", String(config->getDomoVL1IDX())); + } else { html.replace("${config.domoVL1IDX}", ""); } + if(config->getDomoVL2IDX() > 0){ html.replace("${config.domoVL2IDX}", String(config->getDomoVL2IDX())); + } else { html.replace("${config.domoVL2IDX}", ""); } + if(config->getDomoVL3IDX() > 0){ html.replace("${config.domoVL3IDX}", String(config->getDomoVL3IDX())); + } else { html.replace("${config.domoVL3IDX}", ""); } + if(config->getDomoCL1IDX() > 0){ html.replace("${config.domoCL1IDX}", String(config->getDomoCL1IDX())); + } else { html.replace("${config.domoCL1IDX}", ""); } + if(config->getDomoEnergy() > 0.0){ html.replace("${config.domoEnergy}", String(config->getDomoEnergy())); + } else { html.replace("${config.domoEnergy}", ""); } + server.setContentLength(html.length()); + server.send(200, "text/html", html); +} + + void AmsWebServer::configWebHtml() { printD("Serving /config-web.html over http..."); @@ -443,6 +479,18 @@ void AmsWebServer::dataJson() { json.createNestedObject("mqtt"); json["mqtt"]["lastError"] = (int) mqtt->lastError(); + String domoStatus; + if(String(config->getDomoELIDX()).isEmpty()) { + domoStatus = "secondary"; + } else if(mqtt->connected() && config->getMqttPayloadFormat() == 2 && config->getDomoELIDX() > 0) { + domoStatus = "success"; + } else if(mqtt->lastError() == 0) { + domoStatus = "warning"; + } else { + domoStatus = "danger"; + } + json["status"]["domo"] = domoStatus; + serializeJson(json, jsonStr); server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate"); @@ -494,6 +542,21 @@ void AmsWebServer::handleSave() { } } + if(server.hasArg("domoConfig") && server.arg("domoConfig") == "true") { + if(server.hasArg("domo") && server.arg("domo") == "true") { + config->setDomoELIDX(server.arg("domoELIDX").toInt()); + config->setDomoVL1IDX(server.arg("domoVL1IDX").toInt()); + config->setDomoVL2IDX(server.arg("domoVL2IDX").toInt()); + config->setDomoVL3IDX(server.arg("domoVL3IDX").toInt()); + config->setDomoCL1IDX(server.arg("domoCL1IDX").toInt()); + config->setDomoEnergy(server.arg("domoEnergy").toDouble()); + } else { + config->clearDomo(); + } + } + + + if(server.hasArg("authConfig") && server.arg("authConfig") == "true") { config->setAuthSecurity((byte)server.arg("authSecurity").toInt()); if(config->getAuthSecurity() > 0) { diff --git a/src/web/AmsWebServer.h b/src/web/AmsWebServer.h index da221b7b..9d4c4312 100644 --- a/src/web/AmsWebServer.h +++ b/src/web/AmsWebServer.h @@ -60,6 +60,7 @@ private: void configWifiHtml(); void configMqttHtml(); void configWebHtml(); + void configDomoticzHtml(); void bootCss(); void gaugemeterJs(); void dataJson(); diff --git a/web/configdomoticz.html b/web/configdomoticz.html new file mode 100644 index 00000000..07c3ef9e --- /dev/null +++ b/web/configdomoticz.html @@ -0,0 +1,120 @@ + + + + + AMS reader - WiFi configuration + + + + + +
+ +
+ +
+
Domoticz Configuration. Requires that a Domoticz MQTT-message-broker is setup. HOWTO: https://www.domoticz.com/wiki/MQTT.
(This implementation assumes that the host defined on the MQTT tab is used.)

The following virtual sensors can currently be used:
+ "Electricity (instant and counter)", "Electricity Current/Ampere 3 Phase" and "Voltage", see https://www.domoticz.com/wiki/Domoticz_API/JSON_URL's

+ Create the sensors in Domoticz under Hardware > Dummy > Create virtual sensor, and use the IDX assigned to the sensor as input here.

+ "Electricity (instant and counter)" relies on Total energy import "tPI" and will not start before the first value is read (once an hour).
+ +
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+
+
+ +
+
+
+ Back +
+
+ +
+
+
+
+ + + diff --git a/web/configmeter.html b/web/configmeter.html index 9ea83508..4cb8ec4c 100644 --- a/web/configmeter.html +++ b/web/configmeter.html @@ -25,6 +25,9 @@ Web + diff --git a/web/configmqtt.html b/web/configmqtt.html index 29fdcc12..2caf116d 100644 --- a/web/configmqtt.html +++ b/web/configmqtt.html @@ -25,6 +25,9 @@ + @@ -55,6 +58,7 @@ diff --git a/web/configsystem.html b/web/configsystem.html index fc27bed1..8ea4f7d1 100644 --- a/web/configsystem.html +++ b/web/configsystem.html @@ -26,9 +26,12 @@ Web + - +