HEMS: add "smartgrid" session logging (#25907)
This commit is contained in:
parent
73f468e4c5
commit
381573b0eb
33 changed files with 915 additions and 215 deletions
|
|
@ -7,21 +7,91 @@
|
|||
:defaultYaml="defaultYaml"
|
||||
endpoint="/config/hems"
|
||||
removeKey="hems"
|
||||
:noYamlEditor="fromYaml"
|
||||
:disableSave="fromYaml"
|
||||
data-testid="hems-modal"
|
||||
@changed="$emit('changed')"
|
||||
/>
|
||||
@open="loadSessions"
|
||||
>
|
||||
<template #afterDescription>
|
||||
<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>
|
||||
<span>{{ $t("config.hems.eventsRecorded", { count: sessionCount }) }}</span>
|
||||
<span class="ms-2">{{
|
||||
$t("config.hems.lastEvent", { timeAgo: formatLastEvent(lastEvent.created) })
|
||||
}}</span>
|
||||
</div>
|
||||
<a :href="csvLink" download class="alert-link text-nowrap">
|
||||
{{ $t("config.hems.downloadCsv") }}
|
||||
</a>
|
||||
</div>
|
||||
<p v-if="fromYaml" class="text-muted">
|
||||
{{ $t("config.hems.yamlConfigured") }}
|
||||
</p>
|
||||
</template>
|
||||
</YamlModal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import YamlModal from "./YamlModal.vue";
|
||||
import defaultYaml from "./defaultYaml/hems.yaml?raw";
|
||||
import api from "../../api";
|
||||
import formatter from "../../mixins/formatter";
|
||||
|
||||
export default {
|
||||
name: "HemsModal",
|
||||
components: { YamlModal },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
fromYaml: Boolean,
|
||||
},
|
||||
emits: ["changed"],
|
||||
data() {
|
||||
return { defaultYaml: defaultYaml.trim() };
|
||||
return {
|
||||
defaultYaml: defaultYaml.trim(),
|
||||
sessions: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
sessionCount() {
|
||||
return this.sessions.length;
|
||||
},
|
||||
lastEvent() {
|
||||
if (!this.sessions.length) {
|
||||
return null;
|
||||
}
|
||||
return this.sessions[0];
|
||||
},
|
||||
csvLink() {
|
||||
const params = new URLSearchParams({
|
||||
format: "csv",
|
||||
lang: this.$i18n?.locale,
|
||||
});
|
||||
return `./api/gridsessions?${params.toString()}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async loadSessions() {
|
||||
try {
|
||||
const response = await api.get("gridsessions");
|
||||
this.sessions = response.data || [];
|
||||
} catch (e) {
|
||||
// Silently fail if no sessions available
|
||||
this.sessions = [];
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
formatLastEvent(created) {
|
||||
const now = new Date();
|
||||
const eventDate = new Date(created);
|
||||
const diffMs = now - eventDate;
|
||||
return this.fmtTimeAgo(-diffMs);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@
|
|||
{{ $t("config.general.docsLink") }}
|
||||
</a>
|
||||
</p>
|
||||
<slot name="afterDescription" />
|
||||
<p v-if="error" class="text-danger" data-testid="error">{{ error }}</p>
|
||||
<form ref="form" class="container mx-0 px-0">
|
||||
<div class="editor-container">
|
||||
<div v-if="!noYamlEditor" class="editor-container">
|
||||
<YamlEditorContainer
|
||||
v-model="yaml"
|
||||
:errorLine="errorLine"
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
{{ $t("config.general.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!disableSave"
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="saving || nothingChanged"
|
||||
|
|
@ -62,8 +64,10 @@ export default {
|
|||
defaultYaml: String,
|
||||
removeKey: String,
|
||||
size: { type: String, default: "xl" },
|
||||
noYamlEditor: Boolean,
|
||||
disableSave: Boolean,
|
||||
},
|
||||
emits: ["changed"],
|
||||
emits: ["changed", "open"],
|
||||
data() {
|
||||
return {
|
||||
saving: false,
|
||||
|
|
@ -93,6 +97,7 @@ export default {
|
|||
async open() {
|
||||
this.reset();
|
||||
this.modalVisible = true;
|
||||
this.$emit("open");
|
||||
await this.load();
|
||||
},
|
||||
close() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
## external control via binary in put
|
||||
## external control via digital signal
|
||||
|
||||
#type: relay
|
||||
#maxPower: 4200 # limit loadpoints to 4.2 kW total
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@
|
|||
</DeviceCard>
|
||||
<DeviceCard
|
||||
:title="$t('config.hems.title')"
|
||||
:editable="!hems?.fromYaml"
|
||||
editable
|
||||
:error="hasClassError('hems')"
|
||||
data-testid="hems"
|
||||
@edit="openModal('hemsModal')"
|
||||
|
|
@ -368,7 +368,7 @@
|
|||
<MqttModal @changed="loadDirty" />
|
||||
<NetworkModal @changed="loadDirty" />
|
||||
<ControlModal @changed="loadDirty" />
|
||||
<HemsModal @changed="yamlChanged" />
|
||||
<HemsModal :fromYaml="hems?.fromYaml" @changed="yamlChanged" />
|
||||
<ShmModal @changed="loadDirty" />
|
||||
<MessagingModal @changed="yamlChanged" />
|
||||
<TariffsModal @changed="yamlChanged" />
|
||||
|
|
|
|||
23
cmd/setup.go
23
cmd/setup.go
|
|
@ -22,8 +22,6 @@ import (
|
|||
"github.com/evcc-io/evcc/core/circuit"
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
"github.com/evcc-io/evcc/core/metrics"
|
||||
"github.com/evcc-io/evcc/core/session"
|
||||
coresettings "github.com/evcc-io/evcc/core/settings"
|
||||
"github.com/evcc-io/evcc/hems"
|
||||
hemsapi "github.com/evcc-io/evcc/hems/hems"
|
||||
|
|
@ -35,7 +33,6 @@ import (
|
|||
"github.com/evcc-io/evcc/push"
|
||||
"github.com/evcc-io/evcc/server"
|
||||
"github.com/evcc-io/evcc/server/db"
|
||||
"github.com/evcc-io/evcc/server/db/cache"
|
||||
"github.com/evcc-io/evcc/server/db/settings"
|
||||
"github.com/evcc-io/evcc/server/eebus"
|
||||
"github.com/evcc-io/evcc/server/modbus"
|
||||
|
|
@ -578,26 +575,6 @@ func configureDatabase(conf globalconfig.DB) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if err := session.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := metrics.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := settings.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cache.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := config.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
persistSettings := func() {
|
||||
if err := settings.Persist(); err != nil {
|
||||
log.ERROR.Println("cannot save settings:", err)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ type circuitsTestSuite struct {
|
|||
|
||||
func (suite *circuitsTestSuite) SetupSuite() {
|
||||
_ = db.NewInstance("sqlite", ":memory:")
|
||||
config.Init()
|
||||
config.SetupSchema()
|
||||
}
|
||||
|
||||
func (suite *circuitsTestSuite) SetupTest() {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,14 @@ type meter struct {
|
|||
|
||||
var ErrIncomplete = errors.New("meter profile incomplete")
|
||||
|
||||
func Init() error {
|
||||
func init() {
|
||||
db.Register(func() error {
|
||||
return SetupSchema()
|
||||
})
|
||||
}
|
||||
|
||||
// SetupSchema is used for testing
|
||||
func SetupSchema() error {
|
||||
return db.Instance.AutoMigrate(new(meter))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,12 +17,14 @@ var (
|
|||
sessions Sessions
|
||||
)
|
||||
|
||||
func Init() error {
|
||||
err := db.Instance.AutoMigrate(new(Session))
|
||||
if err == nil {
|
||||
err = db.Instance.Find(&sessions).Error
|
||||
}
|
||||
return err
|
||||
func init() {
|
||||
db.Register(func() error {
|
||||
if err := db.Instance.AutoMigrate(new(Session)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Instance.Find(&sessions).Error
|
||||
})
|
||||
}
|
||||
|
||||
// NewStore creates a session store
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
func TestFormatValue(t *testing.T) {
|
||||
mp := message.NewPrinter(language.Make("en"))
|
||||
|
||||
f := 1.2345
|
||||
assert.Equal(t, "1.234", formatValue(mp, f, 3))
|
||||
assert.Equal(t, "1.234", formatValue(mp, &f, 3))
|
||||
}
|
||||
|
|
@ -2,20 +2,11 @@ package session
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"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"
|
||||
csvutil "github.com/evcc-io/evcc/util/csv"
|
||||
)
|
||||
|
||||
// Session is a single charging session
|
||||
|
|
@ -42,112 +33,9 @@ type Sessions []Session
|
|||
|
||||
var _ api.CsvWriter = (*Sessions)(nil)
|
||||
|
||||
func (t *Sessions) writeHeader(ctx context.Context, ww *csv.Writer) error {
|
||||
localizer := locale.Localizer
|
||||
if val := ctx.Value(locale.Locale).(string); val != "" {
|
||||
localizer = i18n.NewLocalizer(locale.Bundle, val, locale.Language)
|
||||
}
|
||||
|
||||
var row []string
|
||||
for _, f := range structs.Fields(Session{}) {
|
||||
csv := f.Tag("csv")
|
||||
if csv == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
caption, err := localizer.Localize(&locale.Config{
|
||||
MessageID: "sessions.csv." + strings.ToLower(f.Name()),
|
||||
})
|
||||
if err != nil {
|
||||
if csv != "" {
|
||||
caption = csv
|
||||
} else {
|
||||
caption = f.Name()
|
||||
}
|
||||
}
|
||||
|
||||
row = append(row, caption)
|
||||
}
|
||||
|
||||
return ww.Write(row)
|
||||
}
|
||||
|
||||
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 (t *Sessions) writeRow(ww *csv.Writer, mp *message.Printer, r Session) error {
|
||||
var row []string
|
||||
for _, f := range structs.Fields(r) {
|
||||
if f.Tag("csv") == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
digits := 3
|
||||
if format := f.Tag("format"); format == "int" {
|
||||
digits = 0
|
||||
}
|
||||
|
||||
val := formatValue(mp, f.Value(), digits)
|
||||
|
||||
row = append(row, val)
|
||||
}
|
||||
|
||||
return ww.Write(row)
|
||||
}
|
||||
|
||||
// WriteCsv implements the api.CsvWriter interface
|
||||
func (t *Sessions) WriteCsv(ctx context.Context, w io.Writer) error {
|
||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get context language
|
||||
lang := locale.Language
|
||||
if language, ok := ctx.Value(locale.Locale).(string); ok && language != "" {
|
||||
lang = language
|
||||
}
|
||||
|
||||
tag, err := language.Parse(lang)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ww := csv.NewWriter(w)
|
||||
|
||||
// set separator according to locale
|
||||
if b, _ := tag.Base(); b.String() == language.German.String() {
|
||||
ww.Comma = ';'
|
||||
}
|
||||
|
||||
if err := t.writeHeader(ctx, ww); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mp := message.NewPrinter(tag)
|
||||
for _, r := range *t {
|
||||
if err := t.writeRow(ww, mp, r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ww.Flush()
|
||||
|
||||
return ww.Error()
|
||||
return csvutil.WriteStructSlice(ctx, w, t, csvutil.Config{
|
||||
I18nPrefix: "sessions.csv",
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ func TestSqliteTimestamp(t *testing.T) {
|
|||
clock.Add(time.Hour)
|
||||
|
||||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, metrics.Init())
|
||||
require.NoError(t, metrics.SetupSchema())
|
||||
|
||||
metrics.Persist(clock.Now(), 0)
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ func TestUpdateHouseholdProfile(t *testing.T) {
|
|||
clock.Set(now.With(clock.Now()).BeginningOfDay())
|
||||
|
||||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
metrics.Init()
|
||||
require.NoError(t, metrics.SetupSchema())
|
||||
|
||||
// 2 days of data
|
||||
// day 1: 0 ... 95
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ func TestUpdateHomeConsumption(t *testing.T) {
|
|||
clock := clock.NewMock()
|
||||
|
||||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
metrics.Init()
|
||||
require.NoError(t, metrics.SetupSchema())
|
||||
|
||||
s := &Site{
|
||||
log: util.NewLogger("foo"),
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ import (
|
|||
"github.com/evcc-io/evcc/core/circuit"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/shared"
|
||||
"github.com/evcc-io/evcc/hems/smartgrid"
|
||||
"github.com/evcc-io/evcc/plugin"
|
||||
"github.com/evcc-io/evcc/server/eebus"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type EEBus struct {
|
||||
|
|
@ -28,6 +30,7 @@ type EEBus struct {
|
|||
root api.Circuit
|
||||
passthrough func(bool) error
|
||||
|
||||
smartgridID uint
|
||||
status status
|
||||
statusUpdated time.Time
|
||||
|
||||
|
|
@ -246,6 +249,39 @@ func (c *EEBus) setStatusAndLimit(status status, limit float64) {
|
|||
c.statusUpdated = time.Now()
|
||||
|
||||
c.setLimit(limit)
|
||||
|
||||
if err := c.updateSession(limit); err != nil {
|
||||
c.log.ERROR.Printf("smartgrid session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO keep in sync across HEMS implementations
|
||||
func (c *EEBus) updateSession(limit float64) error {
|
||||
// start session
|
||||
if limit > 0 && c.smartgridID == 0 {
|
||||
var power *float64
|
||||
if p := c.root.GetChargePower(); p > 0 {
|
||||
power = lo.ToPtr(p)
|
||||
}
|
||||
|
||||
sid, err := smartgrid.StartManage(smartgrid.Dim, power, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.smartgridID = sid
|
||||
}
|
||||
|
||||
// stop session
|
||||
if limit == 0 && c.smartgridID != 0 {
|
||||
if err := smartgrid.StopManage(c.smartgridID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.smartgridID = 0
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *EEBus) setLimit(limit float64) {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import (
|
|||
"github.com/evcc-io/evcc/core/circuit"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/shared"
|
||||
"github.com/evcc-io/evcc/hems/smartgrid"
|
||||
"github.com/evcc-io/evcc/plugin"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type Relay struct {
|
||||
|
|
@ -19,6 +21,8 @@ type Relay struct {
|
|||
|
||||
root api.Circuit
|
||||
passthrough func(bool) error
|
||||
|
||||
smartgridID uint
|
||||
limit func() (bool, error)
|
||||
maxPower float64
|
||||
interval time.Duration
|
||||
|
|
@ -98,21 +102,62 @@ func (c *Relay) Run() {
|
|||
}
|
||||
|
||||
func (c *Relay) run() error {
|
||||
limit, err := c.limit()
|
||||
limited, err := c.limit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var power float64
|
||||
if limit {
|
||||
power = c.maxPower
|
||||
var limit float64
|
||||
if limited {
|
||||
limit = c.maxPower
|
||||
}
|
||||
|
||||
c.root.Dim(limit)
|
||||
c.root.SetMaxPower(power)
|
||||
if err := c.setLimited(limit); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.updateSession(limit); err != nil {
|
||||
return fmt.Errorf("smartgrid session: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO keep in sync across HEMS implementations
|
||||
func (c *Relay) updateSession(limit float64) error {
|
||||
// start session
|
||||
if limit > 0 && c.smartgridID == 0 {
|
||||
var power *float64
|
||||
if p := c.root.GetChargePower(); p > 0 {
|
||||
power = lo.ToPtr(p)
|
||||
}
|
||||
|
||||
sid, err := smartgrid.StartManage(smartgrid.Dim, power, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.smartgridID = sid
|
||||
}
|
||||
|
||||
// stop session
|
||||
if limit == 0 && c.smartgridID != 0 {
|
||||
if err := smartgrid.StopManage(c.smartgridID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.smartgridID = 0
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Relay) setLimited(limit float64) error {
|
||||
c.root.Dim(limit > 0)
|
||||
c.root.SetMaxPower(limit)
|
||||
|
||||
if c.passthrough != nil {
|
||||
if err := c.passthrough(limit); err != nil {
|
||||
if err := c.passthrough(limit > 0); err != nil {
|
||||
return fmt.Errorf("passthrough failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
hems/smartgrid/smartgrid.go
Normal file
32
hems/smartgrid/smartgrid.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package smartgrid
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/server/db"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.Register(func() error {
|
||||
return db.Instance.AutoMigrate(new(GridSession))
|
||||
})
|
||||
}
|
||||
|
||||
func StartManage(typ Type, grid *float64, limit float64) (uint, error) {
|
||||
gs := GridSession{
|
||||
Created: time.Now(),
|
||||
Type: typ,
|
||||
GridPower: grid,
|
||||
LimitPower: limit,
|
||||
}
|
||||
tx := db.Instance.Save(&gs)
|
||||
return gs.ID, tx.Error
|
||||
}
|
||||
|
||||
func StopManage(id uint) error {
|
||||
return db.Instance.Where(&GridSession{
|
||||
ID: id,
|
||||
}).Updates(&GridSession{
|
||||
Finished: time.Now(),
|
||||
}).Error
|
||||
}
|
||||
34
hems/smartgrid/types.go
Normal file
34
hems/smartgrid/types.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package smartgrid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
csvutil "github.com/evcc-io/evcc/util/csv"
|
||||
)
|
||||
|
||||
type GridSession struct {
|
||||
ID uint `json:"id" csv:"-" gorm:"primarykey"`
|
||||
Created time.Time `json:"created,omitzero"`
|
||||
Finished time.Time `json:"finished,omitzero"`
|
||||
Type Type `json:"type"`
|
||||
GridPower *float64 `json:"grid,omitempty"`
|
||||
LimitPower float64 `json:"limit"`
|
||||
}
|
||||
|
||||
type Type string
|
||||
|
||||
const (
|
||||
Dim Type = "consumption"
|
||||
Curtail Type = "feedin"
|
||||
)
|
||||
|
||||
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{
|
||||
I18nPrefix: "config.hems.csv",
|
||||
})
|
||||
}
|
||||
13
i18n/de.json
13
i18n/de.json
|
|
@ -220,8 +220,19 @@
|
|||
"titleEdit": "Netzzähler bearbeiten"
|
||||
},
|
||||
"hems": {
|
||||
"csv": {
|
||||
"created": "Startzeit",
|
||||
"finished": "Endzeit",
|
||||
"gridpower": "Netzbezug (kW)",
|
||||
"limitpower": "Limit (kW)",
|
||||
"type": "Typ"
|
||||
},
|
||||
"description": "Leistungsbegrenzung durch externe Systeme (bspw. Steuerboxen, § 14a EnWG). Setzt Lastmanagementkonfiguration voraus.",
|
||||
"title": "HEMS"
|
||||
"downloadCsv": "CSV herunterladen",
|
||||
"eventsRecorded": "{count} Netzbegrenzungen gespeichert.",
|
||||
"lastEvent": "Zuletzt {timeAgo}.",
|
||||
"title": "HEMS",
|
||||
"yamlConfigured": "Hinweis: Konfiguriert über evcc.yaml. Entferne den Eintrag aus der Datei, um die Bearbeitung hier zu aktivieren."
|
||||
},
|
||||
"icon": {
|
||||
"change": "ändern",
|
||||
|
|
|
|||
13
i18n/en.json
13
i18n/en.json
|
|
@ -220,8 +220,19 @@
|
|||
"titleEdit": "Edit Grid Meter"
|
||||
},
|
||||
"hems": {
|
||||
"csv": {
|
||||
"created": "Created",
|
||||
"finished": "Finished",
|
||||
"gridpower": "Grid Power (kW)",
|
||||
"limitpower": "Limit (kW)",
|
||||
"type": "Type"
|
||||
},
|
||||
"description": "Power limitation by external systems (e.g. EnWG 14a controllers). Requires load management configuration.",
|
||||
"title": "HEMS"
|
||||
"downloadCsv": "Download CSV",
|
||||
"eventsRecorded": "Recorded {count} grid limitation events.",
|
||||
"lastEvent": "Most recent {timeAgo}.",
|
||||
"title": "HEMS",
|
||||
"yamlConfigured": "Note: Configured via evcc.yaml. Remove the entry from the file to enable editing here."
|
||||
},
|
||||
"icon": {
|
||||
"change": "change",
|
||||
|
|
|
|||
6
server/db/cache/cache.go
vendored
6
server/db/cache/cache.go
vendored
|
|
@ -17,8 +17,10 @@ type Cache struct {
|
|||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func Init() error {
|
||||
return db.Instance.AutoMigrate(new(Cache))
|
||||
func init() {
|
||||
db.Register(func() error {
|
||||
return db.Instance.AutoMigrate(new(Cache))
|
||||
})
|
||||
}
|
||||
|
||||
func Put(key string, value any) error {
|
||||
|
|
|
|||
|
|
@ -50,9 +50,24 @@ func New(driver, dsn string) (*gorm.DB, error) {
|
|||
})
|
||||
}
|
||||
|
||||
func NewInstance(driver, dsn string) (err error) {
|
||||
Instance, err = New(strings.ToLower(driver), dsn)
|
||||
return
|
||||
func NewInstance(driver, dsn string) error {
|
||||
inst, err := New(strings.ToLower(driver), dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Instance = inst
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
for _, f := range registry {
|
||||
if err := f(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Close() error {
|
||||
|
|
|
|||
14
server/db/registry.go
Normal file
14
server/db/registry.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package db
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
registry []func() error
|
||||
)
|
||||
|
||||
func Register(fun func() error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
registry = append(registry, fun)
|
||||
}
|
||||
|
|
@ -33,12 +33,14 @@ var (
|
|||
settings []setting
|
||||
)
|
||||
|
||||
func Init() error {
|
||||
err := db.Instance.AutoMigrate(new(setting))
|
||||
if err == nil {
|
||||
err = db.Instance.Find(&settings).Error
|
||||
}
|
||||
return err
|
||||
func init() {
|
||||
db.Register(func() error {
|
||||
if err := db.Instance.AutoMigrate(new(setting)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Instance.Find(&settings).Error
|
||||
})
|
||||
}
|
||||
|
||||
func Persist() error {
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
|
|||
"sessions": {"GET", "/sessions", sessionHandler},
|
||||
"updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
|
||||
"deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},
|
||||
"gridsessions": {"GET", "/gridsessions", gridSessionsHandler},
|
||||
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
|
||||
}
|
||||
|
||||
|
|
|
|||
44
server/http_gridsessions_handler.go
Normal file
44
server/http_gridsessions_handler.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/evcc-io/evcc/hems/smartgrid"
|
||||
"github.com/evcc-io/evcc/server/db"
|
||||
"github.com/evcc-io/evcc/util/locale"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// gridSessionsHandler returns the list of grid sessions
|
||||
func gridSessionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if db.Instance == nil {
|
||||
jsonError(w, http.StatusBadRequest, errors.New("database offline"))
|
||||
return
|
||||
}
|
||||
|
||||
var res smartgrid.GridSessions
|
||||
|
||||
if txn := db.Instance.Order("created DESC").Find(&res); txn.Error != nil {
|
||||
jsonError(w, http.StatusInternalServerError, txn.Error)
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("format") == "csv" {
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
// get request language
|
||||
lang = r.Header.Get("Accept-Language")
|
||||
if tags, _, err := language.ParseAcceptLanguage(lang); err == nil && len(tags) > 0 {
|
||||
lang = tags[0].String()
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), locale.Locale, lang)
|
||||
csvResult(ctx, w, &res, "gridsessions")
|
||||
return
|
||||
}
|
||||
|
||||
jsonWrite(w, res)
|
||||
}
|
||||
|
|
@ -378,18 +378,10 @@
|
|||
"description": "Charging sessions",
|
||||
"items": {
|
||||
"properties": {
|
||||
"chargeDuration": {
|
||||
"description": "Duration of active charging",
|
||||
"type": "number"
|
||||
},
|
||||
"chargedEnergy": {
|
||||
"description": "Charged energy in kWh",
|
||||
"type": "number"
|
||||
},
|
||||
"co2PerKWh": {
|
||||
"description": "Average CO₂ emissions per kWh",
|
||||
"type": "number"
|
||||
},
|
||||
"created": {
|
||||
"$ref": "#/components/schemas/Timestamp"
|
||||
},
|
||||
|
|
@ -417,18 +409,6 @@
|
|||
"nullable": true,
|
||||
"type": "number"
|
||||
},
|
||||
"price": {
|
||||
"description": "Total price of the session",
|
||||
"type": "number"
|
||||
},
|
||||
"pricePerKWh": {
|
||||
"description": "Average price per kWh",
|
||||
"type": "number"
|
||||
},
|
||||
"solarPercentage": {
|
||||
"description": "Solar percentage of the session",
|
||||
"type": "number"
|
||||
},
|
||||
"vehicle": {
|
||||
"$ref": "#/components/schemas/VehicleName"
|
||||
}
|
||||
|
|
@ -454,6 +434,60 @@
|
|||
"minimum": 0,
|
||||
"type": "number"
|
||||
},
|
||||
"GridSessions": {
|
||||
"description": "Grid limitation sessions",
|
||||
"items": {
|
||||
"properties": {
|
||||
"chargeDuration": {
|
||||
"description": "Duration of active charging",
|
||||
"type": "number"
|
||||
},
|
||||
"co2PerKWh": {
|
||||
"description": "Average CO₂ emissions per kWh",
|
||||
"type": "number"
|
||||
},
|
||||
"created": {
|
||||
"$ref": "#/components/schemas/Timestamp"
|
||||
},
|
||||
"finished": {
|
||||
"$ref": "#/components/schemas/Timestamp"
|
||||
},
|
||||
"gridPower": {
|
||||
"description": "Grid power in watts",
|
||||
"type": "number"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "#/components/schemas/Id"
|
||||
},
|
||||
"limitPower": {
|
||||
"description": "Limitation power in watts",
|
||||
"type": "number"
|
||||
},
|
||||
"price": {
|
||||
"description": "Total price of the session",
|
||||
"type": "number"
|
||||
},
|
||||
"pricePerKWh": {
|
||||
"description": "Average price per kWh",
|
||||
"type": "number"
|
||||
},
|
||||
"solarPercentage": {
|
||||
"description": "Solar percentage of the session",
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"description": "Type of grid limitation event",
|
||||
"enum": [
|
||||
"consumption",
|
||||
"feedin"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"HourMinuteTime": {
|
||||
"description": "Time in `HH:MM` format",
|
||||
"example": "12:30",
|
||||
|
|
@ -991,6 +1025,62 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/gridsessions": {
|
||||
"get": {
|
||||
"description": "Returns a list of HEMS grid limitation events.",
|
||||
"operationId": "getGridSessions",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Response format (default json)",
|
||||
"in": "query",
|
||||
"name": "format",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"csv"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Language (defaults to accept header)",
|
||||
"in": "query",
|
||||
"name": "lang",
|
||||
"schema": {
|
||||
"example": "de",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"result": {
|
||||
"$ref": "#/components/schemas/GridSessions"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"text/csv": {
|
||||
"schema": {
|
||||
"description": "Download csv-file",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"summary": "Grid limitation sessions",
|
||||
"tags": [
|
||||
"sessions"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "Returns 200 if the evcc loop runs as expected.",
|
||||
|
|
|
|||
|
|
@ -858,6 +858,28 @@ call deleteSession {
|
|||
}
|
||||
```
|
||||
|
||||
## getGridSessions
|
||||
|
||||
Returns a list of HEMS grid limitation events.
|
||||
|
||||
**Tags:** sessions
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| format | string | Response format (default json) |
|
||||
| lang | string | Language (defaults to accept header) |
|
||||
|
||||
**Example call:**
|
||||
|
||||
```json
|
||||
call getGridSessions {
|
||||
"format": "example",
|
||||
"lang": "example"
|
||||
}
|
||||
```
|
||||
|
||||
## getSessions
|
||||
|
||||
Returns a list of charging sessions.
|
||||
|
|
|
|||
|
|
@ -702,6 +702,42 @@ paths:
|
|||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/NullResult"
|
||||
/gridsessions:
|
||||
get:
|
||||
operationId: getGridSessions
|
||||
summary: Grid limitation sessions
|
||||
description: "Returns a list of HEMS grid limitation events."
|
||||
tags:
|
||||
- sessions
|
||||
parameters:
|
||||
- name: format
|
||||
in: query
|
||||
description: Response format (default json)
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- csv
|
||||
- name: lang
|
||||
in: query
|
||||
description: Language (defaults to accept header)
|
||||
schema:
|
||||
type: string
|
||||
example: de
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/components/schemas/GridSessions"
|
||||
text/csv:
|
||||
schema:
|
||||
description: Download csv-file
|
||||
type: string
|
||||
format: binary
|
||||
/sessions:
|
||||
get:
|
||||
operationId: getSessions
|
||||
|
|
@ -1107,6 +1143,30 @@ components:
|
|||
chargedEnergy:
|
||||
type: number
|
||||
description: Charged energy in kWh
|
||||
GridSessions:
|
||||
description: Grid limitation sessions
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
$ref: "#/components/schemas/Id"
|
||||
created:
|
||||
$ref: "#/components/schemas/Timestamp"
|
||||
finished:
|
||||
$ref: "#/components/schemas/Timestamp"
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- consumption
|
||||
- feedin
|
||||
description: Type of grid limitation event
|
||||
gridPower:
|
||||
type: number
|
||||
description: Grid power in watts
|
||||
limitPower:
|
||||
type: number
|
||||
description: Limitation power in watts
|
||||
chargeDuration:
|
||||
type: number
|
||||
description: Duration of active charging
|
||||
|
|
|
|||
11
tests/hems-yaml.evcc.yaml
Normal file
11
tests/hems-yaml.evcc.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
interval: 0.1s
|
||||
|
||||
hems:
|
||||
type: relay
|
||||
maxPower: 4200
|
||||
limit:
|
||||
source: const
|
||||
value: true
|
||||
|
||||
circuits:
|
||||
- name: main
|
||||
|
|
@ -29,6 +29,49 @@ test.afterEach(async () => {
|
|||
const CONFIG = "fast.evcc.yaml";
|
||||
|
||||
test.describe("HEMS", () => {
|
||||
test("grid sessions", async ({ page }) => {
|
||||
await start(CONFIG, "hems.sql");
|
||||
await page.goto("/#/config");
|
||||
await enableExperimental(page);
|
||||
|
||||
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 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("yaml-editor")).toBeVisible();
|
||||
await expect(hemsModal.getByRole("button", { name: "Save" })).toBeVisible();
|
||||
await expect(hemsModal).not.toContainText("Configured via evcc.yaml");
|
||||
|
||||
await hemsModal.getByRole("button", { name: "Cancel" }).click();
|
||||
await expectModalHidden(hemsModal);
|
||||
});
|
||||
|
||||
test("modal yaml-configured", async ({ page }) => {
|
||||
await start("hems-yaml.evcc.yaml");
|
||||
await page.goto("/#/config");
|
||||
await enableExperimental(page);
|
||||
|
||||
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
|
||||
const hemsModal = page.getByTestId("hems-modal");
|
||||
await expectModalVisible(hemsModal);
|
||||
|
||||
await expect(hemsModal.getByTestId("grid-sessions")).not.toBeVisible();
|
||||
await expect(hemsModal).toContainText("Configured via evcc.yaml");
|
||||
await expect(hemsModal.getByTestId("yaml-editor")).not.toBeVisible();
|
||||
await expect(hemsModal.getByRole("button", { name: "Save" })).not.toBeVisible();
|
||||
await expect(hemsModal.getByRole("button", { name: "Cancel" })).toBeVisible();
|
||||
|
||||
await hemsModal.getByRole("button", { name: "Cancel" }).click();
|
||||
await expectModalHidden(hemsModal);
|
||||
});
|
||||
|
||||
test("configure loadmanagement and hems via UI, verify logic", async ({ page }) => {
|
||||
await start(CONFIG);
|
||||
|
||||
|
|
|
|||
14
tests/hems.sql
Normal file
14
tests/hems.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
DROP TABLE IF EXISTS `grid_sessions`;
|
||||
CREATE TABLE `grid_sessions` (
|
||||
`id` integer,
|
||||
`created` datetime,
|
||||
`finished` datetime,
|
||||
`type` text,
|
||||
`grid_power` real,
|
||||
`limit_power` real,
|
||||
PRIMARY KEY (`id`)
|
||||
);
|
||||
|
||||
INSERT INTO `grid_sessions` VALUES (1,'2025-05-01 08:00:00.0+02:00','2025-05-01 09:30:00.0+02:00','consumption',5000,4200);
|
||||
INSERT INTO `grid_sessions` VALUES (2,'2025-05-02 14:00:00.0+02:00','2025-05-02 15:00:00.0+02:00','feedin',3500,3000);
|
||||
INSERT INTO `grid_sessions` VALUES (3,'2025-05-05 10:00:00.0+02:00','2025-05-05 11:00:00.0+02:00','consumption',4800,4200);
|
||||
|
|
@ -76,7 +76,14 @@ func (d *Config) Delete() error {
|
|||
return db.Instance.Delete(Config{ID: d.ID}).Error
|
||||
}
|
||||
|
||||
func Init() error {
|
||||
func init() {
|
||||
db.Register(func() error {
|
||||
return SetupSchema()
|
||||
})
|
||||
}
|
||||
|
||||
// SetupSchema is used for testing
|
||||
func SetupSchema() error {
|
||||
return db.Instance.AutoMigrate(new(Config))
|
||||
}
|
||||
|
||||
|
|
|
|||
147
util/csv/writer.go
Normal file
147
util/csv/writer.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
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 := 3
|
||||
if format := f.Tag("format"); format == "int" {
|
||||
digits = 0
|
||||
}
|
||||
|
||||
val := formatValue(mp, f.Value(), digits)
|
||||
row = append(row, val)
|
||||
}
|
||||
|
||||
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 {
|
||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lang := locale.Language
|
||||
if language, ok := ctx.Value(locale.Locale).(string); ok && language != "" {
|
||||
lang = language
|
||||
}
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
|
||||
tag, err := language.Parse(lang)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ww := csv.NewWriter(w)
|
||||
|
||||
if b, _ := tag.Base(); b.String() == language.German.String() {
|
||||
ww.Comma = ';'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
mp := message.NewPrinter(tag)
|
||||
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()
|
||||
}
|
||||
127
util/csv/writer_test.go
Normal file
127
util/csv/writer_test.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package csv
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/server/assets"
|
||||
"github.com/evcc-io/evcc/util/locale"
|
||||
)
|
||||
|
||||
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_Empty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
slice := TestStructs{}
|
||||
|
||||
ctx := context.WithValue(context.Background(), locale.Locale, "en")
|
||||
err := WriteStructSlice(ctx, &buf, &slice, Config{
|
||||
I18nPrefix: "test.csv",
|
||||
})
|
||||
|
||||
if 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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
err := WriteStructSlice(ctx, &buf, &slice, Config{
|
||||
I18nPrefix: "test.csv",
|
||||
})
|
||||
|
||||
if 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")
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimPrefix(output, "\xEF\xBB\xBF"), "\n")
|
||||
if len(lines) < 2 {
|
||||
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 !strings.Contains(lines[1], "Test,123.456,42.5,2024-01-01") {
|
||||
t.Errorf("Expected data row to contain Test data\nGot: %s", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStructSlice_GermanLocale(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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue