diff --git a/AGENTS.md b/AGENTS.md
index 6a43b6572..2347c1edc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -173,6 +173,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
- Implement accessibility features (tabindex, aria-label, keyboard handlers)
- Use descriptive names for variables, functions, and event handlers
- Use early returns for readability
+- Prefer named computed properties over inline template expressions, even for single use. Readability beats saving lines
- Use configured Axios instance for HTTP communication
### State Management
diff --git a/api/api.go b/api/api.go
index b764073bc..0e726d747 100644
--- a/api/api.go
+++ b/api/api.go
@@ -1,8 +1,6 @@
package api
import (
- "context"
- "io"
"net/url"
"time"
@@ -254,11 +252,6 @@ type TitleDescriber interface {
GetTitle() string
}
-// CsvWriter converts to csv
-type CsvWriter interface {
- WriteCsv(context.Context, io.Writer) error
-}
-
// CircuitMeasurements is the measurements a circuit or load must deliver
type CircuitMeasurements interface {
GetChargePower() float64
diff --git a/assets/js/components/Config/HemsModal.vue b/assets/js/components/Config/HemsModal.vue
index 11a1e0252..18e1a6e42 100644
--- a/assets/js/components/Config/HemsModal.vue
+++ b/assets/js/components/Config/HemsModal.vue
@@ -38,28 +38,23 @@
{{ $t("config.general.docsLink") }}
-
-
- {{ csvLinkLabel }}
-
+
+
..energy.Wh[, ..returnEnergy.Wh], …
-//
-// Only grid and battery contribute a second returnEnergy column.
-// Values are plain Wh integers (the DB is already rounded to milli-kWh) — no
-// decimal point, no thousands separator, so they survive locale-mismatched
-// spreadsheet importers unambiguously.
-func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error {
- ww, _, err := csvutil.NewLocalizedWriter(ctx, w)
- if err != nil {
- return err
- }
-
+// Write emits the wide table to ww: time.start, time.end, then one energy.Wh
+// column per entity (grid/battery add returnEnergy.Wh) as plain locale-safe Wh ints.
+func (s SeriesExport) Write(ww export.RowWriter) error {
byGroup := make(map[string][]*Series)
for i := range s {
g := s[i].Group
@@ -215,7 +201,7 @@ func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error {
}
})
- header := []string{"time.start", "time.end"}
+ header := []any{"time.start", "time.end"}
type col struct {
series *Series
returnEnergy bool
@@ -292,28 +278,28 @@ func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error {
}
}
- row := make([]string, len(cols))
+ row := make([]any, len(cols))
for _, ts := range timestamps {
- row[0] = ts.Local().Format("2006-01-02 15:04:05")
- row[1] = endByStart[ts.UnixNano()].Local().Format("2006-01-02 15:04:05")
+ row[0] = ts.Local()
+ row[1] = endByStart[ts.UnixNano()].Local()
for i := 2; i < len(cols); i++ {
c := cols[i]
if c.series == nil {
- row[i] = ""
+ row[i] = nil
continue
}
slot, ok := slotIdx[c.series][ts.UnixNano()]
if !ok {
- row[i] = ""
+ row[i] = nil
continue
}
switch {
case c.socTemp:
- row[i] = formatSocTemp(slot.SocTemp)
+ row[i] = socTempValue(slot.SocTemp)
case c.returnEnergy:
- row[i] = strconv.FormatInt(int64(math.Round(slot.ReturnEnergy*1000)), 10)
+ row[i] = int64(math.Round(slot.ReturnEnergy * 1000))
default:
- row[i] = strconv.FormatInt(int64(math.Round(slot.Energy*1000)), 10)
+ row[i] = int64(math.Round(slot.Energy * 1000))
}
}
if err := ww.Write(row); err != nil {
diff --git a/core/metrics/db_history_test.go b/core/metrics/db_history_test.go
index 48f653bab..e771cb852 100644
--- a/core/metrics/db_history_test.go
+++ b/core/metrics/db_history_test.go
@@ -4,11 +4,13 @@ import (
"bytes"
"context"
"encoding/csv"
+ "io"
"strings"
"testing"
"time"
"github.com/evcc-io/evcc/server/db"
+ csvexport "github.com/evcc-io/evcc/util/export/csv"
"github.com/evcc-io/evcc/util/locale"
"github.com/stretchr/testify/require"
)
@@ -17,11 +19,18 @@ func mkSlot(t time.Time, energy, returnEnergy float64) Slot {
return Slot{Start: t, End: t.Add(15 * time.Minute), Energy: energy, ReturnEnergy: returnEnergy}
}
-func TestSeriesCSV_HeaderAndLayout(t *testing.T) {
+// writeSeriesCsv exports s to w as localized CSV via the csv row writer.
+func writeSeriesCsv(t *testing.T, ctx context.Context, s SeriesExport, w io.Writer) error {
+ ww, err := csvexport.New(ctx, w)
+ require.NoError(t, err)
+ return s.Write(ww)
+}
+
+func TestSeriesExport_HeaderAndLayout(t *testing.T) {
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(15 * time.Minute)
- series := SeriesCSV{
+ series := SeriesExport{
{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)}},
@@ -30,7 +39,7 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) {
}
var buf bytes.Buffer
- require.NoError(t, series.WriteCsv(context.Background(), &buf))
+ require.NoError(t, writeSeriesCsv(t, context.Background(), series, &buf))
// UTF-8 BOM
require.True(t, bytes.HasPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF}), "expected UTF-8 BOM")
@@ -66,38 +75,36 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) {
require.Equal(t, "366", rows[1][9]) // home energy
}
-func TestSeriesCSV_GermanLocale(t *testing.T) {
+func TestSeriesExport_GermanLocale(t *testing.T) {
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
- series := SeriesCSV{
+ series := SeriesExport{
{Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2.5, 0)}},
}
var buf bytes.Buffer
ctx := context.WithValue(context.Background(), locale.Locale, "de")
- require.NoError(t, series.WriteCsv(ctx, &buf))
+ require.NoError(t, writeSeriesCsv(t, ctx, series, &buf))
s := string(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF}))
- require.True(t, strings.Contains(s, ";"), "german CSV must use ';' separator")
- // Values are plain Wh integers so they're locale-agnostic. We just check
- // no decimal point / comma snuck in.
+ require.False(t, strings.Contains(s, ";"), "delimiter is ',' regardless of locale")
require.True(t, strings.Contains(s, "2500"), "value should be Wh integer")
require.False(t, strings.Contains(s, "2.500"), "no '.' decimal/thousands")
require.False(t, strings.Contains(s, "2,500"), "no ',' decimal/thousands")
}
-func TestSeriesCSV_MissingSlotIsEmpty(t *testing.T) {
+func TestSeriesExport_MissingSlotIsEmpty(t *testing.T) {
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(15 * time.Minute)
// Two entities, second one only has data for the first timestamp → second
// timestamp must produce an empty cell rather than 0.000.
- series := SeriesCSV{
+ series := SeriesExport{
{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
- require.NoError(t, series.WriteCsv(context.Background(), &buf))
+ require.NoError(t, writeSeriesCsv(t, context.Background(), series, &buf))
r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
rows, err := r.ReadAll()
@@ -115,16 +122,16 @@ func TestSeriesCSV_MissingSlotIsEmpty(t *testing.T) {
require.Equal(t, "", rows[2][3], "missing slot must be empty, not zero")
}
-func TestSeriesCSV_BatteryHasReturnEnergyColumn(t *testing.T) {
+func TestSeriesExport_BatteryHasReturnEnergyColumn(t *testing.T) {
// Battery is bidirectional, so even a series with zero discharge keeps the
// returnEnergy column.
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
- series := SeriesCSV{
+ series := SeriesExport{
{Group: Battery, Title: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}},
}
var buf bytes.Buffer
- require.NoError(t, series.WriteCsv(context.Background(), &buf))
+ require.NoError(t, writeSeriesCsv(t, context.Background(), series, &buf))
r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
rows, err := r.ReadAll()
@@ -134,7 +141,7 @@ func TestSeriesCSV_BatteryHasReturnEnergyColumn(t *testing.T) {
require.Equal(t, "0", rows[1][3])
}
-func TestSeriesCSV_SocTempColumns(t *testing.T) {
+func TestSeriesExport_SocTempColumns(t *testing.T) {
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(15 * time.Minute)
@@ -144,14 +151,14 @@ func TestSeriesCSV_SocTempColumns(t *testing.T) {
return s
}
- series := SeriesCSV{
+ series := SeriesExport{
{Group: Battery, Title: "bat", Data: []Slot{slot(t0, new(90.0)), slot(t1, new(80.0))}},
{Group: Loadpoint, Title: "heater", IsTemp: true, Data: []Slot{slot(t0, new(45.0)), slot(t1, new(46.0))}},
{Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2, 0), mkSlot(t1, 3, 0)}},
}
var buf bytes.Buffer
- require.NoError(t, series.WriteCsv(context.Background(), &buf))
+ require.NoError(t, writeSeriesCsv(t, context.Background(), series, &buf))
out := buf.String()
require.Contains(t, out, "battery.bat.soc.pct")
diff --git a/core/session/session.go b/core/session/session.go
index 82a538933..f8e874ff9 100644
--- a/core/session/session.go
+++ b/core/session/session.go
@@ -1,12 +1,9 @@
package session
import (
- "context"
- "io"
"time"
- "github.com/evcc-io/evcc/api"
- csvutil "github.com/evcc-io/evcc/util/csv"
+ "github.com/evcc-io/evcc/util/export"
)
// Session is a single charging session
@@ -35,11 +32,11 @@ type Session struct {
// Sessions is a list of sessions
type Sessions []Session
-var _ api.CsvWriter = (*Sessions)(nil)
+var _ export.Writer = (*Sessions)(nil)
-// WriteCsv implements the api.CsvWriter interface
-func (t *Sessions) WriteCsv(ctx context.Context, w io.Writer) error {
- return csvutil.WriteStructSlice(ctx, w, t, csvutil.Config{
+// Write implements the export.Writer interface
+func (t *Sessions) Write(ww export.RowWriter) error {
+ return export.WriteStructSlice(ww, t, export.Config{
I18nPrefix: "sessions.csv",
})
}
diff --git a/go.mod b/go.mod
index de0e55414..adf44ee69 100644
--- a/go.mod
+++ b/go.mod
@@ -101,6 +101,7 @@ require (
github.com/traefik/yaegi v0.16.1
github.com/volkszaehler/mbmd v0.0.0-20260623211432-fb8f9c22f121
github.com/warthog618/go-gpiocdev v0.9.1
+ github.com/xuri/excelize/v2 v2.11.0
gitlab.com/bboehmke/sunny v0.17.0
go.bug.st/serial v1.7.1
go.uber.org/mock v0.6.0
@@ -216,6 +217,8 @@ require (
github.com/quic-go/webtransport-go v0.10.0 // indirect
github.com/relvacode/iso8601 v1.6.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/richardlehane/mscfb v1.0.7 // indirect
+ github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/rickb777/period v1.0.22 // indirect
github.com/rickb777/plural v1.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -230,8 +233,11 @@ require (
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/teivah/onecontext v1.3.0 // indirect
+ github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
+ github.com/xuri/efp v0.0.1 // indirect
+ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
gitlab.com/c0b/go-ordered-json v0.0.0-20201030195603-febf46534d5a // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
diff --git a/go.sum b/go.sum
index 25a64eb06..932672573 100644
--- a/go.sum
+++ b/go.sum
@@ -451,6 +451,10 @@ github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QH
github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
+github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
+github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
+github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rickb777/expect v1.0.6 h1:1JE3CfYGyuhN5OTu5nqvIF3VjS2AoqoctcGlbNTVl3w=
github.com/rickb777/expect v1.0.6/go.mod h1:raunaduUM/p8CzpTZeDmoexwlIFF+Peg0Mj/p//9mkA=
github.com/rickb777/period v1.0.22 h1:/X41JreTYsjifLDGBCaVN+s5r5/G0sTbKoHBaISv2ns=
@@ -539,6 +543,8 @@ github.com/teslamotors/vehicle-command v0.4.1 h1:J4ne/TNGwgodJLYJDLm/hjoygXyQ/bp
github.com/teslamotors/vehicle-command v0.4.1/go.mod h1:liN6VG6MCc7m02wFaBm2sQT6MYGm/dJua6bG00QSpnA=
github.com/tess1o/go-ecoflow v1.1.1-0.20251003083510-2ccc15a17e29 h1:whgDCnunp97KCC7TF94E3l1hGo080ICSRs6ei8jCnzs=
github.com/tess1o/go-ecoflow v1.1.1-0.20251003083510-2ccc15a17e29/go.mod h1:kqrfk4Y14pHCPKIwp6o7ZbgpohUzQHh7EMX+RUA4JCU=
+github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
+github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
@@ -551,6 +557,12 @@ github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgX
github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8=
github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4=
github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU=
+github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
+github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
+github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
+github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -598,6 +610,8 @@ golang.org/x/crypto/x509roots/fallback v0.0.0-20260630172432-7626c5025624 h1:qto
golang.org/x/crypto/x509roots/fallback v0.0.0-20260630172432-7626c5025624/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
+golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
+golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
diff --git a/hems/smartgrid/types.go b/hems/smartgrid/types.go
index aca5e44eb..8ad004ea0 100644
--- a/hems/smartgrid/types.go
+++ b/hems/smartgrid/types.go
@@ -1,11 +1,9 @@
package smartgrid
import (
- "context"
- "io"
"time"
- csvutil "github.com/evcc-io/evcc/util/csv"
+ "github.com/evcc-io/evcc/util/export"
)
type GridSession struct {
@@ -26,9 +24,11 @@ const (
type GridSessions []GridSession
-// WriteCsv implements the api.CsvWriter interface
-func (t *GridSessions) WriteCsv(ctx context.Context, w io.Writer) error {
- return csvutil.WriteStructSlice(ctx, w, t, csvutil.Config{
+var _ export.Writer = (*GridSessions)(nil)
+
+// Write implements the export.Writer interface
+func (t *GridSessions) Write(ww export.RowWriter) error {
+ return export.WriteStructSlice(ww, t, export.Config{
I18nPrefix: "config.hems.csv",
})
}
diff --git a/i18n/de.json b/i18n/de.json
index 6dbc30210..fd9624fea 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -370,6 +370,8 @@
"eventsRecorded": "{count} Netzbegrenzungen gespeichert.",
"integrations": "Integrationen",
"lastEvent": "Letzter Eintrag {timeAgo}.",
+ "noEvents": "Es wurden noch keine Ereignisse aufgezeichnet.",
+ "recordedEvents": "Aufgezeichnete Ereignisse",
"template": "Integration",
"title": "Externe Begrenzung"
},
@@ -1131,6 +1133,7 @@
}
},
"general": {
+ "download": "Download",
"note": "Hinweis:"
},
"header": {
diff --git a/i18n/en.json b/i18n/en.json
index 077bfd20e..b72017aab 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -365,11 +365,13 @@
},
"customOption": "User-defined integration",
"description": "Power limitation by external systems (e.g. §14a EnWG, §9 EEG interface or higher-level energy management system). Works together with the load management feature. Curtailing solar production is also supported for selected devices. The actual curtailment limit must be set on each inverter configuration.",
- "downloadCsv": "Download CSV",
+ "download": "Download",
"eventsRecorded": "Recorded {count} grid limitation events.",
"generic": "Generic integrations",
"integrations": "Integrations",
"lastEvent": "Most recent {timeAgo}.",
+ "noEvents": "No events recorded yet.",
+ "recordedEvents": "Recorded events",
"template": "Integration",
"title": "External Limit"
},
@@ -1131,6 +1133,7 @@
}
},
"general": {
+ "download": "Download",
"note": "Note:"
},
"header": {
@@ -1330,7 +1333,7 @@
"returnEnergy": "Energy (reverse)"
}
},
- "downloadCsv": "Download CSV",
+ "download": "Download",
"empty": "No history data available for this time range.",
"group": {
"battery": "Battery",
@@ -1595,9 +1598,9 @@
"solarpercentage": "Solar (%)",
"vehicle": "Vehicle"
},
- "csvPeriod": "Download {period} CSV",
- "csvTotal": "Download total CSV",
"date": "Start",
+ "download": "Download {period}",
+ "downloadTotal": "Download total",
"energy": "Charged",
"filter": {
"allLoadpoints": "all charging points",
diff --git a/server/http_gridsessions_handler.go b/server/http_gridsessions_handler.go
index 31b3dfab9..2535fd261 100644
--- a/server/http_gridsessions_handler.go
+++ b/server/http_gridsessions_handler.go
@@ -25,7 +25,14 @@ func gridSessionsHandler(w http.ResponseWriter, r *http.Request) {
return
}
- if r.URL.Query().Get("format") == "csv" {
+ format := r.URL.Query().Get("format")
+
+ if format == "json" {
+ jsonAttachment(w, &res, "gridsessions")
+ return
+ }
+
+ if format == "csv" || format == "xlsx" {
lang := r.URL.Query().Get("lang")
if lang == "" {
// get request language
@@ -36,7 +43,7 @@ func gridSessionsHandler(w http.ResponseWriter, r *http.Request) {
}
ctx := context.WithValue(context.Background(), locale.Locale, lang)
- csvResult(ctx, w, &res, "gridsessions")
+ exportResult(ctx, w, format, &res, "gridsessions")
return
}
diff --git a/server/http_history_handler.go b/server/http_history_handler.go
index ff4116ffe..bcb91953e 100644
--- a/server/http_history_handler.go
+++ b/server/http_history_handler.go
@@ -58,7 +58,14 @@ func energyHistoryHandler(w http.ResponseWriter, r *http.Request) {
return
}
- if q.Get("format") == "csv" {
+ format := q.Get("format")
+
+ if format == "json" {
+ jsonAttachment(w, res, historyFilename(from, aggregate))
+ return
+ }
+
+ if format == "csv" || format == "xlsx" {
lang := q.Get("lang")
if lang == "" {
if tags, _, err := language.ParseAcceptLanguage(r.Header.Get("Accept-Language")); err == nil && len(tags) > 0 {
@@ -66,7 +73,7 @@ func energyHistoryHandler(w http.ResponseWriter, r *http.Request) {
}
}
ctx := context.WithValue(context.Background(), locale.Locale, lang)
- csvResult(ctx, w, metrics.SeriesCSV(res), historyFilename(from, aggregate))
+ exportResult(ctx, w, format, metrics.SeriesExport(res), historyFilename(from, aggregate))
return
}
diff --git a/server/http_session_handler.go b/server/http_session_handler.go
index 242dca7c3..00ea2b404 100644
--- a/server/http_session_handler.go
+++ b/server/http_session_handler.go
@@ -9,21 +9,43 @@ import (
"net/http"
"strings"
- "github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/server/db"
+ "github.com/evcc-io/evcc/util/export"
+ "github.com/evcc-io/evcc/util/export/csv"
+ "github.com/evcc-io/evcc/util/export/xlsx"
"github.com/evcc-io/evcc/util/locale"
"github.com/gorilla/mux"
"golang.org/x/text/language"
)
-func csvResult(ctx context.Context, w http.ResponseWriter, res any, filename string) {
- w.Header().Set("Content-Type", "text/csv")
- w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`.csv"`)
+// jsonAttachment writes res as json with download headers
+func jsonAttachment(w http.ResponseWriter, res any, filename string) {
+ w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`.json"`)
+ jsonWrite(w, res)
+}
- if ww, ok := api.Cap[api.CsvWriter](res); ok {
- _ = ww.WriteCsv(ctx, w)
+// exportResult writes res to w as format (csv|xlsx) with download headers.
+func exportResult(ctx context.Context, w http.ResponseWriter, format string, res export.Writer, filename string) {
+ if format == "xlsx" {
+ w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+ w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`.xlsx"`)
} else {
+ w.Header().Set("Content-Type", "text/csv")
+ w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`.csv"`)
+ }
+
+ var ww export.RowWriter
+ var err error
+ if format == "xlsx" {
+ ww, err = xlsx.New(ctx, w)
+ } else {
+ ww, err = csv.New(ctx, w)
+ }
+ if err == nil {
+ err = res.Write(ww)
+ }
+ if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
}
@@ -72,7 +94,14 @@ func sessionHandler(w http.ResponseWriter, r *http.Request) {
}
}
- if r.URL.Query().Get("format") == "csv" {
+ format := r.URL.Query().Get("format")
+
+ if format == "json" {
+ jsonAttachment(w, res, filename)
+ return
+ }
+
+ if format == "csv" || format == "xlsx" {
lang := r.URL.Query().Get("lang")
if lang == "" {
// get request language
@@ -83,7 +112,7 @@ func sessionHandler(w http.ResponseWriter, r *http.Request) {
}
ctx := context.WithValue(context.Background(), locale.Locale, lang)
- csvResult(ctx, w, &res, filename)
+ exportResult(ctx, w, format, &res, filename)
return
}
diff --git a/tests/hems.spec.ts b/tests/hems.spec.ts
index fef6dbbf4..59e451c89 100644
--- a/tests/hems.spec.ts
+++ b/tests/hems.spec.ts
@@ -23,7 +23,7 @@ test.afterEach(async () => {
const CONFIG = "fast.evcc.yaml";
test.describe("HEMS", () => {
- test("grid sessions in template modal", async ({ page }) => {
+ test("no recorded events section in create mode", async ({ page }) => {
await start(CONFIG, "hems.sql");
await page.goto("/#/config");
@@ -31,12 +31,7 @@ test.describe("HEMS", () => {
const hemsModal = page.getByTestId("hems-modal");
await expectModalVisible(hemsModal);
- await expect(hemsModal.getByTestId("grid-sessions")).toBeVisible();
- await expect(hemsModal).toContainText("Recorded 3 grid limitation events");
- await expect(hemsModal).toContainText("Most recent");
-
- const csvLink = hemsModal.getByRole("link", { name: "Download CSV" });
- await expect(csvLink).toHaveAttribute("href", /\.\/api\/gridsessions\?format=csv&lang=en/);
+ await expect(hemsModal.getByTestId("grid-sessions")).not.toBeVisible();
// template selector is shown in create mode, yaml editor hidden until "User-defined" is chosen
await expect(hemsModal.getByLabel("Integration")).toBeVisible();
@@ -47,6 +42,33 @@ test.describe("HEMS", () => {
await expectModalHidden(hemsModal);
});
+ test("recorded events in configured modal", async ({ page }) => {
+ await start("hems-yaml.evcc.yaml", "hems.sql");
+ await page.goto("/#/config");
+
+ await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
+ const hemsModal = page.getByTestId("hems-modal");
+ await expectModalVisible(hemsModal);
+
+ await expect(hemsModal.getByTestId("grid-sessions")).toBeVisible();
+ await expect(hemsModal).toContainText("Recorded events");
+ await expect(hemsModal).toContainText("Recorded 3 grid limitation events");
+ await expect(hemsModal).toContainText("Most recent");
+
+ await hemsModal.getByRole("button", { name: "Download" }).click();
+ await expect(hemsModal.getByRole("link", { name: "CSV" })).toHaveAttribute(
+ "href",
+ "./api/gridsessions?lang=en&format=csv"
+ );
+ await expect(hemsModal.getByRole("link", { name: "XLSX" })).toHaveAttribute(
+ "href",
+ "./api/gridsessions?lang=en&format=xlsx"
+ );
+
+ await hemsModal.getByRole("button", { name: "Close" }).click();
+ await expectModalHidden(hemsModal);
+ });
+
test("modal yaml-configured is locked", async ({ page }) => {
await start("hems-yaml.evcc.yaml");
await page.goto("/#/config");
@@ -55,7 +77,8 @@ test.describe("HEMS", () => {
const hemsModal = page.getByTestId("hems-modal");
await expectModalVisible(hemsModal);
- await expect(hemsModal.getByTestId("grid-sessions")).not.toBeVisible();
+ await expect(hemsModal.getByTestId("grid-sessions")).toBeVisible();
+ await expect(hemsModal).toContainText("No events recorded yet");
await expect(hemsModal).toContainText("Configured via evcc.yaml");
await expect(hemsModal.getByLabel("Integration")).not.toBeVisible();
await expect(hemsModal.getByTestId("yaml-editor")).not.toBeVisible();
@@ -317,18 +340,18 @@ w3:
test.describe("grid sessions CSV in app context", () => {
test("dispatches download event", async ({ page }) => {
await enableAppContext(page);
- await start(CONFIG, "hems.sql");
+ await start("hems-yaml.evcc.yaml", "hems.sql");
await page.goto("/#/config");
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
const hemsModal = page.getByTestId("hems-modal");
await expectModalVisible(hemsModal);
- const csvLink = hemsModal.getByRole("link", { name: "Download CSV" });
- await csvLink.click();
+ await hemsModal.getByRole("button", { name: "Download" }).click();
+ await hemsModal.getByRole("link", { name: "CSV" }).click();
expect(await expectAppEvent(page)).toMatchObject({
type: "download",
- url: expect.stringContaining("/api/gridsessions?format=csv&lang=en"),
+ url: expect.stringContaining("/api/gridsessions?lang=en&format=csv"),
});
});
});
diff --git a/tests/sessions.spec.ts b/tests/sessions.spec.ts
index d04bbef2c..6755ce66d 100644
--- a/tests/sessions.spec.ts
+++ b/tests/sessions.spec.ts
@@ -271,43 +271,62 @@ test.describe("columns desktop", async () => {
const date = new Date();
const YEAR = date.getFullYear();
const MONTH = date.getMonth() + 1;
-const MONTH_YEAR = new Intl.DateTimeFormat("en", { month: "long", year: "numeric" }).format(date);
+
+const openDownloadMenu = async (page: Page) => {
+ const toggle = page.getByRole("button", { name: "Download" });
+ if ((await toggle.getAttribute("aria-expanded")) !== "true") {
+ await toggle.click();
+ }
+};
test.describe("csv export", async () => {
test("total export", async ({ page }) => {
await page.goto("/#/sessions?period=total");
- await expect(page.getByRole("link", { name: "Download total CSV" })).toHaveAttribute(
+ await openDownloadMenu(page);
+ await expect(page.getByRole("link", { name: "CSV" })).toHaveAttribute(
"href",
- "./api/sessions?format=csv&lang=en"
+ "./api/sessions?lang=en&format=csv"
+ );
+ await expect(page.getByRole("link", { name: "XLSX" })).toHaveAttribute(
+ "href",
+ "./api/sessions?lang=en&format=xlsx"
+ );
+ await expect(page.getByRole("link", { name: "JSON" })).toHaveAttribute(
+ "href",
+ "./api/sessions?lang=en&format=json"
);
});
test("year export", async ({ page }) => {
// fixed year
await page.goto("/#/sessions?period=year&year=2023");
- await expect(page.getByRole("link", { name: "Download 2023 CSV" })).toHaveAttribute(
+ await openDownloadMenu(page);
+ await expect(page.getByRole("link", { name: "CSV" })).toHaveAttribute(
"href",
- "./api/sessions?format=csv&lang=en&year=2023"
+ "./api/sessions?lang=en&year=2023&format=csv"
);
// current year
await page.goto(`/#/sessions?period=year`);
- await expect(page.getByRole("link", { name: `Download ${YEAR} CSV` })).toHaveAttribute(
+ await openDownloadMenu(page);
+ await expect(page.getByRole("link", { name: "CSV" })).toHaveAttribute(
"href",
- `./api/sessions?format=csv&lang=en&year=${YEAR}`
+ `./api/sessions?lang=en&year=${YEAR}&format=csv`
);
});
test("monthly export", async ({ page }) => {
await page.goto("/#/sessions?&year=2023&month=5");
- await expect(page.getByRole("link", { name: "Download May 2023 CSV" })).toHaveAttribute(
+ await openDownloadMenu(page);
+ await expect(page.getByRole("link", { name: "CSV" })).toHaveAttribute(
"href",
- "./api/sessions?format=csv&lang=en&year=2023&month=5"
+ "./api/sessions?lang=en&year=2023&month=5&format=csv"
);
// current month
await page.goto(`/#/sessions`);
- await expect(page.getByRole("link", { name: `Download ${MONTH_YEAR} CSV` })).toHaveAttribute(
+ await openDownloadMenu(page);
+ await expect(page.getByRole("link", { name: "CSV" })).toHaveAttribute(
"href",
- `./api/sessions?format=csv&lang=en&year=${YEAR}&month=${MONTH}`
+ `./api/sessions?lang=en&year=${YEAR}&month=${MONTH}&format=csv`
);
});
});
@@ -316,18 +335,40 @@ test.describe("csv export download", async () => {
test("in browser context", async ({ page }) => {
await page.goto("/#/sessions?period=total");
const downloadPromise = page.waitForEvent("download");
- await page.getByRole("link", { name: "Download total CSV" }).click();
+ await openDownloadMenu(page);
+ await page.getByRole("link", { name: "CSV" }).click();
const download = await downloadPromise;
- expect(download.url()).toContain("/api/sessions?format=csv");
+ expect(download.url()).toContain("format=csv");
+ expect(download.suggestedFilename()).toBe("session.csv");
+ });
+
+ test("xlsx via default button", async ({ page }) => {
+ await page.goto("/#/sessions?period=total");
+ const downloadPromise = page.waitForEvent("download");
+ await page.getByRole("link", { name: "Download" }).click();
+ const download = await downloadPromise;
+ expect(download.url()).toContain("format=xlsx");
+ expect(download.suggestedFilename()).toBe("session.xlsx");
+ });
+
+ test("json with attachment header", async ({ page }) => {
+ await page.goto("/#/sessions?period=total");
+ const downloadPromise = page.waitForEvent("download");
+ await openDownloadMenu(page);
+ await page.getByRole("link", { name: "JSON" }).click();
+ const download = await downloadPromise;
+ expect(download.url()).toContain("format=json");
+ expect(download.suggestedFilename()).toBe("session.json");
});
test("in app context", async ({ page }) => {
await enableAppContext(page);
await page.goto("/#/sessions?period=total");
- await page.getByRole("link", { name: "Download total CSV" }).click();
+ await openDownloadMenu(page);
+ await page.getByRole("link", { name: "CSV" }).click();
expect(await expectAppEvent(page)).toMatchObject({
type: "download",
- url: expect.stringContaining("/api/sessions?format=csv&lang=en"),
+ url: expect.stringContaining("/api/sessions?lang=en&format=csv"),
});
});
});
diff --git a/util/csv/writer.go b/util/csv/writer.go
deleted file mode 100644
index 8d7e6ed29..000000000
--- a/util/csv/writer.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package csv
-
-import (
- "context"
- "encoding/csv"
- "fmt"
- "io"
- "reflect"
- "strings"
- "time"
-
- "github.com/evcc-io/evcc/util/locale"
- "github.com/fatih/structs"
- "github.com/nicksnyder/go-i18n/v2/i18n"
- "golang.org/x/text/language"
- "golang.org/x/text/message"
- "golang.org/x/text/number"
-)
-
-type Config struct {
- I18nPrefix string // e.g., "sessions.csv" or "config.hems.csv"
-}
-
-func formatValue(mp *message.Printer, value any, digits int) string {
- if rv := reflect.ValueOf(value); rv.Kind() == reflect.Pointer && rv.IsNil() {
- return ""
- }
-
- switch v := value.(type) {
- case float64:
- return mp.Sprint(number.Decimal(v, number.NoSeparator(), number.MaxFractionDigits(digits)))
- case *float64:
- return mp.Sprint(number.Decimal(*v, number.NoSeparator(), number.MaxFractionDigits(digits)))
- case time.Time:
- if v.IsZero() {
- return ""
- }
- return v.Local().Format("2006-01-02 15:04:05")
- default:
- return fmt.Sprintf("%v", value)
- }
-}
-
-func writeHeader(ctx context.Context, ww *csv.Writer, structType any, i18nPrefix string) error {
- localizer := locale.Localizer
- if val, ok := ctx.Value(locale.Locale).(string); ok && val != "" {
- localizer = i18n.NewLocalizer(locale.Bundle, val, locale.Language)
- }
-
- var row []string
- for _, f := range structs.Fields(structType) {
- csvTag := f.Tag("csv")
- if csvTag == "-" {
- continue
- }
-
- caption, err := localizer.Localize(&locale.Config{
- MessageID: i18nPrefix + "." + strings.ToLower(f.Name()),
- })
- if err != nil {
- if csvTag != "" {
- caption = csvTag
- } else {
- caption = f.Name()
- }
- }
-
- row = append(row, caption)
- }
-
- return ww.Write(row)
-}
-
-func writeRow(ww *csv.Writer, mp *message.Printer, structVal any) error {
- var row []string
- for _, f := range structs.Fields(structVal) {
- if f.Tag("csv") == "-" {
- continue
- }
-
- digits := 4
- if format := f.Tag("format"); format == "int" {
- digits = 0
- }
-
- val := formatValue(mp, f.Value(), digits)
- row = append(row, val)
- }
-
- return ww.Write(row)
-}
-
-// NewLocalizedWriter writes a UTF-8 BOM and returns a locale-configured
-// csv.Writer (German uses ';') and matching number printer.
-func NewLocalizedWriter(ctx context.Context, w io.Writer) (*csv.Writer, *message.Printer, error) {
- if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
- return nil, nil, err
- }
-
- lang := locale.Language
- if v, ok := ctx.Value(locale.Locale).(string); ok && v != "" {
- lang = v
- }
- if lang == "" {
- lang = "en"
- }
-
- tag, err := language.Parse(lang)
- if err != nil {
- return nil, nil, err
- }
-
- ww := csv.NewWriter(w)
- if b, _ := tag.Base(); b.String() == language.German.String() {
- ww.Comma = ';'
- }
-
- return ww, message.NewPrinter(tag), nil
-}
-
-// WriteStructSlice writes a slice of structs to CSV format with localized headers
-func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg Config) error {
- ww, mp, err := NewLocalizedWriter(ctx, w)
- if err != nil {
- return err
- }
-
- sliceVal := reflect.ValueOf(slice)
- if sliceVal.Kind() == reflect.Pointer {
- sliceVal = sliceVal.Elem()
- }
- if sliceVal.Kind() != reflect.Slice {
- return fmt.Errorf("expected slice, got %T", slice)
- }
-
- var structType any
- if sliceVal.Len() > 0 {
- structType = sliceVal.Index(0).Interface()
- } else {
- structType = reflect.New(sliceVal.Type().Elem()).Elem().Interface()
- }
-
- if err := writeHeader(ctx, ww, structType, cfg.I18nPrefix); err != nil {
- return err
- }
-
- for i := 0; i < sliceVal.Len(); i++ {
- row := sliceVal.Index(i).Interface()
- if err := writeRow(ww, mp, row); err != nil {
- return err
- }
- }
-
- ww.Flush()
- return ww.Error()
-}
diff --git a/util/export/api.go b/util/export/api.go
new file mode 100644
index 000000000..87d325548
--- /dev/null
+++ b/util/export/api.go
@@ -0,0 +1,22 @@
+package export
+
+import (
+ "github.com/nicksnyder/go-i18n/v2/i18n"
+)
+
+type Config struct {
+ I18nPrefix string // e.g., "sessions.csv" or "config.hems.csv"
+}
+
+// RowWriter is a typed row sink shared by the csv and xlsx exporters
+type RowWriter interface {
+ Write(record []any) error
+ Flush()
+ Error() error
+ Localizer() *i18n.Localizer
+}
+
+// Writer serializes itself to a RowWriter.
+type Writer interface {
+ Write(RowWriter) error
+}
diff --git a/util/export/csv/csv.go b/util/export/csv/csv.go
new file mode 100644
index 000000000..5fa58aabb
--- /dev/null
+++ b/util/export/csv/csv.go
@@ -0,0 +1,56 @@
+package csv
+
+import (
+ "context"
+ "encoding/csv"
+ "fmt"
+ "io"
+ "strconv"
+ "time"
+
+ "github.com/evcc-io/evcc/util/export"
+ "github.com/nicksnyder/go-i18n/v2/i18n"
+)
+
+// rowWriter renders typed cells as locale-independent strings
+type rowWriter struct {
+ *csv.Writer
+ localizer *i18n.Localizer
+}
+
+func (w rowWriter) Localizer() *i18n.Localizer { return w.localizer }
+
+func (w rowWriter) Write(record []any) error {
+ row := make([]string, len(record))
+ for i, v := range record {
+ switch v := v.(type) {
+ case nil:
+ row[i] = ""
+ case float64:
+ row[i] = strconv.FormatFloat(v, 'f', -1, 64)
+ case time.Time:
+ row[i] = v.Format("2006-01-02 15:04:05")
+ default:
+ row[i] = fmt.Sprintf("%v", v)
+ }
+ }
+ return w.Writer.Write(row)
+}
+
+// New writes a UTF-8 BOM and returns a csv row writer. The context locale only affects header captions.
+func New(ctx context.Context, w io.Writer) (export.RowWriter, error) {
+ if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
+ return nil, err
+ }
+
+ return rowWriter{csv.NewWriter(w), export.Localizer(ctx)}, nil
+}
+
+// WriteStructSlice writes a slice of structs to CSV with localized headers.
+func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg export.Config) error {
+ ww, err := New(ctx, w)
+ if err != nil {
+ return err
+ }
+ return export.WriteStructSlice(ww, slice, cfg)
+}
diff --git a/util/csv/writer_test.go b/util/export/csv/csv_test.go
similarity index 57%
rename from util/csv/writer_test.go
rename to util/export/csv/csv_test.go
index a54634d25..86259bdae 100644
--- a/util/csv/writer_test.go
+++ b/util/export/csv/csv_test.go
@@ -9,11 +9,12 @@ import (
"time"
"github.com/evcc-io/evcc/server/assets"
+ "github.com/evcc-io/evcc/util/export"
"github.com/evcc-io/evcc/util/locale"
)
func init() {
- assets.I18n = os.DirFS("../../i18n")
+ assets.I18n = os.DirFS("../../../i18n")
_ = locale.Init()
}
@@ -32,17 +33,12 @@ func TestWriteStructSlice_Empty(t *testing.T) {
slice := TestStructs{}
ctx := context.WithValue(context.Background(), locale.Locale, "en")
- err := WriteStructSlice(ctx, &buf, &slice, Config{
- I18nPrefix: "test.csv",
- })
-
- if err != nil {
+ if err := WriteStructSlice(ctx, &buf, &slice, export.Config{I18nPrefix: "test.csv"}); err != nil {
t.Errorf("Expected no error, got %v", err)
}
- output := buf.String()
- if !strings.Contains(output, "Name") {
- t.Errorf("Expected header with 'Name', got: %s", output)
+ if !strings.Contains(buf.String(), "Name") {
+ t.Errorf("Expected header with 'Name', got: %s", buf.String())
}
}
@@ -60,16 +56,11 @@ func TestWriteStructSlice_WithData(t *testing.T) {
}
ctx := context.WithValue(context.Background(), locale.Locale, "en")
- err := WriteStructSlice(ctx, &buf, &slice, Config{
- I18nPrefix: "test.csv",
- })
-
- if err != nil {
+ if err := WriteStructSlice(ctx, &buf, &slice, export.Config{I18nPrefix: "test.csv"}); err != nil {
t.Errorf("Expected no error, got %v", err)
}
output := buf.String()
-
if !strings.HasPrefix(output, "\xEF\xBB\xBF") {
t.Error("Expected UTF-8 BOM at start")
}
@@ -79,9 +70,8 @@ func TestWriteStructSlice_WithData(t *testing.T) {
t.Fatalf("Expected at least 2 lines (header + data), got: %d", len(lines))
}
- expectedHeader := "Name,Value,OptValue,Created"
- if lines[0] != expectedHeader {
- t.Errorf("Expected header: %s\nGot: %s", expectedHeader, lines[0])
+ if lines[0] != "Name,Value,OptValue,Created" {
+ t.Errorf("Expected header: Name,Value,OptValue,Created\nGot: %s", lines[0])
}
if !strings.Contains(lines[1], "Test,123.456,42.5,2024-01-01") {
@@ -89,39 +79,22 @@ func TestWriteStructSlice_WithData(t *testing.T) {
}
}
-func TestWriteStructSlice_GermanLocale(t *testing.T) {
+func TestWriteStructSlice_LocaleIndependentValues(t *testing.T) {
var buf bytes.Buffer
slice := TestStructs{
{Name: "Test", Value: 123.456},
}
ctx := context.WithValue(context.Background(), locale.Locale, "de")
- err := WriteStructSlice(ctx, &buf, &slice, Config{
- I18nPrefix: "test.csv",
- })
-
- if err != nil {
+ if err := WriteStructSlice(ctx, &buf, &slice, export.Config{I18nPrefix: "test.csv"}); err != nil {
t.Errorf("Expected no error, got %v", err)
}
- output := buf.String()
- lines := strings.Split(output, "\n")
- if len(lines) > 1 && !strings.Contains(lines[1], ";") {
- t.Errorf("Expected German CSV to use ';' separator, got: %s", lines[1])
- }
-}
-
-func TestFormatValue_NilPointer(t *testing.T) {
- var nilPtr *float64
- result := formatValue(nil, nilPtr, 3)
- if result != "" {
- t.Errorf("Expected empty string for nil pointer, got: %s", result)
- }
-}
-
-func TestFormatValue_ZeroTime(t *testing.T) {
- result := formatValue(nil, time.Time{}, 3)
- if result != "" {
- t.Errorf("Expected empty string for zero time, got: %s", result)
+ lines := strings.Split(buf.String(), "\n")
+ if len(lines) < 2 {
+ t.Fatalf("Expected header + data row, got: %s", buf.String())
+ }
+ if !strings.Contains(lines[1], "Test,123.456") {
+ t.Errorf("Expected comma delimiter and dot decimal for German locale, got: %s", lines[1])
}
}
diff --git a/util/export/writer.go b/util/export/writer.go
new file mode 100644
index 000000000..d8d9082e5
--- /dev/null
+++ b/util/export/writer.go
@@ -0,0 +1,121 @@
+package export
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "reflect"
+ "strings"
+ "time"
+
+ "github.com/evcc-io/evcc/util/locale"
+ "github.com/fatih/structs"
+ "github.com/nicksnyder/go-i18n/v2/i18n"
+)
+
+// normalizeValue converts a field into a typed cell value: nil for empty, floats rounded to digits, times localized
+func normalizeValue(value any, digits int) any {
+ if rv := reflect.ValueOf(value); rv.Kind() == reflect.Pointer {
+ if rv.IsNil() {
+ return nil
+ }
+ value = rv.Elem().Interface()
+ }
+
+ switch v := value.(type) {
+ case float64:
+ p := math.Pow10(digits)
+ return math.Round(v*p) / p
+ case time.Time:
+ if v.IsZero() {
+ return nil
+ }
+ return v.Local()
+ default:
+ return v
+ }
+}
+
+func writeHeader(ww RowWriter, structType any, i18nPrefix string) error {
+ localizer := ww.Localizer()
+
+ var row []any
+ for _, f := range structs.Fields(structType) {
+ csvTag := f.Tag("csv")
+ if csvTag == "-" {
+ continue
+ }
+
+ caption, err := localizer.Localize(&locale.Config{
+ MessageID: i18nPrefix + "." + strings.ToLower(f.Name()),
+ })
+ if err != nil {
+ if csvTag != "" {
+ caption = csvTag
+ } else {
+ caption = f.Name()
+ }
+ }
+
+ row = append(row, caption)
+ }
+
+ return ww.Write(row)
+}
+
+func writeRow(ww RowWriter, structVal any) error {
+ var row []any
+ for _, f := range structs.Fields(structVal) {
+ if f.Tag("csv") == "-" {
+ continue
+ }
+
+ digits := 4
+ if format := f.Tag("format"); format == "int" {
+ digits = 0
+ }
+
+ row = append(row, normalizeValue(f.Value(), digits))
+ }
+
+ return ww.Write(row)
+}
+
+// Localizer returns an i18n localizer for the context's locale.
+func Localizer(ctx context.Context) *i18n.Localizer {
+ if val, ok := ctx.Value(locale.Locale).(string); ok && val != "" {
+ return i18n.NewLocalizer(locale.Bundle, val, locale.Language)
+ }
+ return locale.Localizer
+}
+
+// WriteStructSlice emits a slice of structs to ww with localized headers.
+func WriteStructSlice(ww RowWriter, slice any, cfg Config) error {
+ sliceVal := reflect.ValueOf(slice)
+ if sliceVal.Kind() == reflect.Pointer {
+ sliceVal = sliceVal.Elem()
+ }
+ if sliceVal.Kind() != reflect.Slice {
+ return fmt.Errorf("expected slice, got %T", slice)
+ }
+
+ var structType any
+ if sliceVal.Len() > 0 {
+ structType = sliceVal.Index(0).Interface()
+ } else {
+ structType = reflect.New(sliceVal.Type().Elem()).Elem().Interface()
+ }
+
+ if err := writeHeader(ww, structType, cfg.I18nPrefix); err != nil {
+ return err
+ }
+
+ for i := 0; i < sliceVal.Len(); i++ {
+ if err := writeRow(ww, sliceVal.Index(i).Interface()); err != nil {
+ return err
+ }
+ }
+
+ ww.Flush()
+ return ww.Error()
+}
diff --git a/util/export/writer_test.go b/util/export/writer_test.go
new file mode 100644
index 000000000..dd9e42619
--- /dev/null
+++ b/util/export/writer_test.go
@@ -0,0 +1,28 @@
+package export
+
+import (
+ "testing"
+ "time"
+)
+
+func TestNormalizeValue_NilPointer(t *testing.T) {
+ var nilPtr *float64
+ if got := normalizeValue(nilPtr, 3); got != nil {
+ t.Errorf("Expected nil for nil pointer, got: %v", got)
+ }
+}
+
+func TestNormalizeValue_ZeroTime(t *testing.T) {
+ if got := normalizeValue(time.Time{}, 3); got != nil {
+ t.Errorf("Expected nil for zero time, got: %v", got)
+ }
+}
+
+func TestNormalizeValue_Rounding(t *testing.T) {
+ if got := normalizeValue(1.23456789, 4); got != 1.2346 {
+ t.Errorf("Expected 1.2346, got: %v", got)
+ }
+ if got := normalizeValue(42.5, 0); got != 43.0 {
+ t.Errorf("Expected 43, got: %v", got)
+ }
+}
diff --git a/util/export/xlsx/xlsx.go b/util/export/xlsx/xlsx.go
new file mode 100644
index 000000000..7ddf08313
--- /dev/null
+++ b/util/export/xlsx/xlsx.go
@@ -0,0 +1,131 @@
+package xlsx
+
+import (
+ "context"
+ "io"
+ "time"
+
+ "github.com/evcc-io/evcc/util/export"
+ "github.com/nicksnyder/go-i18n/v2/i18n"
+ "github.com/xuri/excelize/v2"
+)
+
+// writer adapts excelize to export.RowWriter with native typed cells
+type writer struct {
+ f *excelize.File
+ sheet string
+ dst io.Writer
+ row int
+ err error
+ localizer *i18n.Localizer
+ dateStyle int
+ durStyle int
+}
+
+// cellStyle returns the number format style for typed cells, 0 for default
+func (x *writer) cellStyle(v any) int {
+ switch v.(type) {
+ case time.Time:
+ return x.dateStyle
+ case time.Duration:
+ return x.durStyle
+ default:
+ return 0
+ }
+}
+
+func (x *writer) Write(record []any) error {
+ for i, v := range record {
+ if v == nil {
+ continue
+ }
+ cell, err := excelize.CoordinatesToCellName(i+1, x.row)
+ if err != nil {
+ x.err = err
+ return err
+ }
+ if err := x.f.SetCellValue(x.sheet, cell, v); err != nil {
+ x.err = err
+ return err
+ }
+ if style := x.cellStyle(v); style != 0 {
+ if err := x.f.SetCellStyle(x.sheet, cell, cell, style); err != nil {
+ x.err = err
+ return err
+ }
+ }
+ }
+ if x.row == 1 {
+ if err := x.styleHeader(len(record)); err != nil {
+ x.err = err
+ return err
+ }
+ }
+ x.row++
+ return nil
+}
+
+// styleHeader makes the header row bold and freezes it below the viewport.
+func (x *writer) styleHeader(cols int) error {
+ if cols == 0 {
+ return nil
+ }
+ style, err := x.f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
+ if err != nil {
+ return err
+ }
+ last, err := excelize.CoordinatesToCellName(cols, 1)
+ if err != nil {
+ return err
+ }
+ if err := x.f.SetCellStyle(x.sheet, "A1", last, style); err != nil {
+ return err
+ }
+ return x.f.SetPanes(x.sheet, &excelize.Panes{Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft"})
+}
+
+func (x *writer) Flush() {
+ if x.err != nil {
+ return
+ }
+ if err := x.f.Write(x.dst); err != nil {
+ x.err = err
+ }
+}
+
+func (x *writer) Error() error { return x.err }
+func (x *writer) Localizer() *i18n.Localizer { return x.localizer }
+
+// New returns an xlsx-backed export.RowWriter. The context locale only affects header captions.
+func New(ctx context.Context, w io.Writer) (export.RowWriter, error) {
+ f := excelize.NewFile()
+
+ dateFmt, durFmt := "yyyy-mm-dd hh:mm:ss", "[h]:mm:ss"
+ dateStyle, err := f.NewStyle(&excelize.Style{CustomNumFmt: &dateFmt})
+ if err != nil {
+ return nil, err
+ }
+ durStyle, err := f.NewStyle(&excelize.Style{CustomNumFmt: &durFmt})
+ if err != nil {
+ return nil, err
+ }
+
+ return &writer{
+ f: f,
+ sheet: f.GetSheetName(0),
+ dst: w,
+ row: 1,
+ localizer: export.Localizer(ctx),
+ dateStyle: dateStyle,
+ durStyle: durStyle,
+ }, nil
+}
+
+// WriteStructSlice writes a slice of structs to xlsx with localized headers.
+func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg export.Config) error {
+ ww, err := New(ctx, w)
+ if err != nil {
+ return err
+ }
+ return export.WriteStructSlice(ww, slice, cfg)
+}
diff --git a/util/export/xlsx/xlsx_test.go b/util/export/xlsx/xlsx_test.go
new file mode 100644
index 000000000..f62dc8eff
--- /dev/null
+++ b/util/export/xlsx/xlsx_test.go
@@ -0,0 +1,112 @@
+package xlsx
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/evcc-io/evcc/server/assets"
+ "github.com/evcc-io/evcc/util/export"
+ "github.com/evcc-io/evcc/util/locale"
+ "github.com/xuri/excelize/v2"
+)
+
+func init() {
+ assets.I18n = os.DirFS("../../../i18n")
+ _ = locale.Init()
+}
+
+type TestStruct struct {
+ ID int `json:"id" csv:"-"`
+ Name string `json:"name"`
+ Value float64 `json:"value"`
+ OptValue *float64 `json:"optValue,omitempty"`
+ Created time.Time `json:"created"`
+}
+
+type TestStructs []TestStruct
+
+func TestWriteStructSlice_WithData(t *testing.T) {
+ var buf bytes.Buffer
+ val := 42.5
+ slice := TestStructs{
+ {
+ ID: 1,
+ Name: "Test",
+ Value: 123.456,
+ OptValue: &val,
+ Created: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
+ },
+ }
+
+ ctx := context.WithValue(context.Background(), locale.Locale, "en")
+ if err := WriteStructSlice(ctx, &buf, &slice, export.Config{I18nPrefix: "test.csv"}); err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ f, err := excelize.OpenReader(&buf)
+ if err != nil {
+ t.Fatalf("output is not a valid xlsx: %v", err)
+ }
+ defer f.Close()
+
+ rows, err := f.GetRows(f.GetSheetName(0))
+ if err != nil {
+ t.Fatalf("GetRows: %v", err)
+ }
+ if len(rows) < 2 {
+ t.Fatalf("expected header + data row, got %d rows", len(rows))
+ }
+
+ if got := rows[0]; !equal(got, []string{"Name", "Value", "OptValue", "Created"}) {
+ t.Errorf("unexpected header: %v", got)
+ }
+ if got := rows[1]; !equal(got[:3], []string{"Test", "123.456", "42.5"}) {
+ t.Errorf("unexpected data row: %v", got)
+ }
+
+ sheet := f.GetSheetName(0)
+
+ // numeric cells carry no type attribute, strings are shared strings
+ if typ, err := f.GetCellType(sheet, "B2"); err != nil || typ == excelize.CellTypeSharedString {
+ t.Errorf("expected numeric cell for Value, got type %v, err %v", typ, err)
+ }
+ if v, err := f.GetCellValue(sheet, "D2", excelize.Options{RawCellValue: true}); err != nil || v == "" || strings.Contains(v, "-") {
+ t.Errorf("expected date serial number for Created, got %q, err %v", v, err)
+ }
+
+ styleID, err := f.GetCellStyle(sheet, "A1")
+ if err != nil {
+ t.Fatalf("GetCellStyle: %v", err)
+ }
+ style, err := f.GetStyle(styleID)
+ if err != nil {
+ t.Fatalf("GetStyle: %v", err)
+ }
+ if style.Font == nil || !style.Font.Bold {
+ t.Error("expected bold header")
+ }
+
+ panes, err := f.GetPanes(sheet)
+ if err != nil {
+ t.Fatalf("GetPanes: %v", err)
+ }
+ if !panes.Freeze {
+ t.Error("expected frozen header row")
+ }
+}
+
+func equal(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/util/locale/locale.go b/util/locale/locale.go
index a77a09043..657594785 100644
--- a/util/locale/locale.go
+++ b/util/locale/locale.go
@@ -49,21 +49,31 @@ func Init() error {
Sessions struct {
CSV map[string]string `json:"csv"`
} `json:"sessions"`
+ Config struct {
+ Hems struct {
+ CSV map[string]string `json:"csv"`
+ } `json:"hems"`
+ } `json:"config"`
}
if err := json.Unmarshal(b, &s); err != nil {
return err
}
- if len(s.Sessions.CSV) > 0 {
- m := make([]*i18n.Message, 0, len(s.Sessions.CSV))
- for k, v := range s.Sessions.CSV {
+ var m []*i18n.Message
+ for prefix, msgs := range map[string]map[string]string{
+ "sessions.csv": s.Sessions.CSV,
+ "config.hems.csv": s.Config.Hems.CSV,
+ } {
+ for k, v := range msgs {
m = append(m, &i18n.Message{
- ID: "sessions.csv." + k,
+ ID: prefix + "." + k,
Other: v,
})
}
+ }
+ if len(m) > 0 {
languageTag := language.Make(strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())))
if err := Bundle.AddMessages(languageTag, m...); err != nil {
return fmt.Errorf("loading locales failed: %w", err)