Config UI: add ext meter (#24044)

This commit is contained in:
Michael Geers 2025-10-10 09:47:45 +02:00 • committed by GitHub
parent 57f74ecc26
commit 4645b67e0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 504 additions and 170 deletions

View file

@ -106,7 +106,7 @@ export default defineComponent({
this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "connector") &&
this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "stationid");
if (isOcpp && this.currentValues) {
return `ws://${window.location.hostname}:8887/${this.currentValues["stationid"] || ""}`;
return `ws://${window.location.hostname}:8887/${this.currentValues.stationid || ""}`;
}
return null;
},
@ -174,9 +174,9 @@ export default defineComponent({
);
},
transformApiData(data: ApiData): ApiData {
if (this.isYamlInput(data["type"] as ConfigType)) {
if (data.type && this.isYamlInput(data.type)) {
// Icon is extracted from yaml on GET for UI purpose only. Don't write it back.
delete data["icon"];
delete data.icon;
}
return data;
},
@ -189,7 +189,7 @@ export default defineComponent({
ConfigType.SgReadyBoost,
].includes(type);
},
async handleTemplateChange(e: Event, values: DeviceValues) {
handleTemplateChange(e: Event, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value as ConfigType;
if (this.isYamlInput(value)) {
values.type = value;
@ -210,8 +210,8 @@ export default defineComponent({
}
});
// default heater icon
if (hasParam("icon") && values["icon"] === undefined) {
values["icon"] = "heater";
if (hasParam("icon") && values.icon === undefined) {
values.icon = "heater";
}
}
},

View file

@ -175,11 +175,15 @@ export default defineComponent({
// Optional: provide template options from parent (to avoid circular dependency)
provideTemplateOptions: Function as PropType<(products: Product[]) => TemplateGroup[]>,
// Optional: handle template change (receives event and values, allows setting values.yaml)
onTemplateChange: Function as PropType<(e: Event, values: DeviceValues) => Promise<void>>,
onTemplateChange: Function as PropType<(e: Event, values: DeviceValues) => void>,
// Optional: default template to select when opening modal for new devices
defaultTemplate: String,
// Optional: callback after configuration is loaded (receives values)
onConfigurationLoaded: Function as PropType<(values: DeviceValues) => void>,
// Optional: external template selection control (for parent to reset template)
externalTemplate: String as PropType<string | null>,
},
emits: ["added", "updated", "removed", "close", "template-changed"],
emits: ["added", "updated", "removed", "close", "template-changed", "update:externalTemplate"],
data() {
return {
isModalVisible: false,
@ -289,10 +293,7 @@ export default defineComponent({
return this.templateName || this.showYamlInput;
},
showYamlInput() {
if (this.isYamlInputType) {
return this.isYamlInputType(this.values.type);
}
return this.values.type === ConfigType.Custom;
return this.isYamlInputTypeByValue(this.values.type);
},
showTemplateSelector() {
return this.computedTemplateOptions.length > 0;
@ -309,15 +310,22 @@ export default defineComponent({
this.loadProducts();
if (this.id !== undefined) {
this.loadConfiguration();
} else {
// For new devices, apply defaults immediately (e.g., default icons based on meter type)
this.applyDefaults();
}
}
},
templateName(newValue, oldValue) {
// Sync back to parent if using externalTemplate
if (this.externalTemplate !== undefined && newValue !== this.externalTemplate) {
this.$emit("update:externalTemplate", newValue);
}
console.log("templateName changed", { newValue, oldValue });
// Reset values when template changes (except on initial load or when switching to YAML input)
// YAML input types set values.type and values.yaml in handleTemplateChange callback
const isYamlInput =
this.isYamlInputType && newValue && this.isYamlInputType(newValue as any);
if (oldValue != null && !isYamlInput) {
if (oldValue != null) {
if (this.preserveOnTemplateChange) {
const preserved: Record<string, any> = {};
this.preserveOnTemplateChange.forEach((field) => {
@ -331,7 +339,11 @@ export default defineComponent({
this.reset();
}
}
this.loadTemplate();
const isYamlInput = this.isYamlInputTypeByValue(newValue as ConfigType);
if (!isYamlInput) {
this.loadTemplate();
}
},
usage() {
// Reload products when usage changes (e.g., meter type selection)
@ -339,6 +351,19 @@ export default defineComponent({
// Apply defaults when usage changes (e.g., set default icon for meter type)
this.applyDefaults();
},
externalTemplate(newValue) {
// Allow parent to control template selection
if (newValue !== this.templateName) {
this.templateName = newValue;
}
},
showMainContent(visible) {
// When main content becomes visible (e.g., meter type selected in MeterModal),
// apply defaults like icon based on type
if (visible) {
this.applyDefaults();
}
},
values: {
handler() {
this.test = initialTestState();
@ -360,13 +385,18 @@ export default defineComponent({
this.values.type = device.type;
this.values.deviceProduct = device.deviceProduct;
if (device.deviceTitle !== undefined) {
this.values["deviceTitle"] = device.deviceTitle;
this.values.deviceTitle = device.deviceTitle;
}
if (device.deviceIcon !== undefined) {
this.values["deviceIcon"] = device.deviceIcon;
this.values.deviceIcon = device.deviceIcon;
}
this.applyDefaults();
this.templateName = this.values.template;
// Allow parent to handle post-load logic
if (this.onConfigurationLoaded) {
this.onConfigurationLoaded(this.values);
}
} catch (e) {
console.error(e);
}
@ -485,13 +515,13 @@ export default defineComponent({
this.$emit("close");
this.isModalVisible = false;
},
async handleTemplateChange(e: Event) {
// Allow parent to handle custom logic (e.g., loading default YAML)
if (this.onTemplateChange) {
await this.onTemplateChange(e, this.values);
}
// Emit to parent for notification
this.$emit("template-changed", e);
handleTemplateChange(e: Event) {
// ensure this triggers after tempateName watcher
this.$nextTick(() => {
if (this.onTemplateChange) {
this.onTemplateChange(e, this.values);
}
});
},
handleSave(force: boolean) {
if (this.isNew) {
@ -503,6 +533,12 @@ export default defineComponent({
handleRemove() {
this.remove();
},
isYamlInputTypeByValue(value: ConfigType): boolean {
if (this.isYamlInputType) {
return this.isYamlInputType(value);
}
return value === ConfigType.Custom;
},
},
});
</script>

View file

@ -6,6 +6,7 @@
ref="select"
v-model="modelProxy"
class="form-select w-100"
:disabled="disabled"
@change="changed"
>
<template v-for="group in groups" :key="group.label">
@ -63,6 +64,7 @@ export default defineComponent({
modelValue: String as PropType<string | null>,
productName: String,
groups: Array as PropType<TemplateGroup[]>,
disabled: Boolean,
},
emits: ["update:modelValue", "change"],
computed: {

View file

@ -1,4 +1,4 @@
import type { DeviceType } from "@/types/evcc";
import type { DeviceType, MeterTemplateUsage } from "@/types/evcc";
import { ConfigType } from "@/types/evcc";
import api from "@/api";
@ -15,7 +15,7 @@ export type Template = {
};
};
export type TemplateType = "vehicle" | "battery" | "grid" | "pv" | "charger" | "aux" | "ext";
export type TemplateParamUsage = "vehicle" | "battery" | "grid" | "pv" | "charger" | "aux" | "ext";
export type TemplateParam = {
Name: string;
@ -24,7 +24,7 @@ export type TemplateParam = {
Deprecated: boolean;
Default?: string | number | boolean;
Choice?: string[];
Usages?: TemplateType[];
Usages?: TemplateParamUsage[];
};
export type ModbusCapability = "rs485" | "tcpip";
@ -38,14 +38,27 @@ export type ModbusParam = TemplateParam & {
export type DeviceValues = {
type: ConfigType;
icon: string | undefined;
deviceProduct: string | undefined;
yaml: string | undefined;
icon?: string;
deviceProduct?: string;
yaml?: string;
template: string | null;
deviceTitle?: string;
deviceIcon?: string;
usage?: MeterTemplateUsage;
heating?: boolean;
integrateddevice?: boolean;
stationid?: string;
[key: string]: any;
};
export type ApiData = Record<string, any>;
export type ApiData = {
type?: ConfigType;
icon?: string;
usage?: MeterTemplateUsage;
title?: string;
identifiers?: string[];
[key: string]: any;
};
export function handleError(e: any, msg: string) {
console.error(e);

View file

@ -0,0 +1,94 @@
<template>
<DeviceCard
:title="cardTitle"
:name="meter.name"
:editable="!!meter.id"
:error="hasError"
:data-testid="meterType"
@edit="$emit('edit', meterType, meter.id)"
>
<template #icon>
<VehicleIcon v-if="isVehicleIcon" :name="iconName" />
<component :is="iconComponent" v-else />
</template>
<template #tags>
<DeviceTags :tags="tags" />
</template>
</DeviceCard>
</template>
<script>
import DeviceCard from "./DeviceCard.vue";
import DeviceTags from "./DeviceTags.vue";
import VehicleIcon from "../VehicleIcon";
export default {
name: "MeterCard",
components: {
DeviceCard,
DeviceTags,
VehicleIcon,
},
props: {
meter: {
type: Object,
required: true,
},
meterType: {
type: String,
required: true,
validator: (value) => ["grid", "pv", "battery", "aux", "ext"].includes(value),
},
hasError: {
type: Boolean,
default: false,
},
tags: {
type: Object,
default: () => ({}),
},
},
emits: ["edit"],
computed: {
cardTitle() {
if (this.meter.deviceTitle) {
return this.meter.deviceTitle;
}
if (this.meter.config?.template) {
return this.meter.config.template;
}
return this.fallbackTitle;
},
fallbackTitle() {
const titleMap = {
grid: this.$t("config.grid.title"),
pv: this.$t("config.devices.solarSystem"),
battery: this.$t("config.devices.batteryStorage"),
aux: this.$t("config.devices.auxMeter"),
ext: this.$t("config.devices.extMeter"),
};
return titleMap[this.meterType];
},
isVehicleIcon() {
return this.meterType === "aux" || this.meterType === "ext";
},
iconComponent() {
const iconMap = {
grid: "shopicon-regular-powersupply",
pv: "shopicon-regular-sun",
battery: "shopicon-regular-batterythreequarters",
};
return iconMap[this.meterType];
},
iconName() {
if (this.meterType === "aux") {
return this.meter.deviceIcon || "smartconsumer";
}
if (this.meterType === "ext") {
return this.meter.deviceIcon || "generic";
}
return null;
},
},
};
</script>

View file

@ -1,7 +1,7 @@
<template>
<DeviceModalBase
:id="id"
ref="deviceModalBase"
v-model:external-template="selectedTemplate"
modal-id="meterModal"
device-type="meter"
:fade="fade"
@ -9,7 +9,6 @@
:modal-title="modalTitle"
:provide-template-options="provideTemplateOptions"
:initial-values="initialValues"
:is-yaml-input-type="isYamlInput"
:transform-api-data="transformApiData"
:filter-template-params="filterTemplateParams"
:on-template-change="handleTemplateChange"
@ -17,7 +16,8 @@
:apply-custom-defaults="applyCustomDefaults"
:custom-fields="customFields"
:preserve-on-template-change="preserveFields"
:usage="meterType || undefined"
:usage="templateUsage"
:on-configuration-loaded="onConfigurationLoaded"
@added="handleAdded"
@updated="$emit('updated')"
@removed="handleRemoved"
@ -39,17 +39,13 @@
<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">
<strong>Work in Progress:</strong> This feature is not yet available.
</div>
</template>
<template #before-template="{ values }">
<FormRow
v-if="hasDeviceTitle"
id="meterParamDeviceTitle"
label="Title"
help="Will be displayed in the user interface"
:label="$t('config.meter.titleLabel')"
>
<PropertyField
id="meterParamDeviceTitle"
@ -60,7 +56,11 @@
required
/>
</FormRow>
<FormRow v-if="hasDeviceIcon" id="meterParamDeviceIcon" label="Icon">
<FormRow
v-if="hasDeviceIcon"
id="meterParamDeviceIcon"
:label="$t('config.icon.label')"
>
<PropertyField
id="meterParamDeviceIcon"
v-model="values.deviceIcon"
@ -71,6 +71,20 @@
required
/>
</FormRow>
<FormRow
v-if="meterType === 'ext'"
id="meterParamExtMeterUsage"
:label="$t('config.meter.usage.label')"
>
<PropertyField
id="meterParamExtMeterUsage"
v-model="extMeterUsage"
:choice="extMeterUsageOptions"
:required="!!extMeterUsage"
:disabled="!isNew"
@change="extMeterUsageChanged"
/>
</FormRow>
</template>
</DeviceModalBase>
</template>
@ -82,7 +96,7 @@ import PropertyField from "./PropertyField.vue";
import NewDeviceButton from "./NewDeviceButton.vue";
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
import { ICONS } from "../VehicleIcon/VehicleIcon.vue";
import { ConfigType, type SelectedMeterType } from "@/types/evcc";
import { ConfigType, type MeterType, type MeterTemplateUsage } from "@/types/evcc";
import type { ModalFade } from "../Helper/GenericModal.vue";
import {
type DeviceValues,
@ -90,9 +104,9 @@ import {
type Product,
type TemplateParam,
type ApiData,
type TemplateType,
} from "./DeviceModal";
import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue";
import defaultMeterYaml from "./defaultYaml/meter.yaml?raw";
const initialValues = {
type: ConfigType.Template,
@ -108,7 +122,7 @@ const CUSTOM_FIELDS = ["usage", "modbus"];
const defaultIcons: Record<string, string> = {
aux: "smartconsumer",
ext: "meter",
ext: "generic",
};
export default defineComponent({
@ -121,21 +135,17 @@ export default defineComponent({
},
props: {
id: Number,
type: {
type: String as () => SelectedMeterType | undefined,
default: undefined,
},
typeChoices: {
type: Array as () => string[],
default: () => ["pv", "battery", "aux", "ext"],
},
type: { type: String as PropType<MeterType>, default: null },
typeChoices: { type: Array as () => MeterType[], default: () => [] },
fade: String as PropType<ModalFade>,
isSponsor: Boolean,
},
emits: ["added", "updated", "removed", "close"],
data() {
return {
selectedType: null as string | null,
selectedType: null as MeterType | null,
extMeterUsage: "charge" as MeterTemplateUsage,
selectedTemplate: null as string | null,
iconChoices: ICONS,
initialValues,
customFields: CUSTOM_FIELDS,
@ -153,10 +163,21 @@ export default defineComponent({
}
return this.$t(`config.${this.meterType}.titleEdit`);
},
meterType(): Exclude<TemplateType, "vehicle" | "charger"> | null {
// @ts-expect-error either this.type or this.selectedType is given
meterType(): MeterType | null {
return this.type || this.selectedType;
},
templateUsage(): MeterTemplateUsage | undefined {
if (!this.meterType) return undefined;
// For ext meters, the user selects the template usage explicitly
// For other meter types, the meter type IS the template usage
if (this.meterType === "ext") {
return this.extMeterUsage;
}
// For non-ext meters, meterType directly maps to template usage
// (grid->grid, pv->pv, battery->battery, charge->charge, aux->aux)
return this.meterType;
},
hasDeviceTitle(): boolean {
return ["pv", "battery", "aux", "ext"].includes(this.meterType || "");
},
@ -169,9 +190,21 @@ export default defineComponent({
isNew(): boolean {
return this.id === undefined;
},
extMeterUsageOptions() {
return ["grid", "pv", "battery", "charge", "aux"].map((key) => ({
name: this.$t(`config.meter.usage.${key}`),
key,
}));
},
},
methods: {
selectType(type: string) {
onConfigurationLoaded(values: DeviceValues) {
// Restore extMeterUsage when editing an existing ext meter
if (this.meterType === "ext" && values.usage) {
this.extMeterUsage = values.usage;
}
},
selectType(type: MeterType) {
this.selectedType = type;
},
provideTemplateOptions(products: Product[]): TemplateGroup[] {
@ -193,7 +226,9 @@ export default defineComponent({
const filtered = params.filter(
(p) =>
!CUSTOM_FIELDS.includes(p.Name) &&
(p.Usages && this.meterType ? p.Usages.includes(this.meterType) : true)
(p.Usages && this.templateUsage
? p.Usages.includes(this.templateUsage as any)
: true)
);
// Make capacity non-advanced for battery meters
@ -206,27 +241,33 @@ export default defineComponent({
},
transformApiData(data: ApiData, values: DeviceValues): ApiData {
if (values.type === ConfigType.Template) {
data["usage"] = this.meterType || undefined;
// Set the template usage (what the template should do)
// For ext meters: user-selected usage (grid, pv, battery, charge, aux)
// For other meters: meterType itself is the usage
data.usage = this.templateUsage;
}
return data;
},
isYamlInput(type: ConfigType): boolean {
return type === ConfigType.Custom;
},
async handleTemplateChange(e: Event, values: DeviceValues) {
handleTemplateChange(e: Event, values: DeviceValues) {
console.log("[MeterModal] handleTemplateChange", { e, values });
const value = (e.target as HTMLSelectElement).value;
if (value === ConfigType.Custom) {
const defaultYaml = await import("./defaultYaml/meter.yaml?raw");
values.type = ConfigType.Custom;
values.yaml = defaultYaml.default;
values.yaml = defaultMeterYaml;
console.log("[MeterModal] set type/yaml", { type: values.type, yaml: values.yaml });
}
},
applyCustomDefaults(_template: Template | null, values: DeviceValues) {
// Apply default icon when template is loaded
if (this.meterType && !values["deviceIcon"]) {
values["deviceIcon"] = defaultIcons[this.meterType] || "";
// Apply default icon when template is loaded or meter type is selected
if (this.meterType && !values.deviceIcon) {
values.deviceIcon = defaultIcons[this.meterType] || "";
}
},
extMeterUsageChanged() {
// When ext meter usage changes, reset template selection to force user to reselect
// This triggers product reload via effectiveUsage computed property change
this.selectedTemplate = null;
},
handleAdded(name: string) {
this.$emit("added", this.meterType, name);
},
@ -235,6 +276,7 @@ export default defineComponent({
},
handleClose() {
this.selectedType = null;
this.extMeterUsage = "charge";
this.$emit("close");
},
},

View file

@ -7,7 +7,6 @@
:modal-title="$t(`config.vehicle.${isNew ? 'titleAdd' : 'titleEdit'}`)"
:provide-template-options="provideTemplateOptions"
:initial-values="initialValues"
:is-yaml-input-type="isYamlInput"
:transform-api-data="transformApiData"
:filter-template-params="filterTemplateParams"
:on-template-change="handleTemplateChange"
@ -141,6 +140,7 @@ import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
import { ConfigType } from "@/types/evcc";
import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue";
import type { Product, ApiData, DeviceValues, TemplateParam } from "./DeviceModal";
import defaultVehicleYaml from "./defaultYaml/vehicle.yaml?raw";
const initialValues = {
type: ConfigType.Template,
@ -229,23 +229,19 @@ export default defineComponent({
},
transformApiData(data: ApiData, values: DeviceValues): ApiData {
if (values.type === ConfigType.Custom) {
delete data["icon"];
delete data["title"];
delete data.icon;
delete data.title;
}
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;
},
isYamlInput(type: ConfigType) {
return type === ConfigType.Custom;
},
async handleTemplateChange(e: Event, values: DeviceValues) {
handleTemplateChange(e: Event, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value;
if (value === ConfigType.Custom) {
const defaultYaml = await import("./defaultYaml/vehicle.yaml?raw");
values.type = ConfigType.Custom;
values.yaml = defaultYaml.default;
values.yaml = defaultVehicleYaml;
}
},
},

View file

@ -415,7 +415,8 @@ export interface SelectOption<T> {
}
export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint";
export type SelectedMeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext";
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext";
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";
// see https://stackoverflow.com/a/54178819
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

View file

@ -80,22 +80,14 @@
<h2 class="my-4 mt-5">{{ $t("config.section.grid") }} 🧪</h2>
<div class="p-0 config-list">
<DeviceCard
<MeterCard
v-if="gridMeter"
:title="$t('config.grid.title')"
:name="gridMeter.name"
:editable="!!gridMeter.id"
:error="hasDeviceError('meter', gridMeter.name)"
data-testid="grid"
@edit="editMeter('grid', gridMeter.id)"
>
<template #icon>
<shopicon-regular-powersupply></shopicon-regular-powersupply>
</template>
<template #tags>
<DeviceTags :tags="deviceTags('meter', gridMeter.name)" />
</template>
</DeviceCard>
:meter="gridMeter"
meter-type="grid"
:has-error="hasDeviceError('meter', gridMeter.name)"
:tags="deviceTags('meter', gridMeter.name)"
@edit="editMeter"
/>
<NewDeviceButton
v-else
:title="$t('config.main.addGrid')"
@ -126,48 +118,24 @@
</div>
<h2 class="my-4 mt-5">{{ $t("config.section.meter") }} 🧪</h2>
<div class="p-0 config-list">
<DeviceCard
<MeterCard
v-for="meter in pvMeters"
:key="meter.name"
:title="
meter.deviceTitle ||
meter.config?.template ||
$t('config.devices.solarSystem')
"
:name="meter.name"
:editable="!!meter.id"
:error="hasDeviceError('meter', meter.name)"
data-testid="pv"
@edit="editMeter('pv', meter.id)"
>
<template #icon>
<shopicon-regular-sun></shopicon-regular-sun>
</template>
<template #tags>
<DeviceTags :tags="deviceTags('meter', meter.name)" />
</template>
</DeviceCard>
<DeviceCard
:meter="meter"
meter-type="pv"
:has-error="hasDeviceError('meter', meter.name)"
:tags="deviceTags('meter', meter.name)"
@edit="editMeter"
/>
<MeterCard
v-for="meter in batteryMeters"
:key="meter.name"
:title="
meter.deviceTitle ||
meter.config?.template ||
$t('config.devices.batteryStorage')
"
:name="meter.name"
:editable="!!meter.id"
:error="hasDeviceError('meter', meter.name)"
data-testid="battery"
@edit="editMeter('battery', meter.id)"
>
<template #icon>
<shopicon-regular-batterythreequarters></shopicon-regular-batterythreequarters>
</template>
<template #tags>
<DeviceTags :tags="deviceTags('meter', meter.name)" />
</template>
</DeviceCard>
:meter="meter"
meter-type="battery"
:has-error="hasDeviceError('meter', meter.name)"
:tags="deviceTags('meter', meter.name)"
@edit="editMeter"
/>
<NewDeviceButton
:title="$t('config.main.addPvBattery')"
@click="addSolarBatteryMeter"
@ -176,27 +144,24 @@
<h2 class="my-4 mt-5">{{ $t("config.section.additionalMeter") }} 🧪</h2>
<div class="p-0 config-list">
<DeviceCard
<MeterCard
v-for="meter in auxMeters"
:key="meter.name"
:title="
meter.deviceTitle ||
meter.config?.template ||
$t('config.devices.auxMeter')
"
:name="meter.name"
:editable="!!meter.id"
:error="hasDeviceError('meter', meter.name)"
data-testid="aux"
@edit="editMeter('aux', meter.id)"
>
<template #icon>
<VehicleIcon :name="meter.deviceIcon || 'smartconsumer'" />
</template>
<template #tags>
<DeviceTags :tags="deviceTags('meter', meter.name)" />
</template>
</DeviceCard>
:meter="meter"
meter-type="aux"
:has-error="hasDeviceError('meter', meter.name)"
:tags="deviceTags('meter', meter.name)"
@edit="editMeter"
/>
<MeterCard
v-for="meter in extMeters"
:key="meter.name"
:meter="meter"
meter-type="ext"
:has-error="hasDeviceError('meter', meter.name)"
:tags="deviceTags('meter', meter.name)"
@edit="editMeter"
/>
<NewDeviceButton
:title="$t('config.main.addAdditional')"
@click="newAdditionalMeter"
@ -436,6 +401,7 @@ import LoadpointModal from "../components/Config/LoadpointModal.vue";
import LoadpointIcon from "../components/MaterialIcon/Loadpoint.vue";
import MessagingModal from "../components/Config/MessagingModal.vue";
import MeterModal from "../components/Config/MeterModal.vue";
import MeterCard from "../components/Config/MeterCard.vue";
import Modal from "bootstrap/js/dist/modal";
import ModbusProxyIcon from "../components/MaterialIcon/ModbusProxy.vue";
import ModbusProxyModal from "../components/Config/ModbusProxyModal.vue";
@ -461,7 +427,7 @@ import type {
ConfigMeter,
LoadpointType,
Timeout,
SelectedMeterType,
MeterType,
SiteConfig,
DeviceType,
} from "@/types/evcc";
@ -495,6 +461,7 @@ export default defineComponent({
InfluxModal,
MessagingModal,
MeterModal,
MeterCard,
LoadpointModal,
LoadpointIcon,
ModbusProxyIcon,
@ -526,8 +493,8 @@ export default defineComponent({
circuits: [] as ConfigCircuit[],
selectedVehicleId: undefined as number | undefined,
selectedMeterId: undefined as number | undefined,
selectedMeterType: undefined as SelectedMeterType | undefined,
selectedMeterTypeChoices: [] as string[],
selectedMeterType: undefined as MeterType | undefined,
selectedMeterTypeChoices: [] as MeterType[],
selectedChargerId: undefined as number | undefined,
selectedLoadpointId: undefined as number | undefined,
selectedLoadpointType: undefined as LoadpointType | undefined,
@ -838,12 +805,12 @@ export default defineComponent({
this.loadpointModal().hide();
this.$nextTick(() => this.editMeter("charge", meter?.id));
},
editMeter(type: SelectedMeterType, id?: number) {
editMeter(type: MeterType, id?: number) {
this.selectedMeterType = type;
this.selectedMeterId = id;
this.$nextTick(() => this.meterModal().show());
},
newMeter(type: SelectedMeterType) {
newMeter(type: MeterType) {
this.selectedMeterId = undefined;
this.selectedMeterType = type;
this.$nextTick(() => this.meterModal().show());
@ -851,13 +818,13 @@ export default defineComponent({
addSolarBatteryMeter() {
this.selectedMeterId = undefined;
this.selectedMeterType = undefined;
this.selectedMeterTypeChoices = ["pv", "battery"];
this.selectedMeterTypeChoices = ["pv", "battery"] as MeterType[];
this.$nextTick(() => this.meterModal().show());
},
newAdditionalMeter() {
this.selectedMeterId = undefined;
this.selectedMeterType = undefined;
this.selectedMeterTypeChoices = ["aux", "ext"];
this.selectedMeterTypeChoices = ["aux", "ext"] as MeterType[];
this.$nextTick(() => this.meterModal().show());
},
editCharger(id?: number, loadpointType?: LoadpointType) {
@ -912,7 +879,7 @@ export default defineComponent({
yamlChanged() {
this.loadDirty();
},
meterAdded(type: SelectedMeterType, name: string) {
meterAdded(type: MeterType, name: string) {
if (type === "charge") {
// update loadpoint
(
@ -946,7 +913,7 @@ export default defineComponent({
}
this.meterChanged();
},
meterRemoved(type: SelectedMeterType) {
meterRemoved(type: MeterType) {
if (type === "charge") {
// update loadpoint
(

View file

@ -143,9 +143,9 @@
"title": "EEBus"
},
"ext": {
"description": "Kann für Lastmanagement oder Statistikzwecke verwendet werden.",
"titleAdd": "Zusätzlichen Zähler hinzufügen",
"titleEdit": "Zusätzlichen Zähler bearbeiten"
"description": "Erfasst Energiewerte ungeregelter Verbraucher (bspw. Kühlschrank, Waschmaschine, etc.) für Statistikzwecke. Diese Zähler können auch für Lastmanagement verwendet werden (bspw. Unterverteilung).",
"titleAdd": "Verbrauchszähler hinzufügen",
"titleEdit": "Verbrauchszähler bearbeiten"
},
"form": {
"danger": "Achtung",
@ -196,7 +196,8 @@
"title": "HEMS"
},
"icon": {
"change": "ändern"
"change": "ändern",
"label": "Symbol"
},
"influx": {
"description": "Schreibt Ladedaten und andere Metriken in InfluxDB. Verwende Grafana oder andere Tools, um die Daten zu visualisieren.",
@ -344,13 +345,22 @@
"option": {
"aux": "Intelligenten Verbraucher hinzufügen",
"battery": "Hausbatterie hinzufügen",
"ext": "Zusätzlichen Zähler hinzufügen",
"ext": "Regulären Verbraucher hinzufügen",
"pv": "PV-Anlage hinzufügen"
},
"save": "Speichern",
"specific": "Spezifische Integrationen",
"template": "Hersteller",
"titleChoice": "Was möchtest du hinzufügen?",
"titleLabel": "Titel",
"usage": {
"aux": "Intelligenter Verbraucher",
"battery": "Batteriezähler",
"charge": "Ladegerätzähler",
"grid": "Netzzähler",
"label": "Verwendung",
"pv": "PV-Zähler"
},
"validateSave": "Überprüfen & speichern"
},
"modbus": {

View file

@ -143,9 +143,9 @@
"title": "EEBus"
},
"ext": {
"description": "Can be used for load management or statistics purposes.",
"titleAdd": "Add External Meter",
"titleEdit": "Edit External Meter"
"description": "Records energy values of uncontrolled consumers (e.g. refrigerator, washing machine, etc.) for statistical purposes. These meters can also be used for load management (e.g. sub-distribution).",
"titleAdd": "Add Consumer Meter",
"titleEdit": "Edit Consumer Meter"
},
"form": {
"danger": "Danger",
@ -196,7 +196,8 @@
"title": "HEMS"
},
"icon": {
"change": "change"
"change": "change",
"label": "Icon"
},
"influx": {
"description": "Writes charging data and other metrics to InfluxDB. Use Grafana or other tools to visualize the data.",
@ -344,13 +345,22 @@
"option": {
"aux": "Add self-regulating consumer",
"battery": "Add battery meter",
"ext": "Add external meter",
"ext": "Add regular consumer",
"pv": "Add solar meter"
},
"save": "Save",
"specific": "Specific integrations",
"template": "Manufacturer",
"titleChoice": "What Do You Want To Add?",
"titleLabel": "Title",
"usage": {
"aux": "Self-regulating consumer meter",
"battery": "Battery meter",
"charge": "Charger meter",
"grid": "Grid meter",
"label": "Usage",
"pv": "Solar meter"
},
"validateSave": "Validate & save"
},
"modbus": {

View file

@ -0,0 +1,163 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import {
editorClear,
editorPaste,
enableExperimental,
expectModalHidden,
expectModalVisible,
} from "./utils";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.beforeEach(async () => {
await start(CONFIG_GRID_ONLY);
});
test.afterEach(async () => {
await stop();
});
test.describe("ext meter", async () => {
test("template-based ext meter", async ({ page }) => {
await page.goto("/#/config");
await enableExperimental(page, false);
await expect(page.getByTestId("ext")).toHaveCount(0);
await page.getByRole("button", { name: "Add additional meter" }).click();
const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal);
await meterModal.getByRole("button", { name: "Add regular consumer" }).click();
await expect(meterModal.getByLabel("Usage")).toHaveValue("charge");
await meterModal.getByLabel("Manufacturer").selectOption("cFos PowerBrain Meter");
await page.waitForLoadState("networkidle");
await meterModal.getByLabel("Usage").selectOption("battery");
await page.waitForLoadState("networkidle");
await meterModal.getByLabel("Manufacturer").selectOption("Demo battery");
await page.waitForLoadState("networkidle");
await expect(meterModal.getByLabel("Charge")).toBeVisible();
await meterModal.getByLabel("Title").fill("House battery");
await meterModal.getByLabel("Charge").fill("75");
const testResult = meterModal.getByTestId("test-result");
await testResult.getByRole("link", { name: "validate" }).click();
await expect(testResult).toContainText("Status: successful");
await expect(testResult).toContainText("75.0%");
await meterModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(meterModal);
await expect(page.getByTestId("ext")).toHaveCount(1);
await expect(page.getByTestId("ext")).toContainText("House battery");
await restart(CONFIG_GRID_ONLY);
await page.reload();
await expect(page.getByTestId("ext")).toHaveCount(1);
await page.getByTestId("ext").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
await expect(meterModal.getByLabel("Title")).toHaveValue("House battery");
await expect(meterModal.getByLabel("Usage")).toBeDisabled();
await expect(meterModal.getByLabel("Manufacturer")).toBeDisabled();
await meterModal.getByLabel("Charge").clear();
await meterModal.getByLabel("Charge").fill("85");
await testResult.getByRole("link", { name: "validate" }).click();
await expect(testResult).toContainText("85.0%");
await meterModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(meterModal);
await page.getByTestId("ext").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
await expect(meterModal.getByLabel("Charge")).toHaveValue("85");
await meterModal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(meterModal);
await expect(page.getByTestId("ext")).toHaveCount(0);
await restart(CONFIG_GRID_ONLY);
await page.reload();
await expect(page.getByTestId("ext")).toHaveCount(0);
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
});
test("switch from template to custom ext meter", async ({ page }) => {
await page.goto("/#/config");
await enableExperimental(page, false);
await page.getByRole("button", { name: "Add additional meter" }).click();
const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal);
await meterModal.getByRole("button", { name: "Add regular consumer" }).click();
await meterModal.getByLabel("Title").fill("Custom ext meter");
await meterModal.getByLabel("Usage").selectOption("battery");
await meterModal.getByLabel("Manufacturer").selectOption("Demo battery");
await meterModal.getByLabel("Charge").fill("50");
await meterModal.getByLabel("Manufacturer").selectOption("User-defined device");
await page.waitForLoadState("networkidle");
const editor = meterModal.getByTestId("yaml-editor");
await expect(editor).toContainText("power: # current power");
const testResult = meterModal.getByTestId("test-result");
await testResult.getByRole("link", { name: "validate" }).click();
await expect(testResult).toContainText("Status: successful");
await expect(testResult).toContainText("1.0 kW");
await meterModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(meterModal);
await expect(page.getByTestId("ext")).toHaveCount(1);
await expect(page.getByTestId("ext")).toContainText("Custom ext meter");
await restart(CONFIG_GRID_ONLY);
await page.reload();
await expect(page.getByTestId("ext")).toHaveCount(1);
await page.getByTestId("ext").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
await expect(meterModal.getByLabel("Title")).toHaveValue("Custom ext meter");
await expect(meterModal.getByLabel("Usage")).toBeDisabled();
await expect(meterModal.getByLabel("Manufacturer")).toHaveValue("User-defined device");
await page.waitForLoadState("networkidle");
await expect(editor).toContainText("value: 1000 # W");
await editorClear(editor);
await editorPaste(
editor,
page,
`power:
source: const
value: 2000 # W`
);
await testResult.getByRole("link", { name: "validate" }).click();
await expect(testResult).toContainText("Status: successful");
await expect(testResult).toContainText("2.0 kW");
await meterModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(meterModal);
await page.getByTestId("ext").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
await page.waitForLoadState("networkidle");
await expect(editor).toContainText("value: 2000 # W");
await meterModal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(meterModal);
await expect(page.getByTestId("ext")).toHaveCount(0);
await restart(CONFIG_GRID_ONLY);
await page.reload();
await expect(page.getByTestId("ext")).toHaveCount(0);
});
});