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

This commit is contained in:
helgeerbe 2022-11-26 16:31:16 +01:00
commit 2109520bde
48 changed files with 419 additions and 91 deletions

View File

@ -154,7 +154,7 @@ This can be achieved by editing the 'platformio.ini' file and add/change one or
* Adjust the COM port in the file "platformio.ini" for your USB-serial-converter. It occurs twice:
* upload_port
* monitor_port
* Select the arrow button in the status bar (PlatformIO: Upload) to compile and upload the firmware. During the compilation, all required libraries are downloaded automatically.
* Select the arrow button in the blue bottom status bar (PlatformIO: Upload) to compile and upload the firmware. During the compilation, all required libraries are downloaded automatically.
* There are two videos showing these steps:
* [Git Clone and compilation](https://youtu.be/9cA_esv3zeA)
* [Full installation and compilation](https://youtu.be/xs6TqHn7QWM)
@ -263,6 +263,7 @@ A documentation of the Web API can be found here: [Web-API Documentation](docs/W
* OpenDTU needs access to a working NTP server to get the current date & time.
* If your problem persists, check the [Issues on Github](https://github.com/tbnobody/OpenDTU/issues). Please inspect not only the open issues, also the closed issues contain useful information.
* Another source of information are the [Discussions](https://github.com/tbnobody/OpenDTU/discussions/)
* When flashing with VSCode Plattform.IO fails and also with ESPRESSIF tool a demo bin file cannot be flashed to the ESP32 with error message "A fatal error occurred: MD5 of file does not match data in flash!" than un-wire/unconnect ESP32 from the NRF24L01+ board. Try to flash again and rewire afterwards.
## Related Projects
- [Ahoy](https://github.com/grindylow/ahoy)

View File

@ -91,6 +91,7 @@ struct CONFIG_T {
bool Mqtt_Hass_Expire;
char Security_Password[WIFI_MAX_PASSWORD_STRLEN + 1];
bool Security_AllowReadonly;
};
class ConfigurationClass {

View File

@ -8,6 +8,7 @@
#include "WebApi_firmware.h"
#include "WebApi_inverter.h"
#include "WebApi_limit.h"
#include "WebApi_maintenance.h"
#include "WebApi_mqtt.h"
#include "WebApi_network.h"
#include "WebApi_ntp.h"
@ -28,6 +29,7 @@ public:
void loop();
static bool checkCredentials(AsyncWebServerRequest* request);
static bool checkCredentialsReadonly(AsyncWebServerRequest* request);
private:
AsyncWebServer _server;
@ -40,6 +42,7 @@ private:
WebApiFirmwareClass _webApiFirmware;
WebApiInverterClass _webApiInverter;
WebApiLimitClass _webApiLimit;
WebApiMaintenanceClass _webApiMaintenance;
WebApiMqttClass _webApiMqtt;
WebApiNetworkClass _webApiNetwork;
WebApiNtpClass _webApiNtp;

View File

@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <ESPAsyncWebServer.h>
class WebApiMaintenanceClass {
public:
void init(AsyncWebServer* server);
void loop();
private:
void onRebootPost(AsyncWebServerRequest* request);
AsyncWebServer* _server;
};

View File

@ -9,8 +9,8 @@ public:
void loop();
private:
void onPasswordGet(AsyncWebServerRequest* request);
void onPasswordPost(AsyncWebServerRequest* request);
void onSecurityGet(AsyncWebServerRequest* request);
void onSecurityPost(AsyncWebServerRequest* request);
void onAuthenticateGet(AsyncWebServerRequest* request);

View File

@ -10,6 +10,7 @@
#define ACCESS_POINT_NAME "OpenDTU-"
#define ACCESS_POINT_PASSWORD "openDTU42"
#define AUTH_USERNAME "admin"
#define SECURITY_ALLOW_READONLY true
#define ADMIN_TIMEOUT 180
#define WIFI_RECONNECT_TIMEOUT 15

View File

@ -77,6 +77,7 @@ bool ConfigurationClass::write()
JsonObject security = doc.createNestedObject("security");
security["password"] = config.Security_Password;
security["allow_readonly"] = config.Security_AllowReadonly;
JsonArray inverters = doc.createNestedArray("inverters");
for (uint8_t i = 0; i < INV_MAX_COUNT; i++) {
@ -202,6 +203,7 @@ bool ConfigurationClass::read()
JsonObject security = doc["security"];
strlcpy(config.Security_Password, security["password"] | ACCESS_POINT_PASSWORD, sizeof(config.Security_Password));
config.Security_AllowReadonly = security["allow_readonly"] | SECURITY_ALLOW_READONLY;
JsonArray inverters = doc["inverters"];
for (uint8_t i = 0; i < INV_MAX_COUNT; i++) {

View File

@ -25,6 +25,7 @@ void WebApiClass::init()
_webApiFirmware.init(&_server);
_webApiInverter.init(&_server);
_webApiLimit.init(&_server);
_webApiMaintenance.init(&_server);
_webApiMqtt.init(&_server);
_webApiNetwork.init(&_server);
_webApiNtp.init(&_server);
@ -49,6 +50,7 @@ void WebApiClass::loop()
_webApiFirmware.loop();
_webApiInverter.loop();
_webApiLimit.loop();
_webApiMaintenance.loop();
_webApiMqtt.loop();
_webApiNetwork.loop();
_webApiNtp.loop();
@ -79,4 +81,14 @@ bool WebApiClass::checkCredentials(AsyncWebServerRequest* request)
return false;
}
bool WebApiClass::checkCredentialsReadonly(AsyncWebServerRequest* request)
{
CONFIG_T& config = Configuration.get();
if (config.Security_AllowReadonly) {
return true;
} else {
return checkCredentials(request);
}
}
WebApiClass WebApi;

View File

@ -6,6 +6,7 @@
#include "ArduinoJson.h"
#include "AsyncJson.h"
#include "Hoymiles.h"
#include "WebApi.h"
#include <ctime>
void WebApiDevInfoClass::init(AsyncWebServer* server)
@ -23,6 +24,10 @@ void WebApiDevInfoClass::loop()
void WebApiDevInfoClass::onDevInfoStatus(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentialsReadonly(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();

View File

@ -6,6 +6,7 @@
#include "ArduinoJson.h"
#include "AsyncJson.h"
#include "Hoymiles.h"
#include "WebApi.h"
void WebApiEventlogClass::init(AsyncWebServer* server)
{
@ -22,6 +23,10 @@ void WebApiEventlogClass::loop()
void WebApiEventlogClass::onEventlogStatus(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentialsReadonly(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse(false, 2048);
JsonObject root = response->getRoot();

View File

@ -24,6 +24,10 @@ void WebApiLimitClass::loop()
void WebApiLimitClass::onLimitStatus(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentialsReadonly(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();

View File

@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-2.0-or-later
/*
* Copyright (C) 2022 Thomas Basler and others
*/
#include "WebApi_maintenance.h"
#include "AsyncJson.h"
#include "WebApi.h"
void WebApiMaintenanceClass::init(AsyncWebServer* server)
{
using std::placeholders::_1;
_server = server;
_server->on("/api/maintenance/reboot", HTTP_POST, std::bind(&WebApiMaintenanceClass::onRebootPost, this, _1));
}
void WebApiMaintenanceClass::loop()
{
}
void WebApiMaintenanceClass::onRebootPost(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse(false, MQTT_JSON_DOC_SIZE);
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() > MQTT_JSON_DOC_SIZE) {
retMsg[F("message")] = F("Data too large!");
response->setLength();
request->send(response);
return;
}
DynamicJsonDocument root(MQTT_JSON_DOC_SIZE);
DeserializationError error = deserializeJson(root, json);
if (error) {
retMsg[F("message")] = F("Failed to parse data!");
response->setLength();
request->send(response);
return;
}
if (!(root.containsKey("reboot"))) {
retMsg[F("message")] = F("Values are missing!");
response->setLength();
request->send(response);
return;
}
if (root[F("reboot")].as<bool>()) {
retMsg[F("type")] = F("success");
retMsg[F("message")] = F("Reboot triggered!");
response->setLength();
request->send(response);
yield();
delay(1000);
yield();
ESP.restart();
} else {
retMsg[F("message")] = F("Reboot cancled!");
response->setLength();
request->send(response);
}
}

View File

@ -28,6 +28,10 @@ void WebApiMqttClass::loop()
void WebApiMqttClass::onMqttStatus(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentialsReadonly(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse(false, MQTT_JSON_DOC_SIZE);
JsonObject root = response->getRoot();
const CONFIG_T& config = Configuration.get();

View File

@ -27,6 +27,10 @@ void WebApiNetworkClass::loop()
void WebApiNetworkClass::onNetworkStatus(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentialsReadonly(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();

View File

@ -29,6 +29,10 @@ void WebApiNtpClass::loop()
void WebApiNtpClass::onNtpStatus(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentialsReadonly(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();
const CONFIG_T& config = Configuration.get();

View File

@ -24,6 +24,10 @@ void WebApiPowerClass::loop()
void WebApiPowerClass::onPowerStatus(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentialsReadonly(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();

View File

@ -15,8 +15,8 @@ void WebApiSecurityClass::init(AsyncWebServer* server)
_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));
_server->on("/api/security/config", HTTP_GET, std::bind(&WebApiSecurityClass::onSecurityGet, this, _1));
_server->on("/api/security/config", HTTP_POST, std::bind(&WebApiSecurityClass::onSecurityPost, this, _1));
_server->on("/api/security/authenticate", HTTP_GET, std::bind(&WebApiSecurityClass::onAuthenticateGet, this, _1));
}
@ -24,7 +24,7 @@ void WebApiSecurityClass::loop()
{
}
void WebApiSecurityClass::onPasswordGet(AsyncWebServerRequest* request)
void WebApiSecurityClass::onSecurityGet(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
@ -35,12 +35,13 @@ void WebApiSecurityClass::onPasswordGet(AsyncWebServerRequest* request)
const CONFIG_T& config = Configuration.get();
root[F("password")] = config.Security_Password;
root[F("allow_readonly")] = config.Security_AllowReadonly;
response->setLength();
request->send(response);
}
void WebApiSecurityClass::onPasswordPost(AsyncWebServerRequest* request)
void WebApiSecurityClass::onSecurityPost(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
@ -76,7 +77,8 @@ void WebApiSecurityClass::onPasswordPost(AsyncWebServerRequest* request)
return;
}
if (!root.containsKey("password")) {
if (!root.containsKey("password")
&& root.containsKey("allow_readonly")) {
retMsg[F("message")] = F("Values are missing!");
response->setLength();
request->send(response);
@ -92,6 +94,7 @@ void WebApiSecurityClass::onPasswordPost(AsyncWebServerRequest* request)
CONFIG_T& config = Configuration.get();
strlcpy(config.Security_Password, root[F("password")].as<String>().c_str(), sizeof(config.Security_Password));
config.Security_AllowReadonly = root[F("allow_readonly")].as<bool>();
Configuration.write();
retMsg[F("type")] = F("success");

View File

@ -7,6 +7,7 @@
#include "AsyncJson.h"
#include "Configuration.h"
#include "NetworkSettings.h"
#include "WebApi.h"
#include <Hoymiles.h>
#include <LittleFS.h>
#include <ResetReason.h>
@ -30,6 +31,10 @@ void WebApiSysstatusClass::loop()
void WebApiSysstatusClass::onSystemStatus(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentialsReadonly(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();

View File

@ -27,6 +27,10 @@ void WebApiVedirectClass::loop()
void WebApiVedirectClass::onVedirectStatus(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentialsReadonly(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();
const CONFIG_T& config = Configuration.get();

View File

@ -5,6 +5,7 @@
#include "WebApi_ws_live.h"
#include "AsyncJson.h"
#include "Configuration.h"
#include "WebApi.h"
#include "defaults.h"
WebApiWsLiveClass::WebApiWsLiveClass()
@ -65,6 +66,13 @@ void WebApiWsLiveClass::loop()
String buffer;
if (buffer) {
serializeJson(root, buffer);
if (Configuration.get().Security_AllowReadonly) {
_ws.setAuthentication("", "");
} else {
_ws.setAuthentication(AUTH_USERNAME, Configuration.get().Security_Password);
}
_ws.textAll(buffer);
}
@ -200,6 +208,10 @@ void WebApiWsLiveClass::onWebsocketEvent(AsyncWebSocket* server, AsyncWebSocketC
void WebApiWsLiveClass::onLivedataStatus(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentialsReadonly(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse(false, 40960U);
JsonVariant root = response->getRoot();

View File

@ -5,6 +5,7 @@
#include "WebApi_ws_vedirect_live.h"
#include "AsyncJson.h"
#include "Configuration.h"
#include "WebApi.h"
WebApiWsVedirectLiveClass::WebApiWsVedirectLiveClass()
: _ws("/vedirectlivedata")
@ -60,6 +61,13 @@ void WebApiWsVedirectLiveClass::loop()
String buffer;
if (buffer) {
serializeJson(root, buffer);
if (Configuration.get().Security_AllowReadonly) {
_ws.setAuthentication("", "");
} else {
_ws.setAuthentication(AUTH_USERNAME, Configuration.get().Security_Password);
}
_ws.textAll(buffer);
}
@ -125,6 +133,9 @@ void WebApiWsVedirectLiveClass::onWebsocketEvent(AsyncWebSocket* server, AsyncWe
void WebApiWsVedirectLiveClass::onLivedataStatus(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentialsReadonly(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse(false, 1024U);
JsonVariant root = response->getRoot().as<JsonVariant>();
generateJsonResponse(root);

View File

@ -1,4 +1,6 @@
# opendtu
# OpenDTU web frontend
You can run the webapp locally with `yarn dev`. If you enter the IP of your ESP in the `vite.config.ts` beforehand, all api requests will even be proxied to the real ESP. Then you can develop the webapp as if it were running directly on the ESP. The `yarn dev` also supports hot reload, i.e. as soon as you save a vue file, it is automatically reloaded in the browser.
## Project Setup

View File

@ -12,7 +12,7 @@
},
"dependencies": {
"@popperjs/core": "^2.11.6",
"bootstrap": "^5.2.2",
"bootstrap": "^5.2.3",
"bootstrap-icons-vue": "^1.8.1",
"mitt": "^3.0.0",
"spark-md5": "^3.0.2",

View File

@ -49,6 +49,9 @@
<li>
<router-link @click="onClick" class="dropdown-item" to="/firmware/upgrade">Firmware Upgrade</router-link>
</li>
<li>
<router-link @click="onClick" class="dropdown-item" to="/maintenance/reboot">Device Reboot</router-link>
</li>
</ul>
</li>
<li class="nav-item dropdown">

View File

@ -16,6 +16,7 @@ import VedirectAdminView from '@/views/VedirectAdminView.vue'
import VedirectInfoView from '@/views/VedirectInfoView.vue'
import SecurityAdminView from '@/views/SecurityAdminView.vue'
import LoginView from '@/views/LoginView.vue'
import MaintenanceRebootView from '@/views/MaintenanceRebootView.vue';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -105,24 +106,13 @@ const router = createRouter({
path: '/settings/security',
name: 'Security',
component: SecurityAdminView
},
{
path: '/maintenance/reboot',
name: 'Device Reboot',
component: MaintenanceRebootView
}
]
});
router.beforeEach((to, from, next) => {
// redirect to login page if not logged in and trying to access a restricted page
const publicPages = ['/', '/login', '/about', '/info/network', '/info/system', '/info/ntp', '/info/mqtt', '/info/vedirect', ];
const authRequired = !publicPages.includes(to.path);
const loggedIn = localStorage.getItem('user');
if (authRequired && !loggedIn) {
return next({
path: '/login',
query: { returnUrl: to.path }
});
}
next();
});
export default router;

View File

@ -1,3 +1,4 @@
export interface SecurityConfig {
password: string;
allow_readonly: boolean;
}

View File

@ -1,17 +1,31 @@
import type { Emitter, EventType } from "mitt";
import type { Router } from "vue-router";
export function authHeader(): Headers {
// return authorization header with basic auth credentials
let user = JSON.parse(localStorage.getItem('user') || "");
let user = null;
try {
user = JSON.parse(localStorage.getItem('user') || "");
} catch { }
const headers = new Headers();
headers.append('X-Requested-With', 'XMLHttpRequest');
if (user && user.authdata) {
headers.append('Authorization', 'Basic ' + user.authdata);
}
return new Headers(headers);
}
export function authUrl(): string {
let user = null;
try {
user = JSON.parse(localStorage.getItem('user') || "");
} catch { }
if (user && user.authdata) {
const headers = new Headers();
headers.append('Authorization', 'Basic ' + user.authdata);
headers.append('X-Requested-With', 'XMLHttpRequest')
return new Headers(headers);
} else {
return new Headers();
return encodeURIComponent(atob(user.authdata)).replace("%3A", ":") + '@';
}
return "";
}
export function logout() {
@ -47,7 +61,7 @@ export function login(username: String, password: String) {
});
}
export function handleResponse(response: Response, emitter: Emitter<Record<EventType, unknown>>) {
export function handleResponse(response: Response, emitter: Emitter<Record<EventType, unknown>>, router: Router) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
@ -55,7 +69,7 @@ export function handleResponse(response: Response, emitter: Emitter<Record<Event
// auto logout if 401 response returned from api
logout();
emitter.emit("logged-out");
location.reload();
router.push({path: "/login", query: { returnUrl: router.currentRoute.value.fullPath }});
}
const error = (data && data.message) || response.statusText;

View File

@ -112,7 +112,7 @@ import {
} from 'bootstrap-icons-vue';
import * as bootstrap from 'bootstrap';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader } from '@/utils/authentication';
import { handleResponse, authHeader, isLoggedIn } from '@/utils/authentication';
export default defineComponent({
components: {
@ -137,6 +137,9 @@ export default defineComponent({
};
},
mounted() {
if (!isLoggedIn()) {
this.$router.push({ path: "/login", query: { returnUrl: this.$router.currentRoute.value.fullPath } });
}
this.modalFactoryReset = new bootstrap.Modal('#factoryReset');
this.loading = false;
},
@ -156,7 +159,7 @@ export default defineComponent({
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -79,7 +79,7 @@ export default defineComponent({
getDtuConfig() {
this.dataLoading = true;
fetch("/api/dtu/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(data) => {
this.dtuConfigList = data;
@ -98,7 +98,7 @@ export default defineComponent({
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -77,7 +77,7 @@ import {
BIconArrowRepeat,
BIconCheckCircle
} from 'bootstrap-icons-vue';
import { authHeader } from '@/utils/authentication';
import { authHeader, isLoggedIn } from '@/utils/authentication';
export default defineComponent({
components: {
@ -184,6 +184,9 @@ export default defineComponent({
},
},
mounted() {
if (!isLoggedIn()) {
this.$router.push({ path: "/login", query: { returnUrl: this.$router.currentRoute.value.fullPath } });
}
this.loading = false;
},
});

View File

@ -345,7 +345,7 @@ import type { EventlogItems } from '@/types/EventlogStatus';
import type { LiveData, Inverter } from '@/types/LiveDataStatus';
import type { LimitStatus } from '@/types/LimitStatus';
import type { LimitConfig } from '@/types/LimitConfig';
import { isLoggedIn, handleResponse, authHeader } from '@/utils/authentication';
import { isLoggedIn, handleResponse, authHeader, authUrl } from '@/utils/authentication';
import { formatNumber } from '@/utils';
export default defineComponent({
@ -467,8 +467,8 @@ export default defineComponent({
isLoggedIn,
getInitialData() {
this.dataLoading = true;
fetch("/api/livedata/status")
.then((response) => response.json())
fetch("/api/livedata/status", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.liveData = data;
this.dataLoading = false;
@ -478,8 +478,9 @@ export default defineComponent({
console.log("Starting connection to WebSocket Server");
const { protocol, host } = location;
const authString = authUrl();
const webSocketUrl = `${protocol === "https:" ? "wss" : "ws"
}://${host}/livedata`;
}://${authString}${host}/livedata`;
this.socket = new WebSocket(webSocketUrl);
@ -502,9 +503,11 @@ export default defineComponent({
},
initDataAgeing() {
this.dataAgeInterval = setInterval(() => {
this.inverterData.forEach(element => {
element.data_age++;
});
if (this.inverterData) {
this.inverterData.forEach(element => {
element.data_age++;
});
}
}, 1000);
},
// Send heartbeat packets regularly * 59s Send a heartbeat
@ -530,8 +533,8 @@ export default defineComponent({
},
onShowEventlog(serial: number) {
this.eventLogLoading = true;
fetch("/api/eventlog/status?inv=" + serial)
.then((response) => response.json())
fetch("/api/eventlog/status?inv=" + serial, { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.eventLogList = data[serial];
this.eventLogLoading = false;
@ -544,8 +547,8 @@ export default defineComponent({
},
onShowDevInfo(serial: number) {
this.devInfoLoading = true;
fetch("/api/devinfo/status")
.then((response) => response.json())
fetch("/api/devinfo/status", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.devInfoList = data[serial][0];
this.devInfoLoading = false;
@ -563,8 +566,8 @@ export default defineComponent({
this.targetLimitTypeText = "Relative (%)";
this.limitSettingLoading = true;
fetch("/api/limit/status")
.then((response) => response.json())
fetch("/api/limit/status", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.currentLimitList = data[serial];
this.targetLimitList.serial = serial;
@ -587,7 +590,7 @@ export default defineComponent({
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(response) => {
if (response.type == "success") {
@ -618,8 +621,8 @@ export default defineComponent({
onShowPowerSettings(serial: number) {
this.powerSettingLoading = true;
fetch("/api/power/status")
.then((response) => response.json())
fetch("/api/power/status", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.successCommandPower = data[serial].power_set_status;
this.powerSettingSerial = serial;
@ -657,7 +660,7 @@ export default defineComponent({
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(response) => {
if (response.type == "success") {

View File

@ -207,7 +207,7 @@ export default defineComponent({
getInverters() {
this.dataLoading = true;
fetch("/api/inverter/list", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.inverters = data.inverter;
this.dataLoading = false;
@ -222,7 +222,7 @@ export default defineComponent({
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.getInverters();
this.alert = data;

View File

@ -0,0 +1,101 @@
<template>
<BasePage :title="'Device Reboot'" :isLoading="dataLoading">
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
</BootstrapAlert>
<div class="card mt-5">
<div class="card-header text-bg-primary">Perform Reboot</div>
<div class="card-body text-center">
<button class="btn btn-danger" @click="onOpenModal(performReboot)">Reboot!
</button>
<div class="alert alert-danger mt-3" role="alert">
<b>Note:</b> A manual reboot does not normally have to be performed. OpenDTU performs any required
reboot (e.g. after a firmware update) automatically. Settings are also adopted without rebooting. If
you need to reboot due to an error, please consider reporting it at <a
href="https://github.com/tbnobody/OpenDTU/issues" class="alert-link"
target="_blank">https://github.com/tbnobody/OpenDTU/issues</a>.
</div>
</div>
</div>
</BasePage>
<div class="modal" id="performReboot" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Reboot OpenDTU</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Do you really want to reboot the device?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onCloseModal(performReboot)"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" @click="onReboot">Reboot</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as bootstrap from 'bootstrap';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader, isLoggedIn } from '@/utils/authentication';
export default defineComponent({
components: {
BasePage,
BootstrapAlert,
},
data() {
return {
performReboot: {} as bootstrap.Modal,
dataLoading: false,
alertMessage: "",
alertType: "info",
showAlert: false,
};
},
mounted() {
if (!isLoggedIn()) {
this.$router.push({ path: "/login", query: { returnUrl: this.$router.currentRoute.value.fullPath } });
}
this.performReboot = new bootstrap.Modal('#performReboot');
},
methods: {
onReboot() {
const formData = new FormData();
formData.append("data", JSON.stringify({ reboot: true }));
fetch("/api/maintenance/reboot", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.alertMessage = data.message;
this.alertType = data.type;
this.showAlert = true;
});
this.onCloseModal(this.performReboot);
},
onOpenModal(modal: bootstrap.Modal) {
modal.show();
},
onCloseModal(modal: bootstrap.Modal) {
modal.hide();
}
},
});
</script>

View File

@ -242,7 +242,7 @@ export default defineComponent({
getMqttConfig() {
this.dataLoading = true;
fetch("/api/mqtt/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.mqttConfigList = data;
this.dataLoading = false;
@ -259,7 +259,7 @@ export default defineComponent({
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -149,6 +149,7 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { handleResponse, authHeader } from '@/utils/authentication';
import BasePage from '@/components/BasePage.vue';
import type { MqttStatus } from '@/types/MqttStatus';
@ -168,8 +169,8 @@ export default defineComponent({
methods: {
getMqttInfo() {
this.dataLoading = true;
fetch("/api/mqtt/status")
.then((response) => response.json())
fetch("/api/mqtt/status", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.mqttDataList = data;
this.dataLoading = false;

View File

@ -128,7 +128,7 @@ export default defineComponent({
getNetworkConfig() {
this.dataLoading = true;
fetch("/api/network/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.networkConfigList = data;
this.dataLoading = false;
@ -145,7 +145,7 @@ export default defineComponent({
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -13,6 +13,7 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { handleResponse, authHeader } from '@/utils/authentication';
import BasePage from '@/components/BasePage.vue';
import WifiStationInfo from "@/components/WifiStationInfo.vue";
import WifiApInfo from "@/components/WifiApInfo.vue";
@ -40,8 +41,8 @@ export default defineComponent({
methods: {
getNetworkInfo() {
this.dataLoading = true;
fetch("/api/network/status")
.then((response) => response.json())
fetch("/api/network/status", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.networkDataList = data;
this.dataLoading = false;

View File

@ -129,7 +129,7 @@ export default defineComponent({
getNtpConfig() {
this.dataLoading = true;
fetch("/api/ntp/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(data) => {
this.ntpConfigList = data;
@ -144,7 +144,7 @@ export default defineComponent({
getCurrentTime() {
this.dataLoading = true;
fetch("/api/ntp/time", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(data) => {
this.mcuTime = new Date(
@ -172,7 +172,7 @@ export default defineComponent({
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(response) => {
this.alertMessage = response.message;
@ -195,7 +195,7 @@ export default defineComponent({
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -54,6 +54,7 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { handleResponse, authHeader } from '@/utils/authentication';
import BasePage from '@/components/BasePage.vue';
import type { NtpStatus } from "@/types/NtpStatus";
@ -73,8 +74,8 @@ export default defineComponent({
methods: {
getNtpInfo() {
this.dataLoading = true;
fetch("/api/ntp/status")
.then((response) => response.json())
fetch("/api/ntp/status", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.ntpDataList = data;
this.dataLoading = false;

View File

@ -32,6 +32,22 @@
</div>
</div>
<div class="card mt-5">
<div class="card-header text-bg-primary">Permissions</div>
<div class="card-body">
<div class="row mb-3">
<label class="col-sm-6 form-check-label" for="inputReadonly">Allow readonly access to web interface</label>
<div class="col-sm-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputReadonly"
v-model="securityConfigList.allow_readonly" />
</div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mb-3">Save</button>
</form>
</BasePage>
@ -66,8 +82,8 @@ export default defineComponent({
methods: {
getPasswordConfig() {
this.dataLoading = true;
fetch("/api/security/password", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
fetch("/api/security/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(data) => {
this.securityConfigList = data;
@ -89,12 +105,12 @@ export default defineComponent({
const formData = new FormData();
formData.append("data", JSON.stringify(this.securityConfigList));
fetch("/api/security/password", {
fetch("/api/security/config", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -13,6 +13,7 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { handleResponse, authHeader } from '@/utils/authentication';
import BasePage from '@/components/BasePage.vue';
import HardwareInfo from "@/components/HardwareInfo.vue";
import FirmwareInfo from "@/components/FirmwareInfo.vue";
@ -40,8 +41,8 @@ export default defineComponent({
methods: {
getSystemInfo() {
this.dataLoading = true;
fetch("/api/system/status")
.then((response) => response.json())
fetch("/api/system/status", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.systemDataList = data;
this.dataLoading = false;

View File

@ -75,7 +75,7 @@ export default defineComponent({
getVedirectConfig() {
this.dataLoading = true;
fetch("/api/vedirect/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.vedirectConfigList = data;
this.dataLoading = false;
@ -92,7 +92,7 @@ export default defineComponent({
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -36,6 +36,7 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { handleResponse, authHeader } from '@/utils/authentication';
import BasePage from '@/components/BasePage.vue';
import type { VedirectStatus } from "@/types/VedirectStatus";
@ -55,8 +56,8 @@ export default defineComponent({
methods: {
getVedirectInfo() {
this.dataLoading = true;
fetch("/api/vedirect/status")
.then((response) => response.json())
fetch("/api/vedirect/status", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.vedirectDataList = data;
this.dataLoading = false;

View File

@ -177,6 +177,8 @@
<script lang="ts">
import { defineComponent } from 'vue';
import type { Vedirect } from '@/types/VedirectLiveDataStatus';
import { isLoggedIn, handleResponse, authHeader, authUrl } from '@/utils/authentication';
export default defineComponent({
components: {
@ -202,8 +204,8 @@ export default defineComponent({
methods: {
getInitialData() {
this.dataLoading = true;
fetch("/api/vedirectlivedata/status")
.then((response) => response.json())
fetch("/api/vedirectlivedata/status", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.vedirectData = data;
this.dataLoading = false;
@ -213,8 +215,9 @@ export default defineComponent({
console.log("Starting connection to WebSocket Server");
const { protocol, host } = location;
const authString = authUrl();
const webSocketUrl = `${protocol === "https:" ? "wss" : "ws"
}://${host}/vedirectlivedata`;
}://${authString}${host}/vedirectlivedata`;
this.socket = new WebSocket(webSocketUrl);
@ -237,7 +240,9 @@ export default defineComponent({
},
initDataAgeing() {
this.dataAgeInterval = setInterval(() => {
this.vedirectData.data_age++;
if (this.vedirectData) {
this.vedirectData.data_age++;
}
}, 1000);
},
// Send heartbeat packets regularly * 59s Send a heartbeat

View File

@ -524,10 +524,10 @@ bootstrap-icons-vue@^1.8.1:
resolved "https://registry.yarnpkg.com/bootstrap-icons-vue/-/bootstrap-icons-vue-1.8.1.tgz#ce4a0c1f6efe41dabcc1341f2cb191d307fbaf50"
integrity sha512-uItRULwQz0epETi9x/RBEqfjHmTAmkIIczpH1R6L9T6yyaaijk0826PzTWnWNm15tw66JT/8GNuXjB0HI5PHLw==
bootstrap@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.2.2.tgz#834e053eed584a65e244d8aa112a6959f56e27a0"
integrity sha512-dEtzMTV71n6Fhmbg4fYJzQsw1N29hJKO1js5ackCgIpDcGid2ETMGC6zwSYw09v05Y+oRdQ9loC54zB1La3hHQ==
bootstrap@^5.2.3:
version "5.2.3"
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.2.3.tgz#54739f4414de121b9785c5da3c87b37ff008322b"
integrity sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==
brace-expansion@^1.1.7:
version "1.1.11"

Binary file not shown.

Binary file not shown.

Binary file not shown.