Charts: color picker for loadpoints and consumers (#30021)

This commit is contained in:
Michael Geers 2026-05-20 12:47:19 +02:00 • committed by GitHub
parent 41c169ee9a
commit 62ecd175a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 994 additions and 144 deletions

90
assets/js/colors.test.ts Normal file
View file

@ -0,0 +1,90 @@
import { describe, it, expect } from "vitest";
import colors, { dimColor, lighterColor, fullColor, resolveColors } from "./colors";
import type { DeviceColors } from "./types/evcc";
describe("setAlpha helpers", () => {
it("appends alpha to 6-digit hex", () => {
expect(dimColor("#abcdef")).toBe("#abcdef20");
expect(lighterColor("#abcdef")).toBe("#abcdefaa");
expect(fullColor("#abcdef")).toBe("#abcdefff");
});
it("replaces alpha on 8-digit hex", () => {
expect(dimColor("#abcdefff")).toBe("#abcdef20");
expect(lighterColor("#abcdef12")).toBe("#abcdefaa");
expect(fullColor("#abcdef00")).toBe("#abcdefff");
});
it("handles null/undefined", () => {
expect(dimColor(null)).toBeUndefined();
expect(lighterColor(null)).toBeUndefined();
expect(fullColor(null)).toBeUndefined();
});
it("returns input unchanged for unexpected length", () => {
expect(dimColor("rgb(1,2,3)")).toBe("rgb(1,2,3)");
});
});
describe("resolveColors", () => {
const palette = colors.palette; // 18 entries
it("returns empty map for empty ids", () => {
expect(resolveColors([], {})).toEqual({});
});
it("autoassigns palette in order without overrides", () => {
const ids = ["a", "b", "c"];
expect(resolveColors(ids, {})).toEqual({
a: palette[0],
b: palette[1],
c: palette[2],
});
});
it("respects explicit override", () => {
const res = resolveColors(["a", "b"], { a: "#123456" });
expect(res["a"]).toBe("#123456");
expect(res["b"]).toBe(palette[0]);
});
it("skips overridden palette entry on autoassign", () => {
// override "a" with first palette color → others should not reuse it
const res = resolveColors(["a", "b", "c"], { a: palette[0] });
expect(res["a"]).toBe(palette[0]);
expect(res["b"]).toBe(palette[1]);
expect(res["c"]).toBe(palette[2]);
});
it("empty-string override falls back to autoassign", () => {
const res = resolveColors(["a", "b"], { a: "" });
expect(res["a"]).toBe(palette[0]);
expect(res["b"]).toBe(palette[1]);
});
it("normalizes case when matching taken colors", () => {
// override uses lowercase; should still skip the matching palette entry
const lower = palette[0].toLowerCase();
const res = resolveColors(["a", "b"], { a: lower });
expect(res["a"]).toBe(lower);
expect(res["b"]).toBe(palette[1]);
});
it("wraps around once palette exhausted", () => {
const ids = Array.from({ length: palette.length + 2 }, (_, i) => `id${i}`);
const res = resolveColors(ids, {});
expect(res[ids[0]]).toBe(palette[0]);
expect(res[ids[palette.length]]).toBe(palette[0]);
expect(res[ids[palette.length + 1]]).toBe(palette[1]);
});
it("with every palette entry overridden, autoassign wraps palette", () => {
const overrides: DeviceColors = {};
palette.forEach((c, i) => (overrides[`o${i}`] = c));
const ids = [...Object.keys(overrides), "extra"];
const res = resolveColors(ids, overrides);
palette.forEach((c, i) => expect(res[`o${i}`]).toBe(c));
// free list empty → falls back to wrap-around on palette
expect(res["extra"]).toBe(palette[0]);
});
});

View file

@ -1,4 +1,5 @@
import { reactive } from "vue";
import type { DeviceColors } from "./types/evcc";
// alternatives
// const COLORS = [ "#40916C", "#52B788", "#74C69D", "#95D5B2", "#B7E4C7", "#D8F3DC", "#081C15", "#1B4332", "#2D6A4F"];
@ -35,25 +36,27 @@ const colors: {
export: null,
background: null,
light: null,
selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"],
selfPalette: ["#0FDE41", "#FFBD2F", "#FD6158", "#03C1EF", "#0F662D", "#FF922E"],
palette: [
// Dynamic palette (vehicles, loadpoints, …): optimized for neighbor contrast, avoids overlap with system colors (solar, battery, grid, …).
"#06B6D4FF", // Cyan
"#2B7FFFFF", // Blue
"#6366F1FF", // Indigo
"#A855F7FF", // Violet
"#D946EFFF", // Magenta
"#EC4899FF", // Pink
"#FB7185FF", // Coral
"#475569FF", // Slate
"#1E40AFFF", // Royal
"#6D28D9FF", // Purple
"#BE185DFF", // Crimson
"#D2691EFF", // Sienna
"#67E8F9FF", // Glacier
"#C084FCFF", // Lilac
"#FFD580FF", // Sand
"#94A3B8FF", // Steel
"#0EA5E9", // Sky
"#EC4899", // Pink
"#34D399", // Mint
"#F97316", // Orange
"#7C3AED", // Violet
"#84CC16", // Lime
"#F43F5E", // Rose
"#2563EB", // Blue
"#FACC15", // Yellow
"#D946EF", // Magenta
"#10B981", // Emerald
"#1E40AF", // Royal
"#BE185D", // Crimson
"#F59E0B", // Marigold
"#6366F1", // Indigo
"#14B8A6", // Teal
"#A855F7", // Lavender
"#DC2626", // Red
],
});
@ -67,6 +70,38 @@ const setAlpha = (color: string | null, alpha: string): string | undefined => {
return c;
};
// regex for raw hex (no leading #): 6 digits, used for input validation
export const HEX_RE = /^[0-9a-fA-F]{6}$/;
// normalize hex to uppercase 7-char #RRGGBB (strips alpha if present)
export const normalizeHex = (color?: string | null): string => {
if (!color) return "";
let c = color.trim().toUpperCase();
if (!c.startsWith("#")) c = "#" + c;
return c.slice(0, 7);
};
// override wins; rest get next free palette entry, wrap-around when exhausted
export function resolveColors(ids: string[], overrides: DeviceColors = {}): DeviceColors {
const taken = new Set(Object.values(overrides).map(normalizeHex));
const free = colors.palette.filter((c) => !taken.has(normalizeHex(c)));
const result: DeviceColors = {};
let idx = 0;
for (const id of ids) {
const ov = overrides[id];
if (ov) {
result[id] = ov;
} else if (free.length) {
result[id] = free[idx % free.length];
idx++;
} else {
result[id] = colors.palette[idx % colors.palette.length];
idx++;
}
}
return result;
}
export const dimColor = (color: string | null) => setAlpha(color, "20");
export const lighterColor = (color: string | null) => setAlpha(color, "aa");

View file

@ -0,0 +1,310 @@
<template>
<Teleport to="body">
<div
v-if="modelValue"
ref="popoverEl"
class="color-picker-popover shadow rounded-3 border"
role="dialog"
:aria-label="title || 'Color picker'"
@click.stop
>
<div class="footer-row">
<button
type="button"
class="auto-btn text-truncate text-uppercase fw-semibold text-body border rounded bg-body"
:class="{ 'is-selected': mode === 'auto' }"
@click="pickAuto"
>
{{ $t("colors.auto") }}
</button>
<div
class="custom-input-wrap d-flex align-items-center border rounded bg-body"
:class="{ 'is-selected': mode === 'custom' }"
>
<label
class="custom-preview position-relative overflow-hidden border rounded flex-shrink-0"
:style="{ backgroundColor: customHex || '#00000000' }"
>
<input
type="color"
class="custom-native position-absolute opacity-0 p-0 border-0 bg-transparent w-100 h-100 top-0 start-0"
:value="nativeColorValue"
@input="onNativeInput"
/>
</label>
<span class="custom-hash text-secondary">#</span>
<input
ref="hexInputEl"
type="text"
class="custom-input border-0 bg-transparent font-monospace text-uppercase text-body flex-grow-1"
:value="hexInputValue"
:aria-label="$t('colors.hex')"
spellcheck="false"
maxlength="6"
@input="onHexInput"
@focus="hexFocused = true"
@blur="onHexBlur"
@keydown.enter.prevent="onHexCommit"
/>
</div>
</div>
<div class="swatch-grid border-top mt-2 pt-2">
<button
v-for="hex in palette"
:key="hex"
type="button"
class="swatch border-0 p-0 d-flex align-items-center justify-content-center position-relative rounded text-white"
:class="{ 'swatch--selected': isSwatchSelected(hex) }"
:style="{ backgroundColor: hex }"
:title="hex"
@click="pick(hex)"
>
<svg
v-if="isSwatchSelected(hex)"
class="swatch-check"
viewBox="0 0 16 16"
width="14"
height="14"
aria-hidden="true"
>
<path
d="M3.5 8.5l3 3 6-6"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="popover-arrow" data-popper-arrow></div>
</div>
</Teleport>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { createPopper, type Instance as PopperInstance } from "@popperjs/core";
import colors, { HEX_RE, normalizeHex } from "../../colors";
export default defineComponent({
name: "ColorPickerPopover",
props: {
modelValue: { type: Boolean, default: false },
anchorEl: { type: Object as PropType<HTMLElement | null>, default: null },
color: { type: String as PropType<string | null>, default: null },
title: { type: String, default: "" },
},
emits: ["update:modelValue", "update:color"],
data() {
return {
palette: colors.palette,
popper: null as PopperInstance | null,
hexInputValue: "",
hexFocused: false,
};
},
computed: {
customHex(): string {
return normalizeHex(this.color);
},
nativeColorValue(): string {
return this.customHex || "#000000";
},
matchesPalette(): boolean {
if (!this.customHex) return false;
return this.palette.some((p) => normalizeHex(p) === this.customHex);
},
mode(): "auto" | "palette" | "custom" {
if (this.hexFocused) return "custom";
if (!this.customHex) return "auto";
if (this.matchesPalette) return "palette";
return "custom";
},
},
watch: {
modelValue(open: boolean) {
if (open) {
this.hexInputValue = this.customHex.replace(/^#/, "");
this.hexFocused = false;
this.$nextTick(this.initPopper);
document.addEventListener("mousedown", this.onDocClick, true);
document.addEventListener("keydown", this.onKey, true);
} else {
this.teardown();
}
},
color(c: string) {
this.hexInputValue = normalizeHex(c).replace(/^#/, "");
},
anchorEl() {
if (this.modelValue) this.$nextTick(this.initPopper);
},
},
beforeUnmount() {
this.teardown();
},
methods: {
isSwatchSelected(hex: string): boolean {
if (!this.customHex) return false;
return normalizeHex(hex) === this.customHex;
},
pick(hex: string) {
this.$emit("update:color", hex);
},
pickAuto() {
this.hexFocused = false;
(this.$refs["hexInputEl"] as HTMLInputElement | undefined)?.blur();
this.$emit("update:color", "");
},
onHexInput(e: Event) {
const v = (e.target as HTMLInputElement).value;
this.hexInputValue = v;
const trimmed = v.trim();
if (HEX_RE.test(trimmed)) {
this.$emit("update:color", "#" + trimmed.toUpperCase());
}
},
onHexCommit() {
const v = this.hexInputValue.trim();
if (v === "") {
this.$emit("update:color", "");
return;
}
if (HEX_RE.test(v)) {
this.$emit("update:color", "#" + v.toUpperCase());
} else {
this.hexInputValue = this.customHex.replace(/^#/, "");
}
},
onHexBlur() {
this.hexFocused = false;
this.onHexCommit();
},
onNativeInput(e: Event) {
const v = (e.target as HTMLInputElement).value;
this.$emit("update:color", v.toUpperCase());
},
initPopper() {
if (!this.anchorEl || !this.$refs["popoverEl"]) return;
this.popper?.destroy();
this.popper = createPopper(this.anchorEl, this.$refs["popoverEl"] as HTMLElement, {
placement: "top",
modifiers: [
{ name: "offset", options: { offset: [0, 10] } },
{ name: "arrow", options: { padding: 8 } },
{ name: "preventOverflow", options: { padding: 8 } },
],
});
},
teardown() {
this.popper?.destroy();
this.popper = null;
document.removeEventListener("mousedown", this.onDocClick, true);
document.removeEventListener("keydown", this.onKey, true);
},
onDocClick(e: MouseEvent) {
const t = e.target as Node;
const inside =
(this.$refs["popoverEl"] as HTMLElement | undefined)?.contains(t) ||
this.anchorEl?.contains(t);
if (!inside) this.close();
},
onKey(e: KeyboardEvent) {
if (e.key === "Escape") this.close();
},
close() {
this.$emit("update:modelValue", false);
},
},
});
</script>
<style scoped>
.color-picker-popover {
z-index: 1080;
background: var(--evcc-box);
padding: 0.6rem;
width: 16rem;
}
.swatch-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.5rem;
}
.swatch {
cursor: pointer;
aspect-ratio: 1;
}
.swatch--selected {
outline: 1px solid var(--evcc-default-text);
outline-offset: 2px;
}
.swatch-check {
pointer-events: none;
}
.footer-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.35rem;
}
.auto-btn {
padding: 0.2rem 0.55rem;
font-size: 0.7rem;
letter-spacing: 0.06em;
cursor: pointer;
}
.auto-btn.is-selected {
border-color: var(--evcc-default-text) !important;
}
.custom-input-wrap {
padding: 0.15rem 0.5rem;
gap: 0.4rem;
min-width: 0;
}
.custom-input-wrap.is-selected {
border-color: var(--evcc-default-text) !important;
}
.custom-preview {
width: 1.2rem;
height: 1.2rem;
cursor: pointer;
}
.custom-native {
cursor: pointer;
}
.custom-input {
width: 7ch;
min-width: 0;
font-size: 0.8rem;
outline: none;
padding: 0.15rem 0;
color: inherit;
}
.popover-arrow {
position: absolute;
width: 12px;
height: 12px;
}
.color-picker-popover[data-popper-placement^="top"] .popover-arrow {
bottom: -6px;
}
.color-picker-popover[data-popper-placement^="bottom"] .popover-arrow {
top: -6px;
}
.popover-arrow::before {
content: "";
position: absolute;
width: 12px;
height: 12px;
background: var(--evcc-box);
border-right: 1px solid var(--bs-border-color-translucent);
border-bottom: 1px solid var(--bs-border-color-translucent);
transform: rotate(45deg);
}
.color-picker-popover[data-popper-placement^="bottom"] .popover-arrow::before {
transform: rotate(225deg);
top: 0;
}
</style>

View file

@ -13,7 +13,8 @@ import {
forecastYAxis,
tooltipStyle,
} from "../Forecast/echarts";
import colors, { lighterColor } from "@/colors";
import colors, { lighterColor, resolveColors } from "@/colors";
import store from "@/store";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import { PERIODS } from "../Sessions/types";
import { is12hFormat } from "@/units";
@ -204,12 +205,16 @@ export default defineComponent({
// stacked segments stay visually distinguishable.
if (this.group === "loadpoint" || this.group === "meter") {
const mutedColor = colors.muted || this.color;
return this.series.map((s, i) => {
const titles: string[] = [];
for (const s of this.series) {
if (!s.virtual && !titles.includes(s.name)) titles.push(s.name);
}
const palette = resolveColors(titles, store.state.deviceColors ?? {});
return this.series.map((s) => {
// Virtual "other consumers" entity renders in a neutral gray to set
// it apart from explicit meter entities.
if (s.virtual) return mutedColor;
const idx = s.paletteIndex ?? i;
return colors.palette[idx % colors.palette.length] || this.color;
return palette[s.name] || this.color;
});
}
if (this.series.length <= 1) return [this.color];

View file

@ -9,7 +9,7 @@
:height="300"
/>
</div>
<LegendList :legends="legends" />
<LegendList :legends="legends" :device-colors="deviceColors" />
</div>
</template>
@ -32,7 +32,7 @@ import {
} from "chart.js";
import { Chart } from "vue-chartjs";
import type { EvoptData } from "./TimeSeriesDataTable.vue";
import type { CURRENCY, BatteryDetail } from "@/types/evcc";
import type { CURRENCY, BatteryDetail, DeviceColors } from "@/types/evcc";
import formatter from "@/mixins/formatter";
import colors from "@/colors";
import LegendList from "../Sessions/LegendList.vue";
@ -79,6 +79,7 @@ export default defineComponent({
type: Array as PropType<string[]>,
default: () => [],
},
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
},
computed: {
timeLabels(): string[] {
@ -244,6 +245,9 @@ export default defineComponent({
};
},
legends(): Legend[] {
const batteryTitles = new Set(
(this.evopt?.res?.batteries || []).map((_, i) => this.getBatteryTitle(i))
);
return this.chartData.datasets
.filter((dataset) => !dataset.hidden)
.map((dataset) => {
@ -255,6 +259,7 @@ export default defineComponent({
color: (dataset.backgroundColor || dataset.borderColor) as string,
value: "", // Required by Legend type, but not used in this context
type: isLine ? "line" : "area",
id: batteryTitles.has(label) ? label : undefined,
};
});
},

View file

@ -4,7 +4,7 @@
<PolarArea :data="chartData" :options="options" />
</div>
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
<LegendList :legends="legends" grid />
<LegendList :legends="legends" :device-colors="deviceColors" grid />
</div>
</div>
</template>
@ -17,7 +17,7 @@ import formatter from "@/mixins/formatter";
import colors, { dimColor } from "@/colors";
import LegendList from "./LegendList.vue";
import { defineComponent, type PropType } from "vue";
import type { CURRENCY } from "@/types/evcc";
import type { CURRENCY, DeviceColors } from "@/types/evcc";
import { TYPES, GROUPS, type Session } from "./types.ts";
registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]);
@ -34,6 +34,7 @@ export default defineComponent({
default: GROUPS.LOADPOINT,
},
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
suggestedMax: { type: Number, default: 0 },
costType: { type: String as PropType<TYPES>, default: TYPES.PRICE },
},
@ -84,6 +85,7 @@ export default defineComponent({
label: label,
color: dataset.borderColor[index],
value: this.formatValue(dataValue),
id: label || undefined,
};
});
},

View file

@ -4,7 +4,7 @@
<Doughnut :data="chartData" :options="options" />
</div>
<div class="col-12 col-md-6 d-flex align-items-center">
<LegendList :legends="legends" grid />
<LegendList :legends="legends" :device-colors="deviceColors" grid />
</div>
</div>
</template>
@ -25,7 +25,7 @@ import formatter from "@/mixins/formatter";
import colors from "@/colors";
import { TYPES, GROUPS, type Session } from "./types";
import { defineComponent, type PropType } from "vue";
import { CURRENCY } from "@/types/evcc";
import { CURRENCY, type DeviceColors } from "@/types/evcc";
registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]);
@ -40,6 +40,7 @@ export default defineComponent({
default: GROUPS.LOADPOINT,
},
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
costType: { type: String as PropType<TYPES>, default: TYPES.PRICE },
},
@ -81,6 +82,7 @@ export default defineComponent({
label: label,
color: dataset.backgroundColor[index],
value: [this.formatValue(dataValue), fmtShare(dataValue)],
id: label || undefined,
};
});
},

View file

@ -3,7 +3,7 @@
<div style="position: relative; height: 300px" class="my-3">
<Bar :data="chartData" :options="options" />
</div>
<LegendList :legends="legends" />
<LegendList :legends="legends" :device-colors="deviceColors" />
</div>
</template>
@ -28,7 +28,7 @@ import LegendList from "./LegendList.vue";
import formatter from "@/mixins/formatter";
import colors from "@/colors";
import { TYPES, GROUPS, PERIODS, type Session } from "./types";
import { CURRENCY } from "@/types/evcc";
import { CURRENCY, type DeviceColors } from "@/types/evcc";
registerChartComponents([
BarController,
@ -52,6 +52,7 @@ export default defineComponent({
period: { type: String as PropType<PERIODS>, default: PERIODS.TOTAL },
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
suggestedMaxAvgCost: { type: Number, default: 0 },
suggestedMaxCost: { type: Number, default: 0 },
},
@ -195,6 +196,7 @@ export default defineComponent({
};
},
legends() {
const pickable = this.groupBy !== GROUPS.NONE;
return this.chartData.datasets.map((dataset) => {
let value = null;
let type: "area" | "line" = "area";
@ -225,6 +227,7 @@ export default defineComponent({
color: dataset.backgroundColor,
value,
type,
id: pickable && type !== "line" ? dataset.label || undefined : undefined,
};
});
},

View file

@ -4,7 +4,7 @@
<Doughnut :data="chartData" :options="options" />
</div>
<div class="col-12 col-md-6 d-flex align-items-center">
<LegendList :legends="legends" grid />
<LegendList :legends="legends" :device-colors="deviceColors" grid />
</div>
</div>
</template>
@ -25,6 +25,7 @@ import { registerChartComponents, commonOptions, tooltipLabelColor } from "./cha
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import colors from "@/colors";
import { GROUPS, type Session } from "./types";
import type { DeviceColors } from "@/types/evcc";
registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]);
@ -36,6 +37,7 @@ export default defineComponent({
sessions: { type: Array as PropType<Session[]>, default: () => [] },
groupBy: { type: String as PropType<GROUPS>, default: GROUPS.NONE },
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {}, solar: {} }) },
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
},
computed: {
chartData() {
@ -86,12 +88,14 @@ export default defineComponent({
maxEnergy < 1 ? POWER_UNIT.W : maxEnergy > 1e4 ? POWER_UNIT.MW : POWER_UNIT.KW;
const fmtShare = (value: number) => this.fmtPercentage((100 / total) * value, 1);
const fmtValue = (value: number) => this.fmtWh(value * 1e3, unit);
const pickable = this.groupBy !== GROUPS.NONE;
return this.chartData.labels.map((label, index) => {
const dataValue = dataset.data[index] as number;
return {
label: label,
color: dataset.backgroundColor[index],
value: [fmtValue(dataValue), fmtShare(dataValue)],
id: pickable ? label || undefined : undefined,
};
});
},

View file

@ -3,7 +3,7 @@
<div style="position: relative; height: 300px" class="my-3">
<Bar :data="chartData" :options="options" />
</div>
<LegendList :legends="legends" />
<LegendList :legends="legends" :device-colors="deviceColors" />
</div>
</template>
@ -27,6 +27,7 @@ import LegendList from "./LegendList.vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import colors from "@/colors";
import { GROUPS, PERIODS, type Session } from "./types";
import type { DeviceColors } from "@/types/evcc";
registerChartComponents([BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip]);
@ -39,6 +40,7 @@ export default defineComponent({
groupBy: { type: String as PropType<GROUPS>, default: GROUPS.NONE },
period: { type: String as PropType<PERIODS>, default: PERIODS.TOTAL },
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
},
computed: {
firstDay() {
@ -148,6 +150,7 @@ export default defineComponent({
};
},
legends() {
const pickable = this.groupBy !== GROUPS.NONE;
return this.chartData.datasets.map((dataset) => ({
label: dataset.label || "",
color: dataset.backgroundColor,
@ -155,6 +158,7 @@ export default defineComponent({
dataset.data.reduce((acc, curr) => acc + curr, 0) * 1e3,
POWER_UNIT.AUTO
),
id: pickable ? dataset.label || undefined : undefined,
}));
},
options() {

View file

@ -1,6 +1,6 @@
<template>
<ul
class="root p-0 d-flex flex-wrap column-gap-4 row-gap-2 overflow-hidden"
class="root p-0 d-flex flex-wrap column-gap-4 row-gap-2"
:class="{
'root--small-equal-widths': smallEqualWidths,
'root--grid': grid,
@ -9,17 +9,43 @@
<li
v-for="legend in legends"
:key="legend.label"
class="legend-item d-flex align-items-baseline gap-2 no-wrap overflow-hidden"
class="legend-item d-flex align-items-baseline gap-2 no-wrap"
:class="{
'legend-item--focusable': legend.focusable,
'legend-item--dim': legend.dim,
}"
:role="legend.focusable ? 'button' : undefined"
:tabindex="legend.focusable ? 0 : undefined"
@click="legend.focusable && onFocus(legend)"
@keydown.enter.prevent="legend.focusable && onFocus(legend)"
@keydown.space.prevent="legend.focusable && onFocus(legend)"
>
<div
v-if="legend.color"
<button
v-if="legend.color && isPickable(legend)"
type="button"
class="legend-color-btn align-self-center me-1 bg-transparent border-0 p-0 d-inline-flex"
:style="{ '--badge-color': legend.color }"
:aria-label="legend.label"
@click.stop="onPick(legend, $event)"
>
<span
class="legend-color"
:class="colorClass(legend)"
:style="{
backgroundColor: legend.color,
borderColor: legend.color,
}"
></span>
</button>
<span
v-else-if="legend.color"
class="legend-color align-self-center me-1"
:class="colorClass(legend)"
:style="{
backgroundColor: legend.color,
borderColor: legend.color,
}"
></div>
></span>
<div class="legend-label text-nowrap">{{ legend.label }}</div>
<div
v-for="value in valueList(legend.value)"
@ -29,19 +55,48 @@
{{ value }}
</div>
</li>
<ColorPickerPopover
v-model="pickerOpen"
:anchor-el="pickerAnchor"
:color="pickerColor"
:title="pickerTitle"
@update:color="onColorChange"
/>
</ul>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import type { Legend } from "./types";
import type { DeviceColors } from "@/types/evcc";
import ColorPickerPopover from "../Helper/ColorPickerPopover.vue";
import api from "../../api";
export default defineComponent({
name: "LegendList",
components: { ColorPickerPopover },
props: {
legends: Array as PropType<Legend[]>,
grid: Boolean,
smallEqualWidths: Boolean,
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
},
emits: ["focus"],
data() {
return {
pickerOpen: false,
pickerAnchor: null as HTMLElement | null,
pickerId: "",
};
},
computed: {
pickerTitle(): string {
const found = (this.legends || []).find((l) => l.id === this.pickerId);
return found?.label || this.pickerId;
},
pickerColor(): string {
return this.deviceColors[this.pickerId] || "";
},
},
methods: {
valueList(value: Legend["value"]) {
@ -51,6 +106,30 @@ export default defineComponent({
colorClass(legend: Legend) {
return legend.type === "line" ? "legend-color--line" : "legend-color--area";
},
isPickable(legend: Legend) {
return !!legend.id;
},
onPick(legend: Legend, e: MouseEvent) {
if (!legend.id) return;
if (this.pickerOpen && this.pickerId === legend.id) {
this.pickerOpen = false;
return;
}
this.pickerAnchor = (e.currentTarget as HTMLElement) || null;
this.pickerId = legend.id;
this.pickerOpen = true;
},
onFocus(legend: Legend) {
this.$emit("focus", legend);
},
async onColorChange(color: string) {
if (!this.pickerId) return;
try {
await api.put("devicecolors", { title: this.pickerId, color });
} catch (e) {
console.error("set device color failed", e);
}
},
},
});
</script>
@ -63,6 +142,15 @@ export default defineComponent({
width: 1rem;
height: 1rem;
flex-shrink: 0;
display: inline-block;
}
.legend-item--focusable {
cursor: pointer;
user-select: none;
transition: opacity 150ms;
}
.legend-item--dim {
opacity: 0.35;
}
.legend-color--area {
@ -75,6 +163,19 @@ export default defineComponent({
align-self: center;
}
.legend-color-btn {
cursor: pointer;
border-radius: 50%;
transition: box-shadow 120ms ease-out;
}
.legend-color-btn:hover {
box-shadow: 0 0 0 5px color-mix(in srgb, var(--badge-color) 25%, transparent);
}
.legend-color-btn:focus-visible {
outline: 2px solid var(--evcc-default-text);
outline-offset: 2px;
}
.legend-label {
flex-shrink: 0;
flex-grow: 0;

View file

@ -4,7 +4,7 @@
<PolarArea :data="chartData" :options="options" />
</div>
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
<LegendList :legends="legends" grid />
<LegendList :legends="legends" :device-colors="deviceColors" grid />
</div>
</div>
</template>
@ -18,6 +18,7 @@ import formatter from "@/mixins/formatter";
import colors, { dimColor } from "@/colors";
import LegendList from "./LegendList.vue";
import { GROUPS, type Session } from "./types.ts";
import type { DeviceColors } from "@/types/evcc";
registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]);
@ -32,6 +33,7 @@ export default defineComponent({
default: GROUPS.LOADPOINT,
},
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
},
computed: {
chartData() {
@ -82,6 +84,7 @@ export default defineComponent({
label: label,
color: dataset.borderColor[index],
value: this.fmtPercentage(dataValue, 1),
id: label || undefined,
};
});
},

View file

@ -21,6 +21,10 @@ export interface Legend {
color: any;
value: string | string[];
type?: "area" | "line";
id?: string;
dim?: boolean;
focusable?: boolean;
focusKey?: string | number;
}
export interface Column {

View file

@ -24,6 +24,8 @@ declare global {
export type AuthProviders = Record<string, { id: string; authenticated: boolean }>;
export type DeviceColors = Record<string, string>;
export interface MqttConfig {
broker: string;
topic: string;
@ -109,6 +111,7 @@ export interface State {
smartCostAvailable?: boolean;
smartCostType?: SMART_COST_TYPE;
siteTitle?: string;
deviceColors?: DeviceColors;
vehicles: Record<string, Vehicle>;
statistics?: Statistics;
authDisabled?: boolean;

View file

@ -86,49 +86,13 @@
:from="displayFrom"
:to="displayTo"
/>
<ul
<LegendList
v-if="hasEntityLegend(group) || hasForecastLegend(group)"
class="entity-legend p-0 mt-4 mb-0 d-flex flex-wrap column-gap-4 row-gap-2"
>
<li
v-for="legend in entityLegends(group)"
:key="legend.entityIndex"
class="entity-legend-item d-flex align-items-baseline gap-2 no-wrap"
:class="{
'entity-legend-item--dim': isDimmed(group, legend.entityIndex),
}"
role="button"
tabindex="0"
@click="toggleFocus(group, legend.entityIndex)"
@keydown.enter.prevent="toggleFocus(group, legend.entityIndex)"
@keydown.space.prevent="toggleFocus(group, legend.entityIndex)"
>
<span
class="entity-legend-dot align-self-center"
:style="{ backgroundColor: legend.color }"
></span>
<span class="text-nowrap">{{ legend.label }}</span>
<span class="text-muted text-nowrap">{{ legend.value }}</span>
</li>
<li
v-if="hasForecastLegend(group)"
class="entity-legend-item entity-legend-item--static d-flex align-items-baseline gap-2 no-wrap"
:class="{
'entity-legend-item--dim': (focusedEntity[group] ?? null) !== null,
}"
>
<span
class="entity-legend-dot entity-legend-dot--line align-self-center"
:style="{ backgroundColor: groupColor('forecast') }"
></span>
<span class="text-nowrap">
{{ $t("main.history.group.forecast") }}
</span>
<span class="text-muted text-nowrap">
{{ forecastTotalLabel }}
</span>
</li>
</ul>
class="mt-4"
:legends="legendsForGroup(group)"
:device-colors="deviceColors"
@focus="onLegendFocus(group, $event)"
/>
</section>
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0">
<a
@ -157,9 +121,11 @@ import GroupChart, {
stepAlpha,
} from "../components/History/GroupChart.vue";
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 colors from "../colors";
import colors, { resolveColors } from "../colors";
import LegendList from "../components/Sessions/LegendList.vue";
import formatter, { POWER_UNIT } from "../mixins/formatter";
import api from "../api";
import store from "../store";
@ -178,6 +144,7 @@ export default defineComponent({
DateNavigator,
PeriodHeader,
GroupChart,
LegendList,
},
mixins: [formatter],
props: {
@ -202,6 +169,9 @@ export default defineComponent({
return { title: this.$t("main.history.title") };
},
computed: {
deviceColors(): DeviceColors {
return store.state.deviceColors ?? {};
},
effectivePeriod(): PERIODS {
return this.period && HISTORY_PERIODS.includes(this.period)
? (this.period as PERIODS)
@ -398,6 +368,30 @@ export default defineComponent({
},
methods: {
groupColor,
legendsForGroup(group: string): Legend[] {
const items = this.entityLegends(group);
const focused = this.focusedEntity[group] ?? null;
const list: Legend[] = items.map((l) => ({
...l,
focusable: true,
focusKey: l.entityIndex,
dim: focused !== null && focused !== l.entityIndex,
}));
if (this.hasForecastLegend(group)) {
list.push({
label: this.$t("main.history.group.forecast") as string,
color: groupColor("forecast"),
value: this.forecastTotalLabel,
type: "line",
dim: focused !== null,
});
}
return list;
},
onLegendFocus(group: string, legend: Legend) {
if (legend.focusKey == null) return;
this.toggleFocus(group, legend.focusKey as number);
},
hasEntityLegend(group: string): boolean {
const list = this.displaySeries(group);
if (!list.length) return false;
@ -412,12 +406,22 @@ export default defineComponent({
const list = this.displaySeries(group);
const baseColor = groupColor(group);
const n = list.length;
const isPickGroup = group === "loadpoint" || group === "meter";
// Build palette for pickable groups in displayed order so that user
// overrides take priority and autoassign skips taken palette entries.
let palette: Record<string, string> = {};
if (isPickGroup) {
const titles: string[] = [];
for (const s of list) {
if (!s.virtual && !titles.includes(s.name)) titles.push(s.name);
}
palette = resolveColors(titles, this.deviceColors);
}
const colorFor = (i: number, s: HistorySeries) => {
if (s.virtual) return colors.muted || baseColor;
if (group === "loadpoint" || group === "meter") {
const idx = s.paletteIndex ?? i;
return colors.palette[idx % colors.palette.length] || baseColor;
}
if (isPickGroup) return palette[s.name] || baseColor;
return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1)));
};
return list.map((s, i) => {
@ -431,6 +435,7 @@ export default defineComponent({
label: s.name,
color: colorFor(i, s),
value: this.fmtWh(watts, POWER_UNIT.AUTO),
id: isPickGroup && !s.virtual ? s.name : undefined,
};
});
},
@ -570,24 +575,6 @@ export default defineComponent({
opacity: 0.8;
}
}
.entity-legend {
list-style: none;
}
.entity-legend-item {
cursor: pointer;
transition: opacity 150ms;
user-select: none;
}
.entity-legend-item--dim {
opacity: 0.35;
}
.entity-legend-dot {
display: inline-block;
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
}
.history-csv-link {
text-decoration: none;
}
@ -596,13 +583,6 @@ export default defineComponent({
color: var(--evcc-default-text);
text-decoration: underline;
}
.entity-legend-dot--line {
height: 2px;
border-radius: 1px;
}
.entity-legend-item--static {
cursor: default;
}
.history-tile-title {
margin: 0 0 0.5rem;
}

View file

@ -43,6 +43,7 @@
:timestamp="evopt.details.timestamp[0]"
:currency="currency"
:battery-colors="batteryColors"
:device-colors="deviceColors"
/>
<h3 class="fw-normal mb-4">Result: SoC Projection</h3>
@ -146,7 +147,7 @@ import { formatCompactJson } from "../components/Optimize/compactJson";
import api from "../api";
import store from "../store";
import formatter from "../mixins/formatter";
import colors from "../colors";
import { resolveColors } from "../colors";
import { CURRENCY } from "../types/evcc";
export default defineComponent({
@ -190,12 +191,19 @@ export default defineComponent({
return "bg-secondary";
}
},
deviceColors() {
return store.state.deviceColors ?? {};
},
batteryTitles(): string[] {
const details = this.evopt?.details?.batteryDetails || [];
return (this.evopt?.res.batteries || []).map(
(_, i) => details[i]?.title || details[i]?.name || `battery-${i}`
);
},
batteryColors() {
if (!this.evopt?.res.batteries) return [];
return this.evopt.res.batteries.map(
(_, index) => colors.palette[index % colors.palette.length] || ""
);
const palette = resolveColors(this.batteryTitles, this.deviceColors);
return this.batteryTitles.map((t) => palette[t] || "");
},
dimmedBatteryColors() {
return (this.batteryColors || []).map((color) => this.dimColorBy25Percent(color));

View file

@ -78,6 +78,7 @@
class="mb-5"
:sessions="currentSessions"
:color-mappings="colorMappings"
:device-colors="deviceColors"
:group-by="selectedGroup"
:period="period"
/>
@ -86,6 +87,7 @@
class="mb-5"
:sessions="currentTypeSessions"
:color-mappings="colorMappings"
:device-colors="deviceColors"
:group-by="selectedGroup"
:cost-type="activeType"
:currency="currency"
@ -106,6 +108,7 @@
v-else
:sessions="currentSessions"
:color-mappings="colorMappings"
:device-colors="deviceColors"
:group-by="selectedGroupWithoutNone"
/>
</div>
@ -113,6 +116,7 @@
v-else
:sessions="currentTypeSessions"
:color-mappings="colorMappings"
:device-colors="deviceColors"
:suggested-max-price="suggestedMaxAvgCost"
:group-by="selectedGroupWithoutNone"
:cost-type="activeType"
@ -125,12 +129,14 @@
v-if="activeType === types.SOLAR"
:sessions="currentSessions"
:color-mappings="colorMappings"
:device-colors="deviceColors"
:group-by="selectedGroupWithoutNone"
/>
<CostGroupedChart
v-else
:sessions="currentTypeSessions"
:color-mappings="colorMappings"
:device-colors="deviceColors"
:group-by="selectedGroupWithoutNone"
:cost-type="activeType"
:currency="currency"
@ -199,7 +205,7 @@ import IconSelectGroup from "../components/Helper/IconSelectGroup.vue";
import IconSelectItem from "../components/Helper/IconSelectItem.vue";
import SelectGroup from "../components/Helper/SelectGroup.vue";
import CustomSelect from "../components/Helper/CustomSelect.vue";
import colors from "../colors";
import colors, { resolveColors } from "../colors";
import settings from "../settings";
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
import DateNavigator from "../components/Sessions/DateNavigator.vue";
@ -497,6 +503,9 @@ export default defineComponent({
}
return this.csvHrefLink();
},
deviceColors() {
return store.state.deviceColors ?? {};
},
colorMappings() {
const lastThreeMonths = new Date();
lastThreeMonths.setMonth(lastThreeMonths.getMonth() - 3);
@ -512,40 +521,36 @@ export default defineComponent({
}, {});
};
// Assign colors based on energy usage
const assignColors = (
// Ordered key list: sessions ranked by recent energy first, then any
// remaining keys encountered in the dataset.
const orderedKeys = (
energyAggregation: Record<string, number>,
colorType: Exclude<GROUPS, GROUPS.NONE>
) => {
const result: Record<string, string> = {};
let colorIndex = 0;
// Assign colors by used energy in the last three months
const sortedEntries = Object.entries(energyAggregation).sort((a, b) => b[1] - a[1]);
sortedEntries.forEach(([key]) => {
if (key && !result[key]) {
result[key] = colors.palette[colorIndex % colors.palette.length] || "";
colorIndex++;
): string[] => {
const ranked = Object.entries(energyAggregation)
.sort((a, b) => b[1] - a[1])
.map(([k]) => k)
.filter(Boolean);
const seen = new Set(ranked);
for (const s of this.sessionsWithDefaults) {
const k = s[colorType];
if (k && !seen.has(k)) {
ranked.push(k);
seen.add(k);
}
});
// Assign colors to remaining entries
this.sessionsWithDefaults.forEach((session) => {
const key = session[colorType];
if (key && !result[key]) {
result[key] = colors.palette[colorIndex % colors.palette.length] || "";
colorIndex++;
}
});
return result;
return ranked;
};
const loadpointEnergy = aggregateEnergy(GROUPS.LOADPOINT);
const loadpointColors = assignColors(loadpointEnergy, GROUPS.LOADPOINT);
const vehicleEnergy = aggregateEnergy(GROUPS.VEHICLE);
const vehicleColors = assignColors(vehicleEnergy, GROUPS.VEHICLE);
const overrides = this.deviceColors;
const loadpointColors = resolveColors(
orderedKeys(aggregateEnergy(GROUPS.LOADPOINT), GROUPS.LOADPOINT),
overrides
);
const vehicleColors = resolveColors(
orderedKeys(aggregateEnergy(GROUPS.VEHICLE), GROUPS.VEHICLE),
overrides
);
const solar = { self: colors.self, grid: colors.grid };
const cost = { price: colors.price, co2: colors.co2 };

View file

@ -24,6 +24,7 @@ import (
"github.com/evcc-io/evcc/server/network"
"github.com/evcc-io/evcc/server/remote"
"github.com/evcc-io/evcc/server/updater"
"github.com/evcc-io/evcc/ui"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/auth"
"github.com/evcc-io/evcc/util/pipe"
@ -360,6 +361,7 @@ func runRoot(cmd *cobra.Command, args []string) {
}
// publish initial settings
valueChan <- util.Param{Key: keys.DeviceColors, Val: ui.GetDeviceColors()}
valueChan <- util.Param{Key: keys.EEBus, Val: globalconfig.ConfigStatus{
Config: conf.EEBus.Redacted(),
Status: eebus.GetStatus(),

View file

@ -36,4 +36,5 @@ const (
RemoteLastSeen = "remoteLastSeen"
AuthDisabled = "authDisabled"
AuthProviders = "authProviders"
DeviceColors = "deviceColors"
)

View file

@ -46,6 +46,10 @@
"noBattery": "Keine Batterien konfiguriert.",
"usageTab": "Batterienutzung"
},
"colors": {
"auto": "Auto",
"hex": "Hex-Farbe"
},
"config": {
"aux": {
"description": "Gerät, das seinen Verbrauch basierend auf verfügbarem Überschuss (z. B. smarter Heizstab) selbstständig anpasst. evcc erwartet, dass dieses Gerät selbstständig seine Leistungsaufnahme reduziert, wenn es notwendig ist.",

View file

@ -46,6 +46,10 @@
"noBattery": "No batteries configured.",
"usageTab": "Battery usage"
},
"colors": {
"auto": "Auto",
"hex": "Hex color"
},
"config": {
"aux": {
"description": "Device that adjusts its consumption based on available surplus (like smart water heaters). evcc expects that this device reduces its power consumption if needed.",

View file

@ -162,6 +162,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
"energyhistory": {"GET", "/history/energy", energyHistoryHandler},
"optimize": {"POST", "/optimize", getHandler(site.Optimize)},
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
"devicecolors": {"PUT", "/devicecolors", updateDeviceColor(site)},
}
for _, r := range routes {

View file

@ -0,0 +1,54 @@
package server
import (
"encoding/json"
"errors"
"maps"
"net/http"
"regexp"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/ui"
)
var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// updateDeviceColor sets/removes a single title→hex association.
func updateDeviceColor(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Color string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
if req.Title == "" {
jsonError(w, http.StatusBadRequest, errors.New("title required"))
return
}
color := req.Color
if color != "" && !hexColorRE.MatchString(color) {
jsonError(w, http.StatusBadRequest, errors.New("invalid hex color"))
return
}
m := ui.GetDeviceColors()
m[req.Title] = color
// clone — Publish serializes async
site.Publish(keys.DeviceColors, maps.Clone(m))
// delete only after publish to enforce update in client
if color == "" {
delete(m, req.Title)
}
if err := ui.SaveDeviceColors(m); err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
jsonWrite(w, m)
}
}

View file

@ -0,0 +1,34 @@
interval: 0.1s
site:
title: Colors
meters:
grid: grid
meters:
- name: grid
type: template
template: demo-meter
power: 200
loadpoints:
- title: Garage
charger: charger1
vehicle: honda
- title: Carport
charger: charger2
vehicle: honda
vehicles:
- name: honda
title: Honda
chargers:
- name: charger1
type: template
template: demo-charger
status: B
- name: charger2
type: template
template: demo-charger
status: B

View file

@ -0,0 +1,83 @@
import { test, expect, type Locator, type Page } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
test.use({ baseURL: baseUrl() });
// from assets/js/colors.ts palette
const SKY = "rgb(14, 165, 233)"; // #0EA5E9 — palette[0]
const PINK = "rgb(236, 72, 153)"; // #EC4899 — palette[1]
const CUSTOM_HEX = "FF0080";
const CUSTOM_RGB = "rgb(255, 0, 128)";
test.beforeAll(async () => {
await start("device-colors.evcc.yaml", "device-colors.sql");
});
test.afterAll(async () => {
await stop();
});
function legendBadge(scope: Locator | Page, label: string): Locator {
// first match — multiple charts can show the same legend
return scope.getByLabel(label, { exact: true }).first();
}
async function expectLegendColor(scope: Locator | Page, label: string, rgb: string): Promise<void> {
const dot = legendBadge(scope, label).locator("span").first();
await expect(dot).toHaveCSS("background-color", rgb);
}
test("device colors: autoassign, override, persistence", async ({ page }) => {
// ---------- Step 1 — Sessions, by-vehicle view ----------
await page.goto("/#/sessions?year=2026&month=5");
await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible();
await page.getByRole("button", { name: "Vehicle", exact: true }).click();
await expectLegendColor(page, "Honda", SKY);
// ---------- Step 2 — Sessions, by-loadpoint + override ----------
await page.getByRole("button", { name: "Charging point", exact: true }).click();
// initial autoassign — Garage (more energy) → palette[0], Carport → palette[1]
await expectLegendColor(page, "Garage", SKY);
await expectLegendColor(page, "Carport", PINK);
// open picker on Garage badge; second palette swatch = PINK
await legendBadge(page, "Garage").click();
const popover = page.getByRole("dialog");
await expect(popover).toBeVisible();
// palette swatches have title=hex
await popover.getByTitle("#EC4899").click();
await page.keyboard.press("Escape");
await expectLegendColor(page, "Garage", PINK);
// non-collision: Carport must shift off PINK to the first free entry (SKY)
await expectLegendColor(page, "Carport", SKY);
// ---------- Step 3 — History view, same colors ----------
await page.goto("/#/history?period=day&year=2026&month=5&day=15");
const lpSection = page
.locator("section")
.filter({ has: page.getByRole("heading", { name: "Charging & Heating" }) });
await expect(lpSection).toBeVisible({ timeout: 10000 });
await expectLegendColor(lpSection, "Garage", PINK);
await expectLegendColor(lpSection, "Carport", SKY);
// ---------- 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();
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);
// reload: color persists via settings DB
await page.reload();
const meterSection2 = page
.locator("section")
.filter({ has: page.getByRole("heading", { name: "Consumption" }) });
await expectLegendColor(meterSection2, "Dishwasher", CUSTOM_RGB);
});

73
tests/device-colors.sql Normal file
View file

@ -0,0 +1,73 @@
CREATE TABLE `sessions` (
`id` integer,
`created` datetime,
`finished` datetime,
`loadpoint` text,
`identifier` text,
`vehicle` text,
`meter_start_kwh` real,
`meter_end_kwh` real,
`charged_kwh` real,
`odometer` real,
`solar_percentage` real,
`price` real,
`price_per_kwh` real,
`co2_per_kwh` real,
`charge_duration` integer,
PRIMARY KEY (`id`)
);
CREATE TABLE `entities` (
`id` integer,
`group` text,
`name` text,
PRIMARY KEY (`id`)
);
CREATE UNIQUE INDEX `entities_group_name` ON `entities`(`group`, `name`);
CREATE TABLE `meters` (
`meter` integer,
`ts` integer,
`import` real,
`export` real
);
CREATE UNIQUE INDEX `meters_meter_ts` ON `meters`(`meter`, `ts`);
CREATE TABLE `settings` (
`key` text
, `value` text
, PRIMARY KEY(`key`)
);
CREATE TABLE `configs` (
`id` integer PRIMARY KEY AUTOINCREMENT
, `class` integer
, `type` text
, `title` text
, `icon` text
, `product` text
, `value` text
);
-- Honda charged on each loadpoint, Garage has more energy than Carport
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)
INSERT INTO `configs`(id, class, type, title, icon, product, value)
VALUES (1, 2, 'template', 'Dishwasher', '', 'Demo meter',
'{"power":"100","template":"demo-meter","usage":"ext"}');
-- assign ext meter ref + enable experimental
INSERT INTO `settings`(key, value) VALUES ('extMeters', 'db:1');
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');
-- 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);

30
ui/colors.go Normal file
View file

@ -0,0 +1,30 @@
// Package ui persists UI-only state (color overrides, etc.).
package ui
import (
"maps"
"sync"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
)
var colorsMu sync.RWMutex
// GetDeviceColors returns the persisted title→hex map (never nil).
func GetDeviceColors() map[string]string {
colorsMu.RLock()
defer colorsMu.RUnlock()
m := map[string]string{}
_ = settings.Json(keys.DeviceColors, &m)
return m
}
// SaveDeviceColors persists the title→hex map.
func SaveDeviceColors(m map[string]string) error {
colorsMu.Lock()
defer colorsMu.Unlock()
clean := make(map[string]string, len(m))
maps.Copy(clean, m)
return settings.SetJson(keys.DeviceColors, clean)
}