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 { 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";
|
import type { DeviceColors } from "./types/evcc";
|
||||||
|
|
||||||
describe("setAlpha helpers", () => {
|
describe("setAlpha helpers", () => {
|
||||||
|
|
@ -88,3 +88,20 @@ describe("resolveColors", () => {
|
||||||
expect(res["extra"]).toBe(palette[0]);
|
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 { 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
|
// alternatives
|
||||||
// const COLORS = [ "#40916C", "#52B788", "#74C69D", "#95D5B2", "#B7E4C7", "#D8F3DC", "#081C15", "#1B4332", "#2D6A4F"];
|
// const COLORS = [ "#40916C", "#52B788", "#74C69D", "#95D5B2", "#B7E4C7", "#D8F3DC", "#081C15", "#1B4332", "#2D6A4F"];
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
forecastYAxis,
|
forecastYAxis,
|
||||||
tooltipStyle,
|
tooltipStyle,
|
||||||
} from "../Forecast/echarts";
|
} from "../Forecast/echarts";
|
||||||
import colors, { lighterColor, resolveColors } from "@/colors";
|
import colors, { lighterColor, resolveColors, deviceColorMap } from "@/colors";
|
||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||||
import { PERIODS } from "../Sessions/types";
|
import { PERIODS } from "../Sessions/types";
|
||||||
|
|
@ -205,7 +205,7 @@ export default defineComponent({
|
||||||
for (const s of this.series) {
|
for (const s of this.series) {
|
||||||
if (!s.virtual && !titles.includes(s.name)) titles.push(s.name);
|
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) => {
|
return this.series.map((s) => {
|
||||||
// Virtual "other consumers" entity renders in a neutral gray to set
|
// Virtual "other consumers" entity renders in a neutral gray to set
|
||||||
// it apart from explicit meter entities.
|
// it apart from explicit meter entities.
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ declare global {
|
||||||
export type AuthProviders = Record<string, { id: string; authenticated: boolean }>;
|
export type AuthProviders = Record<string, { id: string; authenticated: boolean }>;
|
||||||
|
|
||||||
export type DeviceColors = Record<string, string>;
|
export type DeviceColors = Record<string, string>;
|
||||||
|
export type DeviceColorEntry = { title: string; color: string };
|
||||||
|
|
||||||
export interface MqttConfig {
|
export interface MqttConfig {
|
||||||
broker: string;
|
broker: string;
|
||||||
|
|
@ -111,7 +112,7 @@ export interface State {
|
||||||
smartCostAvailable?: boolean;
|
smartCostAvailable?: boolean;
|
||||||
smartCostType?: SMART_COST_TYPE;
|
smartCostType?: SMART_COST_TYPE;
|
||||||
siteTitle?: string;
|
siteTitle?: string;
|
||||||
deviceColors?: DeviceColors;
|
deviceColors?: DeviceColorEntry[];
|
||||||
vehicles: Record<string, Vehicle>;
|
vehicles: Record<string, Vehicle>;
|
||||||
statistics?: Statistics;
|
statistics?: Statistics;
|
||||||
authDisabled?: boolean;
|
authDisabled?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ import type { Legend } from "../components/Sessions/types";
|
||||||
import type { DeviceColors } from "@/types/evcc";
|
import type { DeviceColors } from "@/types/evcc";
|
||||||
import { PERIODS } from "../components/Sessions/types";
|
import { PERIODS } from "../components/Sessions/types";
|
||||||
import { GROUP_ORDER, groupColor } from "../components/History/groups";
|
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 LegendList from "../components/Sessions/LegendList.vue";
|
||||||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
|
|
@ -170,7 +170,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
deviceColors(): DeviceColors {
|
deviceColors(): DeviceColors {
|
||||||
return store.state.deviceColors ?? {};
|
return deviceColorMap(store.state.deviceColors);
|
||||||
},
|
},
|
||||||
effectivePeriod(): PERIODS {
|
effectivePeriod(): PERIODS {
|
||||||
return this.period && HISTORY_PERIODS.includes(this.period)
|
return this.period && HISTORY_PERIODS.includes(this.period)
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ import { formatCompactJson } from "../components/Optimize/compactJson";
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
import store from "../store";
|
import store from "../store";
|
||||||
import formatter from "../mixins/formatter";
|
import formatter from "../mixins/formatter";
|
||||||
import { resolveColors } from "../colors";
|
import { resolveColors, deviceColorMap } from "../colors";
|
||||||
import { CURRENCY } from "../types/evcc";
|
import { CURRENCY } from "../types/evcc";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
|
@ -192,7 +192,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
deviceColors() {
|
deviceColors() {
|
||||||
return store.state.deviceColors ?? {};
|
return deviceColorMap(store.state.deviceColors);
|
||||||
},
|
},
|
||||||
batteryTitles(): string[] {
|
batteryTitles(): string[] {
|
||||||
const details = this.evopt?.details?.batteryDetails || [];
|
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 IconSelectItem from "../components/Helper/IconSelectItem.vue";
|
||||||
import SelectGroup from "../components/Helper/SelectGroup.vue";
|
import SelectGroup from "../components/Helper/SelectGroup.vue";
|
||||||
import CustomSelect from "../components/Helper/CustomSelect.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 settings from "../settings";
|
||||||
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
||||||
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
||||||
|
|
@ -503,8 +504,8 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
return this.csvHrefLink();
|
return this.csvHrefLink();
|
||||||
},
|
},
|
||||||
deviceColors() {
|
deviceColors(): DeviceColors {
|
||||||
return store.state.deviceColors ?? {};
|
return deviceColorMap(store.state.deviceColors);
|
||||||
},
|
},
|
||||||
colorMappings() {
|
colorMappings() {
|
||||||
const lastThreeMonths = new Date();
|
const lastThreeMonths = new Date();
|
||||||
|
|
|
||||||
|
|
@ -361,7 +361,7 @@ func runRoot(cmd *cobra.Command, args []string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// publish initial settings
|
// 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{
|
valueChan <- util.Param{Key: keys.EEBus, Val: globalconfig.ConfigStatus{
|
||||||
Config: conf.EEBus.Redacted(),
|
Config: conf.EEBus.Redacted(),
|
||||||
Status: eebus.GetStatus(),
|
Status: eebus.GetStatus(),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package server
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"maps"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
|
@ -14,7 +13,6 @@ import (
|
||||||
|
|
||||||
var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||||
|
|
||||||
// updateDeviceColor sets/removes a single title→hex association.
|
|
||||||
func updateDeviceColor(site site.API) http.HandlerFunc {
|
func updateDeviceColor(site site.API) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
|
|
@ -29,26 +27,23 @@ func updateDeviceColor(site site.API) http.HandlerFunc {
|
||||||
jsonError(w, http.StatusBadRequest, errors.New("title required"))
|
jsonError(w, http.StatusBadRequest, errors.New("title required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
color := req.Color
|
if req.Color != "" && !hexColorRE.MatchString(req.Color) {
|
||||||
if color != "" && !hexColorRE.MatchString(color) {
|
|
||||||
jsonError(w, http.StatusBadRequest, errors.New("invalid hex color"))
|
jsonError(w, http.StatusBadRequest, errors.New("invalid hex color"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m := ui.GetDeviceColors()
|
m := ui.GetDeviceColors()
|
||||||
m[req.Title] = color
|
if req.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)
|
delete(m, req.Title)
|
||||||
|
} else {
|
||||||
|
m[req.Title] = req.Color
|
||||||
}
|
}
|
||||||
if err := ui.SaveDeviceColors(m); err != nil {
|
if err := ui.SaveDeviceColors(m); err != nil {
|
||||||
jsonError(w, http.StatusInternalServerError, err)
|
jsonError(w, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
site.Publish(keys.DeviceColors, ui.DeviceColorList())
|
||||||
jsonWrite(w, m)
|
jsonWrite(w, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
ui/colors.go
20
ui/colors.go
|
|
@ -3,15 +3,22 @@ package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/core/keys"
|
"github.com/evcc-io/evcc/core/keys"
|
||||||
"github.com/evcc-io/evcc/server/db/settings"
|
"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
|
var colorsMu sync.RWMutex
|
||||||
|
|
||||||
// GetDeviceColors returns the persisted title→hex map (never nil).
|
|
||||||
func GetDeviceColors() map[string]string {
|
func GetDeviceColors() map[string]string {
|
||||||
colorsMu.RLock()
|
colorsMu.RLock()
|
||||||
defer colorsMu.RUnlock()
|
defer colorsMu.RUnlock()
|
||||||
|
|
@ -20,7 +27,6 @@ func GetDeviceColors() map[string]string {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveDeviceColors persists the title→hex map.
|
|
||||||
func SaveDeviceColors(m map[string]string) error {
|
func SaveDeviceColors(m map[string]string) error {
|
||||||
colorsMu.Lock()
|
colorsMu.Lock()
|
||||||
defer colorsMu.Unlock()
|
defer colorsMu.Unlock()
|
||||||
|
|
@ -28,3 +34,13 @@ func SaveDeviceColors(m map[string]string) error {
|
||||||
maps.Copy(clean, m)
|
maps.Copy(clean, m)
|
||||||
return settings.SetJson(keys.DeviceColors, clean)
|
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