Config UI: separate consumers from additional meters (#30243)
This commit is contained in:
parent
614ff4ac06
commit
8769ae489d
31 changed files with 494 additions and 211 deletions
|
|
@ -257,7 +257,9 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
|
|||
- Use `expectModalVisible()` and `expectModalHidden()` helpers
|
||||
- Test configuration persistence across application restarts
|
||||
- Standard structure: import `{ start, stop, baseUrl }` from `./evcc`, use `test.afterEach(stop)`
|
||||
- Never use fixed timeouts, use existance of elements or wait for network idle
|
||||
- Never use fixed timeouts. Wait on element state (visibility, count, value) instead.
|
||||
- Never use `page.waitForLoadState("networkidle")`. SPAs keep emitting requests (websockets, polling), so it either races or hangs. Wait for the specific element / value you need instead.
|
||||
- Keep test names and describe titles short and concrete. They should complement each other, not repeat. Prefer `describe("aux meter") test("create")` over `describe("aux meter") test("create aux meter and verify it appears")`. Drop scenario filler like "and lands in section", "appears correctly", "ensure".
|
||||
|
||||
## Device Integration & Configuration
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ export default {
|
|||
meterType: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ["grid", "pv", "battery", "aux", "ext"].includes(value),
|
||||
validator: (value) =>
|
||||
["grid", "pv", "battery", "aux", "ext", "consumer"].includes(value),
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
|
|
@ -77,11 +78,12 @@ export default {
|
|||
battery: this.$t("config.devices.batteryStorage"),
|
||||
aux: this.$t("config.devices.auxMeter"),
|
||||
ext: this.$t("config.devices.extMeter"),
|
||||
consumer: this.$t("config.devices.consumer"),
|
||||
};
|
||||
return titleMap[this.meterType];
|
||||
},
|
||||
isVehicleIcon() {
|
||||
return this.meterType === "aux" || this.meterType === "ext";
|
||||
return ["aux", "ext", "consumer"].includes(this.meterType);
|
||||
},
|
||||
iconComponent() {
|
||||
const iconMap = {
|
||||
|
|
@ -92,13 +94,8 @@ export default {
|
|||
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;
|
||||
const defaults = { aux: "smartconsumer", consumer: "generic", ext: "meter" };
|
||||
return this.meter.deviceIcon || defaults[this.meterType] || null;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -121,7 +121,8 @@ const CUSTOM_FIELDS = ["usage", "modbus"];
|
|||
|
||||
const defaultIcons: Record<string, string> = {
|
||||
aux: "smartconsumer",
|
||||
ext: "generic",
|
||||
consumer: "generic",
|
||||
ext: "meter",
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
|
|
@ -153,8 +154,8 @@ export default defineComponent({
|
|||
selectedType(): MeterType | undefined {
|
||||
return getModal("meter")?.type as MeterType | undefined;
|
||||
},
|
||||
typeChoices(): MeterType[] {
|
||||
return (getModal("meter")?.choices as MeterType[]) || [];
|
||||
typeChoices(): string[] {
|
||||
return (getModal("meter")?.choices as string[]) || [];
|
||||
},
|
||||
modalTitle(): string {
|
||||
if (this.isNew) {
|
||||
|
|
@ -169,23 +170,24 @@ export default defineComponent({
|
|||
templateUsage(): MeterTemplateUsage | undefined {
|
||||
if (!this.selectedType) return undefined;
|
||||
|
||||
// For ext meters, the user selects the template usage explicitly
|
||||
// For other meter types, the meter type IS the template usage
|
||||
// ext is the only type where usage is user-selected, not the type itself
|
||||
if (this.selectedType === "ext") {
|
||||
return this.extMeterUsage;
|
||||
}
|
||||
// For non-ext meters, selectedType directly maps to template usage
|
||||
// (grid->grid, pv->pv, battery->battery, charge->charge, aux->aux)
|
||||
// consumers are always charge meters
|
||||
if (this.selectedType === "consumer") {
|
||||
return "charge";
|
||||
}
|
||||
return this.selectedType;
|
||||
},
|
||||
hasDeviceTitle(): boolean {
|
||||
return ["pv", "battery", "aux", "ext"].includes(this.selectedType || "");
|
||||
return ["pv", "battery", "aux", "ext", "consumer"].includes(this.selectedType || "");
|
||||
},
|
||||
hasDeviceIcon(): boolean {
|
||||
return ["aux", "ext"].includes(this.selectedType || "");
|
||||
return ["aux", "ext", "consumer"].includes(this.selectedType || "");
|
||||
},
|
||||
hasDescription(): boolean {
|
||||
return ["ext", "aux"].includes(this.selectedType || "");
|
||||
return ["ext", "aux", "consumer"].includes(this.selectedType || "");
|
||||
},
|
||||
isNew(): boolean {
|
||||
return this.id === undefined;
|
||||
|
|
@ -204,8 +206,8 @@ export default defineComponent({
|
|||
this.extMeterUsage = values.usage;
|
||||
}
|
||||
},
|
||||
selectType(type: MeterType) {
|
||||
replaceModal("meter", { id: this.id, type });
|
||||
selectType(type: string) {
|
||||
replaceModal("meter", { id: this.id, type: type as MeterType });
|
||||
},
|
||||
provideTemplateOptions(products: Product[]): TemplateGroup[] {
|
||||
return [
|
||||
|
|
@ -241,9 +243,6 @@ export default defineComponent({
|
|||
},
|
||||
transformApiData(data: ApiData, values: DeviceValues): ApiData {
|
||||
if (values.type === ConfigType.Template) {
|
||||
// Set the template usage (what the template should do)
|
||||
// For ext meters: user-selected usage (grid, pv, battery, charge, aux)
|
||||
// For other meters: selectedType itself is the usage
|
||||
data.usage = this.templateUsage;
|
||||
}
|
||||
return data;
|
||||
|
|
|
|||
|
|
@ -197,9 +197,9 @@
|
|||
@details-clicked="toggleCo2"
|
||||
@toggle="toggleConsumers"
|
||||
>
|
||||
<template v-if="consumers.length > 0" #expanded>
|
||||
<template v-if="allConsumers.length > 0" #expanded>
|
||||
<EnergyflowEntry
|
||||
v-for="(c, index) in consumers"
|
||||
v-for="(c, index) in allConsumers"
|
||||
:key="index"
|
||||
:name="c.title || c.name"
|
||||
:power="c.power"
|
||||
|
|
@ -376,6 +376,7 @@ export default defineComponent({
|
|||
pv: { type: Array as PropType<Meter[]>, default: () => [] },
|
||||
aux: { type: Array as PropType<Meter[]>, default: () => [] },
|
||||
ext: { type: Array as PropType<Meter[]>, default: () => [] },
|
||||
consumers: { type: Array as PropType<Meter[]>, default: () => [] },
|
||||
pvPower: { type: Number, default: 0 },
|
||||
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
|
||||
batteryConfigured: { type: Boolean },
|
||||
|
|
@ -589,8 +590,8 @@ export default defineComponent({
|
|||
count: this.activeLoadpointsCount,
|
||||
});
|
||||
},
|
||||
consumers() {
|
||||
return [...this.aux, ...this.ext];
|
||||
allConsumers() {
|
||||
return [...this.consumers, ...this.aux];
|
||||
},
|
||||
batteryForecastHighest(): BatteryForecastPoint | undefined {
|
||||
return this.battery?.forecast?.highest;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import store from "@/store";
|
|||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||
import { PERIODS } from "../Sessions/types";
|
||||
import { is12hFormat } from "@/units";
|
||||
import { hasColorPicker } from "./groups";
|
||||
|
||||
export interface HistorySlot {
|
||||
start: string;
|
||||
|
|
@ -155,10 +156,9 @@ export default defineComponent({
|
|||
if (this.period === PERIODS.DAY) return this.useSmallUnit ? "W" : "kW";
|
||||
return this.useSmallUnit ? "Wh" : "kWh";
|
||||
},
|
||||
// Consumer groups have many palette colours per entity — a single neutral
|
||||
// tooltip background reads better than picking one entity's colour.
|
||||
// Picker groups have many entity colors, so use a neutral tooltip background.
|
||||
tooltipColor(): string {
|
||||
if (this.group === "loadpoint" || this.group === "meter") {
|
||||
if (hasColorPicker(this.group)) {
|
||||
return colors.text || this.color;
|
||||
}
|
||||
return this.color;
|
||||
|
|
@ -169,7 +169,12 @@ export default defineComponent({
|
|||
return this.series.filter((s, i) => (s.paletteIndex ?? i) === idx);
|
||||
},
|
||||
isBidirectional(): boolean {
|
||||
return BIDIRECTIONAL_GROUPS.has(this.group);
|
||||
if (BIDIRECTIONAL_GROUPS.has(this.group)) return true;
|
||||
// additional grid/battery meters can export
|
||||
if (this.group === "meter") {
|
||||
return this.series.some((s) => s.data.some((slot) => slot.returnEnergy > 0));
|
||||
}
|
||||
return false;
|
||||
},
|
||||
categoryTimestamps(): number[] {
|
||||
const out: number[] = [];
|
||||
|
|
@ -201,10 +206,8 @@ export default defineComponent({
|
|||
return this.categoryTimestamps.map((t) => this.timestampKey(t));
|
||||
},
|
||||
entryColors(): string[] {
|
||||
// Loadpoint and meter use the palette per entity (distinct entities).
|
||||
// Production and battery use the group color with subtle alpha steps so
|
||||
// stacked segments stay visually distinguishable.
|
||||
if (this.group === "loadpoint" || this.group === "meter") {
|
||||
// Picker groups color per entity; pv/battery use alpha steps of the group color.
|
||||
if (hasColorPicker(this.group)) {
|
||||
const mutedColor = colors.muted || this.color;
|
||||
const titles: string[] = [];
|
||||
for (const s of this.series) {
|
||||
|
|
@ -265,6 +268,7 @@ export default defineComponent({
|
|||
// Groups with multiple entities stack them; grid keeps bars side-by-side.
|
||||
const stackEntities =
|
||||
this.group === "loadpoint" ||
|
||||
this.group === "consumer" ||
|
||||
this.group === "meter" ||
|
||||
this.group === "pv" ||
|
||||
this.group === "battery";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
export const GROUP_ORDER = ["pv", "battery", "grid", "loadpoint", "meter"] as const;
|
||||
export const GROUP_ORDER = ["pv", "battery", "grid", "loadpoint", "consumer", "meter"] as const;
|
||||
|
||||
const COLOR_PICKER_GROUPS = ["loadpoint", "consumer", "meter"];
|
||||
|
||||
export function hasColorPicker(group: string): boolean {
|
||||
return COLOR_PICKER_GROUPS.includes(group);
|
||||
}
|
||||
|
||||
const GROUP_COLOR_VAR: Record<string, string> = {
|
||||
pv: "--evcc-dark-green",
|
||||
|
|
@ -6,7 +12,8 @@ const GROUP_COLOR_VAR: Record<string, string> = {
|
|||
loadpoint: "--evcc-dark-green",
|
||||
grid: "--evcc-grid",
|
||||
battery: "--evcc-darker-green",
|
||||
meter: "--evcc-price",
|
||||
consumer: "--evcc-price",
|
||||
meter: "--evcc-self",
|
||||
};
|
||||
|
||||
export function groupColor(group: string): string {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export default defineComponent({
|
|||
pv: { type: Array as PropType<Meter[]>, default: () => [] },
|
||||
aux: { type: Array as PropType<Meter[]>, default: () => [] },
|
||||
ext: { type: Array as PropType<Meter[]>, default: () => [] },
|
||||
consumers: { type: Array as PropType<Meter[]>, default: () => [] },
|
||||
batteryDischargeControl: Boolean,
|
||||
batteryGridChargeLimit: { type: [Number, null] as PropType<number | null>, default: null },
|
||||
batteryGridChargeActive: Boolean,
|
||||
|
|
|
|||
|
|
@ -710,7 +710,7 @@ export interface SelectOption<T> {
|
|||
}
|
||||
|
||||
export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint" | "messenger" | "tariff";
|
||||
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext";
|
||||
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext" | "consumer";
|
||||
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";
|
||||
export type TariffType = "grid" | "feedIn" | "co2" | "planner" | "solar";
|
||||
|
||||
|
|
@ -725,6 +725,7 @@ export interface SiteConfig {
|
|||
title: string;
|
||||
aux: string[] | null;
|
||||
ext: string[] | null;
|
||||
consumers: string[] | null;
|
||||
}
|
||||
|
||||
export type ValueOf<T> = T[keyof T];
|
||||
|
|
|
|||
|
|
@ -81,6 +81,33 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="my-4 mt-5">{{ $t("config.section.consumers") }}</h2>
|
||||
<div class="p-0 config-list">
|
||||
<MeterCard
|
||||
v-for="meter in consumerMeters"
|
||||
:key="meter.name"
|
||||
:meter="meter"
|
||||
meter-type="consumer"
|
||||
:has-error="hasDeviceError('meter', meter.name)"
|
||||
:tags="deviceTags('meter', meter.name)"
|
||||
@edit="(type, id) => openModal('meter', { type, id })"
|
||||
/>
|
||||
<MeterCard
|
||||
v-for="meter in auxMeters"
|
||||
:key="meter.name"
|
||||
:meter="meter"
|
||||
meter-type="aux"
|
||||
:has-error="hasDeviceError('meter', meter.name)"
|
||||
:tags="deviceTags('meter', meter.name)"
|
||||
@edit="(type, id) => openModal('meter', { type, id })"
|
||||
/>
|
||||
<NewDeviceButton
|
||||
data-testid="add-consumer"
|
||||
:title="$t('config.main.addConsumer')"
|
||||
@click="openModal('meter', { choices: ['consumer', 'aux'] })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="my-4 mt-5">{{ $t("config.section.grid") }}</h2>
|
||||
<div class="p-0 config-list">
|
||||
<MeterCard
|
||||
|
|
@ -128,15 +155,6 @@
|
|||
|
||||
<h2 class="my-4 mt-5">{{ $t("config.section.additionalMeter") }}</h2>
|
||||
<div class="p-0 config-list">
|
||||
<MeterCard
|
||||
v-for="meter in auxMeters"
|
||||
:key="meter.name"
|
||||
:meter="meter"
|
||||
meter-type="aux"
|
||||
:has-error="hasDeviceError('meter', meter.name)"
|
||||
:tags="deviceTags('meter', meter.name)"
|
||||
@edit="(type, id) => openModal('meter', { type, id })"
|
||||
/>
|
||||
<MeterCard
|
||||
v-for="meter in extMeters"
|
||||
:key="meter.name"
|
||||
|
|
@ -147,8 +165,9 @@
|
|||
@edit="(type, id) => openModal('meter', { type, id })"
|
||||
/>
|
||||
<NewDeviceButton
|
||||
data-testid="add-additional"
|
||||
:title="$t('config.main.addAdditional')"
|
||||
@click="openModal('meter', { choices: ['aux', 'ext'] })"
|
||||
@click="openModal('meter', { type: 'ext' })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -649,6 +668,7 @@ export default defineComponent({
|
|||
title: "",
|
||||
aux: null as string[] | null,
|
||||
ext: null as string[] | null,
|
||||
consumers: null as string[] | null,
|
||||
} as SiteConfig,
|
||||
deviceValueTimeout: null as Timeout,
|
||||
deviceValues: {
|
||||
|
|
@ -705,6 +725,9 @@ export default defineComponent({
|
|||
const names = this.site?.ext;
|
||||
return this.getMetersByNames(names);
|
||||
},
|
||||
consumerMeters() {
|
||||
return this.getMetersByNames(this.site?.consumers);
|
||||
},
|
||||
gridTariff() {
|
||||
const name = this.tariffRefs?.grid;
|
||||
return name ? this.tariffs.find((t) => t.name === name) : null;
|
||||
|
|
@ -1066,6 +1089,11 @@ export default defineComponent({
|
|||
this.site.ext.push(name);
|
||||
this.saveSite(type);
|
||||
break;
|
||||
case "consumer":
|
||||
if (!this.site.consumers) this.site.consumers = [];
|
||||
this.site.consumers.push(name);
|
||||
this.saveSite("consumers");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@
|
|||
/>
|
||||
<LegendList
|
||||
v-if="hasEntityLegend(group) || hasForecastLegend(group)"
|
||||
class="mt-4"
|
||||
class="mt-4 mb-0"
|
||||
:legends="legendsForGroup(group)"
|
||||
:device-colors="deviceColors"
|
||||
@focus="onLegendFocus(group, $event)"
|
||||
|
|
@ -124,7 +124,7 @@ import GroupChart, {
|
|||
import type { Legend } from "../components/Sessions/types";
|
||||
import type { DeviceColors } from "@/types/evcc";
|
||||
import { PERIODS } from "../components/Sessions/types";
|
||||
import { GROUP_ORDER, groupColor } from "../components/History/groups";
|
||||
import { GROUP_ORDER, groupColor, hasColorPicker } from "../components/History/groups";
|
||||
import colors, { resolveColors, deviceColorMap } from "../colors";
|
||||
import LegendList from "../components/Sessions/LegendList.vue";
|
||||
import { handleDownloadClick } from "@/utils/native";
|
||||
|
|
@ -256,7 +256,7 @@ export default defineComponent({
|
|||
visibleGroups(): string[] {
|
||||
return GROUP_ORDER.filter((g) => {
|
||||
// Consumption section follows `home` (the source of truth).
|
||||
if (g === "meter") {
|
||||
if (g === "consumer") {
|
||||
const home = this.seriesByGroup["home"];
|
||||
if (home?.some((s) => s.data.length > 0)) return true;
|
||||
}
|
||||
|
|
@ -264,51 +264,48 @@ export default defineComponent({
|
|||
return !!list?.some((s) => s.data.length > 0);
|
||||
});
|
||||
},
|
||||
// Series shown in the chart. For the consumption (`meter`) group we append
|
||||
// a virtual "Other consumers" series = home.net − sum(meter entities).
|
||||
// Inactive loadpoints / meters (all-zero data in the selected period) are
|
||||
// dropped from the displayed list; `paletteIndex` carries each entity's
|
||||
// original position so its color stays stable when navigating periods.
|
||||
// Consumer group: append virtual "Other consumers" = home.net − sum(consumers).
|
||||
// paletteIndex pins color across period navigation.
|
||||
displaySeries(): (group: string) => HistorySeries[] {
|
||||
const hasEnergy = (s: HistorySeries) =>
|
||||
s.data.some((slot) => slot.energy !== 0 || slot.returnEnergy !== 0);
|
||||
return (group: string): HistorySeries[] => {
|
||||
if (group === "loadpoint") {
|
||||
const list = this.seriesByGroup["loadpoint"] || [];
|
||||
// Loadpoint and additional meters stack distinct entities without a
|
||||
// home-derived "Others" series.
|
||||
if (group === "loadpoint" || group === "meter") {
|
||||
const list = this.seriesByGroup[group] || [];
|
||||
return list.map((s, i) => ({ ...s, paletteIndex: i })).filter(hasEnergy);
|
||||
}
|
||||
if (group !== "meter") return this.seriesByGroup[group] || [];
|
||||
const meters = this.seriesByGroup["meter"] || [];
|
||||
if (group !== "consumer") return this.seriesByGroup[group] || [];
|
||||
const consumers = this.seriesByGroup["consumer"] || [];
|
||||
const home = (this.seriesByGroup["home"] || [])[0];
|
||||
const activeMeters = meters
|
||||
const active = consumers
|
||||
.map((s, i) => ({ ...s, paletteIndex: i }))
|
||||
.filter(hasEnergy);
|
||||
if (!home) return activeMeters;
|
||||
const meterTotals = new Map<string, number>();
|
||||
// Net per slot is computed from all meters (incl. inactive ones), so
|
||||
if (!home) return active;
|
||||
const totals = new Map<string, number>();
|
||||
// Net per slot is computed from all consumers (incl. inactive ones), so
|
||||
// dropping inactive entries from the display doesn't shift the
|
||||
// "Other consumers" delta.
|
||||
for (const s of meters) {
|
||||
for (const s of consumers) {
|
||||
for (const slot of s.data) {
|
||||
const net = slot.energy - slot.returnEnergy;
|
||||
meterTotals.set(slot.start, (meterTotals.get(slot.start) || 0) + net);
|
||||
totals.set(slot.start, (totals.get(slot.start) || 0) + net);
|
||||
}
|
||||
}
|
||||
const other: HistorySeries = {
|
||||
title: this.$t("main.history.otherConsumers") as string,
|
||||
group: "meter",
|
||||
group: "consumer",
|
||||
virtual: true,
|
||||
// Use meters.length as a stable paletteIndex that can never
|
||||
// collide with real meters (0..meters.length-1).
|
||||
paletteIndex: meters.length,
|
||||
paletteIndex: consumers.length,
|
||||
data: home.data.map((slot) => {
|
||||
const homeNet = slot.energy - slot.returnEnergy;
|
||||
const v = Math.max(0, homeNet - (meterTotals.get(slot.start) || 0));
|
||||
const v = Math.max(0, homeNet - (totals.get(slot.start) || 0));
|
||||
return { start: slot.start, end: slot.end, energy: v, returnEnergy: 0 };
|
||||
}),
|
||||
};
|
||||
if (!hasEnergy(other)) return activeMeters;
|
||||
return [...activeMeters, other];
|
||||
if (!hasEnergy(other)) return active;
|
||||
return [other, ...active];
|
||||
};
|
||||
},
|
||||
hasForecast(): boolean {
|
||||
|
|
@ -399,7 +396,7 @@ export default defineComponent({
|
|||
const list = this.displaySeries(group);
|
||||
if (!list.length) return false;
|
||||
// Without explicit consumers an "Others"-only legend is meaningless.
|
||||
if (group === "loadpoint" || group === "meter") return list.some((s) => !s.virtual);
|
||||
if (hasColorPicker(group)) return list.some((s) => !s.virtual);
|
||||
if (group === "pv" || group === "battery") return list.length > 1;
|
||||
return false;
|
||||
},
|
||||
|
|
@ -410,12 +407,11 @@ export default defineComponent({
|
|||
const list = this.displaySeries(group);
|
||||
const baseColor = groupColor(group);
|
||||
const n = list.length;
|
||||
const isPickGroup = group === "loadpoint" || group === "meter";
|
||||
const colorPicker = hasColorPicker(group);
|
||||
|
||||
// Build palette for pickable groups in displayed order so that user
|
||||
// overrides take priority and autoassign skips taken palette entries.
|
||||
// Resolve in display order so user overrides win and autoassign skips taken entries.
|
||||
let palette: Record<string, string> = {};
|
||||
if (isPickGroup) {
|
||||
if (colorPicker) {
|
||||
const titles: string[] = [];
|
||||
for (const s of list) {
|
||||
if (!s.virtual && !titles.includes(s.title)) titles.push(s.title);
|
||||
|
|
@ -425,7 +421,7 @@ export default defineComponent({
|
|||
|
||||
const colorFor = (i: number, s: HistorySeries) => {
|
||||
if (s.virtual) return colors.muted || baseColor;
|
||||
if (isPickGroup) return palette[s.title] || baseColor;
|
||||
if (colorPicker) return palette[s.title] || baseColor;
|
||||
return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1)));
|
||||
};
|
||||
return list.map((s, i) => {
|
||||
|
|
@ -439,7 +435,7 @@ export default defineComponent({
|
|||
label: s.title,
|
||||
color: colorFor(i, s),
|
||||
value: this.fmtWh(watts, POWER_UNIT.AUTO),
|
||||
id: isPickGroup && !s.virtual ? s.title : undefined,
|
||||
id: colorPicker && !s.virtual ? s.title : undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
@ -458,7 +454,7 @@ export default defineComponent({
|
|||
// Consumption total comes from `home` (overall consumption),
|
||||
// not the sum of explicit meter entities.
|
||||
const list =
|
||||
group === "meter"
|
||||
group === "consumer"
|
||||
? this.seriesByGroup["home"] || []
|
||||
: this.seriesByGroup[group] || [];
|
||||
let sumEnergy = 0;
|
||||
|
|
|
|||
|
|
@ -121,6 +121,12 @@ func runDump(cmd *cobra.Command, args []string) {
|
|||
}
|
||||
}
|
||||
|
||||
for id, name := range site.Meters.ConsumerMetersRef {
|
||||
if name != "" {
|
||||
d.DumpWithHeader(fmt.Sprintf("consumer %d: %s", id+1, name), handle(name, config.Meters()))
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range site.Vehicles().Instances() {
|
||||
d.DumpWithHeader(fmt.Sprintf("vehicle: %s", v.GetTitle()), v)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ func runMigrate(cmd *cobra.Command, args []string) {
|
|||
settings.Delete(keys.PvMeters)
|
||||
settings.Delete(keys.AuxMeters)
|
||||
settings.Delete(keys.ExtMeters)
|
||||
settings.Delete(keys.ConsumerMeters)
|
||||
settings.Delete(keys.BatteryMeters)
|
||||
// clear config table
|
||||
result := db.Instance.Delete(&config.Config{}, "true")
|
||||
|
|
|
|||
|
|
@ -64,13 +64,14 @@ func collectSiteRefs(conf globalconfig.All) error {
|
|||
references.meter = append(references.meter, refs.Meters.BatteryMetersRef...)
|
||||
references.meter = append(references.meter, refs.Meters.ExtMetersRef...)
|
||||
references.meter = append(references.meter, refs.Meters.AuxMetersRef...)
|
||||
references.meter = append(references.meter, refs.Meters.ConsumerMetersRef...)
|
||||
|
||||
// append devices from settings
|
||||
if v, err := settings.String(keys.GridMeter); err == nil && v != "" {
|
||||
references.meter = append(references.meter, v)
|
||||
}
|
||||
|
||||
for _, key := range []string{keys.PvMeters, keys.BatteryMeters, keys.ExtMeters, keys.AuxMeters} {
|
||||
for _, key := range []string{keys.PvMeters, keys.BatteryMeters, keys.ExtMeters, keys.AuxMeters, keys.ConsumerMeters} {
|
||||
if v, err := settings.String(key); err == nil && v != "" {
|
||||
references.meter = append(references.meter, strings.Split(v, ",")...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const (
|
|||
Aux = "aux"
|
||||
AuxPower = "auxPower"
|
||||
Circuits = "circuits"
|
||||
Consumers = "consumers"
|
||||
Currency = "currency"
|
||||
Ext = "ext"
|
||||
GreenShareHome = "greenShareHome"
|
||||
|
|
@ -36,6 +37,7 @@ const (
|
|||
BatteryMeters = "batteryMeters"
|
||||
ExtMeters = "extMeters"
|
||||
AuxMeters = "auxMeters"
|
||||
ConsumerMeters = "consumerMeters"
|
||||
|
||||
// battery settings
|
||||
BatteryDischargeControl = "batteryDischargeControl"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ const (
|
|||
PV = "pv"
|
||||
Home = "home" // meter and group (virtual measurement)
|
||||
Loadpoint = "loadpoint"
|
||||
Meter = "meter" // generic meter (ext/aux)
|
||||
Meter = "meter" // additional meter (ext, monitoring only)
|
||||
Consumer = "consumer" // consumer meter (consumers list or aux)
|
||||
)
|
||||
|
||||
type Collector struct {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ type SeriesCSV []Series
|
|||
|
||||
// GroupOrder is the canonical display order of metric groups, mirroring the
|
||||
// frontend GROUP_ORDER plus home/forecast.
|
||||
var GroupOrder = []string{PV, Battery, Grid, Loadpoint, Meter, Home, Forecast}
|
||||
var GroupOrder = []string{PV, Battery, Grid, Loadpoint, Consumer, Meter, Home, Forecast}
|
||||
|
||||
var aggregateFormats = map[string]string{
|
||||
"15m": "%Y-%m-%d %H:%M",
|
||||
|
|
|
|||
40
core/site.go
40
core/site.go
|
|
@ -72,6 +72,7 @@ type Site struct {
|
|||
batteryMeters []config.Device[api.Meter] // Battery charging meters
|
||||
extMeters []config.Device[api.Meter] // External meters - for monitoring only
|
||||
auxMeters []config.Device[api.Meter] // Auxiliary meters
|
||||
consumerMeters []config.Device[api.Meter] // Consumer meters
|
||||
|
||||
// battery settings
|
||||
prioritySoc float64 // prefer battery up to this Soc
|
||||
|
|
@ -109,6 +110,7 @@ type MetersConfig struct {
|
|||
BatteryMetersRef []string `mapstructure:"battery"` // Battery charging meter
|
||||
ExtMetersRef []string `mapstructure:"ext"` // Meters used only for monitoring
|
||||
AuxMetersRef []string `mapstructure:"aux"` // Auxiliary meters
|
||||
ConsumerMetersRef []string `mapstructure:"consumers"` // Consumer meters
|
||||
}
|
||||
|
||||
// NewSiteFromConfig creates a new site
|
||||
|
|
@ -237,7 +239,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
site.collectors[ref] = me
|
||||
}
|
||||
|
||||
// meters used only for monitoring
|
||||
// additional meters used only for monitoring
|
||||
for _, ref := range site.Meters.ExtMetersRef {
|
||||
dev, err := config.Meters().ByName(ref)
|
||||
if err != nil {
|
||||
|
|
@ -252,7 +254,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
site.collectors[ref] = me
|
||||
}
|
||||
|
||||
// auxiliary meters
|
||||
// auxiliary meters (consumers)
|
||||
for _, ref := range site.Meters.AuxMetersRef {
|
||||
dev, err := config.Meters().ByName(ref)
|
||||
if err != nil {
|
||||
|
|
@ -260,7 +262,22 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
}
|
||||
site.auxMeters = append(site.auxMeters, dev)
|
||||
|
||||
me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev))
|
||||
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
site.collectors[ref] = me
|
||||
}
|
||||
|
||||
// consumer meters
|
||||
for _, ref := range site.Meters.ConsumerMetersRef {
|
||||
dev, err := config.Meters().ByName(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
site.consumerMeters = append(site.consumerMeters, dev)
|
||||
|
||||
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -313,6 +330,9 @@ func (site *Site) restoreMetersAndTitle() {
|
|||
if v, err := settings.String(keys.AuxMeters); err == nil && v != "" {
|
||||
site.Meters.AuxMetersRef = append(site.Meters.AuxMetersRef, filterConfigurable(strings.Split(v, ","))...)
|
||||
}
|
||||
if v, err := settings.String(keys.ConsumerMeters); err == nil && v != "" {
|
||||
site.Meters.ConsumerMetersRef = append(site.Meters.ConsumerMetersRef, filterConfigurable(strings.Split(v, ","))...)
|
||||
}
|
||||
}
|
||||
|
||||
// restoreSettings restores site settings
|
||||
|
|
@ -750,6 +770,19 @@ func (site *Site) updateAuxMeters() {
|
|||
site.publish(keys.Aux, mm)
|
||||
}
|
||||
|
||||
// updateConsumerMeters updates consumer meters
|
||||
func (site *Site) updateConsumerMeters() {
|
||||
if len(site.consumerMeters) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
mm := site.collectMeters("consumer", site.consumerMeters)
|
||||
|
||||
site.addMeterEnergy(site.consumerMeters, mm)
|
||||
|
||||
site.publish(keys.Consumers, mm)
|
||||
}
|
||||
|
||||
// updateExtMeters updates ext meters
|
||||
func (site *Site) updateExtMeters() {
|
||||
if len(site.extMeters) == 0 {
|
||||
|
|
@ -832,6 +865,7 @@ func (site *Site) updateMeters() error {
|
|||
eg.Go(func() error { site.updatePvMeters(); return nil })
|
||||
eg.Go(func() error { site.updateBatteryMeters(); return nil })
|
||||
eg.Go(func() error { site.updateAuxMeters(); return nil })
|
||||
eg.Go(func() error { site.updateConsumerMeters(); return nil })
|
||||
eg.Go(func() error { site.updateExtMeters(); return nil })
|
||||
|
||||
eg.Go(site.updateGridMeter)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ type API interface {
|
|||
SetAuxMeterRefs([]string)
|
||||
GetExtMeterRefs() []string
|
||||
SetExtMeterRefs([]string)
|
||||
GetConsumerMeterRefs() []string
|
||||
SetConsumerMeterRefs([]string)
|
||||
|
||||
// circuits
|
||||
GetCircuit() api.Circuit
|
||||
|
|
|
|||
|
|
@ -129,6 +129,22 @@ func (site *Site) SetAuxMeterRefs(ref []string) {
|
|||
settings.SetString(keys.AuxMeters, strings.Join(filterConfigurable(ref), ","))
|
||||
}
|
||||
|
||||
// GetConsumerMeterRefs returns the ConsumerMeterRef
|
||||
func (site *Site) GetConsumerMeterRefs() []string {
|
||||
site.RLock()
|
||||
defer site.RUnlock()
|
||||
return site.Meters.ConsumerMetersRef
|
||||
}
|
||||
|
||||
// SetConsumerMeterRefs sets the ConsumerMeterRef
|
||||
func (site *Site) SetConsumerMeterRefs(ref []string) {
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
|
||||
site.Meters.ConsumerMetersRef = ref
|
||||
settings.SetString(keys.ConsumerMeters, strings.Join(filterConfigurable(ref), ","))
|
||||
}
|
||||
|
||||
// GetExtMeterRefs returns the ExtMeterRef
|
||||
func (site *Site) GetExtMeterRefs() []string {
|
||||
site.RLock()
|
||||
|
|
|
|||
20
i18n/de.json
20
i18n/de.json
|
|
@ -120,6 +120,11 @@
|
|||
"title": "Lastmanagement",
|
||||
"usableMeters": "Verwendbare Zählerreferenzen"
|
||||
},
|
||||
"consumer": {
|
||||
"description": "Erfasst kleinere, nicht steuerbare Verbraucher (Kühlschrank, Waschmaschine, ...). So bekommst du einen besseren Überblick über deinen Hausverbrauch.",
|
||||
"titleAdd": "Verbrauchszähler hinzufügen",
|
||||
"titleEdit": "Verbrauchszähler bearbeiten"
|
||||
},
|
||||
"control": {
|
||||
"description": "Normalerweise sind die Standardwerte in Ordnung. Ändere nur etwas, wenn du weißt, was du tust.",
|
||||
"descriptionInterval": "Aktualisierungszyklus in Sekunden. Definiert, wie oft evcc Messdaten liest und die Ladung anpasst. Die Standardeinstellung von 30 Sekunden ist eine sichere Wahl. Geräte wie Fahrzeuge, Wallbox und Wechselrichter brauchen in der Regel mehrere Sekunden um ihr Verhalten anzupassen. Wenn deine Komponenten schnell reagieren kannst du niedrigere Werte verwenden. Wir empfehlen dringend nicht unter 10 Sekunden zu gehen. Wenn du komisches Regelverhalten oder springende Leistungswerte beobachtest wähle ein größeres Intervall.",
|
||||
|
|
@ -235,9 +240,9 @@
|
|||
"title": "Experimentell"
|
||||
},
|
||||
"ext": {
|
||||
"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"
|
||||
"description": "Erfasst Zähler, die nicht für Regelung oder Anzeige verwendet werden (Unterverteilungen, alternative Netz- oder PV-Zähler, ...). Ihre Daten werden geloggt und wie andere Zähler exportiert.",
|
||||
"titleAdd": "Zusätzlichen Zähler hinzufügen",
|
||||
"titleEdit": "Zusätzlichen Zähler bearbeiten"
|
||||
},
|
||||
"form": {
|
||||
"danger": "Achtung",
|
||||
|
|
@ -438,6 +443,7 @@
|
|||
},
|
||||
"main": {
|
||||
"addAdditional": "Zusätzlichen Zähler hinzufügen",
|
||||
"addConsumer": "Verbraucher hinzufügen",
|
||||
"addGrid": "Netzzähler hinzufügen",
|
||||
"addLoadpoint": "Wallbox oder Heizung hinzufügen",
|
||||
"addPvBattery": "PV oder Speicher hinzufügen",
|
||||
|
|
@ -526,9 +532,9 @@
|
|||
"delete": "Löschen",
|
||||
"generic": "Generische Integrationen",
|
||||
"option": {
|
||||
"aux": "Intelligenten Verbraucher hinzufügen",
|
||||
"aux": "Selbstregelnden Verbraucher hinzufügen",
|
||||
"battery": "Hausbatterie hinzufügen",
|
||||
"ext": "Regulären Verbraucher hinzufügen",
|
||||
"consumer": "Regulären Verbraucher hinzufügen",
|
||||
"pv": "PV-Anlage hinzufügen"
|
||||
},
|
||||
"save": "Speichern",
|
||||
|
|
@ -724,6 +730,7 @@
|
|||
},
|
||||
"section": {
|
||||
"additionalMeter": "Zusätzliche Zähler",
|
||||
"consumers": "Verbraucher",
|
||||
"general": "Allgemein",
|
||||
"grid": "Netzanschluss",
|
||||
"integrations": "Integrationen",
|
||||
|
|
@ -1250,10 +1257,11 @@
|
|||
"empty": "Keine Verlaufsdaten für diesen Zeitraum.",
|
||||
"group": {
|
||||
"battery": "Batterie",
|
||||
"consumer": "Verbrauch",
|
||||
"forecast": "Vorhersage",
|
||||
"grid": "Netz",
|
||||
"loadpoint": "Laden & Heizen",
|
||||
"meter": "Verbrauch",
|
||||
"meter": "Weitere Zähler",
|
||||
"pv": "Erzeugung"
|
||||
},
|
||||
"otherConsumers": "Andere",
|
||||
|
|
|
|||
18
i18n/en.json
18
i18n/en.json
|
|
@ -120,6 +120,11 @@
|
|||
"title": "Load Management",
|
||||
"usableMeters": "Usable meter references"
|
||||
},
|
||||
"consumer": {
|
||||
"description": "Tracks smaller, non-controllable appliances (refrigerator, washing machine, ...). Gives you a better overview of your home consumption.",
|
||||
"titleAdd": "Add Consumer Meter",
|
||||
"titleEdit": "Edit Consumer Meter"
|
||||
},
|
||||
"control": {
|
||||
"description": "Usually the default values are fine. Only change them if you know what you are doing.",
|
||||
"descriptionInterval": "Update cycle in seconds. Defines how often evcc reads meter data and adjusts charging. The default of 30 seconds is a safe choice. Devices like vehicles, wallboxes and inverters typically need several seconds to adjust their behavior. If your components react quickly you can use lower values. We strongly recommend not going below 10 seconds. If you observe erratic control behavior or jumping power values choose a larger interval.",
|
||||
|
|
@ -235,9 +240,9 @@
|
|||
"title": "Experimental"
|
||||
},
|
||||
"ext": {
|
||||
"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"
|
||||
"description": "Tracks meters that are not used for regulation or shown in the UI (sub-distributions, alternative grid or solar meters, ...). Their data is logged and exported like other meters.",
|
||||
"titleAdd": "Add Additional Meter",
|
||||
"titleEdit": "Edit Additional Meter"
|
||||
},
|
||||
"form": {
|
||||
"danger": "Danger",
|
||||
|
|
@ -437,6 +442,7 @@
|
|||
},
|
||||
"main": {
|
||||
"addAdditional": "Add additional meter",
|
||||
"addConsumer": "Add consumer",
|
||||
"addGrid": "Add grid meter",
|
||||
"addLoadpoint": "Add charger or heater",
|
||||
"addPvBattery": "Add solar or battery",
|
||||
|
|
@ -527,7 +533,7 @@
|
|||
"option": {
|
||||
"aux": "Add self-regulating consumer",
|
||||
"battery": "Add battery meter",
|
||||
"ext": "Add regular consumer",
|
||||
"consumer": "Add regular consumer",
|
||||
"pv": "Add solar meter"
|
||||
},
|
||||
"save": "Save",
|
||||
|
|
@ -723,6 +729,7 @@
|
|||
},
|
||||
"section": {
|
||||
"additionalMeter": "Additional meters",
|
||||
"consumers": "Consumers",
|
||||
"general": "General",
|
||||
"grid": "Grid",
|
||||
"integrations": "Integrations",
|
||||
|
|
@ -1249,10 +1256,11 @@
|
|||
"empty": "No history data available for this time range.",
|
||||
"group": {
|
||||
"battery": "Battery",
|
||||
"consumer": "Consumption",
|
||||
"forecast": "Forecast",
|
||||
"grid": "Grid",
|
||||
"loadpoint": "Charging & Heating",
|
||||
"meter": "Consumption",
|
||||
"meter": "Additional meters",
|
||||
"pv": "Production"
|
||||
},
|
||||
"otherConsumers": "Others",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ func siteHandler(site site.API) http.HandlerFunc {
|
|||
Battery []string `json:"battery"`
|
||||
Aux []string `json:"aux"`
|
||||
Ext []string `json:"ext"`
|
||||
Consumers []string `json:"consumers"`
|
||||
}{
|
||||
Title: site.GetTitle(),
|
||||
Grid: site.GetGridMeterRef(),
|
||||
|
|
@ -24,6 +25,7 @@ func siteHandler(site site.API) http.HandlerFunc {
|
|||
Battery: site.GetBatteryMeterRefs(),
|
||||
Aux: site.GetAuxMeterRefs(),
|
||||
Ext: site.GetExtMeterRefs(),
|
||||
Consumers: site.GetConsumerMeterRefs(),
|
||||
}
|
||||
|
||||
jsonWrite(w, res)
|
||||
|
|
@ -50,6 +52,7 @@ func updateSiteHandler(site site.API) http.HandlerFunc {
|
|||
Battery *[]string
|
||||
Aux *[]string
|
||||
Ext *[]string
|
||||
Consumers *[]string
|
||||
}
|
||||
|
||||
if err := jsonDecoder(r.Body).Decode(&payload); err != nil {
|
||||
|
|
@ -106,6 +109,15 @@ func updateSiteHandler(site site.API) http.HandlerFunc {
|
|||
setConfigDirty()
|
||||
}
|
||||
|
||||
if payload.Consumers != nil {
|
||||
if !validateRefs(w, *payload.Consumers) {
|
||||
return
|
||||
}
|
||||
|
||||
site.SetConsumerMeterRefs(*payload.Consumers)
|
||||
setConfigDirty()
|
||||
}
|
||||
|
||||
status := map[bool]int{false: http.StatusOK, true: http.StatusAccepted}
|
||||
w.WriteHeader(status[ConfigDirty()])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@ test.describe("aux meter", async () => {
|
|||
await expect(page.getByTestId("aux")).toHaveCount(0);
|
||||
|
||||
// create
|
||||
await page.getByRole("button", { name: "Add additional meter" }).click();
|
||||
await page.getByRole("button", { name: "Add consumer" }).click();
|
||||
|
||||
const meterModal = page.getByTestId("meter-modal");
|
||||
await meterModal.getByRole("button", { name: "Add self-regulating consumer" }).click();
|
||||
await meterModal.getByRole("button", { name: "Self-regulating consumer" }).click();
|
||||
await meterModal.getByLabel("Title").fill("Water heater");
|
||||
await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
|
||||
await meterModal.getByLabel("Power").fill("1200");
|
||||
|
|
@ -61,14 +61,13 @@ test.describe("aux meter", async () => {
|
|||
test("user-defined meter", async ({ page }) => {
|
||||
await page.goto("/#/config");
|
||||
|
||||
await page.getByRole("button", { name: "Add additional meter" }).click();
|
||||
await page.getByRole("button", { name: "Add consumer" }).click();
|
||||
const modal = page.getByTestId("meter-modal");
|
||||
await expectModalVisible(modal);
|
||||
await modal.getByRole("button", { name: "Add self-regulating consumer" }).click();
|
||||
await modal.getByRole("button", { name: "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");
|
||||
|
||||
|
|
@ -107,7 +106,6 @@ energy:
|
|||
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");
|
||||
|
||||
|
|
@ -153,15 +151,14 @@ energy:
|
|||
test("user-defined meter with errors", async ({ page }) => {
|
||||
await page.goto("/#/config");
|
||||
|
||||
await page.getByRole("button", { name: "Add additional meter" }).click();
|
||||
await page.getByRole("button", { name: "Add consumer" }).click();
|
||||
|
||||
const modal = page.getByTestId("meter-modal");
|
||||
await expectModalVisible(modal);
|
||||
await modal.getByRole("button", { name: "Add self-regulating consumer" }).click();
|
||||
await modal.getByRole("button", { name: "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
|
||||
|
|
|
|||
61
tests/config-consumer.spec.ts
Normal file
61
tests/config-consumer.spec.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { start, stop, restart, baseUrl } from "./evcc";
|
||||
import { expectModalHidden, expectModalVisible } from "./utils";
|
||||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await start();
|
||||
});
|
||||
test.afterEach(async () => {
|
||||
await stop();
|
||||
});
|
||||
|
||||
test.describe("consumer", () => {
|
||||
test("regular consumer", async ({ page }) => {
|
||||
await page.goto("/#/config");
|
||||
|
||||
await page.getByRole("button", { name: "Add consumer" }).click();
|
||||
const modal = page.getByTestId("meter-modal");
|
||||
await expectModalVisible(modal);
|
||||
await modal.getByRole("button", { name: "Regular consumer" }).click();
|
||||
|
||||
// usage selector hidden — consumers are always charge meters
|
||||
await expect(modal.getByLabel("Usage")).toHaveCount(0);
|
||||
|
||||
await modal.getByLabel("Title").fill("EV charger");
|
||||
await modal.getByLabel("Manufacturer").selectOption("Demo meter");
|
||||
await modal.getByLabel("Power").fill("3500");
|
||||
await modal.getByRole("button", { name: "Save" }).click();
|
||||
await expectModalHidden(modal);
|
||||
|
||||
// appears in consumers section as a consumer card
|
||||
await expect(page.getByTestId("consumer")).toHaveCount(1);
|
||||
await expect(page.getByTestId("consumer")).toContainText("EV charger");
|
||||
|
||||
// restart and confirm persistence
|
||||
await restart();
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("consumer")).toHaveCount(1);
|
||||
await expect(page.getByTestId("consumer")).toContainText("EV charger");
|
||||
});
|
||||
|
||||
test("self-regulating consumer", async ({ page }) => {
|
||||
await page.goto("/#/config");
|
||||
|
||||
await page.getByRole("button", { name: "Add consumer" }).click();
|
||||
const modal = page.getByTestId("meter-modal");
|
||||
await expectModalVisible(modal);
|
||||
await modal.getByRole("button", { name: "Self-regulating consumer" }).click();
|
||||
|
||||
await modal.getByLabel("Title").fill("Heat pump");
|
||||
await modal.getByLabel("Manufacturer").selectOption("Demo meter");
|
||||
await modal.getByLabel("Power").fill("800");
|
||||
await modal.getByRole("button", { name: "Save" }).click();
|
||||
await expectModalHidden(modal);
|
||||
|
||||
// self-regulating consumers are stored as aux meters
|
||||
await expect(page.getByTestId("aux")).toHaveCount(1);
|
||||
await expect(page.getByTestId("aux")).toContainText("Heat pump");
|
||||
});
|
||||
});
|
||||
|
|
@ -7,15 +7,13 @@ const CONFIG_BASICS = "basics.evcc.yaml";
|
|||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
|
||||
async function createExtMeter(page: Page, title: string, power: string) {
|
||||
async function createAdditionalMeter(page: Page, title: string, power: string) {
|
||||
const modal = page.getByTestId("meter-modal");
|
||||
|
||||
await page.getByRole("button", { name: "Add additional meter" }).click();
|
||||
await expectModalVisible(modal);
|
||||
await modal.getByRole("button", { name: "Add regular consumer" }).click();
|
||||
await modal.getByLabel("Title").fill(title);
|
||||
await modal.getByLabel("Usage").selectOption("battery");
|
||||
await modal.getByLabel("Manufacturer").selectOption("Demo battery");
|
||||
await modal.getByLabel("Manufacturer").selectOption("Demo meter");
|
||||
await modal.getByLabel("Power").fill(power);
|
||||
await modal.getByRole("button", { name: "Save" }).click();
|
||||
await expectModalHidden(modal);
|
||||
|
|
@ -33,22 +31,15 @@ test.describe("ext meter", async () => {
|
|||
await page.goto("/#/config");
|
||||
await expect(page.getByTestId("ext")).toHaveCount(0);
|
||||
|
||||
// additional meter flow: opens ext modal directly, usage defaults to charge
|
||||
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");
|
||||
|
|
@ -101,7 +92,6 @@ test.describe("ext meter", async () => {
|
|||
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("pv");
|
||||
|
|
@ -111,7 +101,6 @@ test.describe("ext meter", async () => {
|
|||
await expect(meterModal.getByLabel("IP address or hostname")).toBeVisible();
|
||||
|
||||
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");
|
||||
|
|
@ -137,7 +126,6 @@ test.describe("ext meter", async () => {
|
|||
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);
|
||||
|
|
@ -158,7 +146,6 @@ test.describe("ext meter", async () => {
|
|||
|
||||
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();
|
||||
|
|
@ -184,9 +171,9 @@ test.describe("ext meter order", async () => {
|
|||
await expect(page.getByTestId("ext")).toHaveCount(0);
|
||||
|
||||
// Create meters
|
||||
await createExtMeter(page, "Meter 1", "10");
|
||||
await createExtMeter(page, "Meter 2", "20");
|
||||
await createExtMeter(page, "Meter 3", "30");
|
||||
await createAdditionalMeter(page, "Meter 1", "10");
|
||||
await createAdditionalMeter(page, "Meter 2", "20");
|
||||
await createAdditionalMeter(page, "Meter 3", "30");
|
||||
|
||||
// Verify order in config UI
|
||||
const extMeters = page.getByTestId("ext");
|
||||
|
|
@ -204,15 +191,5 @@ test.describe("ext meter order", async () => {
|
|||
await expect(extMeters.nth(0)).toContainText("Meter 1");
|
||||
await expect(extMeters.nth(1)).toContainText("Meter 2");
|
||||
await expect(extMeters.nth(2)).toContainText("Meter 3");
|
||||
|
||||
// Verify order in main UI consumer dropdown
|
||||
await page.goto("/#/");
|
||||
await expect(page.getByTestId("loadpoint")).toHaveCount(1);
|
||||
await page.getByTestId("energyflow").click();
|
||||
await page.getByRole("button", { name: "Consumption" }).click();
|
||||
const consumers = page.getByTestId("energyflow-entry-consumer");
|
||||
await expect(consumers.nth(0)).toContainText("Meter 1");
|
||||
await expect(consumers.nth(1)).toContainText("Meter 2");
|
||||
await expect(consumers.nth(2)).toContainText("Meter 3");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ const BLUE = "rgb(96, 165, 250)"; // #60A5FA — palette[0]
|
|||
const AMBER = "rgb(251, 191, 36)"; // #FBBF24 — palette[1]
|
||||
const CUSTOM_HEX = "FF0080";
|
||||
const CUSTOM_RGB = "rgb(255, 0, 128)";
|
||||
const CUSTOM_HEX2 = "00C8FF";
|
||||
const CUSTOM_RGB2 = "rgb(0, 200, 255)";
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await start("device-colors.evcc.yaml", "device-colors.sql");
|
||||
|
|
@ -26,6 +28,10 @@ async function expectLegendColor(scope: Locator | Page, label: string, rgb: stri
|
|||
await expect(dot).toHaveCSS("background-color", rgb);
|
||||
}
|
||||
|
||||
function chartSection(page: Page, heading: string): Locator {
|
||||
return page.locator("section").filter({ has: page.getByRole("heading", { name: heading }) });
|
||||
}
|
||||
|
||||
test("device colors: autoassign, override, persistence", async ({ page }) => {
|
||||
// ---------- Step 1 — Sessions, by-vehicle view ----------
|
||||
await page.goto("/#/sessions?year=2026&month=5");
|
||||
|
|
@ -60,24 +66,31 @@ test("device colors: autoassign, override, persistence", async ({ page }) => {
|
|||
await expectLegendColor(lpSection, "Garage", AMBER);
|
||||
await expectLegendColor(lpSection, "Carport", BLUE);
|
||||
|
||||
// ---------- Step 4 — Custom hex on ext meter, reload ----------
|
||||
const meterSection = page
|
||||
.locator("section")
|
||||
.filter({ has: page.getByRole("heading", { name: "Consumption" }) });
|
||||
await expect(meterSection).toBeVisible();
|
||||
await legendBadge(meterSection, "Dishwasher").click();
|
||||
// ---------- Step 4 — Custom hex on consumer meter (Consumption chart) ----------
|
||||
const consumerSection = chartSection(page, "Consumption");
|
||||
await expect(consumerSection).toBeVisible();
|
||||
await legendBadge(consumerSection, "Dishwasher").click();
|
||||
const popover2 = page.getByRole("dialog");
|
||||
await expect(popover2).toBeVisible();
|
||||
// save-on-type fires when input matches hex regex
|
||||
await popover2.getByLabel("Hex color").fill(CUSTOM_HEX);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expectLegendColor(meterSection, "Dishwasher", CUSTOM_RGB);
|
||||
await expectLegendColor(consumerSection, "Dishwasher", CUSTOM_RGB);
|
||||
|
||||
// reload: color persists via settings DB
|
||||
// ---------- Step 5 — Custom hex on ext meter (Additional meters chart) ----------
|
||||
const meterSection = chartSection(page, "Additional meters");
|
||||
await expect(meterSection).toBeVisible();
|
||||
await legendBadge(meterSection, "Alternative grid").click();
|
||||
const popover3 = page.getByRole("dialog");
|
||||
await expect(popover3).toBeVisible();
|
||||
await popover3.getByLabel("Hex color").fill(CUSTOM_HEX2);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expectLegendColor(meterSection, "Alternative grid", CUSTOM_RGB2);
|
||||
|
||||
// ---------- Step 6 — Reload: both colors persist via settings DB ----------
|
||||
await page.reload();
|
||||
const meterSection2 = page
|
||||
.locator("section")
|
||||
.filter({ has: page.getByRole("heading", { name: "Consumption" }) });
|
||||
await expectLegendColor(meterSection2, "Dishwasher", CUSTOM_RGB);
|
||||
await expectLegendColor(chartSection(page, "Consumption"), "Dishwasher", CUSTOM_RGB);
|
||||
await expectLegendColor(chartSection(page, "Additional meters"), "Alternative grid", CUSTOM_RGB2);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,21 +53,31 @@ CREATE TABLE `configs` (
|
|||
INSERT INTO `sessions` VALUES (1, '2026-05-01 08:00:00.0+02:00', '2026-05-01 09:00:00.0+02:00', 'Garage', NULL, 'Honda', NULL, NULL, 8.0, NULL, 50, 1.0, 0.2, 100, 3600000000000);
|
||||
INSERT INTO `sessions` VALUES (2, '2026-05-02 08:00:00.0+02:00', '2026-05-02 09:00:00.0+02:00', 'Carport', NULL, 'Honda', NULL, NULL, 4.0, NULL, 50, 0.5, 0.2, 50, 3600000000000);
|
||||
|
||||
-- db-backed ext meter (class=2 meter, title=Dishwasher, usage=ext)
|
||||
-- db-backed consumer meter (Dishwasher, usage=charge) → "Consumption" chart
|
||||
INSERT INTO `configs`(id, class, type, title, icon, product, value)
|
||||
VALUES (1, 2, 'template', 'Dishwasher', '', 'Demo meter',
|
||||
'{"power":"100","template":"demo-meter","usage":"ext"}');
|
||||
'{"power":"100","template":"demo-meter","usage":"charge"}');
|
||||
|
||||
-- assign ext meter ref + enable experimental
|
||||
INSERT INTO `settings`(key, value) VALUES ('extMeters', 'db:1');
|
||||
-- db-backed ext meter (alternative grid meter, usage=grid) → "Additional meters" chart
|
||||
INSERT INTO `configs`(id, class, type, title, icon, product, value)
|
||||
VALUES (2, 2, 'template', 'Alternative grid', '', 'Demo meter',
|
||||
'{"power":"100","template":"demo-meter","usage":"grid"}');
|
||||
|
||||
-- assign consumer + ext refs, enable experimental
|
||||
INSERT INTO `settings`(key, value) VALUES ('consumerMeters', 'db:1');
|
||||
INSERT INTO `settings`(key, value) VALUES ('extMeters', 'db:2');
|
||||
INSERT INTO `settings`(key, value) VALUES ('experimental', 'true');
|
||||
|
||||
-- history entities matching evcc internal names
|
||||
INSERT INTO `entities` VALUES (1, 'loadpoint', 'lp-1');
|
||||
INSERT INTO `entities` VALUES (2, 'loadpoint', 'lp-2');
|
||||
INSERT INTO `entities` VALUES (3, 'meter', 'db:1');
|
||||
INSERT INTO `entities` VALUES (3, 'home', 'home');
|
||||
INSERT INTO `entities` VALUES (4, 'consumer', 'db:1');
|
||||
INSERT INTO `entities` VALUES (5, 'meter', 'db:2');
|
||||
|
||||
-- one 15-min slot at 2026-05-15 12:00:00+02:00 (ts=1778839200) per entity
|
||||
INSERT INTO `meters` VALUES (1, 1778839200, 2.0, 0);
|
||||
INSERT INTO `meters` VALUES (2, 1778839200, 1.0, 0);
|
||||
INSERT INTO `meters` VALUES (3, 1778839200, 0.5, 0);
|
||||
INSERT INTO `meters` VALUES (1, 1778839200, 2.0, 0); -- lp-1 (Garage)
|
||||
INSERT INTO `meters` VALUES (2, 1778839200, 1.0, 0); -- lp-2 (Carport)
|
||||
INSERT INTO `meters` VALUES (3, 1778839200, 1.0, 0); -- home
|
||||
INSERT INTO `meters` VALUES (4, 1778839200, 0.5, 0); -- Dishwasher (consumer)
|
||||
INSERT INTO `meters` VALUES (5, 1778839200, 0.3, 0); -- Alternative grid (ext)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ test.describe("api", () => {
|
|||
const data = await res.json();
|
||||
expect(data).toHaveLength(2);
|
||||
|
||||
const grid = data.find((s: { title: string }) => s.title === "grid");
|
||||
const grid = data.find((s: { title: string }) => s.title === "Grid");
|
||||
const home = data.find((s: { title: string }) => s.title === "home");
|
||||
expect(grid).toBeDefined();
|
||||
expect(home).toBeDefined();
|
||||
|
|
@ -46,7 +46,7 @@ test.describe("api", () => {
|
|||
const data = await res.json();
|
||||
expect(data).toHaveLength(2);
|
||||
|
||||
const grid = data.find((s: { title: string }) => s.title === "grid");
|
||||
const grid = data.find((s: { title: string }) => s.title === "Grid");
|
||||
const home = data.find((s: { title: string }) => s.title === "home");
|
||||
expect(grid).toBeDefined();
|
||||
expect(home).toBeDefined();
|
||||
|
|
@ -178,7 +178,7 @@ test.describe("consumption breakdown", () => {
|
|||
// virtual Others = home − meters = 0.3 kWh.
|
||||
test("home total, meter legend, virtual Others", async ({ page }) => {
|
||||
await gotoDay(page, 2026, 4, 7);
|
||||
const consumption = section(page, "meter");
|
||||
const consumption = section(page, "consumer");
|
||||
await expect(consumption).toBeVisible();
|
||||
|
||||
// Section total = home, not sum of meters.
|
||||
|
|
@ -193,7 +193,7 @@ test.describe("consumption breakdown", () => {
|
|||
// 2026-03-24: home = 0.4 kWh, no meter entities with data.
|
||||
test("home without meters shows chart without legend", async ({ page }) => {
|
||||
await gotoDay(page, 2026, 3, 24);
|
||||
const consumption = section(page, "meter");
|
||||
const consumption = section(page, "consumer");
|
||||
await expect(consumption).toBeVisible();
|
||||
|
||||
await expect(consumption.getByRole("heading")).toContainText("0.4 kWh");
|
||||
|
|
@ -203,8 +203,8 @@ test.describe("consumption breakdown", () => {
|
|||
|
||||
test("entity focus rescales axis and resets on unfocus", async ({ page }) => {
|
||||
await gotoDay(page, 2026, 4, 7);
|
||||
const consumption = section(page, "meter");
|
||||
const meterChart = chart(page, "meter");
|
||||
const consumption = section(page, "consumer");
|
||||
const meterChart = chart(page, "consumer");
|
||||
|
||||
// Stacked total per slot 1.0 kW → echarts auto-axis 0..1.2 in 1-decimal.
|
||||
expect(await yAxis(meterChart)).toEqual(["kW", "0.0", "0.3", "0.6", "0.9", "1.2"]);
|
||||
|
|
@ -220,6 +220,28 @@ test.describe("consumption breakdown", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test.describe("additional meters", () => {
|
||||
// 2026-04-09: single ext meter "Submeter" = 1.2 kWh, no home data.
|
||||
test("standalone section without virtual Others", async ({ page }) => {
|
||||
await gotoDay(page, 2026, 4, 9);
|
||||
const additional = section(page, "meter");
|
||||
await expect(additional).toBeVisible();
|
||||
|
||||
// Total is the entity sum, not home-derived.
|
||||
await expect(additional.getByRole("heading")).toContainText("1.2 kWh");
|
||||
|
||||
// Explicit entity legend, no virtual "Others" (unlike the consumer group).
|
||||
await expect(additional.getByRole("button", { name: "Submeter 1.2 kWh" })).toBeVisible();
|
||||
await expect(additional.getByText("Others", { exact: true })).toBeHidden();
|
||||
});
|
||||
|
||||
// 2026-04-10: same meter with export. Negatives flip the axis to symmetric.
|
||||
test("bidirectional axis when data contains exports", async ({ page }) => {
|
||||
await gotoDay(page, 2026, 4, 10);
|
||||
expect(await yAxis(chart(page, "meter"))).toEqual(["kW", "-2.0", "-1.0", "0.0", "1.0", "2.0"]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("reconnect", () => {
|
||||
test("history still shows content after backend restart", async ({ page }) => {
|
||||
await gotoDay(page, 2026, 3, 24);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ CREATE TABLE `entities` (
|
|||
`id` integer,
|
||||
`group` text,
|
||||
`name` text,
|
||||
`title` text,
|
||||
PRIMARY KEY (`id`)
|
||||
);
|
||||
CREATE UNIQUE INDEX `entities_group_name` ON `entities`(`group`, `name`);
|
||||
|
|
@ -14,17 +15,19 @@ CREATE TABLE `meters` (
|
|||
);
|
||||
CREATE UNIQUE INDEX `meters_meter_ts` ON `meters`(`meter`, `ts`);
|
||||
|
||||
-- entities
|
||||
INSERT INTO `entities` VALUES (1, 'home', 'home');
|
||||
INSERT INTO `entities` VALUES (2, 'grid', 'grid');
|
||||
INSERT INTO `entities` VALUES (3, 'battery', 'battery');
|
||||
INSERT INTO `entities` VALUES (4, 'pv', 'solar');
|
||||
INSERT INTO `entities` VALUES (5, 'meter', 'Kitchen');
|
||||
INSERT INTO `entities` VALUES (6, 'meter', 'Office');
|
||||
INSERT INTO `entities` VALUES (7, 'pv', 'pv-east');
|
||||
INSERT INTO `entities` VALUES (8, 'pv', 'pv-west');
|
||||
INSERT INTO `entities` VALUES (9, 'forecast', 'forecast');
|
||||
INSERT INTO `entities` VALUES (10, 'battery', 'battery-2');
|
||||
-- entities (name = stable id, title = display label)
|
||||
-- home is a virtual entity; evcc resets its title to "home" on boot
|
||||
INSERT INTO `entities` (id, `group`, name, title) VALUES (1, 'home', 'home', 'home');
|
||||
INSERT INTO `entities` (id, `group`, name, title) VALUES (2, 'grid', 'grid', 'Grid');
|
||||
INSERT INTO `entities` (id, `group`, name, title) VALUES (3, 'battery', 'battery', 'Battery');
|
||||
INSERT INTO `entities` (id, `group`, name, title) VALUES (4, 'pv', 'solar', 'Solar');
|
||||
INSERT INTO `entities` (id, `group`, name, title) VALUES (5, 'consumer', 'kitchen', 'Kitchen');
|
||||
INSERT INTO `entities` (id, `group`, name, title) VALUES (6, 'consumer', 'office', 'Office');
|
||||
INSERT INTO `entities` (id, `group`, name, title) VALUES (7, 'pv', 'pv-east', 'PV East');
|
||||
INSERT INTO `entities` (id, `group`, name, title) VALUES (8, 'pv', 'pv-west', 'PV West');
|
||||
INSERT INTO `entities` (id, `group`, name, title) VALUES (9, 'forecast', 'forecast', 'Forecast');
|
||||
INSERT INTO `entities` (id, `group`, name, title) VALUES (10, 'battery', 'battery-2', 'Battery 2');
|
||||
INSERT INTO `entities` (id, `group`, name, title) VALUES (11, 'meter', 'submeter', 'Submeter');
|
||||
|
||||
-- =====================================================================
|
||||
-- API test data: 2026-03-24/25 (existing). Used by the JSON-shape tests.
|
||||
|
|
@ -154,3 +157,17 @@ INSERT INTO `meters` VALUES (10, 1775645100, 0, 0.2);
|
|||
INSERT INTO `meters` VALUES (3, 1781517600, 1.4, 1.0);
|
||||
INSERT INTO `meters` VALUES (3, 1781604000, 1.4, 1.0);
|
||||
INSERT INTO `meters` VALUES (3, 1781690400, 1.4, 1.0);
|
||||
|
||||
-- 2026-04-09 → additional meter (ext) standalone chart. Single entity 1.2 kWh,
|
||||
-- not home-combined, so no virtual "Others" series.
|
||||
INSERT INTO `meters` VALUES (11, 1775728800, 0.3, 0);
|
||||
INSERT INTO `meters` VALUES (11, 1775729700, 0.3, 0);
|
||||
INSERT INTO `meters` VALUES (11, 1775730600, 0.3, 0);
|
||||
INSERT INTO `meters` VALUES (11, 1775731500, 0.3, 0);
|
||||
|
||||
-- 2026-04-10 → additional meter with export. Import peak 2 kW, export 0.4 kW.
|
||||
-- Negative values must flip the axis to symmetric (bidirectional) ±2 kW.
|
||||
INSERT INTO `meters` VALUES (11, 1775815200, 0.5, 0.1);
|
||||
INSERT INTO `meters` VALUES (11, 1775816100, 0.5, 0.1);
|
||||
INSERT INTO `meters` VALUES (11, 1775817000, 0.5, 0.1);
|
||||
INSERT INTO `meters` VALUES (11, 1775817900, 0.5, 0.1);
|
||||
|
|
|
|||
32
tests/energyflow-consumers.evcc.yaml
Normal file
32
tests/energyflow-consumers.evcc.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
site:
|
||||
title: Hello World
|
||||
meters:
|
||||
grid: grid
|
||||
consumers:
|
||||
- fridge
|
||||
aux:
|
||||
- heater
|
||||
ext:
|
||||
- submeter
|
||||
|
||||
meters:
|
||||
- name: grid
|
||||
type: custom
|
||||
power:
|
||||
source: js
|
||||
script: "1000"
|
||||
- name: fridge
|
||||
type: custom
|
||||
power:
|
||||
source: js
|
||||
script: "200"
|
||||
- name: heater
|
||||
type: custom
|
||||
power:
|
||||
source: js
|
||||
script: "800"
|
||||
- name: submeter
|
||||
type: custom
|
||||
power:
|
||||
source: js
|
||||
script: "300"
|
||||
27
tests/energyflow-consumers.spec.ts
Normal file
27
tests/energyflow-consumers.spec.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { start, stop, baseUrl } from "./evcc";
|
||||
|
||||
const CONFIG = "energyflow-consumers.evcc.yaml";
|
||||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await start(CONFIG);
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await stop();
|
||||
});
|
||||
|
||||
test("consumers breakdown lists consumers and aux, excludes ext", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("energyflow").click();
|
||||
|
||||
// expand the home consumers breakdown
|
||||
await page.getByTestId("energyflow-entry-home").click();
|
||||
|
||||
const consumers = page.getByTestId("energyflow-entry-consumer");
|
||||
await expect(consumers).toHaveCount(2);
|
||||
await expect(consumers.filter({ hasText: "fridge" })).toBeVisible(); // consumer
|
||||
await expect(consumers.filter({ hasText: "heater" })).toBeVisible(); // aux
|
||||
await expect(consumers.filter({ hasText: "submeter" })).toBeHidden(); // ext excluded
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue