Site: add config UI (BC) (#9812)

This commit is contained in:
andig 2024-01-27 13:38:16 +01:00 • committed by GitHub
parent 09d7e76312
commit 186ba62db7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 3910 additions and 1653 deletions

View file

@ -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
View file

@ -13,6 +13,7 @@ __debug_bin
!package*.json
!evcc.dist.yaml
!tests/**/*.evcc.yaml
!tsconfig.json
/templates/docs
evcc
evcc.exe

View file

@ -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;

View file

@ -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})`,

View 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>

View 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>

View 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>

View file

@ -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: {

View 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>

View 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>

View file

@ -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}`),

View 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>

View 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>

View 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>

View file

@ -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 {

View 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;
},
},
};

View file

@ -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>

View 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>

View file

@ -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)

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -31,6 +31,11 @@ const (
TariffPriceLoadpoints = "tariffPriceLoadpoints"
Vehicles = "vehicles"
// meters
BatteryMeters = "batteryMeters"
GridMeter = "GridMeter"
PvMeters = "pvMeters"
// battery settings
BatteryCapacity = "batteryCapacity"
BatteryDischargeControl = "batteryDischargeControl"

View file

@ -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

View file

@ -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
//

View file

@ -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
View file

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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -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: {

View file

@ -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

View file

@ -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()

View file

@ -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)},
}

View file

@ -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))
}

View file

@ -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
}

View 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)
}

View 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()])
}
}

View file

@ -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

View file

@ -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

View file

@ -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();
});
});

View file

@ -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";

View file

@ -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);
};
},
});

View file

@ -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
View 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/*"
]
}
}
}

View file

@ -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

View file

@ -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