Config UI: add user-defined devices (#20966)

Co-authored-by: andig <cpuidle@gmx.de>
Co-authored-by: andig <andi@evcc.io>
This commit is contained in:
Michael Geers 2025-05-25 23:08:35 +02:00 • committed by GitHub
parent d264ec41c5
commit 914ac75167
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1920 additions and 857 deletions

View file

@ -5,195 +5,155 @@
:title="modalTitle"
data-testid="charger-modal"
:fade="fade"
:size="modalSize"
@open="open"
@close="close"
>
<form ref="form" class="container mx-0 px-0">
<FormRow id="chargerTemplate" :label="$t('config.charger.template')">
<select
v-if="isNew"
id="chargerTemplate"
ref="templateSelect"
v-model="templateName"
class="form-select w-100"
@change="templateChanged"
<TemplateSelector
ref="templateSelect"
v-model="templateName"
device-type="charger"
:is-new="isNew"
:product-name="productName"
:groups="templateOptions"
@change="templateChanged"
/>
<YamlEntry
v-if="values.type === 'custom'"
v-model="values.yaml"
type="charger"
:error-line="test.errorLine"
/>
<div v-else>
<p v-if="loadingTemplate">{{ $t("config.general.templateLoading") }}</p>
<SponsorTokenRequired v-if="sponsorTokenRequired" />
<Markdown v-if="description" :markdown="description" class="my-4" />
<FormRow
v-if="ocppUrl"
id="chargerOcppUrl"
:label="$t('config.charger.ocppLabel')"
:help="$t('config.charger.ocppHelp')"
>
<option value="">---</option>
<optgroup :label="$t('config.charger.generic')">
<option
v-for="option in genericOptions"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
<optgroup :label="$t('config.charger.chargers')">
<option
v-for="option in chargerOptions"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
<optgroup :label="$t('config.charger.switchsockets')">
<option
v-for="option in switchSocketOptions"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
<optgroup :label="$t('config.charger.heatingdevices')">
<option
v-for="option in heatingdevicesOptions"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
</select>
<input
v-else
type="text"
:value="productName"
disabled
class="form-control w-100"
<input type="text" class="form-control border" :value="ocppUrl" readonly />
</FormRow>
<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 ? Number(modbus.ID) : undefined"
:defaultComset="modbus.Comset"
:defaultBaudrate="modbus.Baudrate"
:defaultPort="modbus.Port"
:capabilities="modbusCapabilities"
/>
<PropertyEntry
v-for="param in normalParams"
:id="`chargerParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</FormRow>
<p v-if="loadingTemplate">{{ $t("config.general.templateLoading") }}</p>
<SponsorTokenRequired v-if="sponsorTokenRequired" />
<Markdown v-if="description" :markdown="description" class="my-4" />
<FormRow
v-if="ocppUrl"
id="chargerOcppUrl"
:label="$t('config.charger.ocppLabel')"
:help="$t('config.charger.ocppHelp')"
>
<input type="text" class="form-control border" :value="ocppUrl" readonly />
</FormRow>
<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"
/>
<PropertyEntry
v-for="param in normalParams"
:id="`chargerParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
<PropertyCollapsible>
<template v-if="advancedParams.length" #advanced>
<PropertyEntry
v-for="param in advancedParams"
:id="`chargerParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</template>
</PropertyCollapsible>
</div>
<PropertyCollapsible>
<template v-if="advancedParams.length" #advanced>
<PropertyEntry
v-for="param in advancedParams"
:id="`chargerParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</template>
</PropertyCollapsible>
<TestResult
v-if="templateName"
:success="testSuccess"
:failed="testFailed"
:unknown="testUnknown"
:running="testRunning"
:result="testResult"
:error="testError"
<DeviceModalActions
v-if="showActions"
:is-deletable="isDeletable"
:test-state="test"
:is-saving="saving"
:sponsor-token-required="sponsorTokenRequired"
@save="isNew ? create() : update()"
@remove="remove"
@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.general.delete") }}
</button>
<button
v-else
type="button"
class="btn btn-link text-muted"
data-bs-dismiss="modal"
tabindex="0"
>
{{ $t("config.general.cancel") }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="testRunning || saving || sponsorTokenRequired"
tabindex="0"
@click.prevent="isNew ? create() : update()"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{
testUnknown ? $t("config.general.validateSave") : $t("config.general.save")
}}
</button>
</div>
</form>
</GenericModal>
</template>
<script>
<script lang="ts">
import { defineComponent } from "vue";
import FormRow from "./FormRow.vue";
import PropertyEntry from "./PropertyEntry.vue";
import PropertyCollapsible from "./PropertyCollapsible.vue";
import TestResult from "./TestResult.vue";
import api from "@/api";
import test from "./mixins/test";
import Modbus from "./Modbus.vue";
import Modbus from "./DeviceModal/Modbus.vue";
import DeviceModalActions from "./DeviceModal/Actions.vue";
import GenericModal from "../Helper/GenericModal.vue";
import Markdown from "./Markdown.vue";
import SponsorTokenRequired from "./SponsorTokenRequired.vue";
const initialValues = { type: "template" };
import SponsorTokenRequired from "./DeviceModal/SponsorTokenRequired.vue";
import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSelector.vue";
import YamlEntry from "./DeviceModal/YamlEntry.vue";
import { initialTestState, performTest } from "./utils/test";
import {
handleError,
ConfigType,
type DeviceValues,
type Template,
type Product,
type ModbusParam,
type ModbusCapability,
applyDefaultsFromTemplate,
createDeviceUtils,
} from "./DeviceModal";
import defaultYaml from "./defaultYaml/charger.yaml?raw";
function sleep(ms) {
const initialValues = { type: ConfigType.Template };
const device = createDeviceUtils("charger");
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const CUSTOM_FIELDS = ["modbus"];
export default {
type Requirements = {
Description?: string;
EVCC?: string[];
};
type ChargerDeviceValues = DeviceValues & {
modbus?: any;
id?: any;
host?: any;
port?: any;
device?: any;
baudrate?: any;
comset?: any;
yaml?: string;
};
export default defineComponent({
name: "ChargerModal",
components: {
FormRow,
PropertyEntry,
GenericModal,
Modbus,
TestResult,
PropertyCollapsible,
Markdown,
SponsorTokenRequired,
YamlEntry,
TemplateSelector,
DeviceModalActions,
},
mixins: [test],
props: {
id: Number,
name: String,
@ -204,14 +164,14 @@ export default {
data() {
return {
isModalVisible: false,
templates: [],
products: [],
templateName: null,
template: null,
templates: [] as Template[],
products: [] as Product[],
templateName: null as string | null,
template: null as Template | null,
saving: false,
selectedType: null,
loadingTemplate: false,
values: { ...initialValues },
values: { ...initialValues } as ChargerDeviceValues,
test: initialTestState(),
};
},
computed: {
@ -221,17 +181,31 @@ export default {
}
return this.$t(`config.charger.titleEdit`);
},
chargerOptions() {
return this.products.filter((p) => !p.group);
modalSize() {
return this.values.type === ConfigType.Custom ? "xl" : undefined;
},
genericOptions() {
return this.products.filter((p) => p.group === "generic");
},
switchSocketOptions() {
return this.products.filter((p) => p.group === "switchsockets");
},
heatingdevicesOptions() {
return this.products.filter((p) => p.group === "heating");
templateOptions() {
return [
{
label: "generic",
options: [
...this.products.filter((p) => p.group === "generic"),
customTemplateOption(this.$t("config.general.customOption")),
],
},
{
label: "chargers",
options: this.products.filter((p) => !p.group),
},
{
label: "switchsockets",
options: this.products.filter((p) => p.group === "switchsockets"),
},
{
label: "heatingdevices",
options: this.products.filter((p) => p.group === "heating"),
},
];
},
templateParams() {
const params = this.template?.Params || [];
@ -245,10 +219,10 @@ export default {
},
modbus() {
const params = this.template?.Params || [];
return params.find((p) => p.Name === "modbus");
return (params as ModbusParam[]).find((p) => p.Name === "modbus");
},
modbusCapabilities() {
return this.modbus?.Choice || [];
return (this.modbus?.Choice || []) as ModbusCapability[];
},
ocppUrl() {
const isOcpp =
@ -272,18 +246,21 @@ export default {
return this.template?.Requirements?.Description;
},
productName() {
return this.values.deviceProduct || this.templateName;
return this.values.deviceProduct || this.templateName || "";
},
sponsorTokenRequired() {
const list = this.template?.Requirements?.EVCC || [];
return list.includes("sponsorship") && !this.isSponsor;
const requirements = this.template?.Requirements as Requirements | undefined;
return requirements?.EVCC?.includes("sponsorship") && !this.isSponsor;
},
apiData() {
return {
template: this.templateName,
const data: Record<string, any> = {
...this.modbusDefaults,
...this.values,
};
if (this.values.type === ConfigType.Template) {
data["template"] = this.templateName;
}
return data;
},
isNew() {
return this.id === undefined;
@ -291,12 +268,16 @@ export default {
isDeletable() {
return !this.isNew;
},
showActions() {
return this.templateName || this.values.type === ConfigType.Custom;
},
},
watch: {
isModalVisible(visible) {
if (visible) {
this.templateName = null;
this.reset();
this.test = initialTestState();
this.loadProducts();
if (this.id !== undefined) {
this.loadConfiguration();
@ -308,25 +289,25 @@ export default {
},
values: {
handler() {
this.resetTest();
this.test = initialTestState();
},
deep: true,
},
},
methods: {
reset() {
this.values = { ...initialValues };
this.resetTest();
this.values = { ...initialValues } as ChargerDeviceValues;
this.test = initialTestState();
},
async loadConfiguration() {
try {
const charger = (await api.get(`config/devices/charger/${this.id}`)).data.result;
const charger = await device.load(this.id as number);
this.values = charger.config;
// convert structure to flat list
// TODO: adjust GET response to match POST/PUT formats
this.values.type = charger.type;
this.values.deviceProduct = charger.deviceProduct;
this.applyDefaultsFromTemplate();
applyDefaultsFromTemplate(this.template, this.values);
this.templateName = this.values.template;
} catch (e) {
console.error(e);
@ -337,96 +318,81 @@ export default {
return;
}
try {
const opts = { params: { lang: this.$i18n?.locale } };
this.products = (await api.get("config/products/charger", opts)).data.result;
this.products = await device.loadProducts(this.$i18n?.locale);
} catch (e) {
console.error(e);
}
},
async loadTemplate() {
this.template = null;
if (!this.templateName) return;
if (!this.templateName || this.templateName === ConfigType.Custom) return;
this.loadingTemplate = true;
try {
const opts = {
params: {
lang: this.$i18n?.locale,
name: this.templateName,
},
};
const result = await api.get("config/templates/charger", opts);
this.template = result.data.result;
this.applyDefaultsFromTemplate();
this.template = await device.loadTemplate(this.templateName, this.$i18n?.locale);
applyDefaultsFromTemplate(this.template, this.values);
} 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() {
// persist selected template product
if (this.template) {
const select = this.$refs.templateSelect;
const name = select.options[select.selectedIndex].text;
this.values.deviceProduct = name;
if (this.template && this.$refs["templateSelect"]) {
this.values.deviceProduct = (this.$refs["templateSelect"] as any).getProductName();
}
if (this.testUnknown) {
const success = await this.test(this.testCharger);
if (this.test.isUnknown) {
const success = await performTest(
this.test,
this.testCharger,
this.$refs["form"] as HTMLFormElement
);
if (!success) return;
await sleep(100);
}
this.saving = true;
try {
const response = await api.post("config/devices/charger", this.apiData);
const { name } = response.data.result;
const { name } = await device.create(this.apiData);
this.$emit("added", name);
this.$refs.modal.close();
(this.$refs["modal"] as any).close();
} catch (e) {
this.handleCreateError(e);
handleError(e, "create failed");
}
this.saving = false;
},
async testManually() {
await this.test(this.testCharger);
await performTest(this.test, this.testCharger, this.$refs["form"] as HTMLFormElement);
},
async testCharger() {
let url = "config/test/charger";
if (!this.isNew) {
url += `/merge/${this.id}`;
}
return await api.post(url, this.apiData, { timeout: this.testTimeout });
return device.test(this.id, this.apiData);
},
async update() {
if (this.testUnknown) {
const success = await this.test(this.testCharger);
if (this.test.isUnknown) {
const success = await performTest(
this.test,
this.testCharger,
this.$refs["form"] as HTMLFormElement
);
if (!success) return;
await sleep(250);
}
this.saving = true;
try {
await api.put(`config/devices/charger/${this.id}`, this.apiData);
await device.update(this.id as number, this.apiData);
this.$emit("updated");
this.$refs.modal.close();
(this.$refs["modal"] as any).close();
} catch (e) {
this.handleUpdateError(e);
handleError(e, "update failed");
}
this.saving = false;
},
async remove() {
try {
await api.delete(`config/devices/charger/${this.id}`);
await device.remove(this.id as number);
this.$emit("removed", this.name);
this.$refs.modal.close();
(this.$refs["modal"] as any).close();
} catch (e) {
this.handleRemoveError(e);
handleError(e, "remove failed");
}
},
open() {
@ -436,11 +402,16 @@ export default {
this.$emit("close");
this.isModalVisible = false;
},
templateChanged() {
templateChanged(e: Event) {
const value = (e.target as HTMLSelectElement).value;
this.reset();
if (value === ConfigType.Custom) {
this.values.type = ConfigType.Custom;
this.values.yaml = defaultYaml;
}
},
},
};
});
</script>
<style scoped>
.container {

View file

@ -0,0 +1,69 @@
<template>
<div>
<TestResult v-if="testState" v-bind="testState" @test="$emit('test')" />
<div class="my-4 d-flex justify-content-between">
<button
v-if="isDeletable"
type="button"
class="btn btn-link text-danger"
tabindex="0"
@click.prevent="$emit('remove')"
>
{{ $t("config.general.delete") }}
</button>
<button
v-else
type="button"
class="btn btn-link text-muted"
data-bs-dismiss="modal"
tabindex="0"
>
{{ $t("config.general.cancel") }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="testState.isRunning || isSaving || sponsorTokenRequired"
tabindex="0"
@click.prevent="$emit('save')"
>
<span
v-if="isSaving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{
testState.isUnknown
? $t("config.general.validateSave")
: $t("config.general.save")
}}
</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import type { PropType } from "vue";
import TestResult from "../TestResult.vue";
import { type TestState } from "../utils/test";
export default defineComponent({
name: "DeviceModalActions",
components: {
TestResult,
},
props: {
isDeletable: Boolean as PropType<boolean>,
testState: {
type: Object as PropType<TestState>,
default: () => {},
},
isSaving: Boolean as PropType<boolean>,
sponsorTokenRequired: Boolean as PropType<boolean>,
},
emits: ["save", "remove", "test"],
});
</script>

View file

@ -161,11 +161,10 @@
<script lang="ts">
import { defineComponent } from "vue";
import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue";
import FormRow from "../FormRow.vue";
import PropertyField from "../PropertyField.vue";
import type { PropType } from "vue";
type Capability = "rs485" | "tcpip";
import type { ModbusCapability } from "./index";
type Modbus = "rs485serial" | "rs485tcpip" | "tcpip";
type ConnectionOption = "tcpip" | "serial";
type ProtocolOption = "tcp" | "rtu";
@ -175,7 +174,7 @@ export default defineComponent({
components: { FormRow, PropertyField },
props: {
capabilities: {
type: Array as PropType<Capability[]>,
type: Array as PropType<ModbusCapability[]>,
default: () => [],
},
modbus: String as PropType<Modbus>,
@ -233,7 +232,7 @@ export default defineComponent({
selectedModbus(newValue: Modbus) {
this.$emit("update:modbus", newValue);
},
options(newValue: Capability[]) {
options(newValue: ModbusCapability[]) {
this.setProtocolByCapabilities(newValue);
this.$emit("update:modbus", this.selectedModbus);
},
@ -248,7 +247,7 @@ export default defineComponent({
this.$emit("update:modbus", this.selectedModbus);
},
methods: {
setProtocolByCapabilities(capabilities: Capability[]) {
setProtocolByCapabilities(capabilities: ModbusCapability[]) {
this.protocol = capabilities.includes("tcpip") ? "tcp" : "rtu";
},
setConnectionAndProtocolByModbus(modbus?: Modbus) {

View file

@ -0,0 +1,92 @@
<template>
<FormRow :id="`${deviceType}Template`" :label="$t(`config.${deviceType}.template`)">
<select
v-if="isNew"
:id="`${deviceType}Template`"
ref="select"
v-model="modelProxy"
class="form-select w-100"
@change="changed"
>
<template v-for="group in groups" :key="group.label">
<optgroup
v-if="group.options?.length"
:label="$t(`config.${deviceType}.${group.label}`)"
>
<option
v-for="option in group.options"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
</template>
</select>
<input
v-else
:id="`${deviceType}Template`"
type="text"
:value="productName || $t('config.general.customOption')"
disabled
class="form-control w-100"
/>
</FormRow>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import FormRow from "../FormRow.vue";
import type { DeviceType } from "@/types/evcc";
export interface TemplateOption {
name: string;
template: string;
}
export interface PrimaryOption {
name: string;
template: string;
}
export interface TemplateGroup {
label: string;
options: TemplateOption[];
}
export default defineComponent({
name: "TemplateSelector",
components: { FormRow },
props: {
deviceType: String as PropType<DeviceType>,
isNew: Boolean,
modelValue: String as PropType<string | null>,
productName: String,
groups: Array as PropType<TemplateGroup[]>,
},
emits: ["update:modelValue", "change"],
computed: {
modelProxy: {
get() {
return this.modelValue;
},
set(value: string) {
this.$emit("update:modelValue", value);
},
},
},
methods: {
changed(e: Event) {
this.$emit("change", e);
},
getProductName() {
const select = this.$refs["select"] as HTMLSelectElement;
return select.options[select.selectedIndex].text;
},
},
});
export function customTemplateOption(name: string) {
return { template: "custom", name };
}
</script>

View file

@ -0,0 +1,57 @@
<template>
<div>
<p>
<span>{{ $t("config.general.customHelp") + " " }}</span>
<a :href="docsLink" target="_blank">
{{ $t("config.general.docsLink") }}
</a>
</p>
<YamlEditorContainer v-model="localValue" :error-line="errorLine" />
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import YamlEditorContainer from "../YamlEditorContainer.vue";
import { docsPrefix } from "@/i18n";
type DeviceType = "vehicle" | "charger" | "meter";
export default defineComponent({
name: "YamlEntry",
components: {
YamlEditorContainer,
},
props: {
modelValue: String,
type: { type: String as () => DeviceType, required: true },
errorLine: { type: [Number, null], default: null },
},
emits: ["update:modelValue"],
data() {
return {
localValue: this.modelValue,
};
},
computed: {
docsLink() {
return `${docsPrefix()}/docs/devices/plugins#${this.type}`;
},
},
watch: {
modelValue: {
handler(newVal) {
if (this.localValue !== newVal) {
this.localValue = newVal;
}
},
immediate: true,
},
localValue: {
handler(newVal) {
this.$emit("update:modelValue", newVal);
},
},
},
});
</script>

View file

@ -0,0 +1,126 @@
import type { DeviceType } from "@/types/evcc";
import api from "@/api";
export type Product = {
group: string;
name: string;
template: string;
};
export type Template = {
Params: TemplateParam[];
Requirements: {
Description: string;
};
};
export type TemplateParam = {
Name: string;
Required: boolean;
Advanced: boolean;
Deprecated: boolean;
Default: string | number | boolean | undefined;
Choice?: string[];
};
export type ModbusCapability = "rs485" | "tcpip";
export type ModbusParam = TemplateParam & {
ID?: string;
Comset?: string;
Baudrate?: number;
Port?: number;
};
export type DeviceValues = {
type: ConfigType;
icon: string | undefined;
deviceProduct: string | undefined;
yaml: string | undefined;
template: string | null;
[key: string]: any;
};
export function handleError(e: any, msg: string) {
console.error(e);
let message = msg;
const { error } = e.response.data || {};
if (error) message += `: ${error}`;
alert(message);
}
export const timeout = 15000;
export enum ConfigType {
Template = "template",
Custom = "custom",
}
export function applyDefaultsFromTemplate(template: Template | null, values: DeviceValues) {
const params = template?.Params || [];
params
.filter((p) => p.Default && !values[p.Name])
.forEach((p) => {
values[p.Name] = p.Default;
});
}
export function createDeviceUtils(deviceType: DeviceType) {
function test(id: number | undefined, data: any) {
let url = `config/test/${deviceType}`;
if (id !== undefined) {
url += `/merge/${id}`;
}
return api.post(url, data, { timeout });
}
function update(id: number, data: any) {
return api.put(`config/devices/${deviceType}/${id}`, data);
}
function remove(id: number) {
return api.delete(`config/devices/${deviceType}/${id}`);
}
async function load(id: number) {
const response = await api.get(`config/devices/${deviceType}/${id}`);
return response.data.result;
}
async function create(data: any) {
const response = await api.post(`config/devices/${deviceType}`, data);
return response.data.result;
}
async function loadProducts(lang?: string, usage?: string) {
const params: Record<string, string | undefined> = { lang };
if (usage) {
params["usage"] = usage;
}
const response = await api.get(`config/products/${deviceType}`, { params });
return response.data.result;
}
async function loadTemplate(templateName: string, lang?: string) {
if (!templateName) return null;
const opts = {
params: {
lang,
name: templateName,
},
};
const response = await api.get(`config/templates/${deviceType}`, opts);
return response.data.result;
}
return {
test,
update,
remove,
load,
create,
loadProducts,
loadTemplate,
};
}

View file

@ -634,7 +634,10 @@ export default {
},
chargerTitle() {
if (!this.charger) return "";
const title = this.charger.deviceProduct || this.charger.config?.template || "unknown";
const title =
this.charger.deviceProduct ||
this.charger.config?.template ||
this.$t("config.general.customOption");
return `${title} [${this.values.charger}]`;
},
chargerStatus() {

View file

@ -5,6 +5,7 @@
:title="modalTitle"
data-testid="meter-modal"
:fade="fade"
:size="modalSize"
@open="open"
@close="close"
>
@ -18,7 +19,7 @@
/>
</div>
<form v-else ref="form" class="container mx-0 px-0">
<p v-if="['aux', 'ext'].includes(meterType)" class="mt-0 mb-4">
<p v-if="hasDescription" class="mt-0 mb-4">
{{ $t(`config.${meterType}.description`) }}
</p>
<div v-if="meterType === 'ext'" class="alert alert-warning mb-4" role="alert">
@ -51,158 +52,132 @@
required
/>
</FormRow>
<FormRow id="meterTemplate" :label="$t('config.meter.template')">
<select
v-if="isNew"
id="meterTemplate"
ref="templateSelect"
v-model="templateName"
class="form-select w-100"
@change="templateChanged"
>
<option
v-for="option in genericOptions"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
<option v-if="genericOptions.length" disabled>──────────</option>
<option
v-for="option in templateOptions"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</select>
<input
v-else
type="text"
:value="productName"
disabled
class="form-control w-100"
<TemplateSelector
ref="templateSelect"
v-model="templateName"
device-type="meter"
:is-new="isNew"
:product-name="productName"
:groups="templateOptions"
@change="templateChanged"
/>
<YamlEntry
v-if="values.type === 'custom'"
v-model="values.yaml"
type="meter"
:error-line="test.errorLine"
/>
<div v-else>
<p v-if="loadingTemplate">{{ $t("config.general.templateLoading") }}</p>
<Markdown v-if="description" :markdown="description" class="my-4" />
<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 ? Number(modbus.ID) : undefined"
:defaultComset="modbus.Comset"
:defaultBaudrate="modbus.Baudrate"
:defaultPort="modbus.Port"
:capabilities="modbusCapabilities"
/>
<PropertyEntry
v-for="param in normalParams"
:id="`meterParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</FormRow>
<p v-if="loadingTemplate">{{ $t("config.general.templateLoading") }}</p>
<Markdown v-if="description" :markdown="description" class="my-4" />
<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"
/>
<PropertyEntry
v-for="param in normalParams"
:id="`meterParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
<PropertyCollapsible>
<template v-if="advancedParams.length" #advanced>
<PropertyEntry
v-for="param in advancedParams"
:id="`meterParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</template>
</PropertyCollapsible>
<PropertyCollapsible>
<template v-if="advancedParams.length" #advanced>
<PropertyEntry
v-for="param in advancedParams"
:id="`meterParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</template>
</PropertyCollapsible>
</div>
<TestResult
v-if="templateName"
:success="testSuccess"
:failed="testFailed"
:unknown="testUnknown"
:running="testRunning"
:result="testResult"
:error="testError"
<DeviceModalActions
v-if="showActions"
:is-deletable="isDeletable"
:test-state="test"
:is-saving="saving"
@save="isNew ? create() : update()"
@remove="remove"
@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"
tabindex="0"
@click.prevent="remove"
>
{{ $t("config.general.delete") }}
</button>
<button
v-else
type="button"
class="btn btn-link text-muted"
data-bs-dismiss="modal"
tabindex="0"
>
{{ $t("config.general.cancel") }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="testRunning || saving"
tabindex="0"
@click.prevent="isNew ? create() : update()"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{
testUnknown
? $t("config.general.validateSave")
: $t("config.general.save")
}}
</button>
</div>
</div>
</form>
</GenericModal>
</template>
<script>
<script lang="ts">
import { defineComponent } from "vue";
import FormRow from "./FormRow.vue";
import PropertyEntry from "./PropertyEntry.vue";
import PropertyCollapsible from "./PropertyCollapsible.vue";
import TestResult from "./TestResult.vue";
import api from "@/api";
import test from "./mixins/test";
import NewDeviceButton from "./NewDeviceButton.vue";
import Modbus from "./Modbus.vue";
import Modbus from "./DeviceModal/Modbus.vue";
import DeviceModalActions from "./DeviceModal/Actions.vue";
import GenericModal from "../Helper/GenericModal.vue";
import Markdown from "./Markdown.vue";
import PropertyField from "./PropertyField.vue";
const initialValues = { type: "template", deviceTitle: "", deviceIcon: "" };
import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSelector.vue";
import YamlEntry from "./DeviceModal/YamlEntry.vue";
import { ICONS } from "../VehicleIcon/VehicleIcon.vue";
import { initialTestState, performTest } from "./utils/test";
import {
handleError,
ConfigType,
type DeviceValues,
type Template,
type Product,
type ModbusParam,
type ModbusCapability,
applyDefaultsFromTemplate,
createDeviceUtils,
} from "./DeviceModal";
import defaultYaml from "./defaultYaml/meter.yaml?raw";
function sleep(ms) {
const initialValues = { type: ConfigType.Template, deviceTitle: "", deviceIcon: "" };
const device = createDeviceUtils("meter");
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const CUSTOM_FIELDS = ["usage", "modbus"];
const defaultIcons = {
const defaultIcons: Record<string, string> = {
aux: "smartconsumer",
ext: "meter",
};
export default {
type MeterDeviceValues = DeviceValues & {
deviceTitle: string;
deviceIcon: string;
modbus?: any;
id?: any;
host?: any;
port?: any;
device?: any;
baudrate?: any;
comset?: any;
yaml?: string;
};
export default defineComponent({
name: "MeterModal",
components: {
FormRow,
@ -210,18 +185,22 @@ export default {
PropertyField,
GenericModal,
Modbus,
TestResult,
NewDeviceButton,
PropertyCollapsible,
Markdown,
TemplateSelector,
YamlEntry,
DeviceModalActions,
},
mixins: [test],
props: {
id: Number,
name: String,
type: String,
type: {
type: String as () => string | undefined,
default: undefined,
},
typeChoices: {
type: Array,
type: Array as () => string[],
default: () => ["pv", "battery", "aux", "ext"],
},
fade: String,
@ -230,15 +209,16 @@ export default {
data() {
return {
isModalVisible: false,
templates: [],
products: [],
templateName: null,
template: null,
templates: [] as Template[],
products: [] as Product[],
templateName: null as string | null,
template: null as Template | null,
saving: false,
selectedType: null,
selectedType: null as string | null,
loadingTemplate: false,
iconChoices: ICONS,
values: { ...initialValues },
values: { ...initialValues } as MeterDeviceValues,
test: initialTestState(),
};
},
computed: {
@ -256,19 +236,28 @@ export default {
return this.type || this.selectedType;
},
hasDeviceTitle() {
return ["pv", "battery", "aux", "ext"].includes(this.meterType);
return ["pv", "battery", "aux", "ext"].includes(this.meterType || "");
},
hasDeviceIcon() {
return ["aux", "ext"].includes(this.meterType);
return ["aux", "ext"].includes(this.meterType || "");
},
templateOptions() {
return this.products.filter((p) => p.group !== "generic");
},
genericOptions() {
return this.products.filter((p) => p.group === "generic");
return [
{
label: "generic",
options: [
...this.products.filter((p) => p.group === "generic"),
customTemplateOption(this.$t("config.general.customOption")),
],
},
{
label: "specific",
options: this.products.filter((p) => p.group !== "generic"),
},
];
},
templateParams() {
return (this.template?.Params || [])
const params = (this.template?.Params || [])
.filter((p) => !CUSTOM_FIELDS.includes(p.Name))
.map((p) => {
if (this.meterType === "battery" && p.Name === "capacity") {
@ -276,19 +265,20 @@ export default {
}
return p;
});
return params;
},
normalParams() {
return this.templateParams.filter((p) => !p.Advanced);
return this.templateParams.filter((p) => !p.Advanced && !p.Deprecated);
},
advancedParams() {
return this.templateParams.filter((p) => p.Advanced);
return this.templateParams.filter((p) => p.Advanced || p.Deprecated);
},
modbus() {
modbus(): ModbusParam | undefined {
const params = this.template?.Params || [];
return params.find((p) => p.Name === "modbus");
return (params as ModbusParam[]).find((p) => p.Name === "modbus");
},
modbusCapabilities() {
return this.modbus?.Choice || [];
return (this.modbus?.Choice || []) as ModbusCapability[];
},
modbusDefaults() {
const { ID, Comset, Baudrate, Port } = this.modbus || {};
@ -303,15 +293,18 @@ export default {
return this.template?.Requirements?.Description;
},
productName() {
return this.values.deviceProduct || this.templateName;
return this.values.deviceProduct || this.templateName || "";
},
apiData() {
return {
template: this.templateName,
const data: Record<string, any> = {
...this.modbusDefaults,
...this.values,
usage: this.meterType,
};
if (this.values.type === ConfigType.Template) {
data["template"] = this.templateName;
data["usage"] = this.meterType || undefined;
}
return data;
},
isNew() {
return this.id === undefined;
@ -319,6 +312,15 @@ export default {
isDeletable() {
return !this.isNew;
},
modalSize() {
return this.values.type === ConfigType.Custom ? "xl" : undefined;
},
showActions() {
return this.templateName || this.values.type === ConfigType.Custom;
},
hasDescription() {
return ["ext", "aux"].includes(this.meterType || "");
},
},
watch: {
isModalVisible(visible) {
@ -326,6 +328,7 @@ export default {
this.templateName = null;
this.selectedType = null;
this.reset();
this.test = initialTestState();
this.loadProducts();
if (this.id !== undefined) {
this.loadConfiguration();
@ -333,6 +336,7 @@ export default {
}
},
meterType(type) {
if (!type) return;
this.loadProducts();
this.values.deviceIcon = defaultIcons[type] || "";
},
@ -341,7 +345,7 @@ export default {
},
values: {
handler() {
this.resetTest();
this.test = initialTestState();
},
deep: true,
},
@ -351,12 +355,12 @@ export default {
const keep = keepTitle
? { deviceTitle: this.values.deviceTitle, deviceIcon: this.values.deviceIcon }
: {};
this.values = { ...initialValues, ...keep };
this.resetTest();
this.values = { ...initialValues, ...keep } as MeterDeviceValues;
this.test = initialTestState();
},
async loadConfiguration() {
try {
const meter = (await api.get(`config/devices/meter/${this.id}`)).data.result;
const meter = await device.load(this.id as number);
this.values = meter.config;
// convert structure to flat list
// TODO: adjust GET response to match POST/PUT formats
@ -364,7 +368,7 @@ export default {
this.values.deviceTitle = meter.deviceTitle;
this.values.deviceIcon = meter.deviceIcon;
this.values.deviceProduct = meter.deviceProduct;
this.applyDefaultsFromTemplate();
applyDefaultsFromTemplate(this.template, this.values);
this.templateName = this.values.template;
} catch (e) {
console.error(e);
@ -375,101 +379,81 @@ export default {
return;
}
try {
const opts = {
params: {
usage: this.meterType,
lang: this.$i18n?.locale,
},
};
this.products = (await api.get("config/products/meter", opts)).data.result;
this.products = await device.loadProducts(this.$i18n?.locale, this.meterType);
} catch (e) {
console.error(e);
}
},
async loadTemplate() {
this.template = null;
if (!this.templateName) return;
if (!this.templateName || this.templateName === ConfigType.Custom) return;
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();
this.template = await device.loadTemplate(this.templateName, this.$i18n?.locale);
applyDefaultsFromTemplate(this.template, this.values);
} 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() {
// persist selected template product
if (this.template) {
const select = this.$refs.templateSelect;
const name = select.options[select.selectedIndex].text;
this.values.deviceProduct = name;
if (this.template && this.$refs["templateSelect"]) {
this.values.deviceProduct = (this.$refs["templateSelect"] as any).getProductName();
}
if (this.testUnknown) {
const success = await this.test(this.testMeter);
if (this.test.isUnknown) {
const success = await performTest(
this.test,
this.testMeter,
this.$refs["form"] as HTMLFormElement
);
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;
const { name } = await device.create(this.apiData);
this.$emit("added", this.meterType, name);
this.$refs.modal.close();
(this.$refs["modal"] as any).close();
} catch (e) {
this.handleCreateError(e);
handleError(e, "create failed");
}
this.saving = false;
},
async testManually() {
await this.test(this.testMeter);
await performTest(this.test, this.testMeter, this.$refs["form"] as HTMLFormElement);
},
async testMeter() {
let url = "config/test/meter";
if (!this.isNew) {
url += `/merge/${this.id}`;
}
return await api.post(url, this.apiData, { timeout: this.testTimeout });
return device.test(this.id, this.apiData);
},
async update() {
if (this.testUnknown) {
const success = await this.test(this.testMeter);
if (this.test.isUnknown) {
const success = await performTest(
this.test,
this.testMeter,
this.$refs["form"] as HTMLFormElement
);
if (!success) return;
await sleep(250);
}
this.saving = true;
try {
await api.put(`config/devices/meter/${this.id}`, this.apiData);
await device.update(this.id as number, this.apiData);
this.$emit("updated");
this.$refs.modal.close();
(this.$refs["modal"] as any).close();
} catch (e) {
this.handleUpdateError(e);
handleError(e, "update failed");
}
this.saving = false;
},
async remove() {
try {
await api.delete(`config/devices/meter/${this.id}`);
await device.remove(this.id as number);
this.$emit("removed", this.meterType, this.name);
this.$refs.modal.close();
(this.$refs["modal"] as any).close();
} catch (e) {
this.handleRemoveError(e);
handleError(e, "remove failed");
}
},
open() {
@ -479,14 +463,19 @@ export default {
this.$emit("close");
this.isModalVisible = false;
},
selectType(type) {
selectType(type: string) {
this.selectedType = type;
},
templateChanged() {
templateChanged(e: Event) {
const value = (e.target as HTMLSelectElement).value;
this.reset(true);
if (value === ConfigType.Custom) {
this.values.type = ConfigType.Custom;
this.values.yaml = defaultYaml;
}
},
},
};
});
</script>
<style scoped>
.container {

View file

@ -40,7 +40,9 @@
</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>
<a :id="id" class="text-muted" href="#" @click.prevent="toggleSelectMode">
{{ $t("config.icon.change") }}
</a>
</div>
</div>
<SelectGroup

View file

@ -1,19 +1,19 @@
<template>
<div class="test-result my-4 p-4">
<div class="test-result my-4 p-4" data-testid="test-result">
<div class="d-flex justify-content-between align-items-center">
<strong>
<span>{{ $t("config.validation.label") }}: </span>
<span v-if="unknown">{{ $t("config.validation.unknown") }}</span>
<span v-if="running">{{ $t("config.validation.running") }}</span>
<span v-if="success" class="text-success">
<span v-if="isUnknown">{{ $t("config.validation.unknown") }}</span>
<span v-if="isRunning">{{ $t("config.validation.running") }}</span>
<span v-if="isSuccess" class="text-success">
{{ $t("config.validation.success") }}
</span>
<span v-if="failed" class="text-danger">
<span v-if="isError" class="text-danger">
{{ $t("config.validation.failed") }}
</span>
</strong>
<span
v-if="running"
v-if="isRunning"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
@ -33,22 +33,23 @@
</div>
</template>
<script>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import DeviceTags from "./DeviceTags.vue";
export default {
export default defineComponent({
name: "TestResult",
components: { DeviceTags },
props: {
success: Boolean,
failed: Boolean,
unknown: Boolean,
running: Boolean,
result: Object,
error: String,
isUnknown: Boolean,
isSuccess: Boolean,
isError: Boolean,
isRunning: Boolean,
result: Object as PropType<Record<string, any> | null>,
error: String as PropType<string | null>,
},
emits: ["test"],
};
});
</script>
<style scoped>
.test-result {

View file

@ -2,276 +2,232 @@
<GenericModal
id="vehicleModal"
:title="$t(`config.vehicle.${isNew ? 'titleAdd' : 'titleEdit'}`)"
:size="modalSize"
data-testid="vehicle-modal"
@open="open"
@closed="closed"
>
<form ref="form" class="container mx-0 px-0">
<FormRow id="vehicleTemplate" :label="$t('config.vehicle.template')">
<select
v-if="isNew"
id="vehicleTemplate"
ref="templateSelect"
v-model="templateName"
class="form-select w-100"
@change="templateChanged"
>
<option :value="templateOptions.offline.template">
{{ templateOptions.offline.name }}
</option>
<option disabled>----------</option>
<optgroup :label="$t('config.vehicle.online')">
<option
v-for="option in templateOptions.online"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
<optgroup :label="$t('config.vehicle.scooter')">
<option
v-for="option in templateOptions.scooter"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
<optgroup :label="$t('config.vehicle.generic')">
<option
v-for="option in templateOptions.generic"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
</select>
<input
v-else
type="text"
:value="productName"
disabled
class="form-control w-100"
/>
</FormRow>
<p v-if="loadingTemplate">{{ $t("config.general.templateLoading") }}</p>
<Markdown v-if="description" :markdown="description" class="my-4" />
<PropertyEntry
v-for="param in normalParams"
:id="`vehicleParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
<TemplateSelector
ref="templateSelect"
v-model="templateName"
device-type="vehicle"
:is-new="isNew"
:product-name="productName"
:groups="templateOptions"
@change="templateChanged"
/>
<YamlEntry
v-if="values.type === 'custom'"
v-model="values.yaml"
type="vehicle"
:error-line="test.errorLine"
/>
<div v-else>
<p v-if="loadingTemplate">{{ $t("config.general.templateLoading") }}</p>
<Markdown v-if="description" :markdown="description" class="my-4" />
<PropertyEntry
v-for="param in normalParams"
:id="`vehicleParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
<PropertyCollapsible>
<template v-if="advancedParams.length" #advanced>
<PropertyEntry
v-for="param in advancedParams"
:id="`vehicleParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</template>
<template #more>
<h6 class="mt-3">{{ $t("config.vehicle.chargingSettings") }}</h6>
<FormRow
id="vehicleParamMode"
:label="$t('config.vehicle.defaultMode')"
:help="$t('config.vehicle.defaultModeHelp')"
>
<PropertyField
<PropertyCollapsible>
<template v-if="advancedParams.length" #advanced>
<PropertyEntry
v-for="param in advancedParams"
:id="`vehicleParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</template>
<template #more>
<h6 class="mt-3">{{ $t("config.vehicle.chargingSettings") }}</h6>
<FormRow
id="vehicleParamMode"
v-model="values.mode"
type="Choice"
class="w-100"
:choice="[
{ key: 'off', name: $t('main.mode.off') },
{ key: 'pv', name: $t('main.mode.pv') },
{ key: 'minpv', name: $t('main.mode.minpv') },
{ key: 'now', name: $t('main.mode.now') },
]"
/>
</FormRow>
<FormRow
id="vehicleParamPhases"
:label="$t('config.vehicle.maximumPhases')"
:help="$t('config.vehicle.maximumPhasesHelp')"
>
<SelectGroup
:label="$t('config.vehicle.defaultMode')"
:help="$t('config.vehicle.defaultModeHelp')"
>
<PropertyField
id="vehicleParamMode"
v-model="values.mode"
type="Choice"
class="w-100"
:choice="[
{ key: 'off', name: $t('main.mode.off') },
{ key: 'pv', name: $t('main.mode.pv') },
{ key: 'minpv', name: $t('main.mode.minpv') },
{ key: 'now', name: $t('main.mode.now') },
]"
/>
</FormRow>
<FormRow
id="vehicleParamPhases"
v-model="values.phases"
class="w-100"
:options="[
{ name: '1-phase', value: '1' },
{ name: '2-phases', value: '2' },
{ name: '3-phases', value: undefined },
]"
equal-width
transparent
/>
</FormRow>
<div class="row mb-3">
<FormRow
id="vehicleParamMinCurrent"
:label="$t('config.vehicle.minimumCurrent')"
class="col-sm-6 mb-sm-0"
:help="
values.minCurrent && values.minCurrent < 6
? $t('config.vehicle.minimumCurrentHelp')
: null
"
:label="$t('config.vehicle.maximumPhases')"
:help="$t('config.vehicle.maximumPhasesHelp')"
>
<PropertyField
<SelectGroup
id="vehicleParamPhases"
v-model="values.phases"
class="w-100"
:options="[
{ name: '1-phase', value: '1' },
{ name: '2-phases', value: '2' },
{ name: '3-phases', value: '' },
]"
equal-width
transparent
/>
</FormRow>
<div class="row mb-3">
<FormRow
id="vehicleParamMinCurrent"
v-model="values.minCurrent"
type="Float"
unit="A"
size="w-25 w-min-200"
class="me-2"
/>
</FormRow>
:label="$t('config.vehicle.minimumCurrent')"
class="col-sm-6 mb-sm-0"
:help="
values.minCurrent && values.minCurrent < 6
? $t('config.vehicle.minimumCurrentHelp')
: ''
"
>
<PropertyField
id="vehicleParamMinCurrent"
v-model="values.minCurrent"
type="Float"
unit="A"
size="w-25 w-min-200"
class="me-2"
/>
</FormRow>
<FormRow
id="vehicleParamMaxCurrent"
:label="$t('config.vehicle.maximumCurrent')"
class="col-sm-6 mb-sm-0"
:help="
values.minCurrent &&
values.maxCurrent &&
values.maxCurrent < values.minCurrent
? $t('config.vehicle.maximumCurrentHelp')
: ''
"
>
<PropertyField
id="vehicleParamMaxCurrent"
v-model="values.maxCurrent"
type="Float"
unit="A"
size="w-25 w-min-200"
class="me-2"
/>
</FormRow>
</div>
<FormRow
id="vehicleParamMaxCurrent"
:label="$t('config.vehicle.maximumCurrent')"
class="col-sm-6 mb-sm-0"
:help="
values.minCurrent &&
values.maxCurrent &&
values.maxCurrent < values.minCurrent
? $t('config.vehicle.maximumCurrentHelp')
: null
"
id="vehicleParamPriority"
:label="$t('config.vehicle.priority')"
:help="$t('config.vehicle.priorityHelp')"
>
<PropertyField
id="vehicleParamMaxCurrent"
v-model="values.maxCurrent"
type="Float"
unit="A"
size="w-25 w-min-200"
id="vehicleParamPriority"
v-model="values.priority"
type="Choice"
size="w-100"
class="me-2"
:choice="priorityOptions"
required
/>
</FormRow>
<FormRow
id="vehicleParamIdentifiers"
:label="$t('config.vehicle.identifiers')"
:help="$t('config.vehicle.identifiersHelp')"
>
<PropertyField
id="vehicleParamIdentifiers"
v-model="values.identifiers"
type="List"
property="identifiers"
size="w-100"
class="me-2"
/>
</FormRow>
</div>
</template>
</PropertyCollapsible>
</div>
<FormRow
id="vehicleParamPriority"
:label="$t('config.vehicle.priority')"
:help="$t('config.vehicle.priorityHelp')"
>
<PropertyField
id="vehicleParamPriority"
v-model="values.priority"
type="Choice"
size="w-100"
class="me-2"
:choice="priorityOptions"
required
/>
</FormRow>
<FormRow
id="vehicleParamIdentifiers"
:label="$t('config.vehicle.identifiers')"
:help="$t('config.vehicle.identifiersHelp')"
>
<PropertyField
id="vehicleParamIdentifiers"
v-model="values.identifiers"
type="List"
property="identifiers"
size="w-100"
class="me-2"
/>
</FormRow>
</template>
</PropertyCollapsible>
<TestResult
v-if="templateName"
:success="testSuccess"
:failed="testFailed"
:unknown="testUnknown"
:running="testRunning"
:result="testResult"
:error="testError"
<DeviceModalActions
v-if="showActions"
:is-deletable="isDeletable"
:test-state="test"
:is-saving="saving"
@save="isNew ? create() : update()"
@remove="remove"
@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.vehicle.delete") }}
</button>
<button
v-else
type="button"
class="btn btn-link text-muted"
data-bs-dismiss="modal"
>
{{ $t("config.vehicle.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.vehicle.validateSave") : $t("config.vehicle.save")
}}
</button>
</div>
</form>
</GenericModal>
</template>
<script>
<script lang="ts">
import { defineComponent } from "vue";
import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue";
import TestResult from "./TestResult.vue";
import SelectGroup from "../Helper/SelectGroup.vue";
import PropertyEntry from "./PropertyEntry.vue";
import PropertyCollapsible from "./PropertyCollapsible.vue";
import GenericModal from "../Helper/GenericModal.vue";
import Markdown from "./Markdown.vue";
import api from "@/api";
import test from "./mixins/test";
import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSelector.vue";
import DeviceModalActions from "./DeviceModal/Actions.vue";
import YamlEntry from "./DeviceModal/YamlEntry.vue";
import { initialTestState, performTest } from "./utils/test";
import {
handleError,
ConfigType,
type DeviceValues,
type Template,
type Product,
applyDefaultsFromTemplate,
createDeviceUtils,
} from "./DeviceModal";
import defaultYaml from "./defaultYaml/vehicle.yaml?raw";
const initialValues = { type: "template", icon: "car" };
const initialValues = { type: ConfigType.Template, icon: "car" };
const device = createDeviceUtils("vehicle");
function sleep(ms) {
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const CUSTOM_FIELDS = ["minCurrent", "maxCurrent", "priority", "identifiers", "phases", "mode"];
export default {
type VehicleDeviceValues = DeviceValues & {
identifiers: string[] | undefined;
minCurrent: number | undefined;
maxCurrent: number | undefined;
priority: number | undefined;
phases: number | undefined;
mode: string | undefined;
};
export default defineComponent({
name: "VehicleModal",
components: {
FormRow,
PropertyField,
TestResult,
GenericModal,
SelectGroup,
PropertyCollapsible,
PropertyEntry,
Markdown,
TemplateSelector,
DeviceModalActions,
YamlEntry,
},
mixins: [test],
props: {
id: Number,
},
@ -279,23 +235,41 @@ export default {
data() {
return {
isModalVisible: false,
templates: [],
products: [],
templateName: null,
template: null,
templates: [] as Template[],
products: [] as Product[],
templateName: null as string | null,
template: null as Template | null,
saving: false,
loadingTemplate: false,
values: { ...initialValues },
values: { ...initialValues } as VehicleDeviceValues,
test: initialTestState(),
};
},
computed: {
templateOptions() {
return {
online: this.products.filter((p) => !p.group && p.template !== "offline"),
generic: this.products.filter((p) => p.group === "generic"),
scooter: this.products.filter((p) => p.group === "scooter"),
offline: this.products.find((p) => p.template === "offline") || {},
};
return [
{
label: "primary",
options: [
...this.products.filter((p) => p.template === "offline"),
customTemplateOption(this.$t("config.general.customOption")),
],
},
{
label: "online",
options: this.products.filter((p) => !p.group),
},
{
label: "scooter",
options: this.products.filter((p) => p.group === "scooter"),
},
{
label: "generic",
options: this.products.filter(
(p) => p.group === "generic" && p.template !== "offline"
),
},
];
},
templateParams() {
const params = (this.template?.Params || [])
@ -311,7 +285,7 @@ export default {
// non-optional fields first
params.sort((a, b) => (a.Required ? -1 : 1) - (b.Required ? -1 : 1));
// always start with title and icon field
const order = { title: -2, icon: -1 };
const order: Record<string, number> = { title: -2, icon: -1 };
params.sort((a, b) => (order[a.Name] || 0) - (order[b.Name] || 0));
return params;
@ -326,39 +300,54 @@ export default {
return this.template?.Requirements?.Description;
},
productName() {
return this.values.deviceProduct || this.templateName;
return this.values.deviceProduct || this.templateName || "";
},
apiData() {
const data = {
template: this.templateName,
const data: Record<string, any> = {
...this.values,
};
if (this.values.type === ConfigType.Template) {
data["template"] = this.templateName;
}
// remove icon if custom
if (this.values.type === ConfigType.Custom) {
delete data["icon"];
}
// trim and remove empty lines
if (Array.isArray(data.identifiers)) {
data.identifiers = data.identifiers.map((i) => i.trim()).filter((i) => i);
if (Array.isArray(data["identifiers"])) {
data["identifiers"] = data["identifiers"].map((i) => i.trim()).filter((i) => i);
}
return data;
},
isNew() {
return this.id === undefined;
},
modalSize() {
return this.values.type === ConfigType.Custom ? "xl" : undefined;
},
isDeletable() {
return !this.isNew;
},
priorityOptions() {
const result = Array.from({ length: 11 }, (_, i) => ({ key: i, name: `${i}` }));
const result: { key: number | undefined; name: string }[] = Array.from(
{ length: 11 },
(_, i) => ({ key: i, name: `${i}` })
);
result[0].name = "0 (default)";
result[0].key = undefined;
result[10].name = "10 (highest)";
return result;
},
showActions() {
return this.templateName || this.values.type === ConfigType.Custom;
},
},
watch: {
isModalVisible(visible) {
if (visible) {
this.reset();
this.templateName = "offline";
this.resetTest();
this.test = initialTestState();
this.loadProducts();
if (this.id !== undefined) {
this.loadConfiguration();
@ -370,25 +359,25 @@ export default {
},
values: {
handler() {
this.resetTest();
this.test = initialTestState();
},
deep: true,
},
},
methods: {
reset() {
this.values = { ...initialValues };
this.resetTest();
this.values = { ...initialValues } as VehicleDeviceValues;
this.test = initialTestState();
},
async loadConfiguration() {
try {
const vehicle = (await api.get(`config/devices/vehicle/${this.id}`)).data.result;
const vehicle = await device.load(this.id as number);
this.values = vehicle.config;
// convert structure to flat list
// TODO: adjust GET response to match POST/PUT formats
this.values.type = vehicle.type;
this.values.deviceProduct = vehicle.deviceProduct;
this.applyDefaultsFromTemplate();
applyDefaultsFromTemplate(this.template, this.values);
this.templateName = this.values.template;
} catch (e) {
console.error(e);
@ -399,95 +388,72 @@ export default {
return;
}
try {
const opts = { params: { lang: this.$i18n?.locale } };
this.products = (await api.get("config/products/vehicle", opts)).data.result;
this.products = await device.loadProducts(this.$i18n?.locale);
} catch (e) {
console.error(e);
}
},
async loadTemplate() {
this.template = null;
if (!this.templateName) return;
if (!this.templateName || this.templateName === ConfigType.Custom) return;
this.loadingTemplate = true;
try {
const opts = {
params: {
lang: this.$i18n?.locale,
name: this.templateName,
},
};
const result = await api.get("config/templates/vehicle", opts);
this.template = result.data.result;
this.applyDefaultsFromTemplate();
this.template = await device.loadTemplate(this.templateName, this.$i18n?.locale);
applyDefaultsFromTemplate(this.template, this.values);
} 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() {
// persist selected template product
if (this.template) {
const select = this.$refs.templateSelect;
const name = select.options[select.selectedIndex].text;
this.values.deviceProduct = name;
if (this.template && this.$refs["templateSelect"]) {
this.values.deviceProduct = (this.$refs["templateSelect"] as any).getProductName();
}
if (this.testUnknown) {
const success = await this.test(this.testVehicle);
if (this.test.isUnknown) {
const success = await performTest(this.test, this.testVehicle, this.$refs["form"]);
if (!success) return;
await sleep(100);
}
this.saving = true;
try {
await api.post("config/devices/vehicle", this.apiData);
this.$emit("vehicle-changed");
const { name } = await device.create(this.apiData);
this.$emit("vehicle-changed", name);
this.closed();
} catch (e) {
this.handleCreateError(e);
handleError(e, "create failed");
}
this.saving = false;
},
async testManually() {
await this.test(this.testVehicle);
await performTest(this.test, this.testVehicle, this.$refs["form"]);
},
async testVehicle() {
let url = "config/test/vehicle";
if (!this.isNew) {
url += `/merge/${this.id}`;
}
return await api.post(url, this.apiData, { timeout: this.testTimeout });
return device.test(this.id, this.apiData);
},
async update() {
if (this.testUnknown) {
const success = await this.test(this.testVehicle);
if (this.test.isUnknown) {
const success = await performTest(this.test, this.testVehicle, this.$refs["form"]);
if (!success) return;
await sleep(250);
}
this.saving = true;
try {
await api.put(`config/devices/vehicle/${this.id}`, this.apiData);
await device.update(this.id as number, this.apiData);
this.$emit("vehicle-changed");
this.closed();
} catch (e) {
this.handleUpdateError(e);
handleError(e, "update failed");
}
this.saving = false;
},
async remove() {
try {
await api.delete(`config/devices/vehicle/${this.id}`);
await device.remove(this.id as number);
this.$emit("vehicle-changed");
this.closed();
} catch (e) {
this.handleRemoveError(e);
handleError(e, "remove failed");
}
},
open() {
@ -496,11 +462,16 @@ export default {
closed() {
this.isModalVisible = false;
},
templateChanged() {
templateChanged(e: Event) {
const value = (e.target as HTMLSelectElement).value;
this.reset();
if (value === ConfigType.Custom) {
this.values.type = ConfigType.Custom;
this.values.yaml = defaultYaml;
}
},
},
};
});
</script>
<style scoped>
.container {

View file

@ -7,6 +7,7 @@
:theme="theme"
:options="options"
:value="modelValue"
data-testid="yaml-editor"
@update:value="$emit('update:modelValue', $event)"
@mount="ready"
>
@ -17,6 +18,7 @@
:rows="lines"
:value="modelValue"
:disabled="disabled"
data-testid="yaml-editor-fallback"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
@ -54,6 +56,9 @@ export default {
wordWrap: "off",
wrappingStrategy: "advanced",
overviewRulerLanes: 0,
scrollbar: {
alwaysConsumeMouseWheel: false,
},
},
active: true,
pasteDisposable: null,

View file

@ -0,0 +1,66 @@
<template>
<div class="editor-container" :style="{ height: computedHeight }">
<YamlEditor
v-if="!hidden"
v-model="localValue"
class="editor"
:errorLine="errorLine"
:removeKey="removeKey"
/>
</div>
</template>
<script>
import YamlEditor from "./YamlEditor.vue";
export default {
name: "YamlEditorContainer",
components: { YamlEditor },
props: {
modelValue: String,
errorLine: { type: [Number, null], default: null },
removeKey: String,
hidden: Boolean,
},
emits: ["update:modelValue"],
data() {
return {
localValue: this.modelValue,
};
},
computed: {
computedHeight() {
return Math.max(150, (this.localValue || "").split("\n").length * 18) + 22 + "px";
},
},
watch: {
modelValue: {
handler(newVal) {
if (this.localValue !== newVal) {
this.localValue = newVal;
}
},
immediate: true,
},
localValue: {
handler(newVal) {
this.$emit("update:modelValue", newVal);
},
},
},
};
</script>
<style scoped>
.editor-container {
width: 100%;
overflow: hidden;
margin: 0 -1rem 0 -1.25rem;
}
/* reset margins on lg */
@media (min-width: 992px) {
.editor-container {
margin: 0;
}
}
</style>

View file

@ -1,5 +1,5 @@
<template>
<GenericModal ref="modal" :size="size" :title="title" @open="open">
<GenericModal ref="modal" :size="size" :title="title" @open="open" @close="close">
<p v-if="description || docsLink">
<span v-if="description">{{ description + " " }}</span>
<a v-if="docsLink" :href="docsLink" target="_blank">
@ -8,12 +8,12 @@
</p>
<p v-if="error" class="text-danger" data-testid="error">{{ error }}</p>
<form ref="form" class="container mx-0 px-0">
<div class="editor-container" :style="{ height }">
<YamlEditor
<div class="editor-container">
<YamlEditorContainer
v-model="yaml"
class="editor"
:errorLine="errorLine"
:removeKey="removeKey"
:hidden="!modalVisible"
/>
</div>
@ -48,11 +48,11 @@
import GenericModal from "../Helper/GenericModal.vue";
import api from "@/api";
import { docsPrefix } from "@/i18n";
import YamlEditor from "./YamlEditor.vue";
import YamlEditorContainer from "./YamlEditorContainer.vue";
export default {
name: "YamlModal",
components: { GenericModal, YamlEditor },
components: { GenericModal, YamlEditorContainer },
props: {
title: String,
description: String,
@ -70,15 +70,13 @@ export default {
errorLine: undefined,
yaml: "",
serverYaml: "",
modalVisible: false,
};
},
computed: {
docsLink() {
return `${docsPrefix()}${this.docs}`;
},
height() {
return Math.max(150, this.yaml.split("\n").length * 18) + 22 + "px";
},
nothingChanged() {
return this.yaml === this.serverYaml && this.yaml !== "";
},
@ -93,8 +91,12 @@ export default {
},
async open() {
this.reset();
this.modalVisible = true;
await this.load();
},
close() {
this.modalVisible = false;
},
async load() {
try {
const { data } = await api.get(this.endpoint);
@ -135,13 +137,4 @@ export default {
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
.editor-container {
margin: 0 -1rem 0 -1.25rem;
}
/* reset margins on lg */
@media (min-width: 992px) {
.editor-container {
margin: 0;
}
}
</style>

View file

@ -0,0 +1,77 @@
## required attributes
status: # charger status [A..F]
source: const
value: "A"
enabled: # is charging enabled?
source: const
value: true
enable: # enable/disable charging
source: js
script: console.log(enable)
maxcurrent: # set maximum charge current in A
source: js
script: console.log(maxcurrent) #
## optional attributes (read-only)
#power: # charge power in W
# source: const
# value: 11000
#energy: # meter reading in kWh
# source: const
# value: 42.5
#identify: # current RFID identifier
# source: const
# value: "1234567890"
#soc: # state of charge in %
# source: const
# value: 75
#phases: # number of physical phases (1..3)
# source: const
# value: 3
#powers: # phase powers in W
# - source: const
# value: 3600
# - source: const
# value: 3700
# - source: const
# value: 3800
#currents: # phase currents in A
# - source: const
# value: 16.0
# - source: const
# value: 16.1
# - source: const
# value: 16.2
#voltages: # phase voltages in V
# - source: const
# value: 230.1
# - source: const
# value: 230.2
# - source: const
# value: 230.3
#temp: # current temperature in °C (heating)
# source: const
# value: 45.5
#templimit: # temperature limit in °C (heating)
# source: const
# value: 60
#getmode: # SG-Ready mode (heat pump)
# source: const
# value: 1
## optional attributes (writeable)
#maxcurrentmilis: # set maximum charge current in A with decimal precision
# source: js
# script: console.log(maxcurrentmilis);
#phases1p3p: # switch phases (requires 'tos: true')
# source: js
# script: console.log(phases1p3p);
#wakeup: # wake up vehicle
# source: js
# script: console.log(wakeup);
#setmode: # change SG-Ready mode (1: normal, 2: boost, 3: stop)
# source: js
# script: console.log(setmode);

View file

@ -0,0 +1,50 @@
## required attributes
power: # current power
source: const
value: 1000 # W
## optional attributes (read-only)
#energy: # meter reading in kWh
# source: const
# value: 42.5
#maxpower: # maximum AC power in W (for hybrid pv)
# source: const
# value: 5000
#soc: # state of charge in % (for battery)
# source: const
# value: 75
#capacity: # capacity in kWh (for battery)
# source: const
# value: 10.5
#powers: # phase powers in W
# - source: const
# value: 330
# - source: const
# value: 340
# - source: const
# value: 330
#currents: # phase currents in A
# - source: const
# value: 1.5
# - source: const
# value: 1.6
# - source: const
# value: 1.5
#voltages: # phase voltages in V
# - source: const
# value: 230.1
# - source: const
# value: 230.2
# - source: const
# value: 230.3
## optional attributes (writeable)
#limitsoc: # set battery charge target in % (for battery)
# source: js
# script: console.log(limitsoc)
#batterymode: # set charging mode directly (1: normal, 2: hold, 3: charge) (for battery)
# source: js
# script: console.log(batterymode)

View file

@ -0,0 +1,42 @@
title: green Honda
icon: car
capacity: 50 # kWh
## required attributes
soc: # state of charge
source: const
value: 42 # %
## optional attributes (read-only)
#limitsoc: # in-vehicle charge limit
# source: const
# value: 80 # %
#status: # status [A..F]
# source: const
# value: "A"
#range: # range
# source: const
# value: 123 # km
#climater: # climate active
# source: const
# value: true
#getmaxcurrent: # max charge current
# source: const
# value: 16.0 # A
#finishtime: # finish time (RFC3339)
# source: const
# value: "2030-01-01T00:00:00Z"
## optional attributes (writeable)
#wakeup: # wake up vehicle
# source: js
# script: console.log(wakeup);
#chargeenable: # start/stop charging
# source: js
# script: console.log(chargeenable);
#maxcurrent: # set max charge current
# source: js
# script: console.log(maxcurrent);

View file

@ -0,0 +1,55 @@
import type { AxiosResponse } from "axios";
export type TestState = {
isUnknown: boolean;
isSuccess: boolean;
isError: boolean;
isRunning: boolean;
result: Record<string, any> | null;
error: string | null;
errorLine: number | null;
};
export const initialTestState = (): TestState => ({
isUnknown: true,
isSuccess: false,
isError: false,
isRunning: false,
result: null,
error: null,
errorLine: null,
});
export const performTest = async (
state: TestState,
api: () => Promise<AxiosResponse<any, any>>,
form: HTMLElement | undefined
) => {
if (form && !(form as HTMLFormElement).reportValidity()) return false;
state.isUnknown = false;
state.isSuccess = false;
state.isError = false;
state.isRunning = true;
state.errorLine = null;
try {
const res = await api();
for (const [key, value] of Object.entries(res.data.result)) {
const { error } = value as { error?: string };
if (error) {
state.isError = true;
state.error = `${key}: ${error}`;
return false;
}
}
state.isSuccess = true;
state.result = res.data.result;
return true;
} catch (e: any) {
state.isError = true;
state.error = e.response?.data?.error || e.message;
state.errorLine = e.response?.data?.line || null;
} finally {
state.isRunning = false;
}
return false;
};

View file

@ -117,6 +117,8 @@ export interface SelectOption<T> {
disabled?: boolean;
}
export type DeviceType = "charger" | "meter" | "vehicle";
// see https://stackoverflow.com/a/54178819
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

View file

@ -17,7 +17,6 @@
</p>
<p class="mb-1"><strong>Missing features</strong></p>
<ul>
<li>custom/plugin meters and vehicles</li>
<li>migration for loadpoints</li>
</ul>
<p>

View file

@ -12,6 +12,7 @@ import (
"github.com/evcc-io/evcc/cmd/shutdown"
"github.com/evcc-io/evcc/util"
"gopkg.in/yaml.v3"
)
// parseLogLevels parses --log area:level[,...] switch into levels per log area
@ -112,3 +113,14 @@ func wrapFatalError(err error) error {
return &FatalError{err}
}
func customDevice(other map[string]any) (map[string]any, error) {
customYaml, ok := other["yaml"].(string)
if !ok {
return other, nil
}
var res map[string]any
err := yaml.Unmarshal([]byte(customYaml), &res)
return res, err
}

View file

@ -170,7 +170,13 @@ NEXT:
}
log := util.NewLogger("circuit-" + cc.Name)
instance, err := circuit.NewFromConfig(context.TODO(), log, cc.Other)
props, err := customDevice(cc.Other)
if err != nil {
return fmt.Errorf("cannot decode custom circuit '%s': %w", cc.Name, err)
}
instance, err := circuit.NewFromConfig(context.TODO(), log, props)
if err != nil {
return fmt.Errorf("cannot create circuit '%s': %w", cc.Name, err)
}
@ -260,9 +266,17 @@ func configureMeters(static []config.Named, names ...string) error {
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
instance, err := meter.NewFromConfig(ctx, cc.Type, cc.Other)
props, err := customDevice(cc.Other)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom meter '%s': %w", cc.Name, err)}
}
var instance api.Meter
if err == nil {
instance, err = meter.NewFromConfig(ctx, cc.Type, props)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
}
}
if e := config.Meters().Add(config.NewConfigurableDevice(&conf, instance)); e != nil && err == nil {
@ -324,9 +338,17 @@ func configureChargers(static []config.Named, names ...string) error {
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
instance, err := charger.NewFromConfig(ctx, cc.Type, cc.Other)
props, err := customDevice(cc.Other)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)}
err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom charger '%s': %w", cc.Name, err)}
}
var instance api.Charger
if err == nil {
instance, err = charger.NewFromConfig(ctx, cc.Type, props)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)}
}
}
if e := config.Chargers().Add(config.NewConfigurableDevice(&conf, instance)); e != nil && err == nil {
@ -343,7 +365,13 @@ func configureChargers(static []config.Named, names ...string) error {
func vehicleInstance(cc config.Named) (api.Vehicle, error) {
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
instance, err := vehicle.NewFromConfig(ctx, cc.Type, cc.Other)
props, err := customDevice(cc.Other)
var instance api.Vehicle
if err == nil {
instance, err = vehicle.NewFromConfig(ctx, cc.Type, props)
}
if err != nil {
if ce := new(util.ConfigError); errors.As(err, &ce) {
return nil, err
@ -665,7 +693,12 @@ func configureHEMS(conf *globalconfig.Hems, site *core.Site, httpd *server.HTTPd
return nil
}
hems, err := hems.NewFromConfig(context.TODO(), conf.Type, conf.Other, site, httpd)
props, err := customDevice(conf.Other)
if err != nil {
return fmt.Errorf("cannot decode custom hems '%s': %w", conf.Type, err)
}
hems, err := hems.NewFromConfig(context.TODO(), conf.Type, props, site, httpd)
if err != nil {
return fmt.Errorf("failed configuring hems: %w", err)
}
@ -740,10 +773,15 @@ func configureMessengers(conf *globalconfig.Messaging, vehicles push.Vehicles, v
return messageChan, fmt.Errorf("failed configuring push services: %w", err)
}
for _, service := range conf.Services {
impl, err := push.NewFromConfig(context.TODO(), service.Type, service.Other)
for _, conf := range conf.Services {
props, err := customDevice(conf.Other)
if err != nil {
return messageChan, fmt.Errorf("failed configuring push service %s: %w", service.Type, err)
return nil, fmt.Errorf("cannot decode push service '%s': %w", conf.Type, err)
}
impl, err := push.NewFromConfig(context.TODO(), conf.Type, props)
if err != nil {
return messageChan, fmt.Errorf("failed configuring push service %s: %w", conf.Type, err)
}
messageHub.Add(impl)
}
@ -756,7 +794,12 @@ func configureMessengers(conf *globalconfig.Messaging, vehicles push.Vehicles, v
func tariffInstance(name string, conf config.Typed) (api.Tariff, error) {
ctx := util.WithLogger(context.TODO(), util.NewLogger(name))
instance, err := tariff.NewFromConfig(ctx, conf.Type, conf.Other)
props, err := customDevice(conf.Other)
if err != nil {
return nil, fmt.Errorf("cannot decode custom tariff '%s': %w", name, err)
}
instance, err := tariff.NewFromConfig(ctx, conf.Type, props)
if err != nil {
if ce := new(util.ConfigError); errors.As(err, &ce) {
return nil, err

View file

@ -113,6 +113,9 @@
"batteryStorage": "Batterie",
"solarSystem": "PV-Anlage"
},
"editor": {
"loading": "Lade YAML-Editor …"
},
"eebus": {
"description": "Grundkonfiguration für die Kommunikation mit anderen EEBus-Geräten.",
"title": "EEBus"
@ -130,6 +133,8 @@
},
"general": {
"cancel": "Abbrechen",
"customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.",
"customOption": "Benutzerdefiniertes Gerät",
"delete": "Löschen",
"docsLink": "Siehe Dokumentation.",
"experimental": "Experimentell",
@ -155,6 +160,9 @@
"description": "evcc mit einem anderen Heimenergiemanagementsystem verbinden.",
"title": "HEMS"
},
"icon": {
"change": "ändern"
},
"influx": {
"description": "Schreibt Ladedaten und andere Metriken in InfluxDB. Verwende Grafana oder andere Tools, um die Daten zu visualisieren.",
"descriptionToken": "Siehe die InfluxDB-Dokumentation, um zu erfahren, wie du einen erstellst. https://docs.influxdata.com/influxdb/v2/admin/",
@ -269,6 +277,7 @@
"meter": {
"cancel": "Abbrechen",
"delete": "Löschen",
"generic": "Generische Integrationen",
"option": {
"aux": "Intelligenten Verbraucher hinzufügen",
"battery": "Hausbatterie hinzufügen",
@ -276,6 +285,7 @@
"pv": "PV-Anlage hinzufügen"
},
"save": "Speichern",
"specific": "Spezifische Integrationen",
"template": "Hersteller",
"titleChoice": "Was möchtest du hinzufügen?",
"validateSave": "Überprüfen & speichern"
@ -417,6 +427,7 @@
"minimumCurrent": "Minimaler Strom",
"minimumCurrentHelp": "Nur unter 6A, wenn du weißt, was du tust.",
"online": "Fahrzeuge mit Schnittstelle",
"primary": "Generische Integrationen",
"priority": "Priorität",
"priorityHelp": "Höhere Priorität bedeutet, dass dieses Fahrzeug Vorrang vor anderen Fahrzeugen hat.",
"save": "Speichern",

View file

@ -113,6 +113,9 @@
"batteryStorage": "Battery storage",
"solarSystem": "Solar system"
},
"editor": {
"loading": "Loading YAML editor…"
},
"eebus": {
"description": "Configuration that enables evcc to communicate with other EEBus devices.",
"title": "EEBus"
@ -130,6 +133,8 @@
},
"general": {
"cancel": "Cancel",
"customHelp": "Create a user-defined device using evcc's plugin system.",
"customOption": "User-defined device",
"delete": "Delete",
"docsLink": "See documentation.",
"experimental": "Experimental",
@ -155,6 +160,9 @@
"description": "Connect evcc to another home energy management system.",
"title": "HEMS"
},
"icon": {
"change": "change"
},
"influx": {
"description": "Writes charging data and other metrics to InfluxDB. Use Grafana or other tools to visualize the data.",
"descriptionToken": "Check the InfluxDB documentation to learn how to create one. https://docs.influxdata.com/influxdb/v2/admin/",
@ -269,6 +277,7 @@
"meter": {
"cancel": "Cancel",
"delete": "Delete",
"generic": "Generic integrations",
"option": {
"aux": "Add self-regulating consumer",
"battery": "Add battery meter",
@ -276,6 +285,7 @@
"pv": "Add solar meter"
},
"save": "Save",
"specific": "Specific integrations",
"template": "Manufacturer",
"titleChoice": "What Do You Want To Add?",
"validateSave": "Validate & save"
@ -417,6 +427,7 @@
"minimumCurrent": "Minimum current",
"minimumCurrentHelp": "Only go below 6A if you know what you're doing.",
"online": "Vehicles with online API",
"primary": "Generic integrations",
"priority": "Priority",
"priorityHelp": "Higher priority means this vehicle gets preferred access to solar surplus.",
"save": "Save",

View file

@ -2,6 +2,7 @@ package plugin
import (
"context"
"errors"
"fmt"
reg "github.com/evcc-io/evcc/util/registry"
@ -60,6 +61,10 @@ func plugin[T any](typ string, ctx context.Context, config *Config) (T, error) {
return zero, nil
}
if config.Source == "" {
return zero, errors.New("missing plugin source")
}
factory, err := registry.Get(config.Source)
if err != nil {
return zero, err

View file

@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"slices"
"strconv"
@ -224,11 +223,12 @@ func Yaml(key string, other, res any) error {
if err != nil {
return err
}
if s == "" {
return ErrNotFound
}
if err := yaml.NewDecoder(bytes.NewBuffer([]byte(s))).Decode(&other); err != nil && err != io.EOF {
if err := yaml.Unmarshal([]byte(s), &other); err != nil {
return err
}

View file

@ -2,7 +2,6 @@ package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@ -99,12 +98,20 @@ func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[st
return nil, err
}
params, err := sanitizeMasked(class, conf.Other)
if err != nil {
return nil, err
if conf.Type == typeTemplate {
// template device, mask config
params, err := sanitizeMasked(class, conf.Other)
if err != nil {
return nil, err
}
dc["config"] = params
} else {
// custom device, no masking
dc["config"] = conf.Other
}
dc["config"] = params
} else {
}
if dc["config"] == nil {
// add title if available
config := make(map[string]any)
if title, ok := conf.Other["title"].(string); ok {
@ -183,7 +190,7 @@ func deviceStatus[T any](name string, h config.Handler[T]) (T, error) {
return dev.Instance(), nil
}
// deviceStatusHandler returns a device configuration by class
// deviceStatusHandler returns the device test status by class
func deviceStatusHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@ -225,7 +232,7 @@ func newDevice[T any](ctx context.Context, class templates.Class, req configReq,
return nil, err
}
conf, err := config.AddConfig(class, req.Other, config.WithProperties(req.Properties))
conf, err := config.AddConfig(class, req.Serialise(), config.WithProperties(req.Properties))
if err != nil {
return nil, err
}
@ -243,8 +250,8 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
return
}
var req configReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
req, err := decodeDeviceConfig(r.Body)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
@ -300,6 +307,7 @@ func updateDevice[T any](ctx context.Context, id int, class templates.Class, req
if !ok {
return errors.New("not configurable")
}
return configurable.Update(merged, instance, config.WithProperties(req.Properties))
}
@ -319,8 +327,8 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
return
}
var req configReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
req, err := decodeDeviceConfig(r.Body)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
@ -528,8 +536,8 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) {
}
}
var req configReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
req, err := decodeDeviceConfig(r.Body)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}

View file

@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"errors"
"io"
"reflect"
"slices"
"strings"
"sync"
"time"
@ -16,11 +18,12 @@ import (
"github.com/evcc-io/evcc/util/templates"
"github.com/go-viper/mapstructure/v2"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
)
const (
// typeTemplate is the updatable configuration type
typeTemplate = "template"
typeCustom = "custom" // typeCustom is the custom configuration type
typeTemplate = "template" // typeTemplate is the updatable configuration type
// masked indicates a masked config parameter value
masked = "***"
@ -28,6 +31,7 @@ const (
type configReq struct {
config.Properties `json:",inline" mapstructure:",squash"`
Yaml string
Other map[string]any `json:",inline" mapstructure:",remain"`
}
@ -47,6 +51,15 @@ func (c *configReq) UnmarshalJSON(data []byte) error {
return nil
}
func (c *configReq) Serialise() map[string]any {
if c.Yaml != "" {
return map[string]any{
"yaml": c.Yaml,
}
}
return c.Other
}
func propsToMap(props config.Properties) (map[string]any, error) {
res := make(map[string]any)
if err := mapstructure.Decode(props, &res); err != nil {
@ -158,6 +171,12 @@ func deviceInstanceFromMergedConfig[T any](ctx context.Context, id int, class te
conf := dev.Config()
// TODO merge custom config
if req.Yaml != "" {
instance, err := newFromConf(ctx, conf.Type, req.Other)
return dev, instance, req.Serialise(), err
}
merged, err := mergeMasked(class, req.Other, conf.Other)
if err != nil {
return nil, zero, nil, err
@ -307,3 +326,29 @@ func (maskedTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Val
return nil
}
}
func decodeDeviceConfig(r io.Reader) (configReq, error) {
var res configReq
if err := json.NewDecoder(r).Decode(&res); err != nil {
return configReq{}, err
}
if res.Yaml == "" {
return res, nil
}
if !strings.EqualFold(res.Type, typeCustom) {
return configReq{}, errors.New("invalid config: yaml only allowed for custom type")
}
if len(res.Other) != 0 {
return configReq{}, errors.New("invalid config: cannot mix yaml and other")
}
if err := yaml.Unmarshal([]byte(res.Yaml), &res.Other); err != nil && err != io.EOF {
return configReq{}, err
}
return res, nil
}

View file

@ -1,7 +1,6 @@
package server
import (
"bytes"
"encoding/json"
"io"
"net/http"
@ -54,7 +53,7 @@ func settingsSetYamlHandler(key string, other, struc any) http.HandlerFunc {
return
}
if err := yaml.NewDecoder(bytes.NewBuffer(b)).Decode(&other); err != nil && err != io.EOF {
if err := yaml.Unmarshal(b, &other); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}

View file

@ -1,5 +1,4 @@
template: tessie
group: generic
products:
- description:
generic: Tessie

View file

@ -1,8 +1,11 @@
template: offline
products:
- description:
en: Generic vehicle
de: Generisches Fahrzeug
en: Generic vehicle (without API)
de: Generisches Fahrzeug (ohne API)
requirements:
description:
de:
group: generic
params:
- preset: vehicle-common

View file

@ -1,15 +1,21 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils";
import {
editorClear,
editorType,
enableExperimental,
expectModalHidden,
expectModalVisible,
} from "./utils";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
test.beforeEach(async () => {
await start(CONFIG_GRID_ONLY);
});
test.afterAll(async () => {
test.afterEach(async () => {
await stop();
});
@ -58,4 +64,174 @@ test.describe("aux meter", async () => {
await expect(page.getByTestId("aux")).toHaveCount(0);
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
});
test("user-defined meter", async ({ page }) => {
await page.goto("/#/config");
await enableExperimental(page);
await page.getByRole("button", { name: "Add additional meter" }).click();
const modal = page.getByTestId("meter-modal");
await expectModalVisible(modal);
await modal.getByRole("button", { name: "Add self-regulating consumer" }).click();
await modal.getByLabel("Title").fill("Large heater");
await modal.getByLabel("Manufacturer").selectOption("User-defined device");
await page.waitForLoadState("networkidle");
const editor = modal.getByTestId("yaml-editor");
await expect(editor).toContainText("power: # current power");
await editorClear(editor);
await editorType(editor, [
// prettier-ignore
"power:",
" source: const",
"value: 3000 # W",
"Shift+Tab",
"energy:",
" source: const",
"value: 42.0 # kWh",
]);
const restResult = modal.getByTestId("test-result");
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: successful");
await expect(restResult).toContainText(["Power", "3.0 kW"].join(""));
await expect(restResult).toContainText(["Energy", "42.0 kWh"].join(""));
// create
await modal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(modal);
await expect(page.getByTestId("aux")).toHaveCount(1);
await expect(page.getByTestId("aux")).toContainText("Large heater");
// restart evcc
await restart(CONFIG_GRID_ONLY);
await page.reload();
await expect(page.getByTestId("aux")).toHaveCount(1);
await expect(page.getByTestId("aux")).toContainText("Large heater");
await page.getByTestId("aux").getByRole("button", { name: "edit" }).click();
await expectModalVisible(modal);
await expect(modal.getByLabel("Manufacturer")).toHaveValue("User-defined device");
await page.waitForLoadState("networkidle");
await expect(editor).toContainText("value: 3000 # W");
await expect(editor).toContainText("value: 42.0 # kWh");
// update
await modal.getByLabel("Title").fill("Small heater");
await editorClear(editor);
await editorType(editor, [
// prettier-ignore
"power:",
" source: const",
"value: 300 # W",
"Shift+Tab",
"energy:",
" source: const",
"value: 4.2 # kWh",
]);
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: successful");
await expect(restResult).toContainText(["Power", "0.3 kW"].join(""));
await expect(restResult).toContainText(["Energy", "4.2 kWh"].join(""));
await modal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(modal);
await expect(page.getByTestId("aux")).toHaveCount(1);
await expect(page.getByTestId("aux")).toContainText("Small heater");
// delete
await page.getByTestId("aux").getByRole("button", { name: "edit" }).click();
await expectModalVisible(modal);
await expect(editor).toContainText("value: 300 # W");
await expect(editor).toContainText("value: 4.2 # kWh");
await modal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(modal);
await expect(page.getByTestId("aux")).toHaveCount(0);
// restart evcc
await restart(CONFIG_GRID_ONLY);
await page.reload();
await expect(page.getByTestId("aux")).toHaveCount(0);
});
test("user-defined meter with errors", async ({ page }) => {
await page.goto("/#/config");
await enableExperimental(page);
await page.getByRole("button", { name: "Add additional meter" }).click();
const modal = page.getByTestId("meter-modal");
await expectModalVisible(modal);
await modal.getByRole("button", { name: "Add self-regulating consumer" }).click();
await modal.getByLabel("Title").fill("Large heater");
await modal.getByLabel("Manufacturer").selectOption("User-defined device");
await page.waitForLoadState("networkidle");
const editor = modal.getByTestId("yaml-editor");
// yaml syntax error
await editorClear(editor);
await editorType(editor, [
// prettier-ignore
"hello: world",
" foo: bar",
]);
// no errors
await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
const restResult = modal.getByTestId("test-result");
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText(
"yaml: line 2: mapping values are not allowed in this context"
);
await expect(editor.locator(".line-numbers.error")).toHaveCount(1);
// invalid field error
await editorClear(editor);
await editorType(editor, [
// prettier-ignore
"apower:",
" source: const",
"value: 3000 # W",
]);
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("has invalid keys: apower");
await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
// unknown source error
await editorClear(editor);
await editorType(editor, [
// prettier-ignore
"power:",
" source: unknown",
"value: 3000 # W",
]);
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("invalid plugin type: unknown");
await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
// missing required field error
await editorClear(editor);
await editorType(editor, [
// prettier-ignore
"energy:",
" source: const",
"value: 300 # kWh",
]);
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("power: missing plugin source");
await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
});
});

View file

@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { expectModalVisible, expectModalHidden } from "./utils";
import { expectModalVisible, expectModalHidden, editorClear, editorType } from "./utils";
const CONFIG_EMPTY = "config-empty.evcc.yaml";
const CONFIG_ONE_LP = "config-one-lp.evcc.yaml";
@ -49,7 +49,7 @@ async function addVehicle(page, title) {
await page.getByRole("button", { name: "Add vehicle" }).click();
const modal = page.getByTestId("vehicle-modal");
await expectModalVisible(modal);
await modal.getByLabel("Manufacturer").selectOption("Generic vehicle");
await modal.getByLabel("Manufacturer").selectOption("Generic vehicle (without API)");
await modal.getByLabel("Title").fill(title);
await modal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(modal);
@ -414,4 +414,78 @@ test.describe("loadpoint", async () => {
lpModal.getByRole("button", { name: "Add dedicated charger meter" })
).toBeVisible();
});
test("user-defined charger", async ({ page }) => {
await start(CONFIG_EMPTY);
await page.goto("/#/config");
await enableExperimental(page);
// add loadpoint
await newLoadpoint(page, "Carport");
const lpModal = page.getByTestId("loadpoint-modal");
await lpModal.getByRole("button", { name: "Add charger" }).click();
// add user-defined charger
const chargerModal = page.getByTestId("charger-modal");
await expectModalVisible(chargerModal);
await chargerModal.getByLabel("Manufacturer").selectOption("User-defined device");
await page.waitForLoadState("networkidle");
const editor = chargerModal.getByTestId("yaml-editor");
await expect(editor).toContainText("status: # charger status [A..F]");
await editorClear(editor, 10);
await editorType(editor, [
// prettier-ignore
"status:\n source: const\nvalue: 'C'",
"Shift+Tab",
"enabled:\n source: const\nvalue: true",
"Shift+Tab",
"enable:\n source: js\nscript: console.log(enable)",
"Shift+Tab",
"maxcurrent:\n source: js\nscript: console.log(maxcurrent)",
"Shift+Tab",
"power:\n source: const\nvalue: 11000",
]);
const restResult = chargerModal.getByTestId("test-result");
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: successful");
await expect(restResult).toContainText(["Status", "charging"].join(""));
await expect(restResult).toContainText(["Enabled", "yes"].join(""));
await expect(restResult).toContainText(["Power", "11.0 kW"].join(""));
// create
await chargerModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(chargerModal);
await expectModalVisible(lpModal);
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Carport");
// restart evcc
await restart(CONFIG_EMPTY);
await page.reload();
const lpEntry = page.getByTestId("loadpoint");
await expect(lpEntry).toHaveCount(1);
await expect(lpEntry).toContainText("Carport");
await expect(lpEntry).toContainText(["Status", "charging"].join(""));
await expect(lpEntry).toContainText(["Enabled", "yes"].join(""));
await expect(lpEntry).toContainText(["Power", "11.0 kW"].join(""));
await lpEntry.getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal);
await expect(lpModal.getByLabel("Charger").first()).toHaveValue("User-defined device [db:1]");
await lpModal.getByLabel("Charger").first().click();
await expectModalVisible(chargerModal);
await expect(chargerModal.getByLabel("Manufacturer")).toHaveValue("User-defined device");
await page.waitForLoadState("networkidle");
await expect(editor).toContainText("value: 'C'");
await expect(editor).toContainText("value: 11000");
});
});

View file

@ -1,6 +1,12 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils";
import {
enableExperimental,
expectModalHidden,
expectModalVisible,
editorClear,
editorType,
} from "./utils";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
const CONFIG_WITH_TARIFFS = "config-with-tariffs.evcc.yaml";
@ -12,8 +18,6 @@ test.afterEach(async () => {
await stop();
});
const SELECT_ALL = "ControlOrMeta+KeyA";
async function goToConfig(page) {
await page.goto("/#/config");
await enableExperimental(page);
@ -38,31 +42,24 @@ test.describe("tariffs", async () => {
await page.waitForLoadState("networkidle");
// default content
await expect(modal).toContainText("#currency: EUR");
const editor = modal.getByTestId("yaml-editor");
await expect(editor).toContainText("#currency: EUR");
// clear and enter invalid yaml
await modal.locator(".monaco-editor .view-line").nth(0).click();
for (let i = 0; i < 4; i++) {
await page.keyboard.press(SELECT_ALL, { delay: 10 });
await page.keyboard.press("Backspace", { delay: 10 });
}
await page.keyboard.type("foo: bar\n");
await editorClear(editor);
await editorType(editor, "foo: bar");
await page.getByRole("button", { name: "Save" }).click();
await expect(modal.getByTestId("error")).toContainText("invalid keys: foo");
// clear and enter valid yaml
await modal.locator(".monaco-editor .view-line").nth(0).click();
for (let i = 0; i < 4; i++) {
await page.keyboard.press(SELECT_ALL, { delay: 10 });
await page.keyboard.press("Backspace", { delay: 10 });
}
await page.keyboard.type("currency: CHF\n");
await page.keyboard.type("grid:\n");
await page.keyboard.type(" type: fixed\n");
await page.keyboard.type("price: 0.123\n");
await editorClear(editor);
await editorType(editor, [
// prettier-ignore
"currency: CHF",
"grid:",
" type: fixed",
"price: 0.123",
]);
await page.getByRole("button", { name: "Save" }).click();
await expect(modal.getByTestId("error")).not.toBeVisible();

View file

@ -1,6 +1,12 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils";
import {
editorClear,
editorType,
enableExperimental,
expectModalHidden,
expectModalVisible,
} from "./utils";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
const CONFIG_WITH_VEHICLE = "config-with-vehicle.evcc.yaml";
@ -12,6 +18,8 @@ test.afterEach(async () => {
await stop();
});
const GENERIC_VEHICLE = "Generic vehicle (without API)";
test.describe("vehicles", async () => {
test("create, edit and delete vehicles", async ({ page }) => {
await start(CONFIG_GRID_ONLY);
@ -24,7 +32,7 @@ test.describe("vehicles", async () => {
// create #1
await page.getByTestId("add-vehicle").click();
await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await vehicleModal.getByLabel("Title").fill("Green Car");
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
@ -32,7 +40,7 @@ test.describe("vehicles", async () => {
// create #2
await page.getByTestId("add-vehicle").click();
await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await vehicleModal.getByLabel("Title").fill("Yellow Van");
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
@ -79,12 +87,12 @@ test.describe("vehicles", async () => {
// create #1 & #2
await page.getByTestId("add-vehicle").click();
await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
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("Manufacturer").selectOption(GENERIC_VEHICLE);
await vehicleModal.getByLabel("Title").fill("Yellow Van");
await vehicleModal.getByLabel("car").click();
await vehicleModal.getByLabel("van").check();
@ -112,7 +120,7 @@ test.describe("vehicles", async () => {
// create #2
await page.getByTestId("add-vehicle").click();
await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await vehicleModal.getByLabel("Title").fill("Green Car");
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
@ -131,7 +139,7 @@ test.describe("vehicles", async () => {
const vehicleModal = page.getByTestId("vehicle-modal");
// generic
await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await expect(vehicleModal.getByLabel("Title")).toBeVisible();
await expect(vehicleModal.getByLabel("Car")).toBeVisible(); // icon
await expect(vehicleModal.getByLabel("Battery capacity")).toBeVisible();
@ -169,7 +177,7 @@ test.describe("vehicles", async () => {
const vehicleModal = page.getByTestId("vehicle-modal");
// generic
await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await vehicleModal.getByLabel("Title").fill("RFID Car");
await page.getByRole("button", { name: "Show advanced settings" }).click();
await vehicleModal.getByLabel("RFID identifiers").fill("aaa\nbbb \n ccc\n\nddd\n");
@ -188,4 +196,86 @@ test.describe("vehicles", async () => {
await vehicleModal.getByLabel("Close").click();
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
});
test("user-defined vehicle", async ({ page }) => {
await start(CONFIG_GRID_ONLY);
await page.goto("/#/config");
await enableExperimental(page);
await page.getByTestId("add-vehicle").click();
const modal = page.getByTestId("vehicle-modal");
await expectModalVisible(modal);
await modal.getByLabel("Manufacturer").selectOption("User-defined device");
await page.waitForLoadState("networkidle");
const editor = modal.getByTestId("yaml-editor");
await expect(editor).toContainText("title: green Honda");
await editorClear(editor);
await editorType(editor, [
// prettier-ignore
"title: blue Honda",
"capacity: 12.3",
"soc:",
" source: const",
"value: 42",
]);
const restResult = modal.getByTestId("test-result");
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: successful");
await expect(restResult).toContainText(["Capacity", "12.3 kWh"].join(""));
await expect(restResult).toContainText(["Charge", "42.0%"].join(""));
// create
await modal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(modal);
await expect(page.getByTestId("vehicle")).toHaveCount(1);
// restart evcc
await restart(CONFIG_GRID_ONLY);
await page.reload();
await expect(page.getByTestId("vehicle")).toHaveCount(1);
await page.getByTestId("vehicle").getByRole("button", { name: "edit" }).click();
await expectModalVisible(modal);
await expect(modal.getByLabel("Manufacturer")).toHaveValue("User-defined device");
await page.waitForLoadState("networkidle");
await expect(editor).toContainText("title: blue Honda");
// update
await editorClear(editor);
await editorType(editor, [
// prettier-ignore
"title: pink Honda",
"capacity: 23.4",
"soc:",
" source: const",
"value: 32",
]);
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: successful");
await expect(restResult).toContainText(["Capacity", "23.4 kWh"].join(""));
await expect(restResult).toContainText(["Charge", "32.0%"].join(""));
await modal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(modal);
await expect(page.getByTestId("vehicle")).toHaveCount(1);
// delete
await page.getByTestId("vehicle").getByRole("button", { name: "edit" }).click();
await expectModalVisible(modal);
await expect(editor).toContainText("title: pink Honda");
await modal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(modal);
await expect(page.getByTestId("vehicle")).toHaveCount(0);
// restart evcc
await restart(CONFIG_GRID_ONLY);
await page.reload();
await expect(page.getByTestId("vehicle")).toHaveCount(0);
});
});

View file

@ -1,19 +0,0 @@
import { expect } from "@playwright/test";
export async function enableExperimental(page) {
await page.getByTestId("topnavigation-button").click();
await page.getByTestId("topnavigation-settings").click();
await page.getByLabel("Experimental 🧪").click();
await page.getByRole("button", { name: "Close" }).click();
await expect(page.locator(".modal-backdrop")).not.toBeVisible();
}
export async function expectModalVisible(modal) {
await expect(modal).toBeVisible();
await expect(modal).toHaveAttribute("aria-hidden", "false");
}
export async function expectModalHidden(modal) {
await expect(modal).not.toBeVisible();
await expect(modal).toHaveAttribute("aria-hidden", "true");
}

40
tests/utils.ts Normal file
View file

@ -0,0 +1,40 @@
import { expect, Page, Locator } from "@playwright/test";
export async function enableExperimental(page: Page): Promise<void> {
await page.getByTestId("topnavigation-button").click();
await page.getByTestId("topnavigation-settings").click();
await page.getByLabel("Experimental 🧪").click();
await page.getByRole("button", { name: "Close" }).click();
await expect(page.locator(".modal-backdrop")).not.toBeVisible();
}
export async function expectModalVisible(modal: Locator): Promise<void> {
await expect(modal).toBeVisible();
await expect(modal).toHaveAttribute("aria-hidden", "false");
}
export async function expectModalHidden(modal: Locator): Promise<void> {
await expect(modal).not.toBeVisible();
await expect(modal).toHaveAttribute("aria-hidden", "true");
}
export async function editorClear(editor: Locator, iterations = 6): Promise<void> {
await editor.locator(".view-line").nth(0).click();
for (let i = 0; i < iterations; i++) {
await editor.page().keyboard.press("ControlOrMeta+KeyA", { delay: 10 });
await editor.page().keyboard.press("Backspace", { delay: 10 });
}
}
export async function editorType(editor: Locator, text: string | string[]): Promise<void> {
const instructions = ["Shift+Tab"];
await editor.locator(".view-line").nth(0).click();
const lines = Array.isArray(text) ? text : [text];
for (const line of lines) {
if (instructions.includes(line)) {
await editor.page().keyboard.press(line);
} else {
await editor.page().keyboard.type(line + "\n");
}
}
}