diff --git a/cmd/metrics.go b/cmd/metrics.go index 4b6172aeb..d33f1c886 100644 --- a/cmd/metrics.go +++ b/cmd/metrics.go @@ -2,13 +2,10 @@ package cmd import ( "slices" - "strconv" "strings" "time" "github.com/evcc-io/evcc/core/metrics" - "github.com/evcc-io/evcc/util/config" - "github.com/evcc-io/evcc/util/templates" "github.com/spf13/cobra" ) @@ -60,54 +57,11 @@ func metricsFormatDate(t time.Time) string { return t.Local().Format("2006-01-02") } -// metricsEntityTitle resolves human-readable titles for metric entities. Titles -// exist only for configured loadpoints and meters; virtual entities (home, -// forecast) have none. The returned function maps an entity to its title, or an -// empty string when no title is configured. -func metricsEntityTitle() func(group, name string) string { - // loadpoints are addressed as lp-, numbered yaml-first then database, - // mirroring configureLoadpoints - loadpoints := make(map[string]string) - idx := 0 - addLoadpoint := func(n config.Named) { - idx++ - if t, ok := n.Property("title").(string); ok && t != "" { - loadpoints["lp-"+strconv.Itoa(idx)] = t - } - } - for _, lp := range conf.Loadpoints { - addLoadpoint(lp) - } - if devices, err := config.ConfigurationsByClass(templates.Loadpoint); err == nil { - for _, dev := range devices { - addLoadpoint(dev.Named()) - } - } - - // meter entities are addressed by their device ref; the title comes from the - // device configuration - meters := make(map[string]string) - for _, m := range conf.Meters { - if t, ok := m.Property("title").(string); ok && t != "" { - meters[m.Name] = t - } - } - if devices, err := config.ConfigurationsByClass(templates.Meter); err == nil { - for _, dev := range devices { - if dev.Title != "" { - meters[config.NameForID(dev.ID)] = dev.Title - } - } - } - - return func(group, name string) string { - switch group { - case metrics.Loadpoint: - return loadpoints[name] - case metrics.Grid, metrics.PV, metrics.Battery, metrics.Meter: - return meters[name] - default: - return "" - } +// metricsEntityLabel returns the display label for an entity: the title stored +// in the entities table (the single source of truth), or the name as fallback. +func metricsEntityLabel(e metrics.EntityInfo) string { + if e.Title != "" { + return e.Title } + return e.Name } diff --git a/cmd/metrics_battery.go b/cmd/metrics_battery.go index 351c2b31e..88eb77b52 100644 --- a/cmd/metrics_battery.go +++ b/cmd/metrics_battery.go @@ -54,9 +54,7 @@ func runMetricsBattery(cmd *cobra.Command, args []string) { log.FATAL.Fatal("no battery entities found") } - title := metricsEntityTitle() - - selected, err := metricsSelectEntities(batteries, args, "", title) + selected, err := metricsSelectEntities(batteries, args, "") if err != nil { log.FATAL.Fatal(err) } @@ -66,7 +64,7 @@ func runMetricsBattery(cmd *cobra.Command, args []string) { log.FATAL.Fatal(err) } - metricsWriteBatteryTable(os.Stdout, selected, metricsBatteryTotals(series), title) + metricsWriteBatteryTable(os.Stdout, selected, metricsBatteryTotals(series)) fmt.Fprintln(os.Stderr, "\nvalues in kWh") } @@ -98,12 +96,12 @@ func metricsBatteryTotals(series []metrics.Series) map[string]batteryTotals { // metricsWriteBatteryTable renders one row per battery comparing charge and // discharge energy. Efficiency is the discharge/charge ratio, left blank when // no energy was charged. -func metricsWriteBatteryTable(w io.Writer, selected []metrics.EntityInfo, totals map[string]batteryTotals, title func(group, name string) string) { +func metricsWriteBatteryTable(w io.Writer, selected []metrics.EntityInfo, totals map[string]batteryTotals) { tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "name\ttitle\tcharge\tdischarge\tefficiency") for _, e := range selected { - t := totals[title(e.Group, e.Name)] + t := totals[metricsEntityLabel(e)] efficiency := "" if t.charge > 0 { @@ -111,7 +109,7 @@ func metricsWriteBatteryTable(w io.Writer, selected []metrics.EntityInfo, totals } fmt.Fprintf(tw, "%s\t%s\t%.3f\t%.3f\t%s\n", - e.Name, title(e.Group, e.Name), t.charge, t.discharge, efficiency) + e.Name, metricsEntityLabel(e), t.charge, t.discharge, efficiency) } tw.Flush() diff --git a/cmd/metrics_battery_test.go b/cmd/metrics_battery_test.go index 29f6080f3..27d2fd0f1 100644 --- a/cmd/metrics_battery_test.go +++ b/cmd/metrics_battery_test.go @@ -29,36 +29,40 @@ func TestMetricsBatteryTotals(t *testing.T) { func TestMetricsWriteBatteryTable(t *testing.T) { selected := []metrics.EntityInfo{ - {Group: metrics.Battery, Name: "bat1"}, - {Group: metrics.Battery, Name: "bat2"}, + {Group: metrics.Battery, Name: "db:1", Title: "Home"}, + {Group: metrics.Battery, Name: "db:2", Title: "Hyper2000"}, // removed device: stored db title remains + {Group: metrics.Battery, Name: "db:3"}, } totals := map[string]batteryTotals{ - "Home": {charge: 10.0, discharge: 9.0}, - // bat2 deliberately absent: no data in the timeframe - } - title := func(group, name string) string { - if name == "bat1" { - return "Home" - } - return "" + "Home": {charge: 10.0, discharge: 9.0}, + "Hyper2000": {charge: 4.0, discharge: 3.0}, + // db:3 deliberately absent: no data in the timeframe } var buf bytes.Buffer - metricsWriteBatteryTable(&buf, selected, totals, title) + metricsWriteBatteryTable(&buf, selected, totals) lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") - require.Len(t, lines, 3) // header + 2 rows + require.Len(t, lines, 4) // header + 3 rows require.Contains(t, lines[0], "efficiency") - // bat1: title resolved, efficiency = discharge/charge + // db:1: stored title, efficiency = discharge/charge require.Contains(t, lines[1], "Home") require.Contains(t, lines[1], "10.000") require.Contains(t, lines[1], "9.000") require.Contains(t, lines[1], "90.0%") - // bat2: no data -> zero totals, blank efficiency - require.Contains(t, lines[2], "bat2") - require.Contains(t, lines[2], "0.000") - require.NotContains(t, lines[2], "%") + // db:2: removed device still joins via its stored title; label is that title + require.Contains(t, lines[2], "Hyper2000") + require.Contains(t, lines[2], "4.000") + require.Contains(t, lines[2], "3.000") + require.Contains(t, lines[2], "75.0%") + + // db:3: no title anywhere -> label falls back to the name; no data -> blank efficiency + f := strings.Fields(lines[3]) + require.Equal(t, "db:3", f[0]) // name column + require.Equal(t, "db:3", f[1]) // title column falls back to name + require.Contains(t, lines[3], "0.000") + require.NotContains(t, lines[3], "%") } diff --git a/cmd/metrics_data.go b/cmd/metrics_data.go index b0072e6d1..e1429ab4d 100644 --- a/cmd/metrics_data.go +++ b/cmd/metrics_data.go @@ -58,9 +58,7 @@ func runMetricsData(cmd *cobra.Command, args []string) { log.FATAL.Fatal(err) } - title := metricsEntityTitle() - - selected, err := metricsSelectEntities(entities, args, group, title) + selected, err := metricsSelectEntities(entities, args, group) if err != nil { log.FATAL.Fatal(err) } @@ -79,7 +77,7 @@ func runMetricsData(cmd *cobra.Command, args []string) { var out metrics.SeriesCSV seen := make(map[string]bool, len(selected)) for _, e := range selected { - key := e.Group + "/" + title(e.Group, e.Name) + key := e.Group + "/" + metricsEntityLabel(e) if seen[key] { continue } @@ -94,7 +92,7 @@ func runMetricsData(cmd *cobra.Command, args []string) { return } - metricsWriteTable(os.Stdout, selected, byEntity, title, aggregate) + metricsWriteTable(os.Stdout, selected, byEntity, aggregate) fmt.Fprintln(os.Stderr, "\nvalues in kWh") } @@ -151,7 +149,7 @@ func metricsTimeframe(rangeStr, fromStr, toStr string) (time.Time, time.Time, er // metricsSelectEntities resolves the entities to export. Without selectors all // entities (optionally limited to a group) are returned in canonical order; // explicit selectors match by name or title and preserve the requested order. -func metricsSelectEntities(entities []metrics.EntityInfo, args []string, group string, title func(group, name string) string) ([]metrics.EntityInfo, error) { +func metricsSelectEntities(entities []metrics.EntityInfo, args []string, group string) ([]metrics.EntityInfo, error) { if len(args) == 0 { res := make([]metrics.EntityInfo, 0, len(entities)) for _, e := range entities { @@ -170,7 +168,7 @@ func metricsSelectEntities(entities []metrics.EntityInfo, args []string, group s for _, arg := range args { var matched []metrics.EntityInfo for _, e := range entities { - if e.Name == arg || title(e.Group, e.Name) == arg { + if e.Name == arg || e.Title == arg { matched = append(matched, e) } } @@ -197,7 +195,7 @@ func metricsTimeLayout(aggregate string) string { // metricsWriteTable renders the wide energy table: one row per time slot, one // column per entity, plus a second column for the export energy of // bidirectional entities (grid, battery). -func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[string]metrics.Series, title func(group, name string) string, aggregate string) { +func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[string]metrics.Series, aggregate string) { layout := metricsTimeLayout(aggregate) type colSpec struct { @@ -210,10 +208,7 @@ func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[ var specs []colSpec for _, e := range selected { - label := title(e.Group, e.Name) - if label == "" { - label = e.Name - } + label := metricsEntityLabel(e) spec := colSpec{entity: e, energyCol: len(header) - 1, returnCol: -1} header = append(header, label) @@ -242,7 +237,7 @@ func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[ } for _, spec := range specs { - s, ok := byEntity[spec.entity.Group+"/"+title(spec.entity.Group, spec.entity.Name)] + s, ok := byEntity[spec.entity.Group+"/"+metricsEntityLabel(spec.entity)] if !ok { continue } diff --git a/cmd/metrics_data_test.go b/cmd/metrics_data_test.go index 836cb2239..4fc6fb44c 100644 --- a/cmd/metrics_data_test.go +++ b/cmd/metrics_data_test.go @@ -56,31 +56,25 @@ func TestMetricsSelectEntities(t *testing.T) { entities := []metrics.EntityInfo{ {Group: metrics.Grid, Name: "grid"}, {Group: metrics.PV, Name: "pv1"}, - {Group: metrics.Loadpoint, Name: "lp-1"}, - } - title := func(group, name string) string { - if group == metrics.Loadpoint && name == "lp-1" { - return "Carport" - } - return "" + {Group: metrics.Loadpoint, Name: "lp-1", Title: "Carport"}, } - // explicit selectors match by name or title and preserve argument order - res, err := metricsSelectEntities(entities, []string{"Carport", "grid"}, "", title) + // explicit selectors match by name or stored title and preserve argument order + res, err := metricsSelectEntities(entities, []string{"Carport", "grid"}, "") require.NoError(t, err) require.Equal(t, []string{"lp-1", "grid"}, []string{res[0].Name, res[1].Name}) // unknown selector errors - _, err = metricsSelectEntities(entities, []string{"bogus"}, "", title) + _, err = metricsSelectEntities(entities, []string{"bogus"}, "") require.Error(t, err) // no selectors: all entities in canonical group order (pv before grid) - res, err = metricsSelectEntities(entities, nil, "", title) + res, err = metricsSelectEntities(entities, nil, "") require.NoError(t, err) require.Equal(t, metrics.PV, res[0].Group) // empty group errors - _, err = metricsSelectEntities(entities, nil, metrics.Battery, title) + _, err = metricsSelectEntities(entities, nil, metrics.Battery) require.Error(t, err) } @@ -89,7 +83,7 @@ func TestMetricsWriteTable(t *testing.T) { h1 := h0.Add(time.Hour) selected := []metrics.EntityInfo{ - {Group: metrics.Loadpoint, Name: "lp-1"}, + {Group: metrics.Loadpoint, Name: "lp-1", Title: "Carport"}, {Group: metrics.Grid, Name: "grid"}, } byEntity := map[string]metrics.Series{ @@ -101,18 +95,9 @@ func TestMetricsWriteTable(t *testing.T) { {Start: h1, Energy: 0.38, ReturnEnergy: 0.05}, }}, } - title := func(group, name string) string { - if group == metrics.Loadpoint { - return "Carport" - } - if group == metrics.Grid { - return "grid" - } - return "" - } var buf bytes.Buffer - metricsWriteTable(&buf, selected, byEntity, title, "hour") + metricsWriteTable(&buf, selected, byEntity, "hour") lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") require.Len(t, lines, 3) // header + 2 rows diff --git a/cmd/metrics_entities.go b/cmd/metrics_entities.go index 13f06b350..2797e7e24 100644 --- a/cmd/metrics_entities.go +++ b/cmd/metrics_entities.go @@ -30,14 +30,13 @@ func runMetricsEntities(cmd *cobra.Command, args []string) { } metricsSortCanonical(entities) - title := metricsEntityTitle() tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "group\tname\ttitle\tslots\tfirst\tlast") for _, e := range entities { fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\t%s\n", - e.Group, e.Name, title(e.Group, e.Name), e.Slots, + e.Group, e.Name, e.Title, e.Slots, metricsFormatDate(e.First), metricsFormatDate(e.Last)) }