fix: device color MQTT encoding (#30168)
This commit is contained in:
parent
8e0d991455
commit
cf7a95b61a
11 changed files with 107 additions and 26 deletions
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
ui/colors.go
20
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
|
||||
}
|
||||
|
|
|
|||
45
ui/colors_test.go
Normal file
45
ui/colors_test.go
Normal 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())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue