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"
|
:defaultYaml="defaultYaml"
|
||||||
endpoint="/config/hems"
|
endpoint="/config/hems"
|
||||||
removeKey="hems"
|
removeKey="hems"
|
||||||
|
:noYamlEditor="fromYaml"
|
||||||
|
:disableSave="fromYaml"
|
||||||
data-testid="hems-modal"
|
data-testid="hems-modal"
|
||||||
@changed="$emit('changed')"
|
@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>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import YamlModal from "./YamlModal.vue";
|
import YamlModal from "./YamlModal.vue";
|
||||||
import defaultYaml from "./defaultYaml/hems.yaml?raw";
|
import defaultYaml from "./defaultYaml/hems.yaml?raw";
|
||||||
|
import api from "../../api";
|
||||||
|
import formatter from "../../mixins/formatter";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "HemsModal",
|
name: "HemsModal",
|
||||||
components: { YamlModal },
|
components: { YamlModal },
|
||||||
|
mixins: [formatter],
|
||||||
|
props: {
|
||||||
|
fromYaml: Boolean,
|
||||||
|
},
|
||||||
emits: ["changed"],
|
emits: ["changed"],
|
||||||
data() {
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@
|
||||||
{{ $t("config.general.docsLink") }}
|
{{ $t("config.general.docsLink") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
<slot name="afterDescription" />
|
||||||
<p v-if="error" class="text-danger" data-testid="error">{{ error }}</p>
|
<p v-if="error" class="text-danger" data-testid="error">{{ error }}</p>
|
||||||
<form ref="form" class="container mx-0 px-0">
|
<form ref="form" class="container mx-0 px-0">
|
||||||
<div class="editor-container">
|
<div v-if="!noYamlEditor" class="editor-container">
|
||||||
<YamlEditorContainer
|
<YamlEditorContainer
|
||||||
v-model="yaml"
|
v-model="yaml"
|
||||||
:errorLine="errorLine"
|
:errorLine="errorLine"
|
||||||
|
|
@ -27,6 +28,7 @@
|
||||||
{{ $t("config.general.cancel") }}
|
{{ $t("config.general.cancel") }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="!disableSave"
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:disabled="saving || nothingChanged"
|
:disabled="saving || nothingChanged"
|
||||||
|
|
@ -62,8 +64,10 @@ export default {
|
||||||
defaultYaml: String,
|
defaultYaml: String,
|
||||||
removeKey: String,
|
removeKey: String,
|
||||||
size: { type: String, default: "xl" },
|
size: { type: String, default: "xl" },
|
||||||
|
noYamlEditor: Boolean,
|
||||||
|
disableSave: Boolean,
|
||||||
},
|
},
|
||||||
emits: ["changed"],
|
emits: ["changed", "open"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
saving: false,
|
saving: false,
|
||||||
|
|
@ -93,6 +97,7 @@ export default {
|
||||||
async open() {
|
async open() {
|
||||||
this.reset();
|
this.reset();
|
||||||
this.modalVisible = true;
|
this.modalVisible = true;
|
||||||
|
this.$emit("open");
|
||||||
await this.load();
|
await this.load();
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
## external control via binary in put
|
## external control via digital signal
|
||||||
|
|
||||||
#type: relay
|
#type: relay
|
||||||
#maxPower: 4200 # limit loadpoints to 4.2 kW total
|
#maxPower: 4200 # limit loadpoints to 4.2 kW total
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@
|
||||||
</DeviceCard>
|
</DeviceCard>
|
||||||
<DeviceCard
|
<DeviceCard
|
||||||
:title="$t('config.hems.title')"
|
:title="$t('config.hems.title')"
|
||||||
:editable="!hems?.fromYaml"
|
editable
|
||||||
:error="hasClassError('hems')"
|
:error="hasClassError('hems')"
|
||||||
data-testid="hems"
|
data-testid="hems"
|
||||||
@edit="openModal('hemsModal')"
|
@edit="openModal('hemsModal')"
|
||||||
|
|
@ -368,7 +368,7 @@
|
||||||
<MqttModal @changed="loadDirty" />
|
<MqttModal @changed="loadDirty" />
|
||||||
<NetworkModal @changed="loadDirty" />
|
<NetworkModal @changed="loadDirty" />
|
||||||
<ControlModal @changed="loadDirty" />
|
<ControlModal @changed="loadDirty" />
|
||||||
<HemsModal @changed="yamlChanged" />
|
<HemsModal :fromYaml="hems?.fromYaml" @changed="yamlChanged" />
|
||||||
<ShmModal @changed="loadDirty" />
|
<ShmModal @changed="loadDirty" />
|
||||||
<MessagingModal @changed="yamlChanged" />
|
<MessagingModal @changed="yamlChanged" />
|
||||||
<TariffsModal @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/circuit"
|
||||||
"github.com/evcc-io/evcc/core/keys"
|
"github.com/evcc-io/evcc/core/keys"
|
||||||
"github.com/evcc-io/evcc/core/loadpoint"
|
"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"
|
coresettings "github.com/evcc-io/evcc/core/settings"
|
||||||
"github.com/evcc-io/evcc/hems"
|
"github.com/evcc-io/evcc/hems"
|
||||||
hemsapi "github.com/evcc-io/evcc/hems/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/push"
|
||||||
"github.com/evcc-io/evcc/server"
|
"github.com/evcc-io/evcc/server"
|
||||||
"github.com/evcc-io/evcc/server/db"
|
"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/db/settings"
|
||||||
"github.com/evcc-io/evcc/server/eebus"
|
"github.com/evcc-io/evcc/server/eebus"
|
||||||
"github.com/evcc-io/evcc/server/modbus"
|
"github.com/evcc-io/evcc/server/modbus"
|
||||||
|
|
@ -578,26 +575,6 @@ func configureDatabase(conf globalconfig.DB) error {
|
||||||
return err
|
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() {
|
persistSettings := func() {
|
||||||
if err := settings.Persist(); err != nil {
|
if err := settings.Persist(); err != nil {
|
||||||
log.ERROR.Println("cannot save settings:", err)
|
log.ERROR.Println("cannot save settings:", err)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ type circuitsTestSuite struct {
|
||||||
|
|
||||||
func (suite *circuitsTestSuite) SetupSuite() {
|
func (suite *circuitsTestSuite) SetupSuite() {
|
||||||
_ = db.NewInstance("sqlite", ":memory:")
|
_ = db.NewInstance("sqlite", ":memory:")
|
||||||
config.Init()
|
config.SetupSchema()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *circuitsTestSuite) SetupTest() {
|
func (suite *circuitsTestSuite) SetupTest() {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,14 @@ type meter struct {
|
||||||
|
|
||||||
var ErrIncomplete = errors.New("meter profile incomplete")
|
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))
|
return db.Instance.AutoMigrate(new(meter))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,14 @@ var (
|
||||||
sessions Sessions
|
sessions Sessions
|
||||||
)
|
)
|
||||||
|
|
||||||
func Init() error {
|
func init() {
|
||||||
err := db.Instance.AutoMigrate(new(Session))
|
db.Register(func() error {
|
||||||
if err == nil {
|
if err := db.Instance.AutoMigrate(new(Session)); err != nil {
|
||||||
err = db.Instance.Find(&sessions).Error
|
return err
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
|
return db.Instance.Find(&sessions).Error
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore creates a session store
|
// 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/evcc-io/evcc/util/locale"
|
csvutil "github.com/evcc-io/evcc/util/csv"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Session is a single charging session
|
// Session is a single charging session
|
||||||
|
|
@ -42,112 +33,9 @@ type Sessions []Session
|
||||||
|
|
||||||
var _ api.CsvWriter = (*Sessions)(nil)
|
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
|
// WriteCsv implements the api.CsvWriter interface
|
||||||
func (t *Sessions) WriteCsv(ctx context.Context, w io.Writer) error {
|
func (t *Sessions) WriteCsv(ctx context.Context, w io.Writer) error {
|
||||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
return csvutil.WriteStructSlice(ctx, w, t, csvutil.Config{
|
||||||
return err
|
I18nPrefix: "sessions.csv",
|
||||||
}
|
})
|
||||||
|
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ func TestSqliteTimestamp(t *testing.T) {
|
||||||
clock.Add(time.Hour)
|
clock.Add(time.Hour)
|
||||||
|
|
||||||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||||
require.NoError(t, metrics.Init())
|
require.NoError(t, metrics.SetupSchema())
|
||||||
|
|
||||||
metrics.Persist(clock.Now(), 0)
|
metrics.Persist(clock.Now(), 0)
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ func TestUpdateHouseholdProfile(t *testing.T) {
|
||||||
clock.Set(now.With(clock.Now()).BeginningOfDay())
|
clock.Set(now.With(clock.Now()).BeginningOfDay())
|
||||||
|
|
||||||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||||
metrics.Init()
|
require.NoError(t, metrics.SetupSchema())
|
||||||
|
|
||||||
// 2 days of data
|
// 2 days of data
|
||||||
// day 1: 0 ... 95
|
// day 1: 0 ... 95
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ func TestUpdateHomeConsumption(t *testing.T) {
|
||||||
clock := clock.NewMock()
|
clock := clock.NewMock()
|
||||||
|
|
||||||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||||
metrics.Init()
|
require.NoError(t, metrics.SetupSchema())
|
||||||
|
|
||||||
s := &Site{
|
s := &Site{
|
||||||
log: util.NewLogger("foo"),
|
log: util.NewLogger("foo"),
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ import (
|
||||||
"github.com/evcc-io/evcc/core/circuit"
|
"github.com/evcc-io/evcc/core/circuit"
|
||||||
"github.com/evcc-io/evcc/core/site"
|
"github.com/evcc-io/evcc/core/site"
|
||||||
"github.com/evcc-io/evcc/hems/shared"
|
"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/plugin"
|
||||||
"github.com/evcc-io/evcc/server/eebus"
|
"github.com/evcc-io/evcc/server/eebus"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/samber/lo"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EEBus struct {
|
type EEBus struct {
|
||||||
|
|
@ -28,6 +30,7 @@ type EEBus struct {
|
||||||
root api.Circuit
|
root api.Circuit
|
||||||
passthrough func(bool) error
|
passthrough func(bool) error
|
||||||
|
|
||||||
|
smartgridID uint
|
||||||
status status
|
status status
|
||||||
statusUpdated time.Time
|
statusUpdated time.Time
|
||||||
|
|
||||||
|
|
@ -246,6 +249,39 @@ func (c *EEBus) setStatusAndLimit(status status, limit float64) {
|
||||||
c.statusUpdated = time.Now()
|
c.statusUpdated = time.Now()
|
||||||
|
|
||||||
c.setLimit(limit)
|
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) {
|
func (c *EEBus) setLimit(limit float64) {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import (
|
||||||
"github.com/evcc-io/evcc/core/circuit"
|
"github.com/evcc-io/evcc/core/circuit"
|
||||||
"github.com/evcc-io/evcc/core/site"
|
"github.com/evcc-io/evcc/core/site"
|
||||||
"github.com/evcc-io/evcc/hems/shared"
|
"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/plugin"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/samber/lo"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Relay struct {
|
type Relay struct {
|
||||||
|
|
@ -19,6 +21,8 @@ type Relay struct {
|
||||||
|
|
||||||
root api.Circuit
|
root api.Circuit
|
||||||
passthrough func(bool) error
|
passthrough func(bool) error
|
||||||
|
|
||||||
|
smartgridID uint
|
||||||
limit func() (bool, error)
|
limit func() (bool, error)
|
||||||
maxPower float64
|
maxPower float64
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
|
|
@ -98,21 +102,62 @@ func (c *Relay) Run() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Relay) run() error {
|
func (c *Relay) run() error {
|
||||||
limit, err := c.limit()
|
limited, err := c.limit()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var power float64
|
var limit float64
|
||||||
if limit {
|
if limited {
|
||||||
power = c.maxPower
|
limit = c.maxPower
|
||||||
}
|
}
|
||||||
|
|
||||||
c.root.Dim(limit)
|
if err := c.setLimited(limit); err != nil {
|
||||||
c.root.SetMaxPower(power)
|
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 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)
|
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"
|
"titleEdit": "Netzzähler bearbeiten"
|
||||||
},
|
},
|
||||||
"hems": {
|
"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.",
|
"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": {
|
"icon": {
|
||||||
"change": "ändern",
|
"change": "ändern",
|
||||||
|
|
|
||||||
13
i18n/en.json
13
i18n/en.json
|
|
@ -220,8 +220,19 @@
|
||||||
"titleEdit": "Edit Grid Meter"
|
"titleEdit": "Edit Grid Meter"
|
||||||
},
|
},
|
||||||
"hems": {
|
"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.",
|
"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": {
|
"icon": {
|
||||||
"change": "change",
|
"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"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Init() error {
|
func init() {
|
||||||
return db.Instance.AutoMigrate(new(Cache))
|
db.Register(func() error {
|
||||||
|
return db.Instance.AutoMigrate(new(Cache))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Put(key string, value any) error {
|
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) {
|
func NewInstance(driver, dsn string) error {
|
||||||
Instance, err = New(strings.ToLower(driver), dsn)
|
inst, err := New(strings.ToLower(driver), dsn)
|
||||||
return
|
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 {
|
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
|
settings []setting
|
||||||
)
|
)
|
||||||
|
|
||||||
func Init() error {
|
func init() {
|
||||||
err := db.Instance.AutoMigrate(new(setting))
|
db.Register(func() error {
|
||||||
if err == nil {
|
if err := db.Instance.AutoMigrate(new(setting)); err != nil {
|
||||||
err = db.Instance.Find(&settings).Error
|
return err
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
|
return db.Instance.Find(&settings).Error
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Persist() error {
|
func Persist() error {
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
|
||||||
"sessions": {"GET", "/sessions", sessionHandler},
|
"sessions": {"GET", "/sessions", sessionHandler},
|
||||||
"updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
|
"updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
|
||||||
"deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},
|
"deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},
|
||||||
|
"gridsessions": {"GET", "/gridsessions", gridSessionsHandler},
|
||||||
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
|
"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",
|
"description": "Charging sessions",
|
||||||
"items": {
|
"items": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"chargeDuration": {
|
|
||||||
"description": "Duration of active charging",
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"chargedEnergy": {
|
"chargedEnergy": {
|
||||||
"description": "Charged energy in kWh",
|
"description": "Charged energy in kWh",
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
"co2PerKWh": {
|
|
||||||
"description": "Average CO₂ emissions per kWh",
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"created": {
|
"created": {
|
||||||
"$ref": "#/components/schemas/Timestamp"
|
"$ref": "#/components/schemas/Timestamp"
|
||||||
},
|
},
|
||||||
|
|
@ -417,18 +409,6 @@
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "number"
|
"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": {
|
"vehicle": {
|
||||||
"$ref": "#/components/schemas/VehicleName"
|
"$ref": "#/components/schemas/VehicleName"
|
||||||
}
|
}
|
||||||
|
|
@ -454,6 +434,60 @@
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "number"
|
"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": {
|
"HourMinuteTime": {
|
||||||
"description": "Time in `HH:MM` format",
|
"description": "Time in `HH:MM` format",
|
||||||
"example": "12:30",
|
"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": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns 200 if the evcc loop runs as expected.",
|
"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
|
## getSessions
|
||||||
|
|
||||||
Returns a list of charging sessions.
|
Returns a list of charging sessions.
|
||||||
|
|
|
||||||
|
|
@ -702,6 +702,42 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "#/components/responses/NullResult"
|
$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:
|
/sessions:
|
||||||
get:
|
get:
|
||||||
operationId: getSessions
|
operationId: getSessions
|
||||||
|
|
@ -1107,6 +1143,30 @@ components:
|
||||||
chargedEnergy:
|
chargedEnergy:
|
||||||
type: number
|
type: number
|
||||||
description: Charged energy in kWh
|
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:
|
chargeDuration:
|
||||||
type: number
|
type: number
|
||||||
description: Duration of active charging
|
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";
|
const CONFIG = "fast.evcc.yaml";
|
||||||
|
|
||||||
test.describe("HEMS", () => {
|
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 }) => {
|
test("configure loadmanagement and hems via UI, verify logic", async ({ page }) => {
|
||||||
await start(CONFIG);
|
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
|
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))
|
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