diff --git a/assets/js/colors.test.ts b/assets/js/colors.test.ts index 4957fda9a..82b60addd 100644 --- a/assets/js/colors.test.ts +++ b/assets/js/colors.test.ts @@ -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" }); + }); +}); diff --git a/assets/js/colors.ts b/assets/js/colors.ts index 937b34cf5..619d1f1c0 100644 --- a/assets/js/colors.ts +++ b/assets/js/colors.ts @@ -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"]; diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index 79af34805..8a7b93cca 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -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. diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index c1b036b41..5ee75011f 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -25,6 +25,7 @@ declare global { export type AuthProviders = Record; export type DeviceColors = Record; +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; statistics?: Statistics; authDisabled?: boolean; diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index f4bac7ff5..b8fa80235 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -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) diff --git a/assets/js/views/Optimize.vue b/assets/js/views/Optimize.vue index 1d6f011d4..dfcee23c5 100644 --- a/assets/js/views/Optimize.vue +++ b/assets/js/views/Optimize.vue @@ -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 || []; diff --git a/assets/js/views/Sessions.vue b/assets/js/views/Sessions.vue index 2ca3bf132..752986107 100644 --- a/assets/js/views/Sessions.vue +++ b/assets/js/views/Sessions.vue @@ -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(); diff --git a/cmd/root.go b/cmd/root.go index 0d43db9de..e58ac265a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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(), diff --git a/server/http_devicecolors_handler.go b/server/http_devicecolors_handler.go index bdcede30c..17262d38d 100644 --- a/server/http_devicecolors_handler.go +++ b/server/http_devicecolors_handler.go @@ -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) } } diff --git a/ui/colors.go b/ui/colors.go index b4960d09c..597e219d1 100644 --- a/ui/colors.go +++ b/ui/colors.go @@ -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 +} diff --git a/ui/colors_test.go b/ui/colors_test.go new file mode 100644 index 000000000..2329765bb --- /dev/null +++ b/ui/colors_test.go @@ -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()) +}