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:
parent
d264ec41c5
commit
914ac75167
38 changed files with 1920 additions and 857 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
69
assets/js/components/Config/DeviceModal/Actions.vue
Normal file
69
assets/js/components/Config/DeviceModal/Actions.vue
Normal 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>
|
||||
|
|
@ -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) {
|
||||
92
assets/js/components/Config/DeviceModal/TemplateSelector.vue
Normal file
92
assets/js/components/Config/DeviceModal/TemplateSelector.vue
Normal 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>
|
||||
57
assets/js/components/Config/DeviceModal/YamlEntry.vue
Normal file
57
assets/js/components/Config/DeviceModal/YamlEntry.vue
Normal 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>
|
||||
126
assets/js/components/Config/DeviceModal/index.ts
Normal file
126
assets/js/components/Config/DeviceModal/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
66
assets/js/components/Config/YamlEditorContainer.vue
Normal file
66
assets/js/components/Config/YamlEditorContainer.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
77
assets/js/components/Config/defaultYaml/charger.yaml
Normal file
77
assets/js/components/Config/defaultYaml/charger.yaml
Normal 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);
|
||||
50
assets/js/components/Config/defaultYaml/meter.yaml
Normal file
50
assets/js/components/Config/defaultYaml/meter.yaml
Normal 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)
|
||||
42
assets/js/components/Config/defaultYaml/vehicle.yaml
Normal file
42
assets/js/components/Config/defaultYaml/vehicle.yaml
Normal 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);
|
||||
55
assets/js/components/Config/utils/test.ts
Normal file
55
assets/js/components/Config/utils/test.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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>>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
65
cmd/setup.go
65
cmd/setup.go
|
|
@ -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
|
||||
|
|
|
|||
11
i18n/de.json
11
i18n/de.json
|
|
@ -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",
|
||||
|
|
|
|||
11
i18n/en.json
11
i18n/en.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
template: tessie
|
||||
group: generic
|
||||
products:
|
||||
- description:
|
||||
generic: Tessie
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
40
tests/utils.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue