From 4eada9ba5ce71ce44bfae16fcb673df6e777e208 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 28 May 2026 08:28:04 +0200 Subject: [PATCH 001/959] VW: migrate WeConnect auth to OIDC token exchange (#30277) --- vehicle/vag/loginapps/endpoint.go | 96 ----------------------------- vehicle/vag/loginapps/token.go | 28 --------- vehicle/vag/loginapps/token_test.go | 31 ---------- vehicle/vw.go | 8 +-- vehicle/vw/weconnect/oauth.go | 71 +++++++++++++++++++++ vehicle/vw/weconnect/params.go | 8 ++- 6 files changed, 79 insertions(+), 163 deletions(-) delete mode 100644 vehicle/vag/loginapps/endpoint.go delete mode 100644 vehicle/vag/loginapps/token.go delete mode 100644 vehicle/vag/loginapps/token_test.go create mode 100644 vehicle/vw/weconnect/oauth.go diff --git a/vehicle/vag/loginapps/endpoint.go b/vehicle/vag/loginapps/endpoint.go deleted file mode 100644 index c2acc2ca7..000000000 --- a/vehicle/vag/loginapps/endpoint.go +++ /dev/null @@ -1,96 +0,0 @@ -package loginapps - -import ( - "net/http" - "net/url" - "strings" - "time" - - "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/oauth" - "github.com/evcc-io/evcc/util/request" - "github.com/evcc-io/evcc/util/urlvalues" - "github.com/evcc-io/evcc/vehicle/vag/cariad" - "golang.org/x/oauth2" -) - -var Endpoint = &oauth2.Endpoint{ - AuthURL: cariad.BaseURL + "/user-login/login/v1", - TokenURL: cariad.BaseURL + "/user-login/refresh/v1", -} - -type Service struct { - *request.Helper -} - -func New(log *util.Logger) *Service { - return &Service{ - Helper: request.NewHelper(log), - } -} - -func (v *Service) Exchange(q url.Values) (*Token, error) { - if err := urlvalues.Require(q, "state", "id_token", "access_token", "code"); err != nil { - return nil, err - } - - data := map[string]string{ - "region": "emea", - "redirect_uri": "weconnect://authenticated", - "state": q.Get("state"), - "id_token": q.Get("id_token"), - "access_token": q.Get("access_token"), - "authorizationCode": q.Get("code"), - } - - var res Token - - req, err := request.New(http.MethodPost, Endpoint.AuthURL, request.MarshalJSON(data), request.JSONEncoding) - if err == nil { - err = v.DoJSON(req, &res) - } - - return &res, err -} - -func (v *Service) Refresh(token *Token) (*Token, error) { - body := url.Values{ - "grant_type": []string{"refresh_token"}, - "refresh_token": []string{token.RefreshToken}, - "client_id": []string{cariad.ClientID}, - } - - req, err := request.New( - http.MethodPost, - cariad.BaseURL+"/auth/v1/idk/oidc/token", - strings.NewReader(body.Encode()), - request.URLEncoding, - map[string]string{ - "Connection": "keep-alive", - "User-Agent": cariad.UserAgent, - "Accept": "application/json", - "x-android-package-name": cariad.AndroidPackageName, - }, - ) - - var res oauth2.Token - if err == nil { - err = v.DoJSON(req, &res) - } - - res.Expiry = time.Now().Add(time.Duration(res.ExpiresIn) * time.Second) - - t := Token(res) - return &t, err -} - -// refreshToken renews the LoginApps token -func (v *Service) refreshToken(token *oauth2.Token) (*oauth2.Token, error) { - res, err := v.Refresh((*Token)(token)) - return (*oauth2.Token)(res), err -} - -// TokenSource creates a refreshing oauth2 token source -func (v *Service) TokenSource(token *Token) oauth2.TokenSource { - return oauth.RefreshTokenSource((*oauth2.Token)(token), v.refreshToken) -} diff --git a/vehicle/vag/loginapps/token.go b/vehicle/vag/loginapps/token.go deleted file mode 100644 index cd1c6ebc3..000000000 --- a/vehicle/vag/loginapps/token.go +++ /dev/null @@ -1,28 +0,0 @@ -package loginapps - -import ( - "encoding/json" - "time" - - "golang.org/x/oauth2" -) - -// Token is the loginapps token -type Token oauth2.Token - -func (t *Token) UnmarshalJSON(data []byte) error { - var s struct { - AccessToken string - RefreshToken string - } - - err := json.Unmarshal(data, &s) - if err == nil { - t.TokenType = "bearer" - t.AccessToken = s.AccessToken - t.RefreshToken = s.RefreshToken - t.Expiry = time.Now().Add(time.Hour) - } - - return err -} diff --git a/vehicle/vag/loginapps/token_test.go b/vehicle/vag/loginapps/token_test.go deleted file mode 100644 index ef4fb12e0..000000000 --- a/vehicle/vag/loginapps/token_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package loginapps - -import ( - "encoding/json" - "testing" -) - -func TestUnmarshalJSON(t *testing.T) { - var tok Token - str := `{"accesstoken":"access","refreshtoken":"refresh"}` - - if err := json.Unmarshal([]byte(str), &tok); err != nil { - t.Error(err) - } - - if tok.AccessToken != "access" { - t.Error("AccessToken") - } - - if tok.RefreshToken != "refresh" { - t.Error("RefreshToken") - } - - if tok.TokenType != "bearer" { - t.Error("TokenType") - } - - if tok.Expiry.IsZero() { - t.Error("Expiry") - } -} diff --git a/vehicle/vw.go b/vehicle/vw.go index 5807f5857..4d5b3c92d 100644 --- a/vehicle/vw.go +++ b/vehicle/vw.go @@ -6,7 +6,6 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" - "github.com/evcc-io/evcc/vehicle/vag/loginapps" "github.com/evcc-io/evcc/vehicle/vag/vwidentity" "github.com/evcc-io/evcc/vehicle/vw/weconnect" ) @@ -49,18 +48,17 @@ func NewVWFromConfig(other map[string]any) (api.Vehicle, error) { log := util.NewLogger("vw").Redact(cc.User, cc.Password, cc.VIN) - q, err := vwidentity.LoginWithAuthURL(log, weconnect.LoginURL, weconnect.AuthParams, cc.User, cc.Password) + q, err := vwidentity.Login(log, weconnect.AuthParams, cc.User, cc.Password) if err != nil { return nil, err } - apps := loginapps.New(log) - token, err := apps.Exchange(q) + token, err := weconnect.ExchangeCode(log, q) if err != nil { return nil, err } - api := weconnect.NewAPI(log, apps.TokenSource(token)) + api := weconnect.NewAPI(log, weconnect.TokenSource(log, token)) api.Client.Timeout = cc.Timeout vehicle, err := ensureVehicleEx( diff --git a/vehicle/vw/weconnect/oauth.go b/vehicle/vw/weconnect/oauth.go new file mode 100644 index 000000000..206e7a893 --- /dev/null +++ b/vehicle/vw/weconnect/oauth.go @@ -0,0 +1,71 @@ +package weconnect + +import ( + "net/http" + "net/url" + "strings" + "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/urlvalues" + "github.com/evcc-io/evcc/vehicle/vag/cariad" + "golang.org/x/oauth2" +) + +const tokenURL = cariad.BaseURL + "/auth/v1/idk/oidc/token" + +// ExchangeCode swaps the authorization code from the OIDC callback for an +// access/refresh token pair at the cariad BFF OIDC token endpoint. Replaces +// the legacy WeConnect SSO exchange (/user-login/login/v1), which VW removed +// in May 2026. +func ExchangeCode(log *util.Logger, q url.Values) (*oauth2.Token, error) { + if err := urlvalues.Require(q, "code", "code_verifier"); err != nil { + return nil, err + } + + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {q.Get("code")}, + "code_verifier": {q.Get("code_verifier")}, + "redirect_uri": {"weconnect://authenticated"}, + "client_id": {cariad.ClientID}, + } + + return postToken(log, data) +} + +// TokenSource returns a refreshing oauth2.TokenSource that swaps refresh +// tokens for fresh access tokens at the same OIDC token endpoint. +func TokenSource(log *util.Logger, token *oauth2.Token) oauth2.TokenSource { + return oauth.RefreshTokenSource(token, func(t *oauth2.Token) (*oauth2.Token, error) { + data := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {t.RefreshToken}, + "client_id": {cariad.ClientID}, + } + return postToken(log, data) + }) +} + +func postToken(log *util.Logger, data url.Values) (*oauth2.Token, error) { + req, err := request.New(http.MethodPost, tokenURL, strings.NewReader(data.Encode()), + map[string]string{ + "Content-Type": request.FormContent, + "Accept": request.JSONContent, + "User-Agent": cariad.UserAgent, + "x-android-package-name": cariad.AndroidPackageName, + }) + if err != nil { + return nil, err + } + + var token oauth2.Token + if err := request.NewHelper(log).DoJSON(req, &token); err != nil { + return nil, err + } + + token.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + return &token, nil +} diff --git a/vehicle/vw/weconnect/params.go b/vehicle/vw/weconnect/params.go index a6bf9f2a9..65f41fb68 100644 --- a/vehicle/vw/weconnect/params.go +++ b/vehicle/vw/weconnect/params.go @@ -6,11 +6,13 @@ import ( "github.com/evcc-io/evcc/vehicle/vag/cariad" ) -const LoginURL = cariad.BaseURL + "/user-login/v1/authorize" - +// AuthParams are the OIDC authorize parameters for the WeConnect ID client. +// The authorize endpoint is the legacy identity.vwgroup.io OIDC entry point +// (vwidentity.Config.AuthURL); the token endpoint is the cariad BFF (see +// oauth.go). var AuthParams = url.Values{ "response_type": {"code id_token token"}, - "client_id": {"a24fba63-34b3-4d43-b181-942111e6bda8@apps_vw-dilab_com"}, + "client_id": {cariad.ClientID}, "redirect_uri": {"weconnect://authenticated"}, "scope": {"openid profile badge cars vin"}, // dealers } From 498fbaba468d91ddcb24659066d5976cfc26715c Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 28 May 2026 09:14:25 +0200 Subject: [PATCH 002/959] Vehicle (Nissan Ariya/Micra): expose expiry as advanced parameter (#30269) --- templates/definition/vehicle/nissan-ariya.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/templates/definition/vehicle/nissan-ariya.yaml b/templates/definition/vehicle/nissan-ariya.yaml index e16f0978b..0f0fbb7f3 100644 --- a/templates/definition/vehicle/nissan-ariya.yaml +++ b/templates/definition/vehicle/nissan-ariya.yaml @@ -8,7 +8,18 @@ products: generic: Micra params: - preset: vehicle-base + - name: expiry + description: + de: Ablauf + en: Expiry + help: + de: Maximales Alter der Daten vor erneuter Abfrage + en: Maximum data age before refresh + advanced: true + type: duration + example: 5m render: | type: nissan version: v2 {{ include "vehicle-base" . }} + expiry: {{ .expiry }} From 4f943650b2aed78c600ba572d7e7231920710603 Mon Sep 17 00:00:00 2001 From: thecem <46648579+thecem@users.noreply.github.com> Date: Thu, 28 May 2026 12:05:10 +0200 Subject: [PATCH 003/959] Hems/FNN: add curtail and dim for TAB26 Steuerbox relay standard (#29886) Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: andig Co-authored-by: andig Co-authored-by: Claude Opus 4.7 (1M context) --- hems/config.go | 2 +- hems/fnn/fnn-3.go | 153 ------------------------------ hems/fnn/fnn.go | 231 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 154 deletions(-) delete mode 100644 hems/fnn/fnn-3.go create mode 100644 hems/fnn/fnn.go diff --git a/hems/config.go b/hems/config.go index f97e7ceaf..89119487c 100644 --- a/hems/config.go +++ b/hems/config.go @@ -19,7 +19,7 @@ func NewFromConfig(ctx context.Context, typ string, other map[string]any, site s return nil, errors.New("breaking change: Sunny Home Manager integration is always on. See https://github.com/evcc-io/evcc/releases and https://docs.evcc.io/en/docs/integrations/sma-sunny-home-manager") case "eebus": return eebus.NewFromConfig(ctx, other, site) - case "fnn-3": + case "fnn", "fnn-3": return fnn.NewFromConfig(ctx, other, site) case "relay": return relay.NewFromConfig(ctx, other, site) diff --git a/hems/fnn/fnn-3.go b/hems/fnn/fnn-3.go deleted file mode 100644 index d78d92e3f..000000000 --- a/hems/fnn/fnn-3.go +++ /dev/null @@ -1,153 +0,0 @@ -package fnn - -import ( - "context" - "sync" - "time" - - "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/core/site" - "github.com/evcc-io/evcc/hems/smartgrid" - "github.com/evcc-io/evcc/plugin" - "github.com/evcc-io/evcc/util" -) - -type Fnn3 struct { - mu sync.Mutex - log *util.Logger - - root api.Circuit - s1, s2, w3 func() (bool, error) - - smartgridID uint - limit *float64 - maxPower float64 - interval time.Duration -} - -// NewFromConfig creates an Fnn3 HEMS from generic config -func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Fnn3, error) { - cc := struct { - MaxPower float64 - W3 plugin.Config - S1 *plugin.Config - S2 *plugin.Config - Interval time.Duration - }{ - Interval: 10 * time.Second, - } - - if err := util.DecodeOther(other, &cc); err != nil { - return nil, err - } - - // setup grid control circuit - gridcontrol, err := smartgrid.SetupCircuit() - if err != nil { - return nil, err - } - - site.SetCircuit(gridcontrol) - - // s1 getter - s1G, err := cc.S1.BoolGetter(ctx) - if err != nil { - return nil, err - } - - s2G, err := cc.S2.BoolGetter(ctx) - if err != nil { - return nil, err - } - - w3G, err := cc.W3.BoolGetter(ctx) - if err != nil { - return nil, err - } - - return NewFnn3(gridcontrol, s1G, s2G, w3G, cc.MaxPower, cc.Interval) -} - -// NewFnn3 creates Fnn3 HEMS -func NewFnn3(root api.Circuit, s1, s2, w3 func() (bool, error), maxPower float64, interval time.Duration) (*Fnn3, error) { - c := &Fnn3{ - log: util.NewLogger("fnn3"), - root: root, - maxPower: maxPower, - s1: s1, - s2: s2, - w3: w3, - interval: interval, - } - - return c, nil -} - -func (c *Fnn3) Run() { - for range time.Tick(c.interval) { - if err := c.run(); err != nil { - c.log.ERROR.Println(err) - } - } -} - -func (c *Fnn3) run() error { - w3, err := c.w3() - if err != nil { - return err - } - - if w3 { - // 0% - return c.curtail(0.0) - } - - if c.s2 != nil { - s2, err := c.s2() - if err != nil { - return err - } - - if s2 { - // 30% - return c.curtail(0.3) - } - } - - if c.s1 != nil { - s1, err := c.s1() - if err != nil { - return err - } - - if s1 { - // 60% - return c.curtail(0.6) - } - } - - // 100% - return c.curtail(1.0) -} - -func (c *Fnn3) curtail(frac float64) error { - c.mu.Lock() - defer c.mu.Unlock() - - active := frac < 1.0 - - c.limit = nil - if active { - c.limit = new(c.maxPower * frac) - } - - c.root.Curtail(active) - // TODO make ProductionNominalMax configurable (Site kWp) - // c.root.SetMaxPower(c.maxPower*frac) - - if err := smartgrid.UpdateSession(&c.smartgridID, smartgrid.Curtail, c.root.GetChargePower(), c.maxPower*frac, active); err != nil { - c.log.ERROR.Printf("smartgrid session: %v", err) - } - - return nil -} diff --git a/hems/fnn/fnn.go b/hems/fnn/fnn.go new file mode 100644 index 000000000..6d3d6c341 --- /dev/null +++ b/hems/fnn/fnn.go @@ -0,0 +1,231 @@ +package fnn + +import ( + "context" + "errors" + "math" + "sync" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/hems/smartgrid" + "github.com/evcc-io/evcc/plugin" + "github.com/evcc-io/evcc/util" +) + +// NewFromConfig creates an FNN HEMS from generic config. +func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Fnn, error) { + cc := struct { + MaxPower float64 // TODO deprecated + MaxDimPower float64 + MaxCurtailPower *float64 + W3 *plugin.Config + S1 *plugin.Config + S2 *plugin.Config + W4 *plugin.Config + Interval time.Duration + }{ + Interval: 10 * time.Second, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + // setup grid control circuit + gridcontrol, err := smartgrid.SetupCircuit() + if err != nil { + return nil, err + } + + site.SetCircuit(gridcontrol) + + w3G, err := cc.W3.BoolGetter(ctx) + if err != nil { + return nil, err + } + + w4G, err := cc.W4.BoolGetter(ctx) + if err != nil { + return nil, err + } + + // either dim or curtail must be configured + if w3G == nil && w4G == nil { + return nil, errors.New("must have either W3 or W4") + } + + s1G, err := cc.S1.BoolGetter(ctx) + if err != nil { + return nil, err + } + + s2G, err := cc.S2.BoolGetter(ctx) + if err != nil { + return nil, err + } + + maxDimPower := math.Abs(cc.MaxDimPower) + + var maxCurtailPower *float64 + switch { + case cc.MaxCurtailPower != nil: + maxCurtailPower = new(math.Abs(*cc.MaxCurtailPower)) + case cc.MaxPower > 0: + // fnn-3 backwards compatibility: legacy MaxPower was the PV/curtail cap + maxCurtailPower = new(math.Abs(cc.MaxPower)) + } + + return &Fnn{ + log: util.NewLogger("fnn"), + root: gridcontrol, + maxDimPower: maxDimPower, + maxCurtailPower: maxCurtailPower, + s1: s1G, + s2: s2G, + w3: w3G, + w4: w4G, + interval: cc.Interval, + }, nil +} + +// Fnn implements the FNN HEMS logic for curtailment and dimming. +type Fnn struct { + mu sync.Mutex + log *util.Logger + + root api.Circuit + s1, s2, w3 func() (bool, error) + w4 func() (bool, error) + + smartgridConsumptionID uint + smartgridProductionID uint + + maxDimPower float64 + maxCurtailPower *float64 + interval time.Duration +} + +// Run starts the FNN control loop. +func (c *Fnn) Run() { + ticker := time.NewTicker(c.interval) + defer ticker.Stop() + + for range ticker.C { + if err := c.runCurtail(); err != nil { + c.log.ERROR.Println(err) + } + + if err := c.runDim(); err != nil { + c.log.ERROR.Println(err) + } + } +} + +// runCurtail evaluates curtailment rules and applies the appropriate limit. +// No-op if no curtail input is configured. +func (c *Fnn) runCurtail() error { + if c.w3 == nil { + return nil + } + + rules := []struct { + get func() (bool, error) + frac float64 + }{ + {get: c.w3, frac: 0.0}, + {get: c.s2, frac: 0.3}, + {get: c.s1, frac: 0.6}, + } + + for _, rule := range rules { + if rule.get == nil { + continue + } + + active, err := rule.get() + if err != nil { + return err + } + + if active { + return c.setProductionLimit(rule.frac) + } + } + + // 100% + return c.setProductionLimit(1.0) +} + +// runDim evaluates the dimming rule and applies the dim limit. +// No-op if dim input is not configured. +func (c *Fnn) runDim() error { + if c.w4 == nil { + return nil + } + + active, err := c.w4() + if err != nil { + return err + } + + limit := 0.0 + if active { + if c.maxDimPower <= 0 { + return errors.New("dim active but no limit configured") + } + + limit = c.maxDimPower + } + + return c.setConsumptionLimit(limit) +} + +func (c *Fnn) applyMode(id *uint, typ smartgrid.Type, active bool, limit float64, applyRoot func()) { + applyRoot() + + if err := smartgrid.UpdateSession(id, typ, c.root.GetChargePower(), limit, active); err != nil { + c.log.ERROR.Printf("smartgrid session: %v", err) + } +} + +// setProductionLimit applies the curtailment limit to the circuit. +func (c *Fnn) setProductionLimit(frac float64) error { + c.mu.Lock() + defer c.mu.Unlock() + + limit := 0.0 + active := frac < 1.0 + + if active { + if c.maxCurtailPower == nil { + return errors.New("curtail active but no limit configured") + } + + limit = *c.maxCurtailPower * frac + } + + c.applyMode(&c.smartgridProductionID, smartgrid.Curtail, active, limit, func() { + c.root.Curtail(active) + // TODO make ProductionNominalMax configurable (Site kWp) + // c.root.SetMaxPower(c.maxPower*frac) + }) + + return nil +} + +// setConsumptionLimit applies the dimming limit to the circuit. +func (c *Fnn) setConsumptionLimit(limit float64) error { + c.mu.Lock() + defer c.mu.Unlock() + + active := limit > 0 + + c.applyMode(&c.smartgridConsumptionID, smartgrid.Dim, active, limit, func() { + c.root.Dim(active) + c.root.SetMaxPower(limit) + }) + + return nil +} From 8ea620d36844665372c448effd4c296866e14b7f Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 28 May 2026 14:13:21 +0200 Subject: [PATCH 004/959] Audi: rotate qmauth and add assertion headers for IDK token exchange (#30292) --- vehicle/vag/idkproxy/endpoint.go | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/vehicle/vag/idkproxy/endpoint.go b/vehicle/vag/idkproxy/endpoint.go index 1c062e5cb..eefe5ae8b 100644 --- a/vehicle/vag/idkproxy/endpoint.go +++ b/vehicle/vag/idkproxy/endpoint.go @@ -42,8 +42,9 @@ func New(log *util.Logger, q url.Values) *Service { // https://github.com/arjenvrh/audi_connect_ha/issues/133 const ( - qmSecret = "e47866378ef0658ce75d71007a809f34616b9635e2ec228245784c1f63e88d06" - qmClientId = "c95f4fd2" + qmSecret = "1ab69925ac179aaa4e83abe671a9476d176418b85bd706f1436ca15be647989c" + qmClientId = "01da27b0" + userAgent = "Android/4.31.0 (Build 800341641.root project 'myaudi_android'.ext.buildTime) Android/13" ) func qmauth(ts int64) string { @@ -57,6 +58,21 @@ func qmauthNow() string { return "v1:" + qmClientId + ":" + qmauth(time.Now().Unix()/100) } +// tokenHeaders returns the assertion header set the IDK token endpoint +// validates for both authorization_code and refresh_token grants. +func tokenHeaders() map[string]string { + return map[string]string{ + "Content-Type": request.FormContent, + "Accept": request.JSONContent, + "Accept-Charset": "utf-8", + "User-Agent": userAgent, + "x-qmauth": qmauthNow(), + "x-platform": "android", + "x-android-package-name": "de.myaudi.mobile.assistant", + "x-assertion": "0", + } +} + // Exchange exchanges an VAG identity token for an IDK token func (v *Service) Exchange(q url.Values) (*vag.Token, error) { if err := urlvalues.Require(q, "code", "code_verifier"); err != nil { @@ -74,11 +90,7 @@ func (v *Service) Exchange(q url.Values) (*vag.Token, error) { var res vag.Token - req, err := request.New(http.MethodPost, Config.TokenURL, strings.NewReader(data.Encode()), map[string]string{ - "Content-Type": request.FormContent, - "Accept": request.JSONContent, - "x-qmauth": qmauthNow(), - }) + req, err := request.New(http.MethodPost, Config.TokenURL, strings.NewReader(data.Encode()), tokenHeaders()) if err == nil { err = v.DoJSON(req, &res) } @@ -98,11 +110,7 @@ func (v *Service) Refresh(token *vag.Token) (*vag.Token, error) { var res vag.Token - req, err := request.New(http.MethodPost, Config.TokenURL, strings.NewReader(data.Encode()), map[string]string{ - "Content-Type": request.FormContent, - "Accept": request.JSONContent, - "x-qmauth": qmauthNow(), - }) + req, err := request.New(http.MethodPost, Config.TokenURL, strings.NewReader(data.Encode()), tokenHeaders()) if err == nil { err = v.DoJSON(req, &res) } From ceb93b0db679cb4361f86e02cdb0bbc91619d310 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 29 May 2026 12:10:59 +0200 Subject: [PATCH 005/959] chore: upgrade endpoint --- vehicle/vag/idkproxy/endpoint.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vehicle/vag/idkproxy/endpoint.go b/vehicle/vag/idkproxy/endpoint.go index eefe5ae8b..14d821a54 100644 --- a/vehicle/vag/idkproxy/endpoint.go +++ b/vehicle/vag/idkproxy/endpoint.go @@ -18,7 +18,7 @@ import ( "github.com/evcc-io/evcc/vehicle/vag/cariad" ) -const WellKnown = cariad.BaseURL + "/login/v1/idk/openid-configuration" +const WellKnown = cariad.BaseURL + "/auth/v1/idk/oidc/openid-configuration" var Config = &oidc.ProviderConfig{ AuthURL: "https://identity.vwgroup.io/oidc/v1/authorize", From 24e0e1044338453669916291305c84da21d8a6c4 Mon Sep 17 00:00:00 2001 From: tbaehler Date: Fri, 29 May 2026 13:29:24 +0200 Subject: [PATCH 006/959] Zaptec Go2: use ThreeToOnePhaseSwitchCurrent for switching (#29587) --- charger/zaptec.go | 74 +++++++++++++++++++++-------------------- charger/zaptec/types.go | 7 ++-- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/charger/zaptec.go b/charger/zaptec.go index 39f726bd4..859a83888 100644 --- a/charger/zaptec.go +++ b/charger/zaptec.go @@ -41,17 +41,20 @@ import ( // https://api.zaptec.com/.well-known/openid-configuration/ // Zaptec charger implementation + +const _ZaptecGo2PhaseSwitchCurrent = 32.0 // threshold (in A) at which Zaptec Go 2 switches to single-phase according to product/protocol specs + type Zaptec struct { *request.Helper implement.Caps log *util.Logger statusG util.Cacheable[zaptec.StateResponse] instance zaptec.Charger - maxCurrent float64 version int enabled bool priority bool passive bool + lastStatus int } func init() { @@ -82,7 +85,7 @@ func NewZaptecFromConfig(ctx context.Context, other map[string]any) (api.Charger } // NewZaptec creates Zaptec charger -func NewZaptec(ctx context.Context, user, password, id string, priority bool, passive bool, cache time.Duration) (api.Charger, error) { +func NewZaptec(_ context.Context, user, password, id string, priority bool, passive bool, cache time.Duration) (api.Charger, error) { log := util.NewLogger("zaptec").Redact(user, password) if !sponsor.IsAuthorized() { @@ -144,9 +147,8 @@ func NewZaptec(ctx context.Context, user, password, id string, priority bool, pa return nil, err } - if maxCurrent, err := c.getInstallationMaxCurrent(); err == nil { + if err := c.testInstallationPermission(); err == nil || c.version == zaptec.ZaptecGo1_Pro { implement.Has(c, implement.PhaseSwitcher(c.phases1p3p)) - c.maxCurrent = maxCurrent } return c, nil @@ -189,8 +191,13 @@ func (c *Zaptec) Status() (api.ChargeStatus, error) { if err != nil { return api.StatusA, err } + currentStatus, err := res.ObservationByID(zaptec.ChargerOperationMode).Int() + if err == nil && currentStatus == zaptec.OpModeDisconnected && currentStatus != c.lastStatus { + err = c.MaxCurrentMillis(0) + } + c.lastStatus = currentStatus - switch i, err := res.ObservationByID(zaptec.ChargerOperationMode).Int(); i { + switch currentStatus { case zaptec.OpModeDisconnected: return api.StatusA, err case zaptec.OpModeConnectedRequesting, zaptec.OpModeConnectedFinished: @@ -199,7 +206,7 @@ func (c *Zaptec) Status() (api.ChargeStatus, error) { return api.StatusC, err default: if err == nil { - err = fmt.Errorf("unknown status: %d", i) + err = fmt.Errorf("unknown status: %d", currentStatus) } return api.StatusNone, err } @@ -336,19 +343,21 @@ func (c *Zaptec) phases1p3p(phases int) error { return err } - // adjust the current by +/- 0.1A; otherwise, the phase change will not happen - current, err := res.ObservationByID(zaptec.ChargerMaxCurrent).Float64() - if err != nil { - return err - } + if c.version == zaptec.ZaptecGo1_Pro { + // adjust the current by +/- 0.1A; otherwise, the phase change will not happen + current, err := res.ObservationByID(zaptec.ChargerMaxCurrent).Float64() + if err != nil { + return err + } - current -= 0.1 - if current < 6 { - current += 0.2 - } + current -= 0.1 + if current < 6 { + current += 0.2 + } - if err := c.MaxCurrentMillis(current); err != nil { - return err + if err := c.MaxCurrentMillis(current); err != nil { + return err + } } if !c.priority { @@ -368,7 +377,7 @@ func (c *Zaptec) phases1p3p(phases int) error { } func (c *Zaptec) switchPhases(phases int) error { - if c.version != zaptec.ZaptecGo2 { + if c.version == zaptec.ZaptecGo1_Pro { data := zaptec.Update{ MaxChargePhases: &phases, } @@ -376,20 +385,17 @@ func (c *Zaptec) switchPhases(phases int) error { return c.chargerUpdate(data) } - var zero float64 - data := zaptec.UpdateInstallation{ - AvailableCurrentPhase1: &c.maxCurrent, - AvailableCurrentPhase2: &zero, - AvailableCurrentPhase3: &zero, - } - if phases == 3 { - data = zaptec.UpdateInstallation{ - AvailableCurrentPhase1: &c.maxCurrent, - AvailableCurrentPhase2: &c.maxCurrent, - AvailableCurrentPhase3: &c.maxCurrent, - } + var phaseSwitchCurrent float64 + + if phases == 1 { + phaseSwitchCurrent = _ZaptecGo2PhaseSwitchCurrent + } else { + phaseSwitchCurrent = 0 } + data := zaptec.UpdateInstallation{ + ThreeToOnePhaseSwitchCurrent: &phaseSwitchCurrent, + } return c.installationUpdate(data) } @@ -409,15 +415,11 @@ func (c *Zaptec) Identify() (string, error) { return "", nil } -func (c *Zaptec) getInstallationMaxCurrent() (float64, error) { +func (c *Zaptec) testInstallationPermission() error { var res zaptec.Installation uri := fmt.Sprintf("%s/api/installation/%s", zaptec.ApiURL, c.instance.InstallationId) - if err := c.GetJSON(uri, &res); err != nil { - return 0, err - } - - return res.MaxCurrent, nil + return c.GetJSON(uri, &res) } func (c *Zaptec) installationUpdate(data zaptec.UpdateInstallation) error { diff --git a/charger/zaptec/types.go b/charger/zaptec/types.go index 51a11ec8c..df5837598 100644 --- a/charger/zaptec/types.go +++ b/charger/zaptec/types.go @@ -90,9 +90,10 @@ type Installation struct { } type UpdateInstallation struct { - AvailableCurrentPhase1 *float64 `json:"availableCurrentPhase1,omitempty"` - AvailableCurrentPhase2 *float64 `json:"availableCurrentPhase2,omitempty"` - AvailableCurrentPhase3 *float64 `json:"availableCurrentPhase3,omitempty"` + AvailableCurrentPhase1 *float64 `json:"availableCurrentPhase1,omitempty"` + AvailableCurrentPhase2 *float64 `json:"availableCurrentPhase2,omitempty"` + AvailableCurrentPhase3 *float64 `json:"availableCurrentPhase3,omitempty"` + ThreeToOnePhaseSwitchCurrent *float64 `json:"threeToOnePhaseSwitchCurrent,omitempty"` } type CapabilitiesResponse struct { From 5c04eed865e910b74a1de4290d30b80bddc9a61b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 09:05:06 +0200 Subject: [PATCH 007/959] Cupra: map ChargePurposeReachedAndNotConservationCharging to StatusB (#30343) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- vehicle/seat/cupra/provider.go | 2 +- vehicle/seat/cupra/provider_test.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/vehicle/seat/cupra/provider.go b/vehicle/seat/cupra/provider.go index 9fa3f82b2..984a8d5ae 100644 --- a/vehicle/seat/cupra/provider.go +++ b/vehicle/seat/cupra/provider.go @@ -76,7 +76,7 @@ func (v *Provider) Status() (api.ChargeStatus, error) { } switch strings.ToLower(res.Services.Charging.Status) { - case "connected", "readyforcharging", "notreadyforcharging", "error": + case "connected", "readyforcharging", "notreadyforcharging", "error", "chargepurposereachedandnotconservationcharging": return api.StatusB, nil case "charging": return api.StatusC, nil diff --git a/vehicle/seat/cupra/provider_test.go b/vehicle/seat/cupra/provider_test.go index 119743c12..22974c8b5 100644 --- a/vehicle/seat/cupra/provider_test.go +++ b/vehicle/seat/cupra/provider_test.go @@ -31,6 +31,8 @@ func TestProviderStatus(t *testing.T) { {"connected", "Connected", "", api.StatusB}, // fault / error while plugged {"error", "error", "", api.StatusB}, + // target SoC reached in myCupra, cable still connected (#30118) + {"soc_reached", "ChargePurposeReachedAndNotConservationCharging", "", api.StatusB}, // unknown future status with no card info defaults to disconnected {"unknown", "SomethingNew", "", api.StatusA}, } From 086023f7b8d83d4915225287f8a63087a96cc9f9 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 09:07:56 +0200 Subject: [PATCH 008/959] Charger (OpenEVSE): scale CurrentPower by loadpoint phases (#30330) --- charger/openevse.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/charger/openevse.go b/charger/openevse.go index c2939e9f6..d5be22c2b 100644 --- a/charger/openevse.go +++ b/charger/openevse.go @@ -11,6 +11,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/implement" "github.com/evcc-io/evcc/charger/openevse" + "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/transport" @@ -24,6 +25,7 @@ type OpenEVSE struct { statusG util.Cacheable[openevse.Status] current int enabled bool + lp loadpoint.API } func init() { @@ -233,7 +235,22 @@ func (c *OpenEVSE) CurrentPower() (float64, error) { return 0, err } - return res.Amp * res.Voltage / 1e3, err + // status only reports a single phase current, scale by the loadpoint phases + phases := 1 + if c.lp != nil { + if p := c.lp.GetPhases(); p != 0 { + phases = p + } + } + + return res.Amp * res.Voltage / 1e3 * float64(phases), err +} + +var _ loadpoint.Controller = (*OpenEVSE)(nil) + +// LoadpointControl implements loadpoint.Controller +func (c *OpenEVSE) LoadpointControl(lp loadpoint.API) { + c.lp = lp } // phases1p3p implements the api.PhaseSwitcher interface From 8ee26a281ae436b1289b098ca84415b0f671dc01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 09:11:34 +0200 Subject: [PATCH 009/959] chore(deps): bump axios from 1.15.2 to 1.16.1 (#30334) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 38 +++++++++++++++++++++++++++++++------- package.json | 2 +- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index a49574cc7..ab577de2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@h2d2/shopicons": "^1.9.0", "@popperjs/core": "^2.11.8", "@unhead/vue": "^2.1.13", - "axios": "^1.15.2", + "axios": "^1.16.1", "bootstrap": "^5.3.8", "canvas-confetti": "^1.9.4", "chart.js": "^4.5.1", @@ -4432,6 +4432,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -4731,13 +4743,14 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -5667,7 +5680,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -7390,6 +7402,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -8903,7 +8928,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/muggle-string": { diff --git a/package.json b/package.json index 6b943a287..9220cda59 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@h2d2/shopicons": "^1.9.0", "@popperjs/core": "^2.11.8", "@unhead/vue": "^2.1.13", - "axios": "^1.15.2", + "axios": "^1.16.1", "bootstrap": "^5.3.8", "canvas-confetti": "^1.9.4", "chart.js": "^4.5.1", From 791bfae0559ad5b7b7c7dfc33b5e706a32a6db8a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 09:49:24 +0200 Subject: [PATCH 010/959] Toyota: fall back to `evRange` when `evRangeWithAc` is unusable (#30347) --- vehicle/toyota/provider.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/vehicle/toyota/provider.go b/vehicle/toyota/provider.go index 85d3c5e5b..24ca89174 100644 --- a/vehicle/toyota/provider.go +++ b/vehicle/toyota/provider.go @@ -75,5 +75,11 @@ func (v *Provider) Range() (int64, error) { if err != nil { return 0, err } - return res.Payload.EvRangeWithAc.ValueInKilometers() + + rng, err := res.Payload.EvRangeWithAc.ValueInKilometers() + if err == nil { + return rng, nil + } + + return res.Payload.EvRange.ValueInKilometers() } From 72f6861c6e47a17cd05f91d8d655e793964399ca Mon Sep 17 00:00:00 2001 From: CiNcH83 Date: Sun, 31 May 2026 10:37:55 +0200 Subject: [PATCH 011/959] Huawei SUN2000: fix energy yield (#30353) --- templates/definition/meter/huawei-sun2000-hybrid.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/definition/meter/huawei-sun2000-hybrid.yaml b/templates/definition/meter/huawei-sun2000-hybrid.yaml index 36cc5fc02..9fd6d8c8e 100644 --- a/templates/definition/meter/huawei-sun2000-hybrid.yaml +++ b/templates/definition/meter/huawei-sun2000-hybrid.yaml @@ -127,7 +127,7 @@ render: | {{- include "modbus" . | indent 2 }} timeout: {{ .timeout }} register: - address: 32106 # Accumulated energy yield + address: 32108 # Total DC Input Energy type: holding decode: uint32nan scale: 0.01 From 54077151542c53c6e3ce8c9069e9175bc7fe84ff Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 10:46:52 +0200 Subject: [PATCH 012/959] Vehicle: add drivesomethinggreater (VW group EU Data Act) (#30362) --- .../vehicle/drivesomethinggreater.yaml | 59 ++++ vehicle/drivesomethinggreater.go | 67 ++++ vehicle/template_test.go | 1 + vehicle/vw/eudataact/api.go | 317 ++++++++++++++++++ vehicle/vw/eudataact/eudataact_test.go | 118 +++++++ vehicle/vw/eudataact/provider.go | 122 +++++++ vehicle/vw/eudataact/types.go | 178 ++++++++++ 7 files changed, 862 insertions(+) create mode 100644 templates/definition/vehicle/drivesomethinggreater.yaml create mode 100644 vehicle/drivesomethinggreater.go create mode 100644 vehicle/vw/eudataact/api.go create mode 100644 vehicle/vw/eudataact/eudataact_test.go create mode 100644 vehicle/vw/eudataact/provider.go create mode 100644 vehicle/vw/eudataact/types.go diff --git a/templates/definition/vehicle/drivesomethinggreater.yaml b/templates/definition/vehicle/drivesomethinggreater.yaml new file mode 100644 index 000000000..3c8646e05 --- /dev/null +++ b/templates/definition/vehicle/drivesomethinggreater.yaml @@ -0,0 +1,59 @@ +template: drivesomethinggreater +products: + - brand: Volkswagen + description: + generic: EU Data Act + - brand: Audi + description: + generic: EU Data Act + - brand: Škoda + description: + generic: EU Data Act + - brand: Seat + description: + generic: EU Data Act + - brand: Cupra + description: + generic: EU Data Act +requirements: + description: + de: | + Nutzt die im Rahmen des EU Data Act bereitgestellten Fahrzeugdaten über das + VW-Konzern-Portal (eu-data-act.drivesomethinggreater.com). Die Anmeldung + erfolgt mit den Zugangsdaten der jeweiligen Marken-App (We Connect, myAudi, + MyŠkoda, SEAT/CUPRA Connect). + + Voraussetzung (einmalig im Browser): im Portal anmelden, die Einwilligung + bestätigen, das Fahrzeug verknüpfen und eine fortlaufende Datenanfrage + (alle 15 Minuten) aktivieren. Ohne diese Aktivierung liefert das Portal + keine Daten. Die Daten werden ca. alle 15 Minuten aktualisiert. + + Implementierung analog zur "EU Data Act" Datenquelle aus + https://github.com/TA2k/ioBroker.vw-connect + en: | + Uses the vehicle data provided under the EU Data Act through the VW group + portal (eu-data-act.drivesomethinggreater.com). Log in with the credentials + of the respective brand app (We Connect, myAudi, MyŠkoda, SEAT/CUPRA Connect). + + Prerequisite (one-time, in a browser): log in to the portal, confirm the + consent screen, link the vehicle and enable a continuous data request + (every 15 minutes). Without this activation the portal returns no data. + Data is refreshed roughly every 15 minutes. + + Implementation analogous to the "EU Data Act" data source from + https://github.com/TA2k/ioBroker.vw-connect +params: + - preset: vehicle-base + - name: brand + type: choice + choice: ["Volkswagen", "Audi", "Skoda", "Seat", "Cupra"] + required: true + description: + de: Marke + en: Brand + - preset: vehicle-features +render: | + type: drivesomethinggreater + brand: {{ .brand }} + {{ include "vehicle-base" . }} + {{ include "vehicle-features" . }} diff --git a/vehicle/drivesomethinggreater.go b/vehicle/drivesomethinggreater.go new file mode 100644 index 000000000..6d17bafcb --- /dev/null +++ b/vehicle/drivesomethinggreater.go @@ -0,0 +1,67 @@ +package vehicle + +import ( + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/vehicle/vw/eudataact" +) + +// https://github.com/TA2k/ioBroker.vw-connect (EU Data Act data source) + +// DriveSomethingGreater is an api.Vehicle implementation for the VW group +// "EU Data Act" data portal (eu-data-act.drivesomethinggreater.com) +type DriveSomethingGreater struct { + *embed + *eudataact.Provider +} + +func init() { + registry.Add("drivesomethinggreater", NewDriveSomethingGreaterFromConfig) +} + +// NewDriveSomethingGreaterFromConfig creates a new vehicle +func NewDriveSomethingGreaterFromConfig(other map[string]any) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + Brand, User, Password, VIN string + Cache time.Duration + }{ + Brand: "Volkswagen", + Cache: interval, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.User == "" || cc.Password == "" { + return nil, api.ErrMissingCredentials + } + + v := &DriveSomethingGreater{ + embed: &cc.embed, + } + + log := util.NewLogger("dsg").Redact(cc.User, cc.Password, cc.VIN) + + api, err := eudataact.NewAPI(log, cc.Brand, cc.User, cc.Password) + if err != nil { + return nil, err + } + + vehicle, err := ensureVehicleEx( + cc.VIN, api.Vehicles, + func(v eudataact.Vehicle) (string, error) { + return v.Vin(), nil + }, + ) + + if err == nil { + v.fromVehicle(vehicle.Name(), 0) + v.Provider = eudataact.NewProvider(api, vehicle.Vin(), cc.Cache) + } + + return v, err +} diff --git a/vehicle/template_test.go b/vehicle/template_test.go index 9f49f3627..76dda1ae9 100644 --- a/vehicle/template_test.go +++ b/vehicle/template_test.go @@ -25,6 +25,7 @@ var acceptable = []string{ "unexpected status: 401", "discussions/17501", // Tesla "login failed: code not found", // Polestar + "login failed", // drivesomethinggreater (EU Data Act, eager login) "empty instance type- check for missing usage", // Mercedes "connect: connection refused", // MQTT } diff --git a/vehicle/vw/eudataact/api.go b/vehicle/vw/eudataact/api.go new file mode 100644 index 000000000..30415adbf --- /dev/null +++ b/vehicle/vw/eudataact/api.go @@ -0,0 +1,317 @@ +package eudataact + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/vehicle/vag/vwidentity" + "github.com/samber/lo" + "golang.org/x/net/publicsuffix" +) + +// https://github.com/TA2k/ioBroker.vw-connect (lib/euDataAct.js) + +const ( + // BaseURL is the EU Data Act portal that delivers the mandated vehicle datasets + BaseURL = "https://eu-data-act.drivesomethinggreater.com" + // RedirectURI is the OIDC redirect target registered for the portal + RedirectURI = BaseURL + "/login" + // Scope is the OIDC scope requested for the EU Data Act flow + Scope = "openid cars profile" +) + +var portalHost = strings.TrimPrefix(BaseURL, "https://") + +// API is the EU Data Act portal client. It authenticates through the VW group +// identity service and reads vehicle data from the portal's data delivery API. +// +// Unlike the WeConnect/BFF APIs the portal is not a live telemetry service: it +// stores a dataset (a zipped JSON document) roughly every 15 minutes that the +// user has to enable once in the browser. Reading vehicle data means downloading +// the newest dataset and decoding its flat list of data points. +type API struct { + *request.Helper + brand brand + user, password string +} + +// NewAPI creates an EU Data Act client and performs the initial login +func NewAPI(log *util.Logger, brandName, user, password string) (*API, error) { + b, ok := resolveBrand(brandName) + if !ok { + return nil, fmt.Errorf("unknown brand: %s", brandName) + } + + v := &API{ + Helper: request.NewHelper(log), + brand: b, + user: user, + password: password, + } + + if err := v.login(); err != nil { + return nil, fmt.Errorf("login failed: %w", err) + } + + return v, nil +} + +// login performs the OIDC authorization-code flow against the VW identity +// service. The portal relies on the session cookies that are set while the +// browser follows the redirect chain back to RedirectURI, so the cookie jar is +// kept on the client for all subsequent data calls. +func (v *API) login() error { + jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + if err != nil { + return err + } + + v.Client.Jar = jar + v.Client.CheckRedirect = func(req *http.Request, _ []*http.Request) error { + // follow the https redirect chain to the portal, stop at app schemes + if req.URL.Scheme != "https" { + return http.ErrUseLastResponse + } + return nil + } + + // prime the portal session (best effort) + if resp, err := v.Get(BaseURL + "/"); err == nil { + resp.Body.Close() + } + + // start the OIDC authorize flow + q := url.Values{ + "client_id": {v.brand.clientID}, + "response_type": {"code"}, + "scope": {Scope}, + "state": {fmt.Sprintf("de__en__%s", v.brand.state)}, + "redirect_uri": {RedirectURI}, + "prompt": {"login"}, + "nonce": {lo.RandomString(43, lo.LettersCharset)}, + } + + resp, err := v.Get(vwidentity.Config.AuthURL + "?" + q.Encode()) + if err != nil { + return err + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return err + } + + // email/identifier step + vars, err := vwidentity.FormValues(bytes.NewReader(body), "form#emailPasswordForm") + if err != nil { + return fmt.Errorf("identifier form: %w (portal layout may have changed)", err) + } + + uri := vwidentity.BaseURL + vars.Action + resp, err = v.PostForm(uri, url.Values{ + "_csrf": {vars.Inputs["_csrf"]}, + "relayState": {vars.Inputs["relayState"]}, + "hmac": {vars.Inputs["hmac"]}, + "email": {v.user}, + }) + if err != nil { + return err + } + + params, err := vwidentity.ParseCredentialsPage(resp.Body) + resp.Body.Close() + if err != nil { + return fmt.Errorf("credentials page: %w", err) + } + if params.TemplateModel.Error != "" { + return errors.New(params.TemplateModel.Error) + } + + // password/authenticate step - the client follows the redirect chain back to + // the portal which sets the session cookie + uri = strings.ReplaceAll(uri, params.TemplateModel.IdentifierUrl, params.TemplateModel.PostAction) + resp, err = v.PostForm(uri, url.Values{ + "_csrf": {params.CsrfToken}, + "relayState": {params.TemplateModel.RelayState}, + "hmac": {params.TemplateModel.Hmac}, + "email": {v.user}, + "password": {v.password}, + }) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + return errors.New(resp.Status) + } + + // a successful login lands on the portal; a remaining signin/consent url means + // the user has not completed the one-time browser consent and vehicle linking + final := resp.Request.URL + if strings.Contains(final.Path, "signin-service") || strings.Contains(final.Path, "/consent") || strings.Contains(final.Path, "/error") { + return api.UrlError( + fmt.Sprintf("login did not complete- open the portal and confirm consent: %s", final), + final, + ) + } + if final.Host != portalHost { + return fmt.Errorf("login did not complete: unexpected landing host %s", final.Host) + } + + return nil +} + +// get executes a GET request, re-authenticating once on 401/403, and returns the body +func (v *API) get(uri string, headers map[string]string) ([]byte, error) { + req, err := request.New(http.MethodGet, uri, nil, headers) + if err != nil { + return nil, err + } + + resp, err := v.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + resp.Body.Close() + + if err := v.login(); err != nil { + return nil, fmt.Errorf("login failed: %w", err) + } + + if req, err = request.New(http.MethodGet, uri, nil, headers); err != nil { + return nil, err + } + if resp, err = v.Do(req); err != nil { + return nil, err + } + } + + return request.ReadBody(resp) +} + +// getJSON executes a GET request and decodes the JSON response +func (v *API) getJSON(uri string, res any) error { + b, err := v.get(uri, map[string]string{"Accept": request.JSONContent}) + if err != nil { + return err + } + return json.Unmarshal(b, res) +} + +// Vehicles enumerates the vehicles the user has linked to the portal +func (v *API) Vehicles() ([]Vehicle, error) { + uri := BaseURL + "/proxy_api/consent/me/vehicles?viewPosition=FRONT_LEFT" + + b, err := v.get(uri, map[string]string{"Accept": request.JSONContent}) + if err != nil { + return nil, err + } + + // the response is either a bare array or wrapped in {"vehicles": [...]} + var arr []Vehicle + if err := json.Unmarshal(b, &arr); err == nil && len(arr) > 0 { + return arr, nil + } + + var wrap struct { + Vehicles []Vehicle `json:"vehicles"` + } + if err := json.Unmarshal(b, &wrap); err != nil { + return nil, err + } + + return wrap.Vehicles, nil +} + +// identifier returns the data-request identifier required for the data delivery calls +func (v *API) identifier(vin string) (string, error) { + uri := fmt.Sprintf("%s/proxy_api/euda-apim/datarequest/vehicles/%s/metadata/partial", BaseURL, vin) + + var res struct { + Identifier string `json:"Identifier"` + } + if err := v.getJSON(uri, &res); err != nil { + return "", err + } + + if res.Identifier == "" { + return "", errors.New("no data request configured for vehicle") + } + + return res.Identifier, nil +} + +// datasets lists the available datasets for the given data request +func (v *API) datasets(vin, identifier string) ([]dataset, error) { + uri := fmt.Sprintf("%s/proxy_api/euda-apim/datadelivery/vehicles/%s/%s/list", BaseURL, vin, identifier) + + b, err := v.get(uri, map[string]string{"Accept": request.JSONContent, "type": "partial"}) + if err != nil { + return nil, err + } + + var arr []dataset + if err := json.Unmarshal(b, &arr); err == nil && len(arr) > 0 { + return arr, nil + } + + var wrap struct { + Files []dataset `json:"files"` + } + if err := json.Unmarshal(b, &wrap); err != nil { + return nil, err + } + + return wrap.Files, nil +} + +// download fetches the dataset zip archive +func (v *API) download(vin, identifier, name string) ([]byte, error) { + uri := fmt.Sprintf("%s/proxy_api/euda-apim/datadelivery/vehicles/%s/%s/download", BaseURL, vin, identifier) + return v.get(uri, map[string]string{"filename": name, "type": "partial"}) +} + +// Status downloads the newest dataset for the vehicle and decodes its data points +// into a map keyed by the dotted field name (e.g. "state_of_charge"). +func (v *API) Status(vin string) (map[string]string, error) { + identifier, err := v.identifier(vin) + if err != nil { + return nil, err + } + + list, err := v.datasets(vin, identifier) + if err != nil { + return nil, err + } + + name := newestDataset(list) + if name == "" { + if len(list) > 0 { + // the portal only emits "_no_content_found" placeholders while the + // vehicle is asleep and has not produced a dataset with content yet + return nil, fmt.Errorf("no dataset with content- wake the vehicle via the app: %w", api.ErrNotAvailable) + } + return nil, api.ErrNotAvailable + } + + b, err := v.download(vin, identifier, name) + if err != nil { + return nil, err + } + + return parseDataset(b) +} diff --git a/vehicle/vw/eudataact/eudataact_test.go b/vehicle/vw/eudataact/eudataact_test.go new file mode 100644 index 000000000..668a4949f --- /dev/null +++ b/vehicle/vw/eudataact/eudataact_test.go @@ -0,0 +1,118 @@ +package eudataact + +import ( + "archive/zip" + "bytes" + "encoding/json" + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// zipJSON builds an in-memory dataset zip containing a single JSON document +func zipJSON(t *testing.T, doc datasetFile) []byte { + t.Helper() + + raw, err := json.Marshal(doc) + require.NoError(t, err) + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("dataset.json") + require.NoError(t, err) + _, err = w.Write(raw) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + return buf.Bytes() +} + +func TestParseDataset(t *testing.T) { + doc := datasetFile{ + VIN: "WVWZZZ123", + Data: []dataPoint{ + {Key: "ffff", DataFieldName: FieldSoc, Value: "73"}, + {Key: "0001", DataFieldName: FieldSoc, Value: "80"}, // smaller key wins + {Key: "aaaa", DataFieldName: FieldOdometer, Value: "12345"}, + {Key: "bbbb", DataFieldName: FieldRange, Value: "210"}, + {Key: "cccc", DataFieldName: FieldChargingState, Value: "charging"}, + {Key: "dddd", DataFieldName: FieldPlugState, Value: "connected"}, + {Key: "eeee", DataFieldName: FieldTargetSoc, Value: "90"}, + {Key: "0002", DataFieldName: "", Value: "ignored"}, // empty field name skipped + }, + } + + data, err := parseDataset(zipJSON(t, doc)) + require.NoError(t, err) + + assert.Equal(t, "80", data[FieldSoc], "smallest key must win") + assert.Equal(t, "12345", data[FieldOdometer]) + assert.Equal(t, "210", data[FieldRange]) + + p := &Provider{statusG: func() (map[string]string, error) { return data, nil }} + + soc, err := p.Soc() + require.NoError(t, err) + assert.Equal(t, 80.0, soc) + + rng, err := p.Range() + require.NoError(t, err) + assert.Equal(t, int64(210), rng) + + odo, err := p.Odometer() + require.NoError(t, err) + assert.Equal(t, 12345.0, odo) + + status, err := p.Status() + require.NoError(t, err) + assert.Equal(t, api.StatusC, status) + + limit, err := p.GetLimitSoc() + require.NoError(t, err) + assert.Equal(t, int64(90), limit) +} + +func TestStatusPlugStates(t *testing.T) { + tc := []struct { + plug, charge string + expected api.ChargeStatus + }{ + {"", "", api.StatusA}, + {"disconnected", "off", api.StatusA}, + {"connected", "readyForCharging", api.StatusB}, + {"connected", "charging", api.StatusC}, + } + + for _, tc := range tc { + data := map[string]string{FieldPlugState: tc.plug, FieldChargingState: tc.charge} + p := &Provider{statusG: func() (map[string]string, error) { return data, nil }} + + status, err := p.Status() + require.NoError(t, err) + assert.Equal(t, tc.expected, status, "plug=%q charge=%q", tc.plug, tc.charge) + } +} + +func TestResolveBrand(t *testing.T) { + for _, name := range []string{"audi", "AUDI", "Audi", "aUdI"} { + b, ok := resolveBrand(name) + require.True(t, ok, "brand %q must resolve", name) + assert.Equal(t, brands["Audi"], b) + } + + _, ok := resolveBrand("nope") + assert.False(t, ok) +} + +func TestNewestDataset(t *testing.T) { + list := []dataset{ + {Name: "2026-05-31T08-00.zip", CreatedOn: "2026-05-31T08:00:00Z"}, + {Name: "2026-05-31T09-00.zip", CreatedOn: "2026-05-31T09:00:00Z"}, + {Name: "2026-05-31T09-15_no_content_found.zip", CreatedOn: "2026-05-31T09:15:00Z"}, + } + + assert.Equal(t, "2026-05-31T09-00.zip", newestDataset(list), "newest with content, no-content skipped") + assert.Empty(t, newestDataset([]dataset{{Name: "x_no_content_found.zip"}})) +} diff --git a/vehicle/vw/eudataact/provider.go b/vehicle/vw/eudataact/provider.go new file mode 100644 index 000000000..29fae5263 --- /dev/null +++ b/vehicle/vw/eudataact/provider.go @@ -0,0 +1,122 @@ +package eudataact + +import ( + "strconv" + "strings" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" +) + +// Provider implements the vehicle api on top of the EU Data Act dataset +type Provider struct { + statusG func() (map[string]string, error) +} + +// NewProvider creates a vehicle api provider +func NewProvider(api *API, vin string, cache time.Duration) *Provider { + return &Provider{ + statusG: util.Cached(func() (map[string]string, error) { + return api.Status(vin) + }, cache), + } +} + +// lookup returns the first present, non-empty value among the given field names +func lookup(data map[string]string, fields ...string) (string, bool) { + for _, f := range fields { + if v, ok := data[f]; ok && v != "" { + return v, true + } + } + return "", false +} + +var _ api.Battery = (*Provider)(nil) + +// Soc implements the api.Battery interface +func (v *Provider) Soc() (float64, error) { + data, err := v.statusG() + if err != nil { + return 0, err + } + + if s, ok := lookup(data, FieldSoc, FieldHvSoc); ok { + return strconv.ParseFloat(s, 64) + } + + return 0, api.ErrNotAvailable +} + +var _ api.VehicleRange = (*Provider)(nil) + +// Range implements the api.VehicleRange interface +func (v *Provider) Range() (int64, error) { + data, err := v.statusG() + if err != nil { + return 0, err + } + + if s, ok := lookup(data, FieldRange, FieldRangePrimary); ok { + f, err := strconv.ParseFloat(s, 64) + return int64(f), err + } + + return 0, api.ErrNotAvailable +} + +var _ api.VehicleOdometer = (*Provider)(nil) + +// Odometer implements the api.VehicleOdometer interface +func (v *Provider) Odometer() (float64, error) { + data, err := v.statusG() + if err != nil { + return 0, err + } + + if s, ok := lookup(data, FieldOdometer); ok { + return strconv.ParseFloat(s, 64) + } + + return 0, api.ErrNotAvailable +} + +var _ api.ChargeState = (*Provider)(nil) + +// Status implements the api.ChargeState interface +func (v *Provider) Status() (api.ChargeStatus, error) { + status := api.StatusA // disconnected + + data, err := v.statusG() + if err != nil { + return status, err + } + + if s, ok := lookup(data, FieldPlugState); ok && strings.EqualFold(s, "connected") { + status = api.StatusB + } + + if s, ok := lookup(data, FieldChargingState); ok && strings.EqualFold(s, "charging") { + status = api.StatusC + } + + return status, nil +} + +var _ api.SocLimiter = (*Provider)(nil) + +// GetLimitSoc implements the api.SocLimiter interface +func (v *Provider) GetLimitSoc() (int64, error) { + data, err := v.statusG() + if err != nil { + return 0, err + } + + if s, ok := lookup(data, FieldTargetSoc); ok { + f, err := strconv.ParseFloat(s, 64) + return int64(f), err + } + + return 0, api.ErrNotAvailable +} diff --git a/vehicle/vw/eudataact/types.go b/vehicle/vw/eudataact/types.go new file mode 100644 index 000000000..f542889ee --- /dev/null +++ b/vehicle/vw/eudataact/types.go @@ -0,0 +1,178 @@ +package eudataact + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "io" + "strings" +) + +// brand holds the OIDC client id and state suffix for a VW group brand. +// All brands share the same portal and endpoints and differ only in the +// identity client id (see lib/euDataAct.js BRAND_CLIENT_IDS). +type brand struct { + clientID string + state string +} + +// brands maps the configurable brand name to its identity parameters +var brands = map[string]brand{ + "Volkswagen": {"9b58543e-1c15-4193-91d5-8a14145bebb0@apps_vw-dilab_com", "VOLKSWAGEN_PASSENGER_CARS"}, + "Audi": {"cc29b87a-5e9a-4362-aecf-5adea6b01bbb@apps_vw-dilab_com", "AUDI"}, + "Skoda": {"3ea88bf9-1d4e-4a68-b3ad-4098c1f1d246@apps_vw-dilab_com", "SKODA"}, + "Seat": {"f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com", "SEAT"}, + "Cupra": {"f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com", "CUPRA"}, +} + +// Brands returns the supported brand names +func Brands() []string { + return []string{"Volkswagen", "Audi", "Skoda", "Seat", "Cupra"} +} + +// resolveBrand looks up a brand by name, case-insensitively +func resolveBrand(name string) (brand, bool) { + for k, b := range brands { + if strings.EqualFold(k, name) { + return b, true + } + } + return brand{}, false +} + +// Vehicle is a single entry of the portal vehicle list. VIN and name carry +// several alternative field names depending on the response variant. +type Vehicle struct { + VIN string `json:"vin"` + VehicleIdentificationNumber string `json:"vehicleIdentificationNumber"` + NickName string `json:"nickName"` + VehicleNickname string `json:"vehicleNickname"` + Nickname string `json:"nickname"` + ModelName string `json:"modelName"` +} + +// Vin returns the vehicle identification number from whichever field is set +func (v Vehicle) Vin() string { + if v.VIN != "" { + return v.VIN + } + return v.VehicleIdentificationNumber +} + +// Name returns the first non-empty display name +func (v Vehicle) Name() string { + for _, s := range []string{v.NickName, v.VehicleNickname, v.Nickname, v.ModelName} { + if s != "" { + return s + } + } + return "" +} + +// dataset describes a single delivered dataset file +type dataset struct { + Name string `json:"name"` + CreatedOn string `json:"createdOn"` +} + +// sortKey returns the value used to find the newest dataset +func (d dataset) sortKey() string { + if d.CreatedOn != "" { + return d.CreatedOn + } + return d.Name +} + +// dataPoint is a single decoded telemetry value of a dataset +type dataPoint struct { + Key string `json:"key"` + DataFieldName string `json:"dataFieldName"` + Value string `json:"value"` +} + +// datasetFile is the JSON document contained in a dataset zip archive +type datasetFile struct { + VIN string `json:"vin"` + Data []dataPoint `json:"Data"` +} + +// data field names as delivered in the dataset (see lib/euDataActDictionary.json) +const ( + FieldSoc = "state_of_charge" + FieldHvSoc = "hv_soc" + FieldRange = "cruising_range_combined" + FieldRangePrimary = "cruising_range_primary_engine" + FieldOdometer = "mileage" + FieldChargingState = "charging_state" + FieldPlugState = "charging_plug1_connectionstate" + FieldTargetSoc = "settings.target_soc" +) + +// newestDataset returns the name of the most recent dataset that actually +// carries content. The portal emits "..._no_content_found.zip" placeholders +// while the vehicle is asleep, which are skipped. +func newestDataset(list []dataset) string { + var best dataset + for _, d := range list { + if strings.HasSuffix(strings.ToLower(d.Name), "_no_content_found.zip") { + continue + } + if best.Name == "" || d.sortKey() > best.sortKey() { + best = d + } + } + return best.Name +} + +// parseDataset extracts the inner JSON document from the dataset zip archive and +// decodes it into a map keyed by the dotted data field name. On duplicate field +// names the entry with the smallest key uuid wins, matching the adapter. +func parseDataset(b []byte) (map[string]string, error) { + zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + if err != nil { + return nil, err + } + + var file *zip.File + for _, f := range zr.File { + if strings.HasSuffix(strings.ToLower(f.Name), ".json") { + file = f + break + } + } + if file == nil { + return nil, errors.New("no json document in dataset") + } + + rc, err := file.Open() + if err != nil { + return nil, err + } + defer rc.Close() + + raw, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + + var ds datasetFile + if err := json.Unmarshal(raw, &ds); err != nil { + return nil, err + } + + res := make(map[string]string, len(ds.Data)) + keys := make(map[string]string, len(ds.Data)) + for _, p := range ds.Data { + if p.DataFieldName == "" { + continue + } + if k, ok := keys[p.DataFieldName]; ok && k <= p.Key { + continue + } + res[p.DataFieldName] = p.Value + keys[p.DataFieldName] = p.Key + } + + return res, nil +} From 5fa62d520d9dc10c99e37316196f9c4197374772 Mon Sep 17 00:00:00 2001 From: friko21 Date: Sun, 31 May 2026 11:05:05 +0200 Subject: [PATCH 013/959] Add Kaco Blueplanet (#30293) Co-authored-by: andig --- templates/definition/meter/kaco-blueplanet.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 templates/definition/meter/kaco-blueplanet.yaml diff --git a/templates/definition/meter/kaco-blueplanet.yaml b/templates/definition/meter/kaco-blueplanet.yaml new file mode 100644 index 000000000..86a09190b --- /dev/null +++ b/templates/definition/meter/kaco-blueplanet.yaml @@ -0,0 +1,13 @@ +params: + - name: usage + choice: ["pv"] + - name: host + required: true + - name: serial + required: true + example: 8.0NX312001234 +render: | + type: custom + power: + source: http + uri: http://{{ .host }}/getdevdata.cgi?device=2&sn={{ .serial }} From fd94f8a376b947e951e84a7bfef18cb85c15edf8 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 11:05:16 +0200 Subject: [PATCH 014/959] Loadpoint: update odometer on disconnect (#30361) --- core/loadpoint.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/loadpoint.go b/core/loadpoint.go index ee84555e0..6b77ec77c 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -547,6 +547,9 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() { // re-read energy from charger and re-persist session if values improved lp.finalizeSessionEnergy() + // re-read odometer to catch delayed update (#30225) + lp.vehicleOdometer() + // session is persisted during evChargeStopHandler which runs before lp.clearSession() From 545d239495564d0f35c65ebe5021908cc48444b6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 11:45:04 +0200 Subject: [PATCH 015/959] Loadpoint: scale down to 1p for feed-in priority in min+pv mode (#30327) --- core/loadpoint.go | 21 +++++++++++-- core/loadpoint_phases_test.go | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index 6b77ec77c..9ad9002dc 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1324,6 +1324,19 @@ func (lp *Loadpoint) fastCharging() error { return lp.setLimit(lp.effectiveMaxCurrent()) } +// minCharging scales to 1p if available and sets minimum current +func (lp *Loadpoint) minCharging() error { + if lp.hasPhaseSwitching() { + // ignore api.ErrNotAvailable: the phase switch could not be performed + // right now, continue with the current phase configuration + if err := lp.scalePhasesIfAvailable(1); err != nil && !errors.Is(err, api.ErrNotAvailable) { + return err + } + } + + return lp.setLimit(lp.effectiveMinCurrent()) +} + // pvScalePhases switches phases if necessary and returns number of phases switched to func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) int { phases := lp.GetPhases() @@ -2096,11 +2109,13 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, f rate, _ := feedin.At(time.Now()) lp.log.DEBUG.Printf("smart feed-in active: %.2f", rate.Value) - var targetCurrent float64 if mode == api.ModeMinPV { - targetCurrent = lp.GetMinCurrent() + // minimize self-consumption to maximize feed-in by scaling down + // to the absolute minimum of 1 phase at min current + err = lp.minCharging() + } else { + err = lp.setLimit(0) } - err = lp.setLimit(targetCurrent) lp.resetPhaseTimer() lp.elapsePVTimer() // let PV mode disable immediately afterwards diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go index 9d82bc866..21afac059 100644 --- a/core/loadpoint_phases_test.go +++ b/core/loadpoint_phases_test.go @@ -515,6 +515,62 @@ func TestScalePhasesNotAvailable(t *testing.T) { require.Equal(t, 1, lp.GetPhases()) } +// TestMinChargingPhaseScaling verifies that minCharging scales down to 1 phase +// (the absolute minimum) when phase switching is available, so feed-in priority +// in min+pv mode drops to 1p min current instead of staying on 3p (issue #30298). +func TestMinChargingPhaseScaling(t *testing.T) { + Voltage = 230 + + tc := []struct { + desc string + phasesConfigured int + phases int + expectedPhases int + }{ + {desc: "auto, 3p active scales down to 1p", phasesConfigured: 0, phases: 3, expectedPhases: 1}, + {desc: "auto, already 1p", phasesConfigured: 0, phases: 1, expectedPhases: 1}, + {desc: "fixed 3p cannot scale down", phasesConfigured: 3, phases: 3, expectedPhases: 3}, + {desc: "fixed 1p stays 1p", phasesConfigured: 1, phases: 1, expectedPhases: 1}, + } + + for _, tc := range tc { + t.Run(tc.desc, func(t *testing.T) { + ctrl := gomock.NewController(t) + + lp := NewLoadpoint(util.NewLogger("foo"), nil) + lp.minCurrent = 6 + lp.maxCurrent = 16 + lp.phasesConfigured = tc.phasesConfigured + lp.phases = tc.phases + lp.offeredCurrent = 0 // ensure MaxCurrent is called + lp.wakeUpTimer = NewTimer() + + plainCharger := api.NewMockCharger(ctrl) + phaseCharger := api.NewMockPhaseSwitcher(ctrl) + lp.charger = struct { + *api.MockCharger + *api.MockPhaseSwitcher + }{plainCharger, phaseCharger} + + plainCharger.EXPECT().Enabled().Return(true, nil).AnyTimes() + plainCharger.EXPECT().Enable(gomock.Any()).Return(nil).AnyTimes() + + if tc.phases != tc.expectedPhases { + phaseCharger.EXPECT().Phases1p3p(tc.expectedPhases).Return(nil) + } + + // minimum current is offered at the scaled phase count + plainCharger.EXPECT().MaxCurrent(int64(lp.minCurrent)).Return(nil) + + err := lp.minCharging() + require.NoError(t, err) + require.Equal(t, tc.expectedPhases, lp.phases, tc.desc) + + ctrl.Finish() + }) + } +} + func TestFastChargingCircuitBasedPhaseScaling(t *testing.T) { Voltage = 230 From e3a79f4618c439721cc8791fe9d3b9c56fb20715 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 11:48:43 +0200 Subject: [PATCH 016/959] Metrics: distinguish missing meter energy from zero reading (#30297) --- core/site.go | 61 +++++++++++++++++++-------------------------- core/types/types.go | 4 +-- server/mqtt_test.go | 2 +- 3 files changed, 28 insertions(+), 39 deletions(-) diff --git a/core/site.go b/core/site.go index 48a8dbbc7..63dc02bfe 100644 --- a/core/site.go +++ b/core/site.go @@ -496,10 +496,18 @@ func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) [ fun := func(i int, dev config.Device[api.Meter]) { meter := dev.Instance() + props := deviceProperties(dev) + mm[i] = types.Measurement{ + Name: dev.Config().Name, + Title: props.Title, + Icon: props.Icon, + } + // power var b bytes.Buffer power, err := backoff.RetryWithData(meter.CurrentPower, modbus.Backoff()) if err == nil { + mm[i].Power = power site.log.DEBUG.Printf("%s %d power: %.0fW", key, i+1, power) } else { if b.Len() > 0 { @@ -509,22 +517,13 @@ func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) [ } // energy (production) - var energy float64 if m, ok := api.Cap[api.MeterEnergy](meter); err == nil && ok { - energy, err = m.TotalEnergy() - if err != nil { + if f, err := m.TotalEnergy(); err == nil { + mm[i].Energy = &f + } else { site.log.ERROR.Printf("%s %d energy: %v", key, i+1, err) } } - - props := deviceProperties(dev) - mm[i] = types.Measurement{ - Name: dev.Config().Name, - Title: props.Title, - Icon: props.Icon, - Power: power, - Energy: energy, - } } var wg sync.WaitGroup @@ -570,7 +569,10 @@ func (site *Site) updatePvMeters() { return math.Abs(m.ExcessDCPower) }) totalEnergy := lo.SumBy(mm, func(m types.Measurement) float64 { - return m.Energy + if m.Energy == nil { + return 0 + } + return *m.Energy }) if len(site.pvMeters) > 1 { @@ -589,13 +591,7 @@ func (site *Site) updatePvMeters() { // persist per-meter PV energy slots (used for history and forecast scaling) for i, dev := range site.pvMeters { c := site.collectors[dev.Config().Name] - - var importEnergy *float64 - if mm[i].Energy > 0 { - importEnergy = &mm[i].Energy - } - - if err := c.AddEnergy(importEnergy, nil, mm[i].Power); err != nil { + if err := c.AddEnergy(mm[i].Energy, nil, mm[i].Power); err != nil { site.log.ERROR.Printf("persist pv %d energy: %v", i+1, err) } } @@ -652,7 +648,10 @@ func (site *Site) updateBatteryMeters() { return m.Power }) site.battery.Energy = lo.SumBy(mm, func(m types.Measurement) float64 { - return m.Energy + if m.Energy == nil { + return 0 + } + return *m.Energy }) if len(site.batteryMeters) > 1 { @@ -669,11 +668,7 @@ func (site *Site) updateBatteryMeters() { if !ok { continue } - var exportEnergy *float64 - if mm[i].Energy > 0 { - exportEnergy = &mm[i].Energy - } - if err := c.AddEnergy(nil, exportEnergy, -mm[i].Power); err != nil { + if err := c.AddEnergy(nil, mm[i].Energy, -mm[i].Power); err != nil { site.log.ERROR.Printf("persist battery %d energy: %v", i+1, err) } } @@ -708,11 +703,7 @@ func (site *Site) addMeterEnergy(meters []config.Device[api.Meter], mm []types.M if !ok { continue } - var importEnergy *float64 - if mm[i].Energy > 0 { - importEnergy = &mm[i].Energy - } - if err := c.AddEnergy(importEnergy, nil, mm[i].Power); err != nil { + if err := c.AddEnergy(mm[i].Energy, nil, mm[i].Power); err != nil { site.log.ERROR.Printf("persist meter %s energy: %v", ref, err) } } @@ -790,18 +781,16 @@ func (site *Site) updateGridMeter() error { } } - // grid energy (import) - var importEnergy *float64 + // grid energy (import); nil when the device has no MeterEnergy capability or the read fails if energyMeter, ok := api.Cap[api.MeterEnergy](site.gridMeter); ok { if f, err := energyMeter.TotalEnergy(); err == nil { - mm.Energy = f - importEnergy = &f + mm.Energy = &f } else { site.log.ERROR.Printf("grid energy: %v", err) } } - site.collectors[site.Meters.GridMeterRef].AddEnergy(importEnergy, nil, mm.Power) + site.collectors[site.Meters.GridMeterRef].AddEnergy(mm.Energy, nil, mm.Power) site.publish(keys.Grid, mm) diff --git a/core/types/types.go b/core/types/types.go index e4a808a67..fe3b3e724 100644 --- a/core/types/types.go +++ b/core/types/types.go @@ -12,8 +12,8 @@ type Measurement struct { Title string `json:"title,omitempty"` Icon string `json:"icon,omitempty"` Power float64 `json:"power"` - Energy float64 `json:"energy,omitempty"` - ReturnEnergy float64 `json:"returnEnergy,omitempty"` + Energy *float64 `json:"energy,omitempty"` + ReturnEnergy *float64 `json:"returnEnergy,omitempty"` Powers []float64 `json:"powers,omitempty"` Currents []float64 `json:"currents,omitempty"` ExcessDCPower float64 `json:"excessdcpower,omitempty"` diff --git a/server/mqtt_test.go b/server/mqtt_test.go index c262c5a21..e9def0cda 100644 --- a/server/mqtt_test.go +++ b/server/mqtt_test.go @@ -115,7 +115,7 @@ func (suite *mqttSuite) TestMeasurement() { suite.Equal(topics, suite.topics, "topics") suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads") - suite.publish("test", false, types.Measurement{Energy: 1}) + suite.publish("test", false, types.Measurement{Energy: new(1.0)}) suite.Equal(topics, suite.topics, "topics") suite.Equal([]string{"", "", "", "0", "1", "", "", "", "", "", "", ""}, suite.payloads, "energy payloads") From 17c758db034454cb8ced0739ba706de83be293e5 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 11:57:36 +0200 Subject: [PATCH 017/959] Vehicle: route VW/Audi through drivesomethinggreater (BC) (#30364) --- templates/definition/vehicle/audi.yaml | 3 ++- templates/definition/vehicle/vw.yaml | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/templates/definition/vehicle/audi.yaml b/templates/definition/vehicle/audi.yaml index bf6445f0b..2c2147580 100644 --- a/templates/definition/vehicle/audi.yaml +++ b/templates/definition/vehicle/audi.yaml @@ -8,6 +8,7 @@ params: example: WAUZZZ... - preset: vehicle-features render: | - type: audi + type: drivesomethinggreater + brand: Audi {{ include "vehicle-base" . }} {{ include "vehicle-features" . }} diff --git a/templates/definition/vehicle/vw.yaml b/templates/definition/vehicle/vw.yaml index 8c583c023..5912295ab 100644 --- a/templates/definition/vehicle/vw.yaml +++ b/templates/definition/vehicle/vw.yaml @@ -13,10 +13,10 @@ params: - name: vin example: WVWZZZ... - name: timeout - default: 10s + deprecated: true - preset: vehicle-features render: | - type: vw + type: drivesomethinggreater + brand: Volkswagen {{ include "vehicle-base" . }} {{ include "vehicle-features" . }} - timeout: {{ .timeout }} From 529f1b1cb9d9ce243bdbaebec012f2e998ace487 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 12:07:38 +0200 Subject: [PATCH 018/959] Meter: allow battery dimming (#30365) --- meter/meter.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/meter/meter.go b/meter/meter.go index a7f959933..63fde31ed 100644 --- a/meter/meter.go +++ b/meter/meter.go @@ -52,6 +52,14 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M m, _ := NewConfigurable(powerG) implement.May(m, implement.MeterEnergy(energyG)) + // dim/curtail + if err := cc.Dimmer.Implement(ctx, m); err != nil { + return nil, err + } + if err := cc.Curtailer.Implement(ctx, m); err != nil { + return nil, err + } + // decorate soc socG, err := cc.Soc.FloatGetter(ctx) if err != nil { @@ -97,14 +105,6 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M implement.May(m, implement.PhasePowers(powersG)) implement.May(m, implement.MaxACPowerGetter(cc.pvMaxACPower.Decorator())) - // dim/curtail - if err := cc.Dimmer.Implement(ctx, m); err != nil { - return nil, err - } - if err := cc.Curtailer.Implement(ctx, m); err != nil { - return nil, err - } - return m, nil } From f0cabc6850c7d63dfbd5eb499b69d388563b194c Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 16:12:42 +0200 Subject: [PATCH 019/959] Vehicle: refresh drivesomethinggreater on portal schedule (#30368) --- vehicle/vw/eudataact/api.go | 44 ++----- vehicle/vw/eudataact/eudataact_test.go | 162 +++++++++++++++++++++--- vehicle/vw/eudataact/provider.go | 58 +++++++-- vehicle/vw/eudataact/store.go | 166 +++++++++++++++++++++++++ vehicle/vw/eudataact/types.go | 93 ++++++++++---- 5 files changed, 433 insertions(+), 90 deletions(-) create mode 100644 vehicle/vw/eudataact/store.go diff --git a/vehicle/vw/eudataact/api.go b/vehicle/vw/eudataact/api.go index 30415adbf..c108d77dd 100644 --- a/vehicle/vw/eudataact/api.go +++ b/vehicle/vw/eudataact/api.go @@ -41,6 +41,7 @@ var portalHost = strings.TrimPrefix(BaseURL, "https://") // the newest dataset and decoding its flat list of data points. type API struct { *request.Helper + log *util.Logger brand brand user, password string } @@ -54,6 +55,7 @@ func NewAPI(log *util.Logger, brandName, user, password string) (*API, error) { v := &API{ Helper: request.NewHelper(log), + log: log, brand: b, user: user, password: password, @@ -261,6 +263,11 @@ func (v *API) datasets(vin, identifier string) ([]dataset, error) { b, err := v.get(uri, map[string]string{"Accept": request.JSONContent, "type": "partial"}) if err != nil { + // the portal answers 404 "No files available for this request" until the + // vehicle has delivered its first dataset + if se, ok := errors.AsType[*request.StatusError](err); ok && se.HasStatus(http.StatusNotFound) { + return nil, nil + } return nil, err } @@ -269,14 +276,14 @@ func (v *API) datasets(vin, identifier string) ([]dataset, error) { return arr, nil } - var wrap struct { + var res struct { Files []dataset `json:"files"` } - if err := json.Unmarshal(b, &wrap); err != nil { + if err := json.Unmarshal(b, &res); err != nil { return nil, err } - return wrap.Files, nil + return res.Files, nil } // download fetches the dataset zip archive @@ -284,34 +291,3 @@ func (v *API) download(vin, identifier, name string) ([]byte, error) { uri := fmt.Sprintf("%s/proxy_api/euda-apim/datadelivery/vehicles/%s/%s/download", BaseURL, vin, identifier) return v.get(uri, map[string]string{"filename": name, "type": "partial"}) } - -// Status downloads the newest dataset for the vehicle and decodes its data points -// into a map keyed by the dotted field name (e.g. "state_of_charge"). -func (v *API) Status(vin string) (map[string]string, error) { - identifier, err := v.identifier(vin) - if err != nil { - return nil, err - } - - list, err := v.datasets(vin, identifier) - if err != nil { - return nil, err - } - - name := newestDataset(list) - if name == "" { - if len(list) > 0 { - // the portal only emits "_no_content_found" placeholders while the - // vehicle is asleep and has not produced a dataset with content yet - return nil, fmt.Errorf("no dataset with content- wake the vehicle via the app: %w", api.ErrNotAvailable) - } - return nil, api.ErrNotAvailable - } - - b, err := v.download(vin, identifier, name) - if err != nil { - return nil, err - } - - return parseDataset(b) -} diff --git a/vehicle/vw/eudataact/eudataact_test.go b/vehicle/vw/eudataact/eudataact_test.go index 668a4949f..a4328a045 100644 --- a/vehicle/vw/eudataact/eudataact_test.go +++ b/vehicle/vw/eudataact/eudataact_test.go @@ -4,7 +4,9 @@ import ( "archive/zip" "bytes" "encoding/json" + "fmt" "testing" + "time" "github.com/evcc-io/evcc/api" "github.com/stretchr/testify/assert" @@ -29,29 +31,38 @@ func zipJSON(t *testing.T, doc datasetFile) []byte { return buf.Bytes() } +// testProvider returns a provider serving the given static data +func testProvider(data map[string]point) *Provider { + return &Provider{ + statusG: func() (map[string]point, error) { + return data, nil + }, + } +} + func TestParseDataset(t *testing.T) { doc := datasetFile{ VIN: "WVWZZZ123", Data: []dataPoint{ - {Key: "ffff", DataFieldName: FieldSoc, Value: "73"}, - {Key: "0001", DataFieldName: FieldSoc, Value: "80"}, // smaller key wins - {Key: "aaaa", DataFieldName: FieldOdometer, Value: "12345"}, - {Key: "bbbb", DataFieldName: FieldRange, Value: "210"}, - {Key: "cccc", DataFieldName: FieldChargingState, Value: "charging"}, - {Key: "dddd", DataFieldName: FieldPlugState, Value: "connected"}, - {Key: "eeee", DataFieldName: FieldTargetSoc, Value: "90"}, - {Key: "0002", DataFieldName: "", Value: "ignored"}, // empty field name skipped + {DataFieldName: FieldSoc, Value: "73", TimestampUtc: "2026-05-31T07:00:00Z"}, + {DataFieldName: FieldSoc, Value: "80", TimestampUtc: "2026-05-31T08:00:00Z"}, // newest timestamp wins + {DataFieldName: FieldOdometer, Value: "12345", TimestampUtc: "2026-05-31T08:00:00Z"}, + {DataFieldName: FieldRange, Value: "210", TimestampUtc: "2026-05-31T08:00:00Z"}, + {DataFieldName: FieldChargingState, Value: "charging", TimestampUtc: "2026-05-31T08:00:00Z"}, + {DataFieldName: FieldPlugState, Value: "connected", TimestampUtc: "2026-05-31T08:00:00Z"}, + {DataFieldName: FieldTargetSoc, Value: "90", TimestampUtc: "2026-05-31T08:00:00Z"}, + {DataFieldName: "", Value: "ignored"}, // empty field name skipped }, } data, err := parseDataset(zipJSON(t, doc)) require.NoError(t, err) - assert.Equal(t, "80", data[FieldSoc], "smallest key must win") - assert.Equal(t, "12345", data[FieldOdometer]) - assert.Equal(t, "210", data[FieldRange]) + assert.Equal(t, "80", data[FieldSoc].Value, "newest timestamp must win") + assert.Equal(t, "12345", data[FieldOdometer].Value) + assert.Equal(t, "210", data[FieldRange].Value) - p := &Provider{statusG: func() (map[string]string, error) { return data, nil }} + p := testProvider(data) soc, err := p.Soc() require.NoError(t, err) @@ -86,8 +97,11 @@ func TestStatusPlugStates(t *testing.T) { } for _, tc := range tc { - data := map[string]string{FieldPlugState: tc.plug, FieldChargingState: tc.charge} - p := &Provider{statusG: func() (map[string]string, error) { return data, nil }} + data := map[string]point{ + FieldPlugState: {Value: tc.plug}, + FieldChargingState: {Value: tc.charge}, + } + p := testProvider(data) status, err := p.Status() require.NoError(t, err) @@ -106,13 +120,121 @@ func TestResolveBrand(t *testing.T) { assert.False(t, ok) } -func TestNewestDataset(t *testing.T) { +func TestContentDatasets(t *testing.T) { list := []dataset{ - {Name: "2026-05-31T08-00.zip", CreatedOn: "2026-05-31T08:00:00Z"}, - {Name: "2026-05-31T09-00.zip", CreatedOn: "2026-05-31T09:00:00Z"}, - {Name: "2026-05-31T09-15_no_content_found.zip", CreatedOn: "2026-05-31T09:15:00Z"}, + {Name: "20260531090000_WVWZZZ.zip"}, + {Name: "20260531080000_WVWZZZ.zip"}, + {Name: "20260531091500_WVWZZZ_no_content_found.zip"}, } - assert.Equal(t, "2026-05-31T09-00.zip", newestDataset(list), "newest with content, no-content skipped") - assert.Empty(t, newestDataset([]dataset{{Name: "x_no_content_found.zip"}})) + content, err := contentDatasets(list) + require.NoError(t, err) + require.Len(t, content, 2, "no-content placeholder dropped") + assert.Equal(t, "20260531080000_WVWZZZ.zip", content[0].Name, "oldest first") + assert.Equal(t, "20260531090000_WVWZZZ.zip", content[1].Name, "newest last") + assert.Equal(t, time.Date(2026, 5, 31, 8, 0, 0, 0, time.UTC), content[0].Timestamp, "timestamp parsed") + + // no-content placeholders are skipped without parsing + empty, err := contentDatasets([]dataset{{Name: "x_no_content_found.zip"}}) + require.NoError(t, err) + assert.Empty(t, empty) + + // a content dataset with an unparseable timestamp is an error + _, err = contentDatasets([]dataset{{Name: "no-timestamp.zip"}}) + require.Error(t, err) +} + +func TestPending(t *testing.T) { + content := make([]dataset, 0, 11) + for i := range 10 { + content = append(content, dataset{ + Name: fmt.Sprintf("20260531%02d0000_WVWZZZ.zip", i), + Timestamp: time.Date(2026, 5, 31, i, 0, 0, 0, time.UTC), + }) // hour i, oldest first + } + + // first poll (zero high-water): only the latest maxBackfill + got := pending(content, time.Time{}) + require.Len(t, got, maxBackfill) + assert.Equal(t, content[len(content)-maxBackfill].Name, got[0].Name, "oldest within the backfill window") + assert.Equal(t, content[len(content)-1].Name, got[len(got)-1].Name, "newest") + + // fewer datasets than the backfill cap: all returned + assert.Len(t, pending(content[:3], time.Time{}), 3) + + // high-water at the newest merged dataset: nothing new to download + after := content[len(content)-1].Timestamp + assert.Empty(t, pending(content, after)) + + // a newer dataset arrives + content = append(content, dataset{ + Name: "20260531100000_WVWZZZ.zip", + Timestamp: time.Date(2026, 5, 31, 10, 0, 0, 0, time.UTC), + }) + got = pending(content, after) + require.Len(t, got, 1) + assert.Equal(t, "20260531100000_WVWZZZ.zip", got[0].Name, "only the newer dataset is pending") +} + +func TestMerge(t *testing.T) { + t0 := time.Date(2026, 5, 31, 7, 0, 0, 0, time.UTC) + t1 := time.Date(2026, 5, 31, 8, 0, 0, 0, time.UTC) + + dst := map[string]point{ + FieldSoc: {Value: "70", Timestamp: t0}, + FieldOdometer: {Value: "100", Timestamp: t1}, + } + src := map[string]point{ + FieldSoc: {Value: "80", Timestamp: t1}, // newer -> wins + FieldOdometer: {Value: "90", Timestamp: t0}, // older -> ignored + FieldRange: {Value: "200", Timestamp: t1}, // new field -> added + } + + merge(dst, src) + + assert.Equal(t, "80", dst[FieldSoc].Value, "newer datapoint wins") + assert.Equal(t, "100", dst[FieldOdometer].Value, "older datapoint ignored") + assert.Equal(t, "200", dst[FieldRange].Value, "new field added") +} + +func TestDatasetTime(t *testing.T) { + ref := time.Date(2026, 5, 31, 8, 0, 0, 0, time.UTC) + + tc := []struct { + d dataset + expected time.Time + err bool + }{ + {dataset{Name: "20260531080000_WVWZZZ_no_content_found.zip"}, ref, false}, // real portal format + {dataset{Name: "20260531080000_WVWZZZ.zip"}, ref, false}, // content file + {dataset{CreatedOn: "2026-05-31T08:00:00Z"}, ref, false}, // createdOn fallback + {dataset{CreatedOn: "2026-05-31T08:00:00Z", Name: "no-stamp.zip"}, ref, false}, // name unparseable, createdOn used + {dataset{Name: "no-timestamp.zip"}, time.Time{}, true}, // nothing parseable + } + + for _, tc := range tc { + got, err := tc.d.time() + if tc.err { + assert.Error(t, err, "dataset %+v", tc.d) + continue + } + require.NoError(t, err, "dataset %+v", tc.d) + assert.Equal(t, tc.expected, got, "dataset %+v", tc.d) + } +} + +// TestResetDelay verifies the cache reset is scheduled for when the portal is +// expected to deliver the dataset following the one just read. +func TestResetDelay(t *testing.T) { + now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) + + // fresh dataset: reset one interval + latency later + assert.Equal(t, portalInterval+portalLatency, resetDelay(now, now)) + + // dataset already 5 min old: reset interval + latency after its timestamp + assert.Equal(t, portalInterval+portalLatency-5*time.Minute, resetDelay(now.Add(-5*time.Minute), now)) + + // next dataset already due: never reset sooner than the latency margin + assert.Equal(t, portalLatency, resetDelay(now.Add(-portalInterval), now)) + assert.Equal(t, portalLatency, resetDelay(now.Add(-time.Hour), now)) } diff --git a/vehicle/vw/eudataact/provider.go b/vehicle/vw/eudataact/provider.go index 29fae5263..53269fcd4 100644 --- a/vehicle/vw/eudataact/provider.go +++ b/vehicle/vw/eudataact/provider.go @@ -9,25 +9,65 @@ import ( "github.com/evcc-io/evcc/util" ) -// Provider implements the vehicle api on top of the EU Data Act dataset +const ( + // portalInterval is the cadence at which the portal delivers a new dataset + portalInterval = 15 * time.Minute + // portalLatency is the margin added to a dataset's timestamp before the + // following dataset is expected to be available for download + portalLatency = 30 * time.Second +) + +// Provider implements the vehicle api on top of the EU Data Act dataset. +// +// The portal is not a live api: it stores a new dataset roughly every +// portalInterval and only ever appends. Rather than re-reading the full history +// on every poll, a store downloads each dataset once and merges its data points +// into a single map, keeping the newest value per field across all datasets. The +// status getter is cached; instead of relying on the cache ttl alone, each read +// schedules a reset for the moment the next dataset is expected (the dataset's +// timestamp plus portalInterval and a latency margin), so the map is updated as +// soon as the portal delivers a new dataset. type Provider struct { - statusG func() (map[string]string, error) + statusG func() (map[string]point, error) } // NewProvider creates a vehicle api provider func NewProvider(api *API, vin string, cache time.Duration) *Provider { - return &Provider{ - statusG: util.Cached(func() (map[string]string, error) { - return api.Status(vin) - }, cache), + v := &Provider{} + s := newStore(api, vin) + + var cached util.Cacheable[map[string]point] + cached = util.ResettableCached(func() (map[string]point, error) { + ts, err := s.update() + if err != nil { + return nil, err + } + if !ts.IsZero() { + time.AfterFunc(resetDelay(ts, time.Now()), cached.Reset) + } + return s.snapshot(), nil + }, cache) + + v.statusG = cached.Get + + return v +} + +// resetDelay returns the delay until the dataset following the one delivered at +// ts is expected to be available. It never returns less than portalLatency so a +// late or repeated dataset does not cause immediate re-polling. +func resetDelay(ts, now time.Time) time.Duration { + if d := ts.Add(portalInterval + portalLatency).Sub(now); d > portalLatency { + return d } + return portalLatency } // lookup returns the first present, non-empty value among the given field names -func lookup(data map[string]string, fields ...string) (string, bool) { +func lookup(data map[string]point, fields ...string) (string, bool) { for _, f := range fields { - if v, ok := data[f]; ok && v != "" { - return v, true + if v, ok := data[f]; ok && v.Value != "" { + return v.Value, true } } return "", false diff --git a/vehicle/vw/eudataact/store.go b/vehicle/vw/eudataact/store.go new file mode 100644 index 000000000..1b4270ee7 --- /dev/null +++ b/vehicle/vw/eudataact/store.go @@ -0,0 +1,166 @@ +package eudataact + +import ( + "maps" + "slices" + "sync" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" +) + +// maxBackfill bounds how many of the most recent content datasets are downloaded +// on the first poll of a session to seed the merged map. +const maxBackfill = 8 + +// store holds the merged dataset state for a single vehicle across a session. +// The portal only ever appends datasets. Each dataset is downloaded at most once; +// its data points are merged into data, keeping the newest value per field. after +// is the delivery time of the newest dataset already merged, so later polls only +// fetch datasets delivered after it and nothing is downloaded twice. +type store struct { + mu sync.Mutex + api *API + vin string + identifier string + data map[string]point + after time.Time +} + +// newStore creates an empty store for the given vehicle +func newStore(api *API, vin string) *store { + return &store{ + api: api, + vin: vin, + data: make(map[string]point), + } +} + +// update downloads any datasets delivered after the newest one already merged +// and merges them into the map oldest to newest. It returns the newest dataset's +// delivery time (used to schedule the next poll); the merged data is read with +// snapshot. On the first poll only the latest maxBackfill content datasets are +// downloaded. +func (s *store) update() (time.Time, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.identifier == "" { + id, err := s.api.identifier(s.vin) + if err != nil { + return time.Time{}, err + } + s.identifier = id + } + + list, err := s.api.datasets(s.vin, s.identifier) + if err != nil { + return time.Time{}, err + } + + content, err := contentDatasets(list) + if err != nil { + return time.Time{}, err + } + + var newest time.Time + for _, d := range list { + t, err := d.time() + if err != nil { + return time.Time{}, err + } + if t.After(newest) { + newest = t + } + } + + // on the first poll the backfilled datasets are logged once as the final + // merged map below; afterwards each newly received dataset is logged as it + // arrives + initial := s.after.IsZero() + + for _, d := range pending(content, s.after) { + b, err := s.api.download(s.vin, s.identifier, d.Name) + if err != nil { + return newest, err + } + + data, err := parseDataset(b) + if err != nil { + return newest, err + } + + merge(s.data, data) + + // advance the high-water mark so this dataset is never downloaded again + if d.Timestamp.After(s.after) { + s.after = d.Timestamp + } + + if !initial { + logData(s.api.log, data) + } + } + + if len(s.data) == 0 { + return time.Time{}, api.ErrNotAvailable + } + + if initial { + logData(s.api.log, s.data) + } + + return newest, nil +} + +// snapshot returns a copy of the merged data +func (s *store) snapshot() map[string]point { + s.mu.Lock() + defer s.mu.Unlock() + + return maps.Clone(s.data) +} + +// logData logs every field of data at DEBUG level, sorted by field name, with +// its value and own timestamp in local time. +func logData(log *util.Logger, data map[string]point) { + for _, k := range slices.Sorted(maps.Keys(data)) { + p := data[k] + log.DEBUG.Printf("recv %s: %s (%s)", k, p.Value, p.Timestamp.Local().Format("2006-01-02 15:04:05")) + } +} + +// pending returns the content datasets that still need downloading, oldest to +// newest. content must be sorted oldest to newest. On the first poll (after is +// zero) only the latest maxBackfill datasets are returned to seed the map; +// afterwards only datasets delivered after the newest one already merged are +// returned, so no dataset is downloaded twice. +func pending(content []dataset, after time.Time) []dataset { + if after.IsZero() { + if len(content) > maxBackfill { + return content[len(content)-maxBackfill:] + } + return content + } + + res := make([]dataset, 0, len(content)) + for _, d := range content { + if d.Timestamp.After(after) { + res = append(res, d) + } + } + + return res +} + +// merge copies the data points from src into dst, keeping the newest value per +// field across datasets. +func merge(dst, src map[string]point) { + for k, p := range src { + if cur, ok := dst[k]; ok && cur.Timestamp.After(p.Timestamp) { + continue + } + dst[k] = p + } +} diff --git a/vehicle/vw/eudataact/types.go b/vehicle/vw/eudataact/types.go index f542889ee..0a0c3e806 100644 --- a/vehicle/vw/eudataact/types.go +++ b/vehicle/vw/eudataact/types.go @@ -5,8 +5,11 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" + "slices" "strings" + "time" ) // brand holds the OIDC client id and state suffix for a VW group brand. @@ -70,25 +73,44 @@ func (v Vehicle) Name() string { return "" } -// dataset describes a single delivered dataset file +// dataset describes a single delivered dataset file. Timestamp is the parsed +// delivery time; it is populated by contentDatasets from the file name or the +// createdOn field. type dataset struct { - Name string `json:"name"` - CreatedOn string `json:"createdOn"` + Name string `json:"name"` + CreatedOn string `json:"createdOn"` + Timestamp time.Time `json:"-"` } -// sortKey returns the value used to find the newest dataset -func (d dataset) sortKey() string { - if d.CreatedOn != "" { - return d.CreatedOn +// nameTime parses the compact timestamp the portal prefixes to a dataset file +// name, e.g. 20260531102941_WAUZZZ..._no_content_found.zip. +func nameTime(name string) (time.Time, error) { + prefix, _, _ := strings.Cut(name, "_") + return time.Parse("20060102150405", prefix) +} + +// time parses the delivery time the dataset carries. The portal embeds it in the +// file name and also delivers it as the createdOn field; the file name is +// preferred and createdOn is the fallback. An error is returned when neither +// carries a parseable timestamp. +func (d dataset) time() (time.Time, error) { + if t, err := nameTime(d.Name); err == nil { + return t, nil } - return d.Name + return time.Parse(time.RFC3339, d.CreatedOn) } -// dataPoint is a single decoded telemetry value of a dataset +// dataPoint is a single data point as delivered in the dataset JSON document type dataPoint struct { - Key string `json:"key"` DataFieldName string `json:"dataFieldName"` Value string `json:"value"` + TimestampUtc string `json:"timestampUtc"` +} + +// point is a decoded data point: its value and the time it was recorded +type point struct { + Value string + Timestamp time.Time } // datasetFile is the JSON document contained in a dataset zip archive @@ -105,30 +127,42 @@ const ( FieldRangePrimary = "cruising_range_primary_engine" FieldOdometer = "mileage" FieldChargingState = "charging_state" - FieldPlugState = "charging_plug1_connectionstate" + FieldPlugState = "plug_state" FieldTargetSoc = "settings.target_soc" ) -// newestDataset returns the name of the most recent dataset that actually -// carries content. The portal emits "..._no_content_found.zip" placeholders -// while the vehicle is asleep, which are skipped. -func newestDataset(list []dataset) string { - var best dataset +// contentDatasets returns the datasets that actually carry content, with their +// delivery time parsed into Timestamp and sorted from oldest to newest. The +// portal emits "..._no_content_found.zip" placeholders while the vehicle is +// asleep, which are skipped. An error is returned when a content dataset's +// timestamp cannot be parsed. +func contentDatasets(list []dataset) ([]dataset, error) { + content := make([]dataset, 0, len(list)) for _, d := range list { if strings.HasSuffix(strings.ToLower(d.Name), "_no_content_found.zip") { continue } - if best.Name == "" || d.sortKey() > best.sortKey() { - best = d + + t, err := d.time() + if err != nil { + return nil, fmt.Errorf("dataset %q: %w", d.Name, err) } + d.Timestamp = t + + content = append(content, d) } - return best.Name + + slices.SortStableFunc(content, func(a, b dataset) int { + return a.Timestamp.Compare(b.Timestamp) + }) + + return content, nil } // parseDataset extracts the inner JSON document from the dataset zip archive and -// decodes it into a map keyed by the dotted data field name. On duplicate field -// names the entry with the smallest key uuid wins, matching the adapter. -func parseDataset(b []byte) (map[string]string, error) { +// decodes it into a map of data points keyed by the dotted data field name. On +// duplicate field names the entry with the newest timestamp wins. +func parseDataset(b []byte) (map[string]point, error) { zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) if err != nil { return nil, err @@ -161,17 +195,22 @@ func parseDataset(b []byte) (map[string]string, error) { return nil, err } - res := make(map[string]string, len(ds.Data)) - keys := make(map[string]string, len(ds.Data)) + res := make(map[string]point, len(ds.Data)) for _, p := range ds.Data { if p.DataFieldName == "" { continue } - if k, ok := keys[p.DataFieldName]; ok && k <= p.Key { + + ts, err := time.Parse(time.RFC3339, p.TimestampUtc) + if err != nil { + return nil, err + } + + if cur, ok := res[p.DataFieldName]; ok && cur.Timestamp.After(ts) { continue } - res[p.DataFieldName] = p.Value - keys[p.DataFieldName] = p.Key + + res[p.DataFieldName] = point{Value: p.Value, Timestamp: ts} } return res, nil From 7f0a49d819a79a85e576c17ceffd0be46fedec6a Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:52:53 +0200 Subject: [PATCH 020/959] chore: fix status caching (#30386) --- charger/go-e/api.go | 73 ++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/charger/go-e/api.go b/charger/go-e/api.go index 2c6edbb5c..98399f49c 100644 --- a/charger/go-e/api.go +++ b/charger/go-e/api.go @@ -36,9 +36,7 @@ type LocalAPI struct { *request.Helper uri string v2 bool - status Response - updated time.Time - cache time.Duration + statusG util.Cacheable[Response] } var _ API = (*LocalAPI)(nil) @@ -50,10 +48,10 @@ func NewLocal(log *util.Logger, uri string, cache time.Duration) *LocalAPI { api := &LocalAPI{ Helper: request.NewHelper(log), uri: uri, - cache: cache, } api.upgradeV2() + api.statusG = util.ResettableCached(api.status, cache) return api } @@ -83,53 +81,53 @@ func (c *LocalAPI) response(partial string, res any) error { return c.GetJSON(url, &res) } -// Status reads a v1/v2 api response -func (c *LocalAPI) Status() (res Response, err error) { - if time.Since(c.updated) > c.cache { - if c.v2 { - c.status = new(StatusResponse2) - err = c.response("status?filter=alw,car,eto,nrg,wh,trx,cards", &c.status) - } else { - c.status = new(StatusResponse) - err = c.response("status", &c.status) - } - if err == nil { - c.updated = time.Now() - } +// status fetches a fresh v1/v2 api response +func (c *LocalAPI) status() (Response, error) { + if c.v2 { + var res StatusResponse2 + err := c.response("status?filter=alw,car,eto,nrg,wh,trx,cards", &res) + return &res, err } - return c.status, err + + var res StatusResponse + err := c.response("status", &res) + return &res, err +} + +// Status reads a cached v1/v2 api response +func (c *LocalAPI) Status() (Response, error) { + return c.statusG.Get() } // Update executes a v1/v2 api update and returns the response func (c *LocalAPI) Update(payload string) error { - c.updated = time.Time{} + c.statusG.Reset() // invalidate cache so the next Status refetches + res := new(UpdateResponse) if c.v2 { - err := c.response(fmt.Sprintf("set?%s", payload), &res) - return err + return c.response(fmt.Sprintf("set?%s", payload), &res) } - err := c.response(fmt.Sprintf("mqtt?payload=%s", payload), &res) - return err + return c.response(fmt.Sprintf("mqtt?payload=%s", payload), &res) } type cloud struct { *request.Helper token string - cache time.Duration - updated time.Time - status Response + statusG util.Cacheable[Response] } var _ API = (*cloud)(nil) func NewCloud(log *util.Logger, token string, cache time.Duration) API { - return &cloud{ + c := &cloud{ Helper: request.NewHelper(log), token: token, - cache: cache, } + c.statusG = util.ResettableCached(c.status, cache) + + return c } func (c *cloud) IsV2() bool { @@ -152,20 +150,19 @@ func (c *cloud) response(function, payload string) (*StatusResponse, error) { return &status.Data, err } -func (c *cloud) Status() (status Response, err error) { - if time.Since(c.updated) >= c.cache { - status, err = c.response("api_status", "") - if err == nil { - c.updated = time.Now() - c.status = status - } - } +// status fetches a fresh cloud api response +func (c *cloud) status() (Response, error) { + return c.response("api_status", "") +} - return c.status, err +// Status reads a cached cloud api response +func (c *cloud) Status() (Response, error) { + return c.statusG.Get() } func (c *cloud) Update(payload string) error { - c.updated = time.Time{} + c.statusG.Reset() // invalidate cache so the next Status refetches + _, err := c.response("api", payload) return err } From 535dab993547338e642451c370c7b5a27ce7371e Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 1 Jun 2026 14:42:51 +0200 Subject: [PATCH 021/959] Meter (Growatt TL-XH): write battery-first time slot atomically (#30403) --- .../definition/meter/growatt-hybrid-tlxh.yaml | 47 +++++-------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/templates/definition/meter/growatt-hybrid-tlxh.yaml b/templates/definition/meter/growatt-hybrid-tlxh.yaml index 69ce4b042..602b12176 100644 --- a/templates/definition/meter/growatt-hybrid-tlxh.yaml +++ b/templates/definition/meter/growatt-hybrid-tlxh.yaml @@ -124,24 +124,19 @@ render: | set: source: sequence set: + # Time-1 "battery first" slot: start (3038) and end (3039) time words must be written + # in a single FC16 multi-register transaction. The firmware rejects a standalone write + # to the end-time register 3039 with modbus exception 0. + # value = (start << 16) | end = (8192 << 16) | 5947 = 0x2000173B; bit15=0 -> slot disabled - source: const - value: 8192 + value: 536876859 set: source: modbus {{- include "modbus" . | indent 10 }} register: address: 3038 type: writemultiple - decode: uint16 - - source: const - value: 5947 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 3039 - type: writemultiple - decode: uint16 + decode: uint32 - source: const value: 0 set: @@ -155,24 +150,16 @@ render: | set: source: sequence set: + # value = (start << 16) | end = (40960 << 16) | 5947 = 0xA000173B; bit15=1 -> slot enabled - source: const - value: 40960 + value: 2684360507 set: source: modbus {{- include "modbus" . | indent 10 }} register: address: 3038 type: writemultiple - decode: uint16 - - source: const - value: 5947 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 3039 - type: writemultiple - decode: uint16 + decode: uint32 - source: const value: 0 set: @@ -186,24 +173,16 @@ render: | set: source: sequence set: + # value = (start << 16) | end = (40960 << 16) | 5947 = 0xA000173B; bit15=1 -> slot enabled - source: const - value: 40960 + value: 2684360507 set: source: modbus {{- include "modbus" . | indent 10 }} register: address: 3038 type: writemultiple - decode: uint16 - - source: const - value: 5947 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 3039 - type: writemultiple - decode: uint16 + decode: uint32 - source: const value: 1 set: @@ -212,6 +191,6 @@ render: | register: address: 3049 type: writemultiple - decode: uint16 + decode: uint16 {{- include "battery-params" . }} {{- end }} From 88f533f41f00036bfe5231e3818e1bae5e2f64e1 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 1 Jun 2026 14:49:47 +0200 Subject: [PATCH 022/959] chore: upgrade playwright (#30402) --- .github/workflows/default.yml | 2 ++ package-lock.json | 24 ++++++++++++------------ package.json | 2 +- tests/config-loadpoint.spec.ts | 1 + 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 3c134457d..370affa1b 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -255,10 +255,12 @@ jobs: - name: Install Playwright (browsers + deps) if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium + timeout-minutes: 5 - name: Install Playwright (deps only) if: steps.playwright-cache.outputs.cache-hit == 'true' run: npx playwright install-deps chromium + timeout-minutes: 5 - name: Run tests run: npx playwright test diff --git a/package-lock.json b/package-lock.json index ab577de2e..c5627ac18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", "@jest/types": "^30.3.0", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.60.0", "@storybook/vue3": "^10.3.5", "@storybook/vue3-vite": "^10.3.5", "@types/body-parser": "^1.19.6", @@ -2771,13 +2771,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.59.1" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -9386,13 +9386,13 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -9405,9 +9405,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 9220cda59..b2a4a839a 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", "@jest/types": "^30.3.0", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.60.0", "@storybook/vue3": "^10.3.5", "@storybook/vue3-vite": "^10.3.5", "@types/body-parser": "^1.19.6", diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts index d376748a6..a6149cde6 100644 --- a/tests/config-loadpoint.spec.ts +++ b/tests/config-loadpoint.spec.ts @@ -103,6 +103,7 @@ test.describe("charging loadpoint", async () => { await chargerModal.getByLabel("Charge status").selectOption("A"); await chargerModal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(chargerModal); + await expectModalVisible(lpModal); await lpModal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(lpModal); From 471cebdb125b9f7bcdd5fb9609e7e1510d8db694 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 1 Jun 2026 14:51:05 +0200 Subject: [PATCH 023/959] docs: drivesomethinggreater wording (#30399) --- templates/definition/vehicle/audi.yaml | 2 ++ .../definition/vehicle/drivesomethinggreater.yaml | 12 +++--------- templates/definition/vehicle/seat-cupra.yaml | 2 +- templates/definition/vehicle/skoda.yaml | 4 +++- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/templates/definition/vehicle/audi.yaml b/templates/definition/vehicle/audi.yaml index 2c2147580..e28bb53bd 100644 --- a/templates/definition/vehicle/audi.yaml +++ b/templates/definition/vehicle/audi.yaml @@ -2,6 +2,8 @@ template: audi covers: ["etron"] products: - brand: Audi + description: + generic: myAudi params: - preset: vehicle-base - name: vin diff --git a/templates/definition/vehicle/drivesomethinggreater.yaml b/templates/definition/vehicle/drivesomethinggreater.yaml index 3c8646e05..64f246600 100644 --- a/templates/definition/vehicle/drivesomethinggreater.yaml +++ b/templates/definition/vehicle/drivesomethinggreater.yaml @@ -19,7 +19,7 @@ requirements: description: de: | Nutzt die im Rahmen des EU Data Act bereitgestellten Fahrzeugdaten über das - VW-Konzern-Portal (eu-data-act.drivesomethinggreater.com). Die Anmeldung + [VW-Konzern-Portal](https://eu-data-act.drivesomethinggreater.com). Die Anmeldung erfolgt mit den Zugangsdaten der jeweiligen Marken-App (We Connect, myAudi, MyŠkoda, SEAT/CUPRA Connect). @@ -27,21 +27,15 @@ requirements: bestätigen, das Fahrzeug verknüpfen und eine fortlaufende Datenanfrage (alle 15 Minuten) aktivieren. Ohne diese Aktivierung liefert das Portal keine Daten. Die Daten werden ca. alle 15 Minuten aktualisiert. - - Implementierung analog zur "EU Data Act" Datenquelle aus - https://github.com/TA2k/ioBroker.vw-connect en: | - Uses the vehicle data provided under the EU Data Act through the VW group - portal (eu-data-act.drivesomethinggreater.com). Log in with the credentials + Uses the vehicle data provided under the EU Data Act through the [VW group + portal](https://eu-data-act.drivesomethinggreater.com). Log in with the credentials of the respective brand app (We Connect, myAudi, MyŠkoda, SEAT/CUPRA Connect). Prerequisite (one-time, in a browser): log in to the portal, confirm the consent screen, link the vehicle and enable a continuous data request (every 15 minutes). Without this activation the portal returns no data. Data is refreshed roughly every 15 minutes. - - Implementation analogous to the "EU Data Act" data source from - https://github.com/TA2k/ioBroker.vw-connect params: - preset: vehicle-base - name: brand diff --git a/templates/definition/vehicle/seat-cupra.yaml b/templates/definition/vehicle/seat-cupra.yaml index f82d23f5c..b78d107bd 100644 --- a/templates/definition/vehicle/seat-cupra.yaml +++ b/templates/definition/vehicle/seat-cupra.yaml @@ -2,7 +2,7 @@ template: cupra products: - brand: Seat description: - generic: CupraConnect Gen4 (Born, Formentor, Tavascan) + generic: CUPRA Connect Gen4 (Born, Formentor, Tavascan) params: - preset: vehicle-base - preset: vehicle-features diff --git a/templates/definition/vehicle/skoda.yaml b/templates/definition/vehicle/skoda.yaml index 6682ff798..9a47ab6de 100644 --- a/templates/definition/vehicle/skoda.yaml +++ b/templates/definition/vehicle/skoda.yaml @@ -1,7 +1,9 @@ template: skoda covers: ["enyaq"] products: - - brand: Skoda + - brand: Škoda + description: + generic: MyŠkoda params: - preset: vehicle-base - name: timeout From 5409ed21a83865bf682a64ae12efde8d677db048 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 1 Jun 2026 14:51:35 +0200 Subject: [PATCH 024/959] chore: simplify drivesomethinggreater (#30391) --- util/request/roundtrip.go | 14 ++++++- vehicle/vw/eudataact/eudataact_test.go | 56 ++------------------------ vehicle/vw/eudataact/provider.go | 46 ++++++++++++++------- vehicle/vw/eudataact/store.go | 14 +++---- vehicle/vw/eudataact/types.go | 31 ++------------ 5 files changed, 55 insertions(+), 106 deletions(-) diff --git a/util/request/roundtrip.go b/util/request/roundtrip.go index c8f7272a3..39dd301a4 100644 --- a/util/request/roundtrip.go +++ b/util/request/roundtrip.go @@ -3,6 +3,7 @@ package request import ( "bytes" "io" + "mime" "net/http" "net/http/httputil" "strconv" @@ -100,6 +101,13 @@ func headerContainsToken(h http.Header, key, token string) bool { return false } +// binaryContent reports whether the response carries a binary body whose raw +// bytes are not useful in the trace log, such as a downloaded archive. +func binaryContent(h http.Header) bool { + mediatype, _, _ := mime.ParseMediaType(h.Get("Content-Type")) + return mediatype == "application/octet-stream" +} + // copy of http.drainBody func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) { if b == nil || b == http.NoBody { @@ -174,12 +182,14 @@ func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { resMetric.WithLabelValues(req.URL.Hostname(), strconv.Itoa(resp.StatusCode)).Add(1) if !isWebSocketReq { + // skip binary bodies (e.g. downloaded archives) - their raw bytes are noise in the trace log + logBody := !binaryContent(resp.Header) if LogHeaders { - if body, err := httputil.DumpResponse(resp, true); err == nil { + if body, err := httputil.DumpResponse(resp, logBody); err == nil { bld.WriteString("\n\n") bld.Write(bytes.TrimSpace(body[:min(LogMaxLen, len(body))])) } - } else { + } else if logBody { if save, resp.Body, err = drainBody(resp.Body); err == nil { err = dump(save, bld) } diff --git a/vehicle/vw/eudataact/eudataact_test.go b/vehicle/vw/eudataact/eudataact_test.go index a4328a045..678ab023e 100644 --- a/vehicle/vw/eudataact/eudataact_test.go +++ b/vehicle/vw/eudataact/eudataact_test.go @@ -120,36 +120,12 @@ func TestResolveBrand(t *testing.T) { assert.False(t, ok) } -func TestContentDatasets(t *testing.T) { - list := []dataset{ - {Name: "20260531090000_WVWZZZ.zip"}, - {Name: "20260531080000_WVWZZZ.zip"}, - {Name: "20260531091500_WVWZZZ_no_content_found.zip"}, - } - - content, err := contentDatasets(list) - require.NoError(t, err) - require.Len(t, content, 2, "no-content placeholder dropped") - assert.Equal(t, "20260531080000_WVWZZZ.zip", content[0].Name, "oldest first") - assert.Equal(t, "20260531090000_WVWZZZ.zip", content[1].Name, "newest last") - assert.Equal(t, time.Date(2026, 5, 31, 8, 0, 0, 0, time.UTC), content[0].Timestamp, "timestamp parsed") - - // no-content placeholders are skipped without parsing - empty, err := contentDatasets([]dataset{{Name: "x_no_content_found.zip"}}) - require.NoError(t, err) - assert.Empty(t, empty) - - // a content dataset with an unparseable timestamp is an error - _, err = contentDatasets([]dataset{{Name: "no-timestamp.zip"}}) - require.Error(t, err) -} - func TestPending(t *testing.T) { content := make([]dataset, 0, 11) for i := range 10 { content = append(content, dataset{ Name: fmt.Sprintf("20260531%02d0000_WVWZZZ.zip", i), - Timestamp: time.Date(2026, 5, 31, i, 0, 0, 0, time.UTC), + CreatedOn: time.Date(2026, 5, 31, i, 0, 0, 0, time.UTC), }) // hour i, oldest first } @@ -163,13 +139,13 @@ func TestPending(t *testing.T) { assert.Len(t, pending(content[:3], time.Time{}), 3) // high-water at the newest merged dataset: nothing new to download - after := content[len(content)-1].Timestamp + after := content[len(content)-1].CreatedOn assert.Empty(t, pending(content, after)) // a newer dataset arrives content = append(content, dataset{ Name: "20260531100000_WVWZZZ.zip", - Timestamp: time.Date(2026, 5, 31, 10, 0, 0, 0, time.UTC), + CreatedOn: time.Date(2026, 5, 31, 10, 0, 0, 0, time.UTC), }) got = pending(content, after) require.Len(t, got, 1) @@ -197,32 +173,6 @@ func TestMerge(t *testing.T) { assert.Equal(t, "200", dst[FieldRange].Value, "new field added") } -func TestDatasetTime(t *testing.T) { - ref := time.Date(2026, 5, 31, 8, 0, 0, 0, time.UTC) - - tc := []struct { - d dataset - expected time.Time - err bool - }{ - {dataset{Name: "20260531080000_WVWZZZ_no_content_found.zip"}, ref, false}, // real portal format - {dataset{Name: "20260531080000_WVWZZZ.zip"}, ref, false}, // content file - {dataset{CreatedOn: "2026-05-31T08:00:00Z"}, ref, false}, // createdOn fallback - {dataset{CreatedOn: "2026-05-31T08:00:00Z", Name: "no-stamp.zip"}, ref, false}, // name unparseable, createdOn used - {dataset{Name: "no-timestamp.zip"}, time.Time{}, true}, // nothing parseable - } - - for _, tc := range tc { - got, err := tc.d.time() - if tc.err { - assert.Error(t, err, "dataset %+v", tc.d) - continue - } - require.NoError(t, err, "dataset %+v", tc.d) - assert.Equal(t, tc.expected, got, "dataset %+v", tc.d) - } -} - // TestResetDelay verifies the cache reset is scheduled for when the portal is // expected to deliver the dataset following the one just read. func TestResetDelay(t *testing.T) { diff --git a/vehicle/vw/eudataact/provider.go b/vehicle/vw/eudataact/provider.go index 53269fcd4..77dae58ba 100644 --- a/vehicle/vw/eudataact/provider.go +++ b/vehicle/vw/eudataact/provider.go @@ -64,13 +64,13 @@ func resetDelay(ts, now time.Time) time.Duration { } // lookup returns the first present, non-empty value among the given field names -func lookup(data map[string]point, fields ...string) (string, bool) { +func lookup(data map[string]point, fields ...string) *point { for _, f := range fields { - if v, ok := data[f]; ok && v.Value != "" { - return v.Value, true + if v, ok := data[f]; ok { + return new(v) } } - return "", false + return nil } var _ api.Battery = (*Provider)(nil) @@ -82,8 +82,8 @@ func (v *Provider) Soc() (float64, error) { return 0, err } - if s, ok := lookup(data, FieldSoc, FieldHvSoc); ok { - return strconv.ParseFloat(s, 64) + if p := lookup(data, FieldSoc, FieldHvSoc); p != nil { + return strconv.ParseFloat(p.Value, 64) } return 0, api.ErrNotAvailable @@ -98,14 +98,32 @@ func (v *Provider) Range() (int64, error) { return 0, err } - if s, ok := lookup(data, FieldRange, FieldRangePrimary); ok { - f, err := strconv.ParseFloat(s, 64) + if p := lookup(data, FieldRange, FieldRangePrimary); p != nil { + f, err := strconv.ParseFloat(p.Value, 64) return int64(f), err } return 0, api.ErrNotAvailable } +var _ api.VehicleFinishTimer = (*Provider)(nil) + +// FinishTime implements the api.VehicleFinishTimer interface +func (v *Provider) FinishTime() (time.Time, error) { + data, err := v.statusG() + if err != nil { + return time.Time{}, err + } + + if p := lookup(data, FieldRemainingTime); p != nil && p.Value != "65535" { + if v, err := strconv.ParseInt(p.Value, 0, 64); err == nil { + return p.Timestamp.Add(time.Duration(v) * time.Minute), nil + } + } + + return time.Time{}, api.ErrNotAvailable +} + var _ api.VehicleOdometer = (*Provider)(nil) // Odometer implements the api.VehicleOdometer interface @@ -115,8 +133,8 @@ func (v *Provider) Odometer() (float64, error) { return 0, err } - if s, ok := lookup(data, FieldOdometer); ok { - return strconv.ParseFloat(s, 64) + if p := lookup(data, FieldOdometer); p != nil { + return strconv.ParseFloat(p.Value, 64) } return 0, api.ErrNotAvailable @@ -133,11 +151,11 @@ func (v *Provider) Status() (api.ChargeStatus, error) { return status, err } - if s, ok := lookup(data, FieldPlugState); ok && strings.EqualFold(s, "connected") { + if p := lookup(data, FieldPlugState); p != nil && strings.EqualFold(p.Value, "connected") { status = api.StatusB } - if s, ok := lookup(data, FieldChargingState); ok && strings.EqualFold(s, "charging") { + if p := lookup(data, FieldChargingState); p != nil && strings.EqualFold(p.Value, "charging") { status = api.StatusC } @@ -153,8 +171,8 @@ func (v *Provider) GetLimitSoc() (int64, error) { return 0, err } - if s, ok := lookup(data, FieldTargetSoc); ok { - f, err := strconv.ParseFloat(s, 64) + if p := lookup(data, FieldTargetSoc); p != nil { + f, err := strconv.ParseFloat(p.Value, 64) return int64(f), err } diff --git a/vehicle/vw/eudataact/store.go b/vehicle/vw/eudataact/store.go index 1b4270ee7..d25277ef2 100644 --- a/vehicle/vw/eudataact/store.go +++ b/vehicle/vw/eudataact/store.go @@ -66,12 +66,8 @@ func (s *store) update() (time.Time, error) { var newest time.Time for _, d := range list { - t, err := d.time() - if err != nil { - return time.Time{}, err - } - if t.After(newest) { - newest = t + if d.CreatedOn.After(newest) { + newest = d.CreatedOn } } @@ -94,8 +90,8 @@ func (s *store) update() (time.Time, error) { merge(s.data, data) // advance the high-water mark so this dataset is never downloaded again - if d.Timestamp.After(s.after) { - s.after = d.Timestamp + if d.CreatedOn.After(s.after) { + s.after = d.CreatedOn } if !initial { @@ -146,7 +142,7 @@ func pending(content []dataset, after time.Time) []dataset { res := make([]dataset, 0, len(content)) for _, d := range content { - if d.Timestamp.After(after) { + if d.CreatedOn.After(after) { res = append(res, d) } } diff --git a/vehicle/vw/eudataact/types.go b/vehicle/vw/eudataact/types.go index 0a0c3e806..130d4c685 100644 --- a/vehicle/vw/eudataact/types.go +++ b/vehicle/vw/eudataact/types.go @@ -5,7 +5,6 @@ import ( "bytes" "encoding/json" "errors" - "fmt" "io" "slices" "strings" @@ -78,26 +77,7 @@ func (v Vehicle) Name() string { // createdOn field. type dataset struct { Name string `json:"name"` - CreatedOn string `json:"createdOn"` - Timestamp time.Time `json:"-"` -} - -// nameTime parses the compact timestamp the portal prefixes to a dataset file -// name, e.g. 20260531102941_WAUZZZ..._no_content_found.zip. -func nameTime(name string) (time.Time, error) { - prefix, _, _ := strings.Cut(name, "_") - return time.Parse("20060102150405", prefix) -} - -// time parses the delivery time the dataset carries. The portal embeds it in the -// file name and also delivers it as the createdOn field; the file name is -// preferred and createdOn is the fallback. An error is returned when neither -// carries a parseable timestamp. -func (d dataset) time() (time.Time, error) { - if t, err := nameTime(d.Name); err == nil { - return t, nil - } - return time.Parse(time.RFC3339, d.CreatedOn) + CreatedOn time.Time `json:"createdOn"` } // dataPoint is a single data point as delivered in the dataset JSON document @@ -129,6 +109,7 @@ const ( FieldChargingState = "charging_state" FieldPlugState = "plug_state" FieldTargetSoc = "settings.target_soc" + FieldRemainingTime = "remaining_charging_time" ) // contentDatasets returns the datasets that actually carry content, with their @@ -143,17 +124,11 @@ func contentDatasets(list []dataset) ([]dataset, error) { continue } - t, err := d.time() - if err != nil { - return nil, fmt.Errorf("dataset %q: %w", d.Name, err) - } - d.Timestamp = t - content = append(content, d) } slices.SortStableFunc(content, func(a, b dataset) int { - return a.Timestamp.Compare(b.Timestamp) + return a.CreatedOn.Compare(b.CreatedOn) }) return content, nil From 0ffbe0ea782056e75bea292f738d08704fbeda88 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 1 Jun 2026 14:52:19 +0200 Subject: [PATCH 025/959] Session UI: small improvements (#30389) --- .../js/components/Savings/LiveCommunity.vue | 4 +-- .../components/Sessions/CostHistoryChart.vue | 26 ++++++++++---- assets/js/views/Sessions.vue | 35 ------------------- i18n/de.json | 12 +++---- i18n/en.json | 10 +++--- 5 files changed, 33 insertions(+), 54 deletions(-) diff --git a/assets/js/components/Savings/LiveCommunity.vue b/assets/js/components/Savings/LiveCommunity.vue index 868928f36..57caaef02 100644 --- a/assets/js/components/Savings/LiveCommunity.vue +++ b/assets/js/components/Savings/LiveCommunity.vue @@ -59,10 +59,10 @@ export default defineComponent({ }, computed: { totalClients() { - return this.result.totalClients; + return this.fmtNumber(this.result.totalClients || 0, 0); }, activeClients() { - return this.result.activeClients; + return this.fmtNumber(this.result.activeClients || 0, 0); }, chargePower() { const { chargePower = 0 } = this.result; diff --git a/assets/js/components/Sessions/CostHistoryChart.vue b/assets/js/components/Sessions/CostHistoryChart.vue index eedb19560..023809735 100644 --- a/assets/js/components/Sessions/CostHistoryChart.vue +++ b/assets/js/components/Sessions/CostHistoryChart.vue @@ -53,8 +53,6 @@ export default defineComponent({ currency: { type: String as PropType, default: CURRENCY.EUR }, colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) }, deviceColors: { type: Object as PropType, default: () => ({}) }, - suggestedMaxAvgCost: { type: Number, default: 0 }, - suggestedMaxCost: { type: Number, default: 0 }, }, computed: { firstDay() { @@ -179,7 +177,9 @@ export default defineComponent({ this.costType === TYPES.PRICE ? this.$t("sessions.avgPrice") : this.$t("sessions.co2"), - data: Object.values(result).map((index) => index.avgCost), + data: Object.values(result).map((index) => + index.totalKWh > 0 ? index.avgCost : null + ), yAxisID: "y1", tension: 0.25, pointRadius: 0, @@ -231,6 +231,22 @@ export default defineComponent({ }; }); }, + maxBarCost() { + if (this.costType !== TYPES.PRICE) return 0; + const barDatasets = this.chartData.datasets.filter( + (d: any) => (d as any).type === "bar" + ); + const labelCount = this.chartData.labels?.length || 0; + let max = 0; + for (let i = 0; i < labelCount; i++) { + const total = barDatasets.reduce( + (sum: number, d: any) => sum + ((d.data[i] as number) || 0), + 0 + ); + if (total > max) max = total; + } + return max; + }, options() { // capture vue component this to be used in chartjs callbacks // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -331,7 +347,7 @@ export default defineComponent({ ticks: { callback: (value: number) => { if (this.costType === TYPES.PRICE) { - const showDecimals = this.suggestedMaxCost < 4; + const showDecimals = this.maxBarCost < 4; return this.fmtMoney(value, this.currency, showDecimals, true); } else { return this.fmtNumber(value / 1e3, 0); @@ -340,13 +356,11 @@ export default defineComponent({ color: colors.muted, maxTicksLimit: 6, }, - suggestedMax: this.suggestedMaxCost, suggestedMin: 0, }, y1: { position: "left", border: { display: false }, - suggestedMax: this.suggestedMaxAvgCost, grid: { drawOnChartArea: false, }, diff --git a/assets/js/views/Sessions.vue b/assets/js/views/Sessions.vue index a8fe22f48..4bb87725f 100644 --- a/assets/js/views/Sessions.vue +++ b/assets/js/views/Sessions.vue @@ -92,8 +92,6 @@ :cost-type="activeType" :currency="currency" :period="period" - :suggested-max-avg-cost="suggestedMaxAvgCost" - :suggested-max-cost="suggestedMaxCost" />
@@ -640,29 +638,6 @@ export default defineComponent({ ? this.suggestedMaxAvgPrice : this.suggestedMaxAvgCo2; }, - suggestedMaxCo2() { - // returns the 98th percentile of total co2 emissions by time period - const sessionsWithCo2 = this.sessions.filter((s) => s.co2PerKWh !== null); - const co2Map = sessionsWithCo2.reduce((acc: Record, s) => { - const key = this.dateToPeriodKey(new Date(s.created)); - acc[key] = (acc[key] || 0) + (s.co2PerKWh ?? 0) * s.chargedEnergy; - return acc; - }, {}); - return Math.max(this.percentile(Object.values(co2Map), 98) ?? 0, 5); // 5kg default - }, - suggestedMaxPrice() { - // returns the 98th percentile of total price by time period - const sessionsWithPrice = this.sessions.filter((s) => s.price !== null); - const priceMap = sessionsWithPrice.reduce((acc: Record, s) => { - const key = this.dateToPeriodKey(new Date(s.created)); - acc[key] = (acc[key] || 0) + (s.price || 0); - return acc; - }, {}); - return Math.max(this.percentile(Object.values(priceMap), 98) ?? 0, 1); // 1 CURRENCY default - }, - suggestedMaxCost() { - return this.activeType === TYPES.PRICE ? this.suggestedMaxPrice : this.suggestedMaxCo2; - }, }, watch: { offline() { @@ -691,16 +666,6 @@ export default defineComponent({ } this.$router.push({ query: { ...this.$route.query, period, month, year } }); }, - dateToPeriodKey(date: Date) { - const options: Intl.DateTimeFormatOptions = { - year: "numeric", - month: "numeric", - day: "numeric", - }; - if (this.period === PERIODS.YEAR) options.day = undefined; - if (this.period === PERIODS.TOTAL) options.month = undefined; - return date.toLocaleDateString(undefined, options); - }, async loadSessions() { const response = await api.get("sessions"); // ensure sessions are sorted by created date diff --git a/i18n/de.json b/i18n/de.json index da04eae7c..b8a8c4d2c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -881,7 +881,7 @@ "greenShareSub2": "PV und Speicher", "power": "Ladeleistung", "powerSub1": "{activeClients} von {totalClients} Nutzern", - "powerSub2": "laden…", + "powerSub2": "laden gerade.", "tabTitle": "Live-Community" }, "savings": { @@ -1412,7 +1412,7 @@ "meterstart": "Anfangszählerstand", "meterstop": "Endzählerstand", "odometer": "Kilometerstand", - "price": "Preis", + "price": "Kosten", "started": "Startzeit", "title": "Ladevorgang" }, @@ -1451,7 +1451,7 @@ "meterstart": "Anfangszählerstand (kWh)", "meterstop": "Endzählerstand (kWh)", "odometer": "Kilometerstand (km)", - "price": "Preis", + "price": "Kosten", "priceperkwh": "Preis/kWh", "solarpercentage": "Sonne (%)", "vehicle": "Fahrzeug" @@ -1468,7 +1468,7 @@ "group": { "co2": "Emissionen", "grid": "Netz", - "price": "Preis", + "price": "Kosten", "self": "Sonne" }, "groupBy": { @@ -1491,10 +1491,10 @@ "showIndividualEntries": "Einzelne Ladevorgänge anzeigen", "solar": "Sonne", "title": "Ladevorgänge", - "total": "Insgesamt", + "total": "Gesamt", "type": { "co2": "CO₂", - "price": "Preis", + "price": "Kosten", "solar": "Sonne" }, "vehicle": "Fahrzeug" diff --git a/i18n/en.json b/i18n/en.json index a153c0775..a1c853986 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -881,7 +881,7 @@ "greenShareSub2": "solar, and battery storage", "power": "Charging power", "powerSub1": "{activeClients} of {totalClients} participants", - "powerSub2": "charging…", + "powerSub2": "are charging.", "tabTitle": "Live community" }, "savings": { @@ -1412,7 +1412,7 @@ "meterstart": "Meter start", "meterstop": "Meter stop", "odometer": "Mileage", - "price": "Price", + "price": "Cost", "started": "Started", "title": "Charging Session" }, @@ -1451,7 +1451,7 @@ "meterstart": "Meter start (kWh)", "meterstop": "Meter stop (kWh)", "odometer": "Mileage (km)", - "price": "Price", + "price": "Cost", "priceperkwh": "Price/kWh", "solarpercentage": "Solar (%)", "vehicle": "Vehicle" @@ -1468,7 +1468,7 @@ "group": { "co2": "Emissions", "grid": "Grid", - "price": "Price", + "price": "Cost", "self": "Solar" }, "groupBy": { @@ -1494,7 +1494,7 @@ "total": "Total", "type": { "co2": "CO₂", - "price": "Price", + "price": "Cost", "solar": "Solar" }, "vehicle": "Vehicle" From b0c56c0a0d9162946a16fdafbffaa8cb1c9daa05 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 1 Jun 2026 14:54:22 +0200 Subject: [PATCH 026/959] unflake --- assets/js/components/Savings/Savings.vue | 19 ++++++++++++++++++- tests/statistics.spec.ts | 19 ++++++++++--------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/assets/js/components/Savings/Savings.vue b/assets/js/components/Savings/Savings.vue index ff8c4e0d1..b58de4e84 100644 --- a/assets/js/components/Savings/Savings.vue +++ b/assets/js/components/Savings/Savings.vue @@ -31,7 +31,7 @@ data-bs-backdrop="true" tabindex="-1" role="dialog" - aria-hidden="true" + :aria-hidden="modalVisible ? 'false' : 'true'" data-testid="savings-modal" >