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

This commit is contained in:
helgeerbe 2022-10-20 13:34:31 +02:00
commit 905dc359a5
76 changed files with 3272 additions and 7916 deletions

View File

@ -163,6 +163,7 @@ This can be achieved by editing the 'platformio.ini' file and add/change one or
* clean the sources: `platformio run -e generic -t clean`
* erase flash: `platformio run -e generic -t erase`
### using the pre-compiled .bin files
The pre-compiled files can be found on the [github page](https://github.com/tbnobody/OpenDTU) in the tab "Actions" and the sub menu "OpenDTU Build". Just choose the latest build from the master branch (blue font). You need to be logged in with your github account to download the files.
Use a ESP32 flash tool of your choice and flash the .bin files to the right addresses:
| Address | File |
@ -172,7 +173,8 @@ Use a ESP32 flash tool of your choice and flash the .bin files to the right addr
| 0xe000 | boot_app0.bin |
| 0x10000 | opendtu-*.bin |
Make sure too uncheck the DoNotChgBin option. Otherwise you will maybe get errors like "invalid header"
Make sure too uncheck the DoNotChgBin option. Otherwise you will maybe get errors like "invalid header".
For further upgraded you can just use the web interface and upload the opendtu-*.bin file.
## First configuration
* After the initial flashing of the microcontroller, an Access Point called "OpenDTU-*" is opened. The default password is "openDTU42".

View File

@ -39,7 +39,7 @@ serial will be replaced with the serial number of the inverter.
| [serial]/0/temperature | R | Temperature of inverter in degree celsius | Degree Celsius (°C) |
| [serial]/0/voltage | R | AC voltage in volt | Volt (V) |
| [serial]/0/yieldday | R | Energy converted to AC per day in watt hours | Watt hours (Wh) |
| [serial]/0/yieldtotal | R | Energy converted to AC since reset watt hours | Watt hours (Wh) |
| [serial]/0/yieldtotal | R | Energy converted to AC since reset watt hours | Watt hours (kWh) |
### DC input channel topics
@ -52,7 +52,7 @@ serial will be replaced with the serial number of the inverter.
| [serial]/[1-4]/power | R | DC power of specific input in watt | Watt (W) |
| [serial]/[1-4]/voltage | R | DC voltage of specific input in volt | Volt (V) |
| [serial]/[1-4]/yieldday | R | Energy converted to AC per day on specific input | Watt hours (Wh) |
| [serial]/[1-4]/yieldtotal | R | Energy converted to AC since reset on specific input | Watt hours (Wh) |
| [serial]/[1-4]/yieldtotal | R | Energy converted to AC since reset on specific input | Watt hours (kWh) |
### Inverter limit specific topics
@ -69,4 +69,4 @@ cmd topics are used to set values. Status topics are updated from values set in
| [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/restart | W | Restarts the inverters (also resets YieldDay) | 1 |
| [serial]/cmd/restart | W | Restarts the inverters (also resets YieldDay) | 1 |

14
webapp/.eslintrc.cjs Normal file
View File

@ -0,0 +1,14 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

27
webapp/.gitignore vendored
View File

@ -1,21 +1,26 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.vscode
*.suo
*.ntvs*
*.njsproj

3
webapp/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

View File

@ -1,24 +1,25 @@
# opendtu
## Project setup
```
## Project Setup
```sh
yarn install
```
### Compiles and hot-reloads for development
```
yarn serve
### Compile and Hot-Reload for Development
```sh
yarn dev
```
### Compiles and minifies for production
```
### Type-Check, Compile and Minify for Production
```sh
yarn build
```
### Lints and fixes files
```
yarn lint
```
### Lint with [ESLint](https://eslint.org/)
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
```sh
yarn lint
```

View File

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

1
webapp/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

16
webapp/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenDTU</title>
</head>
<body>
<noscript>
<strong>We're sorry but OpenDTU doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -3,57 +3,36 @@
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
"dev": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview --port 4173",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@popperjs/core": "^2.11.6",
"bootstrap": "^5.2.2",
"bootstrap-icons-vue": "^1.8.1",
"core-js": "^3.25.5",
"spark-md5": "^3.0.2",
"vue": "^3.2.40",
"vue-class-component": "^8.0.0-0",
"vue": "^3.2.41",
"vue-router": "^4.1.5"
},
"devDependencies": {
"@babel/core": "^7.19.3",
"@babel/eslint-parser": "^7.19.1",
"@rushstack/eslint-patch": "^1.2.0",
"@types/bootstrap": "^5.2.5",
"@types/node": "^18.8.3",
"@types/node": "^18.11.0",
"@types/spark-md5": "^3.0.2",
"@typescript-eslint/parser": "^5.38.1",
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-eslint": "~5.0.8",
"@vue/cli-plugin-router": "^5.0.6",
"@vue/cli-plugin-typescript": "^5.0.8",
"@vue/cli-service": "~5.0.8",
"@vitejs/plugin-vue": "^3.1.2",
"@vue/eslint-config-typescript": "^11.0.2",
"@vue/tsconfig": "^0.1.3",
"eslint": "^8.25.0",
"eslint-plugin-vue": "^9.6.0",
"npm-run-all": "^4.1.5",
"typescript": "^4.8.4",
"vue-cli-plugin-compression": "~2.0.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/typescript/recommended"
],
"parserOptions": {
"ecmaVersion": 2021
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
"vite": "^3.1.8",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-css-injected-by-js": "^2.1.0",
"vue-tsc": "^1.0.8"
}
}

View File

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@ -5,7 +5,7 @@
</main>
</template>
<script>
<script lang="ts">
import NavBar from "./components/NavBar.vue";
export default {

View File

@ -0,0 +1,30 @@
<template>
<div :class="{'container-xxl': !isWideScreen,
'container-fluid': isWideScreen}" role="main">
<div class="page-header">
<h1>{{ title }}</h1>
</div>
<div class="text-center" v-if="isLoading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<template v-if="!isLoading">
<slot />
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
title: { type: String, required: true },
isLoading: { type: Boolean, required: false, default: false },
isWideScreen: { type: Boolean, required: false, default: false },
},
});
</script>

View File

@ -6,11 +6,11 @@
</div>
</template>
<script>
<script lang="ts">
import { computed, defineComponent, onBeforeUnmount, ref, watch } from "vue";
import Alert from "bootstrap/js/dist/alert";
export const toInteger = (value, defaultValue = NaN) => {
export const toInteger = (value: number, defaultValue = NaN) => {
return Number.isInteger(value) ? value : defaultValue;
};
@ -26,8 +26,8 @@ export default defineComponent({
},
emits: ["dismissed", "dismiss-count-down", "update:modelValue"],
setup(props, { emit }) {
const element = undefined;
let instance = undefined;
const element = ref<HTMLElement>();
const instance = ref<Alert>();
const classes = computed(() => ({
[`alert-${props.variant}`]: props.variant,
show: props.modelValue,
@ -35,9 +35,9 @@ export default defineComponent({
fade: props.modelValue,
}));
let _countDownTimeout = 0;
let _countDownTimeout: number | undefined = 0;
const parseCountDown = (value) => {
const parseCountDown = (value: boolean | number) => {
if (typeof value === "boolean") {
return 0;
}
@ -57,8 +57,8 @@ export default defineComponent({
onBeforeUnmount(() => {
clearCountDownInterval();
instance?.dispose();
instance = undefined;
instance.value?.dispose();
instance.value = undefined;
});
const parsedModelValue = computed(() => {
@ -76,8 +76,8 @@ export default defineComponent({
const handleShowAndModelChanged = () => {
countDown.value = parseCountDown(props.modelValue);
if ((parsedModelValue.value || props.show) && !instance)
instance = new Alert(element);
if ((parsedModelValue.value || props.show) && !instance.value)
instance.value = new Alert(element.value as HTMLElement);
};
const dismissClicked = () => {

View File

@ -1,227 +0,0 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>Config Management</h1>
</div>
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
</BootstrapAlert>
<div class="text-center" v-if="loading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<template v-if="!loading">
<div class="card">
<div class="card-header text-white bg-primary">Backup: Configuration File Backup</div>
<div class="card-body text-center">
Backup the configuration file
<button class="btn btn-primary" @click="downloadConfig">Backup
</button>
</div>
</div>
<div class="card mt-5">
<div class="card-header text-white bg-primary">Restore: Restore the Configuration File</div>
<div class="card-body text-center">
<div v-if="!uploading && UploadError != ''">
<p class="h1 mb-2">
<BIconExclamationCircleFill />
</p>
<span style="vertical-align: middle" class="ml-2">
{{ UploadError }}
</span>
<br />
<br />
<button class="btn btn-light" @click="clear">
<BIconArrowLeft /> Back
</button>
</div>
<div v-else-if="!uploading && UploadSuccess">
<span class="h1 mb-2">
<BIconCheckCircle />
</span>
<span> Upload Success </span>
<br />
<br />
<button class="btn btn-primary" @click="clear">
<BIconArrowLeft /> Back
</button>
</div>
<div v-else-if="!uploading">
<div class="form-group pt-2 mt-3">
<input class="form-control" type="file" ref="file" accept=".json" @change="uploadConfig" />
</div>
</div>
<div v-else-if="uploading">
<div class="progress">
<div class="progress-bar" role="progressbar" :style="{ width: progress + '%' }"
v-bind:aria-valuenow="progress" aria-valuemin="0" aria-valuemax="100">
{{ progress }}%
</div>
</div>
</div>
<div class="alert alert-danger mt-3" role="alert">
<b>Note:</b> This operation replaces the configuration file with the restored configuration and
restarts OpenDTU to apply all settings.
</div>
</div>
</div>
<div class="card mt-5">
<div class="card-header text-white bg-primary">Initialize: Perform Factory Reset</div>
<div class="card-body text-center">
<button class="btn btn-danger" @click="onFactoryResetModal">Restore Factory-Default Settings
</button>
<div class="alert alert-danger mt-3" role="alert">
<b>Note:</b> Click Restore Factory-Default Settings to restore and initialize the
factory-default settings and reboot.
</div>
</div>
</div>
</template>
<div class="modal" id="factoryReset" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Factory Reset</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete the current configuration and reset all settings to their
factory defaults?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onFactoryResetCancel"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" @click="onFactoryResetPerform">Factory
Reset!</button>
</div>
</div>
</div>
</div>
</div>
</template>
<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() {
return {
modalFactoryReset: {} as bootstrap.Modal,
alertMessage: "",
alertType: "info",
showAlert: false,
loading: true,
uploading: false,
progress: 0,
UploadError: "",
UploadSuccess: false,
file: {} as Blob,
};
},
mounted() {
this.modalFactoryReset = new bootstrap.Modal('#factoryReset');
this.loading = false;
},
methods: {
onFactoryResetModal() {
this.modalFactoryReset.show();
},
onFactoryResetCancel() {
this.modalFactoryReset.hide();
},
onFactoryResetPerform() {
const formData = new FormData();
formData.append("data", JSON.stringify({ delete: true }));
fetch("/api/config/delete", {
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;
}
)
this.modalFactoryReset.hide();
},
downloadConfig() {
const link = document.createElement('a')
link.href = "/api/config/get"
link.download = 'config.json'
link.click()
},
uploadConfig(event: Event | null) {
this.uploading = true;
const formData = new FormData();
if (event !== null) {
const target = event.target as HTMLInputElement;
if (target.files !== null) {
this.file = target.files[0];
}
}
const request = new XMLHttpRequest();
request.addEventListener("load", () => {
// request.response will hold the response from the server
if (request.status === 200) {
this.UploadSuccess = true;
} else if (request.status !== 500) {
this.UploadError = `[HTTP ERROR] ${request.statusText}`;
} else {
this.UploadError = request.responseText;
}
this.uploading = false;
this.progress = 0;
});
// Upload progress
request.upload.addEventListener("progress", (e) => {
this.progress = Math.trunc((e.loaded / e.total) * 100);
});
request.withCredentials = true;
formData.append("config", this.file, "config");
request.open("post", "/api/config/upload");
request.send(formData);
},
clear() {
this.UploadError = "";
this.UploadSuccess = false;
},
},
});
</script>

View File

@ -38,19 +38,10 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { defineComponent, type PropType } from 'vue';
import { BIconInfoSquare } from 'bootstrap-icons-vue';
import BootstrapAlert from '@/components/partials/BootstrapAlert.vue';
declare interface DevInfoData {
valid_data: boolean,
fw_bootloader_version: number,
fw_build_version: number,
fw_build_datetime: Date,
hw_part_number: number,
hw_version: number,
hw_model_name: string,
}
import BootstrapAlert from '@/components/BootstrapAlert.vue';
import type { DevInfoStatus } from "@/types/DevInfoStatus";
export default defineComponent({
components: {
@ -58,7 +49,7 @@ export default defineComponent({
BootstrapAlert,
},
props: {
devInfoList: { type: Object as () => DevInfoData, required: true },
devInfoList: { type: Object as PropType<DevInfoStatus>, required: true },
},
computed: {
formatVersion() {

View File

@ -1,128 +0,0 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>DTU 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="saveDtuConfig">
<div class="card">
<div class="card-header text-white bg-primary">DTU Configuration</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputDtuSerial" class="col-sm-2 col-form-label">Serial:</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="inputDtuSerial" min="1" max="99999999999"
placeholder="DTU Serial" v-model="dtuConfigList.dtu_serial" />
</div>
</div>
<div class="row mb-3">
<label for="inputPollInterval" class="col-sm-2 col-form-label">Poll Interval:</label>
<div class="col-sm-10">
<div class="input-group">
<input type="number" class="form-control" id="inputPollInterval" min="1" max="86400"
placeholder="Poll Interval in Seconds" v-model="dtuConfigList.dtu_pollinterval"
aria-describedby="pollIntervalDescription" />
<span class="input-group-text" id="pollIntervalDescription">seconds</span>
</div>
</div>
</div>
<div class="row mb-3">
<label for="inputTimezone" class="col-sm-2 col-form-label">PA Level:</label>
<div class="col-sm-10">
<select class="form-select" v-model="dtuConfigList.dtu_palevel">
<option v-for="palevel in palevelList" :key="palevel.key" :value="palevel.key">
{{ palevel.value }}
</option>
</select>
</div>
</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,
dtuConfigList: {
dtu_serial: 0,
dtu_pollinterval: 0,
dtu_palevel: 0
},
palevelList: [
{ key: 0, value: "Minimum (-18 dBm)" },
{ key: 1, value: "Low (-12 dBm)" },
{ key: 2, value: "High (-6 dBm)" },
{ key: 3, value: "Maximum (0 dBm)" },
],
alertMessage: "",
alertType: "info",
showAlert: false,
};
},
created() {
this.getDtuConfig();
},
methods: {
getDtuConfig() {
this.dataLoading = true;
fetch("/api/dtu/config")
.then((response) => response.json())
.then(
(data) => {
this.dtuConfigList = data;
this.dataLoading = false;
}
);
},
saveDtuConfig(e: Event) {
e.preventDefault();
const formData = new FormData();
formData.append("data", JSON.stringify(this.dtuConfigList));
fetch("/api/dtu/config", {
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>

View File

@ -0,0 +1,39 @@
<template>
<table class="table table-hover">
<thead>
<th scope="col">Start</th>
<th scope="col">Stop</th>
<th scope="col">ID</th>
<th scope="col">Message</th>
</thead>
<tbody>
<template v-for="event in eventLogList.count" :key="event">
<tr>
<td>{{ timeInHours(eventLogList.events[event - 1].start_time) }}</td>
<td>{{ timeInHours(eventLogList.events[event - 1].end_time) }}</td>
<td>{{ eventLogList.events[event - 1].message_id }}</td>
<td>{{ eventLogList.events[event - 1].message }}</td>
</tr>
</template>
</tbody>
</table>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import { timestampToString } from '@/utils';
import type { EventlogItems } from '@/types/EventlogStatus';
export default defineComponent({
props: {
eventLogList: { type: Object as PropType<EventlogItems>, required: true },
},
computed: {
timeInHours() {
return (value: number) => {
return timestampToString(value);
};
},
},
});
</script>

View File

@ -0,0 +1,73 @@
<template>
<div class="card">
<div class="card-header text-white bg-primary">
Firmware Information
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Hostname</th>
<td>{{ systemStatus.hostname }}</td>
</tr>
<tr>
<th>SDK Version</th>
<td>{{ systemStatus.sdkversion }}</td>
</tr>
<tr>
<th>Config Version</th>
<td>{{ systemStatus.config_version }}</td>
</tr>
<tr>
<th>Firmware Version / Git Hash</th>
<td><a :href="'https://github.com/tbnobody/OpenDTU/commits/' + systemStatus.git_hash?.substring(1)"
target="_blank">{{ systemStatus.git_hash?.substring(1) }}</a></td>
</tr>
<tr>
<th>Firmware Update</th>
<td><a :href="systemStatus.update_url" target="_blank"><span class="badge"
:class="systemStatus.update_status">{{
systemStatus.update_text }}</span></a></td>
</tr>
<tr>
<th>Reset Reason CPU 0</th>
<td>{{ systemStatus.resetreason_0 }}</td>
</tr>
<tr>
<th>Reset Reason CPU 1</th>
<td>{{ systemStatus.resetreason_1 }}</td>
</tr>
<tr>
<th>Config save count</th>
<td>{{ systemStatus.cfgsavecount }}</td>
</tr>
<tr>
<th>Uptime</th>
<td>{{ timeInHours(systemStatus.uptime) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { SystemStatus } from '@/types/SystemStatus';
import { timestampToString } from '@/utils';
export default defineComponent({
props: {
systemStatus: { type: Object as PropType<SystemStatus>, required: true },
},
computed: {
timeInHours() {
return (value: number) => {
return timestampToString(value, true);
};
},
},
});
</script>

View File

@ -9,19 +9,19 @@
<tbody>
<tr>
<th>Chip Model</th>
<td>{{ chipmodel }}</td>
<td>{{ systemStatus.chipmodel }}</td>
</tr>
<tr>
<th>Chip Revision</th>
<td>{{ chiprevision }}</td>
<td>{{ systemStatus.chiprevision }}</td>
</tr>
<tr>
<th>Chip Cores</th>
<td>{{ chipcores }}</td>
<td>{{ systemStatus.chipcores }}</td>
</tr>
<tr>
<th>CPU Frequency</th>
<td>{{ cpufreq }} MHz</td>
<td>{{ systemStatus.cpufreq }} MHz</td>
</tr>
</tbody>
</table>
@ -31,14 +31,12 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { SystemStatus } from '@/types/SystemStatus';
import { defineComponent, type PropType } from 'vue';
export default defineComponent({
props: {
chipmodel: String,
chiprevision: { type: Number, required: true },
chipcores: { type: Number, required: true },
cpufreq: { type: Number, required: true },
systemStatus: { type: Object as PropType<SystemStatus>, required: true },
},
});
</script>

View File

@ -1,676 +0,0 @@
<template>
<div class="container-fluid" role="main">
<div class="page-header">
<h1>Live Data</h1>
</div>
<div class="text-center" v-if="dataLoading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<template v-else>
<div class="row gy-3">
<div class="col-sm-3 col-md-2">
<div class="nav nav-pills row-cols-sm-1" id="v-pills-tab" role="tablist"
aria-orientation="vertical">
<button v-for="inverter in inverterData" :key="inverter.serial" class="nav-link"
:id="'v-pills-' + inverter.serial + '-tab'" data-bs-toggle="pill"
:data-bs-target="'#v-pills-' + inverter.serial" type="button" role="tab"
aria-controls="'v-pills-' + inverter.serial" aria-selected="true">
<BIconXCircleFill class="fs-4" v-if="!inverter.reachable" />
<BIconExclamationCircleFill class="fs-4" v-if="inverter.reachable && !inverter.producing" />
<BIconCheckCircleFill class="fs-4" v-if="inverter.reachable && inverter.producing" />
{{ inverter.name }}
</button>
</div>
</div>
<div class="tab-content col-sm-9 col-md-10" id="v-pills-tabContent">
<div v-for="inverter in inverterData" :key="inverter.serial" class="tab-pane fade show"
:id="'v-pills-' + inverter.serial" role="tabpanel"
:aria-labelledby="'v-pills-' + inverter.serial + '-tab'" tabindex="0">
<div class="card">
<div class="card-header text-white bg-primary d-flex justify-content-between align-items-center"
:class="{
'bg-danger': !inverter.reachable,
'bg-warning': inverter.reachable && !inverter.producing,
'bg-primary': inverter.reachable && inverter.producing,
}">
<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)"
title="Show / Set Inverter Limit">
<BIconSpeedometer style="font-size:24px;" />
</button>
</div>
<div class="btn-group me-2" role="group">
<button type="button" class="btn btn-sm btn-danger"
@click="onShowPowerSettings(inverter.serial)" title="Turn Inverter on/off">
<BIconPower style="font-size:24px;" />
</button>
</div>
<div class="btn-group me-2" role="group">
<button type="button" class="btn btn-sm btn-info"
@click="onShowDevInfo(inverter.serial)" title="Show Inverter Info">
<BIconCpu style="font-size:24px;" />
</button>
</div>
<div class="btn-group" role="group">
<button v-if="inverter.events >= 0" type="button"
class="btn btn-sm btn-secondary position-relative"
@click="onShowEventlog(inverter.serial)" title="Show Eventlog">
<BIconJournalText style="font-size:24px;" />
<span
class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">
{{ inverter.events }}
<span class="visually-hidden">unread messages</span>
</span>
</button>
</div>
</div>
</div>
<div class="card-body">
<div class="row flex-row-reverse flex-wrap-reverse align-items-end g-3">
<template v-for="channel in 5" :key="channel">
<div v-if="inverter[channel - 1]" :class="`col order-${5 - channel}`">
<InverterChannelInfo :channelData="inverter[channel - 1]"
:channelNumber="channel - 1" />
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<VedirectView />
<div class="modal" id="eventView" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Event Log</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-center" v-if="eventLogLoading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<EventLog v-if="!eventLogLoading" :eventLogList="eventLogList" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onHideEventlog"
data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal" id="devInfoView" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Inverter Info</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-center" v-if="devInfoLoading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<DevInfo v-if="!devInfoLoading" :devInfoList="devInfoList" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onHideDevInfo"
data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal" id="limitSettingView" ref="limitSettingView" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form @submit="onSubmitLimit">
<div class="modal-header">
<h5 class="modal-title">Limit Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<BootstrapAlert v-model="showAlertLimit" :variant="alertTypeLimit">
{{ alertMessageLimit }}
</BootstrapAlert>
<div class="text-center" v-if="limitSettingLoading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<template v-if="!limitSettingLoading">
<div class="row mb-3">
<label for="inputCurrentLimit" class="col-sm-3 col-form-label">Current
Limit:</label>
<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">
<label for="inputLastLimitSet" class="col-sm-3 col-form-label">Last Limit Set
Status:</label>
<div class="col-sm-9">
<span class="badge" :class="{
'bg-danger': successCommandLimit == 'Failure',
'bg-warning': successCommandLimit == 'Pending',
'bg-success': successCommandLimit == 'Ok',
'bg-secondary': successCommandLimit == 'Unknown',
}">
{{ successCommandLimit }}
</span>
</div>
</div>
<div class="row mb-3">
<label for="inputTargetLimit" class="col-sm-3 col-form-label">Set Limit:</label>
<div class="col-sm-9">
<div class="input-group">
<input type="number" name="inputTargetLimit" class="form-control"
id="inputTargetLimit" :min="targetLimitMin" :max="targetLimitMax"
v-model="targetLimit">
<button class="btn btn-primary dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">{{ targetLimitTypeText
}}</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" @click="onSelectType(1)" href="#">Relative
(%)</a></li>
<li><a class="dropdown-item" @click="onSelectType(0)" href="#">Absolute
(W)</a></li>
</ul>
</div>
<div v-if="targetLimitType == 0" class="alert alert-secondary mt-3"
role="alert">
<b>Hint:</b> If you set the limit as absolute value the display of the
current value will only be updated after ~4 minutes.
</div>
</div>
</div>
</template>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-danger" @click="onSetLimitSettings(true)">Set Limit
Persistent</button>
<button type="submit" class="btn btn-danger" @click="onSetLimitSettings(false)">Set Limit
Non-Persistent</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal" id="powerSettingView" ref="powerSettingView" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Power Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<BootstrapAlert v-model="showAlertPower" :variant="alertTypePower">
{{ alertMessagePower }}
</BootstrapAlert>
<div class="text-center" v-if="powerSettingLoading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<template v-if="!powerSettingLoading">
<div class="row mb-3 align-items-center">
<label for="inputLastPowerSet" class="col col-form-label">Last Power Set
Status:</label>
<div class="col">
<span class="badge" :class="{
'bg-danger': successCommandPower == 'Failure',
'bg-warning': successCommandPower == 'Pending',
'bg-success': successCommandPower == 'Ok',
'bg-secondary': successCommandPower == 'Unknown',
}">
{{ successCommandPower }}
</span>
</div>
</div>
<div class="d-grid gap-2 col-6 mx-auto">
<button type="button" class="btn btn-success" @click="onSetPowerSettings(true)">
<BIconToggleOn class="fs-4" />&nbsp;Turn On
</button>
<button type="button" class="btn btn-danger" @click="onSetPowerSettings(false)">
<BIconToggleOff class="fs-4" />&nbsp;Turn Off
</button>
<button type="button" class="btn btn-warning" @click="onSetPowerSettings(true, true)">
<BIconArrowCounterclockwise class="fs-4" />&nbsp;Restart
</button>
</div>
</template>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from '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 {
serial: number,
name: string,
reachable: boolean,
producing: boolean,
limit_relative: 0,
limit_absolute: 0,
data_age: 0,
events: 0
}
export default defineComponent({
components: {
InverterChannelInfo,
EventLog,
DevInfo,
BootstrapAlert,
BIconXCircleFill,
BIconExclamationCircleFill,
BIconCheckCircleFill,
BIconSpeedometer,
BIconPower,
BIconCpu,
BIconJournalText,
BIconToggleOn,
BIconToggleOff,
BIconArrowCounterclockwise,
VedirectView
},
data() {
return {
socket: {} as WebSocket,
heartInterval: 0,
dataAgeInterval: 0,
dataLoading: true,
inverterData: [] as Inverter[],
isFirstFetchAfterConnect: true,
eventLogView: {} as bootstrap.Modal,
eventLogList: {},
eventLogLoading: true,
devInfoView: {} as bootstrap.Modal,
devInfoList: {},
devInfoLoading: true,
limitSettingView: {} as bootstrap.Modal,
limitSettingSerial: 0,
limitSettingLoading: true,
currentLimit: 0,
currentLimitAbsolute: 0,
successCommandLimit: "",
maxPower: 0,
targetLimit: 0,
targetLimitMin: 10,
targetLimitMax: 100,
targetLimitTypeText: "Relative (%)",
targetLimitType: 1,
targetLimitPersistent: false,
alertMessageLimit: "",
alertTypeLimit: "info",
showAlertLimit: false,
powerSettingView: {} as bootstrap.Modal,
powerSettingSerial: 0,
powerSettingLoading: true,
alertMessagePower: "",
alertTypePower: "info",
showAlertPower: false,
successCommandPower: "",
};
},
created() {
this.getInitialData();
this.initSocket();
this.initDataAgeing();
},
mounted() {
this.eventLogView = new bootstrap.Modal('#eventView');
this.devInfoView = new bootstrap.Modal('#devInfoView');
this.limitSettingView = new bootstrap.Modal('#limitSettingView');
this.powerSettingView = new bootstrap.Modal('#powerSettingView');
(this.$refs.limitSettingView as HTMLElement).addEventListener("hide.bs.modal", this.onHideLimitSettings);
(this.$refs.powerSettingView as HTMLElement).addEventListener("hide.bs.modal", this.onHidePowerSettings);
},
unmounted() {
this.closeSocket();
},
updated() {
// Select first tab
if (this.isFirstFetchAfterConnect) {
this.isFirstFetchAfterConnect = false;
const firstTabEl = this.$el.querySelector(
"#v-pills-tab:first-child button"
);
if (firstTabEl != null) {
const firstTab = new bootstrap.Tab(firstTabEl);
firstTab.show();
}
}
},
methods: {
getInitialData() {
this.dataLoading = true;
fetch("/api/livedata/status")
.then((response) => response.json())
.then((data) => {
this.inverterData = data;
this.dataLoading = false;
});
},
initSocket() {
console.log("Starting connection to WebSocket Server");
const { protocol, host } = location;
const webSocketUrl = `${protocol === "https:" ? "wss" : "ws"
}://${host}/livedata`;
this.socket = new WebSocket(webSocketUrl);
this.socket.onmessage = (event) => {
console.log(event);
this.inverterData = JSON.parse(event.data);
this.dataLoading = false;
this.heartCheck(); // Reset heartbeat detection
};
this.socket.onopen = function (event) {
console.log(event);
console.log("Successfully connected to the echo websocket server...");
};
// Listen to window events , When the window closes , Take the initiative to disconnect websocket Connect
window.onbeforeunload = () => {
this.closeSocket();
};
},
initDataAgeing() {
this.dataAgeInterval = setInterval(() => {
this.inverterData.forEach(element => {
element.data_age++;
});
}, 1000);
},
// Send heartbeat packets regularly * 59s Send a heartbeat
heartCheck() {
this.heartInterval && clearTimeout(this.heartInterval);
this.heartInterval = setInterval(() => {
if (this.socket.readyState === 1) {
// Connection status
this.socket.send("ping");
} else {
this.initSocket(); // Breakpoint reconnection 5 Time
}
}, 59 * 1000);
},
/** To break off websocket Connect */
closeSocket() {
this.socket.close();
this.heartInterval && clearTimeout(this.heartInterval);
this.isFirstFetchAfterConnect = true;
},
onHideEventlog() {
this.eventLogView.hide();
},
onShowEventlog(serial: number) {
this.eventLogLoading = true;
fetch("/api/eventlog/status?inv=" + serial)
.then((response) => response.json())
.then((data) => {
this.eventLogList = data[serial];
this.eventLogLoading = false;
});
this.eventLogView.show();
},
onHideDevInfo() {
this.devInfoView.hide();
},
onShowDevInfo(serial: number) {
this.devInfoLoading = true;
fetch("/api/devinfo/status")
.then((response) => response.json())
.then((data) => {
this.devInfoList = data[serial][0];
this.devInfoLoading = false;
});
this.devInfoView.show();
},
onHideLimitSettings() {
this.limitSettingSerial = 0;
this.targetLimit = 0;
this.targetLimitType = 1;
this.targetLimitTypeText = "Relative (%)";
this.showAlertLimit = false;
},
onShowLimitSettings(serial: number) {
this.limitSettingLoading = true;
fetch("/api/limit/status")
.then((response) => response.json())
.then((data) => {
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;
});
this.limitSettingView.show();
},
onSubmitLimit(e: Event) {
e.preventDefault();
const data = {
serial: this.limitSettingSerial,
limit_value: this.targetLimit,
limit_type: (this.targetLimitPersistent ? 256 : 0) + this.targetLimitType,
};
const formData = new FormData();
formData.append("data", JSON.stringify(data));
console.log(data);
fetch("/api/limit/config", {
method: "POST",
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then(
(response) => {
if (response.type == "success") {
this.limitSettingView.hide();
} else {
this.alertMessageLimit = response.message;
this.alertTypeLimit = response.type;
this.showAlertLimit = true;
}
}
)
},
onSetLimitSettings(setPersistent: boolean) {
this.targetLimitPersistent = setPersistent;
},
onSelectType(type: number) {
if (type == 1) {
this.targetLimitTypeText = "Relative (%)";
this.targetLimitMin = 10;
this.targetLimitMax = 100;
} else {
this.targetLimitTypeText = "Absolute (W)";
this.targetLimitMin = 10;
this.targetLimitMax = 1500;
}
this.targetLimitType = type;
},
onShowPowerSettings(serial: number) {
this.powerSettingLoading = true;
fetch("/api/power/status")
.then((response) => response.json())
.then((data) => {
this.successCommandPower = data[serial].power_set_status;
this.powerSettingSerial = serial;
this.powerSettingLoading = false;
});
this.powerSettingView.show();
},
onHidePowerSettings() {
this.powerSettingSerial = 0;
this.showAlertPower = false;
},
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));
console.log(data);
fetch("/api/power/config", {
method: "POST",
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then(
(response) => {
if (response.type == "success") {
this.powerSettingView.hide();
} else {
this.alertMessagePower = response.message;
this.alertTypePower = response.type;
this.showAlertPower = true;
}
}
)
},
},
});
</script>

View File

@ -9,11 +9,11 @@
<tbody>
<tr>
<th>IP Address</th>
<td>{{ ap_ip }}</td>
<td>{{ networkStatus.ap_ip }}</td>
</tr>
<tr>
<th>MAC Address</th>
<td>{{ ap_mac }}</td>
<td>{{ networkStatus.ap_mac }}</td>
</tr>
</tbody>
</table>
@ -23,12 +23,12 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { NetworkStatus } from '@/types/NetworkStatus';
import { defineComponent, type PropType } from 'vue';
export default defineComponent({
props: {
ap_ip: String,
ap_mac: String,
networkStatus: { type: Object as PropType<NetworkStatus>, required: true },
},
});
</script>

View File

@ -1,7 +1,7 @@
<template>
<div class="card">
<div class="card-header text-white bg-primary">
Network Interface ({{ network_mode }})
Network Interface ({{ networkStatus.network_mode }})
</div>
<div class="card-body">
<div class="table-responsive">
@ -9,31 +9,31 @@
<tbody>
<tr>
<th>Hostname</th>
<td>{{ network_hostname }}</td>
<td>{{ networkStatus.network_hostname }}</td>
</tr>
<tr>
<th>IP Address</th>
<td>{{ network_ip }}</td>
<td>{{ networkStatus.network_ip }}</td>
</tr>
<tr>
<th>Netmask</th>
<td>{{ network_netmask }}</td>
<td>{{ networkStatus.network_netmask }}</td>
</tr>
<tr>
<th>Default Gateway</th>
<td>{{ network_gateway }}</td>
<td>{{ networkStatus.network_gateway }}</td>
</tr>
<tr>
<th>DNS 1</th>
<td>{{ network_dns1 }}</td>
<td>{{ networkStatus.network_dns1 }}</td>
</tr>
<tr>
<th>DNS 2</th>
<td>{{ network_dns2 }}</td>
<td>{{ networkStatus.network_dns2 }}</td>
</tr>
<tr>
<th>MAC Address</th>
<td>{{ network_mac }}</td>
<td>{{ networkStatus.network_mac }}</td>
</tr>
</tbody>
</table>
@ -43,18 +43,12 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { NetworkStatus } from '@/types/NetworkStatus';
import { defineComponent, type PropType } from 'vue';
export default defineComponent({
props: {
network_hostname: String,
network_ip: String,
network_netmask: String,
network_gateway: String,
network_dns1: String,
network_dns2: String,
network_mac: String,
network_mode: String,
networkStatus: { type: Object as PropType<NetworkStatus>, required: true },
},
});
</script>

View File

@ -13,9 +13,11 @@
</thead>
<tbody>
<tr v-for="(property, key) in channelData" :key="`prop-${key}`">
<th scope="row">{{ key }}</th>
<td style="text-align: right">{{ formatNumber(property.v) }}</td>
<td>{{ property.u }}</td>
<template v-if="property">
<th scope="row">{{ key }}</th>
<td style="text-align: right">{{ formatNumber(property.v) }}</td>
<td>{{ property.u }}</td>
</template>
</tr>
</tbody>
</table>
@ -24,18 +26,19 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { defineComponent, type PropType } from 'vue';
import type { InverterStatistics } from '@/types/LiveDataStatus';
export default defineComponent({
props: {
channelData: Object,
channelData: { type: Object as PropType<InverterStatistics>, required: true },
channelNumber: { type: Number, required: true },
},
methods: {
formatNumber(num: string) {
formatNumber(num: number) {
return new Intl.NumberFormat(
undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }
).format(parseFloat(num));
).format(num);
},
},
});

View File

@ -14,10 +14,10 @@
</tr>
</thead>
<tbody>
<FsInfo name="Heap" :total="heap_total" :used="heap_used" />
<FsInfo name="LittleFs" :total="littlefs_total"
:used="littlefs_used" />
<FsInfo name="Sketch" :total="sketch_total" :used="sketch_used" />
<FsInfo name="Heap" :total="systemStatus.heap_total" :used="systemStatus.heap_used" />
<FsInfo name="LittleFs" :total="systemStatus.littlefs_total"
:used="systemStatus.littlefs_used" />
<FsInfo name="Sketch" :total="systemStatus.sketch_total" :used="systemStatus.sketch_used" />
</tbody>
</table>
</div>
@ -26,20 +26,16 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FsInfo from "@/components/partials/FsInfo.vue";
import { defineComponent, type PropType } from 'vue';
import type { SystemStatus } from '@/types/SystemStatus';
import FsInfo from "@/components/FsInfo.vue";
export default defineComponent({
components: {
FsInfo,
},
props: {
heap_total: { type: Number, required: true },
heap_used: { type: Number, required: true },
littlefs_total: { type: Number, required: true },
littlefs_used: { type: Number, required: true },
sketch_total: { type: Number, required: true },
sketch_used: { type: Number, required: true },
systemStatus: { type: Object as PropType<SystemStatus>, required: true },
},
});
</script>

View File

@ -1,304 +0,0 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>MqTT 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="saveMqttConfig">
<div class="card">
<div class="card-header text-white bg-primary">MqTT Configuration</div>
<div class="card-body">
<div class="row mb-3">
<label class="col-sm-4 form-check-label" for="inputMqtt">Enable MqTT</label>
<div class="col-sm-8">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputMqtt"
v-model="mqttConfigList.mqtt_enabled" />
</div>
</div>
</div>
<div class="row mb-3" v-show="mqttConfigList.mqtt_enabled">
<label class="col-sm-4 form-check-label" for="inputMqttHass">Enable Home Assistant MQTT Auto
Discovery</label>
<div class="col-sm-8">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputMqttHass"
v-model="mqttConfigList.mqtt_hass_enabled" />
</div>
</div>
</div>
</div>
</div>
<div class="card mt-5" v-show="mqttConfigList.mqtt_enabled">
<div class="card-header text-white bg-primary">
MqTT Broker Parameter
</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputHostname" class="col-sm-2 col-form-label">Hostname:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputHostname" maxlength="128"
placeholder="Hostname or IP address" v-model="mqttConfigList.mqtt_hostname" />
</div>
</div>
<div class="row mb-3">
<label for="inputPort" class="col-sm-2 col-form-label">Port:</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="inputPort" min="1" max="65535"
placeholder="Port number" v-model="mqttConfigList.mqtt_port" />
</div>
</div>
<div class="row mb-3">
<label for="inputUsername" class="col-sm-2 col-form-label">Username:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputUsername" maxlength="32"
placeholder="Username, leave empty for anonymous connection"
v-model="mqttConfigList.mqtt_username" />
</div>
</div>
<div class="row mb-3">
<label for="inputPassword" class="col-sm-2 col-form-label">Password:</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="inputPassword" maxlength="32"
placeholder="Password, leave empty for anonymous connection"
v-model="mqttConfigList.mqtt_password" />
</div>
</div>
<div class="row mb-3">
<label for="inputTopic" class="col-sm-2 col-form-label">Base Topic:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputTopic" maxlength="32"
placeholder="Base topic, will be prepend to all published topics (e.g. inverter/)"
v-model="mqttConfigList.mqtt_topic" />
</div>
</div>
<div class="row mb-3">
<label for="inputPublishInterval" class="col-sm-2 col-form-label">Publish Interval:</label>
<div class="col-sm-10">
<div class="input-group">
<input type="number" class="form-control" id="inputPublishInterval" min="5"
max="86400" placeholder="Publish Interval in Seconds"
v-model="mqttConfigList.mqtt_publish_interval"
aria-describedby="publishIntervalDescription" />
<span class="input-group-text" id="publishIntervalDescription">seconds</span>
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputRetain">Enable Retain Flag</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputRetain"
v-model="mqttConfigList.mqtt_retain" />
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputTls">Enable TLS</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputTls"
v-model="mqttConfigList.mqtt_tls" />
</div>
</div>
</div>
<div class="row mb-3" v-show="mqttConfigList.mqtt_tls">
<label for="inputCert" class="col-sm-2 col-form-label">CA-Root-Certificate (default
Letsencrypt):</label>
<div class="col-sm-10">
<textarea class="form-control" id="inputCert" maxlength="2048" rows="10"
placeholder="Root CA Certificate from Letsencrypt"
v-model="mqttConfigList.mqtt_root_ca_cert">
</textarea>
</div>
</div>
</div>
</div>
<div class="card mt-5" v-show="mqttConfigList.mqtt_enabled">
<div class="card-header text-white bg-primary">LWT Parameters</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputLwtTopic" class="col-sm-2 col-form-label">LWT Topic:</label>
<div class="col-sm-10">
<div class="input-group">
<span class="input-group-text" id="basic-addon3">{{
mqttConfigList.mqtt_topic
}}</span>
<input type="text" class="form-control" id="inputLwtTopic" maxlength="32"
placeholder="LWT topic, will be append base topic"
v-model="mqttConfigList.mqtt_lwt_topic" aria-describedby="basic-addon3" />
</div>
</div>
</div>
<div class="row mb-3">
<label for="inputLwtOnline" class="col-sm-2 col-form-label">LWT Online message:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputLwtOnline" maxlength="20"
placeholder="Message that will be published to LWT topic when online"
v-model="mqttConfigList.mqtt_lwt_online" />
</div>
</div>
<div class="row mb-3">
<label for="inputLwtOffline" class="col-sm-2 col-form-label">LWT Offline message:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputLwtOffline" maxlength="20"
placeholder="Message that will be published to LWT topic when offline"
v-model="mqttConfigList.mqtt_lwt_offline" />
</div>
</div>
</div>
</div>
<div class="card mt-5" v-show="mqttConfigList.mqtt_enabled && mqttConfigList.mqtt_hass_enabled">
<div class="card-header text-white bg-primary">Home Assistant MQTT Auto Discovery Parameters</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputHassTopic" class="col-sm-2 col-form-label">Prefix Topic:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputHassTopic" maxlength="32"
placeholder="The prefix for the discovery topic"
v-model="mqttConfigList.mqtt_hass_topic" />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputHassRetain">Enable Retain Flag</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputHassRetain"
v-model="mqttConfigList.mqtt_hass_retain" />
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputHassExpire">Enable Expiration</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputHassExpire"
v-model="mqttConfigList.mqtt_hass_expire" />
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputIndividualPanels">Individual
Panels:</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputIndividualPanels"
v-model="mqttConfigList.mqtt_hass_individualpanels" />
</div>
</div>
</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,
mqttConfigList: {
mqtt_enabled: false,
mqtt_hostname: "",
mqtt_port: 0,
mqtt_username: "",
mqtt_password: "",
mqtt_topic: "",
mqtt_publish_interval: 0,
mqtt_retain: false,
mqtt_tls: false,
mqtt_root_ca_cert: "",
mqtt_lwt_topic: "",
mqtt_lwt_online: "",
mqtt_lwt_offline: "",
mqtt_hass_enabled: false,
mqtt_hass_expire: false,
mqtt_hass_retain: false,
mqtt_hass_topic: "",
mqtt_hass_individualpanels: false
},
alertMessage: "",
alertType: "info",
showAlert: false,
};
},
created() {
this.getMqttConfig();
},
methods: {
getMqttConfig() {
this.dataLoading = true;
fetch("/api/mqtt/config")
.then((response) => response.json())
.then((data) => {
this.mqttConfigList = data;
this.dataLoading = false;
});
},
saveMqttConfig(e: Event) {
e.preventDefault();
const formData = new FormData();
formData.append("data", JSON.stringify(this.mqttConfigList));
fetch("/api/mqtt/config", {
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>

View File

@ -1,201 +0,0 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>MqTT Info</h1>
</div>
<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">
<div class="card">
<div class="card-header text-white bg-primary">Configuration Summary</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Status</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_enabled,
'bg-success': mqttDataList.mqtt_enabled,
}">
<span v-if="mqttDataList.mqtt_enabled">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>Server</th>
<td>{{ mqttDataList.mqtt_hostname }}</td>
</tr>
<tr>
<th>Port</th>
<td>{{ mqttDataList.mqtt_port }}</td>
</tr>
<tr>
<th>Username</th>
<td>{{ mqttDataList.mqtt_username }}</td>
</tr>
<tr>
<th>Base Topic</th>
<td>{{ mqttDataList.mqtt_topic }}</td>
</tr>
<tr>
<th>Publish Interval</th>
<td>{{ mqttDataList.mqtt_publish_interval }} seconds</td>
</tr>
<tr>
<th>Retain</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_retain,
'bg-success': mqttDataList.mqtt_retain,
}">
<span v-if="mqttDataList.mqtt_retain">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>TLS</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_tls,
'bg-success': mqttDataList.mqtt_tls,
}">
<span v-if="mqttDataList.mqtt_tls">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr v-show="mqttDataList.mqtt_tls">
<th>Root CA Certifcate Info</th>
<td>{{ mqttDataList.mqtt_root_ca_cert_info }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card mt-5">
<div class="card-header text-white bg-primary">Home Assistant MQTT Auto Discovery Configuration Summary</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Status</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_hass_enabled,
'bg-success': mqttDataList.mqtt_hass_enabled,
}">
<span v-if="mqttDataList.mqtt_hass_enabled">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>Base Topic</th>
<td>{{ mqttDataList.mqtt_hass_topic }}</td>
</tr>
<tr>
<th>Retain</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_hass_retain,
'bg-success': mqttDataList.mqtt_hass_retain,
}">
<span v-if="mqttDataList.mqtt_hass_retain">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>Expire</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_hass_expire,
'bg-success': mqttDataList.mqtt_hass_expire,
}">
<span v-if="mqttDataList.mqtt_hass_expire">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>Individual Panels</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_hass_individualpanels,
'bg-success': mqttDataList.mqtt_hass_individualpanels,
}">
<span v-if="mqttDataList.mqtt_hass_individualpanels">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card mt-5">
<div class="card-header text-white bg-primary">Runtime Summary</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Connection Status</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_connected,
'bg-success': mqttDataList.mqtt_connected,
}">
<span v-if="mqttDataList.mqtt_connected">connected</span>
<span v-else>disconnected</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
data() {
return {
dataLoading: true,
mqttDataList: {
mqtt_enabled: false,
mqtt_hostname: "",
mqtt_port: 0,
mqtt_username: "",
mqtt_topic: "",
mqtt_publish_interval: 0,
mqtt_retain: false,
mqtt_tls: false,
mqtt_root_ca_cert_info: "",
mqtt_connected: false,
mqtt_hass_enabled: false,
mqtt_hass_retain: false,
mqtt_hass_topic: "",
mqtt_hass_individualpanels: false
},
};
},
created() {
this.getNtpInfo();
},
methods: {
getNtpInfo() {
this.dataLoading = true;
fetch("/api/mqtt/status")
.then((response) => response.json())
.then((data) => {
this.mqttDataList = data;
this.dataLoading = false;
});
},
},
});
</script>

View File

@ -83,12 +83,14 @@
</nav>
</template>
<script>
export default {
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
methods: {
onClick() {
this.$refs.navbarCollapse && this.$refs.navbarCollapse.classList.remove("show");
this.$refs.navbarCollapse && (this.$refs.navbarCollapse as HTMLElement).classList.remove("show");
}
},
}
});
</script>

View File

@ -1,181 +0,0 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>Network 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="saveNetworkConfig">
<div class="card">
<div class="card-header text-white bg-primary">WiFi Configuration</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputSSID" class="col-sm-2 col-form-label">WiFi SSID:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputSSID" maxlength="32" placeholder="SSID"
v-model="networkConfigList.ssid" />
</div>
</div>
<div class="row mb-3">
<label for="inputPassword" class="col-sm-2 col-form-label">WiFi Password:</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="inputPassword" maxlength="64"
placeholder="PSK" v-model="networkConfigList.password" />
</div>
</div>
<div class="row mb-3">
<label for="inputHostname" class="col-sm-2 col-form-label">Hostname:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputHostname" maxlength="32"
placeholder="Hostname" v-model="networkConfigList.hostname" />
<div class="alert alert-secondary" role="alert">
<b>Hint:</b> The text <span class="font-monospace">%06X</span> will be replaced
with the last 6 digits of the ESP ChipID in hex format.
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputDHCP">Enable DHCP</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputDHCP"
v-model="networkConfigList.dhcp" />
</div>
</div>
</div>
</div>
</div>
<div class="card" v-show="!networkConfigList.dhcp">
<div class="card-header text-white bg-primary">
Static IP Configuration
</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputIP" class="col-sm-2 col-form-label">IP Address:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputIP" maxlength="32"
placeholder="IP address" v-model="networkConfigList.ipaddress" />
</div>
</div>
<div class="row mb-3">
<label for="inputNetmask" class="col-sm-2 col-form-label">Netmask:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputNetmask" maxlength="32"
placeholder="Netmask" v-model="networkConfigList.netmask" />
</div>
</div>
<div class="row mb-3">
<label for="inputGateway" class="col-sm-2 col-form-label">Default Gateway:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputGateway" maxlength="32"
placeholder="Default Gateway" v-model="networkConfigList.gateway" />
</div>
</div>
<div class="row mb-3">
<label for="inputDNS1" class="col-sm-2 col-form-label">DNS Server 1:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputDNS1" maxlength="32"
placeholder="DNS Server 1" v-model="networkConfigList.dns1" />
</div>
</div>
<div class="row mb-3">
<label for="inputDNS2" class="col-sm-2 col-form-label">DNS Server 2:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputDNS2" maxlength="32"
placeholder="DNS Server 2" v-model="networkConfigList.dns2" />
</div>
</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,
networkConfigList: {
ssid: "",
password: "",
hostname: "",
dhcp: false,
ipaddress: "",
netmask: "",
gateway: "",
dns1: "",
dns2: ""
},
alertMessage: "",
alertType: "info",
showAlert: false,
};
},
created() {
this.getNetworkConfig();
},
methods: {
getNetworkConfig() {
this.dataLoading = true;
fetch("/api/network/config")
.then((response) => response.json())
.then((data) => {
this.networkConfigList = data;
this.dataLoading = false;
});
},
saveNetworkConfig(e: Event) {
e.preventDefault();
const formData = new FormData();
formData.append("data", JSON.stringify(this.networkConfigList));
fetch("/api/network/config", {
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>

View File

@ -1,81 +0,0 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>Network Info</h1>
</div>
<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">
<WifiStationInfo v-bind="networkDataList" />
<div class="mt-5"></div>
<WifiApInfo v-bind="networkDataList" />
<div class="mt-5"></div>
<InterfaceNetworkInfo v-bind="networkDataList" />
<div class="mt-5"></div>
<InterfaceApInfo v-bind="networkDataList" />
<div class="mt-5"></div>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import WifiStationInfo from "./partials/WifiStationInfo.vue";
import WifiApInfo from "./partials/WifiApInfo.vue";
import InterfaceNetworkInfo from "./partials/InterfaceNetworkInfo.vue";
import InterfaceApInfo from "./partials/InterfaceApInfo.vue";
export default defineComponent({
components: {
WifiStationInfo,
WifiApInfo,
InterfaceNetworkInfo,
InterfaceApInfo,
},
data() {
return {
dataLoading: true,
networkDataList: {
// WifiStationInfo
sta_status: false,
sta_ssid: "",
sta_rssi: 0,
// WifiApInfo
ap_status: false,
ap_ssid: "",
ap_stationnum: 0,
// InterfaceNetworkInfo
network_hostname: "",
network_ip: "",
network_netmask: "",
network_gateway: "",
network_dns1: "",
network_dns2: "",
network_mac: "",
network_mode: "",
// InterfaceApInfo
ap_ip: "",
ap_mac: "",
}
}
},
created() {
this.getNetworkInfo();
},
methods: {
getNetworkInfo() {
this.dataLoading = true;
fetch("/api/network/status")
.then((response) => response.json())
.then((data) => {
this.networkDataList = data;
this.dataLoading = false;
});
},
},
});
</script>

View File

@ -1,99 +0,0 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>NTP Info</h1>
</div>
<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">
<div class="card">
<div class="card-header text-white bg-primary">Configuration Summary</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Server</th>
<td>{{ ntpDataList.ntp_server }}</td>
</tr>
<tr>
<th>Timezone</th>
<td>{{ ntpDataList.ntp_timezone }}</td>
</tr>
<tr>
<th>Timezone Description</th>
<td>{{ ntpDataList.ntp_timezone_descr }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card mt-5">
<div class="card-header text-white bg-primary">Current Time</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Status</th>
<td class="badge" :class="{
'bg-danger': !ntpDataList.ntp_status,
'bg-success': ntpDataList.ntp_status,
}">
<span v-if="ntpDataList.ntp_status">synced</span>
<span v-else>not synced</span>
</td>
</tr>
<tr>
<th>Local Time</th>
<td>{{ ntpDataList.ntp_localtime }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
data() {
return {
dataLoading: true,
ntpDataList: {
ntp_server: "",
ntp_timezone: "",
ntp_timezone_descr: "",
ntp_status: false,
ntp_localtime: ""
},
};
},
created() {
this.getNtpInfo();
},
methods: {
getNtpInfo() {
this.dataLoading = true;
fetch("/api/ntp/status")
.then((response) => response.json())
.then((data) => {
this.ntpDataList = data;
this.dataLoading = false;
});
},
},
});
</script>

View File

@ -10,22 +10,24 @@
<tr>
<th>Chip Status</th>
<td class="badge" :class="{
'bg-danger': !radio_connected,
'bg-success': radio_connected,
'bg-danger': !systemStatus.radio_connected,
'bg-success': systemStatus.radio_connected,
}">
<span v-if="radio_connected">connected</span>
<span v-if="systemStatus.radio_connected">connected</span>
<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,
'bg-danger': systemStatus.radio_connected && !systemStatus.radio_pvariant,
'bg-success': systemStatus.radio_connected && systemStatus.radio_pvariant,
'bg-secondary': !systemStatus.radio_connected,
}">
<span v-if="radio_connected && radio_pvariant">nRF24L01+</span>
<span v-else-if="radio_connected && !radio_pvariant">nRF24L01</span>
<span
v-if="systemStatus.radio_connected && systemStatus.radio_pvariant">nRF24L01+</span>
<span
v-else-if="systemStatus.radio_connected && !systemStatus.radio_pvariant">nRF24L01</span>
<span v-else>Unknown</span>
</td>
</tr>
@ -37,12 +39,12 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { defineComponent, type PropType } from 'vue';
import type { SystemStatus } from '@/types/SystemStatus';
export default defineComponent({
props: {
radio_connected: { type: Boolean, required: true },
radio_pvariant: { type: Boolean, required: true },
systemStatus: { type: Object as PropType<SystemStatus>, required: true },
},
});
</script>

View File

@ -1,123 +0,0 @@
<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>

View File

@ -10,20 +10,20 @@
<tr>
<th>Status</th>
<td class="badge" :class="{
'bg-danger': !ap_status,
'bg-success': ap_status,
'bg-danger': !networkStatus.ap_status,
'bg-success': networkStatus.ap_status,
}">
<span v-if="ap_status">enabled</span>
<span v-if="networkStatus.ap_status">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>SSID</th>
<td>{{ ap_ssid }}</td>
<td>{{ networkStatus.ap_ssid }}</td>
</tr>
<tr>
<th># Stations</th>
<td>{{ ap_stationnum }}</td>
<td>{{ networkStatus.ap_stationnum }}</td>
</tr>
</tbody>
</table>
@ -33,13 +33,12 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { NetworkStatus } from '@/types/NetworkStatus';
import { defineComponent, type PropType } from 'vue';
export default defineComponent({
props: {
ap_status: { type: Boolean, required: true },
ap_ssid: String,
ap_stationnum: { type: Number, required: true },
networkStatus: { type: Object as PropType<NetworkStatus>, required: true },
},
});
</script>

View File

@ -10,24 +10,24 @@
<tr>
<th>Status</th>
<td class="badge" :class="{
'bg-danger': !sta_status,
'bg-success': sta_status,
'bg-danger': !networkStatus.sta_status,
'bg-success': networkStatus.sta_status,
}">
<span v-if="sta_status">enabled</span>
<span v-if="networkStatus.sta_status">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>SSID</th>
<td>{{ sta_ssid }}</td>
<td>{{ networkStatus.sta_ssid }}</td>
</tr>
<tr>
<th>Quality</th>
<td>{{ getRSSIasQuality(sta_rssi) }} %</td>
<td>{{ getRSSIasQuality(networkStatus.sta_rssi) }} %</td>
</tr>
<tr>
<th>RSSI</th>
<td>{{ sta_rssi }}</td>
<td>{{ networkStatus.sta_rssi }}</td>
</tr>
</tbody>
</table>
@ -37,13 +37,12 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { NetworkStatus } from '@/types/NetworkStatus';
import { defineComponent, type PropType } from 'vue';
export default defineComponent({
props: {
sta_status: { type: Boolean, required: true },
sta_ssid: String,
sta_rssi: { type: Number, required: true },
networkStatus: { type: Object as PropType<NetworkStatus>, required: true },
},
methods: {
getRSSIasQuality(rssi: number) {

View File

@ -1,60 +0,0 @@
<template>
<table class="table table-hover">
<thead>
<th scope="col">Start</th>
<th scope="col">Stop</th>
<th scope="col">ID</th>
<th scope="col">Message</th>
</thead>
<tbody>
<template v-for="event in eventLogList.count" :key="event">
<tr>
<td>{{ timeInHours(eventLogList.events[event - 1].start_time) }}</td>
<td>{{ timeInHours(eventLogList.events[event - 1].end_time) }}</td>
<td>{{ eventLogList.events[event - 1].message_id }}</td>
<td>{{ eventLogList.events[event - 1].message }}</td>
</tr>
</template>
</tbody>
</table>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
declare interface EventData {
message_id: number,
message: string,
start_time: number,
end_time: number
}
declare interface EventLogData {
count: number,
events: { [key: number]: EventData }
}
export default defineComponent({
props: {
eventLogList: { type: Object as () => EventLogData, required: true },
},
computed: {
timeInHours() {
return (value: number) => {
const days = Math.floor(value / (24 * 60 * 60));
const secAfterDays = value - days * (24 * 60 * 60);
const hours = Math.floor(secAfterDays / (60 * 60));
const secAfterHours = secAfterDays - hours * (60 * 60);
const minutes = Math.floor(secAfterHours / 60);
const seconds = secAfterHours - minutes * 60;
const dHours = hours > 9 ? hours : "0" + hours;
const dMins = minutes > 9 ? minutes : "0" + minutes;
const dSecs = seconds > 9 ? seconds : "0" + seconds;
return dHours + ":" + dMins + ":" + dSecs;
};
},
},
});
</script>

View File

@ -1,89 +0,0 @@
<template>
<div class="card">
<div class="card-header text-white bg-primary">
Firmware Information
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Hostname</th>
<td>{{ hostname }}</td>
</tr>
<tr>
<th>SDK Version</th>
<td>{{ sdkversion }}</td>
</tr>
<tr>
<th>Config Version</th>
<td>{{ config_version }}</td>
</tr>
<tr>
<th>Firmware Version / Git Hash</th>
<td><a :href="'https://github.com/tbnobody/OpenDTU/commits/' + git_hash?.substring(1)"
target="_blank">{{ git_hash?.substring(1) }}</a></td>
</tr>
<tr>
<th>Firmware Update</th>
<td><a :href="update_url" target="_blank"><span class="badge" :class="update_status">{{
update_text }}</span></a></td>
</tr>
<tr>
<th>Reset Reason CPU 0</th>
<td>{{ resetreason_0 }}</td>
</tr>
<tr>
<th>Reset Reason CPU 1</th>
<td>{{ resetreason_1 }}</td>
</tr>
<tr>
<th>Config save count</th>
<td>{{ cfgsavecount }}</td>
</tr>
<tr>
<th>Uptime</th>
<td>{{ timeInHours(uptime) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
hostname: String,
sdkversion: String,
config_version: String,
git_hash: String,
resetreason_0: String,
resetreason_1: String,
cfgsavecount: { type: Number, required: true },
uptime: { type: Number, required: true },
update_text: String,
update_url: String,
update_status: String,
},
computed: {
timeInHours() {
return (value: number) => {
const days = Math.floor(value / 3600 / 24);
const hours = Math.floor((value - days * 3600 * 24) / 3600);
const minutes = Math.floor((value - days * 3600 * 24 - hours * 3600) / 60);
const seconds = (value - days * 3600 * 24 - hours * 3600 + minutes * 60) % 60;
const dHours = hours > 9 ? hours : "0" + hours;
const dMins = minutes > 9 ? minutes : "0" + minutes;
const dSecs = seconds > 9 ? seconds : "0" + seconds;
return days + " days " + dHours + ":" + dMins + ":" + dSecs;
};
},
},
});
</script>

View File

@ -5,4 +5,8 @@ import router from './router'
import "bootstrap/dist/css/bootstrap.min.css"
import "bootstrap"
createApp(App).use(router).mount('#app')
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@ -1,22 +1,25 @@
import { createWebHistory, createRouter, RouteRecordRaw } from 'vue-router';
import HomeView from '@/components/HomeView.vue'
import AboutView from '@/components/AboutView.vue'
import NetworkInfoView from '@/components/NetworkInfoView.vue'
import SystemInfoView from '@/components/SystemInfoView.vue'
import NtpInfoView from '@/components/NtpInfoView.vue'
import NetworkAdminView from '@/components/NetworkAdminView.vue'
import NtpAdminView from '@/components/NtpAdminView.vue'
import MqttAdminView from '@/components/MqttAdminView.vue'
import MqttInfoView from '@/components/MqttInfoView.vue'
import InverterAdminView from '@/components/InverterAdminView.vue'
import DtuAdminView from '@/components/DtuAdminView.vue'
import FirmwareUpgradeView from '@/components/FirmwareUpgradeView.vue'
import ConfigAdminView from '@/components/ConfigAdminView.vue'
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '@/views/HomeView.vue'
import AboutView from '@/views/AboutView.vue'
import NetworkInfoView from '@/views/NetworkInfoView.vue'
import SystemInfoView from '@/views/SystemInfoView.vue'
import NtpInfoView from '@/views/NtpInfoView.vue'
import NetworkAdminView from '@/views/NetworkAdminView.vue'
import NtpAdminView from '@/views/NtpAdminView.vue'
import MqttAdminView from '@/views/MqttAdminView.vue'
import MqttInfoView from '@/views/MqttInfoView.vue'
import InverterAdminView from '@/views/InverterAdminView.vue'
import DtuAdminView from '@/views/DtuAdminView.vue'
import FirmwareUpgradeView from '@/views/FirmwareUpgradeView.vue'
import ConfigAdminView from '@/views/ConfigAdminView.vue'
import VedirectAdminView from '@/components/VedirectAdminView.vue'
import VedirectInfoView from '@/components/VedirectInfoView.vue'
import SecurityAdminView from '@/components/SecurityAdminView.vue'
import SecurityAdminView from '@/views/SecurityAdminView.vue'
const routes: Array<RouteRecordRaw> = [
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
linkActiveClass: "active",
routes: [
{
path: '/',
name: 'Home',
@ -97,12 +100,7 @@ const routes: Array<RouteRecordRaw> = [
name: 'Security',
component: SecurityAdminView
}
];
const router = createRouter({
history: createWebHistory(),
routes,
linkActiveClass: "active",
});
]
})
export default router;

View File

@ -1,6 +0,0 @@
/* eslint-disable */
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -0,0 +1,9 @@
export interface DevInfoStatus {
valid_data: boolean,
fw_bootloader_version: number,
fw_build_version: number,
fw_build_datetime: Date,
hw_part_number: number,
hw_version: number,
hw_model_name: string
}

View File

@ -0,0 +1,5 @@
export interface DtuConfig {
dtu_serial: number,
dtu_pollinterval: number,
dtu_palevel: number
}

View File

@ -0,0 +1,11 @@
export interface EventlogItem {
message_id: number,
message: string,
start_time: number,
end_time: number
}
export interface EventlogItems {
count: number,
events: Array<EventlogItem>,
}

View File

@ -0,0 +1,33 @@
export interface ValueObject {
v: number, // value
u: string, // unit
};
export interface InverterStatistics {
Power?: ValueObject,
Voltage?: ValueObject,
Current?: ValueObject,
"Power DC"?: ValueObject,
YieldDay?: ValueObject,
YieldTotal?: ValueObject,
Frequency?: ValueObject,
Temperature?: ValueObject,
PowerFactor?: ValueObject,
ReactivePower?: ValueObject,
Efficiency?: ValueObject,
Irradiation?: ValueObject,
}
export interface Inverter {
serial: number,
name: string,
data_age: number,
reachable: boolean,
producing: boolean,
limit_relative: number,
limit_absolute: number,
events: number,
[key: number]: InverterStatistics,
};
export interface Inverters extends Array<Inverter>{};

View File

@ -0,0 +1,20 @@
export interface MqttConfig {
mqtt_enabled: boolean,
mqtt_hostname: string,
mqtt_port: number,
mqtt_username: string,
mqtt_password: string,
mqtt_topic: string,
mqtt_publish_interval: number,
mqtt_retain: boolean,
mqtt_tls: boolean,
mqtt_root_ca_cert: string,
mqtt_lwt_topic: string,
mqtt_lwt_online: string,
mqtt_lwt_offline: string,
mqtt_hass_enabled: boolean,
mqtt_hass_expire: boolean,
mqtt_hass_retain: boolean,
mqtt_hass_topic: string,
mqtt_hass_individualpanels: boolean
}

View File

@ -0,0 +1,17 @@
export interface MqttStatus {
mqtt_enabled: boolean,
mqtt_hostname: string,
mqtt_port: number,
mqtt_username: string,
mqtt_topic: string,
mqtt_publish_interval: number,
mqtt_retain: boolean,
mqtt_tls: boolean,
mqtt_root_ca_cert_info: string,
mqtt_connected: boolean,
mqtt_hass_enabled: boolean,
mqtt_hass_expire: boolean,
mqtt_hass_retain: boolean,
mqtt_hass_topic: string,
mqtt_hass_individualpanels: boolean
}

View File

@ -0,0 +1,22 @@
export interface NetworkStatus {
// WifiStationInfo
sta_status: boolean,
sta_ssid: string,
sta_rssi: number,
// WifiApInfo
ap_status: boolean,
ap_ssid: string,
ap_stationnum: number,
// InterfaceNetworkInfo
network_hostname: string,
network_ip: string,
network_netmask: string,
network_gateway: string,
network_dns1: string,
network_dns2: string,
network_mac: string,
network_mode: string,
// InterfaceApInfo
ap_ip: string,
ap_mac: string,
}

View File

@ -0,0 +1,11 @@
export interface NetworkConfig {
ssid: string,
password: string,
hostname: string,
dhcp: boolean,
ipaddress: string,
netmask: string,
gateway: string,
dns1: string,
dns2: string
}

View File

@ -0,0 +1,5 @@
export interface NtpConfig {
ntp_server: string,
ntp_timezone: string,
ntp_timezone_descr: string
}

View File

@ -0,0 +1,7 @@
export interface NtpStatus {
ntp_server: string,
ntp_timezone: string,
ntp_timezone_descr: string
ntp_status: boolean,
ntp_localtime: string
}

View File

@ -0,0 +1,3 @@
export interface SecurityConfig {
password: string
}

View File

@ -0,0 +1,29 @@
export interface SystemStatus {
// HardwareInfo
chipmodel: string,
chiprevision: number,
chipcores: number,
cpufreq: number,
// FirmwareInfo
hostname: string,
sdkversion: string,
config_version: string,
git_hash: string,
resetreason_0: string,
resetreason_1: string,
cfgsavecount: number,
uptime: number,
update_text: string,
update_url: string,
update_status: string,
// MemoryInfo
heap_total: number,
heap_used: number,
littlefs_total: number,
littlefs_used: number,
sketch_total: number,
sketch_used: number,
// RadioInfo
radio_connected: boolean,
radio_pvariant: boolean,
}

View File

@ -0,0 +1,9 @@
import { timestampToString } from './time';
export {
timestampToString,
};
export default {
timestampToString,
}

17
webapp/src/utils/time.ts Normal file
View File

@ -0,0 +1,17 @@
export const timestampToString = (value: number, includeDays = false): string => {
const days = Math.floor(value / (24 * 60 * 60));
const secAfterDays = value - days * (24 * 60 * 60);
const hours = Math.floor(secAfterDays / (60 * 60));
const secAfterHours = secAfterDays - hours * (60 * 60);
const minutes = Math.floor(secAfterHours / 60);
const seconds = secAfterHours - minutes * 60;
const dHours = hours > 9 ? hours : "0" + hours;
const dMins = minutes > 9 ? minutes : "0" + minutes;
const dSecs = seconds > 9 ? seconds : "0" + seconds;
if (includeDays) {
return days + " days " + dHours + ":" + dMins + ":" + dSecs;
}
return dHours + ":" + dMins + ":" + dSecs;
}

View File

@ -1,10 +1,5 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>About OpenDTU</h1>
</div>
<BasePage :title="'About OpenDTU'">
<div class="accordion" id="accordionExample">
<div class="accordion-item">
<h2 class="accordion-header" id="headingOne">
@ -96,12 +91,13 @@
</div>
</div>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import {
BIconInfoCircle,
BIconActivity,
@ -111,6 +107,7 @@ import {
export default defineComponent({
components: {
BasePage,
BIconInfoCircle,
BIconActivity,
BIconBug,

View File

@ -0,0 +1,217 @@
<template>
<BasePage :title="'Config Management'" :isLoading="loading">
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
</BootstrapAlert>
<div class="card">
<div class="card-header text-white bg-primary">Backup: Configuration File Backup</div>
<div class="card-body text-center">
Backup the configuration file
<button class="btn btn-primary" @click="downloadConfig">Backup
</button>
</div>
</div>
<div class="card mt-5">
<div class="card-header text-white bg-primary">Restore: Restore the Configuration File</div>
<div class="card-body text-center">
<div v-if="!uploading && UploadError != ''">
<p class="h1 mb-2">
<BIconExclamationCircleFill />
</p>
<span style="vertical-align: middle" class="ml-2">
{{ UploadError }}
</span>
<br />
<br />
<button class="btn btn-light" @click="clear">
<BIconArrowLeft /> Back
</button>
</div>
<div v-else-if="!uploading && UploadSuccess">
<span class="h1 mb-2">
<BIconCheckCircle />
</span>
<span> Upload Success </span>
<br />
<br />
<button class="btn btn-primary" @click="clear">
<BIconArrowLeft /> Back
</button>
</div>
<div v-else-if="!uploading">
<div class="form-group pt-2 mt-3">
<input class="form-control" type="file" ref="file" accept=".json" @change="uploadConfig" />
</div>
</div>
<div v-else-if="uploading">
<div class="progress">
<div class="progress-bar" role="progressbar" :style="{ width: progress + '%' }"
v-bind:aria-valuenow="progress" aria-valuemin="0" aria-valuemax="100">
{{ progress }}%
</div>
</div>
</div>
<div class="alert alert-danger mt-3" role="alert">
<b>Note:</b> This operation replaces the configuration file with the restored configuration and
restarts OpenDTU to apply all settings.
</div>
</div>
</div>
<div class="card mt-5">
<div class="card-header text-white bg-primary">Initialize: Perform Factory Reset</div>
<div class="card-body text-center">
<button class="btn btn-danger" @click="onFactoryResetModal">Restore Factory-Default Settings
</button>
<div class="alert alert-danger mt-3" role="alert">
<b>Note:</b> Click Restore Factory-Default Settings to restore and initialize the
factory-default settings and reboot.
</div>
</div>
</div>
</BasePage>
<div class="modal" id="factoryReset" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Factory Reset</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete the current configuration and reset all settings to their
factory defaults?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onFactoryResetCancel"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" @click="onFactoryResetPerform">Factory
Reset!</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import {
BIconExclamationCircleFill,
BIconArrowLeft,
BIconCheckCircle
} from 'bootstrap-icons-vue';
import * as bootstrap from 'bootstrap';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
export default defineComponent({
components: {
BasePage,
BIconExclamationCircleFill,
BIconArrowLeft,
BIconCheckCircle,
BootstrapAlert,
},
data() {
return {
modalFactoryReset: {} as bootstrap.Modal,
alertMessage: "",
alertType: "info",
showAlert: false,
loading: true,
uploading: false,
progress: 0,
UploadError: "",
UploadSuccess: false,
file: {} as Blob,
};
},
mounted() {
this.modalFactoryReset = new bootstrap.Modal('#factoryReset');
this.loading = false;
},
methods: {
onFactoryResetModal() {
this.modalFactoryReset.show();
},
onFactoryResetCancel() {
this.modalFactoryReset.hide();
},
onFactoryResetPerform() {
const formData = new FormData();
formData.append("data", JSON.stringify({ delete: true }));
fetch("/api/config/delete", {
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;
}
)
this.modalFactoryReset.hide();
},
downloadConfig() {
const link = document.createElement('a')
link.href = "/api/config/get"
link.download = 'config.json'
link.click()
},
uploadConfig(event: Event | null) {
this.uploading = true;
const formData = new FormData();
if (event !== null) {
const target = event.target as HTMLInputElement;
if (target.files !== null) {
this.file = target.files[0];
}
}
const request = new XMLHttpRequest();
request.addEventListener("load", () => {
// request.response will hold the response from the server
if (request.status === 200) {
this.UploadSuccess = true;
} else if (request.status !== 500) {
this.UploadError = `[HTTP ERROR] ${request.statusText}`;
} else {
this.UploadError = request.responseText;
}
this.uploading = false;
this.progress = 0;
});
// Upload progress
request.upload.addEventListener("progress", (e) => {
this.progress = Math.trunc((e.loaded / e.total) * 100);
});
request.withCredentials = true;
formData.append("config", this.file, "config");
request.open("post", "/api/config/upload");
request.send(formData);
},
clear() {
this.UploadError = "";
this.UploadSuccess = false;
},
},
});
</script>

View File

@ -0,0 +1,116 @@
<template>
<BasePage :title="'DTU Settings'" :isLoading="dataLoading">
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
</BootstrapAlert>
<form @submit="saveDtuConfig">
<div class="card">
<div class="card-header text-white bg-primary">DTU Configuration</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputDtuSerial" class="col-sm-2 col-form-label">Serial:</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="inputDtuSerial" min="1" max="99999999999"
placeholder="DTU Serial" v-model="dtuConfigList.dtu_serial" />
</div>
</div>
<div class="row mb-3">
<label for="inputPollInterval" class="col-sm-2 col-form-label">Poll Interval:</label>
<div class="col-sm-10">
<div class="input-group">
<input type="number" class="form-control" id="inputPollInterval" min="1" max="86400"
placeholder="Poll Interval in Seconds" v-model="dtuConfigList.dtu_pollinterval"
aria-describedby="pollIntervalDescription" />
<span class="input-group-text" id="pollIntervalDescription">seconds</span>
</div>
</div>
</div>
<div class="row mb-3">
<label for="inputTimezone" class="col-sm-2 col-form-label">PA Level:</label>
<div class="col-sm-10">
<select class="form-select" v-model="dtuConfigList.dtu_palevel">
<option v-for="palevel in palevelList" :key="palevel.key" :value="palevel.key">
{{ palevel.value }}
</option>
</select>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mb-3">Save</button>
</form>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import type { DtuConfig } from "@/types/DtuConfig";
export default defineComponent({
components: {
BasePage,
BootstrapAlert,
},
data() {
return {
dataLoading: true,
dtuConfigList: {} as DtuConfig,
palevelList: [
{ key: 0, value: "Minimum (-18 dBm)" },
{ key: 1, value: "Low (-12 dBm)" },
{ key: 2, value: "High (-6 dBm)" },
{ key: 3, value: "Maximum (0 dBm)" },
],
alertMessage: "",
alertType: "info",
showAlert: false,
};
},
created() {
this.getDtuConfig();
},
methods: {
getDtuConfig() {
this.dataLoading = true;
fetch("/api/dtu/config")
.then((response) => response.json())
.then(
(data) => {
this.dtuConfigList = data;
this.dataLoading = false;
}
);
},
saveDtuConfig(e: Event) {
e.preventDefault();
const formData = new FormData();
formData.append("data", JSON.stringify(this.dtuConfigList));
fetch("/api/dtu/config", {
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>

View File

@ -1,9 +1,5 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>Firmware Upgrade</h1>
</div>
<BasePage :title="'Firmware Upgrade'">
<div class="position-relative" v-if="loading">
<div class="position-absolute top-50 start-50 translate-middle">
<div class="spinner-border" role="status">
@ -68,11 +64,12 @@
</div>
</div>
</div>
</div>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import SparkMD5 from "spark-md5";
import {
BIconExclamationCircleFill,
@ -83,6 +80,7 @@ import {
export default defineComponent({
components: {
BasePage,
BIconExclamationCircleFill,
BIconArrowLeft,
BIconArrowRepeat,

View File

@ -0,0 +1,653 @@
<template>
<BasePage :title="'Live Data'" :isLoading="dataLoading" :isWideScreen="true">
<div class="row gy-3">
<div class="col-sm-3 col-md-2" :style="[inverterData.length == 1 ? {'display': 'none' } : {}]">
<div class="nav nav-pills row-cols-sm-1" id="v-pills-tab" role="tablist" aria-orientation="vertical">
<button v-for="inverter in inverterData" :key="inverter.serial" class="nav-link"
:id="'v-pills-' + inverter.serial + '-tab'" data-bs-toggle="pill"
:data-bs-target="'#v-pills-' + inverter.serial" type="button" role="tab"
aria-controls="'v-pills-' + inverter.serial" aria-selected="true">
<BIconXCircleFill class="fs-4" v-if="!inverter.reachable" />
<BIconExclamationCircleFill class="fs-4" v-if="inverter.reachable && !inverter.producing" />
<BIconCheckCircleFill class="fs-4" v-if="inverter.reachable && inverter.producing" />
{{ inverter.name }}
</button>
</div>
</div>
<div class="tab-content" id="v-pills-tabContent" :class="{'col-sm-9 col-md-10': inverterData.length > 1,
'col-sm-12 col-md-12': inverterData.length == 1 }">
<div v-for="inverter in inverterData" :key="inverter.serial" class="tab-pane fade show"
:id="'v-pills-' + inverter.serial" role="tabpanel"
:aria-labelledby="'v-pills-' + inverter.serial + '-tab'" tabindex="0">
<div class="card">
<div class="card-header text-white bg-primary d-flex justify-content-between align-items-center"
:class="{
'bg-danger': !inverter.reachable,
'bg-warning': inverter.reachable && !inverter.producing,
'bg-primary': inverter.reachable && inverter.producing,
}">
<div class="p-1 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)" title="Show / Set Inverter Limit">
<BIconSpeedometer style="font-size:24px;" />
</button>
</div>
<div class="btn-group me-2" role="group">
<button type="button" class="btn btn-sm btn-danger"
@click="onShowPowerSettings(inverter.serial)" title="Turn Inverter on/off">
<BIconPower style="font-size:24px;" />
</button>
</div>
<div class="btn-group me-2" role="group">
<button type="button" class="btn btn-sm btn-info"
@click="onShowDevInfo(inverter.serial)" title="Show Inverter Info">
<BIconCpu style="font-size:24px;" />
</button>
</div>
<div class="btn-group" role="group">
<button v-if="inverter.events >= 0" type="button"
class="btn btn-sm btn-secondary position-relative"
@click="onShowEventlog(inverter.serial)" title="Show Eventlog">
<BIconJournalText style="font-size:24px;" />
<span
class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">
{{ inverter.events }}
<span class="visually-hidden">unread messages</span>
</span>
</button>
</div>
</div>
</div>
<div class="card-body">
<div class="row flex-row-reverse flex-wrap-reverse align-items-end g-3">
<template v-for="channel in 5" :key="channel">
<div v-if="inverter[channel - 1]" :class="`col order-${5 - channel}`">
<InverterChannelInfo :channelData="inverter[channel - 1]"
:channelNumber="channel - 1" />
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</BasePage>
<div class="modal" id="eventView" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Event Log</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-center" v-if="eventLogLoading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<EventLog v-if="!eventLogLoading" :eventLogList="eventLogList" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onHideEventlog"
data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal" id="devInfoView" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Inverter Info</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-center" v-if="devInfoLoading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<DevInfo v-if="!devInfoLoading" :devInfoList="devInfoList" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onHideDevInfo"
data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal" id="limitSettingView" ref="limitSettingView" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form @submit="onSubmitLimit">
<div class="modal-header">
<h5 class="modal-title">Limit Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<BootstrapAlert v-model="showAlertLimit" :variant="alertTypeLimit">
{{ alertMessageLimit }}
</BootstrapAlert>
<div class="text-center" v-if="limitSettingLoading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<template v-if="!limitSettingLoading">
<div class="row mb-3">
<label for="inputCurrentLimit" class="col-sm-3 col-form-label">Current
Limit:</label>
<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">
<label for="inputLastLimitSet" class="col-sm-3 col-form-label">Last Limit Set
Status:</label>
<div class="col-sm-9">
<span class="badge" :class="{
'bg-danger': successCommandLimit == 'Failure',
'bg-warning': successCommandLimit == 'Pending',
'bg-success': successCommandLimit == 'Ok',
'bg-secondary': successCommandLimit == 'Unknown',
}">
{{ successCommandLimit }}
</span>
</div>
</div>
<div class="row mb-3">
<label for="inputTargetLimit" class="col-sm-3 col-form-label">Set Limit:</label>
<div class="col-sm-9">
<div class="input-group">
<input type="number" name="inputTargetLimit" class="form-control"
id="inputTargetLimit" :min="targetLimitMin" :max="targetLimitMax"
v-model="targetLimit">
<button class="btn btn-primary dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">{{ targetLimitTypeText
}}</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" @click="onSelectType(1)" href="#">Relative
(%)</a></li>
<li><a class="dropdown-item" @click="onSelectType(0)" href="#">Absolute
(W)</a></li>
</ul>
</div>
<div v-if="targetLimitType == 0" class="alert alert-secondary mt-3" role="alert">
<b>Hint:</b> If you set the limit as absolute value the display of the
current value will only be updated after ~4 minutes.
</div>
</div>
</div>
</template>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-danger" @click="onSetLimitSettings(true)">Set Limit
Persistent</button>
<button type="submit" class="btn btn-danger" @click="onSetLimitSettings(false)">Set Limit
Non-Persistent</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal" id="powerSettingView" ref="powerSettingView" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Power Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<BootstrapAlert v-model="showAlertPower" :variant="alertTypePower">
{{ alertMessagePower }}
</BootstrapAlert>
<div class="text-center" v-if="powerSettingLoading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<template v-if="!powerSettingLoading">
<div class="row mb-3 align-items-center">
<label for="inputLastPowerSet" class="col col-form-label">Last Power Set
Status:</label>
<div class="col">
<span class="badge" :class="{
'bg-danger': successCommandPower == 'Failure',
'bg-warning': successCommandPower == 'Pending',
'bg-success': successCommandPower == 'Ok',
'bg-secondary': successCommandPower == 'Unknown',
}">
{{ successCommandPower }}
</span>
</div>
</div>
<div class="d-grid gap-2 col-6 mx-auto">
<button type="button" class="btn btn-success" @click="onSetPowerSettings(true)">
<BIconToggleOn class="fs-4" />&nbsp;Turn On
</button>
<button type="button" class="btn btn-danger" @click="onSetPowerSettings(false)">
<BIconToggleOff class="fs-4" />&nbsp;Turn Off
</button>
<button type="button" class="btn btn-warning" @click="onSetPowerSettings(true, true)">
<BIconArrowCounterclockwise class="fs-4" />&nbsp;Restart
</button>
</div>
</template>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.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/EventLog.vue';
import DevInfo from '@/components/DevInfo.vue';
import BootstrapAlert from '@/components/BootstrapAlert.vue';
import InverterChannelInfo from "@/components/InverterChannelInfo.vue";
import type { DevInfoStatus } from '@/types/DevInfoStatus';
import type { EventlogItems } from '@/types/EventlogStatus';
import type { Inverters } from '@/types/LiveDataStatus';
export default defineComponent({
components: {
BasePage,
InverterChannelInfo,
EventLog,
DevInfo,
BootstrapAlert,
BIconXCircleFill,
BIconExclamationCircleFill,
BIconCheckCircleFill,
BIconSpeedometer,
BIconPower,
BIconCpu,
BIconJournalText,
BIconToggleOn,
BIconToggleOff,
BIconArrowCounterclockwise,
},
data() {
return {
socket: {} as WebSocket,
heartInterval: 0,
dataAgeInterval: 0,
dataLoading: true,
inverterData: [] as Inverters,
isFirstFetchAfterConnect: true,
eventLogView: {} as bootstrap.Modal,
eventLogList: {} as EventlogItems,
eventLogLoading: true,
devInfoView: {} as bootstrap.Modal,
devInfoList: {} as DevInfoStatus,
devInfoLoading: true,
limitSettingView: {} as bootstrap.Modal,
limitSettingSerial: 0,
limitSettingLoading: true,
currentLimit: 0,
currentLimitAbsolute: 0,
successCommandLimit: "",
maxPower: 0,
targetLimit: 0,
targetLimitMin: 10,
targetLimitMax: 100,
targetLimitTypeText: "Relative (%)",
targetLimitType: 1,
targetLimitPersistent: false,
alertMessageLimit: "",
alertTypeLimit: "info",
showAlertLimit: false,
powerSettingView: {} as bootstrap.Modal,
powerSettingSerial: 0,
powerSettingLoading: true,
alertMessagePower: "",
alertTypePower: "info",
showAlertPower: false,
successCommandPower: "",
};
},
created() {
this.getInitialData();
this.initSocket();
this.initDataAgeing();
},
mounted() {
this.eventLogView = new bootstrap.Modal('#eventView');
this.devInfoView = new bootstrap.Modal('#devInfoView');
this.limitSettingView = new bootstrap.Modal('#limitSettingView');
this.powerSettingView = new bootstrap.Modal('#powerSettingView');
(this.$refs.limitSettingView as HTMLElement).addEventListener("hide.bs.modal", this.onHideLimitSettings);
(this.$refs.powerSettingView as HTMLElement).addEventListener("hide.bs.modal", this.onHidePowerSettings);
},
unmounted() {
this.closeSocket();
},
updated() {
// Select first tab
if (this.isFirstFetchAfterConnect) {
this.isFirstFetchAfterConnect = false;
const firstTabEl = document.querySelector(
"#v-pills-tab:first-child button"
);
if (firstTabEl != null) {
const firstTab = new bootstrap.Tab(firstTabEl);
firstTab.show();
}
}
},
methods: {
getInitialData() {
this.dataLoading = true;
fetch("/api/livedata/status")
.then((response) => response.json())
.then((data) => {
this.inverterData = data;
this.dataLoading = false;
});
},
initSocket() {
console.log("Starting connection to WebSocket Server");
const { protocol, host } = location;
const webSocketUrl = `${protocol === "https:" ? "wss" : "ws"
}://${host}/livedata`;
this.socket = new WebSocket(webSocketUrl);
this.socket.onmessage = (event) => {
console.log(event);
this.inverterData = JSON.parse(event.data);
this.dataLoading = false;
this.heartCheck(); // Reset heartbeat detection
};
this.socket.onopen = function (event) {
console.log(event);
console.log("Successfully connected to the echo websocket server...");
};
// Listen to window events , When the window closes , Take the initiative to disconnect websocket Connect
window.onbeforeunload = () => {
this.closeSocket();
};
},
initDataAgeing() {
this.dataAgeInterval = setInterval(() => {
this.inverterData.forEach(element => {
element.data_age++;
});
}, 1000);
},
// Send heartbeat packets regularly * 59s Send a heartbeat
heartCheck() {
this.heartInterval && clearTimeout(this.heartInterval);
this.heartInterval = setInterval(() => {
if (this.socket.readyState === 1) {
// Connection status
this.socket.send("ping");
} else {
this.initSocket(); // Breakpoint reconnection 5 Time
}
}, 59 * 1000);
},
/** To break off websocket Connect */
closeSocket() {
this.socket.close();
this.heartInterval && clearTimeout(this.heartInterval);
this.isFirstFetchAfterConnect = true;
},
onHideEventlog() {
this.eventLogView.hide();
},
onShowEventlog(serial: number) {
this.eventLogLoading = true;
fetch("/api/eventlog/status?inv=" + serial)
.then((response) => response.json())
.then((data) => {
this.eventLogList = data[serial];
this.eventLogLoading = false;
});
this.eventLogView.show();
},
onHideDevInfo() {
this.devInfoView.hide();
},
onShowDevInfo(serial: number) {
this.devInfoLoading = true;
fetch("/api/devinfo/status")
.then((response) => response.json())
.then((data) => {
this.devInfoList = data[serial][0];
this.devInfoLoading = false;
});
this.devInfoView.show();
},
onHideLimitSettings() {
this.limitSettingSerial = 0;
this.targetLimit = 0;
this.targetLimitType = 1;
this.targetLimitTypeText = "Relative (%)";
this.showAlertLimit = false;
},
onShowLimitSettings(serial: number) {
this.limitSettingLoading = true;
fetch("/api/limit/status")
.then((response) => response.json())
.then((data) => {
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;
});
this.limitSettingView.show();
},
onSubmitLimit(e: Event) {
e.preventDefault();
const data = {
serial: this.limitSettingSerial,
limit_value: this.targetLimit,
limit_type: (this.targetLimitPersistent ? 256 : 0) + this.targetLimitType,
};
const formData = new FormData();
formData.append("data", JSON.stringify(data));
console.log(data);
fetch("/api/limit/config", {
method: "POST",
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then(
(response) => {
if (response.type == "success") {
this.limitSettingView.hide();
} else {
this.alertMessageLimit = response.message;
this.alertTypeLimit = response.type;
this.showAlertLimit = true;
}
}
)
},
onSetLimitSettings(setPersistent: boolean) {
this.targetLimitPersistent = setPersistent;
},
onSelectType(type: number) {
if (type == 1) {
this.targetLimitTypeText = "Relative (%)";
this.targetLimitMin = 10;
this.targetLimitMax = 100;
} else {
this.targetLimitTypeText = "Absolute (W)";
this.targetLimitMin = 10;
this.targetLimitMax = 1500;
}
this.targetLimitType = type;
},
onShowPowerSettings(serial: number) {
this.powerSettingLoading = true;
fetch("/api/power/status")
.then((response) => response.json())
.then((data) => {
this.successCommandPower = data[serial].power_set_status;
this.powerSettingSerial = serial;
this.powerSettingLoading = false;
});
this.powerSettingView.show();
},
onHidePowerSettings() {
this.powerSettingSerial = 0;
this.showAlertPower = false;
},
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));
console.log(data);
fetch("/api/power/config", {
method: "POST",
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then(
(response) => {
if (response.type == "success") {
this.powerSettingView.hide();
} else {
this.alertMessagePower = response.message;
this.alertTypePower = response.type;
this.showAlertPower = true;
}
}
)
},
},
});
</script>

View File

@ -1,9 +1,5 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>Inverter Settings</h1>
</div>
<BasePage :title="'Inverter Settings'" :isLoading="dataLoading">
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
</BootstrapAlert>
@ -72,86 +68,85 @@
</div>
</div>
</div>
</BasePage>
<div class="modal" id="inverterEdit" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Inverter</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="modal" id="inverterEdit" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Inverter</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form>
<div class="mb-3">
<label for="inverter-serial" class="col-form-label">Serial:</label>
<input v-model="editInverterData.serial" type="number" id="inverter-serial"
class="form-control" />
</div>
<div class="mb-3">
<label for="inverter-name" class="col-form-label">Name:</label>
<input v-model="editInverterData.name" type="text" id="inverter-name"
class="form-control" maxlength="31" />
<form>
<div class="mb-3">
<label for="inverter-serial" class="col-form-label">Serial:</label>
<input v-model="editInverterData.serial" type="number" id="inverter-serial"
class="form-control" />
</div>
<div class="mb-3">
<label for="inverter-name" class="col-form-label">Name:</label>
<input v-model="editInverterData.name" type="text" id="inverter-name" class="form-control"
maxlength="31" />
</div>
<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
}}:</label>
<div class="input-group">
<input type="number" class="form-control" :id="`inverter-max_${index}`" min="0"
v-model="editInverterData.max_power[index]"
:aria-describedby="`inverter-maxDescription_${index} inverter-maxHelpText_${index}`" />
<span class="input-group-text" :id="`inverter-maxDescription_${index}`">W</span>
</div>
<div :id="`inverter-maxHelpText_${index}`" class="form-text">This value is used to
calculate the Irradiation.</div>
</div>
</form>
<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
}}:</label>
<div class="input-group">
<input type="number" class="form-control" :id="`inverter-max_${index}`" min="0"
v-model="editInverterData.max_power[index]"
:aria-describedby="`inverter-maxDescription_${index} inverter-maxHelpText_${index}`" />
<span class="input-group-text" :id="`inverter-maxDescription_${index}`">W</span>
</div>
<div :id="`inverter-maxHelpText_${index}`" class="form-text">This value is used to
calculate the Irradiation.</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onCancel"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" @click="onEditSubmit(editId)">Save
changes</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onCancel"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" @click="onEditSubmit(editId)">Save
changes</button>
</div>
</div>
</div>
</div>
<div class="modal" id="inverterDelete" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Inverter</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete the inverter "{{ deleteInverterData.name }}" with serial number
{{ deleteInverterData.serial }}?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onDeleteCancel"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger"
@click="onDelete(deleteId.toString())">Delete</button>
</div>
<div class="modal" id="inverterDelete" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Inverter</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete the inverter "{{ deleteInverterData.name }}" with serial number
{{ deleteInverterData.serial }}?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onDeleteCancel"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" @click="onDelete(deleteId.toString())">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import {
BIconTrash,
BIconPencil
} from 'bootstrap-icons-vue';
import * as bootstrap from 'bootstrap';
import BootstrapAlert from "@/components/partials/BootstrapAlert.vue";
import BootstrapAlert from "@/components/BootstrapAlert.vue";
declare interface Inverter {
id: string,
@ -163,6 +158,7 @@ declare interface Inverter {
export default defineComponent({
components: {
BasePage,
BootstrapAlert,
BIconTrash,
BIconPencil,
@ -177,6 +173,7 @@ export default defineComponent({
editInverterData: {} as Inverter,
deleteInverterData: {} as Inverter,
inverters: [] as Inverter[],
dataLoading: true,
alertMessage: "",
alertType: "info",
showAlert: false,
@ -198,9 +195,13 @@ export default defineComponent({
},
methods: {
getInverters() {
this.dataLoading = true;
fetch("/api/inverter/list")
.then((response) => response.json())
.then((data) => (this.inverters = data.inverter));
.then((data) => {
this.inverters = data.inverter;
this.dataLoading = false;
});
},
onSubmit() {
const formData = new FormData();

View File

@ -0,0 +1,277 @@
<template>
<BasePage :title="'MqTT Settings'" :isLoading="dataLoading">
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
</BootstrapAlert>
<form @submit="saveMqttConfig">
<div class="card">
<div class="card-header text-white bg-primary">MqTT Configuration</div>
<div class="card-body">
<div class="row mb-3">
<label class="col-sm-4 form-check-label" for="inputMqtt">Enable MqTT</label>
<div class="col-sm-8">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputMqtt"
v-model="mqttConfigList.mqtt_enabled" />
</div>
</div>
</div>
<div class="row mb-3" v-show="mqttConfigList.mqtt_enabled">
<label class="col-sm-4 form-check-label" for="inputMqttHass">Enable Home Assistant MQTT Auto
Discovery</label>
<div class="col-sm-8">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputMqttHass"
v-model="mqttConfigList.mqtt_hass_enabled" />
</div>
</div>
</div>
</div>
</div>
<div class="card mt-5" v-show="mqttConfigList.mqtt_enabled">
<div class="card-header text-white bg-primary">
MqTT Broker Parameter
</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputHostname" class="col-sm-2 col-form-label">Hostname:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputHostname" maxlength="128"
placeholder="Hostname or IP address" v-model="mqttConfigList.mqtt_hostname" />
</div>
</div>
<div class="row mb-3">
<label for="inputPort" class="col-sm-2 col-form-label">Port:</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="inputPort" min="1" max="65535"
placeholder="Port number" v-model="mqttConfigList.mqtt_port" />
</div>
</div>
<div class="row mb-3">
<label for="inputUsername" class="col-sm-2 col-form-label">Username:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputUsername" maxlength="32"
placeholder="Username, leave empty for anonymous connection"
v-model="mqttConfigList.mqtt_username" />
</div>
</div>
<div class="row mb-3">
<label for="inputPassword" class="col-sm-2 col-form-label">Password:</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="inputPassword" maxlength="32"
placeholder="Password, leave empty for anonymous connection"
v-model="mqttConfigList.mqtt_password" />
</div>
</div>
<div class="row mb-3">
<label for="inputTopic" class="col-sm-2 col-form-label">Base Topic:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputTopic" maxlength="32"
placeholder="Base topic, will be prepend to all published topics (e.g. inverter/)"
v-model="mqttConfigList.mqtt_topic" />
</div>
</div>
<div class="row mb-3">
<label for="inputPublishInterval" class="col-sm-2 col-form-label">Publish Interval:</label>
<div class="col-sm-10">
<div class="input-group">
<input type="number" class="form-control" id="inputPublishInterval" min="5" max="86400"
placeholder="Publish Interval in Seconds"
v-model="mqttConfigList.mqtt_publish_interval"
aria-describedby="publishIntervalDescription" />
<span class="input-group-text" id="publishIntervalDescription">seconds</span>
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputRetain">Enable Retain Flag</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputRetain"
v-model="mqttConfigList.mqtt_retain" />
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputTls">Enable TLS</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputTls"
v-model="mqttConfigList.mqtt_tls" />
</div>
</div>
</div>
<div class="row mb-3" v-show="mqttConfigList.mqtt_tls">
<label for="inputCert" class="col-sm-2 col-form-label">CA-Root-Certificate (default
Letsencrypt):</label>
<div class="col-sm-10">
<textarea class="form-control" id="inputCert" maxlength="2048" rows="10"
placeholder="Root CA Certificate from Letsencrypt"
v-model="mqttConfigList.mqtt_root_ca_cert">
</textarea>
</div>
</div>
</div>
</div>
<div class="card mt-5" v-show="mqttConfigList.mqtt_enabled">
<div class="card-header text-white bg-primary">LWT Parameters</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputLwtTopic" class="col-sm-2 col-form-label">LWT Topic:</label>
<div class="col-sm-10">
<div class="input-group">
<span class="input-group-text" id="basic-addon3">{{
mqttConfigList.mqtt_topic
}}</span>
<input type="text" class="form-control" id="inputLwtTopic" maxlength="32"
placeholder="LWT topic, will be append base topic"
v-model="mqttConfigList.mqtt_lwt_topic" aria-describedby="basic-addon3" />
</div>
</div>
</div>
<div class="row mb-3">
<label for="inputLwtOnline" class="col-sm-2 col-form-label">LWT Online message:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputLwtOnline" maxlength="20"
placeholder="Message that will be published to LWT topic when online"
v-model="mqttConfigList.mqtt_lwt_online" />
</div>
</div>
<div class="row mb-3">
<label for="inputLwtOffline" class="col-sm-2 col-form-label">LWT Offline message:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputLwtOffline" maxlength="20"
placeholder="Message that will be published to LWT topic when offline"
v-model="mqttConfigList.mqtt_lwt_offline" />
</div>
</div>
</div>
</div>
<div class="card mt-5" v-show="mqttConfigList.mqtt_enabled && mqttConfigList.mqtt_hass_enabled">
<div class="card-header text-white bg-primary">Home Assistant MQTT Auto Discovery Parameters</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputHassTopic" class="col-sm-2 col-form-label">Prefix Topic:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputHassTopic" maxlength="32"
placeholder="The prefix for the discovery topic"
v-model="mqttConfigList.mqtt_hass_topic" />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputHassRetain">Enable Retain Flag</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputHassRetain"
v-model="mqttConfigList.mqtt_hass_retain" />
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputHassExpire">Enable Expiration</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputHassExpire"
v-model="mqttConfigList.mqtt_hass_expire" />
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputIndividualPanels">Individual
Panels:</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputIndividualPanels"
v-model="mqttConfigList.mqtt_hass_individualpanels" />
</div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mb-3">Save</button>
</form>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import type { MqttConfig } from "@/types/MqttConfig";
export default defineComponent({
components: {
BasePage,
BootstrapAlert,
},
data() {
return {
dataLoading: true,
mqttConfigList: {} as MqttConfig,
alertMessage: "",
alertType: "info",
showAlert: false,
};
},
created() {
this.getMqttConfig();
},
methods: {
getMqttConfig() {
this.dataLoading = true;
fetch("/api/mqtt/config")
.then((response) => response.json())
.then((data) => {
this.mqttConfigList = data;
this.dataLoading = false;
});
},
saveMqttConfig(e: Event) {
e.preventDefault();
const formData = new FormData();
formData.append("data", JSON.stringify(this.mqttConfigList));
fetch("/api/mqtt/config", {
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>

View File

@ -0,0 +1,180 @@
<template>
<BasePage :title="'MqTT Info'" :isLoading="dataLoading">
<div class="card">
<div class="card-header text-white bg-primary">Configuration Summary</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Status</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_enabled,
'bg-success': mqttDataList.mqtt_enabled,
}">
<span v-if="mqttDataList.mqtt_enabled">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>Server</th>
<td>{{ mqttDataList.mqtt_hostname }}</td>
</tr>
<tr>
<th>Port</th>
<td>{{ mqttDataList.mqtt_port }}</td>
</tr>
<tr>
<th>Username</th>
<td>{{ mqttDataList.mqtt_username }}</td>
</tr>
<tr>
<th>Base Topic</th>
<td>{{ mqttDataList.mqtt_topic }}</td>
</tr>
<tr>
<th>Publish Interval</th>
<td>{{ mqttDataList.mqtt_publish_interval }} seconds</td>
</tr>
<tr>
<th>Retain</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_retain,
'bg-success': mqttDataList.mqtt_retain,
}">
<span v-if="mqttDataList.mqtt_retain">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>TLS</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_tls,
'bg-success': mqttDataList.mqtt_tls,
}">
<span v-if="mqttDataList.mqtt_tls">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr v-show="mqttDataList.mqtt_tls">
<th>Root CA Certifcate Info</th>
<td>{{ mqttDataList.mqtt_root_ca_cert_info }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card mt-5">
<div class="card-header text-white bg-primary">Home Assistant MQTT Auto Discovery Configuration Summary
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Status</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_hass_enabled,
'bg-success': mqttDataList.mqtt_hass_enabled,
}">
<span v-if="mqttDataList.mqtt_hass_enabled">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>Base Topic</th>
<td>{{ mqttDataList.mqtt_hass_topic }}</td>
</tr>
<tr>
<th>Retain</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_hass_retain,
'bg-success': mqttDataList.mqtt_hass_retain,
}">
<span v-if="mqttDataList.mqtt_hass_retain">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>Expire</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_hass_expire,
'bg-success': mqttDataList.mqtt_hass_expire,
}">
<span v-if="mqttDataList.mqtt_hass_expire">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
<tr>
<th>Individual Panels</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_hass_individualpanels,
'bg-success': mqttDataList.mqtt_hass_individualpanels,
}">
<span v-if="mqttDataList.mqtt_hass_individualpanels">enabled</span>
<span v-else>disabled</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card mt-5">
<div class="card-header text-white bg-primary">Runtime Summary</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Connection Status</th>
<td class="badge" :class="{
'bg-danger': !mqttDataList.mqtt_connected,
'bg-success': mqttDataList.mqtt_connected,
}">
<span v-if="mqttDataList.mqtt_connected">connected</span>
<span v-else>disconnected</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import type { MqttStatus } from '@/types/MqttStatus';
export default defineComponent({
components: {
BasePage,
},
data() {
return {
dataLoading: true,
mqttDataList: {} as MqttStatus,
};
},
created() {
this.getMqttInfo();
},
methods: {
getMqttInfo() {
this.dataLoading = true;
fetch("/api/mqtt/status")
.then((response) => response.json())
.then((data) => {
this.mqttDataList = data;
this.dataLoading = false;
});
},
},
});
</script>

View File

@ -0,0 +1,163 @@
<template>
<BasePage :title="'Network Settings'" :isLoading="dataLoading">
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
</BootstrapAlert>
<form @submit="saveNetworkConfig">
<div class="card">
<div class="card-header text-white bg-primary">WiFi Configuration</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputSSID" class="col-sm-2 col-form-label">WiFi SSID:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputSSID" maxlength="32" placeholder="SSID"
v-model="networkConfigList.ssid" />
</div>
</div>
<div class="row mb-3">
<label for="inputPassword" class="col-sm-2 col-form-label">WiFi Password:</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="inputPassword" maxlength="64"
placeholder="PSK" v-model="networkConfigList.password" />
</div>
</div>
<div class="row mb-3">
<label for="inputHostname" class="col-sm-2 col-form-label">Hostname:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputHostname" maxlength="32"
placeholder="Hostname" v-model="networkConfigList.hostname" />
<div class="alert alert-secondary" role="alert">
<b>Hint:</b> The text <span class="font-monospace">%06X</span> will be replaced
with the last 6 digits of the ESP ChipID in hex format.
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 form-check-label" for="inputDHCP">Enable DHCP</label>
<div class="col-sm-10">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="inputDHCP"
v-model="networkConfigList.dhcp" />
</div>
</div>
</div>
</div>
</div>
<div class="card" v-show="!networkConfigList.dhcp">
<div class="card-header text-white bg-primary">
Static IP Configuration
</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputIP" class="col-sm-2 col-form-label">IP Address:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputIP" maxlength="32" placeholder="IP address"
v-model="networkConfigList.ipaddress" />
</div>
</div>
<div class="row mb-3">
<label for="inputNetmask" class="col-sm-2 col-form-label">Netmask:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputNetmask" maxlength="32"
placeholder="Netmask" v-model="networkConfigList.netmask" />
</div>
</div>
<div class="row mb-3">
<label for="inputGateway" class="col-sm-2 col-form-label">Default Gateway:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputGateway" maxlength="32"
placeholder="Default Gateway" v-model="networkConfigList.gateway" />
</div>
</div>
<div class="row mb-3">
<label for="inputDNS1" class="col-sm-2 col-form-label">DNS Server 1:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputDNS1" maxlength="32"
placeholder="DNS Server 1" v-model="networkConfigList.dns1" />
</div>
</div>
<div class="row mb-3">
<label for="inputDNS2" class="col-sm-2 col-form-label">DNS Server 2:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputDNS2" maxlength="32"
placeholder="DNS Server 2" v-model="networkConfigList.dns2" />
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mb-3">Save</button>
</form>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import type { NetworkConfig } from "@/types/NetworkkConfig";
export default defineComponent({
components: {
BasePage,
BootstrapAlert,
},
data() {
return {
dataLoading: true,
networkConfigList: {} as NetworkConfig,
alertMessage: "",
alertType: "info",
showAlert: false,
};
},
created() {
this.getNetworkConfig();
},
methods: {
getNetworkConfig() {
this.dataLoading = true;
fetch("/api/network/config")
.then((response) => response.json())
.then((data) => {
this.networkConfigList = data;
this.dataLoading = false;
});
},
saveNetworkConfig(e: Event) {
e.preventDefault();
const formData = new FormData();
formData.append("data", JSON.stringify(this.networkConfigList));
fetch("/api/network/config", {
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>

View File

@ -0,0 +1,52 @@
<template>
<BasePage :title="'Network Info'" :isLoading="dataLoading">
<WifiStationInfo :networkStatus="networkDataList" />
<div class="mt-5"></div>
<WifiApInfo :networkStatus="networkDataList" />
<div class="mt-5"></div>
<InterfaceNetworkInfo :networkStatus="networkDataList" />
<div class="mt-5"></div>
<InterfaceApInfo :networkStatus="networkDataList" />
<div class="mt-5"></div>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import WifiStationInfo from "@/components/WifiStationInfo.vue";
import WifiApInfo from "@/components/WifiApInfo.vue";
import InterfaceNetworkInfo from "@/components/InterfaceNetworkInfo.vue";
import InterfaceApInfo from "@/components/InterfaceApInfo.vue";
import type { NetworkStatus } from '@/types/NetworkStatus';
export default defineComponent({
components: {
BasePage,
WifiStationInfo,
WifiApInfo,
InterfaceNetworkInfo,
InterfaceApInfo,
},
data() {
return {
dataLoading: true,
networkDataList: {} as NetworkStatus,
}
},
created() {
this.getNetworkInfo();
},
methods: {
getNetworkInfo() {
this.dataLoading = true;
fetch("/api/network/status")
.then((response) => response.json())
.then((data) => {
this.networkDataList = data;
this.dataLoading = false;
});
},
},
});
</script>

View File

@ -1,107 +1,92 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>NTP Settings</h1>
</div>
<BasePage :title="'NTP Settings'" :isLoading="dataLoading || timezoneLoading">
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
</BootstrapAlert>
<div class="text-center" v-if="dataLoading || timezoneLoading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<template v-if="!dataLoading && !timezoneLoading">
<form @submit="saveNtpConfig">
<div class="card">
<div class="card-header text-white bg-primary">NTP Configuration</div>
<div class="card-body">
<div class="row mb-3">
<label for="inputNtpServer" class="col-sm-2 col-form-label">Time Server:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputNtpServer" maxlength="32"
placeholder="Time Server" v-model="ntpConfigList.ntp_server" />
</div>
</div>
<div class="row mb-3">
<label for="inputTimezone" class="col-sm-2 col-form-label">Timezone:</label>
<div class="col-sm-10">
<select class="form-select" v-model="timezoneSelect">
<option v-for="(config, name) in timezoneList" :key="name + '---' + config"
:value="name + '---' + config">
{{ name }}
</option>
</select>
</div>
</div>
<div class="row mb-3">
<label for="inputTimezoneConfig" class="col-sm-2 col-form-label">Timezone Config:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputTimezoneConfig" maxlength="32"
placeholder="Timezone" v-model="ntpConfigList.ntp_timezone" disabled />
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mb-3">Save</button>
</form>
</template>
<template v-if="!dataLoading && !timezoneLoading">
<form @submit="saveNtpConfig">
<div class="card">
<div class="card-header text-white bg-primary">Manual Time Synchronization</div>
<div class="card-header text-white bg-primary">NTP Configuration</div>
<div class="card-body">
<div class="row mb-3">
<label for="currentMcuTime" class="col-sm-2 col-form-label">Current OpenDTU Time:</label>
<label for="inputNtpServer" class="col-sm-2 col-form-label">Time Server:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="currentMcuTime" v-model="mcuTime" disabled />
<input type="text" class="form-control" id="inputNtpServer" maxlength="32"
placeholder="Time Server" v-model="ntpConfigList.ntp_server" />
</div>
</div>
<div class="row mb-3">
<label for="currentLocalTime" class="col-sm-2 col-form-label">Current Local Time:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="currentLocalTime" v-model="localTime"
disabled />
</div>
</div>
<div class="text-center mb-3">
<button type="button" class="btn btn-danger" @click="setCurrentTime()"
title="Synchronize Time">Synchronize Time
</button>
</div>
<div class="alert alert-secondary" role="alert">
<b>Hint:</b> You can use the manual time synchronization to set the current time of OpenDTU if
no NTP server is available. But be aware, that in case of power cycle the time gets lost. Also
the time accurancy can be very bad as it is not resynchronised regularly.
</div>
<div class="row mb-3">
<label for="inputTimezone" class="col-sm-2 col-form-label">Timezone:</label>
<div class="col-sm-10">
<select class="form-select" v-model="timezoneSelect">
<option v-for="(config, name) in timezoneList" :key="name + '---' + config"
:value="name + '---' + config">
{{ name }}
</option>
</select>
</div>
</div>
<div class="row mb-3">
<label for="inputTimezoneConfig" class="col-sm-2 col-form-label">Timezone Config:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputTimezoneConfig" maxlength="32"
placeholder="Timezone" v-model="ntpConfigList.ntp_timezone" disabled />
</div>
</div>
</div>
</div>
</template>
</div>
<button type="submit" class="btn btn-primary mb-3">Save</button>
</form>
<div class="card">
<div class="card-header text-white bg-primary">Manual Time Synchronization</div>
<div class="card-body">
<div class="row mb-3">
<label for="currentMcuTime" class="col-sm-2 col-form-label">Current OpenDTU Time:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="currentMcuTime" v-model="mcuTime" disabled />
</div>
</div>
<div class="row mb-3">
<label for="currentLocalTime" class="col-sm-2 col-form-label">Current Local Time:</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="currentLocalTime" v-model="localTime" disabled />
</div>
</div>
<div class="text-center mb-3">
<button type="button" class="btn btn-danger" @click="setCurrentTime()"
title="Synchronize Time">Synchronize Time
</button>
</div>
<div class="alert alert-secondary" role="alert">
<b>Hint:</b> You can use the manual time synchronization to set the current time of OpenDTU if
no NTP server is available. But be aware, that in case of power cycle the time gets lost. Also
the time accurancy can be very bad as it is not resynchronised regularly.
</div>
</div>
</div>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BootstrapAlert from "@/components/partials/BootstrapAlert.vue";
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import type { NtpConfig } from "@/types/NtpConfig";
export default defineComponent({
components: {
BasePage,
BootstrapAlert,
},
data() {
return {
dataLoading: true,
timezoneLoading: true,
ntpConfigList: {
ntp_server: "",
ntp_timezone: "",
ntp_timezone_descr: ""
},
ntpConfigList: {} as NtpConfig,
timezoneList: {},
timezoneSelect: "",
mcuTime: new Date(),

View File

@ -0,0 +1,85 @@
<template>
<BasePage :title="'NTP Info'" :isLoading="dataLoading">
<div class="card">
<div class="card-header text-white bg-primary">Configuration Summary</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Server</th>
<td>{{ ntpDataList.ntp_server }}</td>
</tr>
<tr>
<th>Timezone</th>
<td>{{ ntpDataList.ntp_timezone }}</td>
</tr>
<tr>
<th>Timezone Description</th>
<td>{{ ntpDataList.ntp_timezone_descr }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card mt-5">
<div class="card-header text-white bg-primary">Current Time</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-condensed">
<tbody>
<tr>
<th>Status</th>
<td class="badge" :class="{
'bg-danger': !ntpDataList.ntp_status,
'bg-success': ntpDataList.ntp_status,
}">
<span v-if="ntpDataList.ntp_status">synced</span>
<span v-else>not synced</span>
</td>
</tr>
<tr>
<th>Local Time</th>
<td>{{ ntpDataList.ntp_localtime }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import type { NtpStatus } from "@/types/NtpStatus";
export default defineComponent({
components: {
BasePage,
},
data() {
return {
dataLoading: true,
ntpDataList: {} as NtpStatus,
};
},
created() {
this.getNtpInfo();
},
methods: {
getNtpInfo() {
this.dataLoading = true;
fetch("/api/ntp/status")
.then((response) => response.json())
.then((data) => {
this.ntpDataList = data;
this.dataLoading = false;
});
},
},
});
</script>

View File

@ -0,0 +1,112 @@
<template>
<BasePage :title="'Security Settings'" :isLoading="dataLoading">
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
</BootstrapAlert>
<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="securityConfigList.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>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import type { SecurityConfig } from '@/types/SecurityConfig';
export default defineComponent({
components: {
BasePage,
BootstrapAlert,
},
data() {
return {
dataLoading: true,
alertMessage: "",
alertType: "info",
showAlert: false,
securityConfigList: {} as SecurityConfig,
passwordRepeat: "",
};
},
created() {
this.getPasswordConfig();
},
methods: {
getPasswordConfig() {
this.dataLoading = true;
fetch("/api/security/password")
.then((response) => response.json())
.then(
(data) => {
this.securityConfigList = data;
this.passwordRepeat = this.securityConfigList.password;
this.dataLoading = false;
}
);
},
savePasswordConfig(e: Event) {
e.preventDefault();
if (this.securityConfigList.password != this.passwordRepeat) {
this.alertMessage = "Passwords are not equal";
this.alertType = "warning";
this.showAlert = true;
return;
}
const formData = new FormData();
formData.append("data", JSON.stringify(this.securityConfigList));
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>

View File

@ -1,37 +1,28 @@
<template>
<div class="container-xxl" role="main">
<div class="page-header">
<h1>System Info</h1>
</div>
<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">
<FirmwareInfo v-bind="systemDataList" />
<div class="mt-5"></div>
<HardwareInfo v-bind="systemDataList" />
<div class="mt-5"></div>
<MemoryInfo v-bind="systemDataList" />
<div class="mt-5"></div>
<RadioInfo v-bind="systemDataList" />
<div class="mt-5"></div>
</template>
</div>
<BasePage :title="'System Info'" :isLoading="dataLoading">
<FirmwareInfo :systemStatus="systemDataList" />
<div class="mt-5"></div>
<HardwareInfo :systemStatus="systemDataList" />
<div class="mt-5"></div>
<MemoryInfo :systemStatus="systemDataList" />
<div class="mt-5"></div>
<RadioInfo :systemStatus="systemDataList" />
<div class="mt-5"></div>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import HardwareInfo from "@/components/partials/HardwareInfo.vue";
import FirmwareInfo from "@/components/partials/FirmwareInfo.vue";
import MemoryInfo from "@/components/partials/MemoryInfo.vue";
import RadioInfo from "@/components/partials/RadioInfo.vue";
import BasePage from '@/components/BasePage.vue';
import HardwareInfo from "@/components/HardwareInfo.vue";
import FirmwareInfo from "@/components/FirmwareInfo.vue";
import MemoryInfo from "@/components/MemoryInfo.vue";
import RadioInfo from "@/components/RadioInfo.vue";
import type { SystemStatus } from '@/types/SystemStatus';
export default defineComponent({
components: {
BasePage,
HardwareInfo,
FirmwareInfo,
MemoryInfo,
@ -40,35 +31,7 @@ export default defineComponent({
data() {
return {
dataLoading: true,
systemDataList: {
// HardwareInfo
chipmodel: "",
chiprevision: 0,
chipcores: 0,
cpufreq: 0,
// FirmwareInfo
hostname: "",
sdkversion: "",
config_version: "",
git_hash: "",
resetreason_0: "",
resetreason_1: "",
cfgsavecount: 0,
uptime: 0,
update_text: "",
update_url: "",
update_status: "",
// MemoryInfo
heap_total: 0,
heap_used: 0,
littlefs_total: 0,
littlefs_used: 0,
sketch_total: 0,
sketch_used: 0,
// RadioInfo
radio_connected: false,
radio_pvariant: false,
}
systemDataList: {} as SystemStatus,
}
},
created() {

View File

@ -0,0 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*"],
"compilerOptions": {
"composite": true,
"types": ["node"]
}
}

View File

@ -1,41 +1,16 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"moduleResolution": "node",
"experimentalDecorators": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"sourceMap": true,
"baseUrl": ".",
"types": [
"webpack-env"
],
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
}
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"references": [
{
"path": "./tsconfig.config.json"
}
]
}

48
webapp/vite.config.ts Normal file
View File

@ -0,0 +1,48 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import viteCompression from 'vite-plugin-compression';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
viteCompression({ deleteOriginFile: true, threshold: 0 }),
cssInjectedByJsPlugin()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
// Prevent vendor.css being created
cssCodeSplit: false,
outDir: '../webapp_dist',
emptyOutDir: true,
rollupOptions: {
output: {
// Only create one js file
inlineDynamicImports: true,
// Get rid of hash on js file
entryFileNames: 'js/app.js',
// Get rid of hash on css file
assetFileNames: "assets/[name].[ext]",
},
},
},
server: {
proxy: {
'^/api': {
target: 'http://192.168.20.110/'
},
'^/livedata': {
target: 'ws://192.168.20.110/',
ws: true,
changeOrigin: true
}
}
}
})

View File

@ -1,38 +0,0 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
productionSourceMap: false,
outputDir: '../webapp_dist',
filenameHashing: false,
css: {
extract: false,
},
configureWebpack: {
optimization: {
splitChunks: false
}
},
pluginOptions: {
compression: {
gzip: {
filename: '[file].gz[query]',
algorithm: 'gzip',
include: /\.(js|css|html|svg|json)(\?.*)?$/i,
deleteOriginalAssets: true,
minRatio: 0.8,
}
}
},
devServer: {
proxy: {
'^/api': {
target: 'http://192.168.20.110/'
},
'^/livedata': {
target: 'ws://192.168.20.110/',
ws: true,
changeOrigin: true
}
}
}
})

File diff suppressed because it is too large Load Diff