feat: add xlsx export wherever csv export exists (#31690)
This commit is contained in:
parent
3158ff16ef
commit
3ce6600143
29 changed files with 873 additions and 425 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
63
assets/js/components/Helper/DownloadButton.vue
Normal file
63
assets/js/components/Helper/DownloadButton.vue
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
return math.Round(*v*10) / 10
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
6
go.mod
|
|
@ -101,6 +101,7 @@ require (
|
|||
github.com/traefik/yaegi v0.16.1
|
||||
github.com/volkszaehler/mbmd v0.0.0-20260623211432-fb8f9c22f121
|
||||
github.com/warthog618/go-gpiocdev v0.9.1
|
||||
github.com/xuri/excelize/v2 v2.11.0
|
||||
gitlab.com/bboehmke/sunny v0.17.0
|
||||
go.bug.st/serial v1.7.1
|
||||
go.uber.org/mock v0.6.0
|
||||
|
|
@ -216,6 +217,8 @@ require (
|
|||
github.com/quic-go/webtransport-go v0.10.0 // indirect
|
||||
github.com/relvacode/iso8601 v1.6.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/richardlehane/mscfb v1.0.7 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/rickb777/period v1.0.22 // indirect
|
||||
github.com/rickb777/plural v1.4.7 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
|
|
@ -230,8 +233,11 @@ require (
|
|||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/teivah/onecontext v1.3.0 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
gitlab.com/c0b/go-ordered-json v0.0.0-20201030195603-febf46534d5a // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
|
|
|
|||
14
go.sum
14
go.sum
|
|
@ -451,6 +451,10 @@ github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QH
|
|||
github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
|
||||
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/rickb777/expect v1.0.6 h1:1JE3CfYGyuhN5OTu5nqvIF3VjS2AoqoctcGlbNTVl3w=
|
||||
github.com/rickb777/expect v1.0.6/go.mod h1:raunaduUM/p8CzpTZeDmoexwlIFF+Peg0Mj/p//9mkA=
|
||||
github.com/rickb777/period v1.0.22 h1:/X41JreTYsjifLDGBCaVN+s5r5/G0sTbKoHBaISv2ns=
|
||||
|
|
@ -539,6 +543,8 @@ github.com/teslamotors/vehicle-command v0.4.1 h1:J4ne/TNGwgodJLYJDLm/hjoygXyQ/bp
|
|||
github.com/teslamotors/vehicle-command v0.4.1/go.mod h1:liN6VG6MCc7m02wFaBm2sQT6MYGm/dJua6bG00QSpnA=
|
||||
github.com/tess1o/go-ecoflow v1.1.1-0.20251003083510-2ccc15a17e29 h1:whgDCnunp97KCC7TF94E3l1hGo080ICSRs6ei8jCnzs=
|
||||
github.com/tess1o/go-ecoflow v1.1.1-0.20251003083510-2ccc15a17e29/go.mod h1:kqrfk4Y14pHCPKIwp6o7ZbgpohUzQHh7EMX+RUA4JCU=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
|
||||
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
|
|
@ -551,6 +557,12 @@ github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgX
|
|||
github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8=
|
||||
github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4=
|
||||
github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
|
||||
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
|
@ -598,6 +610,8 @@ golang.org/x/crypto/x509roots/fallback v0.0.0-20260630172432-7626c5025624 h1:qto
|
|||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260630172432-7626c5025624/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
11
i18n/en.json
11
i18n/en.json
|
|
@ -365,11 +365,13 @@
|
|||
},
|
||||
"customOption": "User-defined integration",
|
||||
"description": "Power limitation by external systems (e.g. §14a EnWG, §9 EEG interface or higher-level energy management system). Works together with the load management feature. Curtailing solar production is also supported for selected devices. The actual curtailment limit must be set on each inverter configuration.",
|
||||
"downloadCsv": "Download CSV",
|
||||
"download": "Download",
|
||||
"eventsRecorded": "Recorded {count} grid limitation events.",
|
||||
"generic": "Generic integrations",
|
||||
"integrations": "Integrations",
|
||||
"lastEvent": "Most recent {timeAgo}.",
|
||||
"noEvents": "No events recorded yet.",
|
||||
"recordedEvents": "Recorded events",
|
||||
"template": "Integration",
|
||||
"title": "External Limit"
|
||||
},
|
||||
|
|
@ -1131,6 +1133,7 @@
|
|||
}
|
||||
},
|
||||
"general": {
|
||||
"download": "Download",
|
||||
"note": "Note:"
|
||||
},
|
||||
"header": {
|
||||
|
|
@ -1330,7 +1333,7 @@
|
|||
"returnEnergy": "Energy (reverse)"
|
||||
}
|
||||
},
|
||||
"downloadCsv": "Download CSV",
|
||||
"download": "Download",
|
||||
"empty": "No history data available for this time range.",
|
||||
"group": {
|
||||
"battery": "Battery",
|
||||
|
|
@ -1595,9 +1598,9 @@
|
|||
"solarpercentage": "Solar (%)",
|
||||
"vehicle": "Vehicle"
|
||||
},
|
||||
"csvPeriod": "Download {period} CSV",
|
||||
"csvTotal": "Download total CSV",
|
||||
"date": "Start",
|
||||
"download": "Download {period}",
|
||||
"downloadTotal": "Download total",
|
||||
"energy": "Charged",
|
||||
"filter": {
|
||||
"allLoadpoints": "all charging points",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
22
util/export/api.go
Normal 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
56
util/export/csv/csv.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
121
util/export/writer.go
Normal 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()
|
||||
}
|
||||
28
util/export/writer_test.go
Normal file
28
util/export/writer_test.go
Normal 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
131
util/export/xlsx/xlsx.go
Normal 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)
|
||||
}
|
||||
112
util/export/xlsx/xlsx_test.go
Normal file
112
util/export/xlsx/xlsx_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue