feat: add xlsx export wherever csv export exists (#31690)

This commit is contained in:
andig 2026-07-12 11:58:02 +02:00 • committed by GitHub
parent 3158ff16ef
commit 3ce6600143
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 873 additions and 425 deletions

View file

@ -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

View file

@ -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

View file

@ -38,28 +38,23 @@
{{ $t("config.general.docsLink") }}
</a>
</p>
<div
v-if="sessionCount"
class="alert alert-info my-4 d-flex justify-content-between align-items-start flex-wrap gap-2"
role="alert"
data-testid="grid-sessions"
>
<div class="d-flex flex-wrap">
<span class="me-2">{{
$t("config.hems.eventsRecorded", { count: sessionCount })
}}</span>
<span v-if="lastEventTimeAgo">{{
$t("config.hems.lastEvent", { timeAgo: lastEventTimeAgo })
}}</span>
<div v-if="configured" class="mb-4" data-testid="grid-sessions">
<h6 class="mb-3">{{ $t("config.hems.recordedEvents") }}</h6>
<div class="events-box rounded p-3">
<p v-if="!sessionCount" class="mb-0 text-muted">
{{ $t("config.hems.noEvents") }}
</p>
<template v-else>
<p class="mb-3">
{{ $t("config.hems.eventsRecorded", { count: sessionCount }) }}<br />
<template v-if="lastEventTimeAgo">
{{ $t("config.hems.lastEvent", { timeAgo: lastEventTimeAgo }) }}
</template>
</p>
<DownloadButton :label="$t('general.download')" :href="downloadHref()" />
</template>
</div>
<a
:href="csvLink"
download
class="alert-link text-nowrap"
@click="handleDownloadClick($event, csvLink)"
>
{{ $t("config.hems.downloadCsv") }}
</a>
<hr class="mt-4 mb-0" />
</div>
<p v-if="fromYaml" class="text-muted">
{{ $t("config.general.fromYamlHint") }}
@ -78,7 +73,7 @@ import { customTemplateOption, type TemplateGroup } from "./DeviceModal/Template
import customHemsYaml from "./defaultYaml/customHems.yaml?raw";
import api from "../../api";
import { docsPrefix } from "@/i18n";
import { handleDownloadClick } from "../../utils/native";
import DownloadButton from "../Helper/DownloadButton.vue";
import formatter from "../../mixins/formatter";
const initialValues = {
@ -91,7 +86,7 @@ const initialValues = {
export default defineComponent({
name: "HemsModal",
components: { DeviceModalBase },
components: { DeviceModalBase, DownloadButton },
mixins: [formatter],
props: {
yamlSource: String as PropType<YamlSource>,
@ -109,6 +104,9 @@ export default defineComponent({
fromYaml(): boolean {
return this.yamlSource === "file";
},
configured(): boolean {
return this.id !== undefined || this.fromYaml;
},
sessionCount(): number {
return this.sessions.length;
},
@ -122,19 +120,15 @@ export default defineComponent({
if (!Number.isFinite(ms)) return "";
return (this as any).fmtTimeAgo(ms - Date.now());
},
csvLink(): string {
const params = new URLSearchParams({
format: "csv",
lang: this.$i18n?.locale,
});
return `./api/gridsessions?${params.toString()}`;
},
docsLink(): string {
return `${docsPrefix()}/docs/external-limit`;
},
},
methods: {
handleDownloadClick,
downloadHref(): string {
const params = new URLSearchParams({ lang: this.$i18n?.locale });
return `./api/gridsessions?${params.toString()}`;
},
async loadSessions() {
try {
const response = await api.get("gridsessions", {
@ -192,3 +186,9 @@ export default defineComponent({
},
});
</script>
<style scoped>
.events-box {
background: var(--evcc-background);
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<div class="btn-group">
<a
class="btn btn-outline-secondary"
:href="hrefFor('xlsx')"
download
@click="handleDownloadClick($event, hrefFor('xlsx'))"
>
{{ label }}
</a>
<button
type="button"
class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">{{ label }}</span>
</button>
<ul class="dropdown-menu">
<li v-for="format in formats" :key="format">
<a
class="dropdown-item"
:href="hrefFor(format)"
download
@click="handleDownloadClick($event, hrefFor(format))"
>
{{ format.toUpperCase() }}
</a>
</li>
</ul>
</div>
</template>
<script>
import "bootstrap/js/dist/dropdown";
import { defineComponent } from "vue";
import { handleDownloadClick } from "@/utils/native";
export default defineComponent({
name: "DownloadButton",
props: {
label: { type: String, required: true },
href: { type: String, required: true },
},
computed: {
formats() {
return ["xlsx", "csv", "json"];
},
},
methods: {
handleDownloadClick,
hrefFor(format) {
return `${this.href}${this.href.includes("?") ? "&" : "?"}format=${format}`;
},
},
});
</script>
<style scoped>
.dropdown-menu {
--bs-dropdown-min-width: 0;
}
</style>

View file

@ -87,17 +87,9 @@
@focus="onLegendFocus(group, $event)"
/>
</Card>
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0">
<a
:href="csvLink"
download
class="text-muted small history-csv-link"
data-testid="history-csv-download"
@click="handleDownloadClick($event, csvLink)"
>
{{ $t("main.history.downloadCsv") }}
</a>
</p>
<div v-if="visibleGroups.length" class="d-flex align-items-baseline gap-2 mb-3">
<DownloadButton :label="$t('general.download')" :href="downloadHref()" />
</div>
</main>
</div>
</div>
@ -117,7 +109,7 @@ import { PERIODS } from "../components/Sessions/types";
import { GROUP_ORDER, groupColor, hasColorPicker } from "../components/History/groups";
import colors, { resolveColors, deviceColorMap, darken, batteryColor } from "../colors";
import LegendList from "../components/Sessions/LegendList.vue";
import { handleDownloadClick } from "@/utils/native";
import DownloadButton from "../components/Helper/DownloadButton.vue";
import formatter, { POWER_UNIT } from "../mixins/formatter";
import api from "../api";
import store from "../store";
@ -138,6 +130,7 @@ export default defineComponent({
PeriodHeader,
GroupChart,
LegendList,
DownloadButton,
},
mixins: [formatter],
props: {
@ -316,16 +309,6 @@ export default defineComponent({
}
return this.fmtWh(Math.abs(sum) * 1000, POWER_UNIT.AUTO);
},
csvLink(): string {
const params = new URLSearchParams({
format: "csv",
lang: this.$i18n?.locale,
from: this.from.toISOString(),
to: this.to.toISOString(),
aggregate: this.aggregate,
});
return `./api/history/energy?${params.toString()}`;
},
},
watch: {
fetchKey() {
@ -359,7 +342,15 @@ export default defineComponent({
},
methods: {
groupColor,
handleDownloadClick,
downloadHref(): string {
const params = new URLSearchParams({
lang: this.$i18n?.locale,
from: this.from.toISOString(),
to: this.to.toISOString(),
aggregate: this.aggregate,
});
return `./api/history/energy?${params.toString()}`;
},
legendsForGroup(group: string): Legend[] {
const items = this.entityLegends(group);
const focused = this.focusedEntity[group] ?? null;
@ -571,14 +562,6 @@ export default defineComponent({
opacity: 0.8;
}
}
.history-csv-link {
text-decoration: none;
}
.history-csv-link:hover,
.history-csv-link:focus {
color: var(--evcc-default-text);
text-decoration: underline;
}
@media (max-width: 575.98px) {
/* edge-to-edge on mobile: cancel the container's px-4 padding */
.history-tile {

View file

@ -157,17 +157,8 @@
@show-session="showDetails"
/>
</Card>
<div class="d-flex gap-2 my-3">
<a
class="btn btn-outline-secondary"
tabindex="0"
:href="csvLink"
download
data-testid="sessions-download"
@click="handleDownloadClick($event, csvLink)"
>
{{ csvLinkLabel }}
</a>
<div class="d-flex align-items-baseline gap-2 my-3">
<DownloadButton :label="$t('general.download')" :href="downloadHref()" />
<button
v-if="!showTable"
class="btn btn-link text-muted"
@ -219,7 +210,7 @@ import settings from "../settings";
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
import DateNavigator from "../components/Sessions/DateNavigator.vue";
import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
import { handleDownloadClick } from "@/utils/native";
import DownloadButton from "../components/Helper/DownloadButton.vue";
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types";
import { defineComponent, type PropType } from "vue";
@ -232,6 +223,7 @@ export default defineComponent({
SessionTable,
TopHeader: Header,
Card,
DownloadButton,
EnergyHistoryChart,
EnergyGroupedChart,
IconSelectGroup,
@ -491,28 +483,6 @@ export default defineComponent({
date.setMonth(this.month - 1, 1);
return this.fmtMonth(date, false);
},
csvLinkLabel() {
if (this.period === PERIODS.MONTH) {
const date = new Date();
date.setMonth(this.month - 1, 1);
date.setFullYear(this.year);
const period = this.fmtMonthYear(date);
return this.$t("sessions.csvPeriod", { period });
} else if (this.period === PERIODS.YEAR) {
const period = this.year;
return this.$t("sessions.csvPeriod", { period });
} else {
return this.$t("sessions.csvTotal");
}
},
csvLink() {
if (this.period === PERIODS.MONTH) {
return this.csvHrefLink(this.year, this.month);
} else if (this.period === PERIODS.YEAR) {
return this.csvHrefLink(this.year);
}
return this.csvHrefLink();
},
deviceColors(): DeviceColors {
return deviceColorMap(store.state.deviceColors);
},
@ -657,7 +627,6 @@ export default defineComponent({
this.loadSessions();
},
methods: {
handleDownloadClick,
changePeriod(newPeriod: PERIODS) {
let month: number | undefined = this.month;
let year: number | undefined = this.year;
@ -690,13 +659,14 @@ export default defineComponent({
);
modal.show();
},
csvHrefLink(year?: number, month?: number) {
const params = new URLSearchParams({
format: "csv",
lang: this.$i18n?.locale,
});
if (year) params.append("year", year.toString());
if (month) params.append("month", month.toString());
downloadHref() {
const params = new URLSearchParams({ lang: this.$i18n?.locale });
if (this.period === PERIODS.MONTH || this.period === PERIODS.YEAR) {
params.append("year", this.year.toString());
}
if (this.period === PERIODS.MONTH) {
params.append("month", this.month.toString());
}
return `./api/sessions?${params.toString()}`;
},
updateType(type: TYPES) {

View file

@ -12,6 +12,9 @@ import (
"time"
"github.com/evcc-io/evcc/core/metrics"
"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/spf13/cobra"
)
@ -34,6 +37,8 @@ func init() {
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.Flags().Bool("xlsx", false, "Output XLSX instead of a table")
metricsDataCmd.MarkFlagsMutuallyExclusive("csv", "xlsx")
metricsDataCmd.MarkFlagsMutuallyExclusive("range", "from")
metricsDataCmd.MarkFlagsMutuallyExclusive("range", "to")
}
@ -73,8 +78,10 @@ func runMetricsData(cmd *cobra.Command, args []string) {
byEntity[s.Group+"/"+s.Title] = s
}
if asCSV, _ := cmd.Flags().GetBool("csv"); asCSV {
var out metrics.SeriesCSV
asCSV, _ := cmd.Flags().GetBool("csv")
asXLSX, _ := cmd.Flags().GetBool("xlsx")
if asCSV || asXLSX {
var out metrics.SeriesExport
seen := make(map[string]bool, len(selected))
for _, e := range selected {
key := e.Group + "/" + metricsEntityLabel(e)
@ -86,7 +93,18 @@ func runMetricsData(cmd *cobra.Command, args []string) {
out = append(out, s)
}
}
if err := out.WriteCsv(context.Background(), os.Stdout); err != nil {
var ww export.RowWriter
var err error
if asXLSX {
ww, err = xlsx.New(context.Background(), os.Stdout)
} else {
ww, err = csv.New(context.Background(), os.Stdout)
}
if err != nil {
log.FATAL.Fatal(err)
}
if err := out.Write(ww); err != nil {
log.FATAL.Fatal(err)
}
return

View file

@ -1,18 +1,15 @@
package metrics
import (
"context"
"errors"
"fmt"
"io"
"math"
"slices"
"sort"
"strconv"
"time"
"github.com/evcc-io/evcc/server/db"
csvutil "github.com/evcc-io/evcc/util/csv"
"github.com/evcc-io/evcc/util/export"
)
// Slot represents an aggregated energy time slot
@ -37,8 +34,8 @@ type Series struct {
Data []Slot `json:"data"`
}
// SeriesCSV wraps a slice of Series for CSV export.
type SeriesCSV []Series
// SeriesExport wraps a slice of Series for tabular export.
type SeriesExport []Series
// GroupOrder is the canonical display order of metric groups, mirroring the
// frontend GROUP_ORDER plus home/forecast.
@ -164,28 +161,17 @@ func seriesHasSocTemp(s *Series) bool {
return slices.ContainsFunc(s.Data, func(slot Slot) bool { return slot.SocTemp != nil })
}
// formatSocTemp renders a soc/temp value rounded to 0.1, empty when unset.
func formatSocTemp(v *float64) string {
// socTempValue rounds a soc/temp value to 0.1, nil when unset
func socTempValue(v *float64) any {
if v == nil {
return ""
return nil
}
return strconv.FormatFloat(math.Round(*v*10)/10, 'f', -1, 64)
}
// WriteCsv emits a wide-table CSV with columns
//
// time.start, time.end, <group>.<entity>.energy.Wh[, <group>.<entity>.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
return math.Round(*v*10) / 10
}
// 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 {

View file

@ -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")

View file

@ -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",
})
}

6
go.mod
View file

@ -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

14
go.sum
View file

@ -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=

View file

@ -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",
})
}

View file

@ -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": {

View file

@ -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",

View file

@ -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
}

View file

@ -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
}

View file

@ -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) {
// 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)
}
// 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"`)
}
if ww, ok := api.Cap[api.CsvWriter](res); ok {
_ = ww.WriteCsv(ctx, w)
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
}

View file

@ -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"),
});
});
});

View file

@ -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"),
});
});
});

View file

@ -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()
}

22
util/export/api.go Normal file
View file

@ -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
}

56
util/export/csv/csv.go Normal file
View file

@ -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)
}

View file

@ -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])
}
}

121
util/export/writer.go Normal file
View file

@ -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()
}

View file

@ -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)
}
}

131
util/export/xlsx/xlsx.go Normal file
View file

@ -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)
}

View file

@ -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
}

View file

@ -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)