Metrics: store entity title on lazy-create (#30196)

This commit is contained in:
andig 2026-06-07 13:11:20 +02:00 • committed by GitHub
parent 0fa8b97daf
commit a8361b575b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 209 additions and 142 deletions

View file

@ -27,7 +27,7 @@ export interface HistorySlot {
} }
export interface HistorySeries { export interface HistorySeries {
name: string; title: string;
group: string; group: string;
data: HistorySlot[]; data: HistorySlot[];
// Marks a synthetic / derived series (e.g. "other consumers"). Gets a neutral // 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 mutedColor = colors.muted || this.color;
const titles: string[] = []; const titles: string[] = [];
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.title)) titles.push(s.title);
} }
const palette = resolveColors(titles, deviceColorMap(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.
if (s.virtual) return mutedColor; 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]; if (this.series.length <= 1) return [this.color];
@ -512,7 +512,7 @@ export default defineComponent({
? [this.focusedEntity] ? [this.focusedEntity]
: this.series.map((s, i) => s.paletteIndex ?? i); : this.series.map((s, i) => s.paletteIndex ?? i);
const nameByIdx = new Map( 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; const showName = this.series.length > 1 && this.focusedEntity === null;
@ -692,15 +692,15 @@ export default defineComponent({
directionLabel(s: HistorySeries, dir: "energy" | "returnEnergy"): string { directionLabel(s: HistorySeries, dir: "energy" | "returnEnergy"): string {
const key = `main.history.direction.${s.group}.${dir}`; const key = `main.history.direction.${s.group}.${dir}`;
const label = this.$t(key); const label = this.$t(key);
if (label === key) return s.name; if (label === key) return s.title;
if (this.series.length > 1) return `${s.name} ${label}`; if (this.series.length > 1) return `${s.title} ${label}`;
return String(label); return String(label);
}, },
singleEntityName(s: HistorySeries): string { 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 key = `main.history.group.${s.group}`;
const label = this.$t(key); const label = this.$t(key);
return label === key ? s.name : String(label); return label === key ? s.title : String(label);
}, },
}, },
}); });

View file

@ -1,4 +1,4 @@
import { computed, reactive } from "vue"; import { reactive } from "vue";
import type { State } from "./types/evcc"; import type { State } from "./types/evcc";
import { convertToUiLoadpoints } from "./uiLoadpoints"; import { convertToUiLoadpoints } from "./uiLoadpoints";
import { useDebouncedComputed } from "./utils/useDebouncedComputed"; import { useDebouncedComputed } from "./utils/useDebouncedComputed";
@ -43,25 +43,9 @@ const uiLoadpoints = useDebouncedComputed(
50 50
); );
// name → title lookup for loadpoints and meters
const deviceTitles = computed(() => {
const map: Record<string, string> = {};
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 { export interface Store {
state: State; // raw state from websocket state: State; // raw state from websocket
uiLoadpoints: typeof uiLoadpoints; uiLoadpoints: typeof uiLoadpoints;
deviceTitles: typeof deviceTitles;
offline(value: boolean): void; offline(value: boolean): void;
update(msg: any): void; update(msg: any): void;
reset(): void; reset(): void;
@ -70,7 +54,6 @@ export interface Store {
const store: Store = { const store: Store = {
state, state,
uiLoadpoints, uiLoadpoints,
deviceTitles,
offline(value: boolean) { offline(value: boolean) {
state.offline = value; state.offline = value;
}, },

View file

@ -245,12 +245,10 @@ export default defineComponent({
return `${this.aggregate}|${this.from.getTime()}|${this.to.getTime()}`; return `${this.aggregate}|${this.from.getTime()}|${this.to.getTime()}`;
}, },
seriesByGroup(): Record<string, HistorySeries[]> { seriesByGroup(): Record<string, HistorySeries[]> {
const titles = store.deviceTitles.value;
const map: Record<string, HistorySeries[]> = {}; const map: Record<string, HistorySeries[]> = {};
for (const s of this.rawSeries) { for (const s of this.rawSeries) {
if (!s.group) continue; if (!s.group) continue;
if (!map[s.group]) map[s.group] = []; (map[s.group] ||= []).push(s);
map[s.group]!.push({ ...s, name: titles[s.name] || s.name });
} }
return map; return map;
}, },
@ -284,7 +282,7 @@ export default defineComponent({
const activeMeters = meters const activeMeters = meters
.map((s, i) => ({ ...s, paletteIndex: i })) .map((s, i) => ({ ...s, paletteIndex: i }))
.filter(hasEnergy); .filter(hasEnergy);
if (!home) return activeMeters; if (!home || activeMeters.length === 0) return activeMeters;
const meterTotals = new Map<string, number>(); const meterTotals = new Map<string, number>();
// Net per slot is computed from all meters (incl. inactive ones), so // Net per slot is computed from all meters (incl. inactive ones), so
// dropping inactive entries from the display doesn't shift the // dropping inactive entries from the display doesn't shift the
@ -296,7 +294,7 @@ export default defineComponent({
} }
} }
const other: HistorySeries = { const other: HistorySeries = {
name: this.$t("main.history.otherConsumers") as string, title: this.$t("main.history.otherConsumers") as string,
group: "meter", group: "meter",
virtual: true, virtual: true,
// Use meters.length as a stable paletteIndex that can never // Use meters.length as a stable paletteIndex that can never
@ -309,9 +307,7 @@ export default defineComponent({
}), }),
}; };
if (!hasEnergy(other)) return activeMeters; if (!hasEnergy(other)) return activeMeters;
// First entry in the array = bottom of the stack, so "Other consumers" return [...activeMeters, other];
// always sits underneath the explicit meters.
return [other, ...activeMeters];
}; };
}, },
hasForecast(): boolean { hasForecast(): boolean {
@ -417,14 +413,14 @@ export default defineComponent({
if (isPickGroup) { if (isPickGroup) {
const titles: string[] = []; const titles: string[] = [];
for (const s of list) { 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); palette = resolveColors(titles, this.deviceColors);
} }
const colorFor = (i: number, s: HistorySeries) => { const colorFor = (i: number, s: HistorySeries) => {
if (s.virtual) return colors.muted || baseColor; 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 alphaColor(baseColor, stepAlpha(i, Math.max(n, 1)));
}; };
return list.map((s, i) => { return list.map((s, i) => {
@ -435,10 +431,10 @@ export default defineComponent({
// Use stable paletteIndex as the focus identifier so that the // Use stable paletteIndex as the focus identifier so that the
// selected entity keeps its identity across period navigations. // selected entity keeps its identity across period navigations.
entityIndex: s.paletteIndex ?? i, entityIndex: s.paletteIndex ?? i,
label: s.name, label: s.title,
color: colorFor(i, s), color: colorFor(i, s),
value: this.fmtWh(watts, POWER_UNIT.AUTO), value: this.fmtWh(watts, POWER_UNIT.AUTO),
id: isPickGroup && !s.virtual ? s.name : undefined, id: isPickGroup && !s.virtual ? s.title : undefined,
}; };
}); });
}, },

View file

@ -33,7 +33,6 @@ import (
"github.com/evcc-io/evcc/util/sponsor" "github.com/evcc-io/evcc/util/sponsor"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core" "github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/types" "github.com/lorenzodonini/ocpp-go/ocpp1.6/types"
"github.com/samber/lo"
) )
// OCPP charger implementation // OCPP charger implementation
@ -138,7 +137,7 @@ func NewOCPP(ctx context.Context,
forcePowerCtrl, stackLevelZero, profileKindRelative, remoteStart, noChangeAvailability bool, forcePowerCtrl, stackLevelZero, profileKindRelative, remoteStart, noChangeAvailability bool,
connectTimeout time.Duration, connectTimeout time.Duration,
) (*OCPP, error) { ) (*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, cp, err := ocpp.Instance().RegisterChargepoint(id,
func() *ocpp.CP { func() *ocpp.CP {
@ -167,7 +166,7 @@ func NewOCPP(ctx context.Context,
} }
if remoteStart { 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) conn, err := ocpp.NewConnector(ctx, log, connector, cp, idTag, meterInterval)

View file

@ -78,19 +78,19 @@ type batteryTotals struct {
discharge float64 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 { func metricsBatteryTotals(series []metrics.Series) map[string]batteryTotals {
res := make(map[string]batteryTotals) res := make(map[string]batteryTotals)
for _, s := range series { for _, s := range series {
if s.Group != metrics.Battery { if s.Group != metrics.Battery {
continue continue
} }
t := res[s.Name] t := res[s.Title]
for _, slot := range s.Data { for _, slot := range s.Data {
t.charge += slot.Energy t.charge += slot.Energy
t.discharge += slot.ReturnEnergy t.discharge += slot.ReturnEnergy
} }
res[s.Name] = t res[s.Title] = t
} }
return res return res
} }
@ -103,7 +103,7 @@ func metricsWriteBatteryTable(w io.Writer, selected []metrics.EntityInfo, totals
fmt.Fprintln(tw, "name\ttitle\tcharge\tdischarge\tefficiency") fmt.Fprintln(tw, "name\ttitle\tcharge\tdischarge\tefficiency")
for _, e := range selected { for _, e := range selected {
t := totals[e.Name] t := totals[title(e.Group, e.Name)]
efficiency := "" efficiency := ""
if t.charge > 0 { if t.charge > 0 {

View file

@ -11,12 +11,12 @@ import (
func TestMetricsBatteryTotals(t *testing.T) { func TestMetricsBatteryTotals(t *testing.T) {
series := []metrics.Series{ 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: 1.0, ReturnEnergy: 0.4},
{Energy: 2.0, ReturnEnergy: 1.6}, {Energy: 2.0, ReturnEnergy: 1.6},
}}, }},
// non-battery series must be ignored // 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}, {Energy: 5.0, ReturnEnergy: 3.0},
}}, }},
} }
@ -33,7 +33,7 @@ func TestMetricsWriteBatteryTable(t *testing.T) {
{Group: metrics.Battery, Name: "bat2"}, {Group: metrics.Battery, Name: "bat2"},
} }
totals := map[string]batteryTotals{ 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 // bat2 deliberately absent: no data in the timeframe
} }
title := func(group, name string) string { title := func(group, name string) string {

View file

@ -72,13 +72,19 @@ func runMetricsData(cmd *cobra.Command, args []string) {
byEntity := make(map[string]metrics.Series, len(series)) byEntity := make(map[string]metrics.Series, len(series))
for _, s := range series { for _, s := range series {
byEntity[s.Group+"/"+s.Name] = s byEntity[s.Group+"/"+s.Title] = s
} }
if asCSV, _ := cmd.Flags().GetBool("csv"); asCSV { if asCSV, _ := cmd.Flags().GetBool("csv"); asCSV {
var out metrics.SeriesCSV var out metrics.SeriesCSV
seen := make(map[string]bool, len(selected))
for _, e := range 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) out = append(out, s)
} }
} }
@ -236,7 +242,7 @@ func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[
} }
for _, spec := range specs { 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 { if !ok {
continue continue
} }

View file

@ -93,10 +93,10 @@ func TestMetricsWriteTable(t *testing.T) {
{Group: metrics.Grid, Name: "grid"}, {Group: metrics.Grid, Name: "grid"},
} }
byEntity := map[string]metrics.Series{ 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}, {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: h0, Energy: 0.412},
{Start: h1, Energy: 0.38, ReturnEnergy: 0.05}, {Start: h1, Energy: 0.38, ReturnEnergy: 0.05},
}}, }},
@ -105,6 +105,9 @@ func TestMetricsWriteTable(t *testing.T) {
if group == metrics.Loadpoint { if group == metrics.Loadpoint {
return "Carport" return "Carport"
} }
if group == metrics.Grid {
return "grid"
}
return "" return ""
} }

View file

@ -11,18 +11,18 @@ import (
func TestMetricsForecastTotals(t *testing.T) { func TestMetricsForecastTotals(t *testing.T) {
series := []metrics.Series{ 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}, {Energy: 10.0}, {Energy: 5.0},
}}, }},
// actual PV production is summed across all pv entities // 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}, {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}, {Energy: 2.0},
}}, }},
// other groups are ignored // other groups are ignored
{Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{ {Group: metrics.Grid, Title: "grid", Data: []metrics.Slot{
{Energy: 99.0}, {Energy: 99.0},
}}, }},
} }

View file

@ -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) { 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) 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 { if err != nil {
return nil, err 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 { func configureLoadpoints(conf globalconfig.All) error {

View file

@ -1,6 +1,7 @@
package core package core
import ( import (
"cmp"
"fmt" "fmt"
"slices" "slices"
@ -64,12 +65,7 @@ func deviceProperties[T any](dev config.Device[T]) config.Properties {
// deviceTitleOrName returns device title or name // deviceTitleOrName returns device title or name
func deviceTitleOrName[T any](dev config.Device[T]) string { func deviceTitleOrName[T any](dev config.Device[T]) string {
if d, ok := dev.(config.ConfigurableDevice[T]); ok { return cmp.Or(deviceProperties(dev).Title, dev.Config().Name)
if title := d.Properties().Title; title != "" {
return title
}
}
return dev.Config().Name
} }
// circuitMaxPower returns a circuits power limit // circuitMaxPower returns a circuits power limit

View file

@ -24,8 +24,8 @@ type Collector struct {
started time.Time started time.Time
} }
func NewCollector(group, name string, opt ...func(*Accumulator)) (*Collector, error) { func NewCollector(group, name, title string, opt ...func(*Accumulator)) (*Collector, error) {
entity, err := createEntity(group, name) entity, err := createEntity(group, name, title)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -38,17 +38,30 @@ func NewCollector(group, name string, opt ...func(*Accumulator)) (*Collector, er
return c, nil return c, nil
} }
func createEntity(group, name string) (entity, error) { // createEntity ensures the entity row exists and refreshes its title.
entity := entity{ func createEntity(group, name, title string) (entity, error) {
Group: group, e := entity{Group: group, Name: name}
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 e, e.updateTitle(title)
return entity, err }
// 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 { func (c *Collector) process(fun func()) error {

View file

@ -15,7 +15,7 @@ func TestCollectorAddEnergy(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema()) require.NoError(t, SetupSchema())
col, err := NewCollector("foo", "foo", WithClock(clock)) col, err := NewCollector("foo", "foo", "", WithClock(clock))
require.NoError(t, err) require.NoError(t, err)
require.True(t, col.accu.updated.IsZero()) 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, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema()) require.NoError(t, SetupSchema())
col, err := NewCollector("bar", "bar", WithClock(clock)) col, err := NewCollector("bar", "bar", "", WithClock(clock))
require.NoError(t, err) require.NoError(t, err)
// first call: seeds meter, no delta yet // 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, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema()) require.NoError(t, SetupSchema())
col, err := NewCollector("baz", "baz", WithClock(clock)) col, err := NewCollector("baz", "baz", "", WithClock(clock))
require.NoError(t, err) require.NoError(t, err)
// seed import meter // seed import meter
@ -93,7 +93,7 @@ func TestCollectorAddEnergyWithExportMeterAndImport(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema()) require.NoError(t, SetupSchema())
col, err := NewCollector("baz2", "baz2", WithClock(clock)) col, err := NewCollector("baz2", "baz2", "", WithClock(clock))
require.NoError(t, err) require.NoError(t, err)
// seed export meter // seed export meter
@ -119,7 +119,7 @@ func TestCollectorAddEnergyWithBothMeters(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema()) require.NoError(t, SetupSchema())
col, err := NewCollector("qux", "qux", WithClock(clock)) col, err := NewCollector("qux", "qux", "", WithClock(clock))
require.NoError(t, err) require.NoError(t, err)
// seed both meters // seed both meters
@ -139,7 +139,7 @@ func TestCollectorSetImportAndExportMeterTotal(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema()) require.NoError(t, SetupSchema())
col, err := NewCollector("set", "set", WithClock(clock)) col, err := NewCollector("set", "set", "", WithClock(clock))
require.NoError(t, err) require.NoError(t, err)
// seed both import and export // seed both import and export
@ -163,7 +163,7 @@ func TestCollectorSkipsPartialFirstSlot(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema()) require.NoError(t, SetupSchema())
col, err := NewCollector("partial", "partial", WithClock(clk)) col, err := NewCollector("partial", "partial", "", WithClock(clk))
require.NoError(t, err) require.NoError(t, err)
// first update mid-slot (00:05) - slot 00:00 is only partially covered // 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.NoError(t, db.Instance.First(&m).Error)
require.Equal(t, int64(15*60), m.Timestamp, "persisted slot should start at 00:15") 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")
}

View file

@ -23,6 +23,7 @@ type entity struct {
Id int `gorm:"column:id;primarykey"` Id int `gorm:"column:id;primarykey"`
Group string `gorm:"column:group;uniqueIndex:entities_group_name"` Group string `gorm:"column:group;uniqueIndex:entities_group_name"`
Name string `gorm:"column:name;uniqueIndex:entities_group_name"` Name string `gorm:"column:name;uniqueIndex:entities_group_name"`
Title string `gorm:"column:title"`
} }
func init() { func init() {
@ -41,7 +42,7 @@ func SetupSchema() error {
} }
// ensure home entity exists (reserves id=1 for legacy meter FK references) // 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 return err
} }

View file

@ -11,6 +11,7 @@ import (
type EntityInfo struct { type EntityInfo struct {
Group string Group string
Name string Name string
Title string // human-readable title as captured at the last lazy-create
Slots int // number of persisted 15min slots Slots int // number of persisted 15min slots
First time.Time // start of the earliest slot, zero if no data First time.Time // start of the earliest slot, zero if no data
Last time.Time // start of the latest 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 { type row struct {
Group string Group string
Name string Name string
Title string
Slots int Slots int
First sql.NullInt64 First sql.NullInt64
Last sql.NullInt64 Last sql.NullInt64
@ -30,7 +32,7 @@ func ListEntities() ([]EntityInfo, error) {
var rows []row var rows []row
if err := db.Instance.Table("entities e"). 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, COUNT(m.ts) AS slots,
MIN(m.ts) AS first, MAX(m.ts) AS last`). MIN(m.ts) AS first, MAX(m.ts) AS last`).
Joins("LEFT JOIN meters m ON m.meter = e.id"). Joins("LEFT JOIN meters m ON m.meter = e.id").
@ -41,7 +43,7 @@ func ListEntities() ([]EntityInfo, error) {
res := make([]EntityInfo, 0, len(rows)) res := make([]EntityInfo, 0, len(rows))
for _, r := range 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 { if r.First.Valid {
e.First = time.Unix(r.First.Int64, 0) e.First = time.Unix(r.First.Int64, 0)
} }

View file

@ -27,9 +27,9 @@ func roundEnergy(v float64) float64 {
return max(0, math.Round(v*1000)/1000) 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 { type Series struct {
Name string `json:"name,omitempty"` Title string `json:"title,omitempty"`
Group string `json:"group"` Group string `json:"group"`
Data []Slot `json:"data"` 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) }, "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) { func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, error) {
addDuration := aggregateDurations[aggregate] 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") return nil, errors.New("invalid aggregate value")
} }
groupCols := `e."group", ` + fmt.Sprintf(`strftime('%s', m.ts, 'unixepoch', 'localtime')`, format) titleExpr := `COALESCE(NULLIF(e.title,''), e.name)`
if !grouped { timeCol := fmt.Sprintf(`strftime('%s', m.ts, 'unixepoch', 'localtime')`, format)
groupCols = "e.name, " + groupCols
selectTitle := titleExpr + ` AS title`
groupCols := titleExpr + `, e."group", ` + timeCol
if grouped {
selectTitle = `'' AS title`
groupCols = `e."group", ` + timeCol
} }
type row struct { type row struct {
Name string Title string
Group string Group string
Start SqlTime Start SqlTime
Energy float64 Energy float64
@ -78,7 +83,7 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series,
} }
tx := db.Instance.Table("meters m"). tx := db.Instance.Table("meters m").
Select(`e.name, e."group", Select(selectTitle + `, e."group",
MIN(m.ts) AS start, MIN(m.ts) AS start,
COALESCE(SUM(m.energy), 0) AS energy, COALESCE(SUM(m.energy), 0) AS energy,
COALESCE(SUM(m.return_energy), 0) AS return_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 var res []Series
for _, r := range rows { for _, r := range rows {
name := r.Name if n := len(res); n == 0 || res[n-1].Title != r.Title || res[n-1].Group != r.Group {
if grouped { res = append(res, Series{Title: r.Title, Group: r.Group})
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})
} }
s := &res[len(res)-1] 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) tsSet := make(map[int64]time.Time)
endByStart := 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 { for _, g := range groups {
entities := byGroup[g] 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 { for _, e := range entities {
name := e.Name p := prefix(g, label(e, g))
if name == "" { header = append(header, p+".energy.Wh")
name = g
}
header = append(header, g+"."+name+".energy.Wh")
cols = append(cols, col{series: e, returnEnergy: false}) cols = append(cols, col{series: e, returnEnergy: false})
if hasReturnEnergy(g) { if hasReturnEnergy(g) {
header = append(header, g+"."+name+".returnEnergy.Wh") header = append(header, p+".returnEnergy.Wh")
cols = append(cols, col{series: e, returnEnergy: true}) cols = append(cols, col{series: e, returnEnergy: true})
} }
for _, slot := range e.Data { for _, slot := range e.Data {

View file

@ -21,11 +21,11 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) {
t1 := t0.Add(15 * time.Minute) t1 := t0.Add(15 * time.Minute)
series := SeriesCSV{ series := SeriesCSV{
{Group: PV, Name: "pv", Data: []Slot{mkSlot(t0, 2.5, 0), mkSlot(t1, 3.0, 0)}}, {Group: PV, Title: "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, Title: "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: Battery, Title: "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: Grid, Title: "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: Home, Title: "home", Data: []Slot{mkSlot(t0, 0.3661, 0), mkSlot(t1, 0.4, 0)}},
} }
var buf bytes.Buffer var buf bytes.Buffer
@ -42,13 +42,13 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) {
header := rows[0] header := rows[0]
expected := []string{ expected := []string{
"time.start", "time.end", "time.start", "time.end",
"pv.pv.energy.Wh", "pv.energy.Wh",
"battery.battery-garage.energy.Wh", "battery.battery-garage.returnEnergy.Wh", "battery.battery-garage.energy.Wh", "battery.battery-garage.returnEnergy.Wh",
"battery.battery-home.energy.Wh", "battery.battery-home.returnEnergy.Wh", "battery.battery-home.energy.Wh", "battery.battery-home.returnEnergy.Wh",
"grid.grid.energy.Wh", "grid.grid.returnEnergy.Wh", "grid.energy.Wh", "grid.returnEnergy.Wh",
"home.home.energy.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) // 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]) 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) { func TestSeriesCSV_GermanLocale(t *testing.T) {
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
series := SeriesCSV{ 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 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 // Two entities, second one only has data for the first timestamp → second
// timestamp must produce an empty cell rather than 0.000. // timestamp must produce an empty cell rather than 0.000.
series := SeriesCSV{ series := SeriesCSV{
{Group: PV, Name: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}}, {Group: PV, Title: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}},
{Group: PV, Name: "b", Data: []Slot{mkSlot(t0, 3, 0)}}, {Group: PV, Title: "b", Data: []Slot{mkSlot(t0, 3, 0)}},
} }
var buf bytes.Buffer var buf bytes.Buffer
@ -119,7 +119,7 @@ func TestSeriesCSV_BatteryHasReturnEnergyColumn(t *testing.T) {
// returnEnergy column. // returnEnergy column.
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
series := SeriesCSV{ 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 var buf bytes.Buffer

View file

@ -101,7 +101,7 @@ func TestQueryEnergyGrouped(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Len(t, res, 1) require.Len(t, res, 1)
require.Equal(t, Grid, res[0].Group) 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.Len(t, res[0].Data, 2)
require.InDelta(t, 1+2, res[0].Data[0].Energy, 0.001) require.InDelta(t, 1+2, res[0].Data[0].Energy, 0.001)
require.InDelta(t, 3+4, res[0].Data[1].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.NoError(t, err)
require.Len(t, res, 3) require.Len(t, res, 3)
byName := map[string]Series{} byTitle := map[string]Series{}
for _, s := range res { for _, s := range res {
require.Len(t, s.Data, 2) require.Len(t, s.Data, 2)
byName[s.Name] = s byTitle[s.Title] = s
} }
require.Equal(t, Grid, byName[Grid].Group) require.Equal(t, Grid, byTitle[Grid].Group)
require.Equal(t, PV, byName["pv1"].Group) require.Equal(t, PV, byTitle["pv1"].Group)
require.Equal(t, PV, byName["pv2"].Group) require.Equal(t, PV, byTitle["pv2"].Group)
require.InDelta(t, 1, byName[Grid].Data[0].Energy, 0.001) require.InDelta(t, 1, byTitle[Grid].Data[0].Energy, 0.001)
require.InDelta(t, 2, byName[Grid].Data[1].Energy, 0.001) require.InDelta(t, 2, byTitle[Grid].Data[1].Energy, 0.001)
require.InDelta(t, 10, byName["pv1"].Data[0].ReturnEnergy, 0.001) require.InDelta(t, 10, byTitle["pv1"].Data[0].ReturnEnergy, 0.001)
require.InDelta(t, 21, byName["pv2"].Data[1].ReturnEnergy, 0.001) require.InDelta(t, 21, byTitle["pv2"].Data[1].ReturnEnergy, 0.001)
// grouped: 2 series, pv summed per bucket // grouped: 2 series, pv summed per bucket
res, err = QueryEnergy(from, to, "hour", true) res, err = QueryEnergy(from, to, "hour", true)
@ -159,7 +159,7 @@ func TestQueryEnergyMultipleSeries(t *testing.T) {
byGroup := map[string]Series{} byGroup := map[string]Series{}
for _, s := range res { for _, s := range res {
require.Empty(t, s.Name) require.Empty(t, s.Title)
require.Len(t, s.Data, 2) require.Len(t, s.Data, 2)
byGroup[s.Group] = s byGroup[s.Group] = s
} }

View file

@ -136,7 +136,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.prioritizer = prioritizer.New(log) site.prioritizer = prioritizer.New(log)
site.stats = NewStats() site.stats = NewStats()
me, err := metrics.NewCollector(metrics.Home, metrics.Home) me, err := metrics.NewCollector(metrics.Home, metrics.Home, metrics.Home)
if err != nil { if err != nil {
return err return err
} }
@ -188,7 +188,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
return errors.New("missing grid meter instance") 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 { if err != nil {
return err return err
} }
@ -204,7 +204,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.pvMeters = append(site.pvMeters, dev) site.pvMeters = append(site.pvMeters, dev)
// energy collector (for history persistence and forecast scaling) // 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 { if err != nil {
return err 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) // 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 { if err != nil {
return err return err
} }
@ -226,7 +226,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
} }
site.batteryMeters = append(site.batteryMeters, dev) 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 { if err != nil {
return err return err
} }
@ -241,7 +241,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
} }
site.extMeters = append(site.extMeters, dev) 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 { if err != nil {
return err return err
} }
@ -256,7 +256,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
} }
site.auxMeters = append(site.auxMeters, dev) 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 { if err != nil {
return err return err
} }

View file

@ -1,6 +1,7 @@
package core package core
import ( import (
"cmp"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@ -121,7 +122,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
minLen = min(minLen, len(solar)) 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 { if uri == OPTIMIZER_URI {
// limit to 2 days for sake of performance // limit to 2 days for sake of performance
minLen = min(2*96, minLen) minLen = min(2*96, minLen)

View file

@ -23,8 +23,8 @@ test.describe("api", () => {
const data = await res.json(); const data = await res.json();
expect(data).toHaveLength(2); expect(data).toHaveLength(2);
const grid = data.find((s: { name: string }) => s.name === "grid"); const grid = data.find((s: { title: string }) => s.title === "grid");
const home = data.find((s: { name: string }) => s.name === "home"); const home = data.find((s: { title: string }) => s.title === "home");
expect(grid).toBeDefined(); expect(grid).toBeDefined();
expect(home).toBeDefined(); expect(home).toBeDefined();
@ -46,8 +46,8 @@ test.describe("api", () => {
const data = await res.json(); const data = await res.json();
expect(data).toHaveLength(2); expect(data).toHaveLength(2);
const grid = data.find((s: { name: string }) => s.name === "grid"); const grid = data.find((s: { title: string }) => s.title === "grid");
const home = data.find((s: { name: string }) => s.name === "home"); const home = data.find((s: { title: string }) => s.title === "home");
expect(grid).toBeDefined(); expect(grid).toBeDefined();
expect(home).toBeDefined(); expect(home).toBeDefined();