From a8361b575bd6143d1c9d29e21ec5d9f4f9b0408f Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 7 Jun 2026 13:11:20 +0200 Subject: [PATCH] Metrics: store entity title on lazy-create (#30196) --- assets/js/components/History/GroupChart.vue | 16 +++--- assets/js/store.ts | 19 +------ assets/js/views/History.vue | 20 +++---- charger/ocpp.go | 5 +- cmd/metrics_battery.go | 8 +-- cmd/metrics_battery_test.go | 6 +-- cmd/metrics_data.go | 12 +++-- cmd/metrics_data_test.go | 7 ++- cmd/metrics_forecast_test.go | 8 +-- cmd/setup.go | 16 +++++- core/helper.go | 8 +-- core/metrics/collector.go | 31 +++++++---- core/metrics/collector_test.go | 58 ++++++++++++++++++--- core/metrics/db.go | 3 +- core/metrics/db_entities.go | 6 ++- core/metrics/db_history.go | 55 +++++++++++-------- core/metrics/db_history_test.go | 26 ++++----- core/metrics/db_test.go | 22 ++++---- core/site.go | 14 ++--- core/site_optimizer.go | 3 +- tests/energy-history.spec.ts | 8 +-- 21 files changed, 209 insertions(+), 142 deletions(-) diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index 0a59e33be..91caefdd7 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -27,7 +27,7 @@ export interface HistorySlot { } export interface HistorySeries { - name: string; + title: string; group: string; data: HistorySlot[]; // Marks a synthetic / derived series (e.g. "other consumers"). Gets a neutral @@ -203,14 +203,14 @@ export default defineComponent({ const mutedColor = colors.muted || this.color; const titles: string[] = []; for (const s of this.series) { - if (!s.virtual && !titles.includes(s.name)) titles.push(s.name); + if (!s.virtual && !titles.includes(s.title)) titles.push(s.title); } 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. if (s.virtual) return mutedColor; - return palette[s.name] || this.color; + return palette[s.title] || this.color; }); } if (this.series.length <= 1) return [this.color]; @@ -512,7 +512,7 @@ export default defineComponent({ ? [this.focusedEntity] : this.series.map((s, i) => s.paletteIndex ?? i); const nameByIdx = new Map( - this.series.map((s, i) => [s.paletteIndex ?? i, s.name]) + this.series.map((s, i) => [s.paletteIndex ?? i, s.title]) ); const showName = this.series.length > 1 && this.focusedEntity === null; @@ -692,15 +692,15 @@ export default defineComponent({ directionLabel(s: HistorySeries, dir: "energy" | "returnEnergy"): string { const key = `main.history.direction.${s.group}.${dir}`; const label = this.$t(key); - if (label === key) return s.name; - if (this.series.length > 1) return `${s.name} ${label}`; + if (label === key) return s.title; + if (this.series.length > 1) return `${s.title} ${label}`; return String(label); }, singleEntityName(s: HistorySeries): string { - if (this.series.length > 1) return s.name; + if (this.series.length > 1) return s.title; const key = `main.history.group.${s.group}`; const label = this.$t(key); - return label === key ? s.name : String(label); + return label === key ? s.title : String(label); }, }, }); diff --git a/assets/js/store.ts b/assets/js/store.ts index 54ea0e355..8e6224552 100644 --- a/assets/js/store.ts +++ b/assets/js/store.ts @@ -1,4 +1,4 @@ -import { computed, reactive } from "vue"; +import { reactive } from "vue"; import type { State } from "./types/evcc"; import { convertToUiLoadpoints } from "./uiLoadpoints"; import { useDebouncedComputed } from "./utils/useDebouncedComputed"; @@ -43,25 +43,9 @@ const uiLoadpoints = useDebouncedComputed( 50 ); -// name → title lookup for loadpoints and meters -const deviceTitles = computed(() => { - const map: Record = {}; - const add = (e: { name?: string; title?: string } | undefined) => { - if (e?.name && e.title) map[e.name] = e.title; - }; - state.loadpoints?.forEach(add); - state.pv?.forEach(add); - state.battery?.devices?.forEach(add); - state.aux?.forEach(add); - state.ext?.forEach(add); - add(state.grid); - return map; -}); - export interface Store { state: State; // raw state from websocket uiLoadpoints: typeof uiLoadpoints; - deviceTitles: typeof deviceTitles; offline(value: boolean): void; update(msg: any): void; reset(): void; @@ -70,7 +54,6 @@ export interface Store { const store: Store = { state, uiLoadpoints, - deviceTitles, offline(value: boolean) { state.offline = value; }, diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index edac95fc2..15a92c990 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -245,12 +245,10 @@ export default defineComponent({ return `${this.aggregate}|${this.from.getTime()}|${this.to.getTime()}`; }, seriesByGroup(): Record { - const titles = store.deviceTitles.value; const map: Record = {}; for (const s of this.rawSeries) { if (!s.group) continue; - if (!map[s.group]) map[s.group] = []; - map[s.group]!.push({ ...s, name: titles[s.name] || s.name }); + (map[s.group] ||= []).push(s); } return map; }, @@ -284,7 +282,7 @@ export default defineComponent({ const activeMeters = meters .map((s, i) => ({ ...s, paletteIndex: i })) .filter(hasEnergy); - if (!home) return activeMeters; + if (!home || activeMeters.length === 0) return activeMeters; const meterTotals = new Map(); // Net per slot is computed from all meters (incl. inactive ones), so // dropping inactive entries from the display doesn't shift the @@ -296,7 +294,7 @@ export default defineComponent({ } } const other: HistorySeries = { - name: this.$t("main.history.otherConsumers") as string, + title: this.$t("main.history.otherConsumers") as string, group: "meter", virtual: true, // Use meters.length as a stable paletteIndex that can never @@ -309,9 +307,7 @@ export default defineComponent({ }), }; if (!hasEnergy(other)) return activeMeters; - // First entry in the array = bottom of the stack, so "Other consumers" - // always sits underneath the explicit meters. - return [other, ...activeMeters]; + return [...activeMeters, other]; }; }, hasForecast(): boolean { @@ -417,14 +413,14 @@ export default defineComponent({ if (isPickGroup) { const titles: string[] = []; for (const s of list) { - if (!s.virtual && !titles.includes(s.name)) titles.push(s.name); + if (!s.virtual && !titles.includes(s.title)) titles.push(s.title); } palette = resolveColors(titles, this.deviceColors); } const colorFor = (i: number, s: HistorySeries) => { if (s.virtual) return colors.muted || baseColor; - if (isPickGroup) return palette[s.name] || baseColor; + if (isPickGroup) return palette[s.title] || baseColor; return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1))); }; return list.map((s, i) => { @@ -435,10 +431,10 @@ export default defineComponent({ // Use stable paletteIndex as the focus identifier so that the // selected entity keeps its identity across period navigations. entityIndex: s.paletteIndex ?? i, - label: s.name, + label: s.title, color: colorFor(i, s), value: this.fmtWh(watts, POWER_UNIT.AUTO), - id: isPickGroup && !s.virtual ? s.name : undefined, + id: isPickGroup && !s.virtual ? s.title : undefined, }; }); }, diff --git a/charger/ocpp.go b/charger/ocpp.go index 9a19f474e..36c466121 100644 --- a/charger/ocpp.go +++ b/charger/ocpp.go @@ -33,7 +33,6 @@ import ( "github.com/evcc-io/evcc/util/sponsor" "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" - "github.com/samber/lo" ) // OCPP charger implementation @@ -138,7 +137,7 @@ func NewOCPP(ctx context.Context, forcePowerCtrl, stackLevelZero, profileKindRelative, remoteStart, noChangeAvailability bool, connectTimeout time.Duration, ) (*OCPP, error) { - log := util.NewLogger(fmt.Sprintf("%s-%d", lo.CoalesceOrEmpty(id, "ocpp"), connector)) + log := util.NewLogger(fmt.Sprintf("%s-%d", cmp.Or(id, "ocpp"), connector)) cp, err := ocpp.Instance().RegisterChargepoint(id, func() *ocpp.CP { @@ -167,7 +166,7 @@ func NewOCPP(ctx context.Context, } if remoteStart { - idTag = lo.CoalesceOrEmpty(idTag, cp.IdTag, defaultIdTag) + idTag = cmp.Or(idTag, cp.IdTag, defaultIdTag) } conn, err := ocpp.NewConnector(ctx, log, connector, cp, idTag, meterInterval) diff --git a/cmd/metrics_battery.go b/cmd/metrics_battery.go index 7f136ba66..351c2b31e 100644 --- a/cmd/metrics_battery.go +++ b/cmd/metrics_battery.go @@ -78,19 +78,19 @@ type batteryTotals struct { discharge float64 } -// metricsBatteryTotals sums charge and discharge energy per battery entity. +// metricsBatteryTotals sums charge and discharge energy per battery title. func metricsBatteryTotals(series []metrics.Series) map[string]batteryTotals { res := make(map[string]batteryTotals) for _, s := range series { if s.Group != metrics.Battery { continue } - t := res[s.Name] + t := res[s.Title] for _, slot := range s.Data { t.charge += slot.Energy t.discharge += slot.ReturnEnergy } - res[s.Name] = t + res[s.Title] = t } return res } @@ -103,7 +103,7 @@ func metricsWriteBatteryTable(w io.Writer, selected []metrics.EntityInfo, totals fmt.Fprintln(tw, "name\ttitle\tcharge\tdischarge\tefficiency") for _, e := range selected { - t := totals[e.Name] + t := totals[title(e.Group, e.Name)] efficiency := "" if t.charge > 0 { diff --git a/cmd/metrics_battery_test.go b/cmd/metrics_battery_test.go index bbc61d730..29f6080f3 100644 --- a/cmd/metrics_battery_test.go +++ b/cmd/metrics_battery_test.go @@ -11,12 +11,12 @@ import ( func TestMetricsBatteryTotals(t *testing.T) { series := []metrics.Series{ - {Group: metrics.Battery, Name: "bat", Data: []metrics.Slot{ + {Group: metrics.Battery, Title: "bat", Data: []metrics.Slot{ {Energy: 1.0, ReturnEnergy: 0.4}, {Energy: 2.0, ReturnEnergy: 1.6}, }}, // non-battery series must be ignored - {Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{ + {Group: metrics.Grid, Title: "grid", Data: []metrics.Slot{ {Energy: 5.0, ReturnEnergy: 3.0}, }}, } @@ -33,7 +33,7 @@ func TestMetricsWriteBatteryTable(t *testing.T) { {Group: metrics.Battery, Name: "bat2"}, } totals := map[string]batteryTotals{ - "bat1": {charge: 10.0, discharge: 9.0}, + "Home": {charge: 10.0, discharge: 9.0}, // bat2 deliberately absent: no data in the timeframe } title := func(group, name string) string { diff --git a/cmd/metrics_data.go b/cmd/metrics_data.go index 270c705e8..b0072e6d1 100644 --- a/cmd/metrics_data.go +++ b/cmd/metrics_data.go @@ -72,13 +72,19 @@ func runMetricsData(cmd *cobra.Command, args []string) { byEntity := make(map[string]metrics.Series, len(series)) for _, s := range series { - byEntity[s.Group+"/"+s.Name] = s + byEntity[s.Group+"/"+s.Title] = s } if asCSV, _ := cmd.Flags().GetBool("csv"); asCSV { var out metrics.SeriesCSV + seen := make(map[string]bool, len(selected)) for _, e := range selected { - if s, ok := byEntity[e.Group+"/"+e.Name]; ok { + key := e.Group + "/" + title(e.Group, e.Name) + if seen[key] { + continue + } + seen[key] = true + if s, ok := byEntity[key]; ok { out = append(out, s) } } @@ -236,7 +242,7 @@ func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[ } for _, spec := range specs { - s, ok := byEntity[spec.entity.Group+"/"+spec.entity.Name] + s, ok := byEntity[spec.entity.Group+"/"+title(spec.entity.Group, spec.entity.Name)] if !ok { continue } diff --git a/cmd/metrics_data_test.go b/cmd/metrics_data_test.go index 8df630378..836cb2239 100644 --- a/cmd/metrics_data_test.go +++ b/cmd/metrics_data_test.go @@ -93,10 +93,10 @@ func TestMetricsWriteTable(t *testing.T) { {Group: metrics.Grid, Name: "grid"}, } byEntity := map[string]metrics.Series{ - metrics.Loadpoint + "/lp-1": {Group: metrics.Loadpoint, Name: "lp-1", Data: []metrics.Slot{ + metrics.Loadpoint + "/Carport": {Group: metrics.Loadpoint, Title: "Carport", Data: []metrics.Slot{ {Start: h0, Energy: 1.84}, }}, - metrics.Grid + "/grid": {Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{ + metrics.Grid + "/grid": {Group: metrics.Grid, Title: "grid", Data: []metrics.Slot{ {Start: h0, Energy: 0.412}, {Start: h1, Energy: 0.38, ReturnEnergy: 0.05}, }}, @@ -105,6 +105,9 @@ func TestMetricsWriteTable(t *testing.T) { if group == metrics.Loadpoint { return "Carport" } + if group == metrics.Grid { + return "grid" + } return "" } diff --git a/cmd/metrics_forecast_test.go b/cmd/metrics_forecast_test.go index da0e69284..aff763104 100644 --- a/cmd/metrics_forecast_test.go +++ b/cmd/metrics_forecast_test.go @@ -11,18 +11,18 @@ import ( func TestMetricsForecastTotals(t *testing.T) { series := []metrics.Series{ - {Group: metrics.Forecast, Name: "forecast", Data: []metrics.Slot{ + {Group: metrics.Forecast, Title: "forecast", Data: []metrics.Slot{ {Energy: 10.0}, {Energy: 5.0}, }}, // actual PV production is summed across all pv entities - {Group: metrics.PV, Name: "pv1", Data: []metrics.Slot{ + {Group: metrics.PV, Title: "pv1", Data: []metrics.Slot{ {Energy: 4.0}, {Energy: 3.0}, }}, - {Group: metrics.PV, Name: "pv2", Data: []metrics.Slot{ + {Group: metrics.PV, Title: "pv2", Data: []metrics.Slot{ {Energy: 2.0}, }}, // other groups are ignored - {Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{ + {Group: metrics.Grid, Title: "grid", Data: []metrics.Slot{ {Energy: 99.0}, }}, } diff --git a/cmd/setup.go b/cmd/setup.go index 02947b145..c9601b1f9 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1305,12 +1305,24 @@ func configureSite(conf map[string]any, loadpoints []*core.Loadpoint, tariffs *t func newLoadpoint(idx int, name string, other map[string]any, settingsFn func(*util.Logger) coresettings.Settings) (*core.Loadpoint, error) { log := util.NewLoggerWithLoadpoint("lp-"+strconv.Itoa(idx), idx) - collector, err := metrics.NewCollector(metrics.Loadpoint, name) + collector, err := metrics.NewCollector(metrics.Loadpoint, name, "") if err != nil { return nil, err } - return core.NewLoadpointFromConfig(log, settingsFn(log), collector, other) + lp, err := core.NewLoadpointFromConfig(log, settingsFn(log), collector, other) + if err != nil { + return lp, err + } + + // lazily update entity title + if title := lp.GetTitle(); title != "" { + if err := collector.UpdateTitle(title); err != nil { + return lp, err + } + } + + return lp, nil } func configureLoadpoints(conf globalconfig.All) error { diff --git a/core/helper.go b/core/helper.go index 2db51326e..e0093e1fe 100644 --- a/core/helper.go +++ b/core/helper.go @@ -1,6 +1,7 @@ package core import ( + "cmp" "fmt" "slices" @@ -64,12 +65,7 @@ func deviceProperties[T any](dev config.Device[T]) config.Properties { // deviceTitleOrName returns device title or name func deviceTitleOrName[T any](dev config.Device[T]) string { - if d, ok := dev.(config.ConfigurableDevice[T]); ok { - if title := d.Properties().Title; title != "" { - return title - } - } - return dev.Config().Name + return cmp.Or(deviceProperties(dev).Title, dev.Config().Name) } // circuitMaxPower returns a circuits power limit diff --git a/core/metrics/collector.go b/core/metrics/collector.go index 1b9c29216..02abf8c3b 100644 --- a/core/metrics/collector.go +++ b/core/metrics/collector.go @@ -24,8 +24,8 @@ type Collector struct { started time.Time } -func NewCollector(group, name string, opt ...func(*Accumulator)) (*Collector, error) { - entity, err := createEntity(group, name) +func NewCollector(group, name, title string, opt ...func(*Accumulator)) (*Collector, error) { + entity, err := createEntity(group, name, title) if err != nil { return nil, err } @@ -38,17 +38,30 @@ func NewCollector(group, name string, opt ...func(*Accumulator)) (*Collector, er return c, nil } -func createEntity(group, name string) (entity, error) { - entity := entity{ - Group: group, - Name: name, +// createEntity ensures the entity row exists and refreshes its title. +func createEntity(group, name, title string) (entity, error) { + e := entity{Group: group, Name: name} + + if err := db.Instance.Where(&e).Attrs(entity{Title: title}).FirstOrCreate(&e).Error; err != nil { + return e, err } - if err := db.Instance.Where(&entity).FirstOrCreate(&entity).Error; err != nil { - return entity, err + return e, e.updateTitle(title) +} + +// updateTitle refreshes the entity's stored title if it changed +func (e *entity) updateTitle(title string) error { + if title == "" || e.Title == title { + return nil } - return entity, nil + e.Title = title + return db.Instance.Model(e).UpdateColumn("title", title).Error +} + +// UpdateTitle refreshes the collector entity's stored title if it changed. +func (c *Collector) UpdateTitle(title string) error { + return c.entity.updateTitle(title) } func (c *Collector) process(fun func()) error { diff --git a/core/metrics/collector_test.go b/core/metrics/collector_test.go index 703179df4..0937d8931 100644 --- a/core/metrics/collector_test.go +++ b/core/metrics/collector_test.go @@ -15,7 +15,7 @@ func TestCollectorAddEnergy(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("foo", "foo", WithClock(clock)) + col, err := NewCollector("foo", "foo", "", WithClock(clock)) require.NoError(t, err) require.True(t, col.accu.updated.IsZero()) @@ -42,7 +42,7 @@ func TestCollectorAddEnergyWithImportMeter(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("bar", "bar", WithClock(clock)) + col, err := NewCollector("bar", "bar", "", WithClock(clock)) require.NoError(t, err) // first call: seeds meter, no delta yet @@ -67,7 +67,7 @@ func TestCollectorAddEnergyWithImportMeterAndExport(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("baz", "baz", WithClock(clock)) + col, err := NewCollector("baz", "baz", "", WithClock(clock)) require.NoError(t, err) // seed import meter @@ -93,7 +93,7 @@ func TestCollectorAddEnergyWithExportMeterAndImport(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("baz2", "baz2", WithClock(clock)) + col, err := NewCollector("baz2", "baz2", "", WithClock(clock)) require.NoError(t, err) // seed export meter @@ -119,7 +119,7 @@ func TestCollectorAddEnergyWithBothMeters(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("qux", "qux", WithClock(clock)) + col, err := NewCollector("qux", "qux", "", WithClock(clock)) require.NoError(t, err) // seed both meters @@ -139,7 +139,7 @@ func TestCollectorSetImportAndExportMeterTotal(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("set", "set", WithClock(clock)) + col, err := NewCollector("set", "set", "", WithClock(clock)) require.NoError(t, err) // seed both import and export @@ -163,7 +163,7 @@ func TestCollectorSkipsPartialFirstSlot(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("partial", "partial", WithClock(clk)) + col, err := NewCollector("partial", "partial", "", WithClock(clk)) require.NoError(t, err) // first update mid-slot (00:05) - slot 00:00 is only partially covered @@ -191,3 +191,47 @@ func TestCollectorSkipsPartialFirstSlot(t *testing.T) { require.NoError(t, db.Instance.First(&m).Error) require.Equal(t, int64(15*60), m.Timestamp, "persisted slot should start at 00:15") } + +// TestCreateEntityRefreshesTitle verifies that a second call to createEntity +// with a non-empty title fills in (or updates) the title on an existing row, +// and that passing an empty title never clears a previously stored value. +func TestCreateEntityRefreshesTitle(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + // existing row with empty title (simulates pre-upgrade data) + first, err := createEntity("grid", "grid", "") + require.NoError(t, err) + require.Empty(t, first.Title) + + // lazy-create with a real title must persist it + second, err := createEntity("grid", "grid", "House meter") + require.NoError(t, err) + require.Equal(t, first.Id, second.Id, "should be the same row") + require.Equal(t, "House meter", second.Title) + + var stored entity + require.NoError(t, db.Instance.First(&stored, first.Id).Error) + require.Equal(t, "House meter", stored.Title, "title must be persisted") + + // subsequent call with empty title must not clear the existing one + third, err := createEntity("grid", "grid", "") + require.NoError(t, err) + require.Equal(t, "House meter", third.Title) + + require.NoError(t, db.Instance.First(&stored, first.Id).Error) + require.Equal(t, "House meter", stored.Title, "title must survive empty re-create") + + // changing the title overwrites the stored value + fourth, err := createEntity("grid", "grid", "Grid") + require.NoError(t, err) + require.Equal(t, "Grid", fourth.Title) + + require.NoError(t, db.Instance.First(&stored, first.Id).Error) + require.Equal(t, "Grid", stored.Title) + + // only one row exists despite four createEntity calls with varying titles + var count int64 + require.NoError(t, db.Instance.Model(new(entity)).Where("\"group\" = ? AND name = ?", "grid", "grid").Count(&count).Error) + require.EqualValues(t, 1, count, "must not duplicate existing rows") +} diff --git a/core/metrics/db.go b/core/metrics/db.go index bbaf6b3fb..40cf4dc0e 100644 --- a/core/metrics/db.go +++ b/core/metrics/db.go @@ -23,6 +23,7 @@ type entity struct { Id int `gorm:"column:id;primarykey"` Group string `gorm:"column:group;uniqueIndex:entities_group_name"` Name string `gorm:"column:name;uniqueIndex:entities_group_name"` + Title string `gorm:"column:title"` } func init() { @@ -41,7 +42,7 @@ func SetupSchema() error { } // ensure home entity exists (reserves id=1 for legacy meter FK references) - if _, err := createEntity(Home, Home); err != nil { + if _, err := createEntity(Home, Home, Home); err != nil { return err } diff --git a/core/metrics/db_entities.go b/core/metrics/db_entities.go index 34ad12614..9543dbe9a 100644 --- a/core/metrics/db_entities.go +++ b/core/metrics/db_entities.go @@ -11,6 +11,7 @@ import ( type EntityInfo struct { Group string Name string + Title string // human-readable title as captured at the last lazy-create Slots int // number of persisted 15min slots First time.Time // start of the earliest slot, zero if no data Last time.Time // start of the latest slot, zero if no data @@ -23,6 +24,7 @@ func ListEntities() ([]EntityInfo, error) { type row struct { Group string Name string + Title string Slots int First sql.NullInt64 Last sql.NullInt64 @@ -30,7 +32,7 @@ func ListEntities() ([]EntityInfo, error) { var rows []row if err := db.Instance.Table("entities e"). - Select(`e."group" AS "group", e.name AS name, + Select(`e."group" AS "group", e.name AS name, e.title AS title, COUNT(m.ts) AS slots, MIN(m.ts) AS first, MAX(m.ts) AS last`). Joins("LEFT JOIN meters m ON m.meter = e.id"). @@ -41,7 +43,7 @@ func ListEntities() ([]EntityInfo, error) { res := make([]EntityInfo, 0, len(rows)) for _, r := range rows { - e := EntityInfo{Group: r.Group, Name: r.Name, Slots: r.Slots} + e := EntityInfo{Group: r.Group, Name: r.Name, Title: r.Title, Slots: r.Slots} if r.First.Valid { e.First = time.Unix(r.First.Int64, 0) } diff --git a/core/metrics/db_history.go b/core/metrics/db_history.go index 7f31df1e2..350881d33 100644 --- a/core/metrics/db_history.go +++ b/core/metrics/db_history.go @@ -27,9 +27,9 @@ func roundEnergy(v float64) float64 { return max(0, math.Round(v*1000)/1000) } -// Series represents a named series of energy slots +// Series represents an energy series for one title group or one entity group. type Series struct { - Name string `json:"name,omitempty"` + Title string `json:"title,omitempty"` Group string `json:"group"` Data []Slot `json:"data"` } @@ -55,7 +55,7 @@ var aggregateDurations = map[string]func(time.Time) time.Time{ "month": func(t time.Time) time.Time { return t.AddDate(0, 1, 0) }, } -// QueryEnergy returns aggregated energy data, per entity or per group. +// QueryEnergy returns aggregated energy data, per title or per group. func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, error) { addDuration := aggregateDurations[aggregate] @@ -64,13 +64,18 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, return nil, errors.New("invalid aggregate value") } - groupCols := `e."group", ` + fmt.Sprintf(`strftime('%s', m.ts, 'unixepoch', 'localtime')`, format) - if !grouped { - groupCols = "e.name, " + groupCols + titleExpr := `COALESCE(NULLIF(e.title,''), e.name)` + timeCol := fmt.Sprintf(`strftime('%s', m.ts, 'unixepoch', 'localtime')`, format) + + selectTitle := titleExpr + ` AS title` + groupCols := titleExpr + `, e."group", ` + timeCol + if grouped { + selectTitle = `'' AS title` + groupCols = `e."group", ` + timeCol } type row struct { - Name string + Title string Group string Start SqlTime Energy float64 @@ -78,7 +83,7 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, } tx := db.Instance.Table("meters m"). - Select(`e.name, e."group", + Select(selectTitle + `, e."group", MIN(m.ts) AS start, COALESCE(SUM(m.energy), 0) AS energy, COALESCE(SUM(m.return_energy), 0) AS return_energy`). @@ -100,13 +105,8 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, var res []Series for _, r := range rows { - name := r.Name - if grouped { - name = "" - } - - if n := len(res); n == 0 || res[n-1].Name != name || res[n-1].Group != r.Group { - res = append(res, Series{Name: name, Group: r.Group}) + if n := len(res); n == 0 || res[n-1].Title != r.Title || res[n-1].Group != r.Group { + res = append(res, Series{Title: r.Title, Group: r.Group}) } s := &res[len(res)-1] @@ -180,19 +180,30 @@ func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error { tsSet := make(map[int64]time.Time) endByStart := make(map[int64]time.Time) + label := func(e *Series, g string) string { + if e.Title != "" { + return e.Title + } + return g + } + + prefix := func(g, l string) string { + if l == g { + return g + } + return g + "." + l + } + for _, g := range groups { entities := byGroup[g] - sort.Slice(entities, func(i, j int) bool { return entities[i].Name < entities[j].Name }) + sort.Slice(entities, func(i, j int) bool { return label(entities[i], g) < label(entities[j], g) }) for _, e := range entities { - name := e.Name - if name == "" { - name = g - } - header = append(header, g+"."+name+".energy.Wh") + p := prefix(g, label(e, g)) + header = append(header, p+".energy.Wh") cols = append(cols, col{series: e, returnEnergy: false}) if hasReturnEnergy(g) { - header = append(header, g+"."+name+".returnEnergy.Wh") + header = append(header, p+".returnEnergy.Wh") cols = append(cols, col{series: e, returnEnergy: true}) } for _, slot := range e.Data { diff --git a/core/metrics/db_history_test.go b/core/metrics/db_history_test.go index bf39d74ab..04a900b0a 100644 --- a/core/metrics/db_history_test.go +++ b/core/metrics/db_history_test.go @@ -21,11 +21,11 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) { t1 := t0.Add(15 * time.Minute) series := SeriesCSV{ - {Group: PV, Name: "pv", Data: []Slot{mkSlot(t0, 2.5, 0), mkSlot(t1, 3.0, 0)}}, - {Group: Battery, Name: "battery-home", Data: []Slot{mkSlot(t0, 0, 0.7800), mkSlot(t1, 0.5300, 0)}}, - {Group: Battery, Name: "battery-garage", Data: []Slot{mkSlot(t0, 0, 1.2500), mkSlot(t1, 0, 0)}}, - {Group: Grid, Name: "grid", Data: []Slot{mkSlot(t0, 0, 0.4123), mkSlot(t1, 0.1, 0)}}, - {Group: Home, Name: "home", Data: []Slot{mkSlot(t0, 0.3661, 0), mkSlot(t1, 0.4, 0)}}, + {Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2.5, 0), mkSlot(t1, 3.0, 0)}}, + {Group: Battery, Title: "battery-home", Data: []Slot{mkSlot(t0, 0, 0.7800), mkSlot(t1, 0.5300, 0)}}, + {Group: Battery, Title: "battery-garage", Data: []Slot{mkSlot(t0, 0, 1.2500), mkSlot(t1, 0, 0)}}, + {Group: Grid, Title: "grid", Data: []Slot{mkSlot(t0, 0, 0.4123), mkSlot(t1, 0.1, 0)}}, + {Group: Home, Title: "home", Data: []Slot{mkSlot(t0, 0.3661, 0), mkSlot(t1, 0.4, 0)}}, } var buf bytes.Buffer @@ -42,13 +42,13 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) { header := rows[0] expected := []string{ "time.start", "time.end", - "pv.pv.energy.Wh", + "pv.energy.Wh", "battery.battery-garage.energy.Wh", "battery.battery-garage.returnEnergy.Wh", "battery.battery-home.energy.Wh", "battery.battery-home.returnEnergy.Wh", - "grid.grid.energy.Wh", "grid.grid.returnEnergy.Wh", - "home.home.energy.Wh", + "grid.energy.Wh", "grid.returnEnergy.Wh", + "home.energy.Wh", } - require.Equal(t, expected, header, "header order: GROUP_ORDER, alphabetical entities; returnEnergy only for grid/battery") + require.Equal(t, expected, header, "single-entity groups omit the title level; multi-entity groups include the title") // time.end = time.start + slot length (15 min in mkSlot) require.Equal(t, t0.Local().Format("2006-01-02 15:04:05"), rows[1][0]) @@ -68,7 +68,7 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) { func TestSeriesCSV_GermanLocale(t *testing.T) { t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) series := SeriesCSV{ - {Group: PV, Name: "pv", Data: []Slot{mkSlot(t0, 2.5, 0)}}, + {Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2.5, 0)}}, } var buf bytes.Buffer @@ -91,8 +91,8 @@ func TestSeriesCSV_MissingSlotIsEmpty(t *testing.T) { // Two entities, second one only has data for the first timestamp → second // timestamp must produce an empty cell rather than 0.000. series := SeriesCSV{ - {Group: PV, Name: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}}, - {Group: PV, Name: "b", Data: []Slot{mkSlot(t0, 3, 0)}}, + {Group: PV, Title: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}}, + {Group: PV, Title: "b", Data: []Slot{mkSlot(t0, 3, 0)}}, } var buf bytes.Buffer @@ -119,7 +119,7 @@ func TestSeriesCSV_BatteryHasReturnEnergyColumn(t *testing.T) { // returnEnergy column. t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) series := SeriesCSV{ - {Group: Battery, Name: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}}, + {Group: Battery, Title: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}}, } var buf bytes.Buffer diff --git a/core/metrics/db_test.go b/core/metrics/db_test.go index 8d796dff5..7aa6e166e 100644 --- a/core/metrics/db_test.go +++ b/core/metrics/db_test.go @@ -101,7 +101,7 @@ func TestQueryEnergyGrouped(t *testing.T) { require.NoError(t, err) require.Len(t, res, 1) require.Equal(t, Grid, res[0].Group) - require.Empty(t, res[0].Name) + require.Empty(t, res[0].Title) require.Len(t, res[0].Data, 2) require.InDelta(t, 1+2, res[0].Data[0].Energy, 0.001) require.InDelta(t, 3+4, res[0].Data[1].Energy, 0.001) @@ -138,19 +138,19 @@ func TestQueryEnergyMultipleSeries(t *testing.T) { require.NoError(t, err) require.Len(t, res, 3) - byName := map[string]Series{} + byTitle := map[string]Series{} for _, s := range res { require.Len(t, s.Data, 2) - byName[s.Name] = s + byTitle[s.Title] = s } - require.Equal(t, Grid, byName[Grid].Group) - require.Equal(t, PV, byName["pv1"].Group) - require.Equal(t, PV, byName["pv2"].Group) + require.Equal(t, Grid, byTitle[Grid].Group) + require.Equal(t, PV, byTitle["pv1"].Group) + require.Equal(t, PV, byTitle["pv2"].Group) - require.InDelta(t, 1, byName[Grid].Data[0].Energy, 0.001) - require.InDelta(t, 2, byName[Grid].Data[1].Energy, 0.001) - require.InDelta(t, 10, byName["pv1"].Data[0].ReturnEnergy, 0.001) - require.InDelta(t, 21, byName["pv2"].Data[1].ReturnEnergy, 0.001) + require.InDelta(t, 1, byTitle[Grid].Data[0].Energy, 0.001) + require.InDelta(t, 2, byTitle[Grid].Data[1].Energy, 0.001) + require.InDelta(t, 10, byTitle["pv1"].Data[0].ReturnEnergy, 0.001) + require.InDelta(t, 21, byTitle["pv2"].Data[1].ReturnEnergy, 0.001) // grouped: 2 series, pv summed per bucket res, err = QueryEnergy(from, to, "hour", true) @@ -159,7 +159,7 @@ func TestQueryEnergyMultipleSeries(t *testing.T) { byGroup := map[string]Series{} for _, s := range res { - require.Empty(t, s.Name) + require.Empty(t, s.Title) require.Len(t, s.Data, 2) byGroup[s.Group] = s } diff --git a/core/site.go b/core/site.go index 63dc02bfe..d972774ff 100644 --- a/core/site.go +++ b/core/site.go @@ -136,7 +136,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.prioritizer = prioritizer.New(log) site.stats = NewStats() - me, err := metrics.NewCollector(metrics.Home, metrics.Home) + me, err := metrics.NewCollector(metrics.Home, metrics.Home, metrics.Home) if err != nil { return err } @@ -188,7 +188,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif return errors.New("missing grid meter instance") } - me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef) + me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, deviceTitleOrName(dev)) if err != nil { return err } @@ -204,7 +204,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.pvMeters = append(site.pvMeters, dev) // energy collector (for history persistence and forecast scaling) - me, err := metrics.NewCollector(metrics.PV, ref) + me, err := metrics.NewCollector(metrics.PV, ref, deviceTitleOrName(dev)) if err != nil { return err } @@ -212,7 +212,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif } // solar forecast collector (mirrors PV history shape, used for scale lookup) - fc, err := metrics.NewCollector(metrics.Forecast, metrics.Forecast) + fc, err := metrics.NewCollector(metrics.Forecast, metrics.Forecast, metrics.Forecast) if err != nil { return err } @@ -226,7 +226,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif } site.batteryMeters = append(site.batteryMeters, dev) - me, err := metrics.NewCollector(metrics.Battery, ref) + me, err := metrics.NewCollector(metrics.Battery, ref, deviceTitleOrName(dev)) if err != nil { return err } @@ -241,7 +241,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif } site.extMeters = append(site.extMeters, dev) - me, err := metrics.NewCollector(metrics.Meter, ref) + me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev)) if err != nil { return err } @@ -256,7 +256,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif } site.auxMeters = append(site.auxMeters, dev) - me, err := metrics.NewCollector(metrics.Meter, ref) + me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev)) if err != nil { return err } diff --git a/core/site_optimizer.go b/core/site_optimizer.go index b886f6689..d0f6edaf5 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -1,6 +1,7 @@ package core import ( + "cmp" "context" "encoding/json" "errors" @@ -121,7 +122,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { minLen = min(minLen, len(solar)) } - uri := lo.CoalesceOrEmpty(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI) + uri := cmp.Or(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI) if uri == OPTIMIZER_URI { // limit to 2 days for sake of performance minLen = min(2*96, minLen) diff --git a/tests/energy-history.spec.ts b/tests/energy-history.spec.ts index acb021827..d4a48f2a8 100644 --- a/tests/energy-history.spec.ts +++ b/tests/energy-history.spec.ts @@ -23,8 +23,8 @@ test.describe("api", () => { const data = await res.json(); expect(data).toHaveLength(2); - const grid = data.find((s: { name: string }) => s.name === "grid"); - const home = data.find((s: { name: string }) => s.name === "home"); + const grid = data.find((s: { title: string }) => s.title === "grid"); + const home = data.find((s: { title: string }) => s.title === "home"); expect(grid).toBeDefined(); expect(home).toBeDefined(); @@ -46,8 +46,8 @@ test.describe("api", () => { const data = await res.json(); expect(data).toHaveLength(2); - const grid = data.find((s: { name: string }) => s.name === "grid"); - const home = data.find((s: { name: string }) => s.name === "home"); + const grid = data.find((s: { title: string }) => s.title === "grid"); + const home = data.find((s: { title: string }) => s.title === "home"); expect(grid).toBeDefined(); expect(home).toBeDefined();