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) - Implement accessibility features (tabindex, aria-label, keyboard handlers)
- Use descriptive names for variables, functions, and event handlers - Use descriptive names for variables, functions, and event handlers
- Use early returns for readability - 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 - Use configured Axios instance for HTTP communication
### State Management ### State Management

View file

@ -1,8 +1,6 @@
package api package api
import ( import (
"context"
"io"
"net/url" "net/url"
"time" "time"
@ -254,11 +252,6 @@ type TitleDescriber interface {
GetTitle() string 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 // CircuitMeasurements is the measurements a circuit or load must deliver
type CircuitMeasurements interface { type CircuitMeasurements interface {
GetChargePower() float64 GetChargePower() float64

View file

@ -38,28 +38,23 @@
{{ $t("config.general.docsLink") }} {{ $t("config.general.docsLink") }}
</a> </a>
</p> </p>
<div <div v-if="configured" class="mb-4" data-testid="grid-sessions">
v-if="sessionCount" <h6 class="mb-3">{{ $t("config.hems.recordedEvents") }}</h6>
class="alert alert-info my-4 d-flex justify-content-between align-items-start flex-wrap gap-2" <div class="events-box rounded p-3">
role="alert" <p v-if="!sessionCount" class="mb-0 text-muted">
data-testid="grid-sessions" {{ $t("config.hems.noEvents") }}
> </p>
<div class="d-flex flex-wrap"> <template v-else>
<span class="me-2">{{ <p class="mb-3">
$t("config.hems.eventsRecorded", { count: sessionCount }) {{ $t("config.hems.eventsRecorded", { count: sessionCount }) }}<br />
}}</span> <template v-if="lastEventTimeAgo">
<span v-if="lastEventTimeAgo">{{ {{ $t("config.hems.lastEvent", { timeAgo: lastEventTimeAgo }) }}
$t("config.hems.lastEvent", { timeAgo: lastEventTimeAgo }) </template>
}}</span> </p>
<DownloadButton :label="$t('general.download')" :href="downloadHref()" />
</template>
</div> </div>
<a <hr class="mt-4 mb-0" />
:href="csvLink"
download
class="alert-link text-nowrap"
@click="handleDownloadClick($event, csvLink)"
>
{{ $t("config.hems.downloadCsv") }}
</a>
</div> </div>
<p v-if="fromYaml" class="text-muted"> <p v-if="fromYaml" class="text-muted">
{{ $t("config.general.fromYamlHint") }} {{ $t("config.general.fromYamlHint") }}
@ -78,7 +73,7 @@ import { customTemplateOption, type TemplateGroup } from "./DeviceModal/Template
import customHemsYaml from "./defaultYaml/customHems.yaml?raw"; import customHemsYaml from "./defaultYaml/customHems.yaml?raw";
import api from "../../api"; import api from "../../api";
import { docsPrefix } from "@/i18n"; import { docsPrefix } from "@/i18n";
import { handleDownloadClick } from "../../utils/native"; import DownloadButton from "../Helper/DownloadButton.vue";
import formatter from "../../mixins/formatter"; import formatter from "../../mixins/formatter";
const initialValues = { const initialValues = {
@ -91,7 +86,7 @@ const initialValues = {
export default defineComponent({ export default defineComponent({
name: "HemsModal", name: "HemsModal",
components: { DeviceModalBase }, components: { DeviceModalBase, DownloadButton },
mixins: [formatter], mixins: [formatter],
props: { props: {
yamlSource: String as PropType<YamlSource>, yamlSource: String as PropType<YamlSource>,
@ -109,6 +104,9 @@ export default defineComponent({
fromYaml(): boolean { fromYaml(): boolean {
return this.yamlSource === "file"; return this.yamlSource === "file";
}, },
configured(): boolean {
return this.id !== undefined || this.fromYaml;
},
sessionCount(): number { sessionCount(): number {
return this.sessions.length; return this.sessions.length;
}, },
@ -122,19 +120,15 @@ export default defineComponent({
if (!Number.isFinite(ms)) return ""; if (!Number.isFinite(ms)) return "";
return (this as any).fmtTimeAgo(ms - Date.now()); 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 { docsLink(): string {
return `${docsPrefix()}/docs/external-limit`; return `${docsPrefix()}/docs/external-limit`;
}, },
}, },
methods: { methods: {
handleDownloadClick, downloadHref(): string {
const params = new URLSearchParams({ lang: this.$i18n?.locale });
return `./api/gridsessions?${params.toString()}`;
},
async loadSessions() { async loadSessions() {
try { try {
const response = await api.get("gridsessions", { const response = await api.get("gridsessions", {
@ -192,3 +186,9 @@ export default defineComponent({
}, },
}); });
</script> </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)" @focus="onLegendFocus(group, $event)"
/> />
</Card> </Card>
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0"> <div v-if="visibleGroups.length" class="d-flex align-items-baseline gap-2 mb-3">
<a <DownloadButton :label="$t('general.download')" :href="downloadHref()" />
:href="csvLink" </div>
download
class="text-muted small history-csv-link"
data-testid="history-csv-download"
@click="handleDownloadClick($event, csvLink)"
>
{{ $t("main.history.downloadCsv") }}
</a>
</p>
</main> </main>
</div> </div>
</div> </div>
@ -117,7 +109,7 @@ import { PERIODS } from "../components/Sessions/types";
import { GROUP_ORDER, groupColor, hasColorPicker } from "../components/History/groups"; import { GROUP_ORDER, groupColor, hasColorPicker } from "../components/History/groups";
import colors, { resolveColors, deviceColorMap, darken, batteryColor } from "../colors"; import colors, { resolveColors, deviceColorMap, darken, batteryColor } from "../colors";
import LegendList from "../components/Sessions/LegendList.vue"; 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 formatter, { POWER_UNIT } from "../mixins/formatter";
import api from "../api"; import api from "../api";
import store from "../store"; import store from "../store";
@ -138,6 +130,7 @@ export default defineComponent({
PeriodHeader, PeriodHeader,
GroupChart, GroupChart,
LegendList, LegendList,
DownloadButton,
}, },
mixins: [formatter], mixins: [formatter],
props: { props: {
@ -316,16 +309,6 @@ export default defineComponent({
} }
return this.fmtWh(Math.abs(sum) * 1000, POWER_UNIT.AUTO); 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: { watch: {
fetchKey() { fetchKey() {
@ -359,7 +342,15 @@ export default defineComponent({
}, },
methods: { methods: {
groupColor, 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[] { legendsForGroup(group: string): Legend[] {
const items = this.entityLegends(group); const items = this.entityLegends(group);
const focused = this.focusedEntity[group] ?? null; const focused = this.focusedEntity[group] ?? null;
@ -571,14 +562,6 @@ export default defineComponent({
opacity: 0.8; 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) { @media (max-width: 575.98px) {
/* edge-to-edge on mobile: cancel the container's px-4 padding */ /* edge-to-edge on mobile: cancel the container's px-4 padding */
.history-tile { .history-tile {

View file

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

View file

@ -12,6 +12,9 @@ import (
"time" "time"
"github.com/evcc-io/evcc/core/metrics" "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" "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("aggregate", "hour", "Aggregation interval: 15m, hour, day or month")
metricsDataCmd.Flags().String("group", "", "Limit output to an entity group") metricsDataCmd.Flags().String("group", "", "Limit output to an entity group")
metricsDataCmd.Flags().Bool("csv", false, "Output CSV instead of a table") 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", "from")
metricsDataCmd.MarkFlagsMutuallyExclusive("range", "to") metricsDataCmd.MarkFlagsMutuallyExclusive("range", "to")
} }
@ -73,8 +78,10 @@ func runMetricsData(cmd *cobra.Command, args []string) {
byEntity[s.Group+"/"+s.Title] = s byEntity[s.Group+"/"+s.Title] = s
} }
if asCSV, _ := cmd.Flags().GetBool("csv"); asCSV { asCSV, _ := cmd.Flags().GetBool("csv")
var out metrics.SeriesCSV asXLSX, _ := cmd.Flags().GetBool("xlsx")
if asCSV || asXLSX {
var out metrics.SeriesExport
seen := make(map[string]bool, len(selected)) seen := make(map[string]bool, len(selected))
for _, e := range selected { for _, e := range selected {
key := e.Group + "/" + metricsEntityLabel(e) key := e.Group + "/" + metricsEntityLabel(e)
@ -86,7 +93,18 @@ func runMetricsData(cmd *cobra.Command, args []string) {
out = append(out, s) 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) log.FATAL.Fatal(err)
} }
return return

View file

@ -1,18 +1,15 @@
package metrics package metrics
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io"
"math" "math"
"slices" "slices"
"sort" "sort"
"strconv"
"time" "time"
"github.com/evcc-io/evcc/server/db" "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 // Slot represents an aggregated energy time slot
@ -37,8 +34,8 @@ type Series struct {
Data []Slot `json:"data"` Data []Slot `json:"data"`
} }
// SeriesCSV wraps a slice of Series for CSV export. // SeriesExport wraps a slice of Series for tabular export.
type SeriesCSV []Series type SeriesExport []Series
// GroupOrder is the canonical display order of metric groups, mirroring the // GroupOrder is the canonical display order of metric groups, mirroring the
// frontend GROUP_ORDER plus home/forecast. // 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 }) 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. // socTempValue rounds a soc/temp value to 0.1, nil when unset
func formatSocTemp(v *float64) string { func socTempValue(v *float64) any {
if v == nil { if v == nil {
return "" return nil
} }
return strconv.FormatFloat(math.Round(*v*10)/10, 'f', -1, 64) return math.Round(*v*10) / 10
} }
// WriteCsv emits a wide-table CSV with columns // 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.
// time.start, time.end, <group>.<entity>.energy.Wh[, <group>.<entity>.returnEnergy.Wh], … func (s SeriesExport) Write(ww export.RowWriter) error {
//
// 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
}
byGroup := make(map[string][]*Series) byGroup := make(map[string][]*Series)
for i := range s { for i := range s {
g := s[i].Group 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 { type col struct {
series *Series series *Series
returnEnergy bool 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 { for _, ts := range timestamps {
row[0] = ts.Local().Format("2006-01-02 15:04:05") row[0] = ts.Local()
row[1] = endByStart[ts.UnixNano()].Local().Format("2006-01-02 15:04:05") row[1] = endByStart[ts.UnixNano()].Local()
for i := 2; i < len(cols); i++ { for i := 2; i < len(cols); i++ {
c := cols[i] c := cols[i]
if c.series == nil { if c.series == nil {
row[i] = "" row[i] = nil
continue continue
} }
slot, ok := slotIdx[c.series][ts.UnixNano()] slot, ok := slotIdx[c.series][ts.UnixNano()]
if !ok { if !ok {
row[i] = "" row[i] = nil
continue continue
} }
switch { switch {
case c.socTemp: case c.socTemp:
row[i] = formatSocTemp(slot.SocTemp) row[i] = socTempValue(slot.SocTemp)
case c.returnEnergy: case c.returnEnergy:
row[i] = strconv.FormatInt(int64(math.Round(slot.ReturnEnergy*1000)), 10) row[i] = int64(math.Round(slot.ReturnEnergy * 1000))
default: 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 { if err := ww.Write(row); err != nil {

View file

@ -4,11 +4,13 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/csv" "encoding/csv"
"io"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/evcc-io/evcc/server/db" "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/evcc-io/evcc/util/locale"
"github.com/stretchr/testify/require" "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} 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) t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(15 * time.Minute) 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: 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-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)}}, {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 var buf bytes.Buffer
require.NoError(t, series.WriteCsv(context.Background(), &buf)) require.NoError(t, writeSeriesCsv(t, context.Background(), series, &buf))
// UTF-8 BOM // UTF-8 BOM
require.True(t, bytes.HasPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF}), "expected 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 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) 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)}}, {Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2.5, 0)}},
} }
var buf bytes.Buffer var buf bytes.Buffer
ctx := context.WithValue(context.Background(), locale.Locale, "de") 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})) s := string(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF}))
require.True(t, strings.Contains(s, ";"), "german CSV must use ';' separator") require.False(t, strings.Contains(s, ";"), "delimiter is ',' regardless of locale")
// Values are plain Wh integers so they're locale-agnostic. We just check
// no decimal point / comma snuck in.
require.True(t, strings.Contains(s, "2500"), "value should be Wh integer") 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")
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) t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(15 * time.Minute) t1 := t0.Add(15 * time.Minute)
// Two entities, second one only has data for the first timestamp → second // Two entities, second one only has data for the first timestamp → second
// timestamp must produce an empty cell rather than 0.000. // timestamp must produce an empty cell rather than 0.000.
series := SeriesCSV{ series := SeriesExport{
{Group: PV, Title: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}}, {Group: PV, Title: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}},
{Group: PV, Title: "b", Data: []Slot{mkSlot(t0, 3, 0)}}, {Group: PV, Title: "b", Data: []Slot{mkSlot(t0, 3, 0)}},
} }
var buf bytes.Buffer var buf bytes.Buffer
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}))) r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
rows, err := r.ReadAll() 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") 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 // Battery is bidirectional, so even a series with zero discharge keeps the
// returnEnergy column. // returnEnergy column.
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
series := SeriesCSV{ series := SeriesExport{
{Group: Battery, Title: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}}, {Group: Battery, Title: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}},
} }
var buf bytes.Buffer var buf bytes.Buffer
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}))) r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
rows, err := r.ReadAll() rows, err := r.ReadAll()
@ -134,7 +141,7 @@ func TestSeriesCSV_BatteryHasReturnEnergyColumn(t *testing.T) {
require.Equal(t, "0", rows[1][3]) 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) t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(15 * time.Minute) t1 := t0.Add(15 * time.Minute)
@ -144,14 +151,14 @@ func TestSeriesCSV_SocTempColumns(t *testing.T) {
return s return s
} }
series := SeriesCSV{ series := SeriesExport{
{Group: Battery, Title: "bat", Data: []Slot{slot(t0, new(90.0)), slot(t1, new(80.0))}}, {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: 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)}}, {Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2, 0), mkSlot(t1, 3, 0)}},
} }
var buf bytes.Buffer var buf bytes.Buffer
require.NoError(t, series.WriteCsv(context.Background(), &buf)) require.NoError(t, writeSeriesCsv(t, context.Background(), series, &buf))
out := buf.String() out := buf.String()
require.Contains(t, out, "battery.bat.soc.pct") require.Contains(t, out, "battery.bat.soc.pct")

View file

@ -1,12 +1,9 @@
package session package session
import ( import (
"context"
"io"
"time" "time"
"github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util/export"
csvutil "github.com/evcc-io/evcc/util/csv"
) )
// Session is a single charging session // Session is a single charging session
@ -35,11 +32,11 @@ type Session struct {
// Sessions is a list of sessions // Sessions is a list of sessions
type Sessions []Session type Sessions []Session
var _ api.CsvWriter = (*Sessions)(nil) var _ export.Writer = (*Sessions)(nil)
// WriteCsv implements the api.CsvWriter interface // Write implements the export.Writer interface
func (t *Sessions) WriteCsv(ctx context.Context, w io.Writer) error { func (t *Sessions) Write(ww export.RowWriter) error {
return csvutil.WriteStructSlice(ctx, w, t, csvutil.Config{ return export.WriteStructSlice(ww, t, export.Config{
I18nPrefix: "sessions.csv", I18nPrefix: "sessions.csv",
}) })
} }

6
go.mod
View file

@ -101,6 +101,7 @@ require (
github.com/traefik/yaegi v0.16.1 github.com/traefik/yaegi v0.16.1
github.com/volkszaehler/mbmd v0.0.0-20260623211432-fb8f9c22f121 github.com/volkszaehler/mbmd v0.0.0-20260623211432-fb8f9c22f121
github.com/warthog618/go-gpiocdev v0.9.1 github.com/warthog618/go-gpiocdev v0.9.1
github.com/xuri/excelize/v2 v2.11.0
gitlab.com/bboehmke/sunny v0.17.0 gitlab.com/bboehmke/sunny v0.17.0
go.bug.st/serial v1.7.1 go.bug.st/serial v1.7.1
go.uber.org/mock v0.6.0 go.uber.org/mock v0.6.0
@ -216,6 +217,8 @@ require (
github.com/quic-go/webtransport-go v0.10.0 // indirect github.com/quic-go/webtransport-go v0.10.0 // indirect
github.com/relvacode/iso8601 v1.6.0 // indirect github.com/relvacode/iso8601 v1.6.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/period v1.0.22 // indirect
github.com/rickb777/plural v1.4.7 // indirect github.com/rickb777/plural v1.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // 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/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/teivah/onecontext v1.3.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/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // 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 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
gitlab.com/c0b/go-ordered-json v0.0.0-20201030195603-febf46534d5a // indirect gitlab.com/c0b/go-ordered-json v0.0.0-20201030195603-febf46534d5a // indirect
go.yaml.in/yaml/v3 v3.0.4 // 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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:1JE3CfYGyuhN5OTu5nqvIF3VjS2AoqoctcGlbNTVl3w=
github.com/rickb777/expect v1.0.6/go.mod h1:raunaduUM/p8CzpTZeDmoexwlIFF+Peg0Mj/p//9mkA= 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= 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/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 h1:whgDCnunp97KCC7TF94E3l1hGo080ICSRs6ei8jCnzs=
github.com/tess1o/go-ecoflow v1.1.1-0.20251003083510-2ccc15a17e29/go.mod h1:kqrfk4Y14pHCPKIwp6o7ZbgpohUzQHh7EMX+RUA4JCU= 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 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY= github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 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-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 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4=
github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU= 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 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 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= 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/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 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= 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/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.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=

View file

@ -1,11 +1,9 @@
package smartgrid package smartgrid
import ( import (
"context"
"io"
"time" "time"
csvutil "github.com/evcc-io/evcc/util/csv" "github.com/evcc-io/evcc/util/export"
) )
type GridSession struct { type GridSession struct {
@ -26,9 +24,11 @@ const (
type GridSessions []GridSession type GridSessions []GridSession
// WriteCsv implements the api.CsvWriter interface var _ export.Writer = (*GridSessions)(nil)
func (t *GridSessions) WriteCsv(ctx context.Context, w io.Writer) error {
return csvutil.WriteStructSlice(ctx, w, t, csvutil.Config{ // 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", I18nPrefix: "config.hems.csv",
}) })
} }

View file

@ -370,6 +370,8 @@
"eventsRecorded": "{count} Netzbegrenzungen gespeichert.", "eventsRecorded": "{count} Netzbegrenzungen gespeichert.",
"integrations": "Integrationen", "integrations": "Integrationen",
"lastEvent": "Letzter Eintrag {timeAgo}.", "lastEvent": "Letzter Eintrag {timeAgo}.",
"noEvents": "Es wurden noch keine Ereignisse aufgezeichnet.",
"recordedEvents": "Aufgezeichnete Ereignisse",
"template": "Integration", "template": "Integration",
"title": "Externe Begrenzung" "title": "Externe Begrenzung"
}, },
@ -1131,6 +1133,7 @@
} }
}, },
"general": { "general": {
"download": "Download",
"note": "Hinweis:" "note": "Hinweis:"
}, },
"header": { "header": {

View file

@ -365,11 +365,13 @@
}, },
"customOption": "User-defined integration", "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.", "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.", "eventsRecorded": "Recorded {count} grid limitation events.",
"generic": "Generic integrations", "generic": "Generic integrations",
"integrations": "Integrations", "integrations": "Integrations",
"lastEvent": "Most recent {timeAgo}.", "lastEvent": "Most recent {timeAgo}.",
"noEvents": "No events recorded yet.",
"recordedEvents": "Recorded events",
"template": "Integration", "template": "Integration",
"title": "External Limit" "title": "External Limit"
}, },
@ -1131,6 +1133,7 @@
} }
}, },
"general": { "general": {
"download": "Download",
"note": "Note:" "note": "Note:"
}, },
"header": { "header": {
@ -1330,7 +1333,7 @@
"returnEnergy": "Energy (reverse)" "returnEnergy": "Energy (reverse)"
} }
}, },
"downloadCsv": "Download CSV", "download": "Download",
"empty": "No history data available for this time range.", "empty": "No history data available for this time range.",
"group": { "group": {
"battery": "Battery", "battery": "Battery",
@ -1595,9 +1598,9 @@
"solarpercentage": "Solar (%)", "solarpercentage": "Solar (%)",
"vehicle": "Vehicle" "vehicle": "Vehicle"
}, },
"csvPeriod": "Download {period} CSV",
"csvTotal": "Download total CSV",
"date": "Start", "date": "Start",
"download": "Download {period}",
"downloadTotal": "Download total",
"energy": "Charged", "energy": "Charged",
"filter": { "filter": {
"allLoadpoints": "all charging points", "allLoadpoints": "all charging points",

View file

@ -25,7 +25,14 @@ func gridSessionsHandler(w http.ResponseWriter, r *http.Request) {
return 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") lang := r.URL.Query().Get("lang")
if lang == "" { if lang == "" {
// get request language // get request language
@ -36,7 +43,7 @@ func gridSessionsHandler(w http.ResponseWriter, r *http.Request) {
} }
ctx := context.WithValue(context.Background(), locale.Locale, lang) ctx := context.WithValue(context.Background(), locale.Locale, lang)
csvResult(ctx, w, &res, "gridsessions") exportResult(ctx, w, format, &res, "gridsessions")
return return
} }

View file

@ -58,7 +58,14 @@ func energyHistoryHandler(w http.ResponseWriter, r *http.Request) {
return 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") lang := q.Get("lang")
if lang == "" { if lang == "" {
if tags, _, err := language.ParseAcceptLanguage(r.Header.Get("Accept-Language")); err == nil && len(tags) > 0 { 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) 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 return
} }

View file

@ -9,21 +9,43 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/session" "github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/server/db" "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/evcc-io/evcc/util/locale"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"golang.org/x/text/language" "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
w.Header().Set("Content-Type", "text/csv") func jsonAttachment(w http.ResponseWriter, res any, filename string) {
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`.csv"`) w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`.json"`)
jsonWrite(w, res)
}
if ww, ok := api.Cap[api.CsvWriter](res); ok { // exportResult writes res to w as format (csv|xlsx) with download headers.
_ = ww.WriteCsv(ctx, w) 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 { } 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) 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") lang := r.URL.Query().Get("lang")
if lang == "" { if lang == "" {
// get request language // get request language
@ -83,7 +112,7 @@ func sessionHandler(w http.ResponseWriter, r *http.Request) {
} }
ctx := context.WithValue(context.Background(), locale.Locale, lang) ctx := context.WithValue(context.Background(), locale.Locale, lang)
csvResult(ctx, w, &res, filename) exportResult(ctx, w, format, &res, filename)
return return
} }

View file

@ -23,7 +23,7 @@ test.afterEach(async () => {
const CONFIG = "fast.evcc.yaml"; const CONFIG = "fast.evcc.yaml";
test.describe("HEMS", () => { 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 start(CONFIG, "hems.sql");
await page.goto("/#/config"); await page.goto("/#/config");
@ -31,12 +31,7 @@ test.describe("HEMS", () => {
const hemsModal = page.getByTestId("hems-modal"); const hemsModal = page.getByTestId("hems-modal");
await expectModalVisible(hemsModal); await expectModalVisible(hemsModal);
await expect(hemsModal.getByTestId("grid-sessions")).toBeVisible(); await expect(hemsModal.getByTestId("grid-sessions")).not.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/);
// template selector is shown in create mode, yaml editor hidden until "User-defined" is chosen // template selector is shown in create mode, yaml editor hidden until "User-defined" is chosen
await expect(hemsModal.getByLabel("Integration")).toBeVisible(); await expect(hemsModal.getByLabel("Integration")).toBeVisible();
@ -47,6 +42,33 @@ test.describe("HEMS", () => {
await expectModalHidden(hemsModal); 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 }) => { test("modal yaml-configured is locked", async ({ page }) => {
await start("hems-yaml.evcc.yaml"); await start("hems-yaml.evcc.yaml");
await page.goto("/#/config"); await page.goto("/#/config");
@ -55,7 +77,8 @@ test.describe("HEMS", () => {
const hemsModal = page.getByTestId("hems-modal"); const hemsModal = page.getByTestId("hems-modal");
await expectModalVisible(hemsModal); 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).toContainText("Configured via evcc.yaml");
await expect(hemsModal.getByLabel("Integration")).not.toBeVisible(); await expect(hemsModal.getByLabel("Integration")).not.toBeVisible();
await expect(hemsModal.getByTestId("yaml-editor")).not.toBeVisible(); await expect(hemsModal.getByTestId("yaml-editor")).not.toBeVisible();
@ -317,18 +340,18 @@ w3:
test.describe("grid sessions CSV in app context", () => { test.describe("grid sessions CSV in app context", () => {
test("dispatches download event", async ({ page }) => { test("dispatches download event", async ({ page }) => {
await enableAppContext(page); await enableAppContext(page);
await start(CONFIG, "hems.sql"); await start("hems-yaml.evcc.yaml", "hems.sql");
await page.goto("/#/config"); await page.goto("/#/config");
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click(); await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
const hemsModal = page.getByTestId("hems-modal"); const hemsModal = page.getByTestId("hems-modal");
await expectModalVisible(hemsModal); await expectModalVisible(hemsModal);
const csvLink = hemsModal.getByRole("link", { name: "Download CSV" }); await hemsModal.getByRole("button", { name: "Download" }).click();
await csvLink.click(); await hemsModal.getByRole("link", { name: "CSV" }).click();
expect(await expectAppEvent(page)).toMatchObject({ expect(await expectAppEvent(page)).toMatchObject({
type: "download", 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 date = new Date();
const YEAR = date.getFullYear(); const YEAR = date.getFullYear();
const MONTH = date.getMonth() + 1; 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.describe("csv export", async () => {
test("total export", async ({ page }) => { test("total export", async ({ page }) => {
await page.goto("/#/sessions?period=total"); 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", "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 }) => { test("year export", async ({ page }) => {
// fixed year // fixed year
await page.goto("/#/sessions?period=year&year=2023"); 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", "href",
"./api/sessions?format=csv&lang=en&year=2023" "./api/sessions?lang=en&year=2023&format=csv"
); );
// current year // current year
await page.goto(`/#/sessions?period=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", "href",
`./api/sessions?format=csv&lang=en&year=${YEAR}` `./api/sessions?lang=en&year=${YEAR}&format=csv`
); );
}); });
test("monthly export", async ({ page }) => { test("monthly export", async ({ page }) => {
await page.goto("/#/sessions?&year=2023&month=5"); 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", "href",
"./api/sessions?format=csv&lang=en&year=2023&month=5" "./api/sessions?lang=en&year=2023&month=5&format=csv"
); );
// current month // current month
await page.goto(`/#/sessions`); 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", "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 }) => { test("in browser context", async ({ page }) => {
await page.goto("/#/sessions?period=total"); await page.goto("/#/sessions?period=total");
const downloadPromise = page.waitForEvent("download"); 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; 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 }) => { test("in app context", async ({ page }) => {
await enableAppContext(page); await enableAppContext(page);
await page.goto("/#/sessions?period=total"); 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({ expect(await expectAppEvent(page)).toMatchObject({
type: "download", 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" "time"
"github.com/evcc-io/evcc/server/assets" "github.com/evcc-io/evcc/server/assets"
"github.com/evcc-io/evcc/util/export"
"github.com/evcc-io/evcc/util/locale" "github.com/evcc-io/evcc/util/locale"
) )
func init() { func init() {
assets.I18n = os.DirFS("../../i18n") assets.I18n = os.DirFS("../../../i18n")
_ = locale.Init() _ = locale.Init()
} }
@ -32,17 +33,12 @@ func TestWriteStructSlice_Empty(t *testing.T) {
slice := TestStructs{} slice := TestStructs{}
ctx := context.WithValue(context.Background(), locale.Locale, "en") ctx := context.WithValue(context.Background(), locale.Locale, "en")
err := WriteStructSlice(ctx, &buf, &slice, Config{ if err := WriteStructSlice(ctx, &buf, &slice, export.Config{I18nPrefix: "test.csv"}); err != nil {
I18nPrefix: "test.csv",
})
if err != nil {
t.Errorf("Expected no error, got %v", err) t.Errorf("Expected no error, got %v", err)
} }
output := buf.String() if !strings.Contains(buf.String(), "Name") {
if !strings.Contains(output, "Name") { t.Errorf("Expected header with 'Name', got: %s", buf.String())
t.Errorf("Expected header with 'Name', got: %s", output)
} }
} }
@ -60,16 +56,11 @@ func TestWriteStructSlice_WithData(t *testing.T) {
} }
ctx := context.WithValue(context.Background(), locale.Locale, "en") ctx := context.WithValue(context.Background(), locale.Locale, "en")
err := WriteStructSlice(ctx, &buf, &slice, Config{ if err := WriteStructSlice(ctx, &buf, &slice, export.Config{I18nPrefix: "test.csv"}); err != nil {
I18nPrefix: "test.csv",
})
if err != nil {
t.Errorf("Expected no error, got %v", err) t.Errorf("Expected no error, got %v", err)
} }
output := buf.String() output := buf.String()
if !strings.HasPrefix(output, "\xEF\xBB\xBF") { if !strings.HasPrefix(output, "\xEF\xBB\xBF") {
t.Error("Expected UTF-8 BOM at start") 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)) t.Fatalf("Expected at least 2 lines (header + data), got: %d", len(lines))
} }
expectedHeader := "Name,Value,OptValue,Created" if lines[0] != "Name,Value,OptValue,Created" {
if lines[0] != expectedHeader { t.Errorf("Expected header: Name,Value,OptValue,Created\nGot: %s", lines[0])
t.Errorf("Expected header: %s\nGot: %s", expectedHeader, lines[0])
} }
if !strings.Contains(lines[1], "Test,123.456,42.5,2024-01-01") { 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 var buf bytes.Buffer
slice := TestStructs{ slice := TestStructs{
{Name: "Test", Value: 123.456}, {Name: "Test", Value: 123.456},
} }
ctx := context.WithValue(context.Background(), locale.Locale, "de") ctx := context.WithValue(context.Background(), locale.Locale, "de")
err := WriteStructSlice(ctx, &buf, &slice, Config{ if err := WriteStructSlice(ctx, &buf, &slice, export.Config{I18nPrefix: "test.csv"}); err != nil {
I18nPrefix: "test.csv",
})
if err != nil {
t.Errorf("Expected no error, got %v", err) t.Errorf("Expected no error, got %v", err)
} }
output := buf.String() lines := strings.Split(buf.String(), "\n")
lines := strings.Split(output, "\n") if len(lines) < 2 {
if len(lines) > 1 && !strings.Contains(lines[1], ";") { t.Fatalf("Expected header + data row, got: %s", buf.String())
t.Errorf("Expected German CSV to use ';' separator, got: %s", lines[1]) }
} if !strings.Contains(lines[1], "Test,123.456") {
} t.Errorf("Expected comma delimiter and dot decimal for German locale, 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)
} }
} }

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 { Sessions struct {
CSV map[string]string `json:"csv"` CSV map[string]string `json:"csv"`
} `json:"sessions"` } `json:"sessions"`
Config struct {
Hems struct {
CSV map[string]string `json:"csv"`
} `json:"hems"`
} `json:"config"`
} }
if err := json.Unmarshal(b, &s); err != nil { if err := json.Unmarshal(b, &s); err != nil {
return err return err
} }
if len(s.Sessions.CSV) > 0 { var m []*i18n.Message
m := make([]*i18n.Message, 0, len(s.Sessions.CSV)) for prefix, msgs := range map[string]map[string]string{
for k, v := range s.Sessions.CSV { "sessions.csv": s.Sessions.CSV,
"config.hems.csv": s.Config.Hems.CSV,
} {
for k, v := range msgs {
m = append(m, &i18n.Message{ m = append(m, &i18n.Message{
ID: "sessions.csv." + k, ID: prefix + "." + k,
Other: v, Other: v,
}) })
} }
}
if len(m) > 0 {
languageTag := language.Make(strings.TrimSuffix(d.Name(), filepath.Ext(d.Name()))) languageTag := language.Make(strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())))
if err := Bundle.AddMessages(languageTag, m...); err != nil { if err := Bundle.AddMessages(languageTag, m...); err != nil {
return fmt.Errorf("loading locales failed: %w", err) return fmt.Errorf("loading locales failed: %w", err)