HEMS: add "smartgrid" session logging (#25907)

This commit is contained in:
andig 2025-12-12 18:29:31 +01:00 • committed by GitHub
parent 73f468e4c5
commit 381573b0eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 915 additions and 215 deletions

View file

@ -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>

View file

@ -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() {

View file

@ -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

View file

@ -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" />

View file

@ -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)

View file

@ -23,7 +23,7 @@ type circuitsTestSuite struct {
func (suite *circuitsTestSuite) SetupSuite() {
_ = db.NewInstance("sqlite", ":memory:")
config.Init()
config.SetupSchema()
}
func (suite *circuitsTestSuite) SetupTest() {

View file

@ -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))
}

View file

@ -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
}
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

View file

@ -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))
}

View file

@ -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",
})
}

View file

@ -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

View file

@ -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"),

View file

@ -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) {

View file

@ -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)
}
}

View 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
View 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",
})
}

View file

@ -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",

View file

@ -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",

View file

@ -17,8 +17,10 @@ type Cache struct {
Value string `json:"value"`
}
func Init() error {
func init() {
db.Register(func() error {
return db.Instance.AutoMigrate(new(Cache))
})
}
func Put(key string, value any) error {

View file

@ -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
View 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)
}

View file

@ -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
}
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 {

View file

@ -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)},
}

View 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)
}

View file

@ -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.",

View file

@ -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.

View file

@ -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
View file

@ -0,0 +1,11 @@
interval: 0.1s
hems:
type: relay
maxPower: 4200
limit:
source: const
value: true
circuits:
- name: main

View file

@ -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
View 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);

View file

@ -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
View 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
View 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)
}
}