-
+
+
+
+
+
+
+
+
@@ -209,6 +203,7 @@ import colors from "../colors";
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 DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
import TotalIcon from "../components/MaterialIcon/Total.vue";
import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types";
@@ -231,6 +226,7 @@ export default defineComponent({
SolarYearChart,
PeriodSelector,
DateNavigator,
+ PeriodHeader,
DynamicPriceIcon,
CostHistoryChart,
CostGroupedChart,
@@ -342,10 +338,12 @@ export default defineComponent({
});
},
periodOptions() {
- return Object.entries(PERIODS).map(([key, value]) => ({
- name: this.$t(`sessions.period.${key.toLowerCase()}`),
- value,
- }));
+ return Object.entries(PERIODS)
+ .filter(([, value]) => value !== PERIODS.DAY)
+ .map(([key, value]) => ({
+ name: this.$t(`sessions.period.${key.toLowerCase()}`),
+ value,
+ }));
},
typeOptions() {
const options = Object.values(TYPES).map((value) => {
@@ -741,51 +739,4 @@ export default defineComponent({
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index 808ab4878..51aa53d39 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -2,6 +2,7 @@ package keys
const (
// loadpoint settings
+ Name = "name" // loadpoint name (config identifier)
Title = "title" // loadpoint title
Mode = "mode" // charge mode
DefaultMode = "defaultMode" // default charge mode
diff --git a/core/metrics/collector.go b/core/metrics/collector.go
index c584b1280..1d829f786 100644
--- a/core/metrics/collector.go
+++ b/core/metrics/collector.go
@@ -9,13 +9,13 @@ import (
const (
// groups
+ Forecast = "forecast"
Battery = "battery"
Grid = "grid"
PV = "pv"
Home = "home" // meter and group (virtual measurement)
Loadpoint = "loadpoint"
Meter = "meter" // generic meter (ext/aux)
- Forecast = "forecast"
)
type Collector struct {
diff --git a/core/metrics/db_history.go b/core/metrics/db_history.go
index eedb638ef..de15951ae 100644
--- a/core/metrics/db_history.go
+++ b/core/metrics/db_history.go
@@ -1,11 +1,17 @@
package metrics
import (
+ "context"
"errors"
"fmt"
+ "io"
+ "math"
+ "sort"
+ "strconv"
"time"
"github.com/evcc-io/evcc/server/db"
+ csvutil "github.com/evcc-io/evcc/util/csv"
)
// Slot represents an aggregated energy time slot
@@ -16,6 +22,11 @@ type Slot struct {
ReturnEnergy float64 `json:"returnEnergy"`
}
+// roundEnergy rounds kWh to Wh precision and clamps negative noise to zero.
+func roundEnergy(v float64) float64 {
+ return max(0, math.Round(v*1000)/1000)
+}
+
// Series represents a named series of energy slots
type Series struct {
Name string `json:"name,omitempty"`
@@ -23,6 +34,12 @@ type Series struct {
Data []Slot `json:"data"`
}
+// SeriesCSV wraps a slice of Series for CSV export.
+type SeriesCSV []Series
+
+// csvGroupOrder mirrors the frontend GROUP_ORDER plus home/forecast.
+var csvGroupOrder = []string{PV, Battery, Grid, Loadpoint, Meter, Home, Forecast}
+
var aggregateFormats = map[string]string{
"15m": "%Y-%m-%d %H:%M",
"hour": "%Y-%m-%d %H:00",
@@ -95,10 +112,147 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series,
s.Data = append(s.Data, Slot{
Start: time.Time(r.Start),
End: addDuration(time.Time(r.Start)),
- Energy: r.Energy,
- ReturnEnergy: r.ReturnEnergy,
+ Energy: roundEnergy(r.Energy),
+ ReturnEnergy: roundEnergy(r.ReturnEnergy),
})
}
return res, nil
}
+
+// hasReturnEnergy reports whether a group's CSV export includes a returnEnergy
+// column. Only the bidirectional groups (grid, battery) do; the rest emit a
+// single energy column.
+func hasReturnEnergy(group string) bool {
+ return group == Grid || group == Battery
+}
+
+// WriteCsv emits a wide-table CSV with columns
+//
+// time.start, time.end, ..energy.Wh[, ..returnEnergy.Wh], …
+//
+// Only grid and battery contribute a second returnEnergy column.
+// Values are plain Wh integers (the DB is already rounded to milli-kWh) — no
+// decimal point, no thousands separator, so they survive locale-mismatched
+// spreadsheet importers unambiguously.
+func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error {
+ ww, _, err := csvutil.NewLocalizedWriter(ctx, w)
+ if err != nil {
+ return err
+ }
+
+ byGroup := make(map[string][]*Series)
+ for i := range s {
+ g := s[i].Group
+ byGroup[g] = append(byGroup[g], &s[i])
+ }
+
+ rank := make(map[string]int, len(csvGroupOrder))
+ for i, g := range csvGroupOrder {
+ rank[g] = i
+ }
+ groups := make([]string, 0, len(byGroup))
+ for g := range byGroup {
+ groups = append(groups, g)
+ }
+ sort.Slice(groups, func(i, j int) bool {
+ ri, oki := rank[groups[i]]
+ rj, okj := rank[groups[j]]
+ switch {
+ case oki && okj:
+ return ri < rj
+ case oki:
+ return true
+ case okj:
+ return false
+ default:
+ return groups[i] < groups[j]
+ }
+ })
+
+ header := []string{"time.start", "time.end"}
+ type col struct {
+ series *Series
+ returnEnergy bool
+ }
+ cols := []col{{}, {}}
+ tsSet := make(map[int64]time.Time)
+ endByStart := make(map[int64]time.Time)
+
+ for _, g := range groups {
+ entities := byGroup[g]
+ sort.Slice(entities, func(i, j int) bool { return entities[i].Name < entities[j].Name })
+
+ for _, e := range entities {
+ name := e.Name
+ if name == "" {
+ name = g
+ }
+ header = append(header, g+"."+name+".energy.Wh")
+ cols = append(cols, col{series: e, returnEnergy: false})
+ if hasReturnEnergy(g) {
+ header = append(header, g+"."+name+".returnEnergy.Wh")
+ cols = append(cols, col{series: e, returnEnergy: true})
+ }
+ for _, slot := range e.Data {
+ tsSet[slot.Start.UnixNano()] = slot.Start
+ endByStart[slot.Start.UnixNano()] = slot.End
+ }
+ }
+ }
+
+ if err := ww.Write(header); err != nil {
+ return err
+ }
+
+ if len(tsSet) == 0 {
+ ww.Flush()
+ return ww.Error()
+ }
+
+ timestamps := make([]time.Time, 0, len(tsSet))
+ for _, t := range tsSet {
+ timestamps = append(timestamps, t)
+ }
+ sort.Slice(timestamps, func(i, j int) bool { return timestamps[i].Before(timestamps[j]) })
+
+ slotIdx := make(map[*Series]map[int64]Slot)
+ for _, list := range byGroup {
+ for _, e := range list {
+ m := make(map[int64]Slot, len(e.Data))
+ for _, slot := range e.Data {
+ m[slot.Start.UnixNano()] = slot
+ }
+ slotIdx[e] = m
+ }
+ }
+
+ row := make([]string, 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")
+ for i := 2; i < len(cols); i++ {
+ c := cols[i]
+ if c.series == nil {
+ row[i] = ""
+ continue
+ }
+ slot, ok := slotIdx[c.series][ts.UnixNano()]
+ if !ok {
+ row[i] = ""
+ continue
+ }
+ v := slot.Energy
+ if c.returnEnergy {
+ v = slot.ReturnEnergy
+ }
+ row[i] = strconv.FormatInt(int64(math.Round(v*1000)), 10)
+ }
+ if err := ww.Write(row); err != nil {
+ return err
+ }
+ }
+
+ ww.Flush()
+ return ww.Error()
+}
diff --git a/core/metrics/db_history_test.go b/core/metrics/db_history_test.go
new file mode 100644
index 000000000..bf39d74ab
--- /dev/null
+++ b/core/metrics/db_history_test.go
@@ -0,0 +1,134 @@
+package metrics
+
+import (
+ "bytes"
+ "context"
+ "encoding/csv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/evcc-io/evcc/util/locale"
+ "github.com/stretchr/testify/require"
+)
+
+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) {
+ t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
+ t1 := t0.Add(15 * time.Minute)
+
+ series := SeriesCSV{
+ {Group: PV, Name: "pv", Data: []Slot{mkSlot(t0, 2.5, 0), mkSlot(t1, 3.0, 0)}},
+ {Group: Battery, Name: "battery-home", Data: []Slot{mkSlot(t0, 0, 0.7800), mkSlot(t1, 0.5300, 0)}},
+ {Group: Battery, Name: "battery-garage", Data: []Slot{mkSlot(t0, 0, 1.2500), mkSlot(t1, 0, 0)}},
+ {Group: Grid, Name: "grid", Data: []Slot{mkSlot(t0, 0, 0.4123), mkSlot(t1, 0.1, 0)}},
+ {Group: Home, Name: "home", Data: []Slot{mkSlot(t0, 0.3661, 0), mkSlot(t1, 0.4, 0)}},
+ }
+
+ var buf bytes.Buffer
+ require.NoError(t, series.WriteCsv(context.Background(), &buf))
+
+ // UTF-8 BOM
+ require.True(t, bytes.HasPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF}), "expected UTF-8 BOM")
+
+ r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
+ rows, err := r.ReadAll()
+ require.NoError(t, err)
+ require.Len(t, rows, 3, "expected header + 2 data rows")
+
+ header := rows[0]
+ expected := []string{
+ "time.start", "time.end",
+ "pv.pv.energy.Wh",
+ "battery.battery-garage.energy.Wh", "battery.battery-garage.returnEnergy.Wh",
+ "battery.battery-home.energy.Wh", "battery.battery-home.returnEnergy.Wh",
+ "grid.grid.energy.Wh", "grid.grid.returnEnergy.Wh",
+ "home.home.energy.Wh",
+ }
+ require.Equal(t, expected, header, "header order: GROUP_ORDER, alphabetical entities; returnEnergy only for grid/battery")
+
+ // time.end = time.start + slot length (15 min in mkSlot)
+ require.Equal(t, t0.Local().Format("2006-01-02 15:04:05"), rows[1][0])
+ require.Equal(t, t0.Add(15*time.Minute).Local().Format("2006-01-02 15:04:05"), rows[1][1])
+
+ // First data row values (t0) — plain Wh integers. Cols start at index 2.
+ require.Equal(t, "2500", rows[1][2]) // pv.pv energy
+ require.Equal(t, "0", rows[1][3]) // battery-garage energy
+ require.Equal(t, "1250", rows[1][4]) // battery-garage returnEnergy
+ require.Equal(t, "0", rows[1][5]) // battery-home energy
+ require.Equal(t, "780", rows[1][6]) // battery-home returnEnergy
+ require.Equal(t, "0", rows[1][7]) // grid energy
+ require.Equal(t, "412", rows[1][8]) // grid returnEnergy
+ require.Equal(t, "366", rows[1][9]) // home energy
+}
+
+func TestSeriesCSV_GermanLocale(t *testing.T) {
+ t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
+ series := SeriesCSV{
+ {Group: PV, Name: "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))
+
+ 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.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) {
+ 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{
+ {Group: PV, Name: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}},
+ {Group: PV, Name: "b", Data: []Slot{mkSlot(t0, 3, 0)}},
+ }
+
+ var buf bytes.Buffer
+ require.NoError(t, series.WriteCsv(context.Background(), &buf))
+
+ r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
+ rows, err := r.ReadAll()
+ require.NoError(t, err)
+ require.Len(t, rows, 3)
+
+ require.Equal(t, []string{"time.start", "time.end", "pv.a.energy.Wh", "pv.b.energy.Wh"}, rows[0])
+
+ // row 1: t0 has both entities (Wh integers); cols start at index 2.
+ require.Equal(t, "1000", rows[1][2])
+ require.Equal(t, "3000", rows[1][3])
+
+ // row 2: t1 has only entity a
+ require.Equal(t, "2000", rows[2][2])
+ require.Equal(t, "", rows[2][3], "missing slot must be empty, not zero")
+}
+
+func TestSeriesCSV_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{
+ {Group: Battery, Name: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}},
+ }
+
+ var buf bytes.Buffer
+ require.NoError(t, series.WriteCsv(context.Background(), &buf))
+
+ r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
+ rows, err := r.ReadAll()
+ require.NoError(t, err)
+ require.Equal(t, []string{"time.start", "time.end", "battery.bat.energy.Wh", "battery.bat.returnEnergy.Wh"}, rows[0])
+ require.Equal(t, "500", rows[1][2])
+ require.Equal(t, "0", rows[1][3])
+}
diff --git a/core/site.go b/core/site.go
index 784d274d2..48a8dbbc7 100644
--- a/core/site.go
+++ b/core/site.go
@@ -519,6 +519,7 @@ func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) [
props := deviceProperties(dev)
mm[i] = types.Measurement{
+ Name: dev.Config().Name,
Title: props.Title,
Icon: props.Icon,
Power: power,
@@ -757,7 +758,7 @@ func (site *Site) updateGridMeter() error {
return nil
}
- var mm types.Measurement
+ mm := types.Measurement{Name: site.Meters.GridMeterRef}
if res, err := backoff.RetryWithData(site.gridMeter.CurrentPower, modbus.Backoff()); err == nil {
mm.Power = res
@@ -1087,6 +1088,8 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
site.prepare()
+ lpDevices := config.Loadpoints().Devices()
+
for id, lp := range site.loadpoints {
lpUIChan := make(chan util.Param)
lpPushChan := make(chan messenger.Event)
@@ -1105,6 +1108,11 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
}
}(id)
+ // publish name on the loadpoint's behalf — it doesn't know its own
+ if id < len(lpDevices) {
+ site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Name, Val: lpDevices[id].Config().Name}
+ }
+
lp.Prepare(site, lpUIChan, lpPushChan, site.lpUpdateChan)
}
}
diff --git a/core/types/types.go b/core/types/types.go
index ad233bd82..e4a808a67 100644
--- a/core/types/types.go
+++ b/core/types/types.go
@@ -8,10 +8,12 @@ import (
// Measurement is the device measurements struct
type Measurement struct {
+ Name string `json:"name,omitempty"`
Title string `json:"title,omitempty"`
Icon string `json:"icon,omitempty"`
Power float64 `json:"power"`
Energy float64 `json:"energy,omitempty"`
+ ReturnEnergy float64 `json:"returnEnergy,omitempty"`
Powers []float64 `json:"powers,omitempty"`
Currents []float64 `json:"currents,omitempty"`
ExcessDCPower float64 `json:"excessdcpower,omitempty"`
diff --git a/i18n/de.json b/i18n/de.json
index 60620fbb1..ce03a5390 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -1200,6 +1200,30 @@
"description": "Reduziere Ladeleistung, um {limit} nicht zu überschreiten.",
"title": "Externe Begrenzung:"
},
+ "history": {
+ "direction": {
+ "battery": {
+ "energy": "geladen",
+ "returnEnergy": "entladen"
+ },
+ "grid": {
+ "energy": "bezogen",
+ "returnEnergy": "eingespeist"
+ }
+ },
+ "downloadCsv": "CSV herunterladen",
+ "empty": "Keine Verlaufsdaten für diesen Zeitraum.",
+ "group": {
+ "battery": "Batterie",
+ "forecast": "Vorhersage",
+ "grid": "Netz",
+ "loadpoint": "Laden & Heizen",
+ "meter": "Verbrauch",
+ "pv": "Erzeugung"
+ },
+ "otherConsumers": "Andere",
+ "title": "Historie"
+ },
"loadpoint": {
"avgPrice": "⌀ Preis",
"charged": "Geladen",
@@ -1471,6 +1495,7 @@
"odometer": "Kilometerstand",
"overview": "Übersicht",
"period": {
+ "day": "Tag",
"month": "Monat",
"total": "Gesamt",
"year": "Jahr"
diff --git a/i18n/en.json b/i18n/en.json
index 7b00d7c52..8728a6c5e 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1200,6 +1200,30 @@
"description": "Reduced charging to not exceed {limit}.",
"title": "External limit:"
},
+ "history": {
+ "direction": {
+ "battery": {
+ "energy": "charged",
+ "returnEnergy": "discharged"
+ },
+ "grid": {
+ "energy": "imported",
+ "returnEnergy": "exported"
+ }
+ },
+ "downloadCsv": "Download CSV",
+ "empty": "No history data available for this time range.",
+ "group": {
+ "battery": "Battery",
+ "forecast": "Forecast",
+ "grid": "Grid",
+ "loadpoint": "Charging & Heating",
+ "meter": "Consumption",
+ "pv": "Production"
+ },
+ "otherConsumers": "Others",
+ "title": "History"
+ },
"loadpoint": {
"avgPrice": "⌀ Price",
"charged": "Charged",
@@ -1471,6 +1495,7 @@
"odometer": "Mileage",
"overview": "Overview",
"period": {
+ "day": "Day",
"month": "Month",
"total": "Total",
"year": "Year"
diff --git a/server/http_history_handler.go b/server/http_history_handler.go
index c426017fc..f91b1313f 100644
--- a/server/http_history_handler.go
+++ b/server/http_history_handler.go
@@ -1,12 +1,15 @@
package server
import (
+ "context"
"errors"
"net/http"
"time"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/server/db"
+ "github.com/evcc-io/evcc/util/locale"
+ "golang.org/x/text/language"
)
// energyHistoryHandler returns aggregated energy history data
@@ -49,5 +52,33 @@ func energyHistoryHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ if q.Get("format") == "csv" {
+ lang := q.Get("lang")
+ if lang == "" {
+ if tags, _, err := language.ParseAcceptLanguage(r.Header.Get("Accept-Language")); err == nil && len(tags) > 0 {
+ lang = tags[0].String()
+ }
+ }
+ ctx := context.WithValue(context.Background(), locale.Locale, lang)
+ csvResult(ctx, w, metrics.SeriesCSV(res), historyFilename(from, aggregate))
+ return
+ }
+
jsonWrite(w, res)
}
+
+// historyFilename returns history-energy-YYYY-MM-DD / -YYYY-MM / -YYYY
+// for day/month/year aggregates.
+func historyFilename(from time.Time, aggregate string) string {
+ if from.IsZero() {
+ return "history-energy"
+ }
+ format := "2006-01-02"
+ switch aggregate {
+ case "day":
+ format = "2006-01"
+ case "month":
+ format = "2006"
+ }
+ return "history-energy-" + from.Local().Format(format)
+}
diff --git a/server/mqtt_test.go b/server/mqtt_test.go
index 7564c50bb..c262c5a21 100644
--- a/server/mqtt_test.go
+++ b/server/mqtt_test.go
@@ -106,32 +106,32 @@ func (suite *mqttSuite) TestNilInterface() {
func (suite *mqttSuite) TestMeasurement() {
topics := lo.Map([]string{
- "title", "icon", "power", "energy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable",
+ "name", "title", "icon", "power", "energy", "returnEnergy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable",
}, func(s string, _ int) string {
return "test/" + s
})
suite.publish("test", false, types.Measurement{})
suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"", "", "0", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
+ suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
suite.publish("test", false, types.Measurement{Energy: 1})
suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"", "", "0", "1", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
+ suite.Equal([]string{"", "", "", "0", "1", "", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
suite.publish("test", false, types.Measurement{Controllable: new(false)})
suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "false"}, suite.payloads, "controllable payloads")
+ suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", "false"}, suite.payloads, "controllable payloads")
suite.publish("test", false, types.Measurement{Currents: []float64{1, 2, 3}})
suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "currents topics")
- suite.Equal([]string{"", "", "0", "", "", "3", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
+ suite.Equal([]string{"", "", "", "0", "", "", "", "3", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
}
func (suite *mqttSuite) TestBatteryState() {
topics := lo.Map([]string{
"power", "energy", "capacity", "soc",
- "devices", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable",
+ "devices", "devices/1/name", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/returnEnergy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable",
"forecast",
}, func(s string, _ int) string {
return "test/" + s
@@ -147,5 +147,5 @@ func (suite *mqttSuite) TestBatteryState() {
})
suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"2", "", "", "20", "1", "", "", "1", "", "", "", "", "", "10", "", ""}, suite.payloads, "payloads")
+ suite.Equal([]string{"2", "", "", "20", "1", "", "", "", "1", "", "", "", "", "", "", "10", "", ""}, suite.payloads, "payloads")
}
diff --git a/util/csv/writer.go b/util/csv/writer.go
index 18db9f286..8d7e6ed29 100644
--- a/util/csv/writer.go
+++ b/util/csv/writer.go
@@ -90,15 +90,16 @@ func writeRow(ww *csv.Writer, mp *message.Printer, structVal any) error {
return ww.Write(row)
}
-// 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 {
+// 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 err
+ return nil, nil, err
}
lang := locale.Language
- if language, ok := ctx.Value(locale.Locale).(string); ok && language != "" {
- lang = language
+ if v, ok := ctx.Value(locale.Locale).(string); ok && v != "" {
+ lang = v
}
if lang == "" {
lang = "en"
@@ -106,15 +107,24 @@ func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg Config) e
tag, err := language.Parse(lang)
if err != nil {
- return err
+ 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()
@@ -134,7 +144,6 @@ func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg Config) e
return err
}
- mp := message.NewPrinter(tag)
for i := 0; i < sliceVal.Len(); i++ {
row := sliceVal.Index(i).Interface()
if err := writeRow(ww, mp, row); err != nil {
diff --git a/vite.config.ts b/vite.config.ts
index bcb6cd833..a8ab5c7cb 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -28,15 +28,19 @@ export default defineConfig({
assetsInlineLimit: 1024,
chunkSizeWarningLimit: 800, // legacy build increases file size
},
- server: {
- port: 7071,
- proxy: {
- "/api": "http://localhost:7070",
- "/i18n": "http://localhost:7070",
- "/providerauth": "http://localhost:7070",
- "/ws": { target: "ws://localhost:7070", ws: true },
- },
- },
+ server: (() => {
+ const frontend = Number(process.env.VITE_PORT) || 7071;
+ const backend = Number(process.env.VITE_BACKEND_PORT) || 7070;
+ return {
+ port: frontend,
+ proxy: {
+ "/api": `http://localhost:${backend}`,
+ "/i18n": `http://localhost:${backend}`,
+ "/providerauth": `http://localhost:${backend}`,
+ "/ws": { target: `ws://localhost:${backend}`, ws: true },
+ },
+ };
+ })(),
plugins: [
legacy({
modernPolyfills: ["es.promise.all-settled"],