Config UI: separate consumers from additional meters (#30243)

This commit is contained in:
Michael Geers 2026-06-19 14:31:09 +02:00 • committed by GitHub
parent 614ff4ac06
commit 8769ae489d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 494 additions and 211 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ const (
Aux = "aux"
AuxPower = "auxPower"
Circuits = "circuits"
Consumers = "consumers"
Currency = "currency"
Ext = "ext"
GreenShareHome = "greenShareHome"
@ -31,11 +32,12 @@ const (
Vehicles = "vehicles"
// meters
GridMeter = "gridMeter"
PvMeters = "pvMeters"
BatteryMeters = "batteryMeters"
ExtMeters = "extMeters"
AuxMeters = "auxMeters"
GridMeter = "gridMeter"
PvMeters = "pvMeters"
BatteryMeters = "batteryMeters"
ExtMeters = "extMeters"
AuxMeters = "auxMeters"
ConsumerMeters = "consumerMeters"
// battery settings
BatteryDischargeControl = "batteryDischargeControl"

View file

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

View file

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

View file

@ -65,13 +65,14 @@ type Site struct {
Meters MetersConfig `mapstructure:"meters"` // Meter references
// meters
circuit api.Circuit // Circuit
hems api.HEMS // HEMS (set by configureHEMS at boot)
gridMeter api.Meter // Grid usage meter
pvMeters []config.Device[api.Meter] // PV generation meters
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
circuit api.Circuit // Circuit
hems api.HEMS // HEMS (set by configureHEMS at boot)
gridMeter api.Meter // Grid usage meter
pvMeters []config.Device[api.Meter] // PV generation meters
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
@ -104,11 +105,12 @@ type Site struct {
// MetersConfig contains the site's meter configuration
type MetersConfig struct {
GridMeterRef string `mapstructure:"grid"` // Grid usage meter
PVMetersRef []string `mapstructure:"pv"` // PV meter
BatteryMetersRef []string `mapstructure:"battery"` // Battery charging meter
ExtMetersRef []string `mapstructure:"ext"` // Meters used only for monitoring
AuxMetersRef []string `mapstructure:"aux"` // Auxiliary meters
GridMeterRef string `mapstructure:"grid"` // Grid usage meter
PVMetersRef []string `mapstructure:"pv"` // PV meter
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)

View file

@ -33,6 +33,8 @@ type API interface {
SetAuxMeterRefs([]string)
GetExtMeterRefs() []string
SetExtMeterRefs([]string)
GetConsumerMeterRefs() []string
SetConsumerMeterRefs([]string)
// circuits
GetCircuit() api.Circuit

View file

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

View file

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

View file

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

View file

@ -11,19 +11,21 @@ import (
func siteHandler(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
res := struct {
Title string `json:"title"`
Grid string `json:"grid"`
PV []string `json:"pv"`
Battery []string `json:"battery"`
Aux []string `json:"aux"`
Ext []string `json:"ext"`
Title string `json:"title"`
Grid string `json:"grid"`
PV []string `json:"pv"`
Battery []string `json:"battery"`
Aux []string `json:"aux"`
Ext []string `json:"ext"`
Consumers []string `json:"consumers"`
}{
Title: site.GetTitle(),
Grid: site.GetGridMeterRef(),
PV: site.GetPVMeterRefs(),
Battery: site.GetBatteryMeterRefs(),
Aux: site.GetAuxMeterRefs(),
Ext: site.GetExtMeterRefs(),
Title: site.GetTitle(),
Grid: site.GetGridMeterRef(),
PV: site.GetPVMeterRefs(),
Battery: site.GetBatteryMeterRefs(),
Aux: site.GetAuxMeterRefs(),
Ext: site.GetExtMeterRefs(),
Consumers: site.GetConsumerMeterRefs(),
}
jsonWrite(w, res)
@ -44,12 +46,13 @@ func validateRefs(w http.ResponseWriter, refs []string) bool {
func updateSiteHandler(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var payload struct {
Title *string
Grid *string
PV *[]string
Battery *[]string
Aux *[]string
Ext *[]string
Title *string
Grid *string
PV *[]string
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()])
}

View file

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

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

View file

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

View file

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

View file

@ -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');
INSERT INTO `settings`(key, value) VALUES ('experimental', 'true');
-- 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)

View file

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

View file

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

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

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