Site: add config UI (BC) (#9812)
This commit is contained in:
parent
09d7e76312
commit
186ba62db7
47 changed files with 3910 additions and 1653 deletions
10
.eslintrc.js
10
.eslintrc.js
|
|
@ -5,12 +5,14 @@ module.exports = {
|
|||
es6: true,
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended",
|
||||
"plugin:vue/vue3-recommended",
|
||||
"plugin:prettier/recommended",
|
||||
"prettier",
|
||||
"@vue/eslint-config-typescript",
|
||||
"@vue/eslint-config-prettier/skip-formatting",
|
||||
],
|
||||
parser: "vue-eslint-parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
},
|
||||
rules: {
|
||||
"vue/require-default-prop": "off",
|
||||
"vue/attribute-hyphenation": "off",
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -13,6 +13,7 @@ __debug_bin
|
|||
!package*.json
|
||||
!evcc.dist.yaml
|
||||
!tests/**/*.evcc.yaml
|
||||
!tsconfig.json
|
||||
/templates/docs
|
||||
evcc
|
||||
evcc.exe
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
--evcc-box: var(--bs-white);
|
||||
--evcc-default-text: var(--bs-gray-dark);
|
||||
--evcc-gray: var(--bs-gray-medium);
|
||||
--evcc-gray-50: #93949e80;
|
||||
|
||||
--evcc-accent1: var(--evcc-dark-yellow);
|
||||
--evcc-accent2: var(--evcc-darker-green);
|
||||
|
|
@ -193,19 +194,27 @@ a:hover {
|
|||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
--bs-btn-active-bg: var(--bs-primary);
|
||||
--bs-btn-active-border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.btn-outline-primary,
|
||||
.btn-outline-primary:focus {
|
||||
color: var(--bs-primary);
|
||||
background-color: transparent;
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
color: var(--evcc-accent3) !important;
|
||||
background-color: transparent !important;
|
||||
border-color: var(--evcc-accent3) !important;
|
||||
}
|
||||
|
||||
.btn-group > .btn-check + .btn-outline-primary:hover,
|
||||
.btn-outline-primary:active {
|
||||
color: var(--evcc-default-text) !important;
|
||||
color: var(--bs-white) !important;
|
||||
background-color: var(--evcc-accent3) !important;
|
||||
border-color: var(--evcc-accent3) !important;
|
||||
}
|
||||
|
|
@ -319,7 +328,6 @@ a:hover {
|
|||
|
||||
.modal-body {
|
||||
padding: 1rem 0 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
|
|
@ -427,6 +435,9 @@ input::-webkit-datetime-edit {
|
|||
background-color: var(--evcc-box);
|
||||
color: var(--evcc-default-text);
|
||||
}
|
||||
.form-control:disabled {
|
||||
color: var(--bs-gray-dark);
|
||||
}
|
||||
|
||||
input[type="time"]::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
|
|
@ -436,6 +447,13 @@ input[type="time"]::-webkit-calendar-picker-indicator {
|
|||
--bs-table-bg: transparent;
|
||||
}
|
||||
|
||||
.badge {
|
||||
--bs-badge-font-size: 0.8rem;
|
||||
--bs-badge-padding-x: 0.75em;
|
||||
--bs-badge-padding-y: 0.75em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* larger check switch */
|
||||
.form-switch .form-check-input {
|
||||
height: 1.1rem;
|
||||
|
|
|
|||
|
|
@ -269,12 +269,12 @@ export default {
|
|||
this.$t("main.targetCharge.tomorrow"),
|
||||
];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dayNumber = date.toLocaleDateString(this.$i18n.locale, {
|
||||
const dayNumber = date.toLocaleDateString(this.$i18n?.locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const dayName =
|
||||
labels[i] || date.toLocaleDateString(this.$i18n.locale, { weekday: "long" });
|
||||
labels[i] || date.toLocaleDateString(this.$i18n?.locale, { weekday: "long" });
|
||||
options.push({
|
||||
value: this.fmtDayString(date),
|
||||
name: `${dayNumber} (${dayName})`,
|
||||
|
|
|
|||
45
assets/js/components/Config/AddDeviceButton.vue
Normal file
45
assets/js/components/Config/AddDeviceButton.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<li class="root">
|
||||
<button class="d-flex align-items-center justify-content-center p-3" @click="$emit('add')">
|
||||
<shopicon-regular-plus class="me-1"></shopicon-regular-plus>
|
||||
{{ title }}
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/plus";
|
||||
|
||||
export default {
|
||||
name: "AddDeviceButton",
|
||||
props: {
|
||||
title: String,
|
||||
},
|
||||
emits: ["add"],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.root {
|
||||
padding: 0;
|
||||
display: block;
|
||||
list-style-type: none;
|
||||
min-height: 9rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--evcc-gray-50);
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
transition: border-color var(--evcc-transition-fast) linear;
|
||||
}
|
||||
.root:hover {
|
||||
border-color: var(--evcc-default-text);
|
||||
color: var(--evcc-default-text);
|
||||
}
|
||||
button {
|
||||
border-radius: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
105
assets/js/components/Config/DeviceCard.vue
Normal file
105
assets/js/components/Config/DeviceCard.vue
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<li class="root py-2 px-4">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="icon me-2">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<strong class="flex-grow-1 text-nowrap text-truncate">{{ name }}</strong>
|
||||
<button
|
||||
v-if="unconfigured"
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary position-relative border-0 p-2"
|
||||
:title="$t('config.main.new')"
|
||||
@click="$emit('configure')"
|
||||
>
|
||||
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="editable"
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary position-relative border-0 p-2"
|
||||
:title="$t('config.main.edit')"
|
||||
@click="$emit('edit')"
|
||||
>
|
||||
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
ref="tooltip"
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary position-relative border-0 p-2 opacity-25"
|
||||
data-bs-toggle="tooltip"
|
||||
:title="$t('config.main.yaml')"
|
||||
>
|
||||
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="unconfigured" class="text-center py-3 evcc-gray">
|
||||
{{ $t("config.main.unconfigured") }}
|
||||
</div>
|
||||
<slot v-else name="tags" />
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/adjust";
|
||||
import "@h2d2/shopicons/es/regular/invoice";
|
||||
import "@h2d2/shopicons/es/regular/edit";
|
||||
import Tooltip from "bootstrap/js/dist/tooltip";
|
||||
|
||||
export default {
|
||||
name: "DeviceCard",
|
||||
props: {
|
||||
name: String,
|
||||
editable: Boolean,
|
||||
unconfigured: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tooltip: null,
|
||||
};
|
||||
},
|
||||
emits: ["edit", "configure"],
|
||||
mounted() {
|
||||
this.initTooltip();
|
||||
},
|
||||
watch: {
|
||||
editable() {
|
||||
this.initTooltip();
|
||||
},
|
||||
unconfigured() {
|
||||
this.initTooltip();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
initTooltip() {
|
||||
this.$nextTick(() => {
|
||||
this.tooltip?.dispose();
|
||||
if (this.$refs.tooltip) {
|
||||
this.tooltip = new Tooltip(this.$refs.tooltip);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.root {
|
||||
display: block;
|
||||
list-style-type: none;
|
||||
min-height: 9rem;
|
||||
color: var(--evcc-default-text);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--evcc-gray-50);
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
transition: border-color var(--evcc-transition-fast) linear;
|
||||
background: var(--evcc-box);
|
||||
}
|
||||
.root:hover {
|
||||
border-color: var(--evcc-gray);
|
||||
}
|
||||
.icon:empty {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
64
assets/js/components/Config/DeviceTags.vue
Normal file
64
assets/js/components/Config/DeviceTags.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div v-if="tags" class="d-flex mb-2 flex-wrap">
|
||||
<span
|
||||
v-for="(entry, index) in entries"
|
||||
:key="index"
|
||||
class="badge text-bg-secondary me-2 mb-2"
|
||||
:class="{
|
||||
'text-bg-secondary': !entry.error,
|
||||
'text-bg-danger': entry.error,
|
||||
}"
|
||||
>
|
||||
<strong>{{ $t(`config.deviceValue.${entry.name}`) }}:</strong>
|
||||
{{ fmtDeviceValue(entry) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import formatter from "../../mixins/formatter";
|
||||
|
||||
export default {
|
||||
name: "DeviceTags",
|
||||
props: {
|
||||
tags: Object,
|
||||
},
|
||||
mixins: [formatter],
|
||||
computed: {
|
||||
entries() {
|
||||
return Object.entries(this.tags).map(([name, { value, error }]) => {
|
||||
return { name, value, error };
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fmtDeviceValue(entry) {
|
||||
const { name, value } = entry;
|
||||
switch (name) {
|
||||
case "power":
|
||||
return this.fmtKw(value);
|
||||
case "energy":
|
||||
case "capacity":
|
||||
case "chargedEnergy":
|
||||
return this.fmtKWh(value * 1e3);
|
||||
case "soc":
|
||||
return `${this.fmtNumber(value, 1)}%`;
|
||||
case "odometer":
|
||||
case "range":
|
||||
return `${this.fmtNumber(value, 0)} km`;
|
||||
case "phaseCurrents":
|
||||
return value.map((v) => this.fmtNumber(v, 0)).join(" ") + " A";
|
||||
case "phaseVoltages":
|
||||
return value.map((v) => this.fmtNumber(v, 0)).join(" ") + " V";
|
||||
case "phasePowers":
|
||||
return value.map((v) => this.fmtKw(v)).join(", ");
|
||||
case "chargeStatus":
|
||||
return value;
|
||||
case "socLimit":
|
||||
return `${this.fmtNumber(value)}%`;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped></style>
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<div class="mb-4">
|
||||
<label :for="id">
|
||||
<div class="form-label">
|
||||
{{ label }}
|
||||
<small v-if="optional">{{ $t("config.form.optional") }}</small>
|
||||
<small v-if="optional" class="evcc-gray">{{ $t("config.form.optional") }}</small>
|
||||
</div>
|
||||
</label>
|
||||
<div :class="smallValue ? 'w-50' : 'w-100'">
|
||||
<div class="w-100">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="form-text">
|
||||
<div class="form-text evcc-gray">
|
||||
<div v-if="example">{{ $t("config.form.example") }}: {{ example }}</div>
|
||||
<div v-if="help" v-html="helpHtml"></div>
|
||||
<div v-if="help" v-html="helpHtml" class="text-gray"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -27,7 +27,6 @@ export default {
|
|||
label: String,
|
||||
help: String,
|
||||
optional: Boolean,
|
||||
smallValue: Boolean,
|
||||
example: String,
|
||||
},
|
||||
computed: {
|
||||
|
|
|
|||
406
assets/js/components/Config/MeterModal.vue
Normal file
406
assets/js/components/Config/MeterModal.vue
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
id="meterModal"
|
||||
ref="modal"
|
||||
class="modal fade text-dark"
|
||||
data-bs-backdrop="true"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
data-testid="meter-modal"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
{{ modalTitle }}
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="!meterType">
|
||||
<AddDeviceButton
|
||||
title="Add solar meter"
|
||||
class="mb-4 addButton"
|
||||
@click="selectType('pv')"
|
||||
/>
|
||||
<AddDeviceButton
|
||||
title="Add battery meter"
|
||||
class="addButton"
|
||||
@click="selectType('battery')"
|
||||
/>
|
||||
</div>
|
||||
<form v-else ref="form" class="container mx-0 px-0">
|
||||
<FormRow id="meterTemplate" :label="$t('config.meter.template')">
|
||||
<select
|
||||
id="meterTemplate"
|
||||
v-model="templateName"
|
||||
:disabled="!isNew"
|
||||
class="form-select w-100"
|
||||
>
|
||||
<option
|
||||
v-for="option in templateOptions"
|
||||
:key="option.name"
|
||||
:value="option.template"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
</FormRow>
|
||||
<p v-if="loadingTemplate">Loading ...</p>
|
||||
<Modbus
|
||||
v-if="modbus"
|
||||
v-model:modbus="values.modbus"
|
||||
v-model:id="values.id"
|
||||
v-model:host="values.host"
|
||||
v-model:port="values.port"
|
||||
v-model:device="values.device"
|
||||
v-model:baudrate="values.baudrate"
|
||||
v-model:comset="values.comset"
|
||||
:defaultId="modbus.ID"
|
||||
:defaultComset="modbus.Comset"
|
||||
:defaultBaudrate="modbus.Baudrate"
|
||||
:defaultPort="modbus.Port"
|
||||
:capabilities="modbusCapabilities"
|
||||
/>
|
||||
<FormRow
|
||||
v-for="param in templateParams"
|
||||
:id="`meterParam${param.Name}`"
|
||||
:key="param.Name"
|
||||
:optional="!param.Required"
|
||||
:label="param.Description || `[${param.Name}]`"
|
||||
:help="param.Description === param.Help ? undefined : param.Help"
|
||||
:example="param.Example"
|
||||
>
|
||||
<PropertyField
|
||||
:id="`meterParam${param.Name}`"
|
||||
v-model="values[param.Name]"
|
||||
:masked="param.Mask"
|
||||
:property="param.Name"
|
||||
:type="param.Type"
|
||||
class="me-2"
|
||||
:required="param.Required"
|
||||
:validValues="param.ValidValues"
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<TestResult
|
||||
v-if="templateName"
|
||||
:success="testSuccess"
|
||||
:failed="testFailed"
|
||||
:unknown="testUnknown"
|
||||
:running="testRunning"
|
||||
:result="testResult"
|
||||
:error="testError"
|
||||
@test="testManually"
|
||||
/>
|
||||
|
||||
<div v-if="templateName" class="my-4 d-flex justify-content-between">
|
||||
<button
|
||||
v-if="isDeletable"
|
||||
type="button"
|
||||
class="btn btn-link text-danger"
|
||||
@click.prevent="remove"
|
||||
>
|
||||
{{ $t("config.meter.delete") }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-link text-muted"
|
||||
data-bs-dismiss="modal"
|
||||
>
|
||||
{{ $t("config.meter.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="testRunning || saving"
|
||||
@click.prevent="isNew ? create() : update()"
|
||||
>
|
||||
<span
|
||||
v-if="saving"
|
||||
class="spinner-border spinner-border-sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{
|
||||
testUnknown
|
||||
? $t("config.meter.validateSave")
|
||||
: $t("config.meter.save")
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FormRow from "./FormRow.vue";
|
||||
import PropertyField from "./PropertyField.vue";
|
||||
import TestResult from "./TestResult.vue";
|
||||
import api from "../../api";
|
||||
import test from "./mixins/test";
|
||||
import AddDeviceButton from "./AddDeviceButton.vue";
|
||||
import Modbus from "./Modbus.vue";
|
||||
|
||||
const initialValues = { type: "template" };
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "MeterModal",
|
||||
components: { FormRow, PropertyField, Modbus, TestResult, AddDeviceButton },
|
||||
mixins: [test],
|
||||
props: {
|
||||
id: Number,
|
||||
name: String,
|
||||
type: String,
|
||||
},
|
||||
emits: ["added", "updated", "removed"],
|
||||
data() {
|
||||
return {
|
||||
isModalVisible: false,
|
||||
templates: [],
|
||||
products: [],
|
||||
templateName: null,
|
||||
template: null,
|
||||
saving: false,
|
||||
selectedType: null,
|
||||
loadingTemplate: false,
|
||||
values: { ...initialValues },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
modalTitle() {
|
||||
if (this.isNew) {
|
||||
if (this.meterType) {
|
||||
return this.$t(`config.${this.meterType}.titleAdd`);
|
||||
} else {
|
||||
return this.$t("config.meter.titleChoice");
|
||||
}
|
||||
}
|
||||
return this.$t(`config.${this.meterType}.titleEdit`);
|
||||
},
|
||||
meterType() {
|
||||
return this.type || this.selectedType;
|
||||
},
|
||||
templateOptions() {
|
||||
return this.products;
|
||||
},
|
||||
templateParams() {
|
||||
const params = this.template?.Params || [];
|
||||
return (
|
||||
params
|
||||
// deprecated fields
|
||||
.filter((p) => !p.Deprecated)
|
||||
// remove usage option
|
||||
.filter((p) => p.Name !== "usage")
|
||||
// remove modbus, handles separately
|
||||
.filter((p) => p.Name !== "modbus")
|
||||
// capacity only for battery meters
|
||||
.filter((p) => this.meterType === "battery" || p.Name !== "capacity")
|
||||
);
|
||||
},
|
||||
modbus() {
|
||||
const params = this.template?.Params || [];
|
||||
return params.find((p) => p.Name === "modbus");
|
||||
},
|
||||
modbusCapabilities() {
|
||||
return this.modbus?.Choice || [];
|
||||
},
|
||||
apiData() {
|
||||
return {
|
||||
template: this.templateName,
|
||||
...this.values,
|
||||
usage: this.meterType,
|
||||
};
|
||||
},
|
||||
isNew() {
|
||||
return this.id === undefined;
|
||||
},
|
||||
isDeletable() {
|
||||
return !this.isNew;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isModalVisible(visible) {
|
||||
if (visible) {
|
||||
this.selectedType = null;
|
||||
this.reset();
|
||||
this.resetTest();
|
||||
this.loadProducts();
|
||||
if (this.id !== undefined) {
|
||||
this.loadConfiguration();
|
||||
}
|
||||
}
|
||||
},
|
||||
meterType() {
|
||||
this.loadProducts();
|
||||
},
|
||||
templateName() {
|
||||
this.loadTemplate();
|
||||
},
|
||||
values: {
|
||||
handler() {
|
||||
this.resetTest();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
|
||||
this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
|
||||
},
|
||||
unmounted() {
|
||||
this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
|
||||
this.$refs.modal?.removeEventListener("hide.bs.modal", this.modalInvisible);
|
||||
},
|
||||
methods: {
|
||||
reset() {
|
||||
this.values = { ...initialValues };
|
||||
this.resetTest();
|
||||
},
|
||||
async loadConfiguration() {
|
||||
try {
|
||||
const meter = (await api.get(`config/devices/meter/${this.id}`)).data.result;
|
||||
this.values = meter.config;
|
||||
this.applyDefaultsFromTemplate();
|
||||
this.templateName = this.values.template;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
async loadProducts() {
|
||||
if (!this.isModalVisible || !this.meterType) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const opts = {
|
||||
params: {
|
||||
usage: this.meterType,
|
||||
},
|
||||
};
|
||||
this.products = (await api.get("config/products/meter", opts)).data.result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
async loadTemplate() {
|
||||
this.template = null;
|
||||
this.reset();
|
||||
this.loadingTemplate = true;
|
||||
try {
|
||||
const opts = {
|
||||
params: {
|
||||
lang: this.$i18n?.locale,
|
||||
name: this.templateName,
|
||||
},
|
||||
};
|
||||
const result = await api.get("config/templates/meter", opts);
|
||||
this.template = result.data.result;
|
||||
this.applyDefaultsFromTemplate();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
this.loadingTemplate = false;
|
||||
},
|
||||
applyDefaultsFromTemplate() {
|
||||
const params = this.template?.Params || [];
|
||||
params
|
||||
.filter((p) => p.Default && !this.values[p.Name])
|
||||
.forEach((p) => {
|
||||
this.values[p.Name] = p.Default;
|
||||
});
|
||||
},
|
||||
async create() {
|
||||
if (this.testUnknown) {
|
||||
const success = await this.test(this.testMeter);
|
||||
if (!success) return;
|
||||
await sleep(100);
|
||||
}
|
||||
this.saving = true;
|
||||
try {
|
||||
const response = await api.post("config/devices/meter", this.apiData);
|
||||
const { name } = response.data.result;
|
||||
this.$emit("added", this.meterType, name);
|
||||
this.$emit("updated");
|
||||
this.modalInvisible();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("create failed");
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
async testManually() {
|
||||
await this.test(this.testMeter);
|
||||
},
|
||||
async testMeter() {
|
||||
let url = "config/test/meter";
|
||||
if (!this.isNew) {
|
||||
url += `/merge/${this.id}`;
|
||||
}
|
||||
return await api.post(url, this.apiData);
|
||||
},
|
||||
async update() {
|
||||
if (this.testUnknown) {
|
||||
const success = await this.test(this.testMeter);
|
||||
if (!success) return;
|
||||
await sleep(250);
|
||||
}
|
||||
this.saving = true;
|
||||
try {
|
||||
await api.put(`config/devices/meter/${this.id}`, this.apiData);
|
||||
this.$emit("updated");
|
||||
this.modalInvisible();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("update failed");
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
async remove() {
|
||||
try {
|
||||
await api.delete(`config/devices/meter/${this.id}`);
|
||||
this.$emit("removed", this.meterType, this.name);
|
||||
this.$emit("updated");
|
||||
this.modalInvisible();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("delete failed");
|
||||
}
|
||||
},
|
||||
modalVisible() {
|
||||
this.isModalVisible = true;
|
||||
},
|
||||
modalInvisible() {
|
||||
this.isModalVisible = false;
|
||||
},
|
||||
selectType(type) {
|
||||
this.selectedType = type;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.container {
|
||||
margin-left: calc(var(--bs-gutter-x) * -0.5);
|
||||
margin-right: calc(var(--bs-gutter-x) * -0.5);
|
||||
padding-right: 0;
|
||||
}
|
||||
.addButton {
|
||||
min-height: auto;
|
||||
}
|
||||
</style>
|
||||
252
assets/js/components/Config/Modbus.vue
Normal file
252
assets/js/components/Config/Modbus.vue
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
<template>
|
||||
<FormRow
|
||||
v-if="showConnectionOptions"
|
||||
id="modbusTcpIp"
|
||||
label="Modbus Connection"
|
||||
:help="
|
||||
connection === 'tcpip'
|
||||
? 'The device is addressable from evcc via LAN/Wifi.'
|
||||
: 'The device is diretly connected to evcc via a RS485 interface.'
|
||||
"
|
||||
>
|
||||
<div class="btn-group" role="group">
|
||||
<input
|
||||
id="modbusTcpIp"
|
||||
v-model="connection"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="modbusConnection"
|
||||
value="tcpip"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="btn btn-outline-primary" for="modbusTcpIp">Network</label>
|
||||
<input
|
||||
id="modbusSerial"
|
||||
v-model="connection"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="modbusConnection"
|
||||
value="serial"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="btn btn-outline-primary" for="modbusSerial">Serial / USB</label>
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow id="modbusId" label="Modbus ID">
|
||||
<PropertyField
|
||||
id="modbusId"
|
||||
property="id"
|
||||
type="Number"
|
||||
class="me-2"
|
||||
required
|
||||
:model-value="id || defaultId || 1"
|
||||
@change="$emit('update:id', $event.target.value)"
|
||||
/>
|
||||
</FormRow>
|
||||
<div v-if="connection === 'tcpip'">
|
||||
<FormRow id="modbusHost" label="IP address or hostname" help="Example: 192.0.2.2">
|
||||
<PropertyField
|
||||
id="modbusHost"
|
||||
property="host"
|
||||
type="String"
|
||||
class="me-2"
|
||||
required
|
||||
:model-value="host"
|
||||
@change="$emit('update:host', $event.target.value)"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="modbusPort" label="Port">
|
||||
<PropertyField
|
||||
id="modbusPort"
|
||||
property="port"
|
||||
type="Number"
|
||||
class="me-2 w-50"
|
||||
required
|
||||
:model-value="port || defaultPort || 502"
|
||||
@change="$emit('update:port', $event.target.value)"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow
|
||||
v-if="showProtocolOptions"
|
||||
id="modbusTcp"
|
||||
label="Modbus Protocol"
|
||||
:help="
|
||||
protocol === 'tcp'
|
||||
? 'Device has native LAN/Wifi support or is connected through a RS485 to Ethernet adapter with protocol translation.'
|
||||
: 'Connection through a RS485 to Ethernet adapter without protocol translation.'
|
||||
"
|
||||
>
|
||||
<div class="btn-group" role="group">
|
||||
<input
|
||||
id="modbusTcp"
|
||||
v-model="protocol"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="modbusProtocol"
|
||||
value="tcp"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="btn btn-outline-primary" for="modbusTcp">TCP</label>
|
||||
<input
|
||||
id="modbusRtu"
|
||||
v-model="protocol"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="modbusProtocol"
|
||||
value="rtu"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="btn btn-outline-primary" for="modbusRtu">RTU</label>
|
||||
</div>
|
||||
</FormRow>
|
||||
</div>
|
||||
<div v-else>
|
||||
<FormRow id="modbusDevice" label="Device name" help="Example: /dev/ttyUSB0">
|
||||
<PropertyField
|
||||
id="modbusDevice"
|
||||
property="device"
|
||||
type="String"
|
||||
class="me-2"
|
||||
required
|
||||
:model-value="device"
|
||||
@change="$emit('update:device', $event.target.value)"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="modbusBaudrate" label="Baudrate">
|
||||
<PropertyField
|
||||
id="modbusBaudrate"
|
||||
property="baudrate"
|
||||
type="Number"
|
||||
class="me-2 w-50"
|
||||
:valid-values="baudrateOptions"
|
||||
required
|
||||
:model-value="baudrate || defaultBaudrate"
|
||||
@change="$emit('update:baudrate', $event.target.value)"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="modbusComset" label="ComSet">
|
||||
<PropertyField
|
||||
id="modbusComset"
|
||||
property="comset"
|
||||
type="String"
|
||||
class="me-2 w-50"
|
||||
:valid-values="comsetOptions"
|
||||
required
|
||||
:model-value="comset || defaultComset || '8N1'"
|
||||
@change="$emit('update:comset', $event.target.value)"
|
||||
/>
|
||||
</FormRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import FormRow from "./FormRow.vue";
|
||||
import PropertyField from "./PropertyField.vue";
|
||||
import type { PropType } from "vue";
|
||||
|
||||
type Capability = "rs485" | "tcpip";
|
||||
type Modbus = "rs485serial" | "rs485tcpip" | "tcpip";
|
||||
type ConnectionOption = "tcpip" | "serial";
|
||||
type ProtocolOption = "tcp" | "rtu";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Modbus",
|
||||
components: { FormRow, PropertyField },
|
||||
props: {
|
||||
capabilities: {
|
||||
type: Array as PropType<Capability[]>,
|
||||
default: () => [],
|
||||
},
|
||||
modbus: String as PropType<Modbus>,
|
||||
host: String,
|
||||
port: [Number, String],
|
||||
id: [Number, String],
|
||||
baudrate: [Number, String],
|
||||
comset: String,
|
||||
device: String,
|
||||
defaultPort: Number,
|
||||
defaultId: Number,
|
||||
defaultComset: String,
|
||||
defaultBaudrate: Number,
|
||||
},
|
||||
emits: [
|
||||
"update:modbus",
|
||||
"update:id",
|
||||
"update:host",
|
||||
"update:port",
|
||||
"update:device",
|
||||
"update:baudrate",
|
||||
"update:comset",
|
||||
],
|
||||
data(): { connection: ConnectionOption; protocol: ProtocolOption } {
|
||||
return {
|
||||
connection: "tcpip",
|
||||
protocol: "tcp",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selectedModbus(): Modbus {
|
||||
if (this.connection === "serial") {
|
||||
return "rs485serial";
|
||||
}
|
||||
return this.protocol === "rtu" ? "rs485tcpip" : "tcpip";
|
||||
},
|
||||
showConnectionOptions() {
|
||||
return this.capabilities.includes("rs485");
|
||||
},
|
||||
showProtocolOptions() {
|
||||
return this.connection === "tcpip" && this.capabilities.includes("rs485");
|
||||
},
|
||||
comsetOptions() {
|
||||
return ["8N1", "8E1"].map((v) => {
|
||||
return { key: v, name: v };
|
||||
});
|
||||
},
|
||||
baudrateOptions() {
|
||||
return [1200, 9600, 19200, 38400, 57600, 115200].map((v) => {
|
||||
return { key: v, name: `${v}` };
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedModbus(newValue: Modbus) {
|
||||
this.$emit("update:modbus", newValue);
|
||||
},
|
||||
options(newValue: Capability[]) {
|
||||
this.setProtocolByCapabilities(newValue);
|
||||
this.$emit("update:modbus", this.selectedModbus);
|
||||
},
|
||||
modbus(newValue: Modbus) {
|
||||
if (newValue) {
|
||||
this.setConnectionAndProtocolByModbus(newValue);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setConnectionAndProtocolByModbus(this.modbus);
|
||||
this.$emit("update:modbus", this.selectedModbus);
|
||||
},
|
||||
methods: {
|
||||
setProtocolByCapabilities(capabilities: Capability[]) {
|
||||
this.protocol = capabilities.includes("tcpip") ? "tcp" : "rtu";
|
||||
},
|
||||
setConnectionAndProtocolByModbus(modbus?: Modbus) {
|
||||
switch (modbus) {
|
||||
case "rs485serial":
|
||||
this.connection = "serial";
|
||||
this.protocol = "rtu";
|
||||
break;
|
||||
case "rs485tcpip":
|
||||
this.connection = "tcpip";
|
||||
this.protocol = "rtu";
|
||||
break;
|
||||
case "tcpip":
|
||||
this.connection = "tcpip";
|
||||
this.protocol = "tcp";
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,19 +1,18 @@
|
|||
<template>
|
||||
<div v-if="unit" class="input-group w-100">
|
||||
<div v-if="unit" class="input-group" :class="short ? 'w-50' : ''">
|
||||
<input
|
||||
:id="id"
|
||||
v-model="value"
|
||||
type="number"
|
||||
step="any"
|
||||
:type="inputType"
|
||||
:step="step"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
aria-label="unit"
|
||||
:aria-describedby="id + '_unit'"
|
||||
class="form-control"
|
||||
/>
|
||||
<span :id="id + '_unit'" class="input-group-text">{{ unit }}</span>
|
||||
</div>
|
||||
<div v-else-if="icons" :id="id" class="d-flex flex-wrap">
|
||||
<div v-else-if="icons" class="d-flex flex-wrap">
|
||||
<div
|
||||
v-for="{ key } in selectOptions"
|
||||
v-show="key === value || selectMode"
|
||||
|
|
@ -24,17 +23,23 @@
|
|||
:id="`icon_${key}`"
|
||||
v-model="value"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
:class="selectMode ? 'btn-check' : 'd-none'"
|
||||
:name="property"
|
||||
autocomplete="off"
|
||||
:required="required"
|
||||
:value="key"
|
||||
@click="toggleSelectMode"
|
||||
/>
|
||||
<label class="btn btn-outline-secondary" :for="`icon_${key}`" :aria-label="key">
|
||||
<label
|
||||
class="btn btn-outline-secondary"
|
||||
:class="key === value ? 'active' : ''"
|
||||
:for="`icon_${key}`"
|
||||
:aria-label="key"
|
||||
>
|
||||
<VehicleIcon :name="key" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!selectMode" class="me-2 mb-2 d-flex align-items-end">
|
||||
<a :id="id" class="text-muted" href="#" @click.prevent="toggleSelectMode">change</a>
|
||||
</div>
|
||||
</div>
|
||||
<select v-else-if="select" :id="id" v-model="value" class="form-select">
|
||||
<option v-if="!required" value="">---</option>
|
||||
|
|
@ -47,7 +52,7 @@
|
|||
:id="id"
|
||||
v-model="value"
|
||||
class="form-control"
|
||||
:type="type"
|
||||
:type="inputType"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
rows="4"
|
||||
|
|
@ -57,7 +62,9 @@
|
|||
:id="id"
|
||||
v-model="value"
|
||||
class="form-control"
|
||||
:type="type"
|
||||
:class="short ? 'w-50' : ''"
|
||||
:type="inputType"
|
||||
:step="step"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
/>
|
||||
|
|
@ -74,6 +81,7 @@ export default {
|
|||
property: String,
|
||||
masked: Boolean,
|
||||
placeholder: String,
|
||||
type: String,
|
||||
required: Boolean,
|
||||
validValues: { type: Array, default: () => [] },
|
||||
modelValue: [String, Number, Boolean, Object],
|
||||
|
|
@ -83,8 +91,23 @@ export default {
|
|||
return { selectMode: false };
|
||||
},
|
||||
computed: {
|
||||
type() {
|
||||
return this.masked ? "password" : "text";
|
||||
inputType() {
|
||||
if (this.masked) {
|
||||
return "password";
|
||||
}
|
||||
if (["Number", "Float"].includes(this.type)) {
|
||||
return "number";
|
||||
}
|
||||
return "text";
|
||||
},
|
||||
short() {
|
||||
return ["Number", "Float", "Duration"].includes(this.type);
|
||||
},
|
||||
step() {
|
||||
if (this.type === "Float") {
|
||||
return "any";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
unit() {
|
||||
if (this.property === "capacity") {
|
||||
|
|
@ -102,6 +125,12 @@ export default {
|
|||
return this.validValues.length > 0;
|
||||
},
|
||||
selectOptions() {
|
||||
// If the valid values are already in the correct format, return them
|
||||
if (this.validValues[0].key && this.validValues[0].name) {
|
||||
return this.validValues;
|
||||
}
|
||||
|
||||
// Otherwise, convert them to the correct format
|
||||
return this.validValues.map((value) => ({
|
||||
key: value,
|
||||
name: this.$t(`config.options.${this.property}.${value}`),
|
||||
|
|
|
|||
120
assets/js/components/Config/SiteSettings.vue
Normal file
120
assets/js/components/Config/SiteSettings.vue
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<div class="group p-4 pb-2">
|
||||
<form class="container mx-0 px-0">
|
||||
<FormRow id="siteTitle" label="Site title">
|
||||
<input
|
||||
id="siteTitle"
|
||||
v-model="modifiedFormData.title"
|
||||
class="form-control"
|
||||
placeholder="Home"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="adminPassword" label="Admin password">
|
||||
<input
|
||||
id="adminPassword"
|
||||
type="password"
|
||||
value="- not set -"
|
||||
class="form-control"
|
||||
disabled
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="sponsorToken" label="Sponsor token">
|
||||
<textarea id="sponsorToken" class="form-control" rows="3" disabled value="tba" />
|
||||
</FormRow>
|
||||
<div class="my-4 d-flex justify-content-end">
|
||||
<button type="reset" class="btn btn-link text-muted" @click.prevent="reset">
|
||||
{{ $t("config.site.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="!saveNeeded || saving"
|
||||
@click.prevent="save"
|
||||
>
|
||||
<span
|
||||
v-if="saving"
|
||||
class="spinner-border spinner-border-sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{ $t("config.site.save") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../../api";
|
||||
import FormRow from "../FormRow.vue";
|
||||
|
||||
export default {
|
||||
name: "SiteSettings",
|
||||
components: { FormRow },
|
||||
data() {
|
||||
return {
|
||||
originalFormData: { title: "" },
|
||||
modifiedFormData: { title: "" },
|
||||
saving: false,
|
||||
};
|
||||
},
|
||||
emits: ["site-changed"],
|
||||
async mounted() {
|
||||
await this.load();
|
||||
},
|
||||
computed: {
|
||||
saveNeeded() {
|
||||
return JSON.stringify(this.modifiedFormData) !== JSON.stringify(this.originalFormData);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async load() {
|
||||
try {
|
||||
const { data } = await api.get("/config/site");
|
||||
this.originalFormData.title = data.result.title;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
this.modifiedFormData = { ...this.originalFormData };
|
||||
},
|
||||
async save() {
|
||||
this.saving = true;
|
||||
try {
|
||||
await api.put("/config/site", this.modifiedFormData);
|
||||
this.$emit("site-changed");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
this.saving = false;
|
||||
await this.load();
|
||||
},
|
||||
reset() {
|
||||
this.modifiedFormData = { ...this.originalFormData };
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.group {
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0 0 0 var(--evcc-gray-50);
|
||||
color: var(--evcc-default-text);
|
||||
background: var(--evcc-box);
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
display: block;
|
||||
list-style-type: none;
|
||||
min-height: 10rem;
|
||||
margin-bottom: 5rem;
|
||||
border: 1px solid var(--evcc-gray-50);
|
||||
transition: box-shadow var(--evcc-transition-fast) linear;
|
||||
}
|
||||
|
||||
.group:hover {
|
||||
border-color: var(--evcc-gray);
|
||||
}
|
||||
|
||||
.group:focus-within {
|
||||
box-shadow: 0 0 1rem 0 var(--evcc-gray-50);
|
||||
}
|
||||
</style>
|
||||
56
assets/js/components/Config/TestResult.vue
Normal file
56
assets/js/components/Config/TestResult.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<div
|
||||
class="alert my-4"
|
||||
:class="{
|
||||
'alert-secondary': unknown || running,
|
||||
'alert-success': success,
|
||||
'alert-danger': failed,
|
||||
}"
|
||||
role="alert"
|
||||
>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
{{ $t("config.validation.label") }}:
|
||||
<span v-if="unknown">{{ $t("config.validation.unknown") }}</span>
|
||||
<span v-if="running">{{ $t("config.validation.running") }}</span>
|
||||
<strong v-if="success">{{ $t("config.validation.success") }}</strong>
|
||||
<strong v-if="failed">{{ $t("config.validation.failed") }}</strong>
|
||||
</div>
|
||||
<span
|
||||
v-if="running"
|
||||
class="spinner-border spinner-border-sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<a v-else href="#" class="alert-link" @click.prevent="$emit('test')">
|
||||
{{ $t("config.validation.validate") }}
|
||||
</a>
|
||||
</div>
|
||||
<hr v-if="error" />
|
||||
<div v-if="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
<hr v-if="result" />
|
||||
<div v-if="result">
|
||||
<DeviceTags :tags="result" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DeviceTags from "./DeviceTags.vue";
|
||||
|
||||
export default {
|
||||
name: "TestResult",
|
||||
props: {
|
||||
success: Boolean,
|
||||
failed: Boolean,
|
||||
unknown: Boolean,
|
||||
running: Boolean,
|
||||
result: Object,
|
||||
error: String,
|
||||
},
|
||||
emits: ["test"],
|
||||
components: { DeviceTags },
|
||||
};
|
||||
</script>
|
||||
126
assets/js/components/Config/UserInterfaceSettings.vue
Normal file
126
assets/js/components/Config/UserInterfaceSettings.vue
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<template>
|
||||
<SponsorTokenExpires :sponsorTokenExpires="sponsorTokenExpires" />
|
||||
<div class="container mx-0 px-0">
|
||||
<FormRow id="settingsDesign" :label="$t('settings.theme.label')">
|
||||
<SelectGroup
|
||||
id="settingsDesign"
|
||||
v-model="theme"
|
||||
class="w-100"
|
||||
:options="
|
||||
THEMES.map((value) => ({
|
||||
value,
|
||||
name: $t(`settings.theme.${value}`),
|
||||
}))
|
||||
"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="settingsLanguage" :label="$t('settings.language.label')">
|
||||
<select
|
||||
id="settingsLanguage"
|
||||
v-model="language"
|
||||
class="form-select form-select-sm w-75"
|
||||
>
|
||||
<option value="">{{ $t("settings.language.auto") }}</option>
|
||||
<option v-for="option in languageOptions" :key="option" :value="option.value">
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow id="settingsUnit" :label="$t('settings.unit.label')">
|
||||
<SelectGroup
|
||||
id="settingsUnit"
|
||||
v-model="unit"
|
||||
class="w-75"
|
||||
:options="
|
||||
UNITS.map((value) => ({
|
||||
value,
|
||||
name: $t(`settings.unit.${value}`),
|
||||
}))
|
||||
"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')">
|
||||
<TelemetrySettings :sponsor="sponsor" class="mt-1 mb-0" />
|
||||
</FormRow>
|
||||
<FormRow id="hiddenFeaturesEnabled" :label="`${$t('settings.hiddenFeatures.label')} 🧪`">
|
||||
<div class="form-check form-switch my-1">
|
||||
<input
|
||||
id="hiddenFeaturesEnabled"
|
||||
v-model="hiddenFeatures"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<div class="form-check-label">
|
||||
<label for="hiddenFeaturesEnabled">
|
||||
{{ $t("settings.hiddenFeatures.value") }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</FormRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TelemetrySettings from "../TelemetrySettings.vue";
|
||||
import SponsorTokenExpires from "../SponsorTokenExpires.vue";
|
||||
import FormRow from "../FormRow.vue";
|
||||
import SelectGroup from "../SelectGroup.vue";
|
||||
import {
|
||||
getLocalePreference,
|
||||
setLocalePreference,
|
||||
LOCALES,
|
||||
removeLocalePreference,
|
||||
} from "../../i18n";
|
||||
import { getThemePreference, setThemePreference, THEMES } from "../../theme";
|
||||
import { getUnits, setUnits, UNITS } from "../../units";
|
||||
import { getHiddenFeatures, setHiddenFeatures } from "../../featureflags";
|
||||
|
||||
export default {
|
||||
name: "UserInterfaceSettings",
|
||||
components: { TelemetrySettings, FormRow, SelectGroup, SponsorTokenExpires },
|
||||
props: {
|
||||
sponsor: String,
|
||||
sponsorTokenExpires: Number,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
theme: getThemePreference(),
|
||||
language: getLocalePreference() || "",
|
||||
unit: getUnits(),
|
||||
hiddenFeatures: getHiddenFeatures(),
|
||||
THEMES,
|
||||
UNITS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
languageOptions: () => {
|
||||
const locales = Object.entries(LOCALES).map(([key, value]) => {
|
||||
return { value: key, name: value[1] };
|
||||
});
|
||||
// sort by name
|
||||
locales.sort((a, b) => (a.name < b.name ? -1 : 1));
|
||||
return locales;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
unit(value) {
|
||||
setUnits(value);
|
||||
},
|
||||
theme(value) {
|
||||
setThemePreference(value);
|
||||
},
|
||||
hiddenFeatures(value) {
|
||||
setHiddenFeatures(value);
|
||||
},
|
||||
language(value) {
|
||||
const i18n = this.$root.$i18n;
|
||||
if (value) {
|
||||
setLocalePreference(i18n, value);
|
||||
} else {
|
||||
removeLocalePreference(i18n);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
data-testid="vehicle-modal"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
|
|
@ -30,7 +31,6 @@
|
|||
v-model="templateName"
|
||||
:disabled="!isNew"
|
||||
class="form-select w-100"
|
||||
@change="templateChanged"
|
||||
>
|
||||
<option value="offline">
|
||||
{{ $t("config.vehicle.offline") }}
|
||||
|
|
@ -72,7 +72,6 @@
|
|||
:optional="!param.Required"
|
||||
:label="param.Description || `[${param.Name}]`"
|
||||
:help="param.Description === param.Help ? undefined : param.Help"
|
||||
:small-value="['capacity'].includes(param.Name)"
|
||||
:example="param.Example"
|
||||
>
|
||||
<PropertyField
|
||||
|
|
@ -80,77 +79,58 @@
|
|||
v-model="values[param.Name]"
|
||||
:masked="param.Mask"
|
||||
:property="param.Name"
|
||||
:type="param.Type"
|
||||
class="me-2"
|
||||
:required="param.Required"
|
||||
:validValues="param.ValidValues"
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div
|
||||
<TestResult
|
||||
v-if="templateName"
|
||||
class="alert my-4"
|
||||
:class="{
|
||||
'alert-secondary': testUnknown || testRunning,
|
||||
'alert-success': testSuccess,
|
||||
'alert-danger': testFailed,
|
||||
}"
|
||||
role="alert"
|
||||
>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
{{ $t("config.validation.label") }}:
|
||||
<span v-if="testUnknown">{{
|
||||
$t("config.validation.unknown")
|
||||
}}</span>
|
||||
<span v-if="testRunning">{{
|
||||
$t("config.validation.running")
|
||||
}}</span>
|
||||
<strong v-if="testSuccess">{{
|
||||
$t("config.validation.success")
|
||||
}}</strong>
|
||||
<strong v-if="testFailed">{{
|
||||
$t("config.validation.failed")
|
||||
}}</strong>
|
||||
</div>
|
||||
<a href="#" class="alert-link" @click.prevent="test">
|
||||
{{ $t("config.validation.validate") }}
|
||||
</a>
|
||||
</div>
|
||||
<hr v-if="testResult" />
|
||||
<div v-if="testResult">
|
||||
{{ testResult }}
|
||||
</div>
|
||||
</div>
|
||||
:success="testSuccess"
|
||||
:failed="testFailed"
|
||||
:unknown="testUnknown"
|
||||
:running="testRunning"
|
||||
:result="testResult"
|
||||
:error="testError"
|
||||
@test="testManually"
|
||||
/>
|
||||
|
||||
<div v-if="templateName" class="my-4">
|
||||
<div v-if="templateName" class="my-4 d-flex justify-content-between">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary me-3"
|
||||
:disabled="testRunning"
|
||||
@click.prevent="isNew ? create() : update()"
|
||||
v-if="isDeletable"
|
||||
type="button"
|
||||
class="btn btn-link text-danger"
|
||||
@click.prevent="remove"
|
||||
>
|
||||
{{
|
||||
testUnknown
|
||||
? $t("config.vehicle.validateSave")
|
||||
: $t("config.vehicle.save")
|
||||
}}
|
||||
{{ $t("config.vehicle.delete") }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-link text-muted"
|
||||
data-bs-dismiss="modal"
|
||||
>
|
||||
{{ $t("config.vehicle.cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isDeletable" class="text-center mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link text-danger"
|
||||
@click.prevent="remove"
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="testRunning || saving"
|
||||
@click.prevent="isNew ? create() : update()"
|
||||
>
|
||||
{{ $t("config.vehicle.delete") }}
|
||||
<span
|
||||
v-if="saving"
|
||||
class="spinner-border spinner-border-sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{
|
||||
testUnknown
|
||||
? $t("config.vehicle.validateSave")
|
||||
: $t("config.vehicle.save")
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -164,22 +144,20 @@
|
|||
<script>
|
||||
import FormRow from "./FormRow.vue";
|
||||
import PropertyField from "./PropertyField.vue";
|
||||
import TestResult from "./TestResult.vue";
|
||||
import api from "../../api";
|
||||
import test from "./mixins/test";
|
||||
|
||||
const initialValues = { type: "template", icon: "car" };
|
||||
|
||||
const TEST_UNKNOWN = "unknown";
|
||||
const TEST_SUCCESS = "success";
|
||||
const TEST_FAILED = "failed";
|
||||
const TEST_RUNNING = "running";
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "VehicleModal",
|
||||
components: { FormRow, PropertyField },
|
||||
components: { FormRow, PropertyField, TestResult },
|
||||
mixins: [test],
|
||||
props: {
|
||||
id: Number,
|
||||
},
|
||||
|
|
@ -189,26 +167,13 @@ export default {
|
|||
isModalVisible: false,
|
||||
templates: [],
|
||||
products: [],
|
||||
saving: false,
|
||||
templateName: null,
|
||||
template: null,
|
||||
values: { ...initialValues },
|
||||
testResult: "",
|
||||
testState: TEST_UNKNOWN,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
testRunning() {
|
||||
return this.testState === TEST_RUNNING;
|
||||
},
|
||||
testSuccess() {
|
||||
return this.testState === TEST_SUCCESS;
|
||||
},
|
||||
testFailed() {
|
||||
return this.testState === TEST_FAILED;
|
||||
},
|
||||
testUnknown() {
|
||||
return this.testState === TEST_UNKNOWN;
|
||||
},
|
||||
templateOptions() {
|
||||
return {
|
||||
online: this.products.filter((p) => !p.group && p.template !== "offline"),
|
||||
|
|
@ -275,10 +240,6 @@ export default {
|
|||
this.values = { ...initialValues };
|
||||
this.resetTest();
|
||||
},
|
||||
resetTest() {
|
||||
this.testState = TEST_UNKNOWN;
|
||||
this.testResult = null;
|
||||
},
|
||||
async loadConfiguration() {
|
||||
try {
|
||||
const vehicle = (await api.get(`config/devices/vehicle/${this.id}`)).data.result;
|
||||
|
|
@ -297,10 +258,12 @@ export default {
|
|||
}
|
||||
},
|
||||
async loadTemplate() {
|
||||
this.template = null;
|
||||
this.reset();
|
||||
try {
|
||||
const opts = {
|
||||
params: {
|
||||
lang: this.$i18n.locale,
|
||||
lang: this.$i18n?.locale,
|
||||
name: this.templateName,
|
||||
},
|
||||
};
|
||||
|
|
@ -310,9 +273,6 @@ export default {
|
|||
console.error(e);
|
||||
}
|
||||
},
|
||||
templateChanged() {
|
||||
this.reset();
|
||||
},
|
||||
applyDefaultsFromTemplate() {
|
||||
const params = this.template?.Params || [];
|
||||
params
|
||||
|
|
@ -321,31 +281,13 @@ export default {
|
|||
this.values[p.Name] = p.Default;
|
||||
});
|
||||
},
|
||||
async test() {
|
||||
if (!this.$refs.form.reportValidity()) return false;
|
||||
this.testState = TEST_RUNNING;
|
||||
try {
|
||||
let url = "config/test/vehicle";
|
||||
if (!this.isNew) {
|
||||
url += `/${this.id}`;
|
||||
}
|
||||
await api.post(url, this.apiData);
|
||||
this.testState = TEST_SUCCESS;
|
||||
this.testResult = null;
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.testState = TEST_FAILED;
|
||||
this.testResult = e.response?.data?.error || e.message;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async create() {
|
||||
if (this.testUnknown) {
|
||||
const success = await this.test();
|
||||
const success = await this.test(this.testVehicle);
|
||||
if (!success) return;
|
||||
await sleep(250);
|
||||
}
|
||||
this.saving = true;
|
||||
try {
|
||||
await api.post("config/devices/vehicle", this.apiData);
|
||||
this.$emit("vehicle-changed");
|
||||
|
|
@ -354,13 +296,25 @@ export default {
|
|||
console.error(e);
|
||||
alert("create failed");
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
async testManually() {
|
||||
await this.test(this.testVehicle);
|
||||
},
|
||||
async testVehicle() {
|
||||
let url = "config/test/vehicle";
|
||||
if (!this.isNew) {
|
||||
url += `/merge/${this.id}`;
|
||||
}
|
||||
return await api.post(url, this.apiData);
|
||||
},
|
||||
async update() {
|
||||
if (this.testUnknown) {
|
||||
const success = await this.test();
|
||||
const success = await this.test(this.testVehicle);
|
||||
if (!success) return;
|
||||
await sleep(250);
|
||||
}
|
||||
this.saving = true;
|
||||
try {
|
||||
await api.put(`config/devices/vehicle/${this.id}`, this.apiData);
|
||||
this.$emit("vehicle-changed");
|
||||
|
|
@ -369,6 +323,7 @@ export default {
|
|||
console.error(e);
|
||||
alert("update failed");
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
async remove() {
|
||||
try {
|
||||
|
|
|
|||
61
assets/js/components/Config/mixins/test.js
Normal file
61
assets/js/components/Config/mixins/test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
const TEST_UNKNOWN = "unknown";
|
||||
const TEST_SUCCESS = "success";
|
||||
const TEST_FAILED = "failed";
|
||||
const TEST_RUNNING = "running";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
testState: TEST_UNKNOWN,
|
||||
testError: null,
|
||||
testResult: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
testRunning() {
|
||||
return this.testState === TEST_RUNNING;
|
||||
},
|
||||
testSuccess() {
|
||||
return this.testState === TEST_SUCCESS;
|
||||
},
|
||||
testFailed() {
|
||||
return this.testState === TEST_FAILED;
|
||||
},
|
||||
testUnknown() {
|
||||
return this.testState === TEST_UNKNOWN;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
resetTest() {
|
||||
this.testState = TEST_UNKNOWN;
|
||||
this.testError = null;
|
||||
this.testResult = null;
|
||||
},
|
||||
|
||||
async test(testApi) {
|
||||
if (!this.$refs.form.reportValidity()) return false;
|
||||
this.testState = TEST_RUNNING;
|
||||
try {
|
||||
const res = await testApi();
|
||||
for (const [key, { error }] of Object.entries(res.data.result)) {
|
||||
if (error) {
|
||||
this.testState = TEST_FAILED;
|
||||
this.testResult = null;
|
||||
this.testError = `${key}: ${error}`;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.testResult = res.data.result;
|
||||
this.testError = null;
|
||||
this.testState = TEST_SUCCESS;
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.testState = TEST_FAILED;
|
||||
this.testResult = null;
|
||||
this.testError = e.response?.data?.error || e.message;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -20,73 +20,10 @@
|
|||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<SponsorTokenExpires :sponsorTokenExpires="sponsorTokenExpires" />
|
||||
<div class="container mx-0 px-0">
|
||||
<FormRow id="settingsDesign" :label="$t('settings.theme.label')">
|
||||
<SelectGroup
|
||||
id="settingsDesign"
|
||||
v-model="theme"
|
||||
class="w-100"
|
||||
:options="
|
||||
THEMES.map((value) => ({
|
||||
value,
|
||||
name: $t(`settings.theme.${value}`),
|
||||
}))
|
||||
"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="settingsLanguage" :label="$t('settings.language.label')">
|
||||
<select
|
||||
id="settingsLanguage"
|
||||
v-model="language"
|
||||
class="form-select form-select-sm w-75"
|
||||
>
|
||||
<option value="">{{ $t("settings.language.auto") }}</option>
|
||||
<option
|
||||
v-for="option in languageOptions"
|
||||
:key="option"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow id="settingsUnit" :label="$t('settings.unit.label')">
|
||||
<SelectGroup
|
||||
id="settingsUnit"
|
||||
v-model="unit"
|
||||
class="w-75"
|
||||
:options="
|
||||
UNITS.map((value) => ({
|
||||
value,
|
||||
name: $t(`settings.unit.${value}`),
|
||||
}))
|
||||
"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')">
|
||||
<TelemetrySettings :sponsor="sponsor" class="mt-1 mb-0" />
|
||||
</FormRow>
|
||||
<FormRow
|
||||
id="hiddenFeaturesEnabled"
|
||||
:label="`${$t('settings.hiddenFeatures.label')} 🧪`"
|
||||
>
|
||||
<div class="form-check form-switch col-form-label">
|
||||
<input
|
||||
id="hiddenFeaturesEnabled"
|
||||
v-model="hiddenFeatures"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<div class="form-check-label">
|
||||
<label for="hiddenFeaturesEnabled">
|
||||
{{ $t("settings.hiddenFeatures.value") }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</FormRow>
|
||||
</div>
|
||||
<UserInterfaceSettings
|
||||
:sponsor="sponsor"
|
||||
:sponsorTokenExpires="sponsorTokenExpires"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -95,60 +32,14 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import TelemetrySettings from "./TelemetrySettings.vue";
|
||||
import SponsorTokenExpires from "./SponsorTokenExpires.vue";
|
||||
import FormRow from "./FormRow.vue";
|
||||
import SelectGroup from "./SelectGroup.vue";
|
||||
import { getLocalePreference, setLocalePreference, LOCALES, removeLocalePreference } from "../i18n";
|
||||
import { getThemePreference, setThemePreference, THEMES } from "../theme";
|
||||
import { getUnits, setUnits, UNITS } from "../units";
|
||||
import { getHiddenFeatures, setHiddenFeatures } from "../featureflags";
|
||||
import UserInterfaceSettings from "./Config/UserInterfaceSettings.vue";
|
||||
|
||||
export default {
|
||||
name: "GlobalSettingsModal",
|
||||
components: { TelemetrySettings, FormRow, SelectGroup, SponsorTokenExpires },
|
||||
components: { UserInterfaceSettings },
|
||||
props: {
|
||||
sponsor: String,
|
||||
sponsorTokenExpires: Number,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
theme: getThemePreference(),
|
||||
language: getLocalePreference() || "",
|
||||
unit: getUnits(),
|
||||
hiddenFeatures: getHiddenFeatures(),
|
||||
THEMES,
|
||||
UNITS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
languageOptions: () => {
|
||||
const locales = Object.entries(LOCALES).map(([key, value]) => {
|
||||
return { value: key, name: value[1] };
|
||||
});
|
||||
// sort by name
|
||||
locales.sort((a, b) => (a.name < b.name ? -1 : 1));
|
||||
return locales;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
unit(value) {
|
||||
setUnits(value);
|
||||
},
|
||||
theme(value) {
|
||||
setThemePreference(value);
|
||||
},
|
||||
hiddenFeatures(value) {
|
||||
setHiddenFeatures(value);
|
||||
},
|
||||
language(value) {
|
||||
const i18n = this.$root.$i18n;
|
||||
if (value) {
|
||||
setLocalePreference(i18n, value);
|
||||
} else {
|
||||
removeLocalePreference(i18n);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
45
assets/js/components/TopHeader.vue
Normal file
45
assets/js/components/TopHeader.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<header class="d-flex justify-content-between align-items-center py-3">
|
||||
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate">
|
||||
<router-link class="evcc-default-text" to="/" data-testid="home-link">
|
||||
<shopicon-regular-home size="s" class="home"></shopicon-regular-home>
|
||||
</router-link>
|
||||
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
|
||||
<span class="text-truncate">{{ title }}</span>
|
||||
</h1>
|
||||
<TopNavigation v-bind="topNavigation" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/home";
|
||||
import TopNavigation from "./TopNavigation.vue";
|
||||
import collector from "../mixins/collector";
|
||||
import store from "../store";
|
||||
|
||||
export default {
|
||||
name: "TopHeader",
|
||||
mixins: [collector],
|
||||
components: {
|
||||
TopNavigation,
|
||||
},
|
||||
props: {
|
||||
title: String,
|
||||
},
|
||||
computed: {
|
||||
topNavigation: function () {
|
||||
const vehicleLogins = store.state.auth ? store.state.auth.vehicles : {};
|
||||
return { vehicleLogins, ...this.collectProps(TopNavigation, store.state) };
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
position: relative;
|
||||
top: -3px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -42,7 +42,7 @@ export default {
|
|||
if (withUnit) {
|
||||
unit = kw ? " kW" : " W";
|
||||
}
|
||||
return `${new Intl.NumberFormat(this.$i18n.locale, {
|
||||
return `${new Intl.NumberFormat(this.$i18n?.locale, {
|
||||
style: "decimal",
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
|
|
@ -53,7 +53,7 @@ export default {
|
|||
},
|
||||
fmtNumber: function (number, decimals, unit) {
|
||||
const style = unit ? "unit" : "decimal";
|
||||
return new Intl.NumberFormat(this.$i18n.locale, {
|
||||
return new Intl.NumberFormat(this.$i18n?.locale, {
|
||||
style,
|
||||
unit,
|
||||
minimumFractionDigits: decimals,
|
||||
|
|
@ -73,7 +73,7 @@ export default {
|
|||
return Math.abs(val) >= this.fmtLimit ? "k" : "";
|
||||
},
|
||||
fmtNumberToLocale(val, pad = 0) {
|
||||
return val.toLocaleString(this.$i18n.locale).padStart(pad, "0");
|
||||
return val.toLocaleString(this.$i18n?.locale).padStart(pad, "0");
|
||||
},
|
||||
fmtDurationNs(duration = 0, withUnit = true, minUnit = "s") {
|
||||
return this.fmtDuration(duration / 1e9, withUnit, minUnit);
|
||||
|
|
@ -124,7 +124,7 @@ export default {
|
|||
return tomorrow.toDateString() === date.toDateString();
|
||||
},
|
||||
weekdayPrefix: function (date) {
|
||||
const rtf = new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: "auto" });
|
||||
const rtf = new Intl.RelativeTimeFormat(this.$i18n?.locale, { numeric: "auto" });
|
||||
|
||||
if (this.isToday(date)) {
|
||||
return rtf.formatToParts(0, "day")[0].value;
|
||||
|
|
@ -132,25 +132,25 @@ export default {
|
|||
if (this.isTomorrow(date)) {
|
||||
return rtf.formatToParts(1, "day")[0].value;
|
||||
}
|
||||
return new Intl.DateTimeFormat(this.$i18n.locale, {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
weekday: "short",
|
||||
}).format(date);
|
||||
},
|
||||
weekdayTime: function (date) {
|
||||
return new Intl.DateTimeFormat(this.$i18n.locale, {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
weekday: "short",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(date);
|
||||
},
|
||||
weekdayShort: function (date) {
|
||||
return new Intl.DateTimeFormat(this.$i18n.locale, {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
weekday: "short",
|
||||
}).format(date);
|
||||
},
|
||||
fmtAbsoluteDate: function (date) {
|
||||
const weekday = this.weekdayPrefix(date);
|
||||
const hour = new Intl.DateTimeFormat(this.$i18n.locale, {
|
||||
const hour = new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(date);
|
||||
|
|
@ -158,7 +158,7 @@ export default {
|
|||
return `${weekday} ${hour}`;
|
||||
},
|
||||
fmtFullDateTime: function (date, short) {
|
||||
return new Intl.DateTimeFormat(this.$i18n.locale, {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
weekday: short ? undefined : "short",
|
||||
month: short ? "numeric" : "short",
|
||||
day: "numeric",
|
||||
|
|
@ -167,25 +167,25 @@ export default {
|
|||
}).format(date);
|
||||
},
|
||||
fmtMonthYear: function (date) {
|
||||
return new Intl.DateTimeFormat(this.$i18n.locale, {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
},
|
||||
fmtMonth: function (date, short) {
|
||||
return new Intl.DateTimeFormat(this.$i18n.locale, {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
month: short ? "short" : "long",
|
||||
}).format(date);
|
||||
},
|
||||
fmtDayMonthYear: function (date) {
|
||||
return new Intl.DateTimeFormat(this.$i18n.locale, {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
},
|
||||
fmtMoney: function (amout = 0, currency = "EUR", decimals = true) {
|
||||
return new Intl.NumberFormat(this.$i18n.locale, {
|
||||
return new Intl.NumberFormat(this.$i18n?.locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
currencyDisplay: "code",
|
||||
|
|
@ -208,7 +208,7 @@ export default {
|
|||
minimumFractionDigits = 1;
|
||||
maximumFractionDigits = 1;
|
||||
}
|
||||
const price = new Intl.NumberFormat(this.$i18n.locale, {
|
||||
const price = new Intl.NumberFormat(this.$i18n?.locale, {
|
||||
style: "decimal",
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
|
|
@ -230,7 +230,7 @@ export default {
|
|||
second: 1000,
|
||||
};
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: "auto" });
|
||||
const rtf = new Intl.RelativeTimeFormat(this.$i18n?.locale, { numeric: "auto" });
|
||||
|
||||
// "Math.abs" accounts for both "past" & "future" scenarios
|
||||
for (var u in units)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,6 @@
|
|||
<template>
|
||||
<div class="container px-4">
|
||||
<header class="d-flex justify-content-between align-items-center py-3">
|
||||
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate">
|
||||
<router-link class="evcc-default-text" to="/">
|
||||
<shopicon-regular-home size="s" class="home"></shopicon-regular-home>
|
||||
</router-link>
|
||||
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
|
||||
<span class="text-truncate">{{ $t("sessions.title") }}</span>
|
||||
</h1>
|
||||
<TopNavigation v-bind="topNavigation" />
|
||||
</header>
|
||||
|
||||
<TopHeader :title="$t('sessions.title')" />
|
||||
<div class="row">
|
||||
<main class="col-12">
|
||||
<div class="d-flex align-items-baseline justify-content-between my-3 my-md-5">
|
||||
|
|
@ -252,8 +242,6 @@
|
|||
|
||||
<script>
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import TopNavigation from "../components/TopNavigation.vue";
|
||||
import "@h2d2/shopicons/es/regular/home";
|
||||
import "@h2d2/shopicons/es/regular/angledoubleleftsmall";
|
||||
import "@h2d2/shopicons/es/regular/angledoublerightsmall";
|
||||
import formatter from "../mixins/formatter";
|
||||
|
|
@ -263,7 +251,7 @@ import CustomSelect from "../components/CustomSelect.vue";
|
|||
import ChargingSessionModal from "../components/ChargingSessionModal.vue";
|
||||
import breakpoint from "../mixins/breakpoint";
|
||||
import settings from "../settings";
|
||||
import collector from "../mixins/collector";
|
||||
import TopHeader from "../components/TopHeader.vue";
|
||||
|
||||
const COLUMNS_PER_BREAKPOINT = {
|
||||
xs: 1,
|
||||
|
|
@ -276,8 +264,8 @@ const COLUMNS_PER_BREAKPOINT = {
|
|||
|
||||
export default {
|
||||
name: "ChargingSessions",
|
||||
components: { TopNavigation, ChargingSessionModal, CustomSelect },
|
||||
mixins: [formatter, breakpoint, collector],
|
||||
components: { ChargingSessionModal, CustomSelect, TopHeader },
|
||||
mixins: [formatter, breakpoint],
|
||||
props: {
|
||||
notifications: Array,
|
||||
month: { type: Number, default: () => new Date().getMonth() + 1 },
|
||||
|
|
@ -638,7 +626,7 @@ export default {
|
|||
csvHrefLink(year, month) {
|
||||
const params = new URLSearchParams({
|
||||
format: "csv",
|
||||
lang: this.$i18n.locale,
|
||||
lang: this.$i18n?.locale,
|
||||
});
|
||||
if (year && month) {
|
||||
params.append("year", year);
|
||||
|
|
@ -650,12 +638,6 @@ export default {
|
|||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.home {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
position: relative;
|
||||
top: -3px;
|
||||
}
|
||||
.table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
|
|
|||
|
|
@ -1,283 +1,464 @@
|
|||
<template>
|
||||
<div class="container px-4">
|
||||
<header class="d-flex justify-content-between align-items-center py-3 mb-4">
|
||||
<h1 class="mb-1 pt-1 d-flex text-nowrap">
|
||||
<router-link class="dropdown-item mx-2 me-2" to="/">
|
||||
<shopicon-bold-arrowback size="s" class="back"></shopicon-bold-arrowback>
|
||||
</router-link>
|
||||
Configuration 🧪
|
||||
</h1>
|
||||
<TopNavigation v-bind="topNavigation" />
|
||||
</header>
|
||||
|
||||
<div class="alert alert-danger mb-5" role="alert">
|
||||
<strong>Highly experimental!</strong> Only play around with this settings if you know
|
||||
what your doing. Otherwise you might have to reset or manually repair you database.
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<h2 class="d-flex align-items-center text-evcc">
|
||||
<shopicon-regular-powersupply size="m" class="me-2"></shopicon-regular-powersupply>
|
||||
Grid
|
||||
</h2>
|
||||
<div>
|
||||
<ul class="p-0">
|
||||
<li class="d-flex align-items-center">
|
||||
Grid meter <small class="mx-2">E3/DC</small>
|
||||
|
||||
<button class="btn btn-sm btn-link text-gray" @click="todo">edit</button>
|
||||
</li>
|
||||
<li class="d-flex align-items-center">
|
||||
Grid use <small class="ms-2">32ct/kWh fixed</small>
|
||||
<button class="btn btn-sm btn-link text-gray" @click="todo">edit</button>
|
||||
</li>
|
||||
<li class="d-flex align-items-center">
|
||||
Grid feed-in <small class="ms-2">8ct/kWh fixed</small>
|
||||
<button class="btn btn-sm btn-link text-gray" @click="todo">edit</button>
|
||||
</li>
|
||||
<li class="d-flex align-items-center">
|
||||
Grid CO₂ <small class="ms-2">none</small>
|
||||
<button class="btn btn-sm btn-link text-gray" @click="todo">configure</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<h2 class="d-flex align-items-center text-evcc">
|
||||
<shopicon-regular-sun size="m" class="me-2"></shopicon-regular-sun>
|
||||
Photovoltaics
|
||||
</h2>
|
||||
<div>
|
||||
<ul class="p-0">
|
||||
<li v-for="(meter, index) in pvs" :key="index" class="d-flex align-items-center">
|
||||
PV {{ index + 1 }}
|
||||
<shopicon-regular-warning
|
||||
v-if="index === 3"
|
||||
size="s"
|
||||
class="text-warning ms-2"
|
||||
></shopicon-regular-warning>
|
||||
<button class="btn btn-sm btn-link text-gray" @click="todo">edit</button>
|
||||
</li>
|
||||
<li class="d-flex align-items-center">
|
||||
<button class="btn btn-sm btn-link text-gray px-0" @click="todo">
|
||||
+ add PV
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<h2 class="d-flex align-items-center text-evcc">
|
||||
<shopicon-regular-batterythreequarters
|
||||
size="m"
|
||||
class="me-2"
|
||||
></shopicon-regular-batterythreequarters>
|
||||
Battery storage
|
||||
</h2>
|
||||
<div>
|
||||
<ul class="p-0">
|
||||
<li
|
||||
v-for="(meter, index) in batteries"
|
||||
:key="index"
|
||||
class="d-flex align-items-center"
|
||||
<div class="root">
|
||||
<div class="container px-4">
|
||||
<TopHeader title="Configuration 🧪" />
|
||||
<div class="wrapper">
|
||||
<div
|
||||
v-if="dirty"
|
||||
class="alert alert-secondary d-flex justify-content-between align-items-center my-4"
|
||||
role="alert"
|
||||
>
|
||||
Battery {{ index + 1 }}
|
||||
<button class="btn btn-sm btn-link text-gray" @click="todo">edit</button>
|
||||
</li>
|
||||
<li class="d-flex align-items-center">
|
||||
<button class="btn btn-sm btn-link text-gray px-0" @click="todo">
|
||||
+ add battery
|
||||
<div v-if="restarting"><strong>Restarting evcc.</strong> Please wait ...</div>
|
||||
<div v-else>
|
||||
<strong>Configuration changed.</strong> Please restart to see the effect.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-dark btn-sm"
|
||||
:disabled="restarting || offline"
|
||||
@click="restart"
|
||||
>
|
||||
<span
|
||||
v-if="restarting || offline"
|
||||
class="spinner-border spinner-border-sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span v-else> Restart </span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger my-4" role="alert">
|
||||
<strong>Highly experimental!</strong> Only play around with this settings if you
|
||||
know what your doing. Otherwise you might have to reset or manually repair you
|
||||
database.
|
||||
</div>
|
||||
|
||||
<h2 class="my-4 mt-5">General</h2>
|
||||
<SiteSettings @site-changed="siteChanged" />
|
||||
|
||||
<h2 class="my-4 mt-5">Grid, PV & Battery Systems</h2>
|
||||
<ul class="p-0 config-list">
|
||||
<DeviceCard
|
||||
:name="gridMeter?.config?.template || 'Grid meter'"
|
||||
:unconfigured="!gridMeter"
|
||||
:editable="!!gridMeter?.id"
|
||||
data-testid="grid"
|
||||
@configure="addMeter('grid')"
|
||||
@edit="editMeter(gridMeter.id, 'grid')"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-powersupply></shopicon-regular-powersupply>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('meter', gridMeter?.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
v-for="meter in pvMeters"
|
||||
:key="!!meter.name"
|
||||
:name="meter.config?.template || 'Solar system'"
|
||||
:editable="!!meter.id"
|
||||
data-testid="pv"
|
||||
@edit="editMeter(meter.id, 'pv')"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-sun></shopicon-regular-sun>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('meter', meter.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
v-for="meter in batteryMeters"
|
||||
:key="meter.name"
|
||||
:name="meter.config?.template || 'Battery storage'"
|
||||
:editable="!!meter.id"
|
||||
data-testid="battery"
|
||||
@edit="editMeter(meter.id, 'battery')"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-batterythreequarters></shopicon-regular-batterythreequarters>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('meter', meter.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<AddDeviceButton :title="$t('config.main.addPvBattery')" @add="addMeter" />
|
||||
</ul>
|
||||
|
||||
<h2 class="my-4">Tariffs</h2>
|
||||
|
||||
<ul class="p-0 config-list">
|
||||
<DeviceCard
|
||||
name="Grid"
|
||||
unconfigured
|
||||
data-testid="tariff-grid"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-money></shopicon-regular-money>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
name="Feed-in"
|
||||
unconfigured
|
||||
data-testid="tariff-feedin"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-receivepayment></shopicon-regular-receivepayment>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
name="CO₂ estimate"
|
||||
unconfigured
|
||||
data-testid="tariff-co2"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-eco1></shopicon-regular-eco1>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
</ul>
|
||||
|
||||
<h2 class="my-4">Charge Points</h2>
|
||||
|
||||
<ul class="p-0 config-list">
|
||||
<DeviceCard
|
||||
name="Fake Carport"
|
||||
editable
|
||||
data-testid="chargepoint-1"
|
||||
@edit="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-cablecharge></shopicon-regular-cablecharge>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="{ power: 0 }" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<AddDeviceButton
|
||||
data-testid="add-loadpoint"
|
||||
:title="$t('config.main.addLoadpoint')"
|
||||
@click="todo"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<h2 class="my-4">Vehicles</h2>
|
||||
<div>
|
||||
<ul class="p-0 config-list">
|
||||
<DeviceCard
|
||||
v-for="vehicle in vehicles"
|
||||
:key="vehicle.id"
|
||||
:name="vehicle.config?.title || vehicle.name"
|
||||
:editable="vehicle.id >= 0"
|
||||
data-testid="vehicle"
|
||||
@edit="editVehicle(vehicle.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<VehicleIcon :name="vehicle.config?.icon" />
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('vehicle', vehicle.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<AddDeviceButton
|
||||
data-testid="add-vehicle"
|
||||
:title="$t('config.main.addVehicle')"
|
||||
@click="addVehicle"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<h2 class="my-4">Integrations</h2>
|
||||
|
||||
<ul class="p-0 config-list">
|
||||
<DeviceCard name="MQTT" unconfigured data-testid="mqtt" @configure="todo">
|
||||
<template #icon>
|
||||
<shopicon-regular-fastdelivery1></shopicon-regular-fastdelivery1>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
name="Notifications"
|
||||
unconfigured
|
||||
data-testid="eebus"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-sendit></shopicon-regular-sendit>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard name="InfluxDB" unconfigured data-testid="influx" @configure="todo">
|
||||
<template #icon>
|
||||
<shopicon-regular-diagram></shopicon-regular-diagram>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard name="EEBus" unconfigured data-testid="eebus" @configure="todo">
|
||||
<template #icon>
|
||||
<shopicon-regular-polygon></shopicon-regular-polygon>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
</ul>
|
||||
|
||||
<hr class="my-5" />
|
||||
<VehicleModal :id="selectedVehicleId" @vehicle-changed="vehicleChanged" />
|
||||
<MeterModal
|
||||
:id="selectedMeterId"
|
||||
:name="selectedMeterName"
|
||||
:type="selectedMeterType"
|
||||
@added="addMeterToSite"
|
||||
@updated="meterChanged"
|
||||
@removed="removeMeterFromSite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<h2 class="d-flex align-items-center text-evcc">
|
||||
<shopicon-regular-lightning size="m" class="me-2"></shopicon-regular-lightning>
|
||||
Loadpoints
|
||||
</h2>
|
||||
<div>Bar</div>
|
||||
<hr />
|
||||
-->
|
||||
|
||||
<h2 class="my-4">My Vehicles</h2>
|
||||
<div>
|
||||
<ul class="p-0 config-list">
|
||||
<li
|
||||
v-for="(vehicle, index) in vehicles"
|
||||
:key="index"
|
||||
class="config-entry"
|
||||
data-testid="vehicle"
|
||||
>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<VehicleIcon :name="vehicle.config?.icon" class="me-2" />
|
||||
<span class="flex-grow-1 text-nowrap text-truncate">{{
|
||||
vehicle.config?.title || vehicle.name
|
||||
}}</span>
|
||||
<button
|
||||
v-if="vehicle.id"
|
||||
class="btn btn-sm btn-link text-gray"
|
||||
@click="editVehicle(vehicle.id)"
|
||||
>
|
||||
{{ $t("config.main.edit") }}
|
||||
</button>
|
||||
<span v-else class="text-gray opacity-50 px-2 py-1" disabled>yaml</span>
|
||||
</div>
|
||||
<div v-if="false" class="d-flex mb-2" @click="todo">
|
||||
<!-- example for later -->
|
||||
<span
|
||||
v-if="index > 1 && index < 6"
|
||||
title="Charge"
|
||||
class="badge text-bg-secondary me-1 mb-1"
|
||||
>33%</span
|
||||
>
|
||||
<span
|
||||
v-if="index > 2"
|
||||
title="Capacity"
|
||||
class="badge text-bg-secondary me-1 mb-1"
|
||||
>42 kWh</span
|
||||
>
|
||||
<span
|
||||
v-if="index < 4"
|
||||
title="Power"
|
||||
class="badge text-bg-secondary me-1 mb-1"
|
||||
>+4,3 kW</span
|
||||
>
|
||||
<span title="Odometer" class="badge text-bg-secondary me-1 mb-1"
|
||||
>23.123 km</span
|
||||
>
|
||||
<span
|
||||
v-if="index == 2"
|
||||
title="Climate"
|
||||
class="badge text-bg-secondary me-1 mb-1"
|
||||
>climate</span
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="btn btn-outline-secondary" @click="addVehicle">
|
||||
{{ $t("config.main.addVehicle") }}
|
||||
</button>
|
||||
<VehicleModal :id="vehicleId" @vehicle-changed="vehicleChanged" />
|
||||
</div>
|
||||
<hr class="my-5" />
|
||||
|
||||
<!--
|
||||
<h2 class="d-flex align-items-center text-evcc">
|
||||
<shopicon-regular-megaphone size="m" class="me-2"></shopicon-regular-megaphone>
|
||||
Notifications
|
||||
</h2>
|
||||
<div>Bar</div>
|
||||
<hr />
|
||||
-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TopNavigation from "../components/TopNavigation.vue";
|
||||
import "@h2d2/shopicons/es/bold/arrowback";
|
||||
import "@h2d2/shopicons/es/regular/powersupply";
|
||||
import TopHeader from "../components/TopHeader.vue";
|
||||
import "@h2d2/shopicons/es/regular/sun";
|
||||
import "@h2d2/shopicons/es/regular/warning";
|
||||
import "@h2d2/shopicons/es/regular/car3";
|
||||
import "@h2d2/shopicons/es/regular/megaphone";
|
||||
import "@h2d2/shopicons/es/regular/lightning";
|
||||
import "@h2d2/shopicons/es/regular/batterythreequarters";
|
||||
import "@h2d2/shopicons/es/regular/home";
|
||||
import "@h2d2/shopicons/es/regular/powersupply";
|
||||
import "@h2d2/shopicons/es/regular/money";
|
||||
import "@h2d2/shopicons/es/regular/receivepayment";
|
||||
import "@h2d2/shopicons/es/regular/eco1";
|
||||
import "@h2d2/shopicons/es/regular/fastdelivery1";
|
||||
import "@h2d2/shopicons/es/regular/sendit";
|
||||
import "@h2d2/shopicons/es/regular/diagram";
|
||||
import "@h2d2/shopicons/es/regular/polygon";
|
||||
import "@h2d2/shopicons/es/regular/cablecharge";
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import api from "../api";
|
||||
import VehicleIcon from "../components/VehicleIcon";
|
||||
import VehicleModal from "../components/Config/VehicleModal.vue";
|
||||
import store from "../store";
|
||||
import collector from "../mixins/collector";
|
||||
import DeviceCard from "../components/Config/DeviceCard.vue";
|
||||
import DeviceTags from "../components/Config/DeviceTags.vue";
|
||||
import AddDeviceButton from "../components/Config/AddDeviceButton.vue";
|
||||
import MeterModal from "../components/Config/MeterModal.vue";
|
||||
import SiteSettings from "../components/Config/SiteSettings.vue";
|
||||
import formatter from "../mixins/formatter";
|
||||
|
||||
export default {
|
||||
name: "Config",
|
||||
components: { TopNavigation, VehicleIcon, VehicleModal },
|
||||
mixins: [collector],
|
||||
components: {
|
||||
TopHeader,
|
||||
SiteSettings,
|
||||
VehicleIcon,
|
||||
VehicleModal,
|
||||
DeviceCard,
|
||||
DeviceTags,
|
||||
AddDeviceButton,
|
||||
MeterModal,
|
||||
},
|
||||
props: {
|
||||
offline: Boolean,
|
||||
notifications: Array,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dirty: false,
|
||||
restarting: false,
|
||||
vehicles: [],
|
||||
loadpoints: [],
|
||||
pvs: [],
|
||||
batteries: [],
|
||||
grid: null,
|
||||
vehicleId: undefined,
|
||||
meters: [],
|
||||
selectedVehicleId: undefined,
|
||||
selectedMeterId: undefined,
|
||||
selectedMeterType: undefined,
|
||||
site: { grid: "", pv: [], battery: [] },
|
||||
deviceValueTimeout: undefined,
|
||||
deviceValues: {},
|
||||
};
|
||||
},
|
||||
mixins: [formatter],
|
||||
computed: {
|
||||
topNavigation: function () {
|
||||
const vehicleLogins = store.state.auth ? store.state.auth.vehicles : {};
|
||||
return { vehicleLogins, ...this.collectProps(TopNavigation, store.state) };
|
||||
siteTitle() {
|
||||
return this.site?.title;
|
||||
},
|
||||
gridMeter() {
|
||||
const name = this.site?.grid;
|
||||
return this.getMetersByNames([name])[0];
|
||||
},
|
||||
pvMeters() {
|
||||
const names = this.site?.pv;
|
||||
return this.getMetersByNames(names);
|
||||
},
|
||||
batteryMeters() {
|
||||
const names = this.site?.battery;
|
||||
return this.getMetersByNames(names);
|
||||
},
|
||||
selectedMeterName() {
|
||||
return this.getMeterById(this.selectedMeterId)?.name;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
offline() {
|
||||
if (!this.offline) {
|
||||
this.restarting = false;
|
||||
this.loadAll();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadVehicles();
|
||||
this.loadLoadpoints();
|
||||
this.loadMeters();
|
||||
this.loadAll();
|
||||
},
|
||||
unmounted() {
|
||||
clearTimeout(this.deviceValueTimeout);
|
||||
},
|
||||
methods: {
|
||||
async loadAll() {
|
||||
await this.loadVehicles();
|
||||
await this.loadMeters();
|
||||
await this.loadSite();
|
||||
await this.loadDirty();
|
||||
await this.updateValues();
|
||||
},
|
||||
async loadDirty() {
|
||||
try {
|
||||
const response = await api.get("/config/dirty");
|
||||
this.dirty = response.data?.result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
async loadVehicles() {
|
||||
const response = await api.get("/config/devices/vehicle");
|
||||
this.vehicles = response.data?.result;
|
||||
},
|
||||
async loadLoadpoints() {
|
||||
// TODO: add GET loadpoints API
|
||||
const response = await api.get("/config/devices/charger");
|
||||
this.loadpoints = response.data?.result;
|
||||
this.vehicles = response.data?.result || [];
|
||||
},
|
||||
async loadMeters() {
|
||||
// TODO: split this into separate endpoints for pv, battery, grid
|
||||
const response = await api.get("/config/devices/meter");
|
||||
const meters = response.data?.result || [];
|
||||
this.pvs = meters;
|
||||
this.batteries = meters;
|
||||
this.grid = meters[0];
|
||||
this.meters = response.data?.result || [];
|
||||
},
|
||||
async loadSite() {
|
||||
const response = await api.get("/config/site");
|
||||
this.site = response.data?.result;
|
||||
},
|
||||
getMetersByNames(names) {
|
||||
if (!names || !this.meters) {
|
||||
return [];
|
||||
}
|
||||
return this.meters.filter((m) => names.includes(m.name));
|
||||
},
|
||||
getMeterById(id) {
|
||||
if (!id || !this.meters) {
|
||||
return undefined;
|
||||
}
|
||||
return this.meters.find((m) => m.id === id);
|
||||
},
|
||||
vehicleModal() {
|
||||
return Modal.getOrCreateInstance(document.getElementById("vehicleModal"));
|
||||
},
|
||||
meterModal() {
|
||||
return Modal.getOrCreateInstance(document.getElementById("meterModal"));
|
||||
},
|
||||
editMeter(id, type) {
|
||||
this.selectedMeterId = id;
|
||||
this.selectedMeterType = type;
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
},
|
||||
addMeter(type) {
|
||||
this.selectedMeterId = undefined;
|
||||
this.selectedMeterType = type;
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
},
|
||||
async meterChanged() {
|
||||
this.selectedMeterId = undefined;
|
||||
this.selectedMeterType = undefined;
|
||||
await this.loadMeters();
|
||||
this.meterModal().hide();
|
||||
await this.loadDirty();
|
||||
await this.updateValues();
|
||||
},
|
||||
editVehicle(id) {
|
||||
this.vehicleId = id;
|
||||
this.vehicleModal().show();
|
||||
this.selectedVehicleId = id;
|
||||
this.$nextTick(() => this.vehicleModal().show());
|
||||
},
|
||||
addVehicle() {
|
||||
this.vehicleId = undefined;
|
||||
this.vehicleModal().show();
|
||||
this.selectedVehicleId = undefined;
|
||||
this.$nextTick(() => this.vehicleModal().show());
|
||||
},
|
||||
vehicleChanged() {
|
||||
this.vehicleId = undefined;
|
||||
this.selectedVehicleId = undefined;
|
||||
this.vehicleModal().hide();
|
||||
this.loadVehicles();
|
||||
this.loadDirty();
|
||||
},
|
||||
siteChanged() {
|
||||
this.loadDirty();
|
||||
},
|
||||
addMeterToSite(type, name) {
|
||||
if (type === "grid") {
|
||||
this.site.grid = name;
|
||||
} else {
|
||||
if (!this.site[type]) {
|
||||
this.site[type] = [];
|
||||
}
|
||||
this.site[type].push(name);
|
||||
}
|
||||
this.saveSite(type);
|
||||
},
|
||||
removeMeterFromSite(type, name) {
|
||||
if (type === "grid") {
|
||||
this.site.grid = "";
|
||||
} else {
|
||||
this.site[type] = this.site[type].filter((i) => i !== name);
|
||||
}
|
||||
this.saveSite(type);
|
||||
},
|
||||
async saveSite(key) {
|
||||
const body = key ? { [key]: this.site[key] } : this.site;
|
||||
await api.put("/config/site", body);
|
||||
await this.loadSite();
|
||||
await this.loadDirty();
|
||||
await this.updateValues();
|
||||
},
|
||||
todo() {
|
||||
alert("not implemented yet");
|
||||
},
|
||||
async restart() {
|
||||
try {
|
||||
await api.post("shutdown");
|
||||
this.restarting = true;
|
||||
} catch (e) {
|
||||
alert("Unabled to restart server.");
|
||||
}
|
||||
},
|
||||
async updateDeviceValue(type, name) {
|
||||
try {
|
||||
const response = await api.get(`/config/devices/${type}/${name}/status`);
|
||||
if (!this.deviceValues[type]) this.deviceValues[type] = {};
|
||||
this.deviceValues[type][name] = response.data.result;
|
||||
} catch (error) {
|
||||
console.error("Error fetching device values for", type, name, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async updateValues() {
|
||||
clearTimeout(this.deviceValueTimeout);
|
||||
|
||||
const promises = [
|
||||
...this.meters.map((meter) => this.updateDeviceValue("meter", meter.name)),
|
||||
...this.vehicles.map((vehicle) => this.updateDeviceValue("vehicle", vehicle.name)),
|
||||
];
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
this.deviceValueTimeout = setTimeout(this.updateValues, 10000);
|
||||
},
|
||||
deviceTags(type, id) {
|
||||
return this.deviceValues[type]?.[id] || [];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.back {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
.container {
|
||||
max-width: 700px;
|
||||
}
|
||||
.config-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
grid-gap: 1rem;
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
.config-entry {
|
||||
border-radius: 1rem;
|
||||
color: var(--evcc-default-text);
|
||||
background: var(--evcc-box);
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
display: block;
|
||||
list-style-type: none;
|
||||
.wrapper {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<div>
|
||||
<OfflineIndicator v-if="offline" />
|
||||
|
||||
<StartupError v-if="startupErrors" v-bind="state" :offline="offline" />
|
||||
<Site v-else :notifications="notifications" v-bind="state"></Site>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ const (
|
|||
TariffPriceLoadpoints = "tariffPriceLoadpoints"
|
||||
Vehicles = "vehicles"
|
||||
|
||||
// meters
|
||||
BatteryMeters = "batteryMeters"
|
||||
GridMeter = "GridMeter"
|
||||
PvMeters = "pvMeters"
|
||||
|
||||
// battery settings
|
||||
BatteryCapacity = "batteryCapacity"
|
||||
BatteryDischargeControl = "batteryDischargeControl"
|
||||
|
|
|
|||
20
core/site.go
20
core/site.go
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -160,6 +161,9 @@ func NewSiteFromConfig(
|
|||
}
|
||||
}
|
||||
|
||||
// add meters from config
|
||||
site.restoreMeters()
|
||||
|
||||
// grid meter
|
||||
if site.Meters.GridMeterRef != "" {
|
||||
dev, err := config.Meters().ByName(site.Meters.GridMeterRef)
|
||||
|
|
@ -228,8 +232,24 @@ func NewSite() *Site {
|
|||
return lp
|
||||
}
|
||||
|
||||
// restoreMeters restores site meter configuration
|
||||
func (site *Site) restoreMeters() {
|
||||
if v, err := settings.String(keys.GridMeter); err == nil && v != "" {
|
||||
site.Meters.GridMeterRef = v
|
||||
}
|
||||
if v, err := settings.String(keys.PvMeters); err == nil {
|
||||
site.Meters.PVMetersRef = append(site.Meters.PVMetersRef, strings.Split(v, ",")...)
|
||||
}
|
||||
if v, err := settings.String(keys.BatteryMeters); err == nil {
|
||||
site.Meters.BatteryMetersRef = append(site.Meters.BatteryMetersRef, strings.Split(v, ",")...)
|
||||
}
|
||||
}
|
||||
|
||||
// restoreSettings restores site settings
|
||||
func (site *Site) restoreSettings() error {
|
||||
if v, err := settings.String(keys.Title); err == nil {
|
||||
site.Title = v
|
||||
}
|
||||
if v, err := settings.Float(keys.BufferSoc); err == nil {
|
||||
if err := site.SetBufferSoc(v); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ type API interface {
|
|||
Loadpoints() []loadpoint.API
|
||||
Vehicles() Vehicles
|
||||
|
||||
// Meta
|
||||
GetTitle() string
|
||||
SetTitle(string)
|
||||
|
||||
// Config
|
||||
GetGridMeterRef() string
|
||||
SetGridMeterRef(string)
|
||||
GetPVMeterRefs() []string
|
||||
SetPVMeterRefs([]string)
|
||||
GetBatteryMeterRefs() []string
|
||||
SetBatteryMeterRefs([]string)
|
||||
|
||||
//
|
||||
// battery
|
||||
//
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package core
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
|
|
@ -20,6 +21,74 @@ const (
|
|||
PlannerTariff = "planner"
|
||||
)
|
||||
|
||||
// GetTitle returns the title
|
||||
func (site *Site) GetTitle() string {
|
||||
site.RLock()
|
||||
defer site.RUnlock()
|
||||
return site.Title
|
||||
}
|
||||
|
||||
// SetTitle sets the title
|
||||
func (site *Site) SetTitle(title string) {
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
|
||||
site.Title = title
|
||||
site.publish("siteTitle", title)
|
||||
settings.SetString(keys.Title, title)
|
||||
}
|
||||
|
||||
// GetGridMeterRef returns the GridMeterRef
|
||||
func (site *Site) GetGridMeterRef() string {
|
||||
site.RLock()
|
||||
defer site.RUnlock()
|
||||
return site.Meters.GridMeterRef
|
||||
}
|
||||
|
||||
// SetGridMeterRef sets the GridMeterRef
|
||||
func (site *Site) SetGridMeterRef(ref string) {
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
|
||||
site.Meters.GridMeterRef = ref
|
||||
// site.publish("siteGridMeterRef", meter)
|
||||
settings.SetString(keys.GridMeter, ref)
|
||||
}
|
||||
|
||||
// GetPVMeterRefs returns the PvMeterRef
|
||||
func (site *Site) GetPVMeterRefs() []string {
|
||||
site.RLock()
|
||||
defer site.RUnlock()
|
||||
return site.Meters.PVMetersRef
|
||||
}
|
||||
|
||||
// SetPVMeterRefs sets the PvMeterRef
|
||||
func (site *Site) SetPVMeterRefs(ref []string) {
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
|
||||
site.Meters.PVMetersRef = ref
|
||||
// site.publish("siteGridMeterRef", meter)
|
||||
settings.SetString(keys.PvMeters, strings.Join(ref, ","))
|
||||
}
|
||||
|
||||
// GetBatteryMeterRefs returns the BatteryMeterRef
|
||||
func (site *Site) GetBatteryMeterRefs() []string {
|
||||
site.RLock()
|
||||
defer site.RUnlock()
|
||||
return site.Meters.BatteryMetersRef
|
||||
}
|
||||
|
||||
// SetBatteryMeterRefs sets the BatteryMeterRef
|
||||
func (site *Site) SetBatteryMeterRefs(ref []string) {
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
|
||||
site.Meters.BatteryMetersRef = ref
|
||||
// site.publish("siteGridMeterRef", meter)
|
||||
settings.SetString(keys.BatteryMeters, strings.Join(ref, ","))
|
||||
}
|
||||
|
||||
// Loadpoints returns the list loadpoints
|
||||
func (site *Site) Loadpoints() []loadpoint.API {
|
||||
res := make([]loadpoint.API, len(site.loadpoints))
|
||||
|
|
|
|||
1
env.d.ts
vendored
Normal file
1
env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
46
i18n/en.toml
46
i18n/en.toml
|
|
@ -23,15 +23,59 @@ never = "only with enough surplus"
|
|||
|
||||
[config]
|
||||
|
||||
[config.battery]
|
||||
titleAdd = "Add Battery Meter"
|
||||
titleEdit = "Edit Battery Meter"
|
||||
|
||||
[config.deviceValue]
|
||||
capacity = "Capacity"
|
||||
chargeStatus = "Status"
|
||||
chargedEnergy = "Charged"
|
||||
current = "Current"
|
||||
enabled = "Enabled"
|
||||
energy = "Energy"
|
||||
odometer = "Odometer"
|
||||
phaseCurrents = "Current L1..L3"
|
||||
phasePowers = "Power L1..L3"
|
||||
phaseVoltages = "Voltage L1..L3"
|
||||
power = "Power"
|
||||
range = "Range"
|
||||
soc = "SoC"
|
||||
socLimit = "Limit"
|
||||
|
||||
[config.form]
|
||||
example = "Example"
|
||||
optional = "optional"
|
||||
|
||||
[config.grid]
|
||||
titleAdd = "Add Grid Meter"
|
||||
titleEdit = "Edit Grid Meter"
|
||||
|
||||
[config.main]
|
||||
addLoadpoint = "Add charge point"
|
||||
addPvBattery = "Add solar or battery"
|
||||
addVehicle = "Add vehicle"
|
||||
edit = "edit"
|
||||
title = "Configuration"
|
||||
unconfigured = "not configured"
|
||||
vehicles = "My Vehicles"
|
||||
yaml = "Configured in evcc.yaml. Not editable in the UI."
|
||||
|
||||
[config.meter]
|
||||
cancel = "Cancel"
|
||||
delete = "Delete"
|
||||
save = "Save"
|
||||
template = "Manufacturer"
|
||||
titleChoice = "What Do You Want To Add?"
|
||||
validateSave = "Validate & save"
|
||||
|
||||
[config.pv]
|
||||
titleAdd = "Add Solar Meter"
|
||||
titleEdit = "Edit Solar Meter"
|
||||
|
||||
[config.site]
|
||||
cancel = "Cancel"
|
||||
save = "Save"
|
||||
|
||||
[config.validation]
|
||||
failed = "failed"
|
||||
|
|
@ -43,7 +87,7 @@ validate = "validate"
|
|||
|
||||
[config.vehicle]
|
||||
cancel = "Cancel"
|
||||
delete = "Delete vehicle"
|
||||
delete = "Delete"
|
||||
generic = "Other integrations"
|
||||
offline = "Generic vehicle"
|
||||
online = "Vehicles with online API"
|
||||
|
|
|
|||
2157
package-lock.json
generated
2157
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -22,7 +22,10 @@
|
|||
"@vitejs/plugin-legacy": "^4.0.2",
|
||||
"@vitejs/plugin-vue": "^4.1.0",
|
||||
"@vue/compiler-sfc": "^3.2.30",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/test-utils": "^2.2.7",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"axios": "^1.2.1",
|
||||
"body-parser": "^1.20.2",
|
||||
"bootstrap": "^5.2.2",
|
||||
|
|
@ -42,6 +45,7 @@
|
|||
"prettier-plugin-toml": "^2.0.1",
|
||||
"rollup-plugin-visualizer": "^5.8.3",
|
||||
"smoothscroll-polyfill": "^0.4.4",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.5.1",
|
||||
"vite-plugin-toml": "^0.6.0",
|
||||
"vitest": "^1.1.0",
|
||||
|
|
@ -50,6 +54,7 @@
|
|||
"vue-hot-reload-api": "^2.3.4",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-router": "^4.0.12",
|
||||
"vue-tsc": "^1.8.19",
|
||||
"wait-on": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -58,4 +63,4 @@
|
|||
},
|
||||
"license": "MIT",
|
||||
"repository": "github:evcc-io/evcc"
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ module.exports = defineConfig({
|
|||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
timeout: 15000, // 15s (default 30s)
|
||||
expect: { timeout: 2500 }, // 2.5s (default 5s)
|
||||
workers: 1, // run testfiles serially to avoid port and database conflicts
|
||||
reporter: "html",
|
||||
use: {
|
||||
|
|
|
|||
|
|
@ -114,23 +114,30 @@ func (p *Javascript) BoolGetter() (func() (bool, error), error) {
|
|||
}
|
||||
|
||||
func (p *Javascript) handleGetter() (any, error) {
|
||||
if err := transformInputs(p.in, p.setParam); err != nil {
|
||||
if err := transformInputs(p.in, p.setParamSync); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
javascript.Lock()
|
||||
defer javascript.Unlock()
|
||||
|
||||
return p.evaluate()
|
||||
}
|
||||
|
||||
func (p *Javascript) handleSetter(param string, val any) error {
|
||||
javascript.Lock()
|
||||
if err := p.setParam(param, val); err != nil {
|
||||
javascript.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
v, err := p.evaluate()
|
||||
if err != nil {
|
||||
javascript.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
javascript.Unlock()
|
||||
return transformOutputs(p.out, v)
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +163,13 @@ func (p *Javascript) setParam(param string, val any) error {
|
|||
return p.vm.Set(param, val)
|
||||
}
|
||||
|
||||
// setParamSync is the synchronized version of setParam
|
||||
func (p *Javascript) setParamSync(param string, val any) error {
|
||||
javascript.Lock()
|
||||
defer javascript.Unlock()
|
||||
return p.setParam(param, val)
|
||||
}
|
||||
|
||||
var _ SetIntProvider = (*Javascript)(nil)
|
||||
|
||||
// IntSetter sends int request
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ var (
|
|||
registry = make(map[string]*otto.Otto)
|
||||
)
|
||||
|
||||
// expose mutex to serialize VM access
|
||||
func Lock() {
|
||||
mu.Lock()
|
||||
}
|
||||
|
||||
// expose mutex to serialize VM access
|
||||
func Unlock() {
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// RegisteredVM returns a JS VM. If name is not empty, it will return a shared instance.
|
||||
func RegisteredVM(name, init string) (*otto.Otto, error) {
|
||||
mu.Lock()
|
||||
|
|
|
|||
|
|
@ -83,13 +83,17 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
|
|||
"state": {[]string{"GET"}, "/state", stateHandler(cache)},
|
||||
"config": {[]string{"GET"}, "/config/templates/{class:[a-z]+}", templatesHandler},
|
||||
"products": {[]string{"GET"}, "/config/products/{class:[a-z]+}", productsHandler},
|
||||
"device": {[]string{"GET"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceHandler},
|
||||
"devices": {[]string{"GET"}, "/config/devices/{class:[a-z]+}", devicesHandler},
|
||||
"device": {[]string{"GET"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler},
|
||||
"devicestatus": {[]string{"GET"}, "/config/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
|
||||
"site": {[]string{"GET"}, "/config/site", siteHandler(site)},
|
||||
"dirty": {[]string{"GET"}, "/config/dirty", boolGetHandler(ConfigDirty)},
|
||||
"updatesite": {[]string{"PUT", "OPTIONS"}, "/config/site", updateSiteHandler(site)},
|
||||
"newdevice": {[]string{"POST", "OPTIONS"}, "/config/devices/{class:[a-z]+}", newDeviceHandler},
|
||||
"updatedevice": {[]string{"PUT", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
|
||||
"deletedevice": {[]string{"DELETE", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
|
||||
"testconfig": {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}", testHandler},
|
||||
"testdevice": {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}/{id:[0-9.]+}", testHandler},
|
||||
"testconfig": {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}", testConfigHandler},
|
||||
"testmerged": {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
|
||||
"buffersoc": {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
|
||||
"bufferstartsoc": {[]string{"POST", "OPTIONS"}, "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
|
||||
"batterydischargecontrol": {[]string{"POST", "OPTIONS"}, "/batterydischargecontrol/{value:[a-z]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
|
||||
|
|
@ -98,8 +102,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
|
|||
"smartcost": {[]string{"POST", "OPTIONS"}, "/smartcostlimit/{value:[-0-9.]+}", floatHandler(site.SetSmartCostLimit, site.GetSmartCostLimit)},
|
||||
"tariff": {[]string{"GET"}, "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
|
||||
"sessions": {[]string{"GET"}, "/sessions", sessionHandler},
|
||||
"session1": {[]string{"PUT", "OPTIONS"}, "/session/{id:[0-9]+}", updateSessionHandler},
|
||||
"session2": {[]string{"DELETE", "OPTIONS"}, "/session/{id:[0-9]+}", deleteSessionHandler},
|
||||
"updatesession": {[]string{"PUT", "OPTIONS"}, "/session/{id:[0-9]+}", updateSessionHandler},
|
||||
"deletesession": {[]string{"DELETE", "OPTIONS"}, "/session/{id:[0-9]+}", deleteSessionHandler},
|
||||
"telemetry": {[]string{"GET"}, "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
|
||||
"telemetry2": {[]string{"POST", "OPTIONS"}, "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,8 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/charger"
|
||||
"github.com/evcc-io/evcc/meter"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
|
|
@ -17,145 +14,6 @@ import (
|
|||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
const (
|
||||
// typeTemplate is the updatable configuration type
|
||||
typeTemplate = "template"
|
||||
|
||||
// masked indicates a masked config parameter value
|
||||
masked = "***"
|
||||
)
|
||||
|
||||
// templatesHandler returns the list of templates by class
|
||||
func templatesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
class, err := templates.ClassString(vars["class"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
lang := r.URL.Query().Get("lang")
|
||||
templates.EncoderLanguage(lang)
|
||||
|
||||
if name := r.URL.Query().Get("name"); name != "" {
|
||||
res, err := templates.ByName(class, name)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResult(w, templates.ByClass(class))
|
||||
}
|
||||
|
||||
// productsHandler returns the list of products by class
|
||||
func productsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
class, err := templates.ClassString(vars["class"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := templates.ByClass(class)
|
||||
lang := r.URL.Query().Get("lang")
|
||||
|
||||
res := make(products, 0)
|
||||
for _, t := range tmpl {
|
||||
for _, p := range t.Products {
|
||||
res = append(res, product{
|
||||
Name: p.Title(lang),
|
||||
Template: t.TemplateDefinition.Template,
|
||||
Group: t.Group,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(res, func(a, b product) int {
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
|
||||
jsonResult(w, res)
|
||||
}
|
||||
|
||||
func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[string]any, error) {
|
||||
conf := dev.Config()
|
||||
|
||||
dc := map[string]any{
|
||||
"name": conf.Name,
|
||||
"type": conf.Type,
|
||||
}
|
||||
|
||||
if configurable, ok := dev.(config.ConfigurableDevice[T]); ok {
|
||||
// from database
|
||||
params, err := sanitizeMasked(class, conf.Other)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dc["id"] = configurable.ID()
|
||||
dc["config"] = params
|
||||
} else if title := conf.Other["title"]; title != nil {
|
||||
// from yaml- add title only
|
||||
if s, ok := title.(string); ok {
|
||||
dc["config"] = map[string]any{"title": s}
|
||||
}
|
||||
}
|
||||
|
||||
return dc, nil
|
||||
}
|
||||
|
||||
func deviceConfig[T any](class templates.Class, id int, h config.Handler[T]) (map[string]any, error) {
|
||||
dev, err := h.ByName(config.NameForID(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return deviceConfigMap(class, dev)
|
||||
}
|
||||
|
||||
// deviceHandler returns a device configuration by class
|
||||
func deviceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
class, err := templates.ClassString(vars["class"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
var res map[string]any
|
||||
|
||||
switch class {
|
||||
case templates.Meter:
|
||||
res, err = deviceConfig(class, id, config.Meters())
|
||||
|
||||
case templates.Charger:
|
||||
res, err = deviceConfig(class, id, config.Chargers())
|
||||
|
||||
case templates.Vehicle:
|
||||
res, err = deviceConfig(class, id, config.Vehicles())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
}
|
||||
|
||||
func devicesConfig[T any](class templates.Class, h config.Handler[T]) ([]map[string]any, error) {
|
||||
var res []map[string]any
|
||||
|
||||
|
|
@ -202,6 +60,122 @@ func devicesHandler(w http.ResponseWriter, r *http.Request) {
|
|||
jsonResult(w, res)
|
||||
}
|
||||
|
||||
func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[string]any, error) {
|
||||
conf := dev.Config()
|
||||
|
||||
dc := map[string]any{
|
||||
"name": conf.Name,
|
||||
"type": conf.Type,
|
||||
}
|
||||
|
||||
if configurable, ok := dev.(config.ConfigurableDevice[T]); ok {
|
||||
// from database
|
||||
params, err := sanitizeMasked(class, conf.Other)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dc["id"] = configurable.ID()
|
||||
dc["config"] = params
|
||||
} else if title := conf.Other["title"]; title != nil {
|
||||
// from yaml- add title only
|
||||
if s, ok := title.(string); ok {
|
||||
dc["config"] = map[string]any{"title": s}
|
||||
}
|
||||
}
|
||||
|
||||
return dc, nil
|
||||
}
|
||||
|
||||
func deviceConfig[T any](class templates.Class, id int, h config.Handler[T]) (map[string]any, error) {
|
||||
dev, err := h.ByName(config.NameForID(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return deviceConfigMap(class, dev)
|
||||
}
|
||||
|
||||
// deviceConfigHandler returns a device configuration by class
|
||||
func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
class, err := templates.ClassString(vars["class"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
var res map[string]any
|
||||
|
||||
switch class {
|
||||
case templates.Meter:
|
||||
res, err = deviceConfig(class, id, config.Meters())
|
||||
|
||||
case templates.Charger:
|
||||
res, err = deviceConfig(class, id, config.Chargers())
|
||||
|
||||
case templates.Vehicle:
|
||||
res, err = deviceConfig(class, id, config.Vehicles())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
}
|
||||
|
||||
func deviceStatus[T any](name string, h config.Handler[T]) (T, error) {
|
||||
dev, err := h.ByName(name)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
|
||||
return dev.Instance(), nil
|
||||
}
|
||||
|
||||
// deviceStatusHandler returns a device configuration by class
|
||||
func deviceStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
class, err := templates.ClassString(vars["class"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
name := vars["name"]
|
||||
|
||||
var instance any
|
||||
|
||||
switch class {
|
||||
case templates.Meter:
|
||||
instance, err = deviceStatus(name, config.Meters())
|
||||
|
||||
case templates.Charger:
|
||||
instance, err = deviceStatus(name, config.Chargers())
|
||||
|
||||
case templates.Vehicle:
|
||||
instance, err = deviceStatus(name, config.Vehicles())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResult(w, testInstance(instance))
|
||||
}
|
||||
|
||||
func newDevice[T any](class templates.Class, req map[string]any, newFromConf func(string, map[string]any) (T, error), h config.Handler[T]) (*config.Config, error) {
|
||||
instance, err := newFromConf(typeTemplate, req)
|
||||
if err != nil {
|
||||
|
|
@ -251,17 +225,21 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
setConfigDirty()
|
||||
|
||||
res := struct {
|
||||
ID int `json:"id"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}{
|
||||
ID: conf.ID,
|
||||
ID: conf.ID,
|
||||
Name: config.NameForID(conf.ID),
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
}
|
||||
|
||||
func updateDevice[T any](id int, class templates.Class, conf map[string]any, newFromConf func(string, map[string]any) (T, error), h config.Handler[T]) error {
|
||||
dev, instance, err := deviceInstanceFromMergedConfig(id, class, conf, newFromConf, h)
|
||||
dev, instance, merged, err := deviceInstanceFromMergedConfig(id, class, conf, newFromConf, h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -271,7 +249,7 @@ func updateDevice[T any](id int, class templates.Class, conf map[string]any, new
|
|||
return errors.New("not configurable")
|
||||
}
|
||||
|
||||
return configurable.Update(conf, instance)
|
||||
return configurable.Update(merged, instance)
|
||||
}
|
||||
|
||||
// updateDeviceHandler updates database device's configuration by class
|
||||
|
|
@ -308,6 +286,8 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
|
|||
err = updateDevice(id, class, req, vehicle.NewFromConfig, config.Vehicles())
|
||||
}
|
||||
|
||||
setConfigDirty()
|
||||
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
|
|
@ -369,6 +349,8 @@ func deleteDeviceHandler(w http.ResponseWriter, r *http.Request) {
|
|||
err = deleteDevice(id, config.Vehicles())
|
||||
}
|
||||
|
||||
setConfigDirty()
|
||||
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
|
|
@ -383,18 +365,18 @@ func deleteDeviceHandler(w http.ResponseWriter, r *http.Request) {
|
|||
jsonResult(w, res)
|
||||
}
|
||||
|
||||
func testDevice[T any](id int, class templates.Class, conf map[string]any, newFromConf func(string, map[string]any) (T, error), h config.Handler[T]) (T, error) {
|
||||
func testConfig[T any](id int, class templates.Class, conf map[string]any, newFromConf func(string, map[string]any) (T, error), h config.Handler[T]) (T, error) {
|
||||
if id == 0 {
|
||||
return newFromConf(typeTemplate, conf)
|
||||
}
|
||||
|
||||
_, instance, err := deviceInstanceFromMergedConfig(id, class, conf, newFromConf, h)
|
||||
_, instance, _, err := deviceInstanceFromMergedConfig(id, class, conf, newFromConf, h)
|
||||
|
||||
return instance, err
|
||||
}
|
||||
|
||||
// testHandler tests a configuration by class
|
||||
func testHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// testConfigHandler tests a configuration by class
|
||||
func testConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
class, err := templates.ClassString(vars["class"])
|
||||
|
|
@ -424,13 +406,13 @@ func testHandler(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
switch class {
|
||||
case templates.Charger:
|
||||
instance, err = testDevice(id, class, req, charger.NewFromConfig, config.Chargers())
|
||||
instance, err = testConfig(id, class, req, charger.NewFromConfig, config.Chargers())
|
||||
|
||||
case templates.Meter:
|
||||
instance, err = testDevice(id, class, req, meter.NewFromConfig, config.Meters())
|
||||
instance, err = testConfig(id, class, req, meter.NewFromConfig, config.Meters())
|
||||
|
||||
case templates.Vehicle:
|
||||
instance, err = testDevice(id, class, req, vehicle.NewFromConfig, config.Vehicles())
|
||||
instance, err = testConfig(id, class, req, vehicle.NewFromConfig, config.Vehicles())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -438,27 +420,5 @@ func testHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
type result = struct {
|
||||
Value any `json:"value"`
|
||||
Error error `json:"error"`
|
||||
}
|
||||
|
||||
res := make(map[string]result)
|
||||
|
||||
if dev, ok := instance.(api.Meter); ok {
|
||||
val, err := dev.CurrentPower()
|
||||
res["CurrentPower"] = result{val, err}
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.MeterEnergy); ok {
|
||||
val, err := dev.TotalEnergy()
|
||||
res["TotalEnergy"] = result{val, err}
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.Battery); ok {
|
||||
val, err := dev.Soc()
|
||||
res["Soc"] = result{val, err}
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
jsonResult(w, testInstance(instance))
|
||||
}
|
||||
|
|
@ -2,11 +2,42 @@ package server
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
"github.com/evcc-io/evcc/util/templates"
|
||||
)
|
||||
|
||||
const (
|
||||
// typeTemplate is the updatable configuration type
|
||||
typeTemplate = "template"
|
||||
|
||||
// masked indicates a masked config parameter value
|
||||
masked = "***"
|
||||
)
|
||||
|
||||
var (
|
||||
dirty bool
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
// ConfigDirty returns the dirty flag
|
||||
func ConfigDirty() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
return dirty
|
||||
}
|
||||
|
||||
// setConfigDirty sets the dirty flag indicating that a restart is required
|
||||
func setConfigDirty() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
dirty = true
|
||||
}
|
||||
|
||||
func templateForConfig(class templates.Class, conf map[string]any) (templates.Template, error) {
|
||||
typ, ok := conf[typeTemplate].(string)
|
||||
if !ok {
|
||||
|
|
@ -54,20 +85,106 @@ func mergeMasked(class templates.Class, conf, old map[string]any) (map[string]an
|
|||
return res, nil
|
||||
}
|
||||
|
||||
func deviceInstanceFromMergedConfig[T any](id int, class templates.Class, conf map[string]any, newFromConf func(string, map[string]any) (T, error), h config.Handler[T]) (config.Device[T], T, error) {
|
||||
func deviceInstanceFromMergedConfig[T any](id int, class templates.Class, conf map[string]any, newFromConf func(string, map[string]any) (T, error), h config.Handler[T]) (config.Device[T], T, map[string]any, error) {
|
||||
var zero T
|
||||
|
||||
dev, err := h.ByName(config.NameForID(id))
|
||||
if err != nil {
|
||||
return nil, zero, err
|
||||
return nil, zero, nil, err
|
||||
}
|
||||
|
||||
merged, err := mergeMasked(class, conf, dev.Config().Other)
|
||||
if err != nil {
|
||||
return nil, zero, err
|
||||
return nil, zero, nil, err
|
||||
}
|
||||
|
||||
instance, err := newFromConf(typeTemplate, merged)
|
||||
|
||||
return dev, instance, err
|
||||
return dev, instance, merged, err
|
||||
}
|
||||
|
||||
type testResult = struct {
|
||||
Value any `json:"value"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// testInstance tests the given instance similar to dump
|
||||
// TODO refactor together with dump
|
||||
func testInstance(instance any) map[string]testResult {
|
||||
res := make(map[string]testResult)
|
||||
|
||||
makeResult := func(val any, err error) testResult {
|
||||
res := testResult{Value: val}
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.Meter); ok {
|
||||
val, err := dev.CurrentPower()
|
||||
res["power"] = makeResult(val, err)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.MeterEnergy); ok {
|
||||
val, err := dev.TotalEnergy()
|
||||
res["energy"] = makeResult(val, err)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.Battery); ok {
|
||||
val, err := dev.Soc()
|
||||
res["soc"] = makeResult(val, err)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.VehicleOdometer); ok {
|
||||
val, err := dev.Odometer()
|
||||
res["odometer"] = makeResult(val, err)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.BatteryCapacity); ok {
|
||||
val := dev.Capacity()
|
||||
res["capacity"] = makeResult(val, nil)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.PhaseCurrents); ok {
|
||||
i1, i2, i3, err := dev.Currents()
|
||||
res["phaseCurrents"] = makeResult([]float64{i1, i2, i3}, err)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.PhaseVoltages); ok {
|
||||
u1, u2, u3, err := dev.Voltages()
|
||||
res["phaseVoltages"] = makeResult([]float64{u1, u2, u3}, err)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.PhasePowers); ok {
|
||||
p1, p2, p3, err := dev.Powers()
|
||||
res["phasePowers"] = makeResult([]float64{p1, p2, p3}, err)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.ChargeState); ok {
|
||||
val, err := dev.Status()
|
||||
res["chargeStatus"] = makeResult(val, err)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.Charger); ok {
|
||||
val, err := dev.Enabled()
|
||||
res["enabled"] = makeResult(val, err)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.ChargeRater); ok {
|
||||
val, err := dev.ChargedEnergy()
|
||||
res["chargedEnergy"] = makeResult(val, err)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.VehicleRange); ok {
|
||||
val, err := dev.Range()
|
||||
res["range"] = makeResult(val, err)
|
||||
}
|
||||
|
||||
if dev, ok := instance.(api.SocLimiter); ok {
|
||||
val, err := dev.TargetSoc()
|
||||
res["socLimit"] = makeResult(val, err)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
|
|
|||
82
server/http_config_metadata_handler.go
Normal file
82
server/http_config_metadata_handler.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/evcc-io/evcc/util/templates"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// templatesHandler returns the list of templates by class
|
||||
func templatesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
class, err := templates.ClassString(vars["class"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
lang := r.URL.Query().Get("lang")
|
||||
templates.EncoderLanguage(lang)
|
||||
|
||||
if name := r.URL.Query().Get("name"); name != "" {
|
||||
res, err := templates.ByName(class, name)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResult(w, templates.ByClass(class))
|
||||
}
|
||||
|
||||
// productsHandler returns the list of products by class
|
||||
func productsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
class, err := templates.ClassString(vars["class"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := templates.ByClass(class)
|
||||
lang := r.URL.Query().Get("lang")
|
||||
usage := r.URL.Query().Get("usage")
|
||||
|
||||
res := make(products, 0)
|
||||
for _, t := range tmpl {
|
||||
// if usage filter is specified, only include templates with matching usage
|
||||
includeUsage := usage == ""
|
||||
if !includeUsage {
|
||||
for _, u := range t.Usages() {
|
||||
if u == usage {
|
||||
includeUsage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if includeUsage {
|
||||
for _, p := range t.Products {
|
||||
res = append(res, product{
|
||||
Name: p.Title(lang),
|
||||
Template: t.TemplateDefinition.Template,
|
||||
Group: t.Group,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(res, func(a, b product) int {
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
|
||||
jsonResult(w, res)
|
||||
}
|
||||
89
server/http_config_site_handler.go
Normal file
89
server/http_config_site_handler.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
)
|
||||
|
||||
// siteHandler returns a device configurations by class
|
||||
func siteHandler(site site.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
res := struct {
|
||||
Title string `json:"title"`
|
||||
Grid string `json:"grid"`
|
||||
PV []string `json:"pv"`
|
||||
Battery []string `json:"battery"`
|
||||
}{
|
||||
Title: site.GetTitle(),
|
||||
Grid: site.GetGridMeterRef(),
|
||||
PV: site.GetPVMeterRefs(),
|
||||
Battery: site.GetBatteryMeterRefs(),
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
}
|
||||
}
|
||||
|
||||
func validateRefs(w http.ResponseWriter, refs []string) bool {
|
||||
for _, m := range refs {
|
||||
if _, err := config.Meters().ByName(m); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// siteHandler returns a device configurations by class
|
||||
func updateSiteHandler(site site.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
Title *string
|
||||
Grid *string
|
||||
PV *[]string
|
||||
Battery *[]string
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Title != nil {
|
||||
site.SetTitle(*payload.Title)
|
||||
}
|
||||
|
||||
if payload.Grid != nil {
|
||||
if *payload.Grid != "" && !validateRefs(w, []string{*payload.Grid}) {
|
||||
return
|
||||
}
|
||||
|
||||
site.SetGridMeterRef(*payload.Grid)
|
||||
setConfigDirty()
|
||||
}
|
||||
|
||||
if payload.PV != nil {
|
||||
if !validateRefs(w, *payload.PV) {
|
||||
return
|
||||
}
|
||||
|
||||
site.SetPVMeterRefs(*payload.PV)
|
||||
setConfigDirty()
|
||||
}
|
||||
|
||||
if payload.Battery != nil {
|
||||
if !validateRefs(w, *payload.Battery) {
|
||||
return
|
||||
}
|
||||
|
||||
site.SetBatteryMeterRefs(*payload.Battery)
|
||||
setConfigDirty()
|
||||
}
|
||||
|
||||
status := map[bool]int{false: http.StatusOK, true: http.StatusAccepted}
|
||||
w.WriteHeader(status[ConfigDirty()])
|
||||
}
|
||||
}
|
||||
|
|
@ -120,7 +120,10 @@ func updateSessionHandler(w http.ResponseWriter, r *http.Request) {
|
|||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
var session map[string]any
|
||||
var session struct {
|
||||
Vehicle string
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&session); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ func getPreferredLanguage(header string) string {
|
|||
}
|
||||
|
||||
func indexHandler() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||
|
||||
indexTemplate, err := fs.ReadFile(assets.Web, "index.html")
|
||||
|
|
@ -60,7 +60,7 @@ func indexHandler() http.HandlerFunc {
|
|||
}); err != nil {
|
||||
log.ERROR.Println("httpd: failed to render main page:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// jsonHandler is a middleware that decorates responses with JSON and CORS headers
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const { test, expect } = require("@playwright/test");
|
||||
const { start, stop, restart, cleanRestart } = require("./evcc");
|
||||
const { startSimulator, stopSimulator, SIMULATOR_URL, SIMULATOR_HOST } = require("./simulator");
|
||||
|
||||
const CONFIG_EMPTY = "config-empty.evcc.yaml";
|
||||
const CONFIG_WITH_VEHICLE = "config-with-vehicle.evcc.yaml";
|
||||
|
|
@ -33,20 +34,21 @@ test.describe("vehicles", async () => {
|
|||
await page.goto("/#/config");
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(0);
|
||||
const vehicleModal = page.getByTestId("vehicle-modal");
|
||||
|
||||
// create #1
|
||||
await page.getByRole("button", { name: "Add vehicle" }).click();
|
||||
await page.getByLabel("Manufacturer").selectOption("Generic vehicle");
|
||||
await page.getByLabel("Title").fill("Green Car");
|
||||
await page.getByRole("button", { name: "Validate & save" }).click();
|
||||
await page.getByTestId("add-vehicle").click();
|
||||
await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
|
||||
await vehicleModal.getByLabel("Title").fill("Green Car");
|
||||
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(1);
|
||||
|
||||
// create #2
|
||||
await page.getByRole("button", { name: "Add vehicle" }).click();
|
||||
await page.getByLabel("Manufacturer").selectOption("Generic vehicle");
|
||||
await page.getByLabel("Title").fill("Yellow Van");
|
||||
await page.getByRole("button", { name: "Validate & save" }).click();
|
||||
await page.getByTestId("add-vehicle").click();
|
||||
await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
|
||||
await vehicleModal.getByLabel("Title").fill("Yellow Van");
|
||||
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(2);
|
||||
await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Green Car/);
|
||||
|
|
@ -54,23 +56,23 @@ test.describe("vehicles", async () => {
|
|||
|
||||
// edit #1
|
||||
await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
|
||||
await expect(page.getByLabel("Title")).toHaveValue("Green Car");
|
||||
await page.getByLabel("Title").fill("Fancy Car");
|
||||
await page.getByRole("button", { name: "Validate & save" }).click();
|
||||
await expect(vehicleModal.getByLabel("Title")).toHaveValue("Green Car");
|
||||
await vehicleModal.getByLabel("Title").fill("Fancy Car");
|
||||
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(2);
|
||||
await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Fancy Car/);
|
||||
|
||||
// delete #1
|
||||
await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
|
||||
await page.getByRole("button", { name: "Delete Vehicle" }).click();
|
||||
await vehicleModal.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(1);
|
||||
await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Yellow Van/);
|
||||
|
||||
// delete #2
|
||||
await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
|
||||
await page.getByRole("button", { name: "Delete Vehicle" }).click();
|
||||
await vehicleModal.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(0);
|
||||
});
|
||||
|
|
@ -79,18 +81,20 @@ test.describe("vehicles", async () => {
|
|||
await page.goto("/#/config");
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(0);
|
||||
const vehicleModal = page.getByTestId("vehicle-modal");
|
||||
|
||||
// create #1 & #2
|
||||
await page.getByRole("button", { name: "Add vehicle" }).click();
|
||||
await page.getByLabel("Manufacturer").selectOption("Generic vehicle");
|
||||
await page.getByLabel("Title").fill("Green Car");
|
||||
await page.getByRole("button", { name: "Validate & save" }).click();
|
||||
await page.getByRole("button", { name: "Add vehicle" }).click();
|
||||
await page.getByLabel("Manufacturer").selectOption("Generic vehicle");
|
||||
await page.getByLabel("Title").fill("Yellow Van");
|
||||
await page.getByLabel("car").click();
|
||||
await page.getByLabel("van").check();
|
||||
await page.getByRole("button", { name: "Validate & save" }).click();
|
||||
await page.getByTestId("add-vehicle").click();
|
||||
await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
|
||||
await vehicleModal.getByLabel("Title").fill("Green Car");
|
||||
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
|
||||
await page.getByTestId("add-vehicle").click();
|
||||
await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
|
||||
await vehicleModal.getByLabel("Title").fill("Yellow Van");
|
||||
await vehicleModal.getByLabel("car").click();
|
||||
await vehicleModal.getByLabel("van").check();
|
||||
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(2);
|
||||
|
||||
|
|
@ -109,15 +113,100 @@ test.describe("vehicles", async () => {
|
|||
await page.goto("/#/config");
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(1);
|
||||
const vehicleModal = page.getByTestId("vehicle-modal");
|
||||
|
||||
// create #1
|
||||
await page.getByRole("button", { name: "Add vehicle" }).click();
|
||||
await page.getByLabel("Manufacturer").selectOption("Generic vehicle");
|
||||
await page.getByLabel("Title").fill("Green Car");
|
||||
await page.getByRole("button", { name: "Validate & save" }).click();
|
||||
await page.getByTestId("add-vehicle").click();
|
||||
await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
|
||||
await vehicleModal.getByLabel("Title").fill("Green Car");
|
||||
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(2);
|
||||
await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/YAML Bike/);
|
||||
await expect(page.getByTestId("vehicle").nth(1)).toHaveText(/Green Car/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("meters", async () => {
|
||||
test.beforeAll(async () => {
|
||||
await startSimulator();
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await stopSimulator();
|
||||
});
|
||||
|
||||
test("create, edit and remove battery meter", async ({ page }) => {
|
||||
// setup test data for mock openems api
|
||||
await page.goto(SIMULATOR_URL);
|
||||
await page.getByLabel("Battery Power").fill("-2500");
|
||||
await page.getByLabel("Battery SoC").fill("75");
|
||||
await page.getByRole("button", { name: "Apply changes" }).click();
|
||||
|
||||
await page.goto("/#/config");
|
||||
|
||||
await expect(page.getByTestId("battery")).toHaveCount(0);
|
||||
|
||||
// create #1
|
||||
await page.getByRole("button", { name: "Add solar or battery" }).click();
|
||||
|
||||
const meterModal = page.getByTestId("meter-modal");
|
||||
await meterModal.getByRole("button", { name: "Add battery meter" }).click();
|
||||
await meterModal.getByLabel("Manufacturer").selectOption("OpenEMS");
|
||||
await meterModal.getByLabel("IP address or hostname").fill(SIMULATOR_HOST);
|
||||
await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
|
||||
await meterModal.getByRole("link", { name: "validate" }).click();
|
||||
await expect(meterModal.getByText("SoC: 75.0%")).toBeVisible();
|
||||
await expect(meterModal.getByText("Power: -2.5 kW")).toBeVisible();
|
||||
await meterModal.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByTestId("battery")).toBeVisible(1);
|
||||
await expect(page.getByTestId("battery")).toContainText("openems");
|
||||
|
||||
// edit #1
|
||||
await page.getByTestId("battery").getByRole("button", { name: "edit" }).click();
|
||||
await meterModal.getByLabel("Battery capacity in kWh").fill("20");
|
||||
await meterModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
|
||||
await expect(page.getByTestId("battery")).toBeVisible(1);
|
||||
await expect(page.getByTestId("battery")).toContainText("openems");
|
||||
await expect(page.getByTestId("battery").getByText("SoC: 75.0%")).toBeVisible();
|
||||
await expect(page.getByTestId("battery").getByText("Power: -2.5 kW")).toBeVisible();
|
||||
await expect(page.getByTestId("battery").getByText("Capacity: 20.0 kWh")).toBeVisible();
|
||||
|
||||
// restart and check in main ui
|
||||
await restart(CONFIG_EMPTY);
|
||||
await page.goto("/");
|
||||
await page.getByTestId("visualization").click();
|
||||
await expect(page.getByTestId("energyflow")).toContainText("Battery charging75%2.5 kW");
|
||||
|
||||
// delete #1
|
||||
await page.goto("/#/config");
|
||||
await page.getByTestId("battery").getByRole("button", { name: "edit" }).click();
|
||||
await meterModal.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
await expect(page.getByTestId("battery")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("site", async () => {
|
||||
test("change site title", async ({ page }) => {
|
||||
// initial value on main ui
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Hello World" })).toBeVisible();
|
||||
|
||||
// change value in config
|
||||
await page.goto("/#/config");
|
||||
await page.getByLabel("Site title").fill("Whoops World");
|
||||
|
||||
// reset form to initial value
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByLabel("Site title")).toHaveValue("Hello World");
|
||||
|
||||
// change and save value
|
||||
await page.getByLabel("Site title").fill("Ahoy World");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// check changed value on main ui
|
||||
await page.getByTestId("home-link").click();
|
||||
await expect(page.getByRole("heading", { name: "Ahoy World" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import waitOn from "wait-on";
|
|||
import axios from "axios";
|
||||
import { exec } from "child_process";
|
||||
|
||||
export const SIMULATOR_URL = "http://localhost:7072/";
|
||||
export const SIMULATOR_HOST = "localhost:7072";
|
||||
export const SIMULATOR_URL = `http://${SIMULATOR_HOST}/`;
|
||||
const HEALTH_URL = SIMULATOR_URL + "api/state";
|
||||
const SHUTDOWN_URL = SIMULATOR_URL + "api/shutdown";
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,38 @@ let state = {
|
|||
vehicles: [{ soc: 0, range: 0 }],
|
||||
};
|
||||
|
||||
const stateApiMiddleware = (req, res, next) => {
|
||||
if (req.method === "POST" && req.originalUrl === "/api/state") {
|
||||
console.log("POST /api/state", req.body);
|
||||
state = req.body;
|
||||
res.end();
|
||||
} else if (req.method === "POST" && req.originalUrl === "/api/shutdown") {
|
||||
console.log("POST /api/shutdown", req.body);
|
||||
res.end();
|
||||
process.exit();
|
||||
} else if (req.originalUrl === "/api/state") {
|
||||
res.end(JSON.stringify(state));
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
const openemsMiddleware = (req, res, next) => {
|
||||
const endpoints = {
|
||||
"/rest/channel/_sum/GridActivePower": { value: state.site.grid.power },
|
||||
"/rest/channel/_sum/ProductionActivePower": { value: state.site.pv.power },
|
||||
"/rest/channel/_sum/EssDischargePower": { value: state.site.battery.power },
|
||||
"/rest/channel/_sum/EssSoc": { value: state.site.battery.soc },
|
||||
};
|
||||
const endpoint = endpoints[req.originalUrl];
|
||||
if (req.method === "GET" && endpoint) {
|
||||
console.log("GET", req.originalUrl, endpoint);
|
||||
res.end(JSON.stringify(endpoint));
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
export default () => ({
|
||||
name: "api",
|
||||
enforce: "pre",
|
||||
|
|
@ -17,21 +49,8 @@ export default () => ({
|
|||
console.log("configureServer");
|
||||
return () => {
|
||||
server.middlewares.use(bodyParser.json());
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.method === "POST" && req.originalUrl === "/api/state") {
|
||||
console.log("POST /api/state", req.body);
|
||||
state = req.body;
|
||||
res.end();
|
||||
} else if (req.method === "POST" && req.originalUrl === "/api/shutdown") {
|
||||
console.log("POST /api/shutdown", req.body);
|
||||
res.end();
|
||||
process.exit();
|
||||
} else if (req.originalUrl === "/api/state") {
|
||||
res.end(JSON.stringify(state));
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
server.middlewares.use(stateApiMiddleware);
|
||||
server.middlewares.use(openemsMiddleware);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,11 +38,11 @@
|
|||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="gridPower" class="col-sm-6 col-form-label">Battery Power</label>
|
||||
<label for="batteryPower" class="col-sm-6 col-form-label">Battery Power</label>
|
||||
<div class="col-sm-6">
|
||||
<div class="input-group mb-3">
|
||||
<input
|
||||
id="gridPower"
|
||||
id="batteryPower"
|
||||
v-model.number="state.site.battery.power"
|
||||
type="number"
|
||||
class="form-control"
|
||||
|
|
@ -53,11 +53,11 @@
|
|||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="gridPower" class="col-sm-6 col-form-label">Battery SoC</label>
|
||||
<label for="batterySoc" class="col-sm-6 col-form-label">Battery SoC</label>
|
||||
<div class="col-sm-6">
|
||||
<div class="input-group mb-3">
|
||||
<input
|
||||
id="gridPower"
|
||||
id="batterySoc"
|
||||
v-model.number="state.site.battery.soc"
|
||||
type="number"
|
||||
class="form-control"
|
||||
|
|
|
|||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"assets/js/**/*",
|
||||
"assets/js/src/**/*.vue"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noImplicitAny": false,
|
||||
"allowJs": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,10 @@ func (c *ParamType) UnmarshalText(text []byte) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (c ParamType) MarshalText() ([]byte, error) {
|
||||
return []byte(c.String()), nil
|
||||
}
|
||||
|
||||
//go:generate enumer -type ParamType -trimprefix Type
|
||||
const (
|
||||
TypeString ParamType = iota // default type string
|
||||
|
|
|
|||
|
|
@ -192,10 +192,10 @@ type Param struct {
|
|||
Requirements Requirements `json:"-"` // requirements for this param to be usable, only supported via Type "bool"
|
||||
|
||||
// TODO move somewhere else should not be part of the param definition
|
||||
Baudrate int `json:"-"` // device specific default for modbus RS485 baudrate
|
||||
Comset string `json:"-"` // device specific default for modbus RS485 comset
|
||||
Port int `json:"-"` // device specific default for modbus TCPIP port
|
||||
ID int `json:"-"` // device specific default for modbus ID
|
||||
Baudrate int `json:",omitempty"` // device specific default for modbus RS485 baudrate
|
||||
Comset string `json:",omitempty"` // device specific default for modbus RS485 comset
|
||||
Port int `json:",omitempty"` // device specific default for modbus TCPIP port
|
||||
ID int `json:",omitempty"` // device specific default for modbus ID
|
||||
}
|
||||
|
||||
// DefaultValue returns a default or example value depending on the renderMode
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue