fix: device color MQTT encoding (#30168)

This commit is contained in:
Michael Geers 2026-05-24 11:13:05 +02:00 • committed by GitHub
parent 8e0d991455
commit cf7a95b61a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 107 additions and 26 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import colors, { dimColor, lighterColor, fullColor, resolveColors } from "./colors";
import colors, { dimColor, lighterColor, fullColor, resolveColors, deviceColorMap } from "./colors";
import type { DeviceColors } from "./types/evcc";
describe("setAlpha helpers", () => {
@ -88,3 +88,20 @@ describe("resolveColors", () => {
expect(res["extra"]).toBe(palette[0]);
});
});
describe("deviceColorMap", () => {
it("returns empty map for undefined", () => {
expect(deviceColorMap(undefined)).toEqual({});
});
it("returns empty map for empty list", () => {
expect(deviceColorMap([])).toEqual({});
});
it("converts list of entries to title→color map", () => {
expect(
deviceColorMap([
{ title: "WP-SG+", color: "#2563EB" },
{ title: "Heizung", color: "#DC2626" },
])
).toEqual({ "WP-SG+": "#2563EB", Heizung: "#DC2626" });
});
});

View file

@ -1,5 +1,11 @@
import { reactive } from "vue";
import type { DeviceColors } from "./types/evcc";
import type { DeviceColorEntry, DeviceColors } from "./types/evcc";
export function deviceColorMap(list: DeviceColorEntry[] | undefined): DeviceColors {
const m: DeviceColors = {};
for (const { title, color } of list ?? []) m[title] = color;
return m;
}
// alternatives
// const COLORS = [ "#40916C", "#52B788", "#74C69D", "#95D5B2", "#B7E4C7", "#D8F3DC", "#081C15", "#1B4332", "#2D6A4F"];

View file

@ -13,7 +13,7 @@ import {
forecastYAxis,
tooltipStyle,
} from "../Forecast/echarts";
import colors, { lighterColor, resolveColors } from "@/colors";
import colors, { lighterColor, resolveColors, deviceColorMap } from "@/colors";
import store from "@/store";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import { PERIODS } from "../Sessions/types";
@ -205,7 +205,7 @@ export default defineComponent({
for (const s of this.series) {
if (!s.virtual && !titles.includes(s.name)) titles.push(s.name);
}
const palette = resolveColors(titles, store.state.deviceColors ?? {});
const palette = resolveColors(titles, deviceColorMap(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.

View file

@ -25,6 +25,7 @@ declare global {
export type AuthProviders = Record<string, { id: string; authenticated: boolean }>;
export type DeviceColors = Record<string, string>;
export type DeviceColorEntry = { title: string; color: string };
export interface MqttConfig {
broker: string;
@ -111,7 +112,7 @@ export interface State {
smartCostAvailable?: boolean;
smartCostType?: SMART_COST_TYPE;
siteTitle?: string;
deviceColors?: DeviceColors;
deviceColors?: DeviceColorEntry[];
vehicles: Record<string, Vehicle>;
statistics?: Statistics;
authDisabled?: boolean;

View file

@ -124,7 +124,7 @@ 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, { resolveColors } from "../colors";
import colors, { resolveColors, deviceColorMap } from "../colors";
import LegendList from "../components/Sessions/LegendList.vue";
import formatter, { POWER_UNIT } from "../mixins/formatter";
import api from "../api";
@ -170,7 +170,7 @@ export default defineComponent({
},
computed: {
deviceColors(): DeviceColors {
return store.state.deviceColors ?? {};
return deviceColorMap(store.state.deviceColors);
},
effectivePeriod(): PERIODS {
return this.period && HISTORY_PERIODS.includes(this.period)

View file

@ -147,7 +147,7 @@ import { formatCompactJson } from "../components/Optimize/compactJson";
import api from "../api";
import store from "../store";
import formatter from "../mixins/formatter";
import { resolveColors } from "../colors";
import { resolveColors, deviceColorMap } from "../colors";
import { CURRENCY } from "../types/evcc";
export default defineComponent({
@ -192,7 +192,7 @@ export default defineComponent({
}
},
deviceColors() {
return store.state.deviceColors ?? {};
return deviceColorMap(store.state.deviceColors);
},
batteryTitles(): string[] {
const details = this.evopt?.details?.batteryDetails || [];

View file

@ -205,7 +205,8 @@ 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, { resolveColors } from "../colors";
import colors, { resolveColors, deviceColorMap } from "../colors";
import type { DeviceColors } from "@/types/evcc";
import settings from "../settings";
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
import DateNavigator from "../components/Sessions/DateNavigator.vue";
@ -503,8 +504,8 @@ export default defineComponent({
}
return this.csvHrefLink();
},
deviceColors() {
return store.state.deviceColors ?? {};
deviceColors(): DeviceColors {
return deviceColorMap(store.state.deviceColors);
},
colorMappings() {
const lastThreeMonths = new Date();

View file

@ -361,7 +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.DeviceColors, Val: ui.DeviceColorList()}
valueChan <- util.Param{Key: keys.EEBus, Val: globalconfig.ConfigStatus{
Config: conf.EEBus.Redacted(),
Status: eebus.GetStatus(),

View file

@ -3,7 +3,6 @@ package server
import (
"encoding/json"
"errors"
"maps"
"net/http"
"regexp"
@ -14,7 +13,6 @@ import (
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 {
@ -29,26 +27,23 @@ func updateDeviceColor(site site.API) http.HandlerFunc {
jsonError(w, http.StatusBadRequest, errors.New("title required"))
return
}
color := req.Color
if color != "" && !hexColorRE.MatchString(color) {
if req.Color != "" && !hexColorRE.MatchString(req.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 == "" {
if req.Color == "" {
delete(m, req.Title)
} else {
m[req.Title] = req.Color
}
if err := ui.SaveDeviceColors(m); err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
site.Publish(keys.DeviceColors, ui.DeviceColorList())
jsonWrite(w, m)
}
}

View file

@ -3,15 +3,22 @@ package ui
import (
"maps"
"slices"
"strings"
"sync"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
)
// DeviceColor is the MQTT-safe publish format (map keys would leak into topic segments).
type DeviceColor struct {
Title string `json:"title"`
Color string `json:"color"`
}
var colorsMu sync.RWMutex
// GetDeviceColors returns the persisted title→hex map (never nil).
func GetDeviceColors() map[string]string {
colorsMu.RLock()
defer colorsMu.RUnlock()
@ -20,7 +27,6 @@ func GetDeviceColors() map[string]string {
return m
}
// SaveDeviceColors persists the title→hex map.
func SaveDeviceColors(m map[string]string) error {
colorsMu.Lock()
defer colorsMu.Unlock()
@ -28,3 +34,13 @@ func SaveDeviceColors(m map[string]string) error {
maps.Copy(clean, m)
return settings.SetJson(keys.DeviceColors, clean)
}
func DeviceColorList() []DeviceColor {
m := GetDeviceColors()
list := make([]DeviceColor, 0, len(m))
for title, color := range m {
list = append(list, DeviceColor{Title: title, Color: color})
}
slices.SortFunc(list, func(a, b DeviceColor) int { return strings.Compare(a.Title, b.Title) })
return list
}

45
ui/colors_test.go Normal file
View file

@ -0,0 +1,45 @@
package ui
import (
"path/filepath"
"testing"
"github.com/evcc-io/evcc/server/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupDB(t *testing.T) {
t.Helper()
require.NoError(t, db.NewInstance("sqlite", filepath.Join(t.TempDir(), "test.db")))
}
func TestDeviceColors_RoundTrip(t *testing.T) {
setupDB(t)
in := map[string]string{"WP-SG+": "#2563EB", "Heizung": "#DC2626"}
require.NoError(t, SaveDeviceColors(in))
assert.Equal(t, in, GetDeviceColors())
}
func TestDeviceColors_EmptyWhenAbsent(t *testing.T) {
setupDB(t)
assert.Empty(t, GetDeviceColors())
}
func TestDeviceColorList_SortedAndSafe(t *testing.T) {
setupDB(t)
require.NoError(t, SaveDeviceColors(map[string]string{
"WP-SG+": "#2563EB",
"Heizung": "#DC2626",
"Carport": "#10B981",
}))
assert.Equal(t, []DeviceColor{
{Title: "Carport", Color: "#10B981"},
{Title: "Heizung", Color: "#DC2626"},
{Title: "WP-SG+", Color: "#2563EB"},
}, DeviceColorList())
}