266 lines
7.3 KiB
Go
266 lines
7.3 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/evcc-io/evcc/core/metrics"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// metricsDataCmd represents the metrics data command
|
|
var metricsDataCmd = &cobra.Command{
|
|
Use: "data [entity ...]",
|
|
Short: "Export energy data as a table",
|
|
Long: `Export aggregated energy data as a table.
|
|
|
|
Without arguments all entities are exported for the current day. Entities can be
|
|
selected by name or title; run the entities subcommand to list them.`,
|
|
Run: runMetricsData,
|
|
}
|
|
|
|
func init() {
|
|
metricsCmd.AddCommand(metricsDataCmd)
|
|
metricsDataCmd.Flags().String("range", "", "Quick timeframe: day, month or year")
|
|
metricsDataCmd.Flags().String("from", "", "Start date as YYYY-MM-DD (default today)")
|
|
metricsDataCmd.Flags().String("to", "", "End date as YYYY-MM-DD, inclusive (default today)")
|
|
metricsDataCmd.Flags().String("aggregate", "hour", "Aggregation interval: 15m, hour, day or month")
|
|
metricsDataCmd.Flags().String("group", "", "Limit output to an entity group")
|
|
metricsDataCmd.Flags().Bool("csv", false, "Output CSV instead of a table")
|
|
metricsDataCmd.MarkFlagsMutuallyExclusive("range", "from")
|
|
metricsDataCmd.MarkFlagsMutuallyExclusive("range", "to")
|
|
}
|
|
|
|
func runMetricsData(cmd *cobra.Command, args []string) {
|
|
setupMetrics(cmd)
|
|
|
|
group := cmd.Flag("group").Value.String()
|
|
if group != "" && len(args) > 0 {
|
|
log.FATAL.Fatal("--group and entity arguments are mutually exclusive")
|
|
}
|
|
|
|
from, to, err := metricsTimeframe(cmd.Flag("range").Value.String(), cmd.Flag("from").Value.String(), cmd.Flag("to").Value.String())
|
|
if err != nil {
|
|
log.FATAL.Fatal(err)
|
|
}
|
|
|
|
aggregate := cmd.Flag("aggregate").Value.String()
|
|
|
|
entities, err := metrics.ListEntities()
|
|
if err != nil {
|
|
log.FATAL.Fatal(err)
|
|
}
|
|
|
|
title := metricsEntityTitle()
|
|
|
|
selected, err := metricsSelectEntities(entities, args, group, title)
|
|
if err != nil {
|
|
log.FATAL.Fatal(err)
|
|
}
|
|
|
|
series, err := metrics.QueryEnergy(from, to, aggregate, false)
|
|
if err != nil {
|
|
log.FATAL.Fatal(err)
|
|
}
|
|
|
|
byEntity := make(map[string]metrics.Series, len(series))
|
|
for _, s := range series {
|
|
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 {
|
|
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)
|
|
}
|
|
}
|
|
if err := out.WriteCsv(context.Background(), os.Stdout); err != nil {
|
|
log.FATAL.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
|
|
metricsWriteTable(os.Stdout, selected, byEntity, title, aggregate)
|
|
fmt.Fprintln(os.Stderr, "\nvalues in kWh")
|
|
}
|
|
|
|
// metricsTimeframe resolves the from/to query bounds. A non-empty range string
|
|
// (today, month, year) takes precedence and is mutually exclusive with the
|
|
// from/to date flags. The to date is inclusive; an empty timeframe defaults to
|
|
// the current day.
|
|
func metricsTimeframe(rangeStr, fromStr, toStr string) (time.Time, time.Time, error) {
|
|
const layout = "2006-01-02"
|
|
|
|
now := time.Now()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
|
|
|
if rangeStr != "" {
|
|
switch strings.ToLower(rangeStr) {
|
|
case "day":
|
|
return today, today.AddDate(0, 0, 1), nil
|
|
case "month":
|
|
from := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
|
|
return from, from.AddDate(0, 1, 0), nil
|
|
case "year":
|
|
from := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.Local)
|
|
return from, from.AddDate(1, 0, 0), nil
|
|
default:
|
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid --range %q (day, month or year)", rangeStr)
|
|
}
|
|
}
|
|
|
|
from := today
|
|
if fromStr != "" {
|
|
t, err := time.ParseInLocation(layout, fromStr, time.Local)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid --from date: %w", err)
|
|
}
|
|
from = t
|
|
}
|
|
|
|
to := today.AddDate(0, 0, 1)
|
|
if toStr != "" {
|
|
t, err := time.ParseInLocation(layout, toStr, time.Local)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid --to date: %w", err)
|
|
}
|
|
to = t.AddDate(0, 0, 1) // inclusive end day
|
|
}
|
|
|
|
if to.Before(from) {
|
|
return time.Time{}, time.Time{}, errors.New("--to must not be before --from")
|
|
}
|
|
|
|
return from, to, nil
|
|
}
|
|
|
|
// 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) {
|
|
if len(args) == 0 {
|
|
res := make([]metrics.EntityInfo, 0, len(entities))
|
|
for _, e := range entities {
|
|
if group == "" || e.Group == group {
|
|
res = append(res, e)
|
|
}
|
|
}
|
|
if group != "" && len(res) == 0 {
|
|
return nil, fmt.Errorf("no entities in group %q", group)
|
|
}
|
|
metricsSortCanonical(res)
|
|
return res, nil
|
|
}
|
|
|
|
var res []metrics.EntityInfo
|
|
for _, arg := range args {
|
|
var matched []metrics.EntityInfo
|
|
for _, e := range entities {
|
|
if e.Name == arg || title(e.Group, e.Name) == arg {
|
|
matched = append(matched, e)
|
|
}
|
|
}
|
|
if len(matched) == 0 {
|
|
return nil, fmt.Errorf("unknown entity %q", arg)
|
|
}
|
|
res = append(res, matched...)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// metricsTimeLayout returns the time column format for the given aggregation.
|
|
func metricsTimeLayout(aggregate string) string {
|
|
switch aggregate {
|
|
case "day":
|
|
return "2006-01-02"
|
|
case "month":
|
|
return "2006-01"
|
|
default: // 15m, hour
|
|
return "2006-01-02 15:04"
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
layout := metricsTimeLayout(aggregate)
|
|
|
|
type colSpec struct {
|
|
entity metrics.EntityInfo
|
|
energyCol int
|
|
returnCol int // -1 unless the entity is bidirectional
|
|
}
|
|
|
|
header := []string{"time"}
|
|
var specs []colSpec
|
|
|
|
for _, e := range selected {
|
|
label := title(e.Group, e.Name)
|
|
if label == "" {
|
|
label = e.Name
|
|
}
|
|
|
|
spec := colSpec{entity: e, energyCol: len(header) - 1, returnCol: -1}
|
|
header = append(header, label)
|
|
|
|
if e.Group == metrics.Grid || e.Group == metrics.Battery {
|
|
spec.returnCol = len(header) - 1
|
|
header = append(header, label+"↑")
|
|
}
|
|
|
|
specs = append(specs, spec)
|
|
}
|
|
|
|
ncols := len(header) - 1
|
|
|
|
rowByKey := make(map[string][]string)
|
|
var rowKeys []string
|
|
|
|
cells := func(key string) []string {
|
|
if c, ok := rowByKey[key]; ok {
|
|
return c
|
|
}
|
|
c := make([]string, ncols)
|
|
rowByKey[key] = c
|
|
rowKeys = append(rowKeys, key)
|
|
return c
|
|
}
|
|
|
|
for _, spec := range specs {
|
|
s, ok := byEntity[spec.entity.Group+"/"+title(spec.entity.Group, spec.entity.Name)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, slot := range s.Data {
|
|
row := cells(slot.Start.Local().Format(layout))
|
|
row[spec.energyCol] = fmt.Sprintf("%.3f", slot.Energy)
|
|
if spec.returnCol >= 0 {
|
|
row[spec.returnCol] = fmt.Sprintf("%.3f", slot.ReturnEnergy)
|
|
}
|
|
}
|
|
}
|
|
|
|
slices.Sort(rowKeys)
|
|
|
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(tw, strings.Join(header, "\t"))
|
|
for _, key := range rowKeys {
|
|
fmt.Fprintln(tw, key+"\t"+strings.Join(rowByKey[key], "\t"))
|
|
}
|
|
tw.Flush()
|
|
}
|