From 391cf4defae36a35046fc51db9bd785ca7759bf4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:10:20 +0100 Subject: [PATCH 0001/1580] RCT: make port configurable (#27609) --- meter/rct.go | 2 +- templates/definition/meter/rct-power.yaml | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/meter/rct.go b/meter/rct.go index 05caeefe2..2985d9a16 100644 --- a/meter/rct.go +++ b/meter/rct.go @@ -70,7 +70,7 @@ func NewRCTFromConfig(ctx context.Context, other map[string]any) (api.Meter, err } // NewRCT creates an RCT meter -func NewRCT(ctx context.Context, uri, usage string, batterySocLimits batterySocLimits, batteryPowerLimits batteryPowerLimits, cache time.Duration, externalPower bool, capacity float64, capacity2 float64) (api.Meter, error) { +func NewRCT(ctx context.Context, uri, usage string, batterySocLimits batterySocLimits, batteryPowerLimits batteryPowerLimits, cache time.Duration, externalPower bool, capacity, capacity2 float64) (api.Meter, error) { log := util.NewLogger("rct") // re-use connections diff --git a/templates/definition/meter/rct-power.yaml b/templates/definition/meter/rct-power.yaml index f562f93fd..8c59eb90d 100644 --- a/templates/definition/meter/rct-power.yaml +++ b/templates/definition/meter/rct-power.yaml @@ -10,6 +10,9 @@ params: - name: usage choice: ["grid", "pv", "battery"] - name: host + - name: port + default: 8899 + advanced: true - name: externalpower type: bool description: @@ -38,7 +41,7 @@ params: default: 30s render: | type: rct - uri: {{ .host }} + uri: {{ .host }}:{{ .port }} usage: {{ .usage }} cache: {{ .cache }} {{- include "battery-params" . }} From f70b843b1fa833af941628325545b479bfb268b8 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 22 Feb 2026 17:36:57 +0100 Subject: [PATCH 0002/1580] chore: hide deprecated uri --- server/eebus/types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/eebus/types.go b/server/eebus/types.go index 1bcb52b3e..c1ea9964a 100644 --- a/server/eebus/types.go +++ b/server/eebus/types.go @@ -21,7 +21,7 @@ type Certificate struct { } type Config struct { - URI_ string `mapstructure:"uri" json:"uri"` // TODO deprecated + URI_ string `mapstructure:"uri" json:"uri,omitempty"` // TODO deprecated Port int `json:"port"` ShipID string `json:"shipid"` Interfaces []string `json:"interfaces,omitempty"` From 43075869fba139881ba727ab74024ef7c56c2f63 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 23 Feb 2026 14:36:48 +0100 Subject: [PATCH 0003/1580] chore: suppress server closed error --- server/eebus/eebus.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/eebus/eebus.go b/server/eebus/eebus.go index 794ceaae3..93479e174 100644 --- a/server/eebus/eebus.go +++ b/server/eebus/eebus.go @@ -380,6 +380,13 @@ func (c *EEBus) Infof(format string, args ...any) { } func (c *EEBus) Error(args ...any) { + if len(args) == 2 { + // TODO remove when enbility/ship-go is upgraded + if err := fmt.Sprint(args...); err == "websocket server error: http: Server closed" { + return + } + } + c.log.ERROR.Println(args...) } From b0834f28b0d6ce698c99aaaa6910a59d697f59a6 Mon Sep 17 00:00:00 2001 From: Dodi1968 Date: Mon, 23 Feb 2026 15:52:06 +0100 Subject: [PATCH 0004/1580] Loadpoint: update vehicle identifier in session (#27605) --- core/loadpoint_vehicle.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index 4b702806a..9129f6fee 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -38,9 +38,17 @@ func (lp *Loadpoint) coordinatedVehicles() []api.Vehicle { // setVehicleIdentifier updated the vehicle id as read from the charger func (lp *Loadpoint) setVehicleIdentifier(id string) { - if lp.vehicleIdentifier != id { - lp.vehicleIdentifier = id - lp.publish(keys.VehicleIdentity, id) + if lp.vehicleIdentifier == id { + return + } + + lp.vehicleIdentifier = id + lp.publish(keys.VehicleIdentity, id) + + if id != "" { + lp.updateSession(func(session *session.Session) { + session.Identifier = id + }) } } @@ -160,12 +168,9 @@ func (lp *Loadpoint) setActiveVehicle(v api.Vehicle) { lp.PublishEffectiveValues() lp.updateSession(func(session *session.Session) { - var title string if v != nil { - title = v.GetTitle() + session.Vehicle = v.GetTitle() } - - lp.session.Vehicle = title }) } From 2464bbbb57ac8b437c6cef43d756a5edca8b48ff Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 23 Feb 2026 17:37:14 +0100 Subject: [PATCH 0005/1580] InfluxDB: fix battery state (#27644) --- core/site.go | 2 +- core/site_optimizer.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/core/site.go b/core/site.go index 8468c1c3e..b9597a044 100644 --- a/core/site.go +++ b/core/site.go @@ -673,7 +673,7 @@ func (site *Site) updateBatteryMeters() { site.battery.Devices = mm - site.publish(keys.Battery, util.NewSharder(keys.Battery, site.battery)) + site.publish(keys.Battery, site.battery) } // updateAuxMeters updates aux meters diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 20221c643..f2cb0a4e9 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -18,7 +18,6 @@ import ( "github.com/evcc-io/evcc/core/metrics" "github.com/evcc-io/evcc/core/types" "github.com/evcc-io/evcc/tariff" - "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/sponsor" @@ -273,7 +272,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, resp.JSON200.Batteries) - site.publish(keys.Battery, util.NewSharder(keys.Battery, site.battery)) + site.publish(keys.Battery, site.battery) return nil } From 242727c98d5392f838b9cc2a3610d4400b3f750d Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:13:29 +0100 Subject: [PATCH 0006/1580] Add Mennekes Amtron 4You/4Business (OCPP) (#27647) --- .../definition/charger/ocpp-mennekes-4you.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 templates/definition/charger/ocpp-mennekes-4you.yaml diff --git a/templates/definition/charger/ocpp-mennekes-4you.yaml b/templates/definition/charger/ocpp-mennekes-4you.yaml new file mode 100644 index 000000000..d1ebbd106 --- /dev/null +++ b/templates/definition/charger/ocpp-mennekes-4you.yaml @@ -0,0 +1,16 @@ +template: ocpp-mennekes-4you +products: + - brand: Mennekes + description: + generic: Amtron 4Business + - brand: Mennekes + description: + generic: Amtron 4You +capabilities: ["rfid"] +requirements: + evcc: ["sponsorship", "skiptest"] +params: + - preset: ocpp +render: | + {{ include "ocpp" . }} + connecttimeout: 15m From bb9cdf18f4342659cdd40f7c055f6ce01d58abf9 Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:32:08 +0100 Subject: [PATCH 0007/1580] chore: fix casing --- templates/definition/charger/ocpp-mennekes-4you.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/definition/charger/ocpp-mennekes-4you.yaml b/templates/definition/charger/ocpp-mennekes-4you.yaml index d1ebbd106..461d93bd7 100644 --- a/templates/definition/charger/ocpp-mennekes-4you.yaml +++ b/templates/definition/charger/ocpp-mennekes-4you.yaml @@ -2,10 +2,10 @@ template: ocpp-mennekes-4you products: - brand: Mennekes description: - generic: Amtron 4Business + generic: AMTRON 4Business - brand: Mennekes description: - generic: Amtron 4You + generic: AMTRON 4You capabilities: ["rfid"] requirements: evcc: ["sponsorship", "skiptest"] From c457fe9839825cc363304c8cdc6d3a19662f4e9d Mon Sep 17 00:00:00 2001 From: Marcel Goerentz Date: Mon, 23 Feb 2026 20:52:26 +0100 Subject: [PATCH 0008/1580] Improve logging, set maximum interval for backoff, fix potential fd leaks and move url parsing Signed-off-by: Marcel Goerentz --- charger/warp-ws.go | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/charger/warp-ws.go b/charger/warp-ws.go index 8cb5b6be4..6c9ee9d0a 100644 --- a/charger/warp-ws.go +++ b/charger/warp-ws.go @@ -168,32 +168,41 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) metersValuesTopic: fmt.Sprintf("meters/%d/values", meterIndex), } - go w.run(ctx) - - return w, nil -} - -func (w *WarpWS) run(ctx context.Context) { uri, err := parseURI(w.uri, true) if err != nil { w.log.DEBUG.Println(err) if uri == "" { - return + return nil, err } } - w.log.TRACE.Printf("connecting to %s …", uri) + go w.run(uri, ctx) + + return w, nil +} + +func (w *WarpWS) run(uri string, ctx context.Context) { bo := backoff.NewExponentialBackOff(backoff.WithMaxElapsedTime(0)) + bo.MaxInterval = 30 * time.Second for ctx.Err() == nil { - conn, _, err := websocket.Dial(ctx, uri, nil) + w.log.DEBUG.Printf("ws connecting to %s …", uri) + conn, resp, err := websocket.Dial(ctx, uri, nil) if err != nil { + if resp != nil { + resp.Body.Close() + } + if conn != nil { + conn.Close(websocket.StatusInternalError, "dial failed") + } if ctx.Err() != nil { return } + w.log.DEBUG.Printf("ws reconnecting to %s in %v", uri, bo.NextBackOff()) time.Sleep(bo.NextBackOff()) continue } + w.log.DEBUG.Printf("ws connected to %s", uri) bo.Reset() if err := w.handleConnection(ctx, conn); err != nil { w.log.ERROR.Println(err) @@ -238,9 +247,9 @@ func (w *WarpWS) handleConnection(ctx context.Context, conn *websocket.Conn) err return err } - w.log.TRACE.Printf("ws event %s: %s", event.Topic, event.Payload) + w.log.TRACE.Printf("%s ws event %s: %s", w.uri, event.Topic, event.Payload) if err := w.handleEvent(event.Topic, event.Payload); err != nil { - w.log.ERROR.Printf("bad payload for topic %s: %v", event.Topic, err) + w.log.ERROR.Printf("bad payload from %s for topic %s: %v", w.uri, event.Topic, err) } } } From 217062b22227125c988b910280d186032c345929 Mon Sep 17 00:00:00 2001 From: Marcel Goerentz Date: Mon, 23 Feb 2026 20:58:47 +0100 Subject: [PATCH 0009/1580] Accept sourcery suggestions Signed-off-by: Marcel Goerentz --- charger/warp-ws.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/charger/warp-ws.go b/charger/warp-ws.go index 6c9ee9d0a..4979dc7b3 100644 --- a/charger/warp-ws.go +++ b/charger/warp-ws.go @@ -197,8 +197,15 @@ func (w *WarpWS) run(uri string, ctx context.Context) { if ctx.Err() != nil { return } - w.log.DEBUG.Printf("ws reconnecting to %s in %v", uri, bo.NextBackOff()) - time.Sleep(bo.NextBackOff()) + d := bo.NextBackOff() + w.log.DEBUG.Printf("ws reconnecting to %s in %v", uri, d) + + select { + case <-ctx.Done(): + return + case <-time.After(d): + // continue to next reconnect attempt + } continue } From cdb947e55138af4709d1dc42734bca122ba67dd2 Mon Sep 17 00:00:00 2001 From: Marcel Goerentz Date: Tue, 24 Feb 2026 09:11:23 +0100 Subject: [PATCH 0010/1580] Implement digest auth for websocket Signed-off-by: Marcel Goerentz --- charger/warp-ws.go | 81 ++++++++++++++++------ charger/warp/digest_auth.go | 132 ++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 charger/warp/digest_auth.go diff --git a/charger/warp-ws.go b/charger/warp-ws.go index 4979dc7b3..055bf01de 100644 --- a/charger/warp-ws.go +++ b/charger/warp-ws.go @@ -20,7 +20,6 @@ import ( "github.com/evcc-io/evcc/charger/warp" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" - "github.com/jpfielding/go-http-digest/pkg/digest" ) type WarpWS struct { @@ -32,6 +31,8 @@ type WarpWS struct { uri string pmURI string meterIndex uint + user string + password string mu sync.RWMutex @@ -117,7 +118,11 @@ func NewWarpWSFromConfig(ctx context.Context, other map[string]any) (api.Charger } wb.pmHelper = request.NewHelper(wb.log) if cc.EnergyManagerUser != "" { - wb.pmHelper.Client.Transport = digest.NewTransport(cc.EnergyManagerUser, cc.EnergyManagerPassword, wb.pmHelper.Client.Transport) + wb.pmHelper.Client.Transport = &warp.DigestTransport{ + Username: cc.EnergyManagerUser, + Password: cc.EnergyManagerPassword, + Base: wb.pmHelper.Client.Transport, + } } } @@ -156,7 +161,11 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) client := request.NewHelper(log) if user != "" { - client.Client.Transport = digest.NewTransport(user, password, client.Client.Transport) + client.Client.Transport = &warp.DigestTransport{ + Username: user, + Password: password, + Base: client.Client.Transport, + } } w := &WarpWS{ @@ -166,6 +175,8 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) meterMap: map[int]int{}, metersValueIDsTopic: fmt.Sprintf("meters/%d/value_ids", meterIndex), metersValuesTopic: fmt.Sprintf("meters/%d/values", meterIndex), + user: user, + password: password, } uri, err := parseURI(w.uri, true) @@ -184,9 +195,11 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) func (w *WarpWS) run(uri string, ctx context.Context) { bo := backoff.NewExponentialBackOff(backoff.WithMaxElapsedTime(0)) bo.MaxInterval = 30 * time.Second + for ctx.Err() == nil { w.log.DEBUG.Printf("ws connecting to %s …", uri) - conn, resp, err := websocket.Dial(ctx, uri, nil) + + conn, resp, err := dialWebsocket(ctx, uri, w.user, w.password) if err != nil { if resp != nil { resp.Body.Close() @@ -194,29 +207,59 @@ func (w *WarpWS) run(uri string, ctx context.Context) { if conn != nil { conn.Close(websocket.StatusInternalError, "dial failed") } - if ctx.Err() != nil { - return - } - d := bo.NextBackOff() - w.log.DEBUG.Printf("ws reconnecting to %s in %v", uri, d) + w.log.ERROR.Printf("ws dial failed: %v", err) + } else { + w.log.DEBUG.Printf("ws connected to %s", uri) + bo.Reset() - select { - case <-ctx.Done(): - return - case <-time.After(d): - // continue to next reconnect attempt + if err := w.handleConnection(ctx, conn); err != nil { + w.log.ERROR.Println(err) } - continue } - w.log.DEBUG.Printf("ws connected to %s", uri) - bo.Reset() - if err := w.handleConnection(ctx, conn); err != nil { - w.log.ERROR.Println(err) + if ctx.Err() != nil { + return + } + d := bo.NextBackOff() + w.log.DEBUG.Printf("ws reconnecting to %s in %v", uri, d) + + select { + case <-ctx.Done(): + return + case <-time.After(d): } } } +func dialWebsocket(ctx context.Context, wsURL, user, pass string) (*websocket.Conn, *http.Response, error) { + conn, resp, err := websocket.Dial(ctx, wsURL, nil) + if err == nil { + return conn, resp, err + } + + // Extract challeng from response + www := resp.Header.Get("WWW-Authenticate") + resp.Body.Close() + + ch, err := warp.ParseDigestChallenge(www) + if err != nil { + return nil, resp, fmt.Errorf("digest parse error: %w", err) + } + + // Build authorization header + u, _ := url.Parse(wsURL) + auth := warp.BuildDigestAuthHeader(ch, "GET", u.Path, user, pass) + + // Dial with Digest Auth + dialer := websocket.DialOptions{ + HTTPHeader: http.Header{ + "Authorization": []string{auth}, + }, + } + + return websocket.Dial(ctx, wsURL, &dialer) +} + func parseURI(uri string, toWS bool) (string, error) { u, err := url.Parse(util.DefaultScheme(strings.TrimRight(uri, "/"), "http")) if err != nil { diff --git a/charger/warp/digest_auth.go b/charger/warp/digest_auth.go new file mode 100644 index 000000000..19f3c962b --- /dev/null +++ b/charger/warp/digest_auth.go @@ -0,0 +1,132 @@ +package warp + +import ( + "crypto/md5" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" + "sync" +) + +type DigestTransport struct { + Username string + Password string + Base http.RoundTripper + mu sync.Mutex + challenge *DigestChallenge +} + +type DigestChallenge struct { + Realm string + Nonce string + Qop string + Opaque string + Algorithm string +} + +func ParseDigestChallenge(h string) (*DigestChallenge, error) { + dc := &DigestChallenge{} + parts := strings.Split(h, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if strings.HasPrefix(p, "Digest ") { + p = strings.TrimPrefix(p, "Digest ") + } + kv := strings.SplitN(p, "=", 2) + if len(kv) != 2 { + continue + } + key := strings.TrimSpace(kv[0]) + val := strings.Trim(kv[1], `"`) + switch key { + case "realm": + dc.Realm = val + case "nonce": + dc.Nonce = val + case "opaque": + dc.Opaque = val + case "qop": + dc.Qop = val + case "algorithm": + dc.Algorithm = val + } + } + return dc, nil +} + +func BuildDigestAuthHeader(ch *DigestChallenge, method, uri, user, pass string) string { + ha1 := md5Hex(fmt.Sprintf("%s:%s:%s", user, ch.Realm, pass)) + ha2 := md5Hex(fmt.Sprintf("%s:%s", method, uri)) + + cnonce := randomHex(16) + nc := "00000001" + + response := md5Hex(fmt.Sprintf("%s:%s:%s:%s:%s:%s", + ha1, ch.Nonce, nc, cnonce, ch.Qop, ha2)) + + return fmt.Sprintf( + `Digest username="%s", realm="%s", nonce="%s", uri="%s", algorithm="MD5", response="%s", qop=%s, nc=%s, cnonce="%s"`, + user, ch.Realm, ch.Nonce, uri, response, ch.Qop, nc, cnonce, + ) +} + +func md5Hex(s string) string { + h := md5.Sum([]byte(s)) + return hex.EncodeToString(h[:]) +} + +func randomHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} + +func (t *DigestTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.Base == nil { + t.Base = http.DefaultTransport + } + // If we already have a challenge → send digest directly + t.mu.Lock() + ch := t.challenge + t.mu.Unlock() + if ch != nil { + return t.roundTripWithDigest(req, ch) + } + // 1. First try without Auth + resp, err := t.Base.RoundTrip(req) + if err != nil { + return resp, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, nil + } + // 2. Parse challenge + hdr := resp.Header.Get("WWW-Authenticate") + ch, err = ParseDigestChallenge(hdr) + if err != nil { + return resp, err + } + // Save challenge + t.mu.Lock() + t.challenge = ch + t.mu.Unlock() + // Reread body (if necessery) + if req.Body != nil { + bodyBytes, _ := io.ReadAll(req.Body) + req.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) + } + // 3. Second try with Digest Auth + return t.roundTripWithDigest(req, ch) +} + +func (t *DigestTransport) roundTripWithDigest(req *http.Request, ch *DigestChallenge) (*http.Response, error) { + // Copy request + r2 := req.Clone(req.Context()) + uri := r2.URL.RequestURI() + auth := BuildDigestAuthHeader(ch, r2.Method, uri, t.Username, t.Password) + r2.Header.Set("Authorization", auth) + return t.Base.RoundTrip(r2) +} From 27a512ff45acc5783926f17acd0728fea98b9339 Mon Sep 17 00:00:00 2001 From: Marcel Goerentz Date: Tue, 24 Feb 2026 09:52:33 +0100 Subject: [PATCH 0011/1580] Remove unnecessary fields and improve logging Signed-off-by: Marcel Goerentz --- charger/warp-ws.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/charger/warp-ws.go b/charger/warp-ws.go index 055bf01de..8f8ca5e94 100644 --- a/charger/warp-ws.go +++ b/charger/warp-ws.go @@ -31,8 +31,6 @@ type WarpWS struct { uri string pmURI string meterIndex uint - user string - password string mu sync.RWMutex @@ -175,8 +173,6 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) meterMap: map[int]int{}, metersValueIDsTopic: fmt.Sprintf("meters/%d/value_ids", meterIndex), metersValuesTopic: fmt.Sprintf("meters/%d/values", meterIndex), - user: user, - password: password, } uri, err := parseURI(w.uri, true) @@ -187,19 +183,19 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) } } - go w.run(uri, ctx) + go w.run(uri, user, password, ctx) return w, nil } -func (w *WarpWS) run(uri string, ctx context.Context) { +func (w *WarpWS) run(uri, user, pass string, ctx context.Context) { bo := backoff.NewExponentialBackOff(backoff.WithMaxElapsedTime(0)) bo.MaxInterval = 30 * time.Second for ctx.Err() == nil { w.log.DEBUG.Printf("ws connecting to %s …", uri) - conn, resp, err := dialWebsocket(ctx, uri, w.user, w.password) + conn, resp, err := dialWebsocket(ctx, uri, user, pass) if err != nil { if resp != nil { resp.Body.Close() @@ -390,6 +386,7 @@ func (w *WarpWS) hasFeature(feature string) bool { var f []string if err := w.GetJSON(uri, &f); err == nil { + w.log.DEBUG.Printf("%s features response: %s", w.uri, f) w.mu.Lock() w.features = f w.mu.Unlock() @@ -497,6 +494,7 @@ func (w *WarpWS) disablePhaseAutoSwitch() error { uri := fmt.Sprintf("%s/evse/phase_auto_switch", w.uri) req, _ := request.New(http.MethodPost, uri, request.MarshalJSON(map[string]bool{"enabled": false}), request.JSONEncoding) _, err := w.Do(req) + w.log.DEBUG.Println("disable phase auto switch was successful") return err } From c3cd6e41cde0802eb3225722f92abf29af0cd367 Mon Sep 17 00:00:00 2001 From: Marcel Goerentz Date: Tue, 24 Feb 2026 10:45:02 +0100 Subject: [PATCH 0012/1580] Use digest package instead of own implementation Signed-off-by: Marcel Goerentz --- charger/warp-ws.go | 28 +++++--- charger/warp/digest_auth.go | 132 ------------------------------------ go.mod | 1 + go.sum | 4 ++ 4 files changed, 22 insertions(+), 143 deletions(-) delete mode 100644 charger/warp/digest_auth.go diff --git a/charger/warp-ws.go b/charger/warp-ws.go index 8f8ca5e94..10bc9449a 100644 --- a/charger/warp-ws.go +++ b/charger/warp-ws.go @@ -20,6 +20,7 @@ import ( "github.com/evcc-io/evcc/charger/warp" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" + "github.com/icholy/digest" ) type WarpWS struct { @@ -116,10 +117,9 @@ func NewWarpWSFromConfig(ctx context.Context, other map[string]any) (api.Charger } wb.pmHelper = request.NewHelper(wb.log) if cc.EnergyManagerUser != "" { - wb.pmHelper.Client.Transport = &warp.DigestTransport{ + wb.pmHelper.Client.Transport = &digest.Transport{ Username: cc.EnergyManagerUser, Password: cc.EnergyManagerPassword, - Base: wb.pmHelper.Client.Transport, } } } @@ -159,10 +159,9 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) client := request.NewHelper(log) if user != "" { - client.Client.Transport = &warp.DigestTransport{ + client.Client.Transport = &digest.Transport{ Username: user, Password: password, - Base: client.Client.Transport, } } @@ -203,7 +202,7 @@ func (w *WarpWS) run(uri, user, pass string, ctx context.Context) { if conn != nil { conn.Close(websocket.StatusInternalError, "dial failed") } - w.log.ERROR.Printf("ws dial failed: %v", err) + w.log.ERROR.Printf("ws dial to %s failed: %v", uri, err) } else { w.log.DEBUG.Printf("ws connected to %s", uri) bo.Reset() @@ -234,22 +233,29 @@ func dialWebsocket(ctx context.Context, wsURL, user, pass string) (*websocket.Co } // Extract challeng from response + if resp == nil { + return nil, nil, fmt.Errorf("no response on websocket dial") + } www := resp.Header.Get("WWW-Authenticate") - resp.Body.Close() - ch, err := warp.ParseDigestChallenge(www) + chal, err := digest.ParseChallenge(www) if err != nil { return nil, resp, fmt.Errorf("digest parse error: %w", err) } - // Build authorization header - u, _ := url.Parse(wsURL) - auth := warp.BuildDigestAuthHeader(ch, "GET", u.Path, user, pass) + cred, _ := digest.Digest(chal, digest.Options{ + Username: user, + Password: pass, + Method: "GET", + URI: wsURL, + Count: 1, + }) + resp.Body.Close() // Dial with Digest Auth dialer := websocket.DialOptions{ HTTPHeader: http.Header{ - "Authorization": []string{auth}, + "Authorization": []string{cred.String()}, }, } diff --git a/charger/warp/digest_auth.go b/charger/warp/digest_auth.go deleted file mode 100644 index 19f3c962b..000000000 --- a/charger/warp/digest_auth.go +++ /dev/null @@ -1,132 +0,0 @@ -package warp - -import ( - "crypto/md5" - "crypto/rand" - "encoding/hex" - "fmt" - "io" - "net/http" - "strings" - "sync" -) - -type DigestTransport struct { - Username string - Password string - Base http.RoundTripper - mu sync.Mutex - challenge *DigestChallenge -} - -type DigestChallenge struct { - Realm string - Nonce string - Qop string - Opaque string - Algorithm string -} - -func ParseDigestChallenge(h string) (*DigestChallenge, error) { - dc := &DigestChallenge{} - parts := strings.Split(h, ",") - for _, p := range parts { - p = strings.TrimSpace(p) - if strings.HasPrefix(p, "Digest ") { - p = strings.TrimPrefix(p, "Digest ") - } - kv := strings.SplitN(p, "=", 2) - if len(kv) != 2 { - continue - } - key := strings.TrimSpace(kv[0]) - val := strings.Trim(kv[1], `"`) - switch key { - case "realm": - dc.Realm = val - case "nonce": - dc.Nonce = val - case "opaque": - dc.Opaque = val - case "qop": - dc.Qop = val - case "algorithm": - dc.Algorithm = val - } - } - return dc, nil -} - -func BuildDigestAuthHeader(ch *DigestChallenge, method, uri, user, pass string) string { - ha1 := md5Hex(fmt.Sprintf("%s:%s:%s", user, ch.Realm, pass)) - ha2 := md5Hex(fmt.Sprintf("%s:%s", method, uri)) - - cnonce := randomHex(16) - nc := "00000001" - - response := md5Hex(fmt.Sprintf("%s:%s:%s:%s:%s:%s", - ha1, ch.Nonce, nc, cnonce, ch.Qop, ha2)) - - return fmt.Sprintf( - `Digest username="%s", realm="%s", nonce="%s", uri="%s", algorithm="MD5", response="%s", qop=%s, nc=%s, cnonce="%s"`, - user, ch.Realm, ch.Nonce, uri, response, ch.Qop, nc, cnonce, - ) -} - -func md5Hex(s string) string { - h := md5.Sum([]byte(s)) - return hex.EncodeToString(h[:]) -} - -func randomHex(n int) string { - b := make([]byte, n) - rand.Read(b) - return hex.EncodeToString(b) -} - -func (t *DigestTransport) RoundTrip(req *http.Request) (*http.Response, error) { - if t.Base == nil { - t.Base = http.DefaultTransport - } - // If we already have a challenge → send digest directly - t.mu.Lock() - ch := t.challenge - t.mu.Unlock() - if ch != nil { - return t.roundTripWithDigest(req, ch) - } - // 1. First try without Auth - resp, err := t.Base.RoundTrip(req) - if err != nil { - return resp, err - } - if resp.StatusCode != http.StatusUnauthorized { - return resp, nil - } - // 2. Parse challenge - hdr := resp.Header.Get("WWW-Authenticate") - ch, err = ParseDigestChallenge(hdr) - if err != nil { - return resp, err - } - // Save challenge - t.mu.Lock() - t.challenge = ch - t.mu.Unlock() - // Reread body (if necessery) - if req.Body != nil { - bodyBytes, _ := io.ReadAll(req.Body) - req.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) - } - // 3. Second try with Digest Auth - return t.roundTripWithDigest(req, ch) -} - -func (t *DigestTransport) roundTripWithDigest(req *http.Request, ch *DigestChallenge) (*http.Response, error) { - // Copy request - r2 := req.Clone(req.Context()) - uri := r2.URL.RequestURI() - auth := BuildDigestAuthHeader(ch, r2.Method, uri, t.Username, t.Password) - r2.Header.Set("Authorization", auth) - return t.Base.RoundTrip(r2) -} diff --git a/go.mod b/go.mod index 857aaeafd..10fe85cbc 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( github.com/hashicorp/go-version v1.8.0 github.com/hasura/go-graphql-client v0.15.1 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 + github.com/icholy/digest v1.1.0 github.com/influxdata/influxdb-client-go/v2 v2.14.0 github.com/insomniacslk/tapo v1.0.2 github.com/itchyny/gojq v0.12.18 diff --git a/go.sum b/go.sum index 8f5a1345c..c726c3978 100644 --- a/go.sum +++ b/go.sum @@ -399,6 +399,8 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= +github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= +github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -1066,6 +1068,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= From 1e509e4f6ed66ecec11f1f9f2f0d1de12f95d4a9 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 24 Feb 2026 11:40:52 +0100 Subject: [PATCH 0013/1580] Simplify --- charger/warp-ws.go | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/charger/warp-ws.go b/charger/warp-ws.go index 10bc9449a..475920845 100644 --- a/charger/warp-ws.go +++ b/charger/warp-ws.go @@ -182,17 +182,18 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) } } - go w.run(uri, user, password, ctx) + go w.run(ctx, uri, user, password) return w, nil } -func (w *WarpWS) run(uri, user, pass string, ctx context.Context) { - bo := backoff.NewExponentialBackOff(backoff.WithMaxElapsedTime(0)) - bo.MaxInterval = 30 * time.Second +func (w *WarpWS) run(ctx context.Context, uri, user, pass string) { + bo := backoff.NewExponentialBackOff( + backoff.WithMaxInterval(30*time.Second), + backoff.WithMaxElapsedTime(0)) for ctx.Err() == nil { - w.log.DEBUG.Printf("ws connecting to %s …", uri) + w.log.DEBUG.Printf("ws connecting to %s", uri) conn, resp, err := dialWebsocket(ctx, uri, user, pass) if err != nil { @@ -212,16 +213,10 @@ func (w *WarpWS) run(uri, user, pass string, ctx context.Context) { } } - if ctx.Err() != nil { - return - } - d := bo.NextBackOff() - w.log.DEBUG.Printf("ws reconnecting to %s in %v", uri, d) - select { case <-ctx.Done(): return - case <-time.After(d): + case <-time.After(bo.NextBackOff()): } } } From b816ae43c00ede2f01d5ce35d0f73f6d61bed1a7 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 24 Feb 2026 11:48:57 +0100 Subject: [PATCH 0014/1580] Revert #27652 (#27661) --- charger/warp-ws.go | 112 +++++++++++---------------------------------- go.mod | 1 - go.sum | 4 -- 3 files changed, 27 insertions(+), 90 deletions(-) diff --git a/charger/warp-ws.go b/charger/warp-ws.go index 475920845..8cb5b6be4 100644 --- a/charger/warp-ws.go +++ b/charger/warp-ws.go @@ -20,7 +20,7 @@ import ( "github.com/evcc-io/evcc/charger/warp" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" - "github.com/icholy/digest" + "github.com/jpfielding/go-http-digest/pkg/digest" ) type WarpWS struct { @@ -117,10 +117,7 @@ func NewWarpWSFromConfig(ctx context.Context, other map[string]any) (api.Charger } wb.pmHelper = request.NewHelper(wb.log) if cc.EnergyManagerUser != "" { - wb.pmHelper.Client.Transport = &digest.Transport{ - Username: cc.EnergyManagerUser, - Password: cc.EnergyManagerPassword, - } + wb.pmHelper.Client.Transport = digest.NewTransport(cc.EnergyManagerUser, cc.EnergyManagerPassword, wb.pmHelper.Client.Transport) } } @@ -159,10 +156,7 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) client := request.NewHelper(log) if user != "" { - client.Client.Transport = &digest.Transport{ - Username: user, - Password: password, - } + client.Client.Transport = digest.NewTransport(user, password, client.Client.Transport) } w := &WarpWS{ @@ -174,87 +168,37 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) metersValuesTopic: fmt.Sprintf("meters/%d/values", meterIndex), } - uri, err := parseURI(w.uri, true) - if err != nil { - w.log.DEBUG.Println(err) - if uri == "" { - return nil, err - } - } - - go w.run(ctx, uri, user, password) + go w.run(ctx) return w, nil } -func (w *WarpWS) run(ctx context.Context, uri, user, pass string) { - bo := backoff.NewExponentialBackOff( - backoff.WithMaxInterval(30*time.Second), - backoff.WithMaxElapsedTime(0)) - - for ctx.Err() == nil { - w.log.DEBUG.Printf("ws connecting to %s", uri) - - conn, resp, err := dialWebsocket(ctx, uri, user, pass) - if err != nil { - if resp != nil { - resp.Body.Close() - } - if conn != nil { - conn.Close(websocket.StatusInternalError, "dial failed") - } - w.log.ERROR.Printf("ws dial to %s failed: %v", uri, err) - } else { - w.log.DEBUG.Printf("ws connected to %s", uri) - bo.Reset() - - if err := w.handleConnection(ctx, conn); err != nil { - w.log.ERROR.Println(err) - } - } - - select { - case <-ctx.Done(): - return - case <-time.After(bo.NextBackOff()): - } - } -} - -func dialWebsocket(ctx context.Context, wsURL, user, pass string) (*websocket.Conn, *http.Response, error) { - conn, resp, err := websocket.Dial(ctx, wsURL, nil) - if err == nil { - return conn, resp, err - } - - // Extract challeng from response - if resp == nil { - return nil, nil, fmt.Errorf("no response on websocket dial") - } - www := resp.Header.Get("WWW-Authenticate") - - chal, err := digest.ParseChallenge(www) +func (w *WarpWS) run(ctx context.Context) { + uri, err := parseURI(w.uri, true) if err != nil { - return nil, resp, fmt.Errorf("digest parse error: %w", err) + w.log.DEBUG.Println(err) + if uri == "" { + return + } } + w.log.TRACE.Printf("connecting to %s …", uri) - cred, _ := digest.Digest(chal, digest.Options{ - Username: user, - Password: pass, - Method: "GET", - URI: wsURL, - Count: 1, - }) - resp.Body.Close() + bo := backoff.NewExponentialBackOff(backoff.WithMaxElapsedTime(0)) + for ctx.Err() == nil { + conn, _, err := websocket.Dial(ctx, uri, nil) + if err != nil { + if ctx.Err() != nil { + return + } + time.Sleep(bo.NextBackOff()) + continue + } - // Dial with Digest Auth - dialer := websocket.DialOptions{ - HTTPHeader: http.Header{ - "Authorization": []string{cred.String()}, - }, + bo.Reset() + if err := w.handleConnection(ctx, conn); err != nil { + w.log.ERROR.Println(err) + } } - - return websocket.Dial(ctx, wsURL, &dialer) } func parseURI(uri string, toWS bool) (string, error) { @@ -294,9 +238,9 @@ func (w *WarpWS) handleConnection(ctx context.Context, conn *websocket.Conn) err return err } - w.log.TRACE.Printf("%s ws event %s: %s", w.uri, event.Topic, event.Payload) + w.log.TRACE.Printf("ws event %s: %s", event.Topic, event.Payload) if err := w.handleEvent(event.Topic, event.Payload); err != nil { - w.log.ERROR.Printf("bad payload from %s for topic %s: %v", w.uri, event.Topic, err) + w.log.ERROR.Printf("bad payload for topic %s: %v", event.Topic, err) } } } @@ -387,7 +331,6 @@ func (w *WarpWS) hasFeature(feature string) bool { var f []string if err := w.GetJSON(uri, &f); err == nil { - w.log.DEBUG.Printf("%s features response: %s", w.uri, f) w.mu.Lock() w.features = f w.mu.Unlock() @@ -495,7 +438,6 @@ func (w *WarpWS) disablePhaseAutoSwitch() error { uri := fmt.Sprintf("%s/evse/phase_auto_switch", w.uri) req, _ := request.New(http.MethodPost, uri, request.MarshalJSON(map[string]bool{"enabled": false}), request.JSONEncoding) _, err := w.Do(req) - w.log.DEBUG.Println("disable phase auto switch was successful") return err } diff --git a/go.mod b/go.mod index 10fe85cbc..857aaeafd 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,6 @@ require ( github.com/hashicorp/go-version v1.8.0 github.com/hasura/go-graphql-client v0.15.1 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 - github.com/icholy/digest v1.1.0 github.com/influxdata/influxdb-client-go/v2 v2.14.0 github.com/insomniacslk/tapo v1.0.2 github.com/itchyny/gojq v0.12.18 diff --git a/go.sum b/go.sum index c726c3978..8f5a1345c 100644 --- a/go.sum +++ b/go.sum @@ -399,8 +399,6 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= -github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= -github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -1068,8 +1066,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= From e4b93e14ccf46ea644e31a8e0e5dd0b2b129a8df Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 25 Feb 2026 08:44:22 +0100 Subject: [PATCH 0015/1580] Config UI: improve create loadpoint (#27665) --- assets/css/app.css | 2 + .../components/Config/DeviceCardEditIcon.vue | 8 +- assets/js/components/Config/DeviceRefBox.vue | 40 + .../js/components/Config/LoadpointModal.vue | 893 +++++++++--------- .../Config/Messaging/MessagingModal.vue | 21 +- assets/js/components/Helper/GenericModal.vue | 6 +- assets/js/configModal.ts | 5 +- assets/js/views/Config.vue | 6 + i18n/de.json | 3 +- i18n/en.json | 3 +- tests/config-fatals.spec.ts | 4 +- tests/config-loadpoint.spec.ts | 24 +- 12 files changed, 546 insertions(+), 469 deletions(-) create mode 100644 assets/js/components/Config/DeviceRefBox.vue diff --git a/assets/css/app.css b/assets/css/app.css index 1646e8b07..997593c5a 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -80,6 +80,8 @@ --bs-danger: var(--evcc-red); --bs-danger-rgb: var(--evcc-red-rgb); + --bs-form-invalid-border-color: var(--bs-danger); + --bs-body-font-size: 14px; --bs-font-sans-serif: Montserrat, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", diff --git a/assets/js/components/Config/DeviceCardEditIcon.vue b/assets/js/components/Config/DeviceCardEditIcon.vue index bcca7ab14..fabdc8e8a 100644 --- a/assets/js/components/Config/DeviceCardEditIcon.vue +++ b/assets/js/components/Config/DeviceCardEditIcon.vue @@ -2,8 +2,11 @@ - +
-
+

diff --git a/assets/js/components/MaterialIcon/Optimizer.vue b/assets/js/components/MaterialIcon/Optimizer.vue new file mode 100644 index 000000000..b2a2e45a5 --- /dev/null +++ b/assets/js/components/MaterialIcon/Optimizer.vue @@ -0,0 +1,18 @@ + + + diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 8da356ec0..d53863682 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -112,6 +112,7 @@ export interface State { config?: string; database?: string; ocpp?: Ocpp; + optimizer?: boolean; } export interface ConfigStatus { diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index b435e3f55..affb87be4 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -321,6 +321,19 @@ + + + +

{{ $t("config.section.services") }}

@@ -402,6 +415,7 @@ + @@ -461,6 +475,8 @@ import MqttIcon from "../components/MaterialIcon/Mqtt.vue"; import MqttModal from "../components/Config/MqttModal.vue"; import NetworkModal from "../components/Config/NetworkModal.vue"; import NotificationIcon from "../components/MaterialIcon/Notification.vue"; +import OptimizerIcon from "../components/MaterialIcon/Optimizer.vue"; +import OptimizerModal from "../components/Config/OptimizerModal.vue"; import restart, { performRestart } from "../restart"; import SponsorModal from "../components/Config/SponsorModal.vue"; import store from "../store"; @@ -541,6 +557,8 @@ export default defineComponent({ MqttModal, NetworkModal, NotificationIcon, + OptimizerIcon, + OptimizerModal, SponsorModal, TariffsLegacyModal, TariffCard, @@ -763,6 +781,10 @@ export default defineComponent({ eebus() { return store.state?.eebus; }, + optimizerTags(): DeviceTags { + if (!store.state?.optimizer) return { configured: { value: false } }; + return { configured: { value: true } }; + }, modbusproxyTags(): DeviceTags { const config = store.state?.modbusproxy || []; if (config.length > 0) { diff --git a/cmd/root.go b/cmd/root.go index 283cb0173..a1d10adab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -392,6 +392,7 @@ func runRoot(cmd *cobra.Command, args []string) { valueChan <- util.Param{Key: keys.System, Val: util.System()} valueChan <- util.Param{Key: keys.Timezone, Val: time.Now().Format("MST -07:00")} valueChan <- util.Param{Key: keys.Experimental, Val: isExperimental()} + valueChan <- util.Param{Key: keys.Optimizer, Val: isOptimizer()} // run shutdown functions on stop var once sync.Once diff --git a/cmd/setup.go b/cmd/setup.go index c186355b6..e1574da8b 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1315,3 +1315,9 @@ func isExperimental() bool { b, _ := settings.Bool(keys.Experimental) return b } + +// isOptimizer returns if optimizer is enabled +func isOptimizer() bool { + b, _ := settings.Bool(keys.Optimizer) + return b +} diff --git a/core/keys/global.go b/core/keys/global.go index 99420e193..f8fa5eba1 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -28,6 +28,7 @@ const ( SetupRequired = "setupRequired" // initial setup is required (lp = 0), fresh installation Plant = "plant" Telemetry = "telemetry" + Optimizer = "optimizer" DemoMode = "demoMode" AuthDisabled = "authDisabled" AuthProviders = "authProviders" diff --git a/core/site.go b/core/site.go index 2e6d66c2b..0d9ceb0eb 100644 --- a/core/site.go +++ b/core/site.go @@ -767,13 +767,19 @@ func (site *Site) updateMeters() error { return err } - if sponsor.IsAuthorized() { + if sponsor.IsAuthorized() && optimizerEnabled() { go site.optimizerUpdateAsync() } return nil } +func optimizerEnabled() bool { + exp, _ := settings.Bool(keys.Experimental) + opt, _ := settings.Bool(keys.Optimizer) + return exp && opt +} + func (site *Site) updateHomeConsumption(homePower float64) { site.householdEnergy.AddPower(homePower) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index f19c72fb7..b79659067 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -54,6 +54,8 @@ func (r optimizerResult) MarshalBytes() ([]byte, error) { type batteryType string const ( + OPTIMIZER_URI = "https://optimizer.evcc.io" + batteryTypeLoadpoint batteryType = "loadpoint" batteryTypeVehicle batteryType = "vehicle" batteryTypeBattery batteryType = "battery" @@ -127,11 +129,6 @@ func (site *Site) optimizerUpdateAsync() { } func (site *Site) optimizerUpdate(battery []types.Measurement) error { - uri := os.Getenv("OPTIMIZER_URI") - if uri == "" { - return nil - } - solarTariff := site.GetTariff(api.TariffUsageSolar) solar := currentRates(solarTariff) @@ -237,6 +234,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { httpClient := request.NewClient(site.log) httpClient.Timeout = 30 * time.Second + uri := lo.CoalesceOrEmpty(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI) apiClient, err := optimizer.NewClientWithResponses(uri, optimizer.WithHTTPClient(httpClient)) if err != nil { return err diff --git a/i18n/de.json b/i18n/de.json index 844c0de65..4e34cb620 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -599,6 +599,11 @@ "url": "Server-URL", "urlHelp": "Kopiere diese URL in die Konfiguration deiner Wallbox. Details findest du im Handbuch des Herstellers. Die Wallbox sollte automatisch ihre eindeutige Kennung (Station-ID) an die URL anhängen. In seltenen Fällen musst du die Kennung manuell angeben. Beispiel: `{url}`" }, + "optimizer": { + "description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen.", + "enable": "Optimizer aktivieren", + "title": "Optimizer" + }, "options": { "boolean": { "no": "nein", diff --git a/i18n/en.json b/i18n/en.json index bfd017351..f87439e35 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -599,6 +599,11 @@ "url": "Server URL", "urlHelp": "Copy this URL into your charger's configuration. Check the manufacturer's manual for details. The charger is expected to automatically append its unique identifier (station ID) to the url. In rare cases, you may need to manually specify the identifier. Example: `{url}`" }, + "optimizer": { + "description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing.", + "enable": "Enable Optimizer", + "title": "Optimizer" + }, "options": { "boolean": { "no": "no", diff --git a/server/http.go b/server/http.go index aa353b752..4279605d1 100644 --- a/server/http.go +++ b/server/http.go @@ -295,6 +295,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti "updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)}, "deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)}, "experimental": {"POST", "/experimental/{value:[01truefalse]+}", boolHandler(setExperimental(pub), getExperimental)}, + "optimizer": {"POST", "/optimizer/{value:[01truefalse]+}", boolHandler(setOptimizer(pub), getOptimizer)}, } // yaml handlers diff --git a/server/http_config_site_other_handler.go b/server/http_config_site_other_handler.go index b59efff52..8127b9b14 100644 --- a/server/http_config_site_other_handler.go +++ b/server/http_config_site_other_handler.go @@ -10,6 +10,19 @@ import ( "github.com/evcc-io/evcc/util/sponsor" ) +func setOptimizer(pub publisher) func(bool) error { + return func(b bool) error { + settings.SetBool(keys.Optimizer, b) + pub(keys.Optimizer, b) + return nil + } +} + +func getOptimizer() bool { + b, _ := settings.Bool(keys.Optimizer) + return b +} + func setExperimental(pub publisher) func(bool) error { return func(b bool) error { settings.SetBool(keys.Experimental, b) From d5e0d27a35be9f75a517ad944b7a0aa00bb1493d Mon Sep 17 00:00:00 2001 From: Klaas Gadeyne Date: Thu, 2 Apr 2026 21:28:32 +0200 Subject: [PATCH 0240/1580] Goodwe: decode float32 (#28765) --- plugin/aa55udp.go | 11 +++++++++-- plugin/aa55udp_test.go | 10 ++++++++++ templates/definition/meter/goodwe-wifi-et.yaml | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/plugin/aa55udp.go b/plugin/aa55udp.go index fed3063b6..84c95258d 100644 --- a/plugin/aa55udp.go +++ b/plugin/aa55udp.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "math" "net" "strings" "time" @@ -68,9 +69,9 @@ func NewAA55UDPFromConfig(_ context.Context, other map[string]interface{}) (Plug } switch cc.Decode { - case "int32be", "uint32be", "int16be", "uint16be": + case "int32be", "uint32be", "int16be", "uint16be", "float32be": default: - return nil, fmt.Errorf("aa55udp: unsupported decode %q (want int32be|uint32be|int16be|uint16be)", cc.Decode) + return nil, fmt.Errorf("aa55udp: unsupported decode %q (want int32be|uint32be|int16be|uint16be|float32be)", cc.Decode) } pdu := buildPDU(cc.Register, cc.Count) @@ -183,6 +184,12 @@ func stripAA55Header(buf []byte) ([]byte, error) { // interprets it according to decode. func decodeAt(payload []byte, offset int, decode string) (float64, error) { switch decode { + case "float32be": + if len(payload) < offset+4 { + return 0, fmt.Errorf("payload too short for float32be at offset %d (len=%d)", offset, len(payload)) + } + bits := binary.BigEndian.Uint32(payload[offset:]) + return float64(math.Float32frombits(bits)), nil case "int32be": if len(payload) < offset+4 { return 0, fmt.Errorf("payload too short for int32be at offset %d (len=%d)", offset, len(payload)) diff --git a/plugin/aa55udp_test.go b/plugin/aa55udp_test.go index a617a6ffc..22e8759e4 100644 --- a/plugin/aa55udp_test.go +++ b/plugin/aa55udp_test.go @@ -3,6 +3,7 @@ package plugin import ( "encoding/binary" "encoding/hex" + "math" "net" "testing" @@ -153,6 +154,15 @@ func TestDecodeAt_Int16BE_Negative(t *testing.T) { assert.InDelta(t, -300.0, v, 0) } +func TestDecodeAt_Float32BE(t *testing.T) { + payload := make([]byte, 4) + bits := math.Float32bits(123.456) + binary.BigEndian.PutUint32(payload, bits) + v, err := decodeAt(payload, 0, "float32be") + require.NoError(t, err) + assert.InDelta(t, 123.456, v, 0.001) +} + func TestDecodeAt_TooShort(t *testing.T) { _, err := decodeAt([]byte{0x00}, 0, "int32be") require.Error(t, err) diff --git a/templates/definition/meter/goodwe-wifi-et.yaml b/templates/definition/meter/goodwe-wifi-et.yaml index f1ffe2419..46e8a5dbd 100644 --- a/templates/definition/meter/goodwe-wifi-et.yaml +++ b/templates/definition/meter/goodwe-wifi-et.yaml @@ -23,7 +23,7 @@ render: | host: {{ .host }} register: 36017 count: 2 - decode: float32 + decode: float32be scale: 0.001 {{- end }} {{- if eq .usage "pv" }} From d517f92d183aad148c51786d6d220d3801f86959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Fri, 3 Apr 2026 13:21:09 +0200 Subject: [PATCH 0241/1580] Easee: remove incorrect ReasonDisconnectRequired for ModeCompleted (#28781) --- charger/easee.go | 2 -- charger/easee_test.go | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/charger/easee.go b/charger/easee.go index 3bb1ddd41..1e40d5b7f 100644 --- a/charger/easee.go +++ b/charger/easee.go @@ -795,8 +795,6 @@ func (c *Easee) StatusReason() (api.Reason, error) { switch c.opMode { case easee.ModeAwaitingAuthentication: return api.ReasonWaitingForAuthorization, nil - case easee.ModeCompleted: - return api.ReasonDisconnectRequired, nil } return api.ReasonUnknown, nil } diff --git a/charger/easee_test.go b/charger/easee_test.go index a2a91162b..ab4e1e33b 100644 --- a/charger/easee_test.go +++ b/charger/easee_test.go @@ -224,7 +224,7 @@ func TestEasee_StatusReason(t *testing.T) { expectedReason api.Reason }{ {easee.ModeAwaitingAuthentication, api.ReasonWaitingForAuthorization}, - {easee.ModeCompleted, api.ReasonDisconnectRequired}, + {easee.ModeCompleted, api.ReasonUnknown}, {easee.ModeDisconnected, api.ReasonUnknown}, {easee.ModeAwaitingStart, api.ReasonUnknown}, {easee.ModeCharging, api.ReasonUnknown}, From d42c79932bbff0d522d4f816fe8f221e30a42888 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 3 Apr 2026 14:02:03 +0200 Subject: [PATCH 0242/1580] Goodwe: restore wifi implementation (#28759) --- .../definition/meter/goodwe-wifi-es.yaml | 2 +- templates/definition/meter/goodwe-wifi.yaml | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 templates/definition/meter/goodwe-wifi.yaml diff --git a/templates/definition/meter/goodwe-wifi-es.yaml b/templates/definition/meter/goodwe-wifi-es.yaml index d07cc40f2..668a84770 100644 --- a/templates/definition/meter/goodwe-wifi-es.yaml +++ b/templates/definition/meter/goodwe-wifi-es.yaml @@ -1,5 +1,5 @@ template: goodwe-wifi-es -covers: ["goodwe-wifi"] +deprecated: true requirements: evcc: ["skiptest"] products: diff --git a/templates/definition/meter/goodwe-wifi.yaml b/templates/definition/meter/goodwe-wifi.yaml new file mode 100644 index 000000000..e262ffa3f --- /dev/null +++ b/templates/definition/meter/goodwe-wifi.yaml @@ -0,0 +1,19 @@ +template: goodwe-wifi +# UDP implementation is broken +# deprecated: true +products: + - brand: GoodWe + description: + generic: ES/EM Hybrid Inverter (WiFi) +requirements: + evcc: ["skiptest"] +params: + - name: usage + choice: ["grid", "pv", "battery"] + - name: host + - name: uri + deprecated: true +render: | + type: goodwe-wifi + usage: {{ .usage }} + uri: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} From 59c49e7ce12c3dbac770bf5be419047ba753f5b9 Mon Sep 17 00:00:00 2001 From: Mathias Walter Date: Fri, 3 Apr 2026 14:47:52 +0200 Subject: [PATCH 0243/1580] Solarprognose.de: fix gaps and undeprecate (#28642) --- .../definition/tariff/solarprognose.yaml | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/templates/definition/tariff/solarprognose.yaml b/templates/definition/tariff/solarprognose.yaml index f5d171d95..00103c9eb 100644 --- a/templates/definition/tariff/solarprognose.yaml +++ b/templates/definition/tariff/solarprognose.yaml @@ -1,5 +1,4 @@ template: solarprognose -deprecated: true products: - brand: Solarprognose requirements: @@ -13,7 +12,7 @@ params: description: en: Access Token from User Profile de: Zugriffs-Schlüssel aus Benutzer-Profil - advanced: true + required: true - name: item choice: ["location", "plant", "inverter", "module_field"] description: @@ -39,20 +38,55 @@ params: en: Forecasting Algorithm (mosmix, own-v1 or clearsky) de: Prognosealgorithmus (mosmix, own-v1 oder clearsky) advanced: true + - name: forecast_days + description: + en: Number of additional days to forecast (1-6) + de: Anzahl zusätzlicher Tage für die Prognose (1-6) + help: + en: "Number of additional days to forecast (1-6), excluding today. Remember: evcc calculates energy for today, tomorrow, and the day after tomorrow, effectively handling up to three days of solar forecast data, but the UI shows up to 96 hours." + de: "Anzahl der zusätzlichen Tage für die Prognose (1-6), ohne den heutigen Tag. Bedenke: evcc berechnet die Energie für heute, morgen und übermorgen und verarbeitet dabei effektiv bis zu drei Tage Solarprognosedaten, zeigt jedoch bis zu 96 Stunden an." + default: 4 + advanced: true - name: interval default: 1h advanced: true render: | type: custom tariff: solar + features: ["cacheable"] forecast: source: http - # Base URL with required params (format, type, algorithm, access token), then optional item-based parameters - uri: https://www.solarprognose.de/web/solarprediction/api/v1?_format=json&type=hourly{{ if .algorithm }}&algorithm={{ .algorithm }}{{ end }}&access-token={{ .token }}{{ if .item }}&item={{ .item }}{{ if .id }}&id={{ .id }}{{ else }}&token={{ unquote .uniquetoken }}{{ end }}{{ end }} + # Base URL with required params (format, type, access token), then optional item-based parameters + uri: https://www.solarprognose.de/web/solarprediction/api/v1?_format=json&type=hourly&project=https://evcc.io&access-token={{ .token }}{{ if .item }}&item={{ .item }}{{ if .id }}&id={{ .id }}{{ else }}&token={{ unquote .uniquetoken }}{{ end }}{{ end }}{{ if .algorithm }}&algorithm={{ .algorithm }}{{ end }}{{ if .forecast_days }}&end_day={{ .forecast_days }}{{ end }} jq: | - [ .data | to_entries.[] | { - "start": (.key | tonumber | strftime("%FT%TZ") ), - "end": (.key | tonumber + 3600 | strftime("%FT%TZ") ) , - "value": (.value.[0]*1000) # returned value unit is always kW - } ] | tostring + def step: 3600; # hourly interval + + def lookup: + (.data // {}) + | if type == "object" and length > 0 then + to_entries + | map({key, value: (.value[0] * 1000)}) + | from_entries + else + {} + end; + + . as $root + | ($root | lookup) as $L + | ($L | keys | map(tonumber) | sort) as $ts + | ($ts[0]) as $min + | ($ts[-1]) as $max + | reduce range($min; $max + step; step) as $t + ( { last: null, out: [] }; + ($L[($t|tostring)]?) as $v + | .last = (if $v == null then .last else $v end) + | .out += + [ { + start: ($t | strftime("%FT%TZ")), + end: (($t + step) | strftime("%FT%TZ")), + value: (.last // 0) + } ] + ) + | .out + | tostring interval: {{ .interval }} From 72e6e2cbcfb95ca18f1fed6b9df8c6993ad1a33b Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 3 Apr 2026 15:20:23 +0200 Subject: [PATCH 0244/1580] MG: add trace logging for decoded API responses (#28790) --- vehicle/saic/api.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vehicle/saic/api.go b/vehicle/saic/api.go index e0258c0f2..1412d0821 100644 --- a/vehicle/saic/api.go +++ b/vehicle/saic/api.go @@ -127,6 +127,8 @@ func doRequest[T any](v *API, req *http.Request, result *requests.Answer[T]) (st return event_id, fmt.Errorf("decrypt: %w", err2) } + v.log.TRACE.Printf("recv: %s", body) + if err2 := json.Unmarshal(body, result); err2 == nil && result.Code != 0 { if result.Code == 4 { err = api.ErrMustRetry From 7ec10e0b30ead7e9e371e17124e1dad3bc0b8209 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 3 Apr 2026 16:15:26 +0200 Subject: [PATCH 0245/1580] Goodwe: use legacy wifi for ET series (#28791) --- templates/definition/meter/goodwe-wifi-es.yaml | 1 - templates/definition/meter/goodwe-wifi-et.yaml | 2 +- templates/definition/meter/goodwe-wifi.yaml | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/templates/definition/meter/goodwe-wifi-es.yaml b/templates/definition/meter/goodwe-wifi-es.yaml index 668a84770..056b36a9c 100644 --- a/templates/definition/meter/goodwe-wifi-es.yaml +++ b/templates/definition/meter/goodwe-wifi-es.yaml @@ -1,5 +1,4 @@ template: goodwe-wifi-es -deprecated: true requirements: evcc: ["skiptest"] products: diff --git a/templates/definition/meter/goodwe-wifi-et.yaml b/templates/definition/meter/goodwe-wifi-et.yaml index 46e8a5dbd..f2c8bb44f 100644 --- a/templates/definition/meter/goodwe-wifi-et.yaml +++ b/templates/definition/meter/goodwe-wifi-et.yaml @@ -4,7 +4,7 @@ requirements: products: - brand: GoodWe description: - generic: ET/EH/BT/BH Hybrid Inverter (WiFi) + generic: EH/BT/BH Hybrid Inverter (WiFi) params: - name: usage choice: ["grid", "pv", "battery"] diff --git a/templates/definition/meter/goodwe-wifi.yaml b/templates/definition/meter/goodwe-wifi.yaml index e262ffa3f..3bd8eab9b 100644 --- a/templates/definition/meter/goodwe-wifi.yaml +++ b/templates/definition/meter/goodwe-wifi.yaml @@ -4,7 +4,7 @@ template: goodwe-wifi products: - brand: GoodWe description: - generic: ES/EM Hybrid Inverter (WiFi) + generic: ET Hybrid Inverter (WiFi) requirements: evcc: ["skiptest"] params: From 8dd9333003bb243567a5039fc742cc16f95856ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:17:09 +0200 Subject: [PATCH 0246/1580] chore(deps-dev): bump lodash from 4.17.23 to 4.18.1 (#28772) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ddddc4d98..ae50d2813 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8581,9 +8581,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, From 764aa45d8c46bca6a196cba0cabb84aaeb9d9875 Mon Sep 17 00:00:00 2001 From: Andreas Linde <42185+DerAndereAndi@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:23:27 +0200 Subject: [PATCH 0247/1580] EEBus: always send OPEV and OSCEV limits together (#28761) --- charger/eebus.go | 188 ++++-------------- charger/eebus_test.go | 170 ++++++++++++++++ templates/definition/charger/elli-2.yaml | 1 - .../definition/charger/porsche-pmcc.yaml | 1 - .../definition/charger/porsche-wallbox.yaml | 1 - 5 files changed, 204 insertions(+), 157 deletions(-) diff --git a/charger/eebus.go b/charger/eebus.go index e3a4bb146..41bc3f81e 100644 --- a/charger/eebus.go +++ b/charger/eebus.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "slices" "sync" "time" @@ -13,7 +12,6 @@ import ( "github.com/enbility/eebus-go/usecases/cem/evcc" "github.com/enbility/eebus-go/usecases/cem/evcem" spineapi "github.com/enbility/spine-go/api" - "github.com/enbility/spine-go/model" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/server/eebus" @@ -41,7 +39,6 @@ type EEBus struct { limitUpdated time.Time // time of last limit change - vasVW bool // wether the EVSE supports VW VAS with ISO15118-2 enabled bool reconnect bool current float64 @@ -60,7 +57,6 @@ func NewEEBusFromConfig(ctx context.Context, other map[string]any) (api.Charger, Ip string Meter bool ChargedEnergy *bool - VasVW bool } if err := util.DecodeOther(other, &cc); err != nil { @@ -70,13 +66,13 @@ func NewEEBusFromConfig(ctx context.Context, other map[string]any) (api.Charger, // default true hasChargedEnergy := cc.ChargedEnergy != nil && *cc.ChargedEnergy - return NewEEBus(ctx, cc.Ski, cc.Ip, cc.Meter, hasChargedEnergy, cc.VasVW) + return NewEEBus(ctx, cc.Ski, cc.Ip, cc.Meter, hasChargedEnergy) } //go:generate go tool decorate -f decorateEEBus -b *EEBus -r api.Charger -t api.Meter,api.PhaseCurrents,api.ChargeRater // NewEEBus creates EEBus charger -func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy, vasVW bool) (api.Charger, error) { +func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy bool) (api.Charger, error) { if eebus.Instance == nil { return nil, errors.New("eebus not configured") } @@ -84,7 +80,6 @@ func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy, v c := &EEBus{ log: util.NewLogger("eebus"), current: 6, - vasVW: vasVW, cem: eebus.Instance.CustomerEnergyManagement(), } @@ -239,24 +234,6 @@ func (c *EEBus) Enabled() (bool, error) { return c.enabled, nil } - // if the VW VAS PV mode is active, use PV limits - if c.hasActiveVASVW(evEntity) { - limits, err := c.cem.OscEV.LoadControlLimits(evEntity) - if err != nil { - // there are no limits available, e.g. because the data was not received yet - return c.enabled, nil - } - - for _, limit := range limits { - // check if there is an active limit set - if limit.IsActive && limit.Value >= 1 { - return true, nil - } - } - - return false, nil - } - limits, err := c.cem.OpEV.LoadControlLimits(evEntity) if err != nil { // there are no limits available, e.g. because the data was not received yet @@ -319,7 +296,7 @@ func (c *EEBus) writeCurrentLimitData(evEntity spineapi.EntityRemoteInterface, c c.log.DEBUG.Println("no limits from the EVSE are provided:", err) } - // setup the limit data structure + // setup the obligation limit data structure var limits []ucapi.LoadLimitsPhase for phase := range len(ucapi.PhaseNameMapping) { limit := ucapi.LoadLimitsPhase{ @@ -336,157 +313,60 @@ func (c *EEBus) writeCurrentLimitData(evEntity spineapi.EntityRemoteInterface, c limits = append(limits, limit) } - // if VAS VW is available, limits are completely covered by it - // this way evcc can fully control the charging behavior - if c.writeLoadControlLimitsVASVW(evEntity, limits) { - c.mux.Lock() - defer c.mux.Unlock() - - c.limitUpdated = time.Now() - return nil + // always set overload protection limits (obligation) + if _, err := c.cem.OpEV.WriteLoadControlLimits(evEntity, limits, nil); err != nil { + return err } - // make sure the recommendations are inactive, otherwise the EV won't go to sleep - // but only if it supports OSCEV and has required data! - if c.cem.OscEV.IsScenarioAvailableAtEntity(evEntity, 1) { - if _, err := c.cem.OscEV.LoadControlLimits(evEntity); err == nil { - if err := c.disableLimits(evEntity, c.cem.OscEV); err != nil { - return err - } - } - } + // additionally set self-consumption recommendation limits if available + c.writeOscevLimits(evEntity, current) - // set overload protection limits - _, err = c.cem.OpEV.WriteLoadControlLimits(evEntity, limits, nil) - if err == nil { - c.mux.Lock() - defer c.mux.Unlock() + c.mux.Lock() + defer c.mux.Unlock() - c.limitUpdated = time.Now() - } + c.limitUpdated = time.Now() - return err + return nil } -// returns if the connected EV has an active VW PV mode -// in this mode, the EV does not have an active charging demand -func (c *EEBus) hasActiveVASVW(evEntity spineapi.EntityRemoteInterface) bool { - // EVSE has to support VW VAS - if !c.vasVW { - return false - } - - // ISO15118-2 has to be used between EVSE and EV - if comStandard, err := c.cem.EvCC.CommunicationStandard(evEntity); err != nil || comStandard != model.DeviceConfigurationKeyValueStringTypeISO151182ED2 { - return false - } - - // SoC has to be available, otherwise it is plain ISO15118-2 - // SoC has to be >= 25%, because the Taycan can't be setup with a Min SoC below 25%, oherwise obligations have to be used - if soc, err := c.Soc(); err != nil || soc < 25 { - return false - } - - // Optimization of self consumption use case support has to be available - if !c.cem.EvSoc.IsScenarioAvailableAtEntity(evEntity, 1) { - return false - } - - // the use case has to be reported as active - // only then the EV has no active charging demand and will charge based on OSCEV recommendations - // this is a workaround for EVSE changing isActive to false, even though they should - // not announce the use case at all in that case - for _, uci := range evEntity.Device().UseCases() { - // check if the referenced entity address is identical to the ev entity address - // the address may not exist, as it only available since SPINE 1.3 - if uci.Address != nil && - evEntity.Address() != nil && - slices.Compare(uci.Address.Entity, evEntity.Address().Entity) != 0 { - continue - } - - for _, uc := range uci.UseCaseSupport { - if uc.UseCaseName != nil && *uc.UseCaseName == model.UseCaseNameTypeOptimizationOfSelfConsumptionDuringEVCharging && - uc.UseCaseAvailable != nil && *uc.UseCaseAvailable { - return true - } - } - } - - return false -} - -// provides support for the special VW VAS ISO15118-2 charging behavior if supported -// will return false if it isn't supported or successful -// -// this functionality allows to fully control charging without the EV actually having a -// charging demand by itself -func (c *EEBus) writeLoadControlLimitsVASVW(evEntity spineapi.EntityRemoteInterface, limits []ucapi.LoadLimitsPhase) bool { - if !c.hasActiveVASVW(evEntity) { - return false - } - - // check if the EVSE supports optimization of self consumption limits +// writeOscevLimits writes OSCEV recommendation limits if the use case is available. +// An active recommendation triggers the EV to charge with surplus energy. +// An inactive recommendation is equivalent to no recommendation existing. +func (c *EEBus) writeOscevLimits(evEntity spineapi.EntityRemoteInterface, current float64) { if !c.cem.OscEV.IsScenarioAvailableAtEntity(evEntity, 1) { - return false + return } // OSCEV requires recommendation limits to be available if _, err := c.cem.OscEV.LoadControlLimits(evEntity); err != nil { - return false + return } - // on OSCEV all limits have to be active except they are set to the default value minLimits, _, _, err := c.cem.OscEV.CurrentLimits(evEntity) if err != nil { - return false + return } - for index, item := range limits { - // if the limit is equal or bigger than the min allowed, then the recommendation limit is active, otherwise it is not - limits[index].IsActive = false - if index < len(minLimits) { - limits[index].IsActive = item.Value >= minLimits[index] + var limits []ucapi.LoadLimitsPhase + for phase := range len(ucapi.PhaseNameMapping) { + limit := ucapi.LoadLimitsPhase{ + Phase: ucapi.PhaseNameMapping[phase], + IsActive: false, + Value: current, } + + // below min charging current there is nothing to recommend + // in contrast to OPEV the max value has to be active to trigger the recommendation to have any effect + if phase < len(minLimits) { + limit.IsActive = current >= minLimits[phase] + } + + limits = append(limits, limit) } - // set recommendation limits if _, err := c.cem.OscEV.WriteLoadControlLimits(evEntity, limits, nil); err != nil { - return false + c.log.DEBUG.Println("failed to write OSCEV limits:", err) } - - if err := c.disableLimits(evEntity, c.cem.OpEV); err != nil { - return false - } - - return true -} - -type eebusLimitController interface { - LoadControlLimits(spineapi.EntityRemoteInterface) ([]ucapi.LoadLimitsPhase, error) - WriteLoadControlLimits(spineapi.EntityRemoteInterface, []ucapi.LoadLimitsPhase, func(result model.ResultDataType)) (*model.MsgCounterType, error) -} - -// make sure the limits are inactive, otherwise the EV won't go to sleep -func (c *EEBus) disableLimits(evEntity spineapi.EntityRemoteInterface, uc eebusLimitController) error { - limits, err := uc.LoadControlLimits(evEntity) - if err != nil { - return err - } - - var writeNeeded bool - for index, item := range limits { - if item.IsActive { - limits[index].IsActive = false - writeNeeded = true - } - } - - if writeNeeded { - _, err = uc.WriteLoadControlLimits(evEntity, limits, nil) - } - - return err } // MaxCurrent implements the api.Charger interface diff --git a/charger/eebus_test.go b/charger/eebus_test.go index 631c92350..434df0375 100644 --- a/charger/eebus_test.go +++ b/charger/eebus_test.go @@ -5,9 +5,12 @@ import ( "testing" "time" + ucapi "github.com/enbility/eebus-go/usecases/api" evcemuc "github.com/enbility/eebus-go/usecases/cem/evcem" "github.com/enbility/eebus-go/usecases/mocks" spinemocks "github.com/enbility/spine-go/mocks" + "github.com/enbility/spine-go/model" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/server/eebus" "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/assert" @@ -103,6 +106,173 @@ func TestEEBusNoCurrents(t *testing.T) { assert.Equal(t, 10.4, l3) } +// newTestEEBus creates an EEBus instance with all mocks wired up for limit writing tests. +func newTestEEBus(t *testing.T) (*EEBus, *mocks.CemOPEVInterface, *mocks.CemOSCEVInterface, *spinemocks.EntityRemoteInterface) { + t.Helper() + + opev := mocks.NewCemOPEVInterface(t) + oscev := mocks.NewCemOSCEVInterface(t) + evEntity := spinemocks.NewEntityRemoteInterface(t) + + eebus := &EEBus{ + cem: &eebus.CustomerEnergyManagement{ + OpEV: opev, + OscEV: oscev, + }, + ev: evEntity, + log: util.NewLogger("test"), + } + + return eebus, opev, oscev, evEntity +} + +// 3-phase limits helper +func opevLimits3p(min, max, def float64) ([]float64, []float64, []float64, error) { + return []float64{min, min, min}, []float64{max, max, max}, []float64{def, def, def}, nil +} + +func TestWriteCurrentLimitData_OpevOnly(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + _ = eebus + + // OPEV available, OSCEV not available + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 10 + }), mock.Anything).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(false) + + err := eebus.writeCurrentLimitData(evEntity, 10) + require.NoError(t, err) +} + +func TestWriteCurrentLimitData_OpevAndOscev(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + _ = eebus + + // Both available, current = 10A (between min and max) + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OPEV: active at 10A (below max of 16) + return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 10 + }), mock.Anything).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + oscev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{}, nil) + oscev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(2, 16, 0)) + oscev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OSCEV: active at 10A (>= min of 2, recommendation to charge) + return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 10 + }), mock.Anything).Return(nil, nil) + + err := eebus.writeCurrentLimitData(evEntity, 10) + require.NoError(t, err) +} + +func TestWriteCurrentLimitData_AtMax(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + _ = eebus + + // Current equals max limit + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OPEV: inactive at max (no restriction needed) + return len(limits) == 3 && !limits[0].IsActive && limits[0].Value == 16 + }), mock.Anything).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + oscev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{}, nil) + oscev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(2, 16, 0)) + oscev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OSCEV: active at 16A (>= min, recommend charging) + return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 16 + }), mock.Anything).Return(nil, nil) + + err := eebus.writeCurrentLimitData(evEntity, 16) + require.NoError(t, err) +} + +func TestWriteCurrentLimitData_Disable(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + _ = eebus + + // Current = 0 (disable charging) + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OPEV: active at 0A (hard stop) + return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 0 + }), mock.Anything).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + oscev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{}, nil) + oscev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(2, 16, 0)) + oscev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OSCEV: inactive at 0A (no recommendation, < min) + return len(limits) == 3 && !limits[0].IsActive && limits[0].Value == 0 + }), mock.Anything).Return(nil, nil) + + err := eebus.writeCurrentLimitData(evEntity, 0) + require.NoError(t, err) +} + +func TestWriteCurrentLimitData_OscevNoLimitData(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + _ = eebus + + // OSCEV scenario available but no limit data (e.g. PCMP wallbox) + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.Anything, mock.Anything).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + oscev.EXPECT().LoadControlLimits(evEntity).Return(nil, errors.New("data not available")) + // no WriteLoadControlLimits call expected for OSCEV + + err := eebus.writeCurrentLimitData(evEntity, 10) + require.NoError(t, err) +} + +func TestWriteCurrentLimitData_OpevNotAvailable(t *testing.T) { + eebus, opev, _, evEntity := newTestEEBus(t) + _ = eebus + + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(false) + + err := eebus.writeCurrentLimitData(evEntity, 10) + require.ErrorIs(t, err, api.ErrNotAvailable) +} + +func TestEnabledAlwaysReadsOpev(t *testing.T) { + evcc := mocks.NewCemEVCCInterface(t) + opev := mocks.NewCemOPEVInterface(t) + + evEntity := spinemocks.NewEntityRemoteInterface(t) + eebus := &EEBus{ + cem: &eebus.CustomerEnergyManagement{ + EvCC: evcc, + OpEV: opev, + }, + ev: evEntity, + log: util.NewLogger("test"), + } + + evcc.EXPECT().EVConnected(evEntity).Return(true) + opev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{ + {Phase: model.ElectricalConnectionPhaseNameTypeA, IsActive: true, Value: 10}, + {Phase: model.ElectricalConnectionPhaseNameTypeB, IsActive: true, Value: 10}, + {Phase: model.ElectricalConnectionPhaseNameTypeC, IsActive: true, Value: 10}, + }, nil) + + enabled, err := eebus.Enabled() + require.NoError(t, err) + assert.True(t, enabled) +} + func TestEEBusIsCharging(t *testing.T) { type limitStruct struct { min, max, pause float64 diff --git a/templates/definition/charger/elli-2.yaml b/templates/definition/charger/elli-2.yaml index 5c1149ba1..d70a53ccf 100644 --- a/templates/definition/charger/elli-2.yaml +++ b/templates/definition/charger/elli-2.yaml @@ -57,4 +57,3 @@ params: render: | {{ include "eebus" . }} meter: true - vasvw: true diff --git a/templates/definition/charger/porsche-pmcc.yaml b/templates/definition/charger/porsche-pmcc.yaml index 05a766b82..9d1c84b3e 100644 --- a/templates/definition/charger/porsche-pmcc.yaml +++ b/templates/definition/charger/porsche-pmcc.yaml @@ -11,4 +11,3 @@ params: render: | {{ include "eebus" . }} meter: true - vasvw: true diff --git a/templates/definition/charger/porsche-wallbox.yaml b/templates/definition/charger/porsche-wallbox.yaml index 1c24d16e0..ebe7c4d9b 100644 --- a/templates/definition/charger/porsche-wallbox.yaml +++ b/templates/definition/charger/porsche-wallbox.yaml @@ -24,4 +24,3 @@ params: render: | {{ include "eebus" . }} meter: true - vasvw: true From bb07ec0d4d17f189a4f74893d250582b34ce7019 Mon Sep 17 00:00:00 2001 From: RenatusRo <126881912+RenatusRo@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:52:13 +0200 Subject: [PATCH 0248/1580] Optimizer: fix timestamp generation (#28677) --- .../Optimize/TimeSeriesDataTable.vue | 19 +++++++------ assets/js/views/Optimize.vue | 2 +- core/site_optimizer.go | 27 +++++++++---------- core/site_optimizer_test.go | 20 ++++++++++++++ 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/assets/js/components/Optimize/TimeSeriesDataTable.vue b/assets/js/components/Optimize/TimeSeriesDataTable.vue index 796c96a97..2bf0d63d6 100644 --- a/assets/js/components/Optimize/TimeSeriesDataTable.vue +++ b/assets/js/components/Optimize/TimeSeriesDataTable.vue @@ -218,9 +218,9 @@ export default defineComponent({ type: Array as PropType, required: true, }, - timestamp: { - type: String, - default: "", + timestamps: { + type: Array as PropType, + default: () => [], }, currency: { type: String as PropType, @@ -262,13 +262,16 @@ export default defineComponent({ return (seconds / 3600).toFixed(2); }, formatHour(index: number): string { - // Show label only every 4th slot (every hour for 15-minute slots) - if (index % 4 !== 0) { + if (this.timestamps.length <= index) { return ""; } - const startTime = new Date(this.timestamp); - const currentTime = new Date(startTime.getTime() + index * 15 * 60 * 1000); // Add 15-minute intervals - return currentTime.getHours().toString(); + // Show label on fresh hour or on first quarter hour for slot 0 + const ts = new Date(this.timestamps[index]); + const minutes = ts.getMinutes(); + if (minutes === 0 || (index === 0 && minutes < 15)) { + return ts.getHours().toString(); + } + return ""; }, getBatteryTitle(index: number): string { const detail = this.batteryDetails[index]; diff --git a/assets/js/views/Optimize.vue b/assets/js/views/Optimize.vue index 3a133b713..77126ec81 100644 --- a/assets/js/views/Optimize.vue +++ b/assets/js/views/Optimize.vue @@ -82,7 +82,7 @@ time.Second && d < tariff.SlotDuration { + eos := now.Truncate(tariff.SlotDuration).Add(tariff.SlotDuration) + if d := eos.Sub(now); d > time.Second && d < tariff.SlotDuration { res = append(res, int(d.Seconds())) } @@ -625,13 +621,16 @@ func timeSteps(minLen int) []int { return res } -func asTimestamps(dt []int) []time.Time { +func asTimestamps(dt []int, now time.Time) []time.Time { res := make([]time.Time, 0, len(dt)) - eoh := endOfHour(time.Now()) - res = append(res, eoh.Add(-time.Duration(dt[0])*time.Second)) + + eos := now.Truncate(tariff.SlotDuration).Add(tariff.SlotDuration) + res = append(res, eos.Add(-time.Duration(dt[0])*time.Second)) + for i := range len(dt) - 1 { - res = append(res, eoh.Add(time.Duration(dt[i+1])*time.Second)) + res = append(res, res[i].Add(time.Duration(dt[i])*time.Second)) } + return res } diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 70fda2362..ca8e86146 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -111,6 +111,26 @@ func TestLoadpointProfile(t *testing.T) { require.Equal(t, []float64{250, 250, 250, 250, 250, 250, 250, 50}, loadpointProfile(lp, 8)) } +func TestAsTimestamps(t *testing.T) { + // now is 10 minutes into a 15-minute slot + now := time.Date(2025, 1, 1, 12, 10, 0, 0, time.UTC) + + // dt[0]=300 means first event is 300s (5min) before end of current slot + // dt[1..] just mark subsequent slot boundaries + dt := []int{60 * 5, 60 * 15, 60 * 15} + + got := asTimestamps(dt, now) + + // current slot: 12:00–12:15 + // first timestamp: 12:15 - 5min = 12:10 + // subsequent: 12:15, 12:30 + assert.Equal(t, []time.Time{ + time.Date(2025, 1, 1, 12, 10, 0, 0, time.UTC), + time.Date(2025, 1, 1, 12, 15, 0, 0, time.UTC), + time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC), + }, got) +} + func TestBatteryForecastTotals(t *testing.T) { site := new(Site) From 3c10d114afd874b4d7ca500a8b863fa3a3b5e44b Mon Sep 17 00:00:00 2001 From: VolkerK62 <99199865+VolkerK62@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:16:07 +0200 Subject: [PATCH 0249/1580] DaheimLaden: add firmware update handling (#28758) --- charger/daheimladen.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/charger/daheimladen.go b/charger/daheimladen.go index d745b7006..d30baeb96 100644 --- a/charger/daheimladen.go +++ b/charger/daheimladen.go @@ -188,6 +188,8 @@ func (wb *DaheimLaden) Status() (api.ChargeStatus, error) { return api.StatusB, nil case 6: // Session Terminated by EVSE return api.StatusB, nil + case 9: // Firmware Update + return api.StatusA, nil default: // Other return api.StatusNone, fmt.Errorf("invalid status: %d", s) } From 70b17d09d72b3085a9df20c32f6b4eb43f6afc77 Mon Sep 17 00:00:00 2001 From: Andreas Linde <42185+DerAndereAndi@users.noreply.github.com> Date: Sat, 4 Apr 2026 10:19:40 +0200 Subject: [PATCH 0250/1580] Add GhostEEBus charger (#28573) --- charger/eebus.go | 17 +- charger/ghosteebus.go | 218 +++++++++ charger/ghosteebus_decorators.go | 89 ++++ charger/ghosteebus_test.go | 422 ++++++++++++++++++ charger/ghostone/identity.go | 93 ++++ charger/ghostone/types.go | 44 ++ core/loadpoint.go | 2 +- templates/definition/charger/elli-2.yaml | 30 +- templates/definition/charger/ghost.yaml | 30 +- .../definition/charger/porsche-wallbox.yaml | 30 +- 10 files changed, 941 insertions(+), 34 deletions(-) create mode 100644 charger/ghosteebus.go create mode 100644 charger/ghosteebus_decorators.go create mode 100644 charger/ghosteebus_test.go create mode 100644 charger/ghostone/identity.go create mode 100644 charger/ghostone/types.go diff --git a/charger/eebus.go b/charger/eebus.go index 41bc3f81e..269cdac84 100644 --- a/charger/eebus.go +++ b/charger/eebus.go @@ -64,15 +64,16 @@ func NewEEBusFromConfig(ctx context.Context, other map[string]any) (api.Charger, } // default true - hasChargedEnergy := cc.ChargedEnergy != nil && *cc.ChargedEnergy + hasChargedEnergy := cc.ChargedEnergy == nil || *cc.ChargedEnergy return NewEEBus(ctx, cc.Ski, cc.Ip, cc.Meter, hasChargedEnergy) } //go:generate go tool decorate -f decorateEEBus -b *EEBus -r api.Charger -t api.Meter,api.PhaseCurrents,api.ChargeRater -// NewEEBus creates EEBus charger -func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy bool) (api.Charger, error) { +// newEEBus creates and initializes a raw *EEBus charger. +// It registers the device with the EEBus instance and waits for the connection. +func newEEBus(ctx context.Context, ski, ip string) (*EEBus, error) { if eebus.Instance == nil { return nil, errors.New("eebus not configured") } @@ -101,6 +102,16 @@ func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy bo eebus.Instance.UnregisterDevice(ski, c) }() + return c, nil +} + +// NewEEBus creates EEBus charger +func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy bool) (api.Charger, error) { + c, err := newEEBus(ctx, ski, ip) + if err != nil { + return nil, err + } + if hasMeter { var energyG func() (float64, error) if hasChargedEnergy { diff --git a/charger/ghosteebus.go b/charger/ghosteebus.go new file mode 100644 index 000000000..31e6f2991 --- /dev/null +++ b/charger/ghosteebus.go @@ -0,0 +1,218 @@ +package charger + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/enbility/spine-go/model" + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/charger/ghostone" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" + "golang.org/x/oauth2" +) + +// GhostEEBus charger implementation combining EEBus protocol for EV communication +// with Ghost platform REST API for phase switching and RFID identification. +type GhostEEBus struct { + *EEBus + *request.Helper + uri string // REST API base URL, e.g. "https://10.0.1.30/api/v2" + hasRFID bool +} + +func init() { + registry.AddCtx("ghosteebus", NewGhostEEBusFromConfig) +} + +// NewGhostEEBusFromConfig creates a GhostEEBus charger from generic config +func NewGhostEEBusFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) { + var cc struct { + Ski string + Ip string + Meter bool + ChargedEnergy *bool + User string + Password string + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + // default true + hasChargedEnergy := cc.ChargedEnergy == nil || *cc.ChargedEnergy + + return NewGhostEEBus(ctx, cc.Ski, cc.Ip, cc.User, cc.Password, cc.Meter, hasChargedEnergy) +} + +//go:generate go tool decorate -f decorateGhostEEBus -b *GhostEEBus -r api.Charger -t api.Meter,api.PhaseCurrents,api.ChargeRater,api.PhaseSwitcher,api.PhaseGetter + +// NewGhostEEBus creates a GhostEEBus charger combining EEBus with Ghost REST API +func NewGhostEEBus(ctx context.Context, ski, ip, user, password string, hasMeter, hasChargedEnergy bool) (api.Charger, error) { + eb, err := newEEBus(ctx, ski, ip) + if err != nil { + return nil, err + } + + log := util.NewLogger("ghost-eebus").Redact(user, password) + + uri := "https://" + ip + "/api/v2" + + wb := &GhostEEBus{ + EEBus: eb, + Helper: request.NewHelper(log), + uri: uri, + } + + // REST API features require IP and credentials + var phasesS func(int) error + var phasesG func() (int, error) + + if ip != "" && user != "" && password != "" { + ts, err := ghostone.TokenSource(log, wb.uri, user, password) + if err != nil { + return nil, err + } + + wb.Client.Transport = &oauth2.Transport{ + Source: ts, + Base: transport.Insecure(), + } + + // warn if PV optimization is active + var pvMode ghostone.PvOptimizationMode + if err := wb.GetJSON(wb.uri+"/charging/pvoptimization/mode", &pvMode); err == nil && pvMode.Value != ghostone.PvModeNone { + log.WARN.Printf("wallbox PV optimization is active (%s), should be disabled when using evcc", pvMode.Value) + } + + // warn if phase switching is disabled + var relaisEnabled ghostone.Enabled + if err := wb.GetJSON(wb.uri+"/system/relais-switch/enabled", &relaisEnabled); err == nil && !relaisEnabled.Enabled { + log.WARN.Println("phase switching is disabled, enable it in the wallbox settings to use 1p3p switching") + } + + // always wire up phase switching and RFID - the wallbox will reject + // operations at runtime if the feature is disabled or not possible + phasesS = wb.phases1p3p + phasesG = wb.getPhases + wb.hasRFID = true + } + + // EEBus meter capabilities + var meter func() (float64, error) + var phaseCurrents func() (float64, float64, float64, error) + var chargeRater func() (float64, error) + + if hasMeter { + meter = eb.currentPower + phaseCurrents = eb.currents + if hasChargedEnergy { + chargeRater = eb.chargedEnergy + } + } + + return decorateGhostEEBus(wb, meter, phaseCurrents, chargeRater, phasesS, phasesG), nil +} + +var _ api.Identifier = (*GhostEEBus)(nil) + +// Identify implements api.Identifier, preferring RFID over EEBUS identification +func (wb *GhostEEBus) Identify() (string, error) { + if wb.hasRFID { + if id, err := wb.identify(); err == nil && id != "" { + return id, nil + } + } + return wb.EEBus.Identify() +} + +// putJSON sends a PUT request with JSON body to the REST API. +func (wb *GhostEEBus) putJSON(url string, data any) error { + req, err := request.New(http.MethodPut, url, request.MarshalJSON(data), request.JSONEncoding) + if err == nil { + _, err = wb.DoBody(req) + } + return err +} + +// phases1p3p implements phase switching via REST API. +// Returns api.ErrNotAvailable when the EV communicates via ISO 15118, +// as relay switching would violate the high-level power contract. +func (wb *GhostEEBus) phases1p3p(phases int) error { + evEntity, connected := wb.EEBus.isEvConnected() + if connected { + comStandard, err := wb.EEBus.cem.EvCC.CommunicationStandard(evEntity) + if err != nil { + return api.ErrNotAvailable + } + if comStandard != model.DeviceConfigurationKeyValueStringTypeIEC61851 { + return api.ErrNotAvailable + } + } + + val := ghostone.RelaisStateOnePhase + if phases == 3 { + val = ghostone.RelaisStateThreePhase + } + + if err := wb.putJSON(wb.uri+"/system/relais-switch/state", ghostone.RelaisSwitchStateWrite{Value: val}); err != nil { + return err + } + + // verify the switch was accepted by reading back the state + var res ghostone.RelaisSwitchStateRead + if err := wb.GetJSON(wb.uri+"/system/relais-switch/state", &res); err != nil { + return err + } + + switch res.Value { + case ghostone.RelaisStateNotPossible, ghostone.RelaisStateNotAvailable: + msg := fmt.Sprintf("phase switching denied: %s", res.Value) + if res.LimitationReason != "" { + msg += fmt.Sprintf(" (%s)", res.LimitationReason) + } + return errors.New(msg) + } + + return nil +} + +// getPhases implements phase reading via REST API. +func (wb *GhostEEBus) getPhases() (int, error) { + var res ghostone.RelaisSwitchStateRead + err := wb.GetJSON(wb.uri+"/system/relais-switch/state", &res) + if err != nil { + return 0, err + } + + // check for transient or error states + // "notPossible" is also returned if the EV is connected via ISO 15118, as phase switching is not allowed in that case + switch res.Value { + case ghostone.RelaisStateSwitchInProgress, ghostone.RelaisStateNotAvailable, ghostone.RelaisStateNotPossible: + msg := fmt.Sprintf("phase switch state: %s", res.Value) + if res.LimitationReason != "" { + msg += fmt.Sprintf(" (%s)", res.LimitationReason) + } + return 0, errors.New(msg) + } + + switch res.CurrentState { + case ghostone.RelaisStateOnePhase: + return 1, nil + case ghostone.RelaisStateThreePhase: + return 3, nil + default: + return 0, fmt.Errorf("unknown phase state: %s", res.CurrentState) + } +} + +// identify implements RFID identification via REST API. +func (wb *GhostEEBus) identify() (string, error) { + var res ghostone.RfidCardLastRead + err := wb.GetJSON(wb.uri+"/rfid-cards/last-read", &res) + return res.UUID, err +} diff --git a/charger/ghosteebus_decorators.go b/charger/ghosteebus_decorators.go new file mode 100644 index 000000000..bfaa947f9 --- /dev/null +++ b/charger/ghosteebus_decorators.go @@ -0,0 +1,89 @@ +package charger + +// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT. + +import ( + "reflect" + + "github.com/evcc-io/evcc/api" +) + +func decorateGhostEEBus(base *GhostEEBus, meter func() (float64, error), phaseCurrents func() (float64, float64, float64, error), chargeRater func() (float64, error), phaseSwitcher func(int) error, phaseGetter func() (int, error)) api.Charger { + caps := make(map[reflect.Type]any) + + if meter != nil { + caps[reflect.TypeFor[api.Meter]()] = &decorateGhostEEBusMeterImpl{meter: meter} + } + + if phaseCurrents != nil { + caps[reflect.TypeFor[api.PhaseCurrents]()] = &decorateGhostEEBusPhaseCurrentsImpl{phaseCurrents: phaseCurrents} + } + + if chargeRater != nil { + caps[reflect.TypeFor[api.ChargeRater]()] = &decorateGhostEEBusChargeRaterImpl{chargeRater: chargeRater} + } + + if phaseSwitcher != nil { + caps[reflect.TypeFor[api.PhaseSwitcher]()] = &decorateGhostEEBusPhaseSwitcherImpl{phaseSwitcher: phaseSwitcher} + } + + if phaseGetter != nil { + caps[reflect.TypeFor[api.PhaseGetter]()] = &decorateGhostEEBusPhaseGetterImpl{phaseGetter: phaseGetter} + } + + if len(caps) == 0 { + return base + } + + return &decorateGhostEEBusCapable{GhostEEBus: base, caps: caps} +} + +type decorateGhostEEBusCapable struct { + *GhostEEBus + caps map[reflect.Type]any +} + +func (d *decorateGhostEEBusCapable) Capability(typ reflect.Type) (any, bool) { + c, ok := d.caps[typ] + return c, ok +} + +type decorateGhostEEBusChargeRaterImpl struct { + chargeRater func() (float64, error) +} + +func (impl *decorateGhostEEBusChargeRaterImpl) ChargedEnergy() (float64, error) { + return impl.chargeRater() +} + +type decorateGhostEEBusMeterImpl struct { + meter func() (float64, error) +} + +func (impl *decorateGhostEEBusMeterImpl) CurrentPower() (float64, error) { + return impl.meter() +} + +type decorateGhostEEBusPhaseCurrentsImpl struct { + phaseCurrents func() (float64, float64, float64, error) +} + +func (impl *decorateGhostEEBusPhaseCurrentsImpl) Currents() (float64, float64, float64, error) { + return impl.phaseCurrents() +} + +type decorateGhostEEBusPhaseGetterImpl struct { + phaseGetter func() (int, error) +} + +func (impl *decorateGhostEEBusPhaseGetterImpl) GetPhases() (int, error) { + return impl.phaseGetter() +} + +type decorateGhostEEBusPhaseSwitcherImpl struct { + phaseSwitcher func(int) error +} + +func (impl *decorateGhostEEBusPhaseSwitcherImpl) Phases1p3p(p0 int) error { + return impl.phaseSwitcher(p0) +} diff --git a/charger/ghosteebus_test.go b/charger/ghosteebus_test.go new file mode 100644 index 000000000..b4cd44177 --- /dev/null +++ b/charger/ghosteebus_test.go @@ -0,0 +1,422 @@ +package charger + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + + ucapi "github.com/enbility/eebus-go/usecases/api" + "github.com/enbility/eebus-go/usecases/mocks" + spinemocks "github.com/enbility/spine-go/mocks" + "github.com/enbility/spine-go/model" + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/charger/ghostone" + "github.com/evcc-io/evcc/server/eebus" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestGhostEEBusREST creates a GhostEEBus with a minimal EEBus (no EV connected), for pure REST tests. +func newTestGhostEEBusREST(t *testing.T) *GhostEEBus { + t.Helper() + return &GhostEEBus{ + EEBus: &EEBus{ + log: util.NewLogger("test"), + cem: &eebus.CustomerEnergyManagement{}, + }, + Helper: request.NewHelper(util.NewLogger("test")), + uri: "https://wallbox.local/api/v2", + } +} + +// newTestGhostEEBusWithEEBus creates a GhostEEBus with mocked EEBUS dependencies. +func newTestGhostEEBusWithEEBus(t *testing.T) (*GhostEEBus, *mocks.CemEVCCInterface, *spinemocks.EntityRemoteInterface) { + t.Helper() + + evccMock := mocks.NewCemEVCCInterface(t) + evEntity := spinemocks.NewEntityRemoteInterface(t) + + eb := &EEBus{ + cem: &eebus.CustomerEnergyManagement{ + EvCC: evccMock, + }, + ev: evEntity, + log: util.NewLogger("test"), + } + + wb := &GhostEEBus{ + EEBus: eb, + Helper: request.NewHelper(util.NewLogger("test")), + uri: "https://wallbox.local/api/v2", + } + + return wb, evccMock, evEntity +} + +const ghostEEBusRelaisStateURL = "https://wallbox.local/api/v2/system/relais-switch/state" + +func TestGhostEEBus_PhaseSwitchISO15118(t *testing.T) { + tests := []struct { + name string + connected bool + comStandard model.DeviceConfigurationKeyValueStringType + comErr error + wantErr error + }{ + { + name: "no_ev_connected", + connected: false, + }, + { + name: "iec61851_allowed", + connected: true, + comStandard: model.DeviceConfigurationKeyValueStringTypeIEC61851, + }, + { + name: "iso15118_ed1_blocked", + connected: true, + comStandard: model.DeviceConfigurationKeyValueStringTypeISO151182ED1, + wantErr: api.ErrNotAvailable, + }, + { + name: "iso15118_ed2_blocked", + connected: true, + comStandard: model.DeviceConfigurationKeyValueStringTypeISO151182ED2, + wantErr: api.ErrNotAvailable, + }, + { + name: "com_standard_error", + connected: true, + comErr: errors.New("data not available"), + wantErr: api.ErrNotAvailable, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + wb, evccMock, evEntity := newTestGhostEEBusWithEEBus(t) + + httpmock.ActivateNonDefault(wb.Client) + defer httpmock.DeactivateAndReset() + + evccMock.EXPECT().EVConnected(evEntity).Return(tc.connected) + if tc.connected { + if tc.comErr != nil { + evccMock.EXPECT().CommunicationStandard(evEntity).Return( + model.DeviceConfigurationKeyValueStringType(""), tc.comErr, + ) + } else { + evccMock.EXPECT().CommunicationStandard(evEntity).Return(tc.comStandard, nil) + } + } + + if tc.wantErr == nil { + // mock PUT + GET for successful phase switch + httpmock.RegisterResponder(http.MethodPut, ghostEEBusRelaisStateURL, + httpmock.NewStringResponder(200, ""), + ) + body, _ := json.Marshal(ghostone.RelaisSwitchStateRead{ + Value: ghostone.RelaisStateOnePhase, CurrentState: ghostone.RelaisStateOnePhase, + }) + httpmock.RegisterResponder(http.MethodGet, ghostEEBusRelaisStateURL, + httpmock.NewBytesResponder(200, body), + ) + } + + err := wb.phases1p3p(1) + + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGhostEEBus_PhaseSwitch(t *testing.T) { + tests := []struct { + name string + phases int + wantValue string + readBack ghostone.RelaisSwitchStateRead + wantErr bool + }{ + { + name: "switch_to_1p", + phases: 1, + wantValue: ghostone.RelaisStateOnePhase, + readBack: ghostone.RelaisSwitchStateRead{Value: ghostone.RelaisStateSwitchInProgress, CurrentState: ghostone.RelaisStateThreePhase}, + }, + { + name: "switch_to_3p", + phases: 3, + wantValue: ghostone.RelaisStateThreePhase, + readBack: ghostone.RelaisSwitchStateRead{Value: ghostone.RelaisStateSwitchInProgress, CurrentState: ghostone.RelaisStateOnePhase}, + }, + { + name: "already_in_target_state", + phases: 1, + wantValue: ghostone.RelaisStateOnePhase, + readBack: ghostone.RelaisSwitchStateRead{Value: ghostone.RelaisStateOnePhase, CurrentState: ghostone.RelaisStateOnePhase}, + }, + { + name: "denied_not_possible", + phases: 1, + wantValue: ghostone.RelaisStateOnePhase, + readBack: ghostone.RelaisSwitchStateRead{Value: ghostone.RelaisStateNotPossible, LimitationReason: "hlcLimitation"}, + wantErr: true, + }, + { + name: "denied_not_available", + phases: 3, + wantValue: ghostone.RelaisStateThreePhase, + readBack: ghostone.RelaisSwitchStateRead{Value: ghostone.RelaisStateNotAvailable}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + wb := newTestGhostEEBusREST(t) + + httpmock.ActivateNonDefault(wb.Client) + defer httpmock.DeactivateAndReset() + + // mock PUT (phase switch command) + var capturedBody ghostone.RelaisSwitchStateWrite + httpmock.RegisterResponder(http.MethodPut, ghostEEBusRelaisStateURL, + func(req *http.Request) (*http.Response, error) { + if err := json.NewDecoder(req.Body).Decode(&capturedBody); err != nil { + return httpmock.NewStringResponse(400, ""), nil + } + return httpmock.NewStringResponse(200, ""), nil + }, + ) + + // mock GET (read-after-write verification) + body, _ := json.Marshal(tc.readBack) + httpmock.RegisterResponder(http.MethodGet, ghostEEBusRelaisStateURL, + httpmock.NewBytesResponder(200, body), + ) + + err := wb.phases1p3p(tc.phases) + + assert.Equal(t, tc.wantValue, capturedBody.Value) + assert.Equal(t, 2, httpmock.GetTotalCallCount(), "expected PUT + GET") + + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGhostEEBus_GetPhases(t *testing.T) { + tests := []struct { + name string + currentState string + value string + wantPhases int + wantErr bool + }{ + { + name: "one_phase", + currentState: ghostone.RelaisStateOnePhase, + value: ghostone.RelaisStateOnePhase, + wantPhases: 1, + }, + { + name: "three_phase", + currentState: ghostone.RelaisStateThreePhase, + value: ghostone.RelaisStateThreePhase, + wantPhases: 3, + }, + { + name: "switch_in_progress", + currentState: ghostone.RelaisStateOnePhase, + value: ghostone.RelaisStateSwitchInProgress, + wantErr: true, + }, + { + name: "unknown_state", + currentState: "foo", + value: "foo", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + wb := newTestGhostEEBusREST(t) + + httpmock.ActivateNonDefault(wb.Client) + defer httpmock.DeactivateAndReset() + + resp := ghostone.RelaisSwitchStateRead{ + Value: tc.value, + CurrentState: tc.currentState, + } + body, _ := json.Marshal(resp) + httpmock.RegisterResponder(http.MethodGet, ghostEEBusRelaisStateURL, + httpmock.NewBytesResponder(200, body), + ) + + phases, err := wb.getPhases() + + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.wantPhases, phases) + } + }) + } +} + +func TestGhostEEBus_Decorator(t *testing.T) { + wb := newTestGhostEEBusREST(t) + + // with phase switching + decorated := decorateGhostEEBus(wb, nil, nil, nil, wb.phases1p3p, wb.getPhases) + assert.True(t, api.HasCap[api.PhaseSwitcher](decorated), "expected PhaseSwitcher") + assert.True(t, api.HasCap[api.PhaseGetter](decorated), "expected PhaseGetter") + + // without phase switching + decorated = decorateGhostEEBus(wb, nil, nil, nil, nil, nil) + assert.False(t, api.HasCap[api.PhaseSwitcher](decorated), "unexpected PhaseSwitcher") +} + +func TestGhostEEBus_Config(t *testing.T) { + // test that config decoding works (fails on missing eebus instance, not on decoding) + ctx := context.Background() + config := map[string]any{ + "ski": "test-ski", + "ip": "10.0.1.30", + "user": "technician", + "password": "secret", + } + + _, err := NewGhostEEBusFromConfig(ctx, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "eebus not configured") +} + +func TestGhostEEBus_ConfigMissingCredentials(t *testing.T) { + // without credentials, falls back to pure EEBUS (no REST features) + // fails on missing eebus instance, not on credentials + ctx := context.Background() + config := map[string]any{ + "ski": "test-ski", + "ip": "10.0.1.30", + "meter": true, + } + + _, err := NewGhostEEBusFromConfig(ctx, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "eebus not configured") +} + +func TestGhostEEBus_Identify(t *testing.T) { + tests := []struct { + name string + uuid string + wantID string + }{ + { + name: "with_uuid", + uuid: "ABC123", + wantID: "ABC123", + }, + { + name: "empty", + uuid: "", + wantID: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + wb := newTestGhostEEBusREST(t) + + httpmock.ActivateNonDefault(wb.Client) + defer httpmock.DeactivateAndReset() + + resp := ghostone.RfidCardLastRead{UUID: tc.uuid} + body, _ := json.Marshal(resp) + httpmock.RegisterResponder(http.MethodGet, + "https://wallbox.local/api/v2/rfid-cards/last-read", + httpmock.NewBytesResponder(200, body), + ) + + id, err := wb.identify() + require.NoError(t, err) + assert.Equal(t, tc.wantID, id) + }) + } +} + +func TestGhostEEBus_IdentifyFallback(t *testing.T) { + t.Run("rfid_disabled_falls_back_to_eebus", func(t *testing.T) { + wb, evccMock, evEntity := newTestGhostEEBusWithEEBus(t) + wb.hasRFID = false + + // EEBus identification returns MAC + evccMock.EXPECT().EVConnected(evEntity).Return(true) + evccMock.EXPECT().Identifications(evEntity).Return([]ucapi.IdentificationItem{{Value: "MAC-001"}}, nil) + + id, err := wb.Identify() + require.NoError(t, err) + assert.Equal(t, "MAC-001", id) + }) + + t.Run("rfid_returns_id", func(t *testing.T) { + wb, _, _ := newTestGhostEEBusWithEEBus(t) + wb.hasRFID = true + + httpmock.ActivateNonDefault(wb.Client) + defer httpmock.DeactivateAndReset() + + resp := ghostone.RfidCardLastRead{UUID: "RFID-42"} + body, _ := json.Marshal(resp) + httpmock.RegisterResponder(http.MethodGet, + "https://wallbox.local/api/v2/rfid-cards/last-read", + httpmock.NewBytesResponder(200, body), + ) + + id, err := wb.Identify() + require.NoError(t, err) + assert.Equal(t, "RFID-42", id) + }) + + t.Run("rfid_empty_falls_back_to_eebus", func(t *testing.T) { + wb, evccMock, evEntity := newTestGhostEEBusWithEEBus(t) + wb.hasRFID = true + + httpmock.ActivateNonDefault(wb.Client) + defer httpmock.DeactivateAndReset() + + // RFID returns empty + resp := ghostone.RfidCardLastRead{UUID: ""} + body, _ := json.Marshal(resp) + httpmock.RegisterResponder(http.MethodGet, + "https://wallbox.local/api/v2/rfid-cards/last-read", + httpmock.NewBytesResponder(200, body), + ) + + // Falls back to EEBus + evccMock.EXPECT().EVConnected(evEntity).Return(true) + evccMock.EXPECT().Identifications(evEntity).Return([]ucapi.IdentificationItem{{Value: "MAC-001"}}, nil) + + id, err := wb.Identify() + require.NoError(t, err) + assert.Equal(t, "MAC-001", id) + }) +} diff --git a/charger/ghostone/identity.go b/charger/ghostone/identity.go new file mode 100644 index 000000000..2ec74a543 --- /dev/null +++ b/charger/ghostone/identity.go @@ -0,0 +1,93 @@ +package ghostone + +import ( + "errors" + "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/transport" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2" +) + +type tokenSource struct { + *request.Helper + oauth2.TokenSource + uri string + user string + password string +} + +// TokenSource creates a JWT token source for the ghost REST API +func TokenSource(log *util.Logger, uri, user, password string) (oauth2.TokenSource, error) { + c := &tokenSource{ + Helper: request.NewHelper(log), + uri: uri + "/jwt/login", + user: user, + password: password, + } + + c.Client.Transport = transport.Insecure() + + token, err := c.login() + if err != nil { + return nil, err + } + + c.TokenSource = oauth.RefreshTokenSource(token, c.refresh) + + return c, nil +} + +func (c *tokenSource) login() (*oauth2.Token, error) { + data := url.Values{ + "user": {c.user}, + "pass": {c.password}, + } + + req, err := request.New(http.MethodPost, c.uri, strings.NewReader(data.Encode()), request.URLEncoding) + if err != nil { + return nil, err + } + + resp, err := c.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := request.ResponseError(resp); err != nil { + return nil, err + } + + token := resp.Header.Get("Authorization") + if token == "" { + return nil, errors.New("missing authorization header") + } + + // strip "Bearer " prefix if present + token = strings.TrimPrefix(token, "Bearer ") + + // extract expiry from JWT claims + expiry := time.Now().Add(10 * time.Minute) // fallback + var claims jwt.RegisteredClaims + if _, _, err := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(token, &claims); err == nil && claims.ExpiresAt != nil { + expiry = claims.ExpiresAt.Time + } + + return &oauth2.Token{ + AccessToken: token, + TokenType: "Bearer", + Expiry: expiry, + }, nil +} + +func (c *tokenSource) refresh(_ *oauth2.Token) (*oauth2.Token, error) { + // re-login to get new token + return c.login() +} diff --git a/charger/ghostone/types.go b/charger/ghostone/types.go new file mode 100644 index 000000000..e7425400c --- /dev/null +++ b/charger/ghostone/types.go @@ -0,0 +1,44 @@ +package ghostone + +// Relais switch state values +const ( + RelaisStateOnePhase = "onePhase" + RelaisStateThreePhase = "threePhase" + RelaisStateNotAvailable = "notAvailable" + RelaisStateSwitchInProgress = "switchInProgress" + RelaisStateNotPossible = "notPossible" +) + +// PV optimization mode values +const ( + PvModeNone = "noPV" +) + +// Enabled is the response from GET /system/relais-switch/enabled +type Enabled struct { + Enabled bool `json:"enabled"` +} + +// RelaisSwitchStateRead is the response from GET /system/relais-switch/state +type RelaisSwitchStateRead struct { + Value string `json:"value"` // onePhase, threePhase, notAvailable, switchInProgress, notPossible + Mode string `json:"mode"` // manual, automatic + LimitationReason string `json:"limitationReason"` // plcLimitation, hlcLimitation, gridLimitation + CurrentState string `json:"currentState"` // onePhase, threePhase +} + +// RelaisSwitchStateWrite is the request body for PUT /system/relais-switch/state +type RelaisSwitchStateWrite struct { + Value string `json:"value"` // onePhase, threePhase, automatic +} + +// PvOptimizationMode is the response from GET /charging/pvoptimization/mode +type PvOptimizationMode struct { + Value string `json:"value"` // noPV, pvOnly, pvPlus +} + +// RfidCardLastRead is the response from GET /rfid-cards/last-read +type RfidCardLastRead struct { + UUID string `json:"uuid"` + ID string `json:"id,omitempty"` +} diff --git a/core/loadpoint.go b/core/loadpoint.go index abe402b63..00a51a489 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1235,7 +1235,7 @@ func (lp *Loadpoint) scalePhasesRequired() bool { return lp.hasPhaseSwitching() && lp.phasesConfigured != 0 && lp.phasesConfigured != lp.GetPhases() } -// scalePhasesIfAvailable scales if api.PhaseSwitcher is available +// scalePhasesIfAvailable scales if api.PhaseSwitcher is available and allowed func (lp *Loadpoint) scalePhasesIfAvailable(phases int) error { if lp.phasesConfigured != 0 { phases = lp.phasesConfigured diff --git a/templates/definition/charger/elli-2.yaml b/templates/definition/charger/elli-2.yaml index d70a53ccf..5225a8afc 100644 --- a/templates/definition/charger/elli-2.yaml +++ b/templates/definition/charger/elli-2.yaml @@ -36,24 +36,34 @@ products: - brand: Cupra description: generic: Charger Pro Eichrecht 2 -capabilities: ["iso151182", "mA"] +capabilities: ["iso151182", "1p3p", "rfid", "mA"] requirements: evcc: ["eebus"] description: de: | - Die Wallbox muss im normalen Lademodus betrieben werden. + Das PV-Überschussladen der Wallbox muss deaktiviert sein (Ladeverwaltung -> Ladeeinstellungen -> PV-Überschussladen aus). - Die Nutzung der Phasenumschaltung ist in evcc momentan noch nicht möglich. - - Eine Identifikation des Fahrzeugs über die RFID Karte ist nicht möglich. + Für Phasenumschaltung und RFID-Identifikation werden IP-Adresse und Techniker-Passwort benötigt. Die Phasenumschaltung sollte in der Wallbox aktiviert werden (Ladeverwaltung -> Ladeeinstellungen). en: | - The device has to be used in the normal charging mode. + The wallbox PV surplus charging must be disabled (Charging management -> Charging settings -> PV surplus charging off). - The usage of phase switching is currently not possible in evcc. - - The identification of a vehicle using the RFID card is not possible. + Phase switching and RFID identification require the IP address and technician password. Phase switching should be enabled in the wallbox settings (Charging management -> Charging settings). params: - preset: eebus + - name: ip + help: + de: IP-Adresse der Wallbox. Erforderlich für Phasenumschaltung und RFID-Identifikation. + en: IP address of the wallbox. Required for phase switching and RFID identification. + - name: password + help: + de: Techniker-Passwort. Erforderlich für Phasenumschaltung und RFID-Identifikation. + en: Technician password. Required for phase switching and RFID identification. render: | - {{ include "eebus" . }} + type: ghosteebus + ski: {{ .ski }} + ip: {{ .ip }} meter: true + {{- if .password }} + user: technician + password: {{ .password }} + {{- end }} diff --git a/templates/definition/charger/ghost.yaml b/templates/definition/charger/ghost.yaml index 856585093..782690876 100644 --- a/templates/definition/charger/ghost.yaml +++ b/templates/definition/charger/ghost.yaml @@ -6,24 +6,34 @@ products: - brand: Kontron Solar description: generic: Charger -capabilities: ["iso151182", "mA"] +capabilities: ["iso151182", "1p3p", "rfid", "mA"] requirements: evcc: ["eebus"] description: de: | - Die Wallbox muss im normalen Lademodus betrieben werden. + Das PV-Überschussladen der Wallbox muss deaktiviert sein (Ladeverwaltung -> Ladeeinstellungen -> PV-Überschussladen aus). - Die Nutzung der Phasenumschaltung ist in evcc momentan noch nicht möglich. - - Eine Identifikation des Fahrzeugs über die RFID Karte ist nicht möglich. + Für Phasenumschaltung und RFID-Identifikation werden IP-Adresse und Techniker-Passwort benötigt. Die Phasenumschaltung sollte in der Wallbox aktiviert werden (Ladeverwaltung -> Ladeeinstellungen). en: | - The device has to be used in the normal charging mode. + The wallbox PV surplus charging must be disabled (Charging management -> Charging settings -> PV surplus charging off). - The usage of phase switching is currently not possible in evcc. - - The identification of a vehicle using the RFID card is not possible. + Phase switching and RFID identification require the IP address and technician password. Phase switching should be enabled in the wallbox settings (Charging management -> Charging settings). params: - preset: eebus + - name: ip + help: + de: IP-Adresse der Wallbox. Erforderlich für Phasenumschaltung und RFID-Identifikation. + en: IP address of the wallbox. Required for phase switching and RFID identification. + - name: password + help: + de: Techniker-Passwort. Erforderlich für Phasenumschaltung und RFID-Identifikation. + en: Technician password. Required for phase switching and RFID identification. render: | - {{ include "eebus" . }} + type: ghosteebus + ski: {{ .ski }} + ip: {{ .ip }} meter: true + {{- if .password }} + user: technician + password: {{ .password }} + {{- end }} diff --git a/templates/definition/charger/porsche-wallbox.yaml b/templates/definition/charger/porsche-wallbox.yaml index ebe7c4d9b..6805a7490 100644 --- a/templates/definition/charger/porsche-wallbox.yaml +++ b/templates/definition/charger/porsche-wallbox.yaml @@ -3,24 +3,34 @@ products: - brand: Porsche description: generic: Wallbox -capabilities: ["iso151182", "mA"] +capabilities: ["iso151182", "1p3p", "rfid", "mA"] requirements: evcc: ["eebus"] description: de: | - Die Wallbox muss im normalen Lademodus betrieben werden. + Das PV-Überschussladen der Wallbox muss deaktiviert sein (Ladeverwaltung -> Ladeeinstellungen -> PV-Überschussladen aus). - Die Nutzung der Phasenumschaltung ist in evcc momentan noch nicht möglich. - - Eine Identifikation des Fahrzeugs über die RFID Karte ist nicht möglich. + Für Phasenumschaltung und RFID-Identifikation werden IP-Adresse und Techniker-Passwort benötigt. Die Phasenumschaltung sollte in der Wallbox aktiviert werden (Ladeverwaltung -> Ladeeinstellungen). en: | - The device has to be used in the normal charging mode. + The wallbox PV surplus charging must be disabled (Charging management -> Charging settings -> PV surplus charging off). - The usage of phase switching is currently not possible in evcc. - - The identification of a vehicle using the RFID card is not possible. + Phase switching and RFID identification require the IP address and technician password. Phase switching should be enabled in the wallbox settings (Charging management -> Charging settings). params: - preset: eebus + - name: ip + help: + de: IP-Adresse der Wallbox. Erforderlich für Phasenumschaltung und RFID-Identifikation. + en: IP address of the wallbox. Required for phase switching and RFID identification. + - name: password + help: + de: Techniker-Passwort. Erforderlich für Phasenumschaltung und RFID-Identifikation. + en: Technician password. Required for phase switching and RFID identification. render: | - {{ include "eebus" . }} + type: ghosteebus + ski: {{ .ski }} + ip: {{ .ip }} meter: true + {{- if .password }} + user: technician + password: {{ .password }} + {{- end }} From e647eadf3fd806502d4556bb1b052e617c957544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Sun, 5 Apr 2026 18:20:28 +0200 Subject: [PATCH 0251/1580] chore: fix Toyota integration test compilation (#28834) --- vehicle/toyota/identity_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vehicle/toyota/identity_test.go b/vehicle/toyota/identity_test.go index 187e96e5b..7522f2289 100644 --- a/vehicle/toyota/identity_test.go +++ b/vehicle/toyota/identity_test.go @@ -35,7 +35,7 @@ func TestIdentityLogin(t *testing.T) { originalAccessToken := token.AccessToken // Test token refresh - newToken, err := identity.RefreshToken(token) + newToken, err := identity.refreshToken(token) require.NoError(t, err) require.NotEmpty(t, newToken.AccessToken) require.NotEmpty(t, newToken.RefreshToken) From 1c8387c97848b6d06d864989b0c25a2da94ead67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Sun, 5 Apr 2026 18:21:58 +0200 Subject: [PATCH 0252/1580] Add Lexus vehicle support (#28836) --- templates/definition/vehicle/lexus.yaml | 26 +++++++++++++++++++++++++ vehicle/toyota.go | 19 +++++++++++------- vehicle/toyota/api.go | 3 ++- vehicle/toyota/api_test.go | 4 ++-- vehicle/toyota/identity.go | 14 ++++++++----- vehicle/toyota/identity_test.go | 2 +- 6 files changed, 52 insertions(+), 16 deletions(-) create mode 100644 templates/definition/vehicle/lexus.yaml diff --git a/templates/definition/vehicle/lexus.yaml b/templates/definition/vehicle/lexus.yaml new file mode 100644 index 000000000..e47910975 --- /dev/null +++ b/templates/definition/vehicle/lexus.yaml @@ -0,0 +1,26 @@ +template: lexus +products: + - brand: Lexus +requirements: + description: + de: | + Benötigt Lexus Link+ Connected Services Account. + en: | + Requires Lexus Link+ Connected Services Account. +params: + - preset: vehicle-common + - name: user + required: true + - name: password + required: true + - name: vin + example: JT... + - name: cache + default: 15m +render: | + type: lexus + {{ include "vehicle-common" . }} + user: {{ .user }} + password: {{ .password }} + vin: {{ .vin }} + cache: {{ .cache }} diff --git a/vehicle/toyota.go b/vehicle/toyota.go index 97e1912f9..3f0269f9d 100644 --- a/vehicle/toyota.go +++ b/vehicle/toyota.go @@ -9,18 +9,23 @@ import ( "github.com/evcc-io/evcc/vehicle/toyota" ) -// Toyota is an api.Vehicle implementation for Toyota cars +// Toyota is an api.Vehicle implementation for Toyota/Lexus cars type Toyota struct { *embed *toyota.Provider } func init() { - registry.Add("toyota", NewToyotaFromConfig) + registry.Add("toyota", func(other map[string]any) (api.Vehicle, error) { + return newToyotaFromConfig("toyota", "T", other) + }) + registry.Add("lexus", func(other map[string]any) (api.Vehicle, error) { + return newToyotaFromConfig("lexus", "L", other) + }) } -// NewToyotaFromConfig creates a new vehicle -func NewToyotaFromConfig(other map[string]any) (api.Vehicle, error) { +// newToyotaFromConfig creates a new vehicle +func newToyotaFromConfig(brand, brandCode string, other map[string]any) (api.Vehicle, error) { cc := struct { embed `mapstructure:",squash"` User, Password, VIN string @@ -41,15 +46,15 @@ func NewToyotaFromConfig(other map[string]any) (api.Vehicle, error) { embed: &cc.embed, } - log := util.NewLogger("toyota").Redact(cc.User, cc.Password, cc.VIN) - identity := toyota.NewIdentity(log) + log := util.NewLogger(brand).Redact(cc.User, cc.Password, cc.VIN) + identity := toyota.NewIdentity(log, brandCode) err := identity.Login(cc.User, cc.Password) if err != nil { return v, fmt.Errorf("login failed: %w", err) } - api := toyota.NewAPI(log, identity) + api := toyota.NewAPI(log, identity, brandCode) cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles) if err == nil { diff --git a/vehicle/toyota/api.go b/vehicle/toyota/api.go index 79a27513f..9c303b501 100644 --- a/vehicle/toyota/api.go +++ b/vehicle/toyota/api.go @@ -34,7 +34,7 @@ type API struct { identity *Identity } -func NewAPI(log *util.Logger, identity *Identity) *API { +func NewAPI(log *util.Logger, identity *Identity, brandCode string) *API { v := &API{ Helper: request.NewHelper(log), log: log, @@ -57,6 +57,7 @@ func NewAPI(log *util.Logger, identity *Identity) *API { "x-client-ref": clientRef, "x-appversion": clientRefKey, "x-channel": channel, + "X-Appbrand": brandCode, }), Base: &oauth2.Transport{ Source: identity, diff --git a/vehicle/toyota/api_test.go b/vehicle/toyota/api_test.go index 0ef3e65d4..cf890d02f 100644 --- a/vehicle/toyota/api_test.go +++ b/vehicle/toyota/api_test.go @@ -21,12 +21,12 @@ func TestAPI(t *testing.T) { // Create and login identity log := util.NewLogger("foo") - identity := NewIdentity(log) + identity := NewIdentity(log, "T") err := identity.Login(user, password) require.NoError(t, err) // Create API client - api := NewAPI(log, identity) + api := NewAPI(log, identity, "T") // Test Vehicles method vehicles, err := api.Vehicles() diff --git a/vehicle/toyota/identity.go b/vehicle/toyota/identity.go index 3583f6c2a..57eeac33c 100644 --- a/vehicle/toyota/identity.go +++ b/vehicle/toyota/identity.go @@ -26,13 +26,15 @@ type Identity struct { log *util.Logger *request.Helper oauth2.TokenSource - uuid string + uuid string + brandCode string } -func NewIdentity(log *util.Logger) *Identity { +func NewIdentity(log *util.Logger, brandCode string) *Identity { return &Identity{ - log: log, - Helper: request.NewHelper(log), + log: log, + Helper: request.NewHelper(log), + brandCode: brandCode, } } @@ -162,7 +164,9 @@ func (v *Identity) refreshToken(token *oauth2.Token) (*oauth2.Token, error) { func (v *Identity) Login(user, password string) error { uri := fmt.Sprintf("%s/%s", BaseUrl, AuthenticationPath) req, err := request.New(http.MethodPost, uri, nil, map[string]string{ - "Accept": "application/json", + "Accept": "application/json", + "X-Brand": v.brandCode, + "X-Region": "EU", }) if err != nil { return fmt.Errorf("failed to create authentication request: %w", err) diff --git a/vehicle/toyota/identity_test.go b/vehicle/toyota/identity_test.go index 7522f2289..3b72fae7d 100644 --- a/vehicle/toyota/identity_test.go +++ b/vehicle/toyota/identity_test.go @@ -20,7 +20,7 @@ func TestIdentityLogin(t *testing.T) { util.LogLevel("trace", nil) // Enable trace logging log := util.NewLogger("test") - identity := NewIdentity(log) + identity := NewIdentity(log, "T") err := identity.Login(user, password) require.NoError(t, err) From e995c83214807018162eb28690e196b70757e456 Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:48:35 +0200 Subject: [PATCH 0253/1580] Smartfox: fix id selector (#28371) --- templates/definition/meter/smartfox-em2.yaml | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/templates/definition/meter/smartfox-em2.yaml b/templates/definition/meter/smartfox-em2.yaml index b8c3491d0..c9999c1fa 100644 --- a/templates/definition/meter/smartfox-em2.yaml +++ b/templates/definition/meter/smartfox-em2.yaml @@ -14,7 +14,7 @@ products: generic: Pro Light 2 - brand: Smartfox description: - generic: Light (EM2 firmware) + generic: Light requirements: description: de: | @@ -39,51 +39,51 @@ render: | source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="detailsPowerValue")."#content" | rtrimstr(" W") + jq: .values.value[] | select((.id // .attrid)=="detailsPowerValue")."#content" | rtrimstr(" W") energy: # grid energy in kWh source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="energyValue")."#content" | rtrimstr(" kWh") + jq: .values.value[] | select((.id // .attrid)=="energyValue")."#content" | rtrimstr(" kWh") voltages: # grid voltages in V - source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="voltageL1Value")."#content" | rtrimstr(" V") + jq: .values.value[] | select((.id // .attrid)=="voltageL1Value")."#content" | rtrimstr(" V") - source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="voltageL2Value")."#content" | rtrimstr(" V") + jq: .values.value[] | select((.id // .attrid)=="voltageL2Value")."#content" | rtrimstr(" V") - source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="voltageL3Value")."#content" | rtrimstr(" V") + jq: .values.value[] | select((.id // .attrid)=="voltageL3Value")."#content" | rtrimstr(" V") currents: # grid currents in A - source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="ampereL1Value")."#content" | rtrimstr(" A") + jq: .values.value[] | select((.id // .attrid)=="ampereL1Value")."#content" | rtrimstr(" A") - source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="ampereL2Value")."#content" | rtrimstr(" A") + jq: .values.value[] | select((.id // .attrid)=="ampereL2Value")."#content" | rtrimstr(" A") - source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="ampereL3Value")."#content" | rtrimstr(" A") + jq: .values.value[] | select((.id // .attrid)=="ampereL3Value")."#content" | rtrimstr(" A") powers: # grid powers in W - source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="powerL1Value")."#content" | rtrimstr(" W") + jq: .values.value[] | select((.id // .attrid)=="powerL1Value")."#content" | rtrimstr(" W") - source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="powerL2Value")."#content" | rtrimstr(" W") + jq: .values.value[] | select((.id // .attrid)=="powerL2Value")."#content" | rtrimstr(" W") - source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="powerL3Value")."#content" | rtrimstr(" W") + jq: .values.value[] | select((.id // .attrid)=="powerL3Value")."#content" | rtrimstr(" W") {{- end }} {{- if eq .usage "pv" }} # PV power and energy values are delivered for each inverter, we select them via regex and sum the values @@ -91,19 +91,19 @@ render: | source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: '[.values.value[] | select(.attrid|test("wr\\d+PowerValue"))."#content" | rtrimstr(" kW") | tonumber] | add' + jq: '[.values.value[] | select((.id // .attrid)|test("wr\\d+PowerValue"))."#content" | rtrimstr(" kW") | tonumber] | add' scale: 1000 # wr1PowerValue, ..., wr5PowerValue are in kW energy: # PV energy in kWh source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: '[.values.value[] | select(.attrid|test("wr\\d+EnergyValue"))."#content" | rtrimstr(" kWh") | tonumber] | add' + jq: '[.values.value[] | select((.id // .attrid)|test("wr\\d+EnergyValue"))."#content" | rtrimstr(" kWh") | tonumber] | add' {{- end }} {{- if eq .usage "aux" }} power: # heating power in W source: http cache: {{ .cache }} uri: {{ include "uri" . }} - jq: .values.value[] | select(.attrid=="htPowerMeasValue")."#content" | rtrimstr(" kW") + jq: .values.value[] | select((.id // .attrid)=="htPowerMeasValue")."#content" | rtrimstr(" kW") scale: 1000 {{- end }} From 536ebcb31972fb0286af977c31f65134129fc1e3 Mon Sep 17 00:00:00 2001 From: Hofy <68365998+Hofyyy@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:46:50 +0200 Subject: [PATCH 0254/1580] TWC3: allow fast charging when vehicle does not support current control (#28802) --- charger/twc3.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/charger/twc3.go b/charger/twc3.go index f53f8170f..5d987d9e6 100644 --- a/charger/twc3.go +++ b/charger/twc3.go @@ -139,6 +139,11 @@ func (c *Twc3) MaxCurrent(current int64) error { v, ok := api.Cap[api.CurrentController](c.lp.GetVehicle()) if !ok { + if c.lp.GetMode() == api.ModeNow { + // vehicle does not support current control- ignore silently + // since TWC3 cannot limit current on its own + return nil + } return errors.New("vehicle not capable of current control") } From 397b3c003eb5f4feeef1ee113ce5ae24f8450549 Mon Sep 17 00:00:00 2001 From: Klaas Gadeyne Date: Mon, 6 Apr 2026 09:52:29 +0200 Subject: [PATCH 0255/1580] 55aa: fix PDU slave id for ET series (#28828) --- plugin/aa55udp.go | 27 +++++++++------- plugin/aa55udp_test.go | 31 ++++++++++++------- .../definition/meter/goodwe-wifi-et.yaml | 13 +++++++- 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/plugin/aa55udp.go b/plugin/aa55udp.go index 84c95258d..c8f384a51 100644 --- a/plugin/aa55udp.go +++ b/plugin/aa55udp.go @@ -33,7 +33,7 @@ type AA55UDP struct { log *util.Logger conn *net.UDPConn pdu []byte // 6-byte PDU body, no CRC - decode string // int32be | uint32be | int16be | uint16be + decode string // int32be | uint32be | int16be | uint16be | float32be scale float64 } @@ -45,18 +45,21 @@ func init() { // // source: aa55udp // host: 192.168.1.26 # inverter IP; port 8899 is always used +// id: 0x7F # inverter address byte: 0x7F for DT/DNS/ES/EM, 0xF7 for ET/EH/BT/BH // register: 30127 # Modbus register address (0-based, uint16) // count: 2 # number of registers to read (1=U16, 2=S32/U32) -// decode: int32be # int32be | uint32be | int16be | uint16be +// decode: int32be # int32be | uint32be | int16be | uint16be | float32be // scale: 1.0 # optional multiplier (default 1.0) func NewAA55UDPFromConfig(_ context.Context, other map[string]interface{}) (Plugin, error) { cc := struct { Host string `mapstructure:"host"` + Id int `mapstructure:"id"` Register uint16 `mapstructure:"register"` Count uint16 `mapstructure:"count"` Decode string `mapstructure:"decode"` Scale float64 `mapstructure:"scale"` }{ + Id: int(aa55InverterAddr), Count: 2, Scale: 1.0, } @@ -68,13 +71,17 @@ func NewAA55UDPFromConfig(_ context.Context, other map[string]interface{}) (Plug return nil, errors.New("aa55udp: count must be ≥ 1") } + if cc.Id < 0 || cc.Id > 255 { + return nil, fmt.Errorf("aa55udp: id must be 0-255, got %d", cc.Id) + } + switch cc.Decode { case "int32be", "uint32be", "int16be", "uint16be", "float32be": default: return nil, fmt.Errorf("aa55udp: unsupported decode %q (want int32be|uint32be|int16be|uint16be|float32be)", cc.Decode) } - pdu := buildPDU(cc.Register, cc.Count) + pdu := buildPDU(byte(cc.Id), cc.Register, cc.Count) addr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(cc.Host, "8899")) if err != nil { @@ -132,20 +139,18 @@ func (p *AA55UDP) query() (float64, error) { return v * p.scale, nil } -// aa55InverterAddr is the target address byte used in all AA55 request PDUs. -// The GoodWe WiFi module always uses 0x7F as the inverter target address, -// regardless of inverter family. The family only affects the source byte in -// responses (0x7F for DT/DNS, 0xF7 for ET/EH/BT/BH). +// aa55InverterAddr is the default inverter address byte, used by DT/DNS and ES/EM families. +// ET/EH/BT/BH families require 0xF7 instead. const aa55InverterAddr = 0x7F // aa55ReadFunc is the Modbus function code for READ HOLDING REGISTERS. const aa55ReadFunc = 0x03 -// buildPDU constructs the 6-byte Modbus PDU body for a READ HOLDING REGISTERS -// request targeting the GoodWe WiFi module. -func buildPDU(register, count uint16) []byte { +// buildPDU constructs the 6-byte PDU for a READ HOLDING REGISTERS request. +// addr is the inverter address byte: 0x7F for DT/DNS/ES/EM, 0xF7 for ET/EH/BT/BH. +func buildPDU(addr byte, register, count uint16) []byte { return []byte{ - aa55InverterAddr, aa55ReadFunc, + addr, aa55ReadFunc, byte(register >> 8), byte(register), byte(count >> 8), byte(count), } diff --git a/plugin/aa55udp_test.go b/plugin/aa55udp_test.go index 22e8759e4..0ebefb259 100644 --- a/plugin/aa55udp_test.go +++ b/plugin/aa55udp_test.go @@ -63,20 +63,27 @@ const ( func TestBuildPDU_DTpower(t *testing.T) { // DT power: register 0x75AF, count 2 → READ 2 @ 0x75AF - got := buildPDU(0x75AF, 2) + got := buildPDU(0x7F, 0x75AF, 2) assert.Equal(t, []byte{0x7f, 0x03, 0x75, 0xaf, 0x00, 0x02}, got) } +func TestBuildPDU_DefaultAddress(t *testing.T) { + // When address is omitted from config, aa55InverterAddr (0x7F) must be used. + // This guards existing DT/DNS and ES/EM setups that rely on the default. + got := buildPDU(aa55InverterAddr, 0x75AF, 2) + assert.Equal(t, byte(0x7F), got[0], "default address byte must be 0x7F") +} + func TestBuildPDU_ETgrid(t *testing.T) { // ET grid: register 0x8943, count 2 - got := buildPDU(0x8943, 2) - assert.Equal(t, []byte{0x7f, 0x03, 0x89, 0x43, 0x00, 0x02}, got) + got := buildPDU(0xF7, 0x8943, 2) + assert.Equal(t, []byte{0xf7, 0x03, 0x89, 0x43, 0x00, 0x02}, got) } func TestBuildPDU_SoC(t *testing.T) { // ET SoC: register 0x908F, count 1 (U16) - got := buildPDU(0x908F, 1) - assert.Equal(t, []byte{0x7f, 0x03, 0x90, 0x8f, 0x00, 0x01}, got) + got := buildPDU(0xF7, 0x908F, 1) + assert.Equal(t, []byte{0xf7, 0x03, 0x90, 0x8f, 0x00, 0x01}, got) } // --------------------------------------------------------------------------- @@ -179,7 +186,7 @@ func TestDecodeAt_UnknownType(t *testing.T) { func TestModbusCRC16_DTPdu(t *testing.T) { // DT power PDU 7f 03 75 af 00 02 → CRC d1 ba - pdu := buildPDU(0x75AF, 2) + pdu := buildPDU(0x7F, 0x75AF, 2) crc := modbusCRC16(pdu) // Verify round-trip: CRC is 2 bytes and deterministic assert.Len(t, crc, 2) @@ -188,7 +195,7 @@ func TestModbusCRC16_DTPdu(t *testing.T) { func TestModbusCRC16_ETPdu(t *testing.T) { // ET grid PDU 7f 03 89 43 00 02 → CRC is 2 bytes - pdu := buildPDU(0x8943, 2) + pdu := buildPDU(0xF7, 0x8943, 2) crc := modbusCRC16(pdu) assert.Len(t, crc, 2) assert.Equal(t, crc, modbusCRC16(pdu)) @@ -292,7 +299,7 @@ func TestFloatGetter_DT_Power(t *testing.T) { p := &AA55UDP{ log: util.NewLogger("test"), conn: mockConn(t, response), - pdu: buildPDU(0x75AF, 2), + pdu: buildPDU(0x7F, 0x75AF, 2), decode: "int32be", scale: 1.0, } @@ -309,7 +316,7 @@ func TestFloatGetter_DT_Energy(t *testing.T) { p := &AA55UDP{ log: util.NewLogger("test"), conn: mockConn(t, response), - pdu: buildPDU(0x75C1, 2), + pdu: buildPDU(0x7F, 0x75C1, 2), decode: "uint32be", scale: 0.1, } @@ -326,7 +333,7 @@ func TestFloatGetter_ET_PV(t *testing.T) { p := &AA55UDP{ log: util.NewLogger("test"), conn: mockConn(t, response), - pdu: buildPDU(0x8941, 2), + pdu: buildPDU(0xF7, 0x8941, 2), decode: "int32be", scale: 1.0, } @@ -343,7 +350,7 @@ func TestFloatGetter_ET_Battery(t *testing.T) { p := &AA55UDP{ log: util.NewLogger("test"), conn: mockConn(t, response), - pdu: buildPDU(0x896E, 2), + pdu: buildPDU(0xF7, 0x896E, 2), decode: "int32be", scale: 1.0, } @@ -360,7 +367,7 @@ func TestFloatGetter_ET_SoC(t *testing.T) { p := &AA55UDP{ log: util.NewLogger("test"), conn: mockConn(t, response), - pdu: buildPDU(0x908F, 1), + pdu: buildPDU(0xF7, 0x908F, 1), decode: "uint16be", scale: 1.0, } diff --git a/templates/definition/meter/goodwe-wifi-et.yaml b/templates/definition/meter/goodwe-wifi-et.yaml index f2c8bb44f..0fdb7121f 100644 --- a/templates/definition/meter/goodwe-wifi-et.yaml +++ b/templates/definition/meter/goodwe-wifi-et.yaml @@ -4,23 +4,29 @@ requirements: products: - brand: GoodWe description: - generic: EH/BT/BH Hybrid Inverter (WiFi) + generic: ET/EH/BT/BH Hybrid Inverter (WiFi) + params: - name: usage choice: ["grid", "pv", "battery"] + - name: host + example: 192.168.1.26 + render: | type: custom {{- if eq .usage "grid" }} power: source: aa55udp host: {{ .host }} + id: 247 register: 35139 count: 2 decode: int32be energy: source: aa55udp host: {{ .host }} + id: 247 register: 36017 count: 2 decode: float32be @@ -30,12 +36,14 @@ render: | power: source: aa55udp host: {{ .host }} + id: 247 register: 35137 count: 2 decode: int32be energy: source: aa55udp host: {{ .host }} + id: 247 register: 35191 count: 2 decode: uint32be @@ -45,12 +53,14 @@ render: | power: source: aa55udp host: {{ .host }} + id: 247 register: 35182 count: 2 decode: int32be energy: source: aa55udp host: {{ .host }} + id: 247 register: 35209 count: 2 decode: uint32be @@ -58,6 +68,7 @@ render: | soc: source: aa55udp host: {{ .host }} + id: 247 register: 37007 count: 1 decode: uint16be From cd02ce1fb9d1db44d2345846d564532d4d3c2975 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 6 Apr 2026 10:13:48 +0200 Subject: [PATCH 0256/1580] Add switch devices (#28803) --- api/feature.go | 5 +- api/feature_enumer.go | 76 ++++++++++--------- charger/embed_test.go | 25 ++++++ charger/vaillant.go | 2 +- templates/definition/charger/emsesp.yaml | 1 + templates/definition/charger/homewizard.yaml | 1 + templates/definition/charger/lg-therma.yaml | 1 + templates/definition/charger/luxtronik.yaml | 1 + templates/definition/charger/stiebel-lwa.yaml | 1 + templates/definition/charger/stiebel-wpm.yaml | 1 + templates/definition/charger/viessmann.yaml | 1 + .../definition/charger/weishaupt-wpm.yaml | 1 + util/templates/includes/heatpumpswitch.tpl | 7 ++ util/templates/includes/switchsocket.tpl | 1 + 14 files changed, 85 insertions(+), 39 deletions(-) create mode 100644 charger/embed_test.go create mode 100644 util/templates/includes/heatpumpswitch.tpl diff --git a/api/feature.go b/api/feature.go index 452a3b268..117e4a338 100644 --- a/api/feature.go +++ b/api/feature.go @@ -6,8 +6,9 @@ type Feature int const ( _ Feature = iota CoarseCurrent // charger - IntegratedDevice // charger - Heating // charger - heating device + IntegratedDevice // charger - always connected - no vehicle, no charging sessions + SwitchDevice // charger - no current control - heat pumps or switch sockets + Heating // charger - heating device - soc ist temperature (°C) Continuous // charger - heating device where disabled means "normal operation" Average // tariff Cacheable // tariff diff --git a/api/feature_enumer.go b/api/feature_enumer.go index cf271aa77..65a9b871b 100644 --- a/api/feature_enumer.go +++ b/api/feature_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _FeatureName = "CoarseCurrentIntegratedDeviceHeatingContinuousAverageCacheableOfflineRetryableStreamingWelcomeCharge" +const _FeatureName = "CoarseCurrentIntegratedDeviceSwitchDeviceHeatingContinuousAverageCacheableOfflineRetryableStreamingWelcomeCharge" -var _FeatureIndex = [...]uint8{0, 13, 29, 36, 46, 53, 62, 69, 78, 87, 100} +var _FeatureIndex = [...]uint8{0, 13, 29, 41, 48, 58, 65, 74, 81, 90, 99, 112} -const _FeatureLowerName = "coarsecurrentintegrateddeviceheatingcontinuousaveragecacheableofflineretryablestreamingwelcomecharge" +const _FeatureLowerName = "coarsecurrentintegrateddeviceswitchdeviceheatingcontinuousaveragecacheableofflineretryablestreamingwelcomecharge" func (i Feature) String() string { i -= 1 @@ -27,52 +27,56 @@ func _FeatureNoOp() { var x [1]struct{} _ = x[CoarseCurrent-(1)] _ = x[IntegratedDevice-(2)] - _ = x[Heating-(3)] - _ = x[Continuous-(4)] - _ = x[Average-(5)] - _ = x[Cacheable-(6)] - _ = x[Offline-(7)] - _ = x[Retryable-(8)] - _ = x[Streaming-(9)] - _ = x[WelcomeCharge-(10)] + _ = x[SwitchDevice-(3)] + _ = x[Heating-(4)] + _ = x[Continuous-(5)] + _ = x[Average-(6)] + _ = x[Cacheable-(7)] + _ = x[Offline-(8)] + _ = x[Retryable-(9)] + _ = x[Streaming-(10)] + _ = x[WelcomeCharge-(11)] } -var _FeatureValues = []Feature{CoarseCurrent, IntegratedDevice, Heating, Continuous, Average, Cacheable, Offline, Retryable, Streaming, WelcomeCharge} +var _FeatureValues = []Feature{CoarseCurrent, IntegratedDevice, SwitchDevice, Heating, Continuous, Average, Cacheable, Offline, Retryable, Streaming, WelcomeCharge} var _FeatureNameToValueMap = map[string]Feature{ _FeatureName[0:13]: CoarseCurrent, _FeatureLowerName[0:13]: CoarseCurrent, _FeatureName[13:29]: IntegratedDevice, _FeatureLowerName[13:29]: IntegratedDevice, - _FeatureName[29:36]: Heating, - _FeatureLowerName[29:36]: Heating, - _FeatureName[36:46]: Continuous, - _FeatureLowerName[36:46]: Continuous, - _FeatureName[46:53]: Average, - _FeatureLowerName[46:53]: Average, - _FeatureName[53:62]: Cacheable, - _FeatureLowerName[53:62]: Cacheable, - _FeatureName[62:69]: Offline, - _FeatureLowerName[62:69]: Offline, - _FeatureName[69:78]: Retryable, - _FeatureLowerName[69:78]: Retryable, - _FeatureName[78:87]: Streaming, - _FeatureLowerName[78:87]: Streaming, - _FeatureName[87:100]: WelcomeCharge, - _FeatureLowerName[87:100]: WelcomeCharge, + _FeatureName[29:41]: SwitchDevice, + _FeatureLowerName[29:41]: SwitchDevice, + _FeatureName[41:48]: Heating, + _FeatureLowerName[41:48]: Heating, + _FeatureName[48:58]: Continuous, + _FeatureLowerName[48:58]: Continuous, + _FeatureName[58:65]: Average, + _FeatureLowerName[58:65]: Average, + _FeatureName[65:74]: Cacheable, + _FeatureLowerName[65:74]: Cacheable, + _FeatureName[74:81]: Offline, + _FeatureLowerName[74:81]: Offline, + _FeatureName[81:90]: Retryable, + _FeatureLowerName[81:90]: Retryable, + _FeatureName[90:99]: Streaming, + _FeatureLowerName[90:99]: Streaming, + _FeatureName[99:112]: WelcomeCharge, + _FeatureLowerName[99:112]: WelcomeCharge, } var _FeatureNames = []string{ _FeatureName[0:13], _FeatureName[13:29], - _FeatureName[29:36], - _FeatureName[36:46], - _FeatureName[46:53], - _FeatureName[53:62], - _FeatureName[62:69], - _FeatureName[69:78], - _FeatureName[78:87], - _FeatureName[87:100], + _FeatureName[29:41], + _FeatureName[41:48], + _FeatureName[48:58], + _FeatureName[58:65], + _FeatureName[65:74], + _FeatureName[74:81], + _FeatureName[81:90], + _FeatureName[90:99], + _FeatureName[99:112], } // FeatureString retrieves an enum value from the enum constants string name. diff --git a/charger/embed_test.go b/charger/embed_test.go new file mode 100644 index 000000000..c69d37ed4 --- /dev/null +++ b/charger/embed_test.go @@ -0,0 +1,25 @@ +package charger + +import ( + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/stretchr/testify/require" +) + +func TestEmbed(t *testing.T) { + embed := embed{ + Icon_: "heatpump", + Features_: []api.Feature{api.Continuous, api.Heating, api.IntegratedDevice}, + } + + other := map[string]any{ + "features": []string{"switchdevice"}, + } + + require.NoError(t, util.DecodeOther(other, &embed)) + + // note: slices are not merged + require.Len(t, embed.Features_, 1) +} diff --git a/charger/vaillant.go b/charger/vaillant.go index 33dfa393f..ed3877527 100644 --- a/charger/vaillant.go +++ b/charger/vaillant.go @@ -58,7 +58,7 @@ func NewVaillantFromConfig(ctx context.Context, other map[string]any) (api.Charg }{ embed: embed{ Icon_: "heatpump", - Features_: []api.Feature{api.Continuous, api.Heating, api.IntegratedDevice}, + Features_: []api.Feature{api.Continuous, api.Heating, api.IntegratedDevice, api.SwitchDevice}, }, Realm: sensonet.REALM_GERMANY, Cache: time.Minute, diff --git a/templates/definition/charger/emsesp.yaml b/templates/definition/charger/emsesp.yaml index 677183e7a..5ad96970b 100644 --- a/templates/definition/charger/emsesp.yaml +++ b/templates/definition/charger/emsesp.yaml @@ -192,3 +192,4 @@ render: | uri: http://{{ .host }}/api/boiler/dhw/settemp jq: .value {{- end }} + {{ include "heatpumpswitch" . }} diff --git a/templates/definition/charger/homewizard.yaml b/templates/definition/charger/homewizard.yaml index fd839424a..01f44d4d4 100644 --- a/templates/definition/charger/homewizard.yaml +++ b/templates/definition/charger/homewizard.yaml @@ -8,3 +8,4 @@ params: render: | type: homewizard uri: http://{{ .host }} + {{ include "switchsocket" . }} diff --git a/templates/definition/charger/lg-therma.yaml b/templates/definition/charger/lg-therma.yaml index bad09e27d..1c45b8ea8 100644 --- a/templates/definition/charger/lg-therma.yaml +++ b/templates/definition/charger/lg-therma.yaml @@ -104,3 +104,4 @@ render: | type: input encoding: uint16 {{- end }} + {{ include "heatpumpswitch" . }} diff --git a/templates/definition/charger/luxtronik.yaml b/templates/definition/charger/luxtronik.yaml index 2e72ff302..8395cb1ca 100644 --- a/templates/definition/charger/luxtronik.yaml +++ b/templates/definition/charger/luxtronik.yaml @@ -277,3 +277,4 @@ render: | type: input decode: uint16 scale: 0.1 + {{ include "heatpumpswitch" . }} diff --git a/templates/definition/charger/stiebel-lwa.yaml b/templates/definition/charger/stiebel-lwa.yaml index daed38336..eb1677b39 100644 --- a/templates/definition/charger/stiebel-lwa.yaml +++ b/templates/definition/charger/stiebel-lwa.yaml @@ -110,3 +110,4 @@ render: | encoding: int16 scale: 0.1 {{- end }} + {{ include "heatpumpswitch" . }} diff --git a/templates/definition/charger/stiebel-wpm.yaml b/templates/definition/charger/stiebel-wpm.yaml index ac7012b8e..872c5251e 100644 --- a/templates/definition/charger/stiebel-wpm.yaml +++ b/templates/definition/charger/stiebel-wpm.yaml @@ -107,3 +107,4 @@ render: | encoding: int16 scale: 0.1 {{- end }} + {{ include "heatpumpswitch" . }} diff --git a/templates/definition/charger/viessmann.yaml b/templates/definition/charger/viessmann.yaml index c682522ea..f159ee2c8 100644 --- a/templates/definition/charger/viessmann.yaml +++ b/templates/definition/charger/viessmann.yaml @@ -205,3 +205,4 @@ render: | set: source: error error: ErrNotAvailable + {{ include "heatpumpswitch" . }} diff --git a/templates/definition/charger/weishaupt-wpm.yaml b/templates/definition/charger/weishaupt-wpm.yaml index bf36f203d..a79e9c306 100644 --- a/templates/definition/charger/weishaupt-wpm.yaml +++ b/templates/definition/charger/weishaupt-wpm.yaml @@ -122,3 +122,4 @@ render: | encoding: int16 scale: 0.1 {{- end }} + {{ include "heatpumpswitch" . }} diff --git a/util/templates/includes/heatpumpswitch.tpl b/util/templates/includes/heatpumpswitch.tpl new file mode 100644 index 000000000..e94ce1a60 --- /dev/null +++ b/util/templates/includes/heatpumpswitch.tpl @@ -0,0 +1,7 @@ +{{ define "heatpumpswitch" }} +features: +- continuous +- heating +- integrateddevice +- switchdevice +{{- end }} diff --git a/util/templates/includes/switchsocket.tpl b/util/templates/includes/switchsocket.tpl index 524f069de..01b1056e9 100644 --- a/util/templates/includes/switchsocket.tpl +++ b/util/templates/includes/switchsocket.tpl @@ -1,6 +1,7 @@ {{ define "switchsocket" }} standbypower: {{ .standbypower }} features: +- switchdevice {{- if and .integrateddevice (ne .integrateddevice "false") }} - integrateddevice {{- end }} From 529e169f8996e9423564b15b00ccfe3178eab1cc Mon Sep 17 00:00:00 2001 From: sgiffhorn <143400305+sgiffhorn@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:17:13 +0200 Subject: [PATCH 0257/1580] Add Marstek Venus A, D and fix Venus E, E Gen 2.0, Venus C and E Gen 3.0 (#28855) --- .../definition/meter/marstek-venus-a.yaml | 192 ++++++++++++++++++ .../definition/meter/marstek-venus-d.yaml | 192 ++++++++++++++++++ ...tek-venus.yaml => marstek-venus-e-v3.yaml} | 24 ++- .../definition/meter/marstek-venus-e.yaml | 162 +++++++++++++++ 4 files changed, 561 insertions(+), 9 deletions(-) create mode 100644 templates/definition/meter/marstek-venus-a.yaml create mode 100644 templates/definition/meter/marstek-venus-d.yaml rename templates/definition/meter/{marstek-venus.yaml => marstek-venus-e-v3.yaml} (90%) create mode 100644 templates/definition/meter/marstek-venus-e.yaml diff --git a/templates/definition/meter/marstek-venus-a.yaml b/templates/definition/meter/marstek-venus-a.yaml new file mode 100644 index 000000000..97e81f79d --- /dev/null +++ b/templates/definition/meter/marstek-venus-a.yaml @@ -0,0 +1,192 @@ +template: marstek-venus-a +products: + - brand: Marstek + description: + generic: Venus A +capabilities: ["battery-control"] +params: + - name: usage + choice: ["pv", "battery"] + - name: modbus + choice: ["rs485", "tcpip"] + baudrate: 115200 + comset: 8N1 + - preset: battery-params + - name: capacity + default: 2.12 + help: + de: Basisgerät 2.12 kWh, jede Erweiterung zzgl. 2.12 kWh + en: Base unit 2.12 kWh, each extension plus 2.12 kWh + - name: minsoc + default: 11 + - name: maxsoc + default: 100 + - name: maxchargepower + default: 1200 + - name: work_mode_normal + default: 1 + advanced: true + description: + en: Work mode for Normal state + de: Work mode für Normal-Modus + help: + en: 0=manual, 1=anti-feed, 2=trade mode. + de: 0=manuell, 1=Eigenverbrauch, 2=AI-Optimierung. +render: | + type: custom + {{- if eq .usage "pv" }} + power: + source: calc + add: + - source: modbus + {{- include "modbus" . | indent 4 }} + register: + address: 30037 # MPPT1 power (W) + type: holding + decode: uint16 + scale: 0.1 + - source: modbus + {{- include "modbus" . | indent 4 }} + register: + address: 30038 # MPPT2 power (W) + type: holding + decode: uint16 + scale: 0.1 + - source: modbus + {{- include "modbus" . | indent 4 }} + register: + address: 30039 # MPPT3 power (W) + type: holding + decode: uint16 + scale: 0.1 + - source: modbus + {{- include "modbus" . | indent 4 }} + register: + address: 30040 # MPPT4 power (W) + type: holding + decode: uint16 + scale: 0.1 + {{- end }} + {{- if eq .usage "battery" }} + power: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 30001 # Battery power (W) + type: holding + decode: int16 + scale: -1 + energy: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 33002 # Total energy discharged from battery (kWh) + type: holding + decode: uint32 + scale: 0.01 + soc: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 32104 # Battery SOC (%) + type: holding + decode: uint16 + batterymode: + source: switch + switch: + - case: 1 # normal + set: + source: sequence + set: + # Enable RS485 Control Mode + - source: const + value: 21930 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Enabled + type: writesingle + decode: uint16 + # Set User Work Mode + - source: const + value: {{ .work_mode_normal }} + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 43000 # User Work Mode + type: writesingle + decode: uint16 + # Disable RS485 Control Mode + - source: const + value: 21947 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Disabled + type: writesingle + decode: uint16 + - case: 2 # hold + set: + source: sequence + set: + # Enable RS485 Control Mode + - source: const + value: 21930 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Enabled + type: writesingle + decode: uint16 + # Set Force Charge/Discharge to Stop + - source: const + value: 0 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42010 # Force Charge/Discharge = Stop + type: writesingle + decode: uint16 + # Do not disable RS485 Control Mode because it will reset the device and let it charge/discharge again + - case: 3 # charge + set: + source: sequence + set: + # Enable RS485 Control Mode + - source: const + value: 21930 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Enabled + type: writesingle + decode: uint16 + # Set Force Charge/Discharge to Charge + - source: const + value: 1 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42010 # Force Charge/Discharge = Charge + type: writesingle + decode: uint16 + # Set Forcible Charge Power + - source: const + value: {{ .maxchargepower }} + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42020 # Forcible Charge Power + type: writesingle + decode: uint16 + # Do not disable RS485 Control Mode because it will reset the device and let it charge/discharge again + {{- include "battery-params" . }} + {{- end }} diff --git a/templates/definition/meter/marstek-venus-d.yaml b/templates/definition/meter/marstek-venus-d.yaml new file mode 100644 index 000000000..e3a965a69 --- /dev/null +++ b/templates/definition/meter/marstek-venus-d.yaml @@ -0,0 +1,192 @@ +template: marstek-venus-d +products: + - brand: Marstek + description: + generic: Venus D +capabilities: ["battery-control"] +params: + - name: usage + choice: ["pv", "battery"] + - name: modbus + choice: ["rs485", "tcpip"] + baudrate: 115200 + comset: 8N1 + - preset: battery-params + - name: capacity + default: 2.56 + help: + de: Basisgerät 2.56 kWh, jede Erweiterung zzgl. 2.56 kWh + en: Base unit 2.56 kWh, each extension plus 2.56 kWh + - name: minsoc + default: 11 + - name: maxsoc + default: 100 + - name: maxchargepower + default: 2200 + - name: work_mode_normal + default: 1 + advanced: true + description: + en: Work mode for Normal state + de: Work mode für Normal-Modus + help: + en: 0=manual, 1=anti-feed, 2=trade mode. + de: 0=manuell, 1=Eigenverbrauch, 2=AI-Optimierung. +render: | + type: custom + {{- if eq .usage "pv" }} + power: + source: calc + add: + - source: modbus + {{- include "modbus" . | indent 4 }} + register: + address: 30037 # MPPT1 power (W) + type: holding + decode: uint16 + scale: 0.1 + - source: modbus + {{- include "modbus" . | indent 4 }} + register: + address: 30038 # MPPT2 power (W) + type: holding + decode: uint16 + scale: 0.1 + - source: modbus + {{- include "modbus" . | indent 4 }} + register: + address: 30039 # MPPT3 power (W) + type: holding + decode: uint16 + scale: 0.1 + - source: modbus + {{- include "modbus" . | indent 4 }} + register: + address: 30040 # MPPT4 power (W) + type: holding + decode: uint16 + scale: 0.1 + {{- end }} + {{- if eq .usage "battery" }} + power: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 30001 # Battery power (W) + type: holding + decode: int16 + scale: -1 + energy: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 33002 # Total energy discharged from battery (kWh) + type: holding + decode: uint32 + scale: 0.01 + soc: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 32104 # Battery SOC (%) + type: holding + decode: uint16 + batterymode: + source: switch + switch: + - case: 1 # normal + set: + source: sequence + set: + # Enable RS485 Control Mode + - source: const + value: 21930 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Enabled + type: writesingle + decode: uint16 + # Set User Work Mode + - source: const + value: {{ .work_mode_normal }} + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 43000 # User Work Mode + type: writesingle + decode: uint16 + # Disable RS485 Control Mode + - source: const + value: 21947 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Disabled + type: writesingle + decode: uint16 + - case: 2 # hold + set: + source: sequence + set: + # Enable RS485 Control Mode + - source: const + value: 21930 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Enabled + type: writesingle + decode: uint16 + # Set Force Charge/Discharge to Stop + - source: const + value: 0 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42010 # Force Charge/Discharge = Stop + type: writesingle + decode: uint16 + # Do not disable RS485 Control Mode because it will reset the device and let it charge/discharge again + - case: 3 # charge + set: + source: sequence + set: + # Enable RS485 Control Mode + - source: const + value: 21930 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Enabled + type: writesingle + decode: uint16 + # Set Force Charge/Discharge to Charge + - source: const + value: 1 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42010 # Force Charge/Discharge = Charge + type: writesingle + decode: uint16 + # Set Forcible Charge Power + - source: const + value: {{ .maxchargepower }} + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42020 # Forcible Charge Power + type: writesingle + decode: uint16 + # Do not disable RS485 Control Mode because it will reset the device and let it charge/discharge again + {{- include "battery-params" . }} + {{- end }} diff --git a/templates/definition/meter/marstek-venus.yaml b/templates/definition/meter/marstek-venus-e-v3.yaml similarity index 90% rename from templates/definition/meter/marstek-venus.yaml rename to templates/definition/meter/marstek-venus-e-v3.yaml index 93cbd166d..353380cd6 100644 --- a/templates/definition/meter/marstek-venus.yaml +++ b/templates/definition/meter/marstek-venus-e-v3.yaml @@ -1,8 +1,8 @@ -template: marstek-venus +template: marstek-venus-e-v3 products: - brand: Marstek description: - generic: Venus Battery Storage + generic: Venus E Gen 3.0 capabilities: ["battery-control"] params: - name: usage @@ -13,9 +13,7 @@ params: comset: 8N1 - preset: battery-params - name: capacity - help: - de: Venus-E 5.12 kWh, Venus-C 2.56 kWh - en: Venus-E 5.12 kWh, Venus-C 2.56 kWh + default: 5.12 - name: minsoc default: 11 - name: maxsoc @@ -37,18 +35,26 @@ render: | source: modbus {{- include "modbus" . | indent 2 }} register: - address: 32202 # AC Power (Watt) + address: 30006 # AC Power (W) type: holding - decode: int32 + decode: int16 scale: 1 + energy: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 33002 # Total energy discharged from battery (kWh) + type: holding + decode: uint32 + scale: 0.01 soc: source: modbus {{- include "modbus" . | indent 2 }} register: - address: 32104 # Battery SOC (%) + address: 34002 # Battery SOC (%) type: holding decode: uint16 - scale: 1 + scale: 0.1 batterymode: source: switch switch: diff --git a/templates/definition/meter/marstek-venus-e.yaml b/templates/definition/meter/marstek-venus-e.yaml new file mode 100644 index 000000000..a408aed2c --- /dev/null +++ b/templates/definition/meter/marstek-venus-e.yaml @@ -0,0 +1,162 @@ +template: marstek-venus-e +covers: ["marstek-venus"] +products: + - brand: Marstek + description: + generic: Venus E + - brand: Marstek + description: + generic: Venus E Gen 2.0 + - brand: Marstek + description: + generic: Venus C +capabilities: ["battery-control"] +params: + - name: usage + choice: ["battery"] + - name: modbus + choice: ["rs485", "tcpip"] + baudrate: 115200 + comset: 8N1 + - preset: battery-params + - name: capacity + default: 5.12 + - name: minsoc + default: 11 + - name: maxsoc + default: 100 + - name: maxchargepower + default: 2500 + - name: work_mode_normal + default: 1 + advanced: true + description: + en: Work mode for Normal state + de: Work mode für Normal-Modus + help: + en: 0=manual, 1=anti-feed, 2=trade mode. + de: 0=manuell, 1=Eigenverbrauch, 2=AI-Optimierung. +render: | + type: custom + power: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 32202 # AC Power (W) + type: holding + decode: int32 + scale: 1 + energy: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 33002 # Total energy discharged from battery (kWh) + type: holding + decode: uint32 + scale: 0.01 + soc: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 32104 # Battery SOC (%) + type: holding + decode: uint16 + scale: 1 + batterymode: + source: switch + switch: + - case: 1 # normal + set: + source: sequence + set: + # Enable RS485 Control Mode + - source: const + value: 21930 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Enabled + type: writesingle + decode: uint16 + # Set User Work Mode + - source: const + value: {{ .work_mode_normal }} + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 43000 # User Work Mode + type: writesingle + decode: uint16 + # Disable RS485 Control Mode + - source: const + value: 21947 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Disabled + type: writesingle + decode: uint16 + - case: 2 # hold + set: + source: sequence + set: + # Enable RS485 Control Mode + - source: const + value: 21930 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Enabled + type: writesingle + decode: uint16 + # Set Force Charge/Discharge to Stop + - source: const + value: 0 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42010 # Force Charge/Discharge = Stop + type: writesingle + decode: uint16 + # Do not disable RS485 Control Mode because it will reset the device and let it charge/discharge again + - case: 3 # charge + set: + source: sequence + set: + # Enable RS485 Control Mode + - source: const + value: 21930 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42000 # RS485 Control Mode = Enabled + type: writesingle + decode: uint16 + # Set Force Charge/Discharge to Charge + - source: const + value: 1 + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42010 # Force Charge/Discharge = Charge + type: writesingle + decode: uint16 + # Set Forcible Charge Power + - source: const + value: {{ .maxchargepower }} + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 42020 # Forcible Charge Power + type: writesingle + decode: uint16 + # Do not disable RS485 Control Mode because it will reset the device and let it charge/discharge again + {{- include "battery-params" . }} From 3967594b798b04684db3f4fcc85a9de7dec2ce76 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 6 Apr 2026 16:18:08 +0200 Subject: [PATCH 0258/1580] Fix auth handler race condition (#28864) --- server/http_auth.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/http_auth.go b/server/http_auth.go index 537f1bb14..4b89a8f26 100644 --- a/server/http_auth.go +++ b/server/http_auth.go @@ -164,6 +164,11 @@ func logoutHandler(w http.ResponseWriter, r *http.Request) { func ensureAuthHandler(authObject auth.Auth) mux.MiddlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if next == nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + if authObject.GetAuthMode() == auth.Disabled { next.ServeHTTP(w, r) return From c785c54bbe9178bafab3893b2a4cac9dfbc8e40b Mon Sep 17 00:00:00 2001 From: JANMCFLY98 <144254342+JANMCFLY98@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:22:36 +0200 Subject: [PATCH 0259/1580] HomeAssistant: add phase switching (#27896) --- charger/homeassistant.go | 23 ++++++++++++++-- charger/homeassistant_decorators.go | 26 ++++++++++++++++++- .../definition/charger/homeassistant.yaml | 14 ++++++++++ util/homeassistant/connection.go | 15 +++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/charger/homeassistant.go b/charger/homeassistant.go index 852a56850..2f31b6d63 100644 --- a/charger/homeassistant.go +++ b/charger/homeassistant.go @@ -1,11 +1,12 @@ package charger -//go:generate go tool decorate -f decorateHomeAssistant -b *HomeAssistant -r api.Charger -t api.Meter,api.MeterEnergy,api.PhaseCurrents,api.PhaseVoltages +//go:generate go tool decorate -f decorateHomeAssistant -b *HomeAssistant -r api.Charger -t api.Meter,api.MeterEnergy,api.PhaseCurrents,api.PhaseVoltages,api.PhaseSwitcher,api.PhaseGetter // -t api.CurrentGetter import ( "errors" "fmt" + "strconv" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" @@ -39,6 +40,7 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) { Energy string // optional - energy sensor Currents []string // optional - current sensors for L1, L2, L3 Voltages []string // optional - voltage sensors for L1, L2, L3 + Phases string // optional - select entity for 1p/3p phase switching } if err := util.DecodeOther(other, &cc); err != nil { @@ -76,6 +78,8 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) { // decorators for optional interfaces var power, energy func() (float64, error) var currents, voltages func() (float64, float64, float64, error) + var phases1p3p func(int) error + var phasesG func() (int, error) if cc.Power != "" { power = func() (float64, error) { return conn.GetFloatState(cc.Power) } @@ -98,7 +102,22 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) { return nil, fmt.Errorf("voltages: %w", err) } - return decorateHomeAssistant(c, power, energy, currents, voltages), nil + // phase switching (optional) + if cc.Phases != "" { + phases1p3p = func(phases int) error { + return conn.CallSelectService(cc.Phases, strconv.Itoa(phases)) + } + + phasesG = func() (int, error) { + val, err := conn.GetIntState(cc.Phases) + if err != nil { + return 0, err + } + return int(val), nil + } + } + + return decorateHomeAssistant(c, power, energy, currents, voltages, phases1p3p, phasesG), nil } var _ api.Charger = (*HomeAssistant)(nil) diff --git a/charger/homeassistant_decorators.go b/charger/homeassistant_decorators.go index d16f901c7..2040ad1fb 100644 --- a/charger/homeassistant_decorators.go +++ b/charger/homeassistant_decorators.go @@ -8,7 +8,7 @@ import ( "github.com/evcc-io/evcc/api" ) -func decorateHomeAssistant(base *HomeAssistant, meter func() (float64, error), meterEnergy func() (float64, error), phaseCurrents func() (float64, float64, float64, error), phaseVoltages func() (float64, float64, float64, error)) api.Charger { +func decorateHomeAssistant(base *HomeAssistant, meter func() (float64, error), meterEnergy func() (float64, error), phaseCurrents func() (float64, float64, float64, error), phaseVoltages func() (float64, float64, float64, error), phaseSwitcher func(int) error, phaseGetter func() (int, error)) api.Charger { caps := make(map[reflect.Type]any) if meter != nil { @@ -27,6 +27,14 @@ func decorateHomeAssistant(base *HomeAssistant, meter func() (float64, error), m caps[reflect.TypeFor[api.PhaseVoltages]()] = &decorateHomeAssistantPhaseVoltagesImpl{phaseVoltages: phaseVoltages} } + if phaseSwitcher != nil { + caps[reflect.TypeFor[api.PhaseSwitcher]()] = &decorateHomeAssistantPhaseSwitcherImpl{phaseSwitcher: phaseSwitcher} + } + + if phaseGetter != nil { + caps[reflect.TypeFor[api.PhaseGetter]()] = &decorateHomeAssistantPhaseGetterImpl{phaseGetter: phaseGetter} + } + if len(caps) == 0 { return base } @@ -68,6 +76,22 @@ func (impl *decorateHomeAssistantPhaseCurrentsImpl) Currents() (float64, float64 return impl.phaseCurrents() } +type decorateHomeAssistantPhaseGetterImpl struct { + phaseGetter func() (int, error) +} + +func (impl *decorateHomeAssistantPhaseGetterImpl) GetPhases() (int, error) { + return impl.phaseGetter() +} + +type decorateHomeAssistantPhaseSwitcherImpl struct { + phaseSwitcher func(int) error +} + +func (impl *decorateHomeAssistantPhaseSwitcherImpl) Phases1p3p(p0 int) error { + return impl.phaseSwitcher(p0) +} + type decorateHomeAssistantPhaseVoltagesImpl struct { phaseVoltages func() (float64, float64, float64, error) } diff --git a/templates/definition/charger/homeassistant.yaml b/templates/definition/charger/homeassistant.yaml index 4dc44a19b..9cc8c82bb 100644 --- a/templates/definition/charger/homeassistant.yaml +++ b/templates/definition/charger/homeassistant.yaml @@ -142,6 +142,17 @@ params: help: en: Entity ID for L3 voltage measurement in volts de: Entitäts-ID für L3 Spannungsmessung in Volt + + - name: phases + description: + de: Phasenumschaltungs-Entität + en: Phase switching entity + service: homeassistant/entities?uri={uri}&domain=select,input_select + example: "select.charger_phases" + advanced: true + help: + en: Entity ID for 1p/3p phase switching (select entity with options "1" and "3") + de: Entitäts-ID für 1p/3p Phasenumschaltung (Select-Entität mit Optionen "1" und "3") render: | type: homeassistant uri: {{ .uri }} @@ -163,3 +174,6 @@ render: | - {{ .voltageL2 }} - {{ .voltageL3 }} {{- end }} + {{- if .phases }} + phases: {{ .phases }} + {{- end }} diff --git a/util/homeassistant/connection.go b/util/homeassistant/connection.go index c05e66811..1b7f6456e 100644 --- a/util/homeassistant/connection.go +++ b/util/homeassistant/connection.go @@ -267,6 +267,21 @@ func (c *Connection) CallNumberService(entity string, value float64) error { return c.CallService(domain, "set_value", data) } +// CallSelectService is a convenience method for setting select entity options. +func (c *Connection) CallSelectService(entity, option string) error { + domain, err := domain(entity) + if err != nil { + return err + } + + data := map[string]any{ + "entity_id": entity, + "option": option, + } + + return c.CallService(domain, "select_option", data) +} + // GetPhaseFloatStates retrieves three phase values (currents, voltages, etc.) func (c *Connection) GetPhaseFloatStates(entities []string) (float64, float64, float64, error) { if len(entities) != 3 { From f00cde4972f3688ef5bc3950711f7619aa4967e8 Mon Sep 17 00:00:00 2001 From: Blowfly69 Date: Mon, 6 Apr 2026 16:31:21 +0200 Subject: [PATCH 0260/1580] Deye-hybrid-3p: merge LV and HV templates (BC) (#28670) --- .../definition/meter/deye-hybrid-3p.yaml | 113 ++++++- .../definition/meter/deye-hybrid-hp3.yaml | 301 ------------------ 2 files changed, 101 insertions(+), 313 deletions(-) delete mode 100644 templates/definition/meter/deye-hybrid-hp3.yaml diff --git a/templates/definition/meter/deye-hybrid-3p.yaml b/templates/definition/meter/deye-hybrid-3p.yaml index 179daef24..2ffe1f4c9 100644 --- a/templates/definition/meter/deye-hybrid-3p.yaml +++ b/templates/definition/meter/deye-hybrid-3p.yaml @@ -1,5 +1,5 @@ template: deye-hybrid-3p -covers: ["deye-hybrid"] +covers: ["deye-hybrid", "deye-hybrid-hp3"] products: - brand: Deye description: @@ -16,7 +16,21 @@ params: baudrate: 9600 id: 1 - name: maxacpower + - name: storageunit + choice: ["1", "2"] - preset: battery-params + - name: batterytype + choice: ["lv", "hv"] + required: true + default: "lv" + description: + de: Wechselrichter für LV- oder HV-Batterie + en: Inverter for LV or HV batteries + help: + de: | + "hv" für eine Hochvolt-Batterie mit Nennspannung über 60V verwenden + en: | + Choose "hv" if you are using a high voltage battery with a nominal voltage over 60V - name: maxdischargecurrent required: true default: 100 @@ -27,10 +41,10 @@ params: help: en: | Maximum battery discharge current in Amperes (A) for normal mode. Typical range 50-350A depending on battery capacity. - Power calculation: P(W) = Current(A) * Voltage(V). Example with 48V battery: 100A * 48V = 4800W (4.8kW). + Power calculation: P(W) = Current(A) x Voltage(V). Example with 48V battery: 100A x 48V = 4800W (4.8kW). de: | Maximaler Batterieentladestrom in Ampere (A) für Normalmodus. Typischer Bereich 50-350A abhängig von Batteriekapazität. - Leistungsberechnung: P(W) = Strom(A) * Spannung(V). Beispiel mit 48V Batterie: 100A * 48V = 4800W (4,8kW). + Leistungsberechnung: P(W) = Strom(A) x Spannung(V). Beispiel mit 48V Batterie: 100A x 48V = 4800W (4,8kW). - name: gridchargecurrent required: true default: 60 @@ -41,10 +55,32 @@ params: help: en: | Maximum battery charging current in Amperes (A) from grid for charge mode. Typical range 30-350A depending on battery and inverter capacity. - Power calculation: P(W) = Current(A) * Voltage(V). Example with 48V battery: 60A * 48V = 2880W (2.9kW). + Power calculation: P(W) = Current(A) x Voltage(V). Example with 48V battery: 60A x 48V = 2880W (2.9kW). de: | Maximaler Batterieladestrom in Ampere (A) aus dem Netz für Lademodus. Typischer Bereich 30-350A abhängig von Batterie- und Wechselrichterkapazität. - Leistungsberechnung: P(W) = Strom(A) * Spannung(V). Beispiel mit 48V Batterie: 60A * 48V = 2880W (2,9kW). + Leistungsberechnung: P(W) = Strom(A) x Spannung(V). Beispiel mit 48V Batterie: 60A x 48V = 2880W (2,9kW). + - name: includegenport + type: bool + advanced: true + description: + de: GEN-Anschluss als Solar-Eingang verwenden + en: Treat GEN port as solar input + help: + de: | + Wenn aktiviert, wird der GEN-Anschluss des Wechselrichters als zusätzlicher Solar-Eingang behandelt. Dadurch werden die Leistung und Energie, die über den GEN-Port eingespeist werden, zum Gesamt-Solarertrag addiert. + en: | + When enabled, the GEN port of the inverter will be treated as an additional solar input. This will add the power and energy fed in through the GEN port to the total solar yield. + - name: firmware1098 + type: bool + advanced: true + description: + de: Firmware 1098 oder neuer (nur für HV Versionen) + en: Firmware 1098 or newer (only for HV versions) + help: + de: | + Nach einem Firmware-Update für Hochvolt-Wechselrichter haben einige Registerwerte ihren Skalierungsfaktor geändert. Wenn der Wechselrichter die Firmware-Version 1098 oder neuer hat, muss diese Option aktiviert werden, um korrekte Werte zu erhalten. + en: | + After a firmware update of the high voltage inverter, some register values have changed their scaling factor. If your inverter has firmware version 1098 or newer, you need to enable this option to get correct values. render: | type: custom {{- if eq .usage "grid" }} @@ -62,7 +98,9 @@ render: | address: 522 # "Total_GridBuy_Power Wh" type: holding decode: uint32s + {{- if and (eq .batterytype "hv") (not .firmware1098) }} scale: 0.1 + {{- end }} currents: - source: modbus {{- include "modbus" . | indent 4 }} @@ -96,32 +134,69 @@ render: | address: 672 # "PV1 input power" type: holding decode: uint16 + {{- if eq .batterytype "hv" }} + scale: 10 + {{- end }} - source: modbus {{- include "modbus" . | indent 4 }} register: address: 673 # "PV2 input power" type: holding decode: uint16 + {{- if eq .batterytype "hv" }} + scale: 10 + {{- end }} - source: modbus {{- include "modbus" . | indent 4 }} register: address: 674 # "PV3 input power" type: holding decode: uint16 + {{- if eq .batterytype "hv" }} + scale: 10 + {{- end }} - source: modbus {{- include "modbus" . | indent 4 }} register: address: 675 # "PV4 input power" type: holding decode: uint16 + {{- if eq .batterytype "hv" }} + scale: 10 + {{- end }} + {{- if .includegenport }} + - source: modbus + {{- include "modbus" . | indent 4 }} + register: + address: 667 # "low word of input power at GEN-Port" + type: holding + decode: int16 # value is part of a signed 32-bit value; high word is at 671 but non-adjacent + # NOTE: Only the low 16 bits of GEN power are used here. This is sufficient up to ±32 kW, + # which covers existing inverter variants. + {{- end }} energy: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 534 # "Total_PV_Power_Wh" - type: holding - decode: uint32s - scale: 0.1 + source: calc + add: + - source: modbus + {{- include "modbus" . | indent 4 }} + register: + address: 534 # "Total_PV_Power_Wh" + type: holding + decode: uint32s + {{- if and (eq .batterytype "hv") (not .firmware1098) }} + scale: 0.1 + {{- end }} + {{- if .includegenport }} + - source: modbus + {{- include "modbus" . | indent 4 }} + register: + address: 537 # "Total_Gen_Power_Wh" + type: holding + decode: int32s + {{- if and (eq .batterytype "hv") (not .firmware1098) }} + scale: 0.1 + {{- end }} + {{- end }} maxacpower: {{ .maxacpower }} {{- end }} {{- if eq .usage "battery" }} @@ -129,9 +204,17 @@ render: | source: modbus {{- include "modbus" . | indent 2 }} register: + {{- if eq .storageunit "1" }} address: 590 # "Battery output power" + {{- end }} + {{- if eq .storageunit "2" }} + address: 595 # "Battery2 output power" + {{- end }} type: holding decode: int16 + {{- if eq .batterytype "hv" }} + scale: 10 + {{- end }} energy: source: modbus {{- include "modbus" . | indent 2 }} @@ -139,12 +222,18 @@ render: | address: 518 # "Total discharge of the battery (Wh)" type: holding decode: uint32s + {{- if and (eq .batterytype "hv") (not .firmware1098) }} scale: 0.1 + {{- end }} soc: source: modbus {{- include "modbus" . | indent 2 }} register: + {{- if eq .storageunit "1" }} address: 588 # "battery capacity" + {{- else }} + address: 589 # "battery2 capacity" + {{- end }} type: holding decode: uint16 batterymode: diff --git a/templates/definition/meter/deye-hybrid-hp3.yaml b/templates/definition/meter/deye-hybrid-hp3.yaml deleted file mode 100644 index 8ea267279..000000000 --- a/templates/definition/meter/deye-hybrid-hp3.yaml +++ /dev/null @@ -1,301 +0,0 @@ -template: deye-hybrid-hp3 -products: - - brand: Deye - description: - generic: hp3 hybrid inverter - - brand: Sunsynk - description: - generic: hp3 hybrid inverter -capabilities: ["battery-control"] -params: - - name: usage - choice: ["grid", "pv", "battery"] - - name: modbus - choice: ["rs485", "tcpip"] - baudrate: 9600 - id: 1 - - name: maxacpower - - name: storageunit - - preset: battery-params - - name: includegenport - type: bool - advanced: true - description: - de: GEN-Anschluss als Solar-Eingang verwenden - en: Treat GEN port as solar input - - name: firmware1098 - type: bool - advanced: true - description: - de: Firmware 1098 oder neuer - en: Firmware 1098 or newer -render: | - type: custom - {{- if eq .usage "grid" }} - power: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 625 # Grid side total power - type: holding - decode: int16 - energy: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 522 # "Total_GridBuy_Power Wh" - type: holding - decode: uint32s - {{- if ne .firmware1098 "true" }} - scale: 0.1 - {{- end }} - currents: - - source: modbus - {{- include "modbus" . | indent 4 }} - register: - address: 613 # "Out-of-grid - current A" - type: holding - decode: int16 - scale: 0.01 - - source: modbus - {{- include "modbus" . | indent 4 }} - register: - address: 614 # "Out-of-grid - current B" - type: holding - decode: int16 - scale: 0.01 - - source: modbus - {{- include "modbus" . | indent 4 }} - register: - address: 615 # "Out-of-grid - current C" - type: holding - decode: int16 - scale: 0.01 - {{- end }} - {{- if eq .usage "pv" }} - power: - source: calc - add: - - source: modbus - {{- include "modbus" . | indent 4 }} - register: - address: 672 # "PV1 input power" - type: holding - decode: uint16 - scale: 10 - - source: modbus - {{- include "modbus" . | indent 4 }} - register: - address: 673 # "PV2 input power" - type: holding - decode: uint16 - scale: 10 - - source: modbus - {{- include "modbus" . | indent 4 }} - register: - address: 674 # "PV3 input power" - type: holding - decode: uint16 - scale: 10 - - source: modbus - {{- include "modbus" . | indent 4 }} - register: - address: 675 # "PV4 input power" - type: holding - decode: uint16 - scale: 10 - {{- if eq .includegenport "true" }} - - source: modbus - {{- include "modbus" . | indent 4 }} - register: - address: 667 # "low word of input power at GEN-Port" - type: holding - decode: int16 # "value is actually a signed 32 bit value but high word is at reg 671 - scale: 1 - {{- end }} - energy: - source: calc - add: - - source: modbus - {{- include "modbus" . | indent 4 }} - register: - address: 534 # "Total_PV_Power_Wh" - type: holding - decode: uint32s - {{- if ne .firmware1098 "true" }} - scale: 0.1 - {{- end }} - {{- if eq .includegenport "true" }} - - source: modbus - {{- include "modbus" . | indent 4 }} - register: - address: 537 # "Total_Gen_Power_Wh" - type: holding - decode: int32s - {{- if ne .firmware1098 "true" }} - scale: 0.1 - {{- end }} - {{- end }} - maxacpower: {{ .maxacpower }} - {{- end }} - {{- if eq .usage "battery" }} - power: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - {{- if eq .storageunit "1" }} - address: 590 # "Battery output power" - {{- end }} - {{- if eq .storageunit "2" }} - address: 595 # "Battery2 output power" - {{- end }} - type: holding - decode: int16 - scale: 10 - energy: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 518 # "Total discharge of the battery (Wh)" - type: holding - decode: uint32s - {{- if ne .firmware1098 "true" }} - scale: 0.1 - {{- end }} - soc: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - {{- if eq .storageunit "1" }} - address: 588 # "battery capacity" - {{- end }} - {{- if eq .storageunit "2" }} - address: 589 # "battery2 capacity" - {{- end }} - type: holding - decode: uint16 - batterymode: - source: switch - switch: - - case: 1 # normal - set: - source: sequence - set: - - source: const - value: {{ .minsoc }} - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 166 - type: writemultiple - decode: int16 - - source: const - value: 0 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 148 - type: writemultiple - decode: int32s - - source: const - value: 2355 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 149 - type: writemultiple - decode: int32s - - source: const - value: 0 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 172 - type: writemultiple - decode: int32s - - case: 2 # hold - set: - source: sequence - set: - - source: const - value: {{ .maxsoc }} - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 166 - type: writemultiple - decode: int16 - - source: const - value: 0 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 148 # soc time of use 1 - type: writemultiple - decode: int32s - - source: const - value: 2355 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 149 # soc time of use 1 - type: writemultiple - decode: int32s - - source: const - value: 0 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 172 - type: writemultiple - decode: int32s - - case: 3 # charge - set: - source: sequence - set: - - source: const - value: {{ .maxsoc }} - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 166 - type: writemultiple - decode: int16 - - source: const - value: 0 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 148 # soc time of use 1 - type: writemultiple - decode: int32s - - source: const - value: 2355 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 149 # soc time of use 1 - type: writemultiple - decode: int32s - - source: const - value: 1 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 172 - type: writemultiple - decode: int32s - {{- include "battery-params" . }} - {{- end }} From 188a5d8216a6396e6335f2a9bf7c18a845fed6de Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 6 Apr 2026 16:47:01 +0200 Subject: [PATCH 0261/1580] Session: add reference price and CO2 (#28712) --- core/loadpoint_session.go | 7 +++++++ core/session/session.go | 32 +++++++++++++++++--------------- tariff/tariffs.go | 26 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/core/loadpoint_session.go b/core/loadpoint_session.go index 5f8c677e9..6ebe6251f 100644 --- a/core/loadpoint_session.go +++ b/core/loadpoint_session.go @@ -2,11 +2,13 @@ package core import ( "errors" + "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/keys" "github.com/evcc-io/evcc/core/session" "github.com/evcc-io/evcc/core/wrapper" + "github.com/evcc-io/evcc/tariff" "github.com/jinzhu/now" ) @@ -51,6 +53,11 @@ func (lp *Loadpoint) createSession() { } } + if lp.site != nil { + lp.session.ReferencePricePerKWh = tariff.AverageRate(lp.site.GetTariff(api.TariffUsageGrid), 24*time.Hour) + lp.session.ReferenceCo2PerKWh = tariff.AverageRate(lp.site.GetTariff(api.TariffUsageCo2), 24*time.Hour) + } + // energy lp.energyMetrics.Reset() lp.energyMetrics.Publish("session", lp) diff --git a/core/session/session.go b/core/session/session.go index 540bfea96..619c52477 100644 --- a/core/session/session.go +++ b/core/session/session.go @@ -11,21 +11,23 @@ import ( // Session is a single charging session type Session struct { - ID uint `json:"id" csv:"-" gorm:"primarykey"` - Created time.Time `json:"created"` - Finished time.Time `json:"finished"` - Loadpoint string `json:"loadpoint"` - Identifier string `json:"identifier"` - Vehicle string `json:"vehicle"` - Odometer *float64 `json:"odometer" format:"int"` - MeterStart *float64 `json:"meterStart" csv:"Meter Start (kWh)" gorm:"column:meter_start_kwh"` - MeterStop *float64 `json:"meterStop" csv:"Meter Stop (kWh)" gorm:"column:meter_end_kwh"` - ChargedEnergy float64 `json:"chargedEnergy" csv:"Charged Energy (kWh)" gorm:"column:charged_kwh"` - ChargeDuration *time.Duration `json:"chargeDuration" csv:"Charge Duration" gorm:"column:charge_duration"` - SolarPercentage *float64 `json:"solarPercentage" csv:"Solar (%)" gorm:"column:solar_percentage"` - Price *float64 `json:"price" csv:"Price" gorm:"column:price"` - PricePerKWh *float64 `json:"pricePerKWh" csv:"Price/kWh" gorm:"column:price_per_kwh"` - Co2PerKWh *float64 `json:"co2PerKWh" csv:"CO2/kWh (gCO2eq)" gorm:"column:co2_per_kwh"` + ID uint `json:"id" csv:"-" gorm:"primarykey"` + Created time.Time `json:"created"` + Finished time.Time `json:"finished"` + Loadpoint string `json:"loadpoint"` + Identifier string `json:"identifier"` + Vehicle string `json:"vehicle"` + Odometer *float64 `json:"odometer" format:"int"` + MeterStart *float64 `json:"meterStart" csv:"Meter Start (kWh)" gorm:"column:meter_start_kwh"` + MeterStop *float64 `json:"meterStop" csv:"Meter Stop (kWh)" gorm:"column:meter_end_kwh"` + ChargedEnergy float64 `json:"chargedEnergy" csv:"Charged Energy (kWh)" gorm:"column:charged_kwh"` + ChargeDuration *time.Duration `json:"chargeDuration" csv:"Charge Duration" gorm:"column:charge_duration"` + SolarPercentage *float64 `json:"solarPercentage" csv:"Solar (%)" gorm:"column:solar_percentage"` + Price *float64 `json:"price" csv:"Price" gorm:"column:price"` + PricePerKWh *float64 `json:"pricePerKWh" csv:"Price/kWh" gorm:"column:price_per_kwh"` + Co2PerKWh *float64 `json:"co2PerKWh" csv:"CO2/kWh (gCO2eq)" gorm:"column:co2_per_kwh"` + ReferencePricePerKWh *float64 `json:"referencePricePerKWh" csv:"Reference Price/kWh" gorm:"column:reference_price_per_kwh"` + ReferenceCo2PerKWh *float64 `json:"referenceCo2PerKWh" csv:"Reference CO2/kWh (gCO2eq)" gorm:"column:reference_co2_per_kwh"` } // Sessions is a list of sessions diff --git a/tariff/tariffs.go b/tariff/tariffs.go index 3f88a1994..ff12198f4 100644 --- a/tariff/tariffs.go +++ b/tariff/tariffs.go @@ -44,6 +44,32 @@ func Rates(t api.Tariff) api.Rates { return rr } +// AverageRate returns the arithmetic mean of rates in [now, now+d), or nil if unavailable. +func AverageRate(t api.Tariff, d time.Duration) *float64 { + rr := Rates(t) + if rr == nil { + return nil + } + + now := time.Now() + end := now.Add(d) + + var sum float64 + var count int + for _, r := range rr { + if r.Start.Before(end) && r.End.After(now) { + sum += r.Value + count++ + } + } + + if count == 0 { + return nil + } + + return new(sum / float64(count)) +} + func (t *Tariffs) Get(u api.TariffUsage) api.Tariff { // ensure tariff is not a wrapper exists := func(t api.Tariff) bool { From c764bbe964a8a2e5cd27f07da3a598a5e1f66865 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 6 Apr 2026 19:14:44 +0200 Subject: [PATCH 0262/1580] chore: upgrade modules --- go.mod | 52 +++++++++++++-------------- go.sum | 108 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 80 insertions(+), 80 deletions(-) diff --git a/go.mod b/go.mod index deb8e7c68..951979826 100644 --- a/go.mod +++ b/go.mod @@ -12,10 +12,10 @@ require ( github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef - github.com/aws/aws-sdk-go-v2 v1.41.4 - github.com/aws/aws-sdk-go-v2/config v1.32.12 - github.com/aws/aws-sdk-go-v2/credentials v1.19.12 - github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.33.21 + github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2/config v1.32.14 + github.com/aws/aws-sdk-go-v2/credentials v1.19.14 + github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.33.22 github.com/basgys/goxml2json v1.1.0 github.com/basvdlei/gotsmart v0.0.3 github.com/benbjohnson/clock v1.3.5 @@ -32,14 +32,14 @@ require ( github.com/enbility/ship-go v0.6.0 github.com/enbility/spine-go v0.7.0 github.com/evcc-io/openapi-mcp v0.6.0 - github.com/evcc-io/optimizer v0.0.0-20260315083404-e456cb811a4e + github.com/evcc-io/optimizer v0.0.0-20260321154346-f7a5fa8cbe06 github.com/evcc-io/rct v0.2.0 - github.com/evcc-io/tesla-proxy-client v0.0.0-20240221194046-4168b3759701 + github.com/evcc-io/tesla-proxy-client v0.0.0-20260324063928-151fe10796ae github.com/fatih/structs v1.1.0 github.com/getkin/kin-openapi v0.134.0 github.com/glebarez/sqlite v1.11.0 github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 - github.com/go-playground/validator/v10 v10.30.1 + github.com/go-playground/validator/v10 v10.30.2 github.com/go-telegram/bot v1.20.0 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/godbus/dbus/v5 v5.2.2 @@ -51,13 +51,13 @@ require ( github.com/gorilla/mux v1.8.1 github.com/gosimple/slug v1.15.0 github.com/gregdel/pushover v1.4.0 - github.com/grid-x/modbus v0.0.0-20260122103929-e192bc287e6e - github.com/hashicorp/go-version v1.8.0 + github.com/grid-x/modbus v0.0.0-20260325140807-cf9e1b9daae0 + github.com/hashicorp/go-version v1.9.0 github.com/hasura/go-graphql-client v0.15.1 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 github.com/influxdata/influxdb-client-go/v2 v2.14.0 github.com/insomniacslk/tapo v1.0.2 - github.com/itchyny/gojq v0.12.18 + github.com/itchyny/gojq v0.12.19 github.com/jarcoal/httpmock v1.4.1 github.com/jeremywohl/flatten v1.0.1 github.com/jinzhu/now v1.1.5 @@ -74,7 +74,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/muka/go-bluetooth v0.0.0-20240701044517-04c4f09c514e - github.com/nicholas-fedor/shoutrrr v0.14.0 + github.com/nicholas-fedor/shoutrrr v0.14.3 github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/olekukonko/tablewriter v1.1.4 github.com/philippseith/signalr v0.8.0 @@ -104,14 +104,14 @@ require ( go.uber.org/mock v0.6.0 go.yaml.in/yaml/v4 v4.0.0-rc.4 golang.org/x/crypto v0.49.0 - golang.org/x/crypto/x509roots/fallback v0.0.0-20260311141749-982eaa62dfb7 + golang.org/x/crypto/x509roots/fallback v0.0.0-20260323153451-8400f4a93807 golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/text v0.35.0 golang.org/x/tools v0.43.0 - google.golang.org/grpc v1.79.3 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gorm.io/gorm v1.31.1 ) @@ -123,16 +123,16 @@ require ( github.com/andybalholm/cascadia v1.3.3 // indirect github.com/antihax/optional v1.0.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect github.com/azihsoyn/rijndael256 v0.0.0-20200316065338-d14eefa2b66b // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -156,7 +156,7 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 // indirect github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a // indirect @@ -181,7 +181,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect github.com/insomniacslk/xjson v0.0.0-20231023101448-2249e546a131 // indirect - github.com/itchyny/timefmt-go v0.1.7 // indirect + github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -207,7 +207,7 @@ require ( github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/ll v0.1.6 // indirect github.com/pascaldekloe/name v1.0.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -241,14 +241,14 @@ require ( golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/tools/gopls v0.21.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect gopkg.in/go-playground/validator.v9 v9.30.0 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.47.0 // indirect + modernc.org/sqlite v1.48.1 // indirect ) tool ( diff --git a/go.sum b/go.sum index 72336242b..e88854466 100644 --- a/go.sum +++ b/go.sum @@ -56,34 +56,34 @@ github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7h github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= -github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= -github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= -github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= +github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.33.21 h1:wmrtur9xeyeOH4kCjEli8jHrFEClQe2Rx4y8Db5hl5s= -github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.33.21/go.mod h1:C6jqGR0ygJGfIDFwywTcCXkos5Dd+SMe8N35RGNlU5U= +github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.33.22 h1:2PLid0bFtOHTaE/wJFsF1GG8y9IP9UnE3pzIT7nL7O8= +github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.33.22/go.mod h1:1B444G9MawXoeRgdtRqkFIsTvVlGB+OIsdtWVGhNyKc= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/azihsoyn/rijndael256 v0.0.0-20200316065338-d14eefa2b66b h1:/2dABok/UswXOj5rjbR5bZ411ApGBq1pAEZdy5rvFrY= @@ -196,12 +196,12 @@ github.com/evcc-io/ocpp-go v0.0.0-20251212212612-b7f92ee0443b h1:5vFr7wOwoi5ylyb github.com/evcc-io/ocpp-go v0.0.0-20251212212612-b7f92ee0443b/go.mod h1:2kcukDdhui4u730VfnYVWuwzDLgw+mBRGDir/QAyBhg= github.com/evcc-io/openapi-mcp v0.6.0 h1:cq8CWG+3gNQ9ID0ZE8MoDHZxgdJmBZ1fFyt5xAfTRfA= github.com/evcc-io/openapi-mcp v0.6.0/go.mod h1:YyOx4zwr6xVUcchAETHERZ+75cZMIrdcjVIZFwBlE1Q= -github.com/evcc-io/optimizer v0.0.0-20260315083404-e456cb811a4e h1:z3+YaJXA311+X4nMjQfA8g/HRISJ36q2I2MjbjXQjV0= -github.com/evcc-io/optimizer v0.0.0-20260315083404-e456cb811a4e/go.mod h1:cHMeq6GfoXQ8LxqY4LH1wn/hrgYEn+ka5RPI1CF37SY= +github.com/evcc-io/optimizer v0.0.0-20260321154346-f7a5fa8cbe06 h1:Yn0zCt586aeYpmWwx5vj3985euSTP7lji5HmZj80U38= +github.com/evcc-io/optimizer v0.0.0-20260321154346-f7a5fa8cbe06/go.mod h1:cHMeq6GfoXQ8LxqY4LH1wn/hrgYEn+ka5RPI1CF37SY= github.com/evcc-io/rct v0.2.0 h1:qjXKI7NKwW5YpEKEb27MwLMBaR4ne2Kd2cPV2PYTKn4= github.com/evcc-io/rct v0.2.0/go.mod h1:n6MTBU36QOadGlxxiADu86VaT5l0eYdbmFBHF14AD/s= -github.com/evcc-io/tesla-proxy-client v0.0.0-20240221194046-4168b3759701 h1:3JplY3KS6KMDVDNAU+3+KWmSWmoHIU34qwuIpW6SiHk= -github.com/evcc-io/tesla-proxy-client v0.0.0-20240221194046-4168b3759701/go.mod h1:zWtAweBqXJTk3HSrPSecz3Q3a2hAUQ4vOE6paJfn03I= +github.com/evcc-io/tesla-proxy-client v0.0.0-20260324063928-151fe10796ae h1:0ihqBMBjkekSlwD3ZYQIiYite6ZAln9ze60evOE6FkQ= +github.com/evcc-io/tesla-proxy-client v0.0.0-20260324063928-151fe10796ae/go.mod h1:L8AR3E7/MbbvCgnrtzsT6YsgQJtN5gk/LumR1gDBeJM= github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -217,8 +217,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -260,8 +260,8 @@ github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/Nu github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= -github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -329,8 +329,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg= github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= @@ -375,8 +375,8 @@ github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdv github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= -github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= @@ -409,10 +409,10 @@ github.com/insomniacslk/tapo v1.0.2 h1:o2Bz941/Kp3LEjO5kvBbAE9Z4z9BcQMXRols3QkTn github.com/insomniacslk/tapo v1.0.2/go.mod h1:1wuMYu0+alZ4oE4BIzxAwQNveZgbb2tRqiIUwIe7SZE= github.com/insomniacslk/xjson v0.0.0-20231023101448-2249e546a131 h1:bVGPuMhjgFtxVdQGfYnFq+EnCqArOAjLNciow/nArwE= github.com/insomniacslk/xjson v0.0.0-20231023101448-2249e546a131/go.mod h1:Z4EVr4bVv9LZbbje9xyZEyOLpdCOmCvr5S9BJtrdTfw= -github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= -github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= -github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= -github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= +github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= +github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= +github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/jeremywohl/flatten v1.0.1 h1:LrsxmB3hfwJuE+ptGOijix1PIfOoKLJ3Uee/mzbgtrs= @@ -544,8 +544,8 @@ github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxzi github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/nicholas-fedor/shoutrrr v0.14.0 h1:DNgpGjuwA3CCpLr+8+dboB7mPYr5+nfIe+s3KG2Pex0= -github.com/nicholas-fedor/shoutrrr v0.14.0/go.mod h1:8qDD9uIIeQ6qwcdJEqS4TgvpgLuoZ2Vvym+Nc4RFWUA= +github.com/nicholas-fedor/shoutrrr v0.14.3 h1:aBX2iw9a7jl5wfHd3bi9LnS5ucoYIy6KcLH9XVF+gig= +github.com/nicholas-fedor/shoutrrr v0.14.3/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -593,8 +593,8 @@ github.com/pascaldekloe/name v1.0.1 h1:9lnXOHeqeHHnWLbKfH6X98+4+ETVqFqxN09UXSjcM github.com/pascaldekloe/name v1.0.1/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM= github.com/paypal/gatt v0.0.0-20151011220935-4ae819d591cf/go.mod h1:+AwQL2mK3Pd3S+TUwg0tYQjid0q1txyNUJuuSmz8Kdk= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= @@ -834,8 +834,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/crypto/x509roots/fallback v0.0.0-20260311141749-982eaa62dfb7 h1:uX5F+sUnmp3J14eF1J92KuF4wi4GP4lnnEbFfwuNVVU= -golang.org/x/crypto/x509roots/fallback v0.0.0-20260311141749-982eaa62dfb7/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260323153451-8400f4a93807 h1:sQVhWLXbNsa8CTzHOX3IHc7C4Q2JyxI5AweuMQZ/5H0= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260323153451-8400f4a93807/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= @@ -1001,8 +1001,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1012,8 +1012,8 @@ google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRn google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= @@ -1023,8 +1023,8 @@ google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -1092,8 +1092,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= -modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA= +modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= From 960aeb03ebd8ee3ba97ad0059e36a430adc0b86d Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 6 Apr 2026 19:30:20 +0200 Subject: [PATCH 0263/1580] cli: add curtail/dim (#28877) --- cmd/capabilities.go | 54 +++++++++++++++++++++++++++++++++++++++++++++ cmd/charger.go | 10 +++++++++ cmd/dumper.go | 14 ++++++++++++ cmd/flags.go | 6 +++++ cmd/meter.go | 12 ++++++++++ 5 files changed, 96 insertions(+) create mode 100644 cmd/capabilities.go diff --git a/cmd/capabilities.go b/cmd/capabilities.go new file mode 100644 index 000000000..69626974a --- /dev/null +++ b/cmd/capabilities.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "github.com/evcc-io/evcc/api" + "github.com/spf13/cobra" +) + +// handleCurtailFlag handles the --curtail flag for a given device. +// Returns true if the flag was used. +func handleCurtailFlag(cmd *cobra.Command, v any) bool { + if !cmd.Flags().Changed(flagCurtail) { + return false + } + + val, err := cmd.Flags().GetInt(flagCurtail) + if err != nil { + log.ERROR.Println("curtail:", err) + return true + } + + if vv, ok := api.Cap[api.Curtailer](v); ok { + if err := vv.Curtail(val > 0); err != nil { + log.ERROR.Println("curtail:", err) + } + } else { + log.ERROR.Println("curtail: not implemented") + } + + return true +} + +// handleDimFlag handles the --dim flag for a given device. +// Returns true if the flag was used. +func handleDimFlag(cmd *cobra.Command, v any) bool { + if !cmd.Flags().Changed(flagDim) { + return false + } + + val, err := cmd.Flags().GetInt(flagDim) + if err != nil { + log.ERROR.Println("dim:", err) + return true + } + + if vv, ok := api.Cap[api.Dimmer](v); ok { + if err := vv.Dim(val > 0); err != nil { + log.ERROR.Println("dim:", err) + } + } else { + log.ERROR.Println("dim: not implemented") + } + + return true +} diff --git a/cmd/charger.go b/cmd/charger.go index 6c87e6318..55467c443 100644 --- a/cmd/charger.go +++ b/cmd/charger.go @@ -28,6 +28,8 @@ func init() { //lint:ignore SA1019 as Title is safe on ascii chargerCmd.Flags().BoolP(flagDisable, "d", false, strings.Title(flagDisable)) chargerCmd.Flags().Bool(flagDiagnose, false, flagDiagnoseDescription) + chargerCmd.Flags().IntP(flagCurtail, "u", -1, flagCurtailDescription) + chargerCmd.Flags().IntP(flagDim, "m", -1, flagDimDescription) chargerCmd.Flags().BoolP(flagWakeup, "w", false, flagWakeupDescription) chargerCmd.Flags().IntP(flagPhases, "p", 0, flagPhasesDescription) chargerCmd.Flags().Bool(flagHeartbeat, false, flagHeartbeatDescription) @@ -97,6 +99,14 @@ func runCharger(cmd *cobra.Command, args []string) { } } + if handleCurtailFlag(cmd, v) { + flagUsed = true + } + + if handleDimFlag(cmd, v) { + flagUsed = true + } + if cmd.Flag(flagWakeup).Changed { flagUsed = true diff --git a/cmd/dumper.go b/cmd/dumper.go index f39046979..6d6e17521 100644 --- a/cmd/dumper.go +++ b/cmd/dumper.go @@ -164,6 +164,20 @@ func (d *dumper) Dump(name string, v any) { fmt.Fprintf(w, "Max AC power:\t%.0fW\t\t\n", v.MaxACPower()) } + if v, ok := api.Cap[api.Dimmer](v); ok { + d.measureTime(w, "Dimmed", func() (string, error) { + dimmed, err := v.Dimmed() + return fmt.Sprintf("%t", dimmed), err + }) + } + + if v, ok := api.Cap[api.Curtailer](v); ok { + d.measureTime(w, "Curtailed", func() (string, error) { + curtailed, err := v.Curtailed() + return fmt.Sprintf("%t", curtailed), err + }) + } + // charger if v, ok := api.Cap[api.ChargeState](v); ok { diff --git a/cmd/flags.go b/cmd/flags.go index 7b14b5f10..c3e49f990 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -51,6 +51,12 @@ const ( flagDiagnose = "diagnose" flagDiagnoseDescription = "Diagnose" + flagCurtail = "curtail" + flagCurtailDescription = "Curtail (0/1 to switch, only available if supported by device)" + + flagDim = "dim" + flagDimDescription = "Dim (0/1 to switch, only available if supported by device)" + flagWakeup = "wakeup" flagWakeupDescription = "Wake up" diff --git a/cmd/meter.go b/cmd/meter.go index 2bf06b82b..e52d2e8c2 100644 --- a/cmd/meter.go +++ b/cmd/meter.go @@ -23,6 +23,8 @@ func init() { meterCmd.Flags().StringP(flagBatteryMode, "b", "", flagBatteryModeDescription) meterCmd.Flags().DurationP(flagBatteryModeWait, "w", 0, flagBatteryModeWaitDescription) meterCmd.Flags().Bool(flagDiagnose, false, flagDiagnoseDescription) + meterCmd.Flags().IntP(flagCurtail, "u", -1, flagCurtailDescription) + meterCmd.Flags().IntP(flagDim, "m", -1, flagDimDescription) meterCmd.Flags().BoolP(flagRepeat, "r", false, flagRepeatDescription) meterCmd.Flags().Duration(flagRepeatInterval, 0, flagRepeatIntervalDescription) meterCmd.Flags().Bool(flagHeartbeat, false, flagHeartbeatDescription) @@ -73,6 +75,16 @@ func runMeter(cmd *cobra.Command, args []string) { } } + for _, v := range config.Instances(meters) { + if handleCurtailFlag(cmd, v) { + flagUsed = true + } + + if handleDimFlag(cmd, v) { + flagUsed = true + } + } + if !flagUsed { timeout, _ := cmd.Flags().GetDuration(flagTimeout) d := dumper{len: len(meters), timeout: timeout} From 21d89bbd75cb222ad82b28c0743a5cf9c44b5c2d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:58:04 +0200 Subject: [PATCH 0264/1580] chore(deps-dev): bump vite from 8.0.3 to 8.0.5 (#28879) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index ae50d2813..06b68f900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,7 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint-language-service": "^5.0.5", - "vite": "^8.0.3", + "vite": "^8.0.5", "vitest": "^4.1.2", "vue-eslint-parser": "^10.4.0", "vue-hot-reload-api": "^2.3.4", @@ -11698,9 +11698,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz", + "integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11725,7 +11725,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/package.json b/package.json index f236d1e09..c75824b6b 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint-language-service": "^5.0.5", - "vite": "^8.0.3", + "vite": "^8.0.5", "vitest": "^4.1.2", "vue-eslint-parser": "^10.4.0", "vue-hot-reload-api": "^2.3.4", From a2dc5ba479c06d5d0e3f1010c0254e3108818292 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 7 Apr 2026 08:26:01 +0200 Subject: [PATCH 0265/1580] Config UI: add curtailed device info (#28889) --- server/http_config_helper.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/http_config_helper.go b/server/http_config_helper.go index e8f90dad5..3930942a1 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -340,6 +340,11 @@ func testInstance(instance any) map[string]testResult { makeResult("dimmed", val, err) } + if dev, ok := api.Cap[api.Curtailer](instance); ok { + val, err := dev.Curtailed() + makeResult("curtailed", val, err) + } + if dev, ok := api.Cap[api.Identifier](instance); ok { val, err := dev.Identify() makeResult("identifier", val, err) From b51891b24beaf871acd56ba74167894b89d681ef Mon Sep 17 00:00:00 2001 From: pirndi <30297631+pirndi@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:26:52 +0200 Subject: [PATCH 0266/1580] Add MTEC heat pump (#28880) --- templates/definition/charger/mtec.yaml | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 templates/definition/charger/mtec.yaml diff --git a/templates/definition/charger/mtec.yaml b/templates/definition/charger/mtec.yaml new file mode 100644 index 000000000..ce04e3580 --- /dev/null +++ b/templates/definition/charger/mtec.yaml @@ -0,0 +1,49 @@ +template: MTec +products: + - brand: M-Tec +group: heating +requirements: + # evcc: ["sponsorship"] +params: + - name: modbus + choice: ["tcpip"] + - name: tempsource + type: choice + choice: ["warmwater_top", "buffer"] + +render: | + type: heatpump + setmaxpower: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 1000 # PV Überschussleistung in Watt + type: writeholdings + decode: int16 + scale: 1 + power: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 707 # aktuelle Aufnahmeleistung der WP + type: holding + decode: int16 + scale: 1 + energy: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 706 # aktuelle Wärmemenge der WP + type: holding + decode: int16 + scale: 1 + {{- if .tempsource }} + temp: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: {{ if eq .tempsource "warmwater_top" -}} 401 {{ else if eq .tempsource "buffer" -}} 601 {{- end }} # + type: holding + decode: int16 + scale: 0.1 + {{- end }} From cebe55dfdb1faec59e2706075b1a037ce0f99a9f Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Apr 2026 08:27:23 +0200 Subject: [PATCH 0267/1580] Templates: support IPv6 (#28872) --- .../definition/charger/daheimladen-pro.yaml | 2 +- templates/definition/charger/daheimladen.yaml | 2 +- templates/definition/charger/e3dc-rscp.yaml | 2 +- templates/definition/charger/etrel-duo.yaml | 2 +- templates/definition/charger/etrel.yaml | 2 +- templates/definition/charger/homematic.yaml | 2 +- templates/definition/charger/kermi.yaml | 12 ++-- .../definition/charger/lambda-zewotherm.yaml | 10 ++-- .../definition/charger/ochsner-bwwp.yaml | 8 +-- .../definition/charger/wallbe-meter.yaml | 2 +- .../charger/wallbe-pre2019-meter.yaml | 2 +- .../definition/charger/wallbe-pre2019.yaml | 2 +- templates/definition/charger/wallbe.yaml | 2 +- .../definition/charger/webasto-next.yaml | 2 +- templates/definition/charger/weidmüller.yaml | 2 +- templates/definition/meter/dsmr.yaml | 2 +- templates/definition/meter/e3dc-modbus.yaml | 10 ++-- templates/definition/meter/e3dc-rscp.yaml | 2 +- templates/definition/meter/fronius-gen24.yaml | 52 ++++++++--------- .../definition/meter/fronius-vertoplus.yaml | 56 +++++++++---------- .../definition/meter/hager-flow-modbus.yaml | 14 ++--- templates/definition/meter/huawei-emma.yaml | 20 +++---- .../definition/meter/huawei-smartlogger.yaml | 4 +- templates/definition/meter/rct-power.yaml | 2 +- templates/definition/meter/sigenergy.yaml | 20 +++---- templates/definition/meter/solarlog.yaml | 8 +-- templates/definition/meter/sunspec-meter.yaml | 26 ++++----- templates/definition/meter/varta.yaml | 8 +-- .../definition/meter/victron-energy.yaml | 32 +++++------ util/templates/includes/mqtt.tpl | 2 +- util/templates/modbus.tpl | 6 +- util/templates/utils.go | 8 +++ 32 files changed, 167 insertions(+), 159 deletions(-) diff --git a/templates/definition/charger/daheimladen-pro.yaml b/templates/definition/charger/daheimladen-pro.yaml index 8b8b7e7b0..56b4e2a25 100644 --- a/templates/definition/charger/daheimladen-pro.yaml +++ b/templates/definition/charger/daheimladen-pro.yaml @@ -13,5 +13,5 @@ params: default: 502 render: | type: daheimladen - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} phases1p3p: true diff --git a/templates/definition/charger/daheimladen.yaml b/templates/definition/charger/daheimladen.yaml index c009f10e9..461478990 100644 --- a/templates/definition/charger/daheimladen.yaml +++ b/templates/definition/charger/daheimladen.yaml @@ -15,4 +15,4 @@ params: default: 502 render: | type: daheimladen - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} diff --git a/templates/definition/charger/e3dc-rscp.yaml b/templates/definition/charger/e3dc-rscp.yaml index a62ccbe70..b25fbf799 100644 --- a/templates/definition/charger/e3dc-rscp.yaml +++ b/templates/definition/charger/e3dc-rscp.yaml @@ -47,7 +47,7 @@ params: advanced: true render: | type: e3dc-rscp - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} user: {{ .user }} password: {{ .password }} key: {{ .key }} diff --git a/templates/definition/charger/etrel-duo.yaml b/templates/definition/charger/etrel-duo.yaml index bfbc80341..38730a5a9 100644 --- a/templates/definition/charger/etrel-duo.yaml +++ b/templates/definition/charger/etrel-duo.yaml @@ -17,4 +17,4 @@ params: render: | type: etrel connector: {{ .connector }} - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} diff --git a/templates/definition/charger/etrel.yaml b/templates/definition/charger/etrel.yaml index 56201b9d9..24e4091f2 100644 --- a/templates/definition/charger/etrel.yaml +++ b/templates/definition/charger/etrel.yaml @@ -18,4 +18,4 @@ params: default: 502 render: | type: etrel - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} diff --git a/templates/definition/charger/homematic.yaml b/templates/definition/charger/homematic.yaml index 13223c8f8..9e0cdb4a7 100644 --- a/templates/definition/charger/homematic.yaml +++ b/templates/definition/charger/homematic.yaml @@ -58,7 +58,7 @@ params: - preset: switchsocket render: | type: homematic - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} device: {{ .device }} user: {{ .user }} password: {{ .password }} diff --git a/templates/definition/charger/kermi.yaml b/templates/definition/charger/kermi.yaml index 0580f60a6..f6634c5c3 100644 --- a/templates/definition/charger/kermi.yaml +++ b/templates/definition/charger/kermi.yaml @@ -25,7 +25,7 @@ render: | timeout: {{ .watchdog }} # re-write at timeout/2 set: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 40 # WP register: address: 301 # PV Überschussleistung @@ -34,7 +34,7 @@ render: | scale: 10.0 power: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 40 register: address: 108 @@ -44,7 +44,7 @@ render: | {{- if eq .tempsource "warmwater" }} temp: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 51 # TWE register: address: 100 @@ -53,7 +53,7 @@ render: | scale: 0.1 limittemp: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 51 # TWE register: address: 101 @@ -64,7 +64,7 @@ render: | {{- if eq .tempsource "buffer" }} temp: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 50 # Speicher register: address: 1 @@ -73,7 +73,7 @@ render: | scale: 0.1 limittemp: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 50 # Speicher register: address: 2 diff --git a/templates/definition/charger/lambda-zewotherm.yaml b/templates/definition/charger/lambda-zewotherm.yaml index eb3d7d5ab..7a9b23627 100644 --- a/templates/definition/charger/lambda-zewotherm.yaml +++ b/templates/definition/charger/lambda-zewotherm.yaml @@ -58,7 +58,7 @@ render: | initial: 0 set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 102 # PV Überschussleistung @@ -67,7 +67,7 @@ render: | scale: {{ if eq .excess "plus" }}1{{ else }}-1{{ end }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 103 # aktuelle Aufnahmeleistung [E] der WP @@ -75,7 +75,7 @@ render: | decode: int16 energy: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 1020 # kumulierter Stromverbrauch (Energieaufnahme) [E / Wh] seit dem letzten Reset @@ -85,7 +85,7 @@ render: | {{- if .tempsource }} temp: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: {{ if eq .tempsource "warmwater_top" -}} 2002 {{ else if eq .tempsource "warmwater_bottom" -}} 2003 {{ else if eq .tempsource "buffer_top" -}} 3002 {{ else }} 3003 {{- end }} # 2002 Trinkwasser Oben, 2003 Trinkwasser Unten, 3002 Wärmespeicher Oben, 3003 Wärmespeicher Unten @@ -94,7 +94,7 @@ render: | scale: 0.1 limittemp: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: {{ if eq .tempsource "warmwater_top" -}} 2050 {{ else if eq .tempsource "warmwater_bottom" -}} 2050 {{ else if eq .tempsource "buffer_top" -}} 3050 {{ else }} 3050 {{- end }} # 2050 Trinkwasser Speicher, 3050 Wärmespeicher diff --git a/templates/definition/charger/ochsner-bwwp.yaml b/templates/definition/charger/ochsner-bwwp.yaml index cd0126dc6..c1b8c3621 100644 --- a/templates/definition/charger/ochsner-bwwp.yaml +++ b/templates/definition/charger/ochsner-bwwp.yaml @@ -20,7 +20,7 @@ render: | source: delta # nur Differenz zu der von WP berechneten Überschussleistung (Reg. 2012) get: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 2012 # totale von der WP berechnete Überschussleistung Auflösung 1 W @@ -28,7 +28,7 @@ render: | decode: int16 set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 2201 # SUR Überschussleistung Auflösung 1 W @@ -36,7 +36,7 @@ render: | decode: int16 temp: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 2000 # angezeigte Temperatur Auflösung 0,1°C @@ -45,7 +45,7 @@ render: | scale: 0.1 limittemp: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 2203 # Legionellen/Überschuss Solltemperatur Auflösung 0,1°C diff --git a/templates/definition/charger/wallbe-meter.yaml b/templates/definition/charger/wallbe-meter.yaml index 6561fc936..292bb5667 100644 --- a/templates/definition/charger/wallbe-meter.yaml +++ b/templates/definition/charger/wallbe-meter.yaml @@ -19,7 +19,7 @@ params: default: 502 render: | type: wallbe - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} meter: # only if a charge meter is connected to the controller power: true energy: true diff --git a/templates/definition/charger/wallbe-pre2019-meter.yaml b/templates/definition/charger/wallbe-pre2019-meter.yaml index 4f9694bc2..db7ddfaac 100644 --- a/templates/definition/charger/wallbe-pre2019-meter.yaml +++ b/templates/definition/charger/wallbe-pre2019-meter.yaml @@ -19,7 +19,7 @@ params: default: 502 render: | type: wallbe - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} legacy: true # set only for older Wallbe devices (pre ~2019, old controller firmware) meter: # only if a charge meter is connected to the controller power: true diff --git a/templates/definition/charger/wallbe-pre2019.yaml b/templates/definition/charger/wallbe-pre2019.yaml index 872731861..4004b3e68 100644 --- a/templates/definition/charger/wallbe-pre2019.yaml +++ b/templates/definition/charger/wallbe-pre2019.yaml @@ -19,5 +19,5 @@ params: default: 502 render: | type: wallbe - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} legacy: true # set only for older Wallbe devices (pre ~2019, old controller firmware) diff --git a/templates/definition/charger/wallbe.yaml b/templates/definition/charger/wallbe.yaml index 7888d8e88..5bd293daa 100644 --- a/templates/definition/charger/wallbe.yaml +++ b/templates/definition/charger/wallbe.yaml @@ -17,4 +17,4 @@ params: default: 502 render: | type: wallbe - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} diff --git a/templates/definition/charger/webasto-next.yaml b/templates/definition/charger/webasto-next.yaml index df7e64e9e..fc8fecbf4 100644 --- a/templates/definition/charger/webasto-next.yaml +++ b/templates/definition/charger/webasto-next.yaml @@ -18,4 +18,4 @@ params: default: 502 render: | type: webasto-next - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} diff --git a/templates/definition/charger/weidmüller.yaml b/templates/definition/charger/weidmüller.yaml index e2e9d84c4..64ebad857 100644 --- a/templates/definition/charger/weidmüller.yaml +++ b/templates/definition/charger/weidmüller.yaml @@ -10,4 +10,4 @@ params: - name: host render: | type: weidmüller - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} diff --git a/templates/definition/meter/dsmr.yaml b/templates/definition/meter/dsmr.yaml index ff60c547b..d3803b656 100644 --- a/templates/definition/meter/dsmr.yaml +++ b/templates/definition/meter/dsmr.yaml @@ -18,7 +18,7 @@ params: type: string render: | type: dsmr - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} {{- if .energy }} energy: {{ .energy }} {{- end }} diff --git a/templates/definition/meter/e3dc-modbus.yaml b/templates/definition/meter/e3dc-modbus.yaml index dbf566017..189361cea 100644 --- a/templates/definition/meter/e3dc-modbus.yaml +++ b/templates/definition/meter/e3dc-modbus.yaml @@ -14,7 +14,7 @@ render: | {{- if eq .usage "grid" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32s @@ -25,13 +25,13 @@ render: | source: calc add: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32s address: 40067 # Photovoltaikleistung in Watt - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32s @@ -41,7 +41,7 @@ render: | {{- if eq .usage "battery" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32s @@ -49,7 +49,7 @@ render: | scale: -1 # reverse direction soc: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 40082 # Batterie-SOC in % type: holding diff --git a/templates/definition/meter/e3dc-rscp.yaml b/templates/definition/meter/e3dc-rscp.yaml index 8163716cc..1c242eec2 100644 --- a/templates/definition/meter/e3dc-rscp.yaml +++ b/templates/definition/meter/e3dc-rscp.yaml @@ -67,7 +67,7 @@ params: render: | type: e3dc-rscp usage: {{ .usage }} - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} user: {{ .user }} password: {{ .password }} key: {{ .key }} diff --git a/templates/definition/meter/fronius-gen24.yaml b/templates/definition/meter/fronius-gen24.yaml index 6a97859c1..1bec978f0 100644 --- a/templates/definition/meter/fronius-gen24.yaml +++ b/templates/definition/meter/fronius-gen24.yaml @@ -40,7 +40,7 @@ render: | {{- if eq .usage "grid" }} power: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:W @@ -49,7 +49,7 @@ render: | - 213:W energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:TotWhImp @@ -59,7 +59,7 @@ render: | scale: 0.001 currents: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphA @@ -67,7 +67,7 @@ render: | - 203:AphA - 213:AphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphB @@ -75,7 +75,7 @@ render: | - 203:AphB - 213:AphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphC @@ -84,7 +84,7 @@ render: | - 213:AphC voltages: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphA @@ -92,7 +92,7 @@ render: | - 203:PhVphA - 213:PhVphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphB @@ -100,7 +100,7 @@ render: | - 203:PhVphB - 213:PhVphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphC @@ -109,7 +109,7 @@ render: | - 213:PhVphC powers: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphA @@ -117,7 +117,7 @@ render: | - 203:WphA - 213:WphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphB @@ -125,7 +125,7 @@ render: | - 203:WphB - 213:WphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphC @@ -138,23 +138,23 @@ render: | source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:1:DCW # mppt 1 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:2:DCW # mppt 2 energy: source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:1:DCWH # mppt 1 scale: 0.001 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:2:DCWH # mppt 2 scale: 0.001 @@ -165,23 +165,23 @@ render: | source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:3:DCW # mppt 3 charge scale: -1 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:4:DCW # mppt 4 discharge energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:4:DCWH # mppt 4 (discharge) scale: 0.001 soc: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:ChaState batterymode: # model 124 @@ -195,14 +195,14 @@ render: | value: 0 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: 100 # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte - case: 2 # hold @@ -213,14 +213,14 @@ render: | value: 2 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: 0 # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte - case: 3 # charge @@ -231,21 +231,21 @@ render: | value: 1 # off set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:ChaGriSet - source: const value: 2 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: {{ if .maxchargerate }}-{{ .maxchargerate }}{{ end }} # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte {{- include "battery-params" . }} diff --git a/templates/definition/meter/fronius-vertoplus.yaml b/templates/definition/meter/fronius-vertoplus.yaml index 77cca6b3e..b85653940 100644 --- a/templates/definition/meter/fronius-vertoplus.yaml +++ b/templates/definition/meter/fronius-vertoplus.yaml @@ -27,7 +27,7 @@ render: | {{- if eq .usage "grid" }} power: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:W @@ -36,7 +36,7 @@ render: | - 213:W energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:TotWhImp @@ -46,7 +46,7 @@ render: | scale: 0.001 currents: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphA @@ -54,7 +54,7 @@ render: | - 203:AphA - 213:AphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphB @@ -62,7 +62,7 @@ render: | - 203:AphB - 213:AphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphC @@ -71,7 +71,7 @@ render: | - 213:AphC voltages: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphA @@ -79,7 +79,7 @@ render: | - 203:PhVphA - 213:PhVphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphB @@ -87,7 +87,7 @@ render: | - 203:PhVphB - 213:PhVphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphC @@ -96,7 +96,7 @@ render: | - 213:PhVphC powers: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphA @@ -104,7 +104,7 @@ render: | - 203:WphA - 213:WphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphB @@ -112,7 +112,7 @@ render: | - 203:WphB - 213:WphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphC @@ -125,32 +125,32 @@ render: | source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:1:DCW # mppt 1 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:2:DCW # mppt 2 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:3:DCW # mppt 3 energy: source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:1:DCWH # mppt 1 scale: 0.001 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:2:DCWH # mppt 2 scale: 0.001 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:3:DCWH # mppt 3 scale: 0.001 @@ -161,23 +161,23 @@ render: | source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:4:DCW # mppt 4 charge scale: -1 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:5:DCW # mppt 5 discharge energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:5:DCWH # mppt 5 (discharge) scale: 0.001 soc: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:ChaState batterymode: # model 124 @@ -191,14 +191,14 @@ render: | value: 0 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: 100 # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte - case: 2 # hold @@ -209,14 +209,14 @@ render: | value: 2 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: 0 # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte - case: 3 # charge @@ -227,21 +227,21 @@ render: | value: 1 # off set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:ChaGriSet - source: const value: 2 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: -{{ .maxchargerate }} # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte {{- include "battery-params" . }} diff --git a/templates/definition/meter/hager-flow-modbus.yaml b/templates/definition/meter/hager-flow-modbus.yaml index 903613276..9b2989566 100644 --- a/templates/definition/meter/hager-flow-modbus.yaml +++ b/templates/definition/meter/hager-flow-modbus.yaml @@ -29,21 +29,21 @@ render: | currents: - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 4099 # Netz Ampere L1 | Grid amps L1 type: holding decode: uint16 - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 4100 # Netz Ampere L2 | Grid amps L2 type: holding decode: uint16 - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 4101 # Netz Ampere L3 | Grid amps L3 type: holding @@ -51,7 +51,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32 @@ -63,7 +63,7 @@ render: | add: - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: uint32 @@ -73,7 +73,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32 @@ -82,7 +82,7 @@ render: | soc: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 4146 # Batterie-SOC in % type: holding diff --git a/templates/definition/meter/huawei-emma.yaml b/templates/definition/meter/huawei-emma.yaml index b4374ee93..9feef6c89 100644 --- a/templates/definition/meter/huawei-emma.yaml +++ b/templates/definition/meter/huawei-emma.yaml @@ -15,7 +15,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 31657 # Active power of built-in electric energy sensor type: holding @@ -23,7 +23,7 @@ render: | energy: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 31679 # Total negative active energy of built-in electric energy sensor type: holding @@ -32,7 +32,7 @@ render: | currents: - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 31651 # Huawei phase A grid current type: holding @@ -40,7 +40,7 @@ render: | scale: 0.1 - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 31653 # Huawei phase B grid current type: holding @@ -48,7 +48,7 @@ render: | scale: 0.1 - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 31655 # Huawei phase C grid current type: holding @@ -59,7 +59,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 30354 # Active power type: holding @@ -67,7 +67,7 @@ render: | energy: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 30344 # E-Total type: holding @@ -78,7 +78,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 30360 type: holding @@ -87,7 +87,7 @@ render: | energy: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 30312 # Total discharge type: holding @@ -96,7 +96,7 @@ render: | soc: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 30368 type: holding diff --git a/templates/definition/meter/huawei-smartlogger.yaml b/templates/definition/meter/huawei-smartlogger.yaml index 40001ae51..e18e2f7d2 100644 --- a/templates/definition/meter/huawei-smartlogger.yaml +++ b/templates/definition/meter/huawei-smartlogger.yaml @@ -61,7 +61,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 40521 # Active power type: holding @@ -69,7 +69,7 @@ render: | energy: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 40560 # E-Total type: holding diff --git a/templates/definition/meter/rct-power.yaml b/templates/definition/meter/rct-power.yaml index dcf6405df..c53b05b1b 100644 --- a/templates/definition/meter/rct-power.yaml +++ b/templates/definition/meter/rct-power.yaml @@ -41,7 +41,7 @@ params: default: 30s render: | type: rct - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} usage: {{ .usage }} cache: {{ .cache }} {{- include "battery-params" . }} diff --git a/templates/definition/meter/sigenergy.yaml b/templates/definition/meter/sigenergy.yaml index bfa52d1eb..cfa5441f2 100644 --- a/templates/definition/meter/sigenergy.yaml +++ b/templates/definition/meter/sigenergy.yaml @@ -29,7 +29,7 @@ render: | {{- if eq .usage "grid" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 247 register: address: 30005 # [Grid sensor] Active power @@ -37,7 +37,7 @@ render: | decode: int32 energy: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 30562 # Accumulated import energy @@ -46,7 +46,7 @@ render: | scale: 0.01 currents: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 31017 # Phase A current @@ -54,7 +54,7 @@ render: | decode: int32 scale: 0.01 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 31019 # Phase B current @@ -62,7 +62,7 @@ render: | decode: int32 scale: 0.01 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 31021 # Phase C current @@ -73,7 +73,7 @@ render: | {{- if eq .usage "pv" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 247 register: type: holding @@ -84,7 +84,7 @@ render: | {{- if eq .usage "battery" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 247 register: type: holding @@ -93,7 +93,7 @@ render: | scale: -1 energy: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 30574 # Battery accumulated discharge energy @@ -102,7 +102,7 @@ render: | scale: 0.01 soc: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 247 register: address: 30014 # Energy storage system SOC @@ -114,7 +114,7 @@ render: | convert: float2int set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 247 register: address: 40048 # Discharge Cut-Off SoC diff --git a/templates/definition/meter/solarlog.yaml b/templates/definition/meter/solarlog.yaml index cae9462f6..1093a0258 100644 --- a/templates/definition/meter/solarlog.yaml +++ b/templates/definition/meter/solarlog.yaml @@ -23,7 +23,7 @@ render: | source: calc add: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 3502 # Pac @@ -31,7 +31,7 @@ render: | decode: uint32s scale: -1 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 3518 # Pac consumption @@ -41,7 +41,7 @@ render: | {{- if eq .usage "pv" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 3502 # Pac @@ -49,7 +49,7 @@ render: | decode: uint32s energy: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 3516 # total yield diff --git a/templates/definition/meter/sunspec-meter.yaml b/templates/definition/meter/sunspec-meter.yaml index 0cc7fac26..f3af86bb5 100644 --- a/templates/definition/meter/sunspec-meter.yaml +++ b/templates/definition/meter/sunspec-meter.yaml @@ -18,7 +18,7 @@ render: | {{- if or (eq .usage "grid") (eq .usage "charge") }} power: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:W @@ -29,7 +29,7 @@ render: | - 213:W energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:TotWhImp @@ -41,7 +41,7 @@ render: | scale: 0.001 currents: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphA @@ -51,7 +51,7 @@ render: | - 203:AphA - 213:AphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphB @@ -61,7 +61,7 @@ render: | - 203:AphB - 213:AphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphC @@ -72,7 +72,7 @@ render: | - 213:AphC voltages: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphA @@ -82,7 +82,7 @@ render: | - 203:PhVphA - 213:PhVphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphB @@ -92,7 +92,7 @@ render: | - 203:PhVphB - 213:PhVphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphC @@ -103,7 +103,7 @@ render: | - 213:PhVphC powers: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphA @@ -113,7 +113,7 @@ render: | - 203:WphA - 213:WphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphB @@ -123,7 +123,7 @@ render: | - 203:WphB - 213:WphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphC @@ -136,7 +136,7 @@ render: | {{- if eq .usage "pv" }} power: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:W @@ -147,7 +147,7 @@ render: | - 213:W energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:TotWhExp diff --git a/templates/definition/meter/varta.yaml b/templates/definition/meter/varta.yaml index 420cc2a6f..c010a49f7 100644 --- a/templates/definition/meter/varta.yaml +++ b/templates/definition/meter/varta.yaml @@ -35,7 +35,7 @@ render: | type: custom power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} {{- if eq .usage "grid" }} id: 255 register: @@ -60,7 +60,7 @@ render: | scale: -1 soc: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 255 register: address: 1068 # SOC @@ -81,7 +81,7 @@ render: | value: -{{- .maxdischargepower }} # value must be negative set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 255 register: address: 1074 # max discharge power @@ -95,7 +95,7 @@ render: | value: 0 set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 255 register: address: 1074 # max discharge power diff --git a/templates/definition/meter/victron-energy.yaml b/templates/definition/meter/victron-energy.yaml index 5142acb4a..4234c1be0 100644 --- a/templates/definition/meter/victron-energy.yaml +++ b/templates/definition/meter/victron-energy.yaml @@ -32,21 +32,21 @@ render: | source: calc add: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 820 # L1 grid power type: input decode: int16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 821 # L2 grid power type: input decode: int16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 822 # L3 grid power @@ -55,7 +55,7 @@ render: | {{- if .meterid }} currents: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .meterid }} register: address: 2617 # L1 grid current @@ -63,7 +63,7 @@ render: | decode: int16 scale: 0.1 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .meterid }} register: address: 2619 # L2 grid current @@ -71,7 +71,7 @@ render: | decode: int16 scale: 0.1 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .meterid }} register: address: 2621 # L3 grid current @@ -85,49 +85,49 @@ render: | source: calc add: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 808 # ACout pv power L1 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 809 # ACout pv power L2 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 810 # ACout pv power L3 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 811 # ACin pv power L1 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 812 # ACin pv power L2 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 813 # ACin pv power L3 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 850 # DC pv power @@ -138,7 +138,7 @@ render: | {{- if eq .usage "battery" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 842 # active DC power @@ -147,7 +147,7 @@ render: | scale: -1 soc: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 843 # Soc @@ -158,7 +158,7 @@ render: | convert: float2int set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 2901 # limit soc diff --git a/util/templates/includes/mqtt.tpl b/util/templates/includes/mqtt.tpl index 4200a544b..f4472655c 100644 --- a/util/templates/includes/mqtt.tpl +++ b/util/templates/includes/mqtt.tpl @@ -1,5 +1,5 @@ {{ define "mqtt" }} -broker: {{ .host }}:{{ .port }} +broker: {{ joinHostPort .host .port }} {{- if .user }} user: {{ .user }} {{- end }} diff --git a/util/templates/modbus.tpl b/util/templates/modbus.tpl index 5ce1a4eb2..b46136e26 100644 --- a/util/templates/modbus.tpl +++ b/util/templates/modbus.tpl @@ -7,15 +7,15 @@ baudrate: {{ .baudrate }} comset: {{ .comset }} {{- else if or (eq .modbus "rs485tcpip") .rs485tcpip }} # RS485 via TCP/IP (Modbus RTU) -uri: {{ .host }}:{{ .port }} +uri: {{ joinHostPort .host .port }} rtu: true {{- else if or (eq .modbus "tcpip") .tcpip }} # Modbus TCP -uri: {{ .host }}:{{ .port }} +uri: {{ joinHostPort .host .port }} rtu: false {{- else if or (eq .modbus "udp") .udp }} # Modbus UDP -uri: {{ .host }}:{{ if (ne .port "502") }}{{ .port }}{{ else }}8899{{ end }} +uri: {{ joinHostPort .host .port }} udp: true rtu: true {{- else }} diff --git a/util/templates/utils.go b/util/templates/utils.go index 8aa5a5fbe..429fdfc4b 100644 --- a/util/templates/utils.go +++ b/util/templates/utils.go @@ -3,6 +3,7 @@ package templates import ( "bytes" "fmt" + "net" "net/url" "strings" "text/template" @@ -70,6 +71,13 @@ func FuncMap(tmpl *template.Template) *template.Template { "timeRound": func(t time.Time, d time.Duration) time.Time { return t.Round(d) }, + "joinHostPort": func(host, port string) string { + res := net.JoinHostPort(host, port) + if strings.Contains(host, ":") { + return `"` + res + `"` + } + return res + }, "urlEncode": url.QueryEscape, "unquote": unquote, "quote": yamlQuote, From 807fe1a90309fcd45c8046b56895c51211207131 Mon Sep 17 00:00:00 2001 From: Filipe Alves Date: Tue, 7 Apr 2026 09:14:08 +0100 Subject: [PATCH 0268/1580] Add OMIE day-ahead tariff for Portugal and Spain (#28768) --- templates/definition/tariff/omie.yaml | 165 ++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 templates/definition/tariff/omie.yaml diff --git a/templates/definition/tariff/omie.yaml b/templates/definition/tariff/omie.yaml new file mode 100644 index 000000000..49e04f099 --- /dev/null +++ b/templates/definition/tariff/omie.yaml @@ -0,0 +1,165 @@ +template: omie +products: + - brand: OMIE + description: + en: Iberian day-ahead market prices + de: Iberische Day-Ahead-Marktpreise +requirements: + evcc: ["skiptest"] + description: + en: "Day-ahead electricity prices from OMIE for Portugal and Spain. No API key is required." + de: "Day-Ahead-Strompreise von OMIE fuer Portugal und Spanien. Es ist kein API-Schluessel erforderlich." +group: price +countries: ["PT", "ES"] +params: + - name: country + description: + en: Country + de: Land + help: + en: Select whether to use the Portuguese or Spanish market price published by OMIE. + de: Waehlt aus, ob der von OMIE veroeffentlichte portugiesische oder spanische Marktpreis verwendet werden soll. + type: choice + choice: ["PT", "ES"] + default: PT + required: true + - preset: tariff-base + - preset: tariff-features +render: | + type: custom + {{ include "tariff-base" . }} + {{ include "tariff-features" . }} + forecast: + source: http + uri: https://www.omie.es/sites/default/files/dados/NUEVA_SECCION/INT_PBC_EV_H_ACUM.TXT + cache: 1h + regex: '(?ms)((?:^[0-9]{2}/[0-9]{2}/[0-9]{4};[^\r\n]*(?:\r?\n|$))+)' + default: "" + quote: true + jq: | + def market: + {{ if eq .country "PT" }} + { code: "PT", priceIndex: 3 } + {{ else }} + { code: "ES", priceIndex: 2 } + {{ end }}; + + def quarterHourSeconds: 900; + def dstStartMonth: 3; + def dstEndMonth: 10; + def standardOffsetSeconds: 3600; + def daylightOffsetSeconds: 7200; + def shortDayPeriodCount: 92; + def normalDayPeriodCount: 96; + def longDayPeriodCount: 100; + def minimumRequiredColumnCount: market.priceIndex + 1; + + def pad2: + tostring | if length == 1 then "0" + . else . end; + + def isoDate($y; $m; $d): + "\($y)-\($m | pad2)-\($d | pad2)"; + + def lastSunday($y; $m): + first([31, 30, 29, 28, 27, 26, 25][] | select(((isoDate($y; $m; .) + "T00:00:00Z") | fromdateiso8601 | gmtime | .[6]) == 0)); + + def daylightSavingSwitchDay($y; $m): + lastSunday($y; $m); + + def isStandardOffsetMonth($m): + $m < dstStartMonth or $m > dstEndMonth; + + def isDaylightOffsetMonth($m): + $m > dstStartMonth and $m < dstEndMonth; + + def marketStartOffsetSeconds($y; $m; $d): + if isStandardOffsetMonth($m) then + standardOffsetSeconds + elif isDaylightOffsetMonth($m) then + daylightOffsetSeconds + elif $m == dstStartMonth then + if $d <= daylightSavingSwitchDay($y; dstStartMonth) then standardOffsetSeconds else daylightOffsetSeconds end + else + if $d <= daylightSavingSwitchDay($y; dstEndMonth) then daylightOffsetSeconds else standardOffsetSeconds end + end; + + def marketStartUtc($y; $m; $d): + ((isoDate($y; $m; $d) + "T00:00:00Z") | fromdateiso8601) - marketStartOffsetSeconds($y; $m; $d); + + def isQuarterHourDay($day): + ([ $day[].period ] | length) as $count + | select($count == shortDayPeriodCount or $count == normalDayPeriodCount or $count == longDayPeriodCount); + + def parseDateParts: + split("/") | map(tonumber) | { d: .[0], m: .[1], y: .[2] }; + + def cutoffUtc: + ((now / quarterHourSeconds) | floor) * quarterHourSeconds; + + split("\n") + | map(gsub("\r"; "")) + | map(select(length > 0)) as $lines + | if ($lines | length) == 0 then + error("OMIE data rows not found") + elif (($lines[0] | split(";") | length) < minimumRequiredColumnCount) then + error("unexpected OMIE row layout") + else + ($lines + | map(split(";")) + | map({ + date: .[0], + dateParts: (.[0] | parseDateParts), + period: (.[1] | tonumber), + value: (.[(market.priceIndex)] | gsub(","; ".") | tonumber / 1000) + } + | . + .dateParts + | del(.dateParts) + ) + ) as $entries + | if ($entries | length) == 0 then + error("no OMIE rows parsed") + else + $entries + end + | ($entries + | sort_by(.date, .period) + | group_by(.date) + | map(isQuarterHourDay(.)) + ) as $quarterHourDays + | if ($quarterHourDays | length) == 0 then + error("no OMIE quarter-hour days parsed") + else + $quarterHourDays + end + | ($quarterHourDays + | all( + ([.[].period] | sort) as $periods + | ($periods | length) as $count + | ($periods == [range(1; $count + 1)]) + ) + ) as $validPeriods + | if ($validPeriods | not) then + error("unexpected OMIE period sequence") + else + ($quarterHourDays + | add + | sort_by(.y, .m, .d, .period) + | map( + (marketStartUtc(.y; .m; .d) + ((.period - 1) * quarterHourSeconds)) as $start + | ($start + quarterHourSeconds) as $end + | select($end > cutoffUtc) + | { + start: ($start | todateiso8601), + end: ($end | todateiso8601), + value: .value + } + ) + ) + end + | if length == 0 then + error("no OMIE \(market.code) rates parsed") + else + . + end + | tostring + end From 8144589d346ba4486b0156a7d442780ddf39033f Mon Sep 17 00:00:00 2001 From: tukutt Date: Tue, 7 Apr 2026 10:33:32 +0200 Subject: [PATCH 0269/1580] Add Lektrico charger (#28195) --- charger/lektrico.go | 290 +++++++++++++++++++++ templates/definition/charger/lektrico.yaml | 14 + 2 files changed, 304 insertions(+) create mode 100644 charger/lektrico.go create mode 100644 templates/definition/charger/lektrico.yaml diff --git a/charger/lektrico.go b/charger/lektrico.go new file mode 100644 index 000000000..84878c7e0 --- /dev/null +++ b/charger/lektrico.go @@ -0,0 +1,290 @@ +package charger + +// LICENSE + +// Copyright (c) evcc.io (andig, naltatis, premultiply) + +// This module is NOT covered by the MIT license. All rights reserved. + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// https://github.com/Lektrico/lektricowifi +// +// Lektrico charger protocol (discovered from lektricowifi source + real device testing): +// +// GET http:///rpc/ -> direct JSON response +// POST http:///rpc -> JSON-RPC body, response wrapped in "result" field +// +// Main endpoint: charger_info.get returns all data in a single request. +// +// Example response from charger_info.get: +// { +// "charger_state": "B", // raw IEC state: A/B/C/D/E/F/B_AUTH/B_PAUSE/OTA/LOCKED +// "extended_charger_state": "B_AUTH", +// "session_energy": 38.48, // Wh +// "instant_power": 0.0, // W +// "currents": [0.0, 0.0, 0.0], // A, array [L1, L2, L3] +// "voltages": [237.65, 0.0, 0.0], // V, array [L1, L2, L3] +// "total_charged_energy": 9683.844, // kWh +// "dynamic_current": 32, // allowed current (0=pause, 6-32=active) +// "relay_mode": 0, // phase mode +// "has_active_errors": false, +// "charger_is_paused": false, +// "current_limit_reason": 2, // int: 0=no_limit, 1=installation_current, 2=user_limit, +// // 3=dynamic_limit, 4=schedule, 5=em_offline, 6=em, +// // 7=ocpp, 8=overtemperature, 9=switching_phases, +// // 10=user_limit, 11=1p_charging_disabled, 12+=unknown +// "temperature": 18.8, +// "fw_version": "1.51", +// "headless": true, // true = no authentication required +// "install_current": 32, +// } + +import ( + "fmt" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/sponsor" +) + +// lektricoStateBAUTH is the extended state indicating waiting for RFID authentication +const lektricoStateBAUTH = "B_AUTH" + +// lektricoInfo maps the JSON response from charger_info.get +type lektricoInfo struct { + ChargerState string `json:"charger_state"` + ExtendedChargerState string `json:"extended_charger_state"` + HasActiveErrors bool `json:"has_active_errors"` + InstantPower float64 `json:"instant_power"` + SessionEnergy float64 `json:"session_energy"` + TotalChargedEnergy float64 `json:"total_charged_energy"` + Currents []float64 `json:"currents"` + Voltages []float64 `json:"voltages"` + DynamicCurrent int `json:"dynamic_current"` + FwVersion string `json:"fw_version"` +} + +// lektricoRPCRequest is the POST JSON-RPC request format +type lektricoRPCRequest struct { + Src string `json:"src"` + ID int `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params,omitempty"` +} + +// lektricoRPCResponse wraps the POST response +type lektricoRPCResponse struct { + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// Lektrico implements api.Charger for Lektrico 1P7K / 3P22K charging stations +type Lektrico struct { + *request.Helper + rpcID atomic.Uint32 + uri string + current int64 + statusG util.Cacheable[lektricoInfo] +} + +var _ api.Charger = (*Lektrico)(nil) + +func init() { + registry.Add("lektrico", NewLektricoFromConfig) +} + +// NewLektricoFromConfig creates a Lektrico charger from evcc configuration +func NewLektricoFromConfig(other map[string]any) (api.Charger, error) { + cc := struct { + Host string + Cache time.Duration + }{ + Cache: time.Second, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.Host == "" { + return nil, fmt.Errorf("missing host") + } + + if !sponsor.IsAuthorized() { + return nil, api.ErrSponsorRequired + } + + return NewLektrico(cc.Host, cc.Cache) +} + +// NewLektrico creates a Lektrico charger and verifies connectivity +func NewLektrico(host string, cache time.Duration) (*Lektrico, error) { + uri := fmt.Sprintf("http://%s/rpc", strings.TrimSuffix(host, "/")) + + wb := &Lektrico{ + Helper: request.NewHelper(util.NewLogger("lektrico")), + uri: uri, + current: 6, + } + + wb.statusG = util.ResettableCached(func() (lektricoInfo, error) { + var res lektricoInfo + err := wb.GetJSON(uri+"/charger_info.get", &res) + return res, err + }, cache) + + return wb, nil +} + +// post sends a JSON-RPC command to the charger +func (wb *Lektrico) post(method string, params map[string]any) error { + payload := lektricoRPCRequest{ + Src: "evcc", + ID: int(wb.rpcID.Add(1)), + Method: method, + Params: params, + } + + req, _ := request.New(http.MethodPost, wb.uri, request.MarshalJSON(payload), request.JSONEncoding) + + var res lektricoRPCResponse + if err := wb.DoJSON(req, &res); err != nil { + return err + } + if res.Error != nil { + return fmt.Errorf("%d: %s", res.Error.Code, res.Error.Message) + } + + wb.statusG.Reset() + return nil +} + +// Status implements the api.Charger interface +func (wb *Lektrico) Status() (api.ChargeStatus, error) { + res, err := wb.statusG.Get() + if err != nil { + return api.StatusNone, err + } + + switch res.ChargerState { + case "A": + return api.StatusA, nil + case "B": + return api.StatusB, nil + case "C": + return api.StatusC, nil + default: + return api.StatusNone, fmt.Errorf("invalid state: %s", res.ChargerState) + } +} + +var _ api.StatusReasoner = (*Lektrico)(nil) + +// StatusReason implements the api.StatusReasoner interface +func (wb *Lektrico) StatusReason() (api.Reason, error) { + res, err := wb.statusG.Get() + if err == nil && res.ExtendedChargerState == lektricoStateBAUTH { + return api.ReasonWaitingForAuthorization, nil + } + + return api.ReasonUnknown, err +} + +// Enabled implements the api.Charger interface +func (wb *Lektrico) Enabled() (bool, error) { + res, err := wb.statusG.Get() + if err != nil { + return false, err + } + + return res.DynamicCurrent > 0, nil +} + +// sendCurrent sets the dynamic_current on the charger. +func (wb *Lektrico) setCurrent(value int64) error { + return wb.post("dynamic_current.set", map[string]any{ + "dynamic_current": value, + }) +} + +// Enable implements the api.Charger interface +func (wb *Lektrico) Enable(enable bool) error { + var curr int64 + if enable { + curr = wb.current + } + + return wb.setCurrent(curr) +} + +// MaxCurrent implements the api.Charger interface +func (wb *Lektrico) MaxCurrent(current int64) error { + err := wb.setCurrent(current) + if err == nil { + wb.current = current + } + return err +} + +var _ api.Meter = (*Lektrico)(nil) + +// CurrentPower implements the api.Meter interface +func (wb *Lektrico) CurrentPower() (float64, error) { + res, err := wb.statusG.Get() + return res.InstantPower, err +} + +var _ api.MeterEnergy = (*Lektrico)(nil) + +// TotalEnergy implements the api.MeterEnergy interface +func (wb *Lektrico) TotalEnergy() (float64, error) { + res, err := wb.statusG.Get() + return res.TotalChargedEnergy, err +} + +var _ api.ChargeRater = (*Lektrico)(nil) + +// ChargedEnergy implements the api.ChargeRater interface +func (wb *Lektrico) ChargedEnergy() (float64, error) { + res, err := wb.statusG.Get() + return res.SessionEnergy / 1000.0, err +} + +var _ api.PhaseCurrents = (*Lektrico)(nil) + +// Currents implements the api.PhaseCurrents interface +func (wb *Lektrico) Currents() (float64, float64, float64, error) { + res, err := wb.statusG.Get() + if err != nil { + return 0, 0, 0, err + } + return res.Currents[0], res.Currents[1], res.Currents[2], nil +} + +var _ api.PhaseVoltages = (*Lektrico)(nil) + +// Voltages implements the api.PhaseVoltages interface +func (wb *Lektrico) Voltages() (float64, float64, float64, error) { + res, err := wb.statusG.Get() + if err != nil { + return 0, 0, 0, err + } + return res.Voltages[0], res.Voltages[1], res.Voltages[2], nil +} diff --git a/templates/definition/charger/lektrico.yaml b/templates/definition/charger/lektrico.yaml new file mode 100644 index 000000000..d02da441a --- /dev/null +++ b/templates/definition/charger/lektrico.yaml @@ -0,0 +1,14 @@ +template: lektrico +products: + - brand: Lektrico + description: + generic: 1P7K / 3P22K Charging Station +requirements: + evcc: ["sponsorship"] +params: + - name: host + - name: cache +render: | + type: lektrico + host: {{ .host }} + cache: {{ .cache }} From 690873570feac796b443cbe2bfde815f41771544 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Apr 2026 12:17:19 +0200 Subject: [PATCH 0270/1580] chore: simplify --- templates/definition/meter/ecoflow-powerocean.yaml | 2 +- templates/definition/meter/ecoflow-stream.yaml | 2 +- templates/definition/meter/goodwe-wifi-es.yaml | 8 ++++---- templates/definition/meter/goodwe-wifi.yaml | 2 +- templates/definition/meter/sonnenbatterie_eco56.yaml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/templates/definition/meter/ecoflow-powerocean.yaml b/templates/definition/meter/ecoflow-powerocean.yaml index d914bb6a6..1fa102169 100644 --- a/templates/definition/meter/ecoflow-powerocean.yaml +++ b/templates/definition/meter/ecoflow-powerocean.yaml @@ -56,7 +56,7 @@ render: | type: ecoflow accesskey: {{ .accesskey }} secretkey: {{ .secretkey }} - serial: {{ if .serial }}{{ .serial }}{{ else }}{{ .deviceid }}{{ end }} + serial: {{ or .serial .deviceid }} usage: {{ .usage }} region: {{ .region }} cache: {{ .cache }} diff --git a/templates/definition/meter/ecoflow-stream.yaml b/templates/definition/meter/ecoflow-stream.yaml index f6056c30e..65b886318 100644 --- a/templates/definition/meter/ecoflow-stream.yaml +++ b/templates/definition/meter/ecoflow-stream.yaml @@ -57,7 +57,7 @@ render: | type: ecoflow accesskey: {{ .accesskey }} secretkey: {{ .secretkey }} - serial: {{ if .serial }}{{ .serial }}{{ else }}{{ .deviceid }}{{ end }} + serial: {{ or .serial .deviceid }} usage: {{ .usage }} cache: {{ .cache }} region: {{ .region }} diff --git a/templates/definition/meter/goodwe-wifi-es.yaml b/templates/definition/meter/goodwe-wifi-es.yaml index 056b36a9c..e7eb69805 100644 --- a/templates/definition/meter/goodwe-wifi-es.yaml +++ b/templates/definition/meter/goodwe-wifi-es.yaml @@ -16,7 +16,7 @@ render: | {{- if eq .usage "grid" }} power: source: aa55udp - host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + host: {{ or .host .uri }} register: 29964 count: 2 decode: int32be @@ -24,7 +24,7 @@ render: | {{- if eq .usage "pv" }} power: source: aa55udp - host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + host: {{ or .host .uri }} register: 29958 count: 2 decode: int32be @@ -32,13 +32,13 @@ render: | {{- if eq .usage "battery" }} power: source: aa55udp - host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + host: {{ or .host .uri }} register: 29970 count: 2 decode: int32be soc: source: aa55udp - host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + host: {{ or .host .uri }} register: 29966 count: 1 decode: uint16be diff --git a/templates/definition/meter/goodwe-wifi.yaml b/templates/definition/meter/goodwe-wifi.yaml index 3bd8eab9b..b2d4f0c66 100644 --- a/templates/definition/meter/goodwe-wifi.yaml +++ b/templates/definition/meter/goodwe-wifi.yaml @@ -16,4 +16,4 @@ params: render: | type: goodwe-wifi usage: {{ .usage }} - uri: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + uri: {{ or .host .uri }} diff --git a/templates/definition/meter/sonnenbatterie_eco56.yaml b/templates/definition/meter/sonnenbatterie_eco56.yaml index a3829cbfb..03033abd4 100644 --- a/templates/definition/meter/sonnenbatterie_eco56.yaml +++ b/templates/definition/meter/sonnenbatterie_eco56.yaml @@ -115,6 +115,6 @@ render: | - source: http method: PUT uri: http://{{ .host }}:{{ .port }}/rest/devices/battery/C24 - body: {{ if .maxchargepower }}{{ .maxchargepower }}{{ else }}99000{{ end }} + body: {{ or .maxchargepower 99000 }} {{- include "battery-params" . }} {{- end }} From ed3f44fe1925fde7b06fe70001ab6842db7287ac Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Apr 2026 13:32:38 +0200 Subject: [PATCH 0271/1580] Ecoflow: add battery params (#28903) --- meter/ecoflow.go | 86 ++++++++----------- .../definition/meter/ecoflow-powerocean.yaml | 16 +++- .../definition/meter/ecoflow-stream.yaml | 16 +++- 3 files changed, 60 insertions(+), 58 deletions(-) diff --git a/meter/ecoflow.go b/meter/ecoflow.go index af468a265..acc014055 100644 --- a/meter/ecoflow.go +++ b/meter/ecoflow.go @@ -21,7 +21,7 @@ type EcoFlow struct { client *ecoflow.Client dataG func() (*ecoflow.GetCmdResponse, error) - gridPower, pvPower, batteryPower, batterySoc string + power, batterySoc string } func init() { @@ -31,10 +31,13 @@ func init() { // NewEcoFlowFromConfig creates an EcoFlow meter from generic config func NewEcoFlowFromConfig(ctx context.Context, other map[string]any) (api.Meter, error) { cc := struct { - Usage string - AccessKey, SecretKey, Serial, Region string - GridPower, PvPower, BatteryPower, BatterySoc string - Cache time.Duration + batteryCapacity `mapstructure:",squash"` + batteryPowerLimits `mapstructure:",squash"` + batterySocLimits `mapstructure:",squash"` + Usage string + AccessKey, SecretKey, Serial, Region string + Power, Soc string + Cache time.Duration }{ Cache: 30 * time.Second, } @@ -67,14 +70,16 @@ func NewEcoFlowFromConfig(ctx context.Context, other map[string]any) (api.Meter, return nil, fmt.Errorf("invalid region: %s", cc.Region) } - m, err := NewEcoFlow(ctx, cc.AccessKey, cc.SecretKey, cc.Serial, cc.Usage, uri, - cc.GridPower, cc.PvPower, cc.BatteryPower, cc.BatterySoc, cc.Cache) + m, err := NewEcoFlow(ctx, cc.AccessKey, cc.SecretKey, cc.Serial, cc.Usage, uri, cc.Power, cc.Soc, cc.Cache) if err != nil { return nil, err } if cc.Usage == "battery" { - return &EcoFlowBattery{m}, nil + return decorateMeterBattery( + m, nil, m.soc, cc.batteryCapacity.Decorator(), + cc.batterySocLimits.Decorator(), cc.batteryPowerLimits.Decorator(), nil, + ), nil } return m, nil @@ -82,17 +87,15 @@ func NewEcoFlowFromConfig(ctx context.Context, other map[string]any) (api.Meter, // NewEcoFlow constructs the EcoFlow struct func NewEcoFlow(ctx context.Context, accessKey, secretKey, serial, usage, uri string, - gridPower, pvPower, batteryPower, batterySoc string, cache time.Duration) (*EcoFlow, error) { + power, soc string, cache time.Duration) (*EcoFlow, error) { m := &EcoFlow{ - ctx: ctx, - serial: serial, - usage: usage, - cache: cache, - client: ecoflow.NewEcoflowClient(accessKey, secretKey, ecoflow.WithBaseUrl(uri)), - gridPower: gridPower, - pvPower: pvPower, - batteryPower: batteryPower, - batterySoc: batterySoc, + ctx: ctx, + serial: serial, + usage: usage, + cache: cache, + client: ecoflow.NewEcoflowClient(accessKey, secretKey, ecoflow.WithBaseUrl(uri)), + power: power, + batterySoc: soc, } m.dataG = util.Cached(m.getData, cache) @@ -105,14 +108,10 @@ func (m *EcoFlow) getData() (*ecoflow.GetCmdResponse, error) { ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second) defer cancel() - var params []string - switch m.usage { - case "grid": - params = []string{m.gridPower} - case "pv": - params = []string{m.pvPower} - case "battery": - params = []string{m.batteryPower, m.batterySoc} + params := []string{m.power} + + if m.usage == "battery" { + params = append(params, m.batterySoc) } return m.client.GetDeviceParameters(ctx, m.serial, params) @@ -127,24 +126,16 @@ func (m *EcoFlow) CurrentPower() (float64, error) { return 0, err } - // sysGridPwr responds with int - // mpptPwr responds with an array of floats - // bpPwr responds with int - // bpSoc responds with int - switch m.usage { - case "grid": - return ecoflowValue(response.Data, m.gridPower) - case "pv": - return ecoflowValue(response.Data, m.pvPower) - case "battery": - pwr, err := ecoflowValue(response.Data, m.batteryPower) - if err != nil { - return 0, err - } - return -pwr, nil // invert battery power: ecoflow returns negative when discharging and positive when charging. - default: - return 0, fmt.Errorf("invalid usage: %s", m.usage) + pwr, err := ecoflowValue(response.Data, m.power) + if err != nil { + return 0, err } + + if m.usage == "battery" { + pwr = -pwr // invert battery power: ecoflow returns negative when discharging and positive when charging. + } + + return pwr, nil } // extractFloat extracts a float64 or int value from a map by key. @@ -157,13 +148,8 @@ func ecoflowValue(data map[string]any, key string) (float64, error) { return 0, api.ErrNotAvailable } -// EcoFlowBattery represents the EcoFlow battery decorator -type EcoFlowBattery struct { - *EcoFlow -} - -// Soc implements the api.Battery interface for battery usage -func (m *EcoFlowBattery) Soc() (float64, error) { +// soc returns the battery state of charge +func (m *EcoFlow) soc() (float64, error) { response, err := m.dataG() if err != nil { return 0, err diff --git a/templates/definition/meter/ecoflow-powerocean.yaml b/templates/definition/meter/ecoflow-powerocean.yaml index 1fa102169..5468e9f74 100644 --- a/templates/definition/meter/ecoflow-powerocean.yaml +++ b/templates/definition/meter/ecoflow-powerocean.yaml @@ -52,6 +52,7 @@ params: - name: cache default: 30s advanced: true + - preset: battery-params render: | type: ecoflow accesskey: {{ .accesskey }} @@ -60,7 +61,14 @@ render: | usage: {{ .usage }} region: {{ .region }} cache: {{ .cache }} - gridpower: sysGridPwr - pvpower: mpptPwr - batterypower: bpPwr - batterysoc: bpSoc + {{- if eq .usage "grid" }} + power: sysGridPwr + {{- end }} + {{- if eq .usage "pv" }} + power: mpptPwr + {{- end }} + {{- if eq .usage "battery" }} + power: bpPwr + soc: bpSoc + {{- include "battery-params" . }} + {{- end }} diff --git a/templates/definition/meter/ecoflow-stream.yaml b/templates/definition/meter/ecoflow-stream.yaml index 65b886318..95040b692 100644 --- a/templates/definition/meter/ecoflow-stream.yaml +++ b/templates/definition/meter/ecoflow-stream.yaml @@ -53,6 +53,7 @@ params: - name: cache default: 30s advanced: true + - preset: battery-params render: | type: ecoflow accesskey: {{ .accesskey }} @@ -61,7 +62,14 @@ render: | usage: {{ .usage }} cache: {{ .cache }} region: {{ .region }} - gridpower: powGetSysGrid - pvpower: powGetPvSum - batterypower: powGetBpCms - batterysoc: cmsBattSoc + {{- if eq .usage "grid" }} + power: powGetSysGrid + {{- end }} + {{- if eq .usage "pv" }} + power: powGetPvSum + {{- end }} + {{- if eq .usage "battery" }} + power: powGetBpCms + soc: cmsBattSoc + {{- include "battery-params" . }} + {{- end }} From c375354f08dded981e5694e94e5dbb953a7b61b5 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Apr 2026 13:41:02 +0200 Subject: [PATCH 0272/1580] Goodwe-wifi: deprecate (#28898) --- templates/definition/meter/goodwe-wifi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/definition/meter/goodwe-wifi.yaml b/templates/definition/meter/goodwe-wifi.yaml index b2d4f0c66..73dd6b3b4 100644 --- a/templates/definition/meter/goodwe-wifi.yaml +++ b/templates/definition/meter/goodwe-wifi.yaml @@ -1,6 +1,6 @@ template: goodwe-wifi # UDP implementation is broken -# deprecated: true +deprecated: true products: - brand: GoodWe description: From e75b5e91a07f4b7ad477efd14e89141f5cdf2a23 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 7 Apr 2026 16:51:28 +0200 Subject: [PATCH 0273/1580] Log UI: progress indicator (#28914) --- .../components/MaterialIcon/ProgressRing.vue | 52 +++++++++++++++++++ assets/js/views/Log.vue | 25 +++------ 2 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 assets/js/components/MaterialIcon/ProgressRing.vue diff --git a/assets/js/components/MaterialIcon/ProgressRing.vue b/assets/js/components/MaterialIcon/ProgressRing.vue new file mode 100644 index 000000000..0a53ce737 --- /dev/null +++ b/assets/js/components/MaterialIcon/ProgressRing.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/assets/js/views/Log.vue b/assets/js/views/Log.vue index f6ff1d02e..1c2a6556d 100644 --- a/assets/js/views/Log.vue +++ b/assets/js/views/Log.vue @@ -15,11 +15,11 @@ {{ $t("log.update") }} - @@ -110,7 +110,7 @@ import "@h2d2/shopicons/es/regular/download"; import Header from "../components/Top/Header.vue"; import Play from "../components/MaterialIcon/Play.vue"; -import Record from "../components/MaterialIcon/Record.vue"; +import ProgressRing from "../components/MaterialIcon/ProgressRing.vue"; import MultiSelect from "../components/Helper/MultiSelect.vue"; import api from "../api"; import store from "../store"; @@ -126,7 +126,7 @@ export default defineComponent({ components: { TopHeader: Header, Play, - Record, + ProgressRing, MultiSelect, }, props: { @@ -141,6 +141,7 @@ export default defineComponent({ timeout: null as Timeout, levels: LOG_LEVELS, busy: false, + tick: 0, }; }, head() { @@ -221,6 +222,7 @@ export default defineComponent({ // prevent concurrent requests if (this.busy) return; + this.tick++; try { this.busy = true; const response = await api.get("/system/log", { @@ -327,17 +329,6 @@ export default defineComponent({ .play { transform: scale(1.2); } -.spin { - animation: rotation 3s infinite ease-in-out; -} -@keyframes rotation { - from { - transform: rotate(0deg) scale(1, -1); - } - to { - transform: rotate(1440deg) scale(1, -1); - } -} @keyframes fadeIn { from { opacity: 0.3; From 1e15854687af51e3cc829544f5122f22c9049981 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 7 Apr 2026 18:25:55 +0200 Subject: [PATCH 0274/1580] chore: add status.evcc.io badge --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 31564c2c0..0c66d6742 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # evcc 🚘☀️ [![Build](https://github.com/evcc-io/evcc/actions/workflows/nightly.yml/badge.svg)](https://github.com/evcc-io/evcc/actions/workflows/nightly.yml) +[![Statuspage](https://img.shields.io/badge/status-evcc.io-green?color=brightgreen&link=https%3A%2F%2Fstatus.evcc.io)](https://status.evcc.io/) [![Translation](https://hosted.weblate.org/widgets/evcc/-/evcc/svg-badge.svg)](https://hosted.weblate.org/engage/evcc/) ![Docker Pulls](https://img.shields.io/docker/pulls/evcc/evcc) [![OSS hosting by cloudsmith](https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith)](https://cloudsmith.io/~evcc/packages/) -[![Latest Version](https://img.shields.io/github/release/evcc-io/evcc.svg)](https://github.com/evcc-io/evcc/releases)
+[![Latest Version](https://img.shields.io/github/release/evcc-io/evcc.svg)](https://github.com/evcc-io/evcc/releases) +
[![Built with Depot](https://depot.dev/badges/built-with-depot.svg)](https://depot.dev/?utm_source=evcc) evcc is an extensible EV Charge Controller and home energy management system. From cf9ea4ce907fc809cadc55e4c8b3ec304bc2b2be Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 7 Apr 2026 18:29:15 +0200 Subject: [PATCH 0275/1580] UI: prevent multiselect bottomnav overlap (#28901) --- assets/js/components/Helper/MultiSelect.vue | 73 +++++++++++++-------- assets/js/views/Log.vue | 1 + 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/assets/js/components/Helper/MultiSelect.vue b/assets/js/components/Helper/MultiSelect.vue index e59f0da0f..e47133b00 100644 --- a/assets/js/components/Helper/MultiSelect.vue +++ b/assets/js/components/Helper/MultiSelect.vue @@ -11,40 +11,46 @@ > - + + @@ -61,6 +67,7 @@ export default defineComponent({ value: { type: Array as PropType, default: () => [] }, options: { type: Array as PropType[]>, default: () => [] }, selectAllLabel: String, + isTopLevel: Boolean, }, emits: ["open", "update:modelValue"], data() { @@ -116,3 +123,15 @@ export default defineComponent({ }, }); + diff --git a/assets/js/views/Log.vue b/assets/js/views/Log.vue index 1c2a6556d..601be30af 100644 --- a/assets/js/views/Log.vue +++ b/assets/js/views/Log.vue @@ -60,6 +60,7 @@
Date: Tue, 7 Apr 2026 19:41:13 +0200 Subject: [PATCH 0276/1580] Revert "Goodwe-wifi: deprecate (#28898)" This reverts commit c375354f08dded981e5694e94e5dbb953a7b61b5. --- templates/definition/meter/goodwe-wifi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/definition/meter/goodwe-wifi.yaml b/templates/definition/meter/goodwe-wifi.yaml index 73dd6b3b4..b2d4f0c66 100644 --- a/templates/definition/meter/goodwe-wifi.yaml +++ b/templates/definition/meter/goodwe-wifi.yaml @@ -1,6 +1,6 @@ template: goodwe-wifi # UDP implementation is broken -deprecated: true +# deprecated: true products: - brand: GoodWe description: From f1408ad10fa5cfb7ac0e3a3438f96bf90b2b56f1 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 8 Apr 2026 09:07:19 +0200 Subject: [PATCH 0277/1580] Fix chargeMeter capability lookup for decorated chargers (#28920) --- api/capable_test.go | 46 +++++++++++++++++++++++++++++++++++++++++++++ core/loadpoint.go | 17 ++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/api/capable_test.go b/api/capable_test.go index a365299ea..565dd596e 100644 --- a/api/capable_test.go +++ b/api/capable_test.go @@ -66,6 +66,52 @@ func TestCap_CapableRegistryLookup(t *testing.T) { assert.False(t, ok) } +// decoratedCharger simulates a real decorated charger where Meter is NOT +// directly embedded but only available through the capability registry. +type decoratedCharger struct { + caps map[reflect.Type]any +} + +func (d *decoratedCharger) Capability(typ reflect.Type) (any, bool) { + c, ok := d.caps[typ] + return c, ok +} + +func TestCap_ExtractedCapabilityLosesRegistry(t *testing.T) { + // Reproduces https://github.com/evcc-io/evcc/issues/28915 + // When a Meter is extracted from a decorated charger via Cap[Meter], + // the extracted impl does NOT carry the Capable interface, so + // subsequent Cap[MeterEnergy] on the extracted value fails. + decorated := &decoratedCharger{ + caps: map[reflect.Type]any{ + reflect.TypeFor[Meter](): &testMeterImpl{}, + reflect.TypeFor[MeterEnergy](): &testMeterEnergyImpl{}, + }, + } + + // extract Meter from decorated source (slow path: from caps registry) + mt, ok := Cap[Meter](decorated) + require.True(t, ok) + + // Bug: extracted meter cannot find MeterEnergy because it's a standalone impl + _, ok = Cap[MeterEnergy](mt) + assert.False(t, ok, "extracted meter should NOT have MeterEnergy capability") + + // Fix: wrapping extracted meter with source's Capable preserves registry + type capableMeter struct { + Meter + Capable + } + wrapped := &capableMeter{Meter: mt, Capable: decorated} + + me, ok := Cap[MeterEnergy](wrapped) + require.True(t, ok, "wrapped meter should find MeterEnergy via Capable") + + energy, err := me.TotalEnergy() + assert.NoError(t, err) + assert.Equal(t, 99.0, energy) +} + func TestCap_NilValue(t *testing.T) { _, ok := Cap[MeterEnergy](nil) assert.False(t, ok) diff --git a/core/loadpoint.go b/core/loadpoint.go index 00a51a489..4177e6a34 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -395,6 +395,14 @@ func (lp *Loadpoint) requestUpdate() { } } +// capableMeter wraps a meter with capability lookup from its source. +// This preserves capability checks (like MeterEnergy, PhaseCurrents, PhaseVoltages) when +// the meter was extracted from a decorated charger's capability registry. +type capableMeter struct { + api.Meter + api.Capable +} + // configureChargerType ensures that chargeMeter, Rate and Timer can use charger capabilities func (lp *Loadpoint) configureChargerType(charger api.Charger) { var integrated bool @@ -404,7 +412,14 @@ func (lp *Loadpoint) configureChargerType(charger api.Charger) { integrated = true if mt, ok := api.Cap[api.Meter](charger); ok { - lp.chargeMeter = mt + // preserve charger's capability registry so that subsequent + // capability checks on chargeMeter (e.g. MeterEnergy, PhaseCurrents) + // still work for decorated chargers (https://github.com/evcc-io/evcc/issues/28915) + if c, ok := charger.(api.Capable); ok { + lp.chargeMeter = &capableMeter{Meter: mt, Capable: c} + } else { + lp.chargeMeter = mt + } } else { mt := new(wrapper.ChargeMeter) _ = lp.bus.Subscribe(evChargeCurrent, lp.evChargeCurrentWrappedMeterHandler) From e20d76534493fef2d2c907d2612bc08cc930fb37 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 8 Apr 2026 09:08:29 +0200 Subject: [PATCH 0278/1580] Forecast UI: compacter, small fixes (#28917) --- assets/js/components/Forecast/Co2Chart.vue | 7 ------- assets/js/components/Forecast/PriceChart.vue | 11 ++--------- assets/js/components/Forecast/SolarChart.vue | 7 ------- assets/js/components/Forecast/chartMixin.ts | 7 +++++++ assets/js/components/Forecast/chartStyles.css | 7 +++++++ assets/js/components/Forecast/echarts.ts | 14 ++++++++------ assets/js/views/Forecast.vue | 9 ++++++--- 7 files changed, 30 insertions(+), 32 deletions(-) create mode 100644 assets/js/components/Forecast/chartStyles.css diff --git a/assets/js/components/Forecast/Co2Chart.vue b/assets/js/components/Forecast/Co2Chart.vue index 438098327..ea688deea 100644 --- a/assets/js/components/Forecast/Co2Chart.vue +++ b/assets/js/components/Forecast/Co2Chart.vue @@ -118,10 +118,3 @@ export default defineComponent({ }, }); - - diff --git a/assets/js/components/Forecast/PriceChart.vue b/assets/js/components/Forecast/PriceChart.vue index 14a99622e..00298fa81 100644 --- a/assets/js/components/Forecast/PriceChart.vue +++ b/assets/js/components/Forecast/PriceChart.vue @@ -131,8 +131,8 @@ export default defineComponent({ ? { opacity: 0.33 } : undefined, })), - barMaxWidth: 4, - barMinWidth: 4, + barMaxWidth: 2, + barMinWidth: 2, itemStyle: { color: priceColor, borderRadius: 2, @@ -164,10 +164,3 @@ export default defineComponent({ }, }); - - diff --git a/assets/js/components/Forecast/SolarChart.vue b/assets/js/components/Forecast/SolarChart.vue index bd13a76d9..528279bef 100644 --- a/assets/js/components/Forecast/SolarChart.vue +++ b/assets/js/components/Forecast/SolarChart.vue @@ -129,10 +129,3 @@ export default defineComponent({ }, }); - - diff --git a/assets/js/components/Forecast/chartMixin.ts b/assets/js/components/Forecast/chartMixin.ts index 4df452935..d8c9cecf3 100644 --- a/assets/js/components/Forecast/chartMixin.ts +++ b/assets/js/components/Forecast/chartMixin.ts @@ -1,5 +1,6 @@ import { defineComponent, markRaw } from "vue"; import { echarts } from "./echarts"; +import "./chartStyles.css"; // chartOption is provided by each consuming component's computed type WithChartOption = { chartOption: Record }; @@ -62,6 +63,12 @@ export default defineComponent({ this.chart.on("hideTip", () => { this.tooltipVisible = false; }); + const resetTouch = () => { + this.chart?.dispatchAction({ type: "hideTip" }); + this.tooltipVisible = false; + }; + el.addEventListener("touchend", resetTouch); + el.addEventListener("touchcancel", resetTouch); }, }, }); diff --git a/assets/js/components/Forecast/chartStyles.css b/assets/js/components/Forecast/chartStyles.css new file mode 100644 index 000000000..5b9f3e23e --- /dev/null +++ b/assets/js/components/Forecast/chartStyles.css @@ -0,0 +1,7 @@ +.forecast-chart-scroll { + overflow-x: auto; + padding-bottom: 4px; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index d8f61a122..d56556b54 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -51,7 +51,7 @@ export function markPointLabel( return { animation: true, - clip: false, + clip: true, symbol: "rect", symbolSize: 1, label: { @@ -76,11 +76,12 @@ export function tooltipStyle( getChart?: () => { convertToPixel: echarts.ECharts["convertToPixel"] } | null ) { return { + confine: true, backgroundColor: color, borderColor: color, borderWidth: 0, padding: [5, 10], - extraCssText: "box-shadow: none; border-radius: 4px; text-align: center;", + extraCssText: "box-shadow: none; border-radius: 4px; text-align: center; z-index: 1000;", position( point: [number, number], params: { value: [string, number] }[] | { value: [string, number] }, @@ -110,7 +111,7 @@ export function tooltipStyle( } export function forecastGrid() { - return { top: 36, right: 16, bottom: 4, left: 48, borderWidth: 0 }; + return { top: 36, right: 16, bottom: 4, left: 34, borderWidth: 0 }; } export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: Date) => string) { @@ -129,7 +130,7 @@ export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: formatter: (value: number) => { const date = new Date(value); const h = date.getHours(); - if (h % 2 !== 0) return ""; + if (h % 4 !== 0) return ""; if (h === 0) return `${h}\n${weekdayShort(date)}`; return `${h}`; }, @@ -171,7 +172,8 @@ export function forecastYAxis(overrides: Record = {}) { lineStyle: { color: colors.border || "" }, }, axisLabel: { - fontSize: 14, + fontSize: 10, + opacity: 0.5, ...(axisLabel as Record), }, ...rest, @@ -188,7 +190,7 @@ export function filterForecastSlots( endDate: Date ): ForecastSlot[] { if (!Array.isArray(slots)) return []; - return slots.filter((s) => new Date(s.end) >= startDate && new Date(s.start) <= endDate); + return slots.filter((s) => new Date(s.end) > startDate && new Date(s.start) <= endDate); } export function minSlotIndex(slots: ForecastSlot[]): number { diff --git a/assets/js/views/Forecast.vue b/assets/js/views/Forecast.vue index 65d4f6d65..2410839d4 100644 --- a/assets/js/views/Forecast.vue +++ b/assets/js/views/Forecast.vue @@ -157,7 +157,7 @@ import store from "../store"; import { adjustedSolar, ForecastType } from "@/utils/forecast"; import type { ForecastSlot } from "../components/Forecast/types"; -const MIN_HOURS = 38; +const MIN_HOURS = 76; const MAX_HOURS = 96; const SLOTS_PER_HOUR = 4; @@ -219,7 +219,7 @@ export default defineComponent({ chartWidth(): number { const ms = this.chartEndDate.getTime() - this.startDate.getTime(); const slots = Math.ceil(ms / (15 * 60 * 1000)); - return slots * 8 + 56; + return slots * 4 + 56; }, currency() { return store.state?.currency; @@ -242,7 +242,10 @@ export default defineComponent({ : this.forecast.solar; }, solarAdjustText() { - return this.$t("forecast.solarAdjustShort"); + const text = this.$t("forecast.solarAdjustShort"); + const scale = this.forecast.solar?.scale || 1; + const percentDiff = scale * 100 - 100; + return `${text} (${this.fmtPercentage(percentDiff, 0, true)})`; }, solarSubtitle(): string { const s = this.solar; From 3001cfbf41e6aa933d12cbc2e10b81dc428e0277 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 8 Apr 2026 09:09:01 +0200 Subject: [PATCH 0279/1580] Energyflow: forecast/battery navigation (#28918) --- assets/js/components/Energyflow/Energyflow.vue | 8 ++++++++ assets/js/components/Top/Navigation.vue | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index a6918d5cb..70335a9f1 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -652,12 +652,20 @@ export default defineComponent({ this.detailsCompleteHeight = this.$refs["detailsInner"]?.offsetHeight ?? 0; }, openBatterySettingsModal() { + if (this.experimental) { + this.$router.push("/battery"); + return; + } const modal = Modal.getOrCreateInstance( document.getElementById("batterySettingsModal") as HTMLElement ); modal.show(); }, openForecastModal() { + if (this.experimental) { + this.$router.push("/forecast"); + return; + } const modal = Modal.getOrCreateInstance( document.getElementById("forecastModal") as HTMLElement ); diff --git a/assets/js/components/Top/Navigation.vue b/assets/js/components/Top/Navigation.vue index 5ba73e434..3efc6d1d9 100644 --- a/assets/js/components/Top/Navigation.vue +++ b/assets/js/components/Top/Navigation.vue @@ -249,12 +249,20 @@ export default defineComponent({ modal.show(); }, openBatterySettingsModal() { + if (this.experimental) { + this.$router.push("/battery"); + return; + } const modal = Modal.getOrCreateInstance( document.getElementById("batterySettingsModal") as HTMLElement ); modal.show(); }, openForecastModal() { + if (this.experimental) { + this.$router.push("/forecast"); + return; + } const modal = Modal.getOrCreateInstance( document.getElementById("forecastModal") as HTMLElement ); From 411916982d4a164cd317d56c1d9a91317da3cfaf Mon Sep 17 00:00:00 2001 From: Martin Porsch Date: Wed, 8 Apr 2026 09:10:09 +0200 Subject: [PATCH 0280/1580] Fix: site soc calc was wrong if some batteries had no capacity (#28910) --- core/site.go | 47 +++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/core/site.go b/core/site.go index 0d9ceb0eb..b4596dd8b 100644 --- a/core/site.go +++ b/core/site.go @@ -631,27 +631,19 @@ func (site *Site) updateBatteryMeters() { mm[i].Controllable = new(controllable) } - batterySocAcc := lo.SumBy(mm, func(m types.Measurement) float64 { - if m.Soc == nil { - return 0 - } - // weigh soc by capacity - if m.Capacity != nil && *m.Capacity > 0 { - return *m.Soc * *m.Capacity - } - return *m.Soc - }) - totalCapacity := lo.SumBy(mm, func(m types.Measurement) float64 { - if m.Capacity == nil { - return 0 - } - return *m.Capacity - }) + var batterySocAcc float64 + var totalCapacity float64 - // convert weighed socs to total soc - if totalCapacity == 0 { + if lo.SomeBy(mm, func(m types.Measurement) bool { return m.Capacity == nil || *m.Capacity <= 0 }) { + // any capacity is missing + batterySocAcc = sumOfSocs(mm) totalCapacity = float64(len(site.batteryMeters)) + } else { + // all capacities available - weigh soc by capacity + batterySocAcc = weightedSumOfSocs(mm) + totalCapacity = lo.SumBy(mm, func(m types.Measurement) float64 { return *m.Capacity }) } + site.battery.Soc = batterySocAcc / totalCapacity site.battery.Capacity = totalCapacity @@ -672,6 +664,25 @@ func (site *Site) updateBatteryMeters() { site.publish(keys.Battery, site.battery) } +func sumOfSocs(mm []types.Measurement) float64 { + return lo.SumBy(mm, func(m types.Measurement) float64 { + if m.Soc == nil { + return 0 + } + return *m.Soc + }) +} + +func weightedSumOfSocs(mm []types.Measurement) float64 { + return lo.SumBy(mm, func(m types.Measurement) float64 { + if m.Soc == nil { + return 0 + } + // weigh soc by capacity + return *m.Soc * *m.Capacity + }) +} + // updateAuxMeters updates aux meters func (site *Site) updateAuxMeters() { if len(site.auxMeters) == 0 { From 09d319b136566fa0e9367c75c18b54964b7428a5 Mon Sep 17 00:00:00 2001 From: Rene Nulsch <33263735+ReneNulschDE@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:20:56 +0200 Subject: [PATCH 0281/1580] Mercedes: update application and SDK versions (#28938) --- vehicle/mercedes/helper.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/vehicle/mercedes/helper.go b/vehicle/mercedes/helper.go index d91ea8b6a..a53a670f9 100644 --- a/vehicle/mercedes/helper.go +++ b/vehicle/mercedes/helper.go @@ -35,12 +35,12 @@ const ( BffUriNORAM = "https://bff.amap-prod.mobilesdk.mercedes-benz.com" WidgetUriNORAM = "https://widget.amap-prod.mobilesdk.mercedes-benz.com" IdUri = "https://id.mercedes-benz.com" - RisApplicationVersionEMEA = "1.63.0 (3044)" - RisSdkVersionEMEA = "3.26.2" - RisApplicationVersionAPAC = "1.63.0" - RisSdkVersionAPAC = "3.150.1" - RisApplicationVersionNORAM = "3.63.0" - RisSdkVersionNORAM = "3.150.1" + RisApplicationVersionEMEA = "1.65.1 (3174)" + RisSdkVersionEMEA = "4.4.2" + RisApplicationVersionAPAC = "1.65.0" + RisSdkVersionAPAC = "4.4.2" + RisApplicationVersionNORAM = "3.65.0" + RisSdkVersionNORAM = "4.4.2" RisOsVersion = "26.3" RisOsName = "ios" XApplicationNameEMEA = "mycar-store-ece" From f79bddd116636cd319d095bee39d4286920a73f8 Mon Sep 17 00:00:00 2001 From: Hobby-Student <6012744+Hobby-Student@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:22:11 +0200 Subject: [PATCH 0282/1580] Add Askoma ASKOHEAT+ (#28827) --- templates/definition/charger/askoheat.yaml | 84 ++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 templates/definition/charger/askoheat.yaml diff --git a/templates/definition/charger/askoheat.yaml b/templates/definition/charger/askoheat.yaml new file mode 100644 index 000000000..d5af7d6fc --- /dev/null +++ b/templates/definition/charger/askoheat.yaml @@ -0,0 +1,84 @@ +template: askoheat +products: + - brand: Askoma + description: + generic: ASKOHEAT+ +group: heating +requirements: + description: + de: | + Der ASKOHEAT+ muss über Modbus TCP erreichbar sein (Port 502). + Über das Webinterface sicherstellen, dass "Load Setpoint" im Input Setting aktiviert ist. + + **Wichtig:** Im Loadpoint `mincurrent` und `maxcurrent` passend zum Gerät setzen, + sowie `phases: 1` für einphasigen Betrieb konfigurieren. + Beispiel AHIR-BI-plus-1.75: `mincurrent: 1.1`, `maxcurrent: 7.6` (bei 230V, 1 Phase). + en: | + The ASKOHEAT+ must be reachable via Modbus TCP (port 502). + Ensure "Load Setpoint" is enabled in Input Settings via the web interface. + + **Important:** In the loadpoint configuration, set `mincurrent` and `maxcurrent` to match + your device, and configure `phases: 1` for single-phase operation. + Example AHIR-BI-plus-1.75: `mincurrent: 1.1`, `maxcurrent: 7.6` (at 230V, 1 phase). +params: + - name: host + - name: port + default: 502 + - name: id + default: 1 + - name: tempsensor + type: choice + choice: ["0", "1", "2", "3", "4"] + default: "0" + description: + de: Temperatursensor + en: Temperature sensor +render: | + type: heatpump + setmaxpower: + source: watchdog + timeout: 30s + set: + source: modbus + uri: {{ .host }}:{{ .port }} + id: {{ .id }} + register: + address: 319 + type: writesingle + encoding: int16 + getmaxpower: + source: modbus + uri: {{ .host }}:{{ .port }} + id: {{ .id }} + register: + address: 319 + type: input + encoding: int16 + power: + source: modbus + uri: {{ .host }}:{{ .port }} + id: {{ .id }} + register: + address: 317 + type: input + encoding: uint16 + temp: + source: modbus + uri: {{ .host }}:{{ .port }} + id: {{ .id }} + register: + address: {{ add 325 (mul (int .tempsensor) 2) }} + type: input + encoding: float32 + limittemp: + source: modbus + uri: {{ .host }}:{{ .port }} + id: {{ .id }} + register: + address: 640 + type: input + encoding: uint16 + icon: waterheater + features: + - heating + - integrateddevice From dec0e7d5b22530cedb038c8f647e677df72505a7 Mon Sep 17 00:00:00 2001 From: RenatusRo <126881912+RenatusRo@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:35:17 +0200 Subject: [PATCH 0283/1580] Optimizer: improve error message for solar forecast (#28934) --- core/site_optimizer.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index b351a9596..8ce7eb9b0 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -136,12 +136,16 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { feedIn := currentRates(site.GetTariff(api.TariffUsageFeedIn)) minLen := lo.Min([]int{len(grid), len(feedIn)}) - if solarTariff != nil { - // allow empty solar forecast + // exclude empty solar forecast from minLen + if solarTariff != nil && len(solar) > 0 { minLen = min(minLen, len(solar)) } - if minLen < 8 { - return fmt.Errorf("not enough slots for optimization: %d (grid=%d, feedIn=%d, solar=%d)", minLen, len(grid), len(feedIn), len(solar)) + const expectedSlots = 8 + if minLen < expectedSlots { + if solarTariff != nil { + return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d, solar=%d)", minLen, expectedSlots, len(grid), len(feedIn), len(solar)) + } + return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d)", minLen, expectedSlots, len(grid), len(feedIn)) } now := time.Now() From 80900fd64846e46a2867a9b9e31cd95492fd9ccb Mon Sep 17 00:00:00 2001 From: Rishabh Vaish Date: Wed, 8 Apr 2026 03:36:38 -0700 Subject: [PATCH 0284/1580] chore: fix response body leak on 404 and hoist LoadLocation (#28612) --- tariff/pun.go | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tariff/pun.go b/tariff/pun.go index fa86988e2..a60f0fd88 100644 --- a/tariff/pun.go +++ b/tariff/pun.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "encoding/xml" + "errors" "fmt" "net/http" "net/http/cookiejar" @@ -19,6 +20,12 @@ import ( "github.com/evcc-io/evcc/util/request" ) +// ErrPunDataNotAvailable indicates that GME has not yet published prices for the requested day. +var ErrPunDataNotAvailable = errors.New("PUN data not available") + +// romeLocation is resolved once at package init to avoid repeated filesystem lookups. +var romeLocation *time.Location + type Pun struct { *embed log *util.Logger @@ -46,6 +53,7 @@ var _ api.Tariff = (*Pun)(nil) func init() { registry.Add("pun", NewPunFromConfig) + romeLocation, _ = time.LoadLocation("Europe/Rome") } func NewPunFromConfig(other map[string]any) (api.Tariff, error) { @@ -137,9 +145,14 @@ func (t *Pun) getData(day time.Time) (api.Rates, error) { } resp, err := client.Do(req) - if err != nil || resp.StatusCode == http.StatusNotFound { + if err != nil { return nil, err } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("%w: %s", ErrPunDataNotAvailable, day.Format("2006-01-02")) + } body, err := request.ReadBody(resp) if err != nil { @@ -193,17 +206,12 @@ func (t *Pun) getData(day time.Time) (api.Rates, error) { date = date.AddDate(0, 0, -1) } - location, err := time.LoadLocation("Europe/Rome") - if err != nil { - return nil, fmt.Errorf("load location: %w", err) - } - price, err := strconv.ParseFloat(strings.ReplaceAll(p.PUN, ",", "."), 64) if err != nil { return nil, fmt.Errorf("parse price: %w", err) } - ts := time.Date(date.Year(), date.Month(), date.Day(), hour-1, 0, 0, 0, location) + ts := time.Date(date.Year(), date.Month(), date.Day(), hour-1, 0, 0, 0, romeLocation) ar := api.Rate{ Start: ts, End: ts.Add(time.Hour), From 462dbd0def95f86cea51041579437adf8a9f2854 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 8 Apr 2026 17:24:08 +0200 Subject: [PATCH 0285/1580] Config UI: fix default for required choice params (#28935) --- assets/js/components/Config/DeviceModal/index.ts | 1 + assets/js/components/Config/LoadpointModal.vue | 6 +----- assets/js/components/Config/PropertyField.vue | 8 ++++---- assets/js/components/Config/VehicleModal.vue | 3 ++- tests/boost.spec.ts | 1 + tests/config-loadpoint.spec.ts | 4 ++-- 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index f1304d289..afa1123d8 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -68,6 +68,7 @@ export type ApiData = { icon?: string; usage?: MeterTemplateUsage; title?: string; + priority?: number; identifiers?: string[]; [key: string]: any; }; diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 7f5defd2f..96739b376 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -114,7 +114,6 @@ v-model="values.defaultMode" type="Choice" class="w-100" - required :choice="[ { key: '', name: '---' }, { key: 'off', name: $t('main.mode.off') }, @@ -302,8 +301,8 @@ type="Choice" size="w-100" class="me-2" - :choice="priorityOptions" required + :choice="priorityOptions" /> @@ -435,7 +434,6 @@ type="Choice" class="me-2" :choice="circuitOptions" - required />
@@ -465,7 +463,6 @@ type="Choice" class="me-2" :choice="allVehicleOptions" - required /> @@ -744,7 +741,6 @@ export default { name: string; }[]; result[0]!.name = "0 (default)"; - result[0]!.key = undefined; result[10]!.name = "10 (highest)"; return result; }, diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue index 6c1ee9d48..d6d823747 100644 --- a/assets/js/components/Config/PropertyField.vue +++ b/assets/js/components/Config/PropertyField.vue @@ -51,9 +51,10 @@ v-model="value" class="form-select" :class="inputClasses" + :required="required" :disabled="disabled" > - + + + + + + @@ -473,6 +487,9 @@ import ModbusProxyIcon from "../components/MaterialIcon/ModbusProxy.vue"; import ModbusProxyModal from "../components/Config/ModbusProxyModal.vue"; import MqttIcon from "../components/MaterialIcon/Mqtt.vue"; import MqttModal from "../components/Config/MqttModal.vue"; +import RemoteAccessIcon from "../components/MaterialIcon/RemoteAccess.vue"; +import RemoteModal from "../components/Config/Remote/RemoteModal.vue"; +import { isRemoteClientActive } from "@/utils/remote"; import NetworkModal from "../components/Config/NetworkModal.vue"; import NotificationIcon from "../components/MaterialIcon/Notification.vue"; import OptimizerIcon from "../components/MaterialIcon/Optimizer.vue"; @@ -504,6 +521,7 @@ import type { SiteConfig, DeviceType, Notification, + Remote, } from "@/types/evcc"; import { CURRENCY, GRID_CONTROL } from "@/types/evcc"; import { circuitTree } from "@/utils/circuits"; @@ -555,6 +573,8 @@ export default defineComponent({ ModbusProxyModal, MqttIcon, MqttModal, + RemoteAccessIcon, + RemoteModal, NetworkModal, NotificationIcon, OptimizerIcon, @@ -763,6 +783,30 @@ export default defineComponent({ return result; }, + remote(): Remote | undefined { + return store.state?.remote; + }, + remoteTags(): DeviceTags { + const remote = this.remote; + if (!remote?.status?.url) { + return { configured: { value: false } }; + } + const tags: DeviceTags = { + enabled: { value: remote.config?.enabled }, + connected: { value: remote.status?.connected }, + }; + if (remote.status?.loginBlocked) { + tags["loginBlocked"] = { value: true, error: true }; + } + if (remote.status?.connected) { + const lastSeen = remote.status?.lastSeen; + const count = lastSeen + ? Object.keys(lastSeen).filter((u) => isRemoteClientActive(lastSeen, u)).length + : 0; + tags["activeClients"] = { value: count }; + } + return tags; + }, sponsor() { return store.state?.sponsor; }, diff --git a/cmd/root.go b/cmd/root.go index a1d10adab..be00a6c64 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ import ( "github.com/evcc-io/evcc/server/eebus" "github.com/evcc-io/evcc/server/mcp" "github.com/evcc-io/evcc/server/network" + "github.com/evcc-io/evcc/server/remote" "github.com/evcc-io/evcc/server/updater" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/auth" @@ -237,6 +238,12 @@ func runRoot(cmd *cobra.Command, args []string) { // publish to UI go socketHub.Run(pipe.NewDropper(ignoreEmpty).Pipe(tee.Attach()), cache) + // remote access tunnel + var remoteAccess *remote.Remote + if remoteHost := os.Getenv("EVCC_REMOTE_ACCESS"); remoteHost != "" { + remoteAccess = remote.New(remoteHost, httpd.Router(), valueChan) + } + // signal ui listening valueChan <- util.Param{Key: keys.StartupCompleted, Val: false} @@ -385,6 +392,11 @@ func runRoot(cmd *cobra.Command, args []string) { YamlSource: yamlSource.tariffs, }} + // publish remote access status + if remoteAccess != nil { + valueChan <- util.Param{Key: keys.Remote, Val: remoteAccess.ConfigStatus()} + } + // publish system infos valueChan <- util.Param{Key: keys.Version, Val: util.FormattedVersion()} valueChan <- util.Param{Key: keys.Config, Val: viper.ConfigFileUsed()} @@ -429,7 +441,7 @@ func runRoot(cmd *cobra.Command, args []string) { log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.") err = errors.New("restart required") // https://gokrazy.org/development/process-interface/ once.Do(func() { close(stopC) }) // signal loop to end - }, viper.ConfigFileUsed()) + }, viper.ConfigFileUsed(), remoteAccess) // show and check version, reduce api load during development if util.Version != util.DevVersion { diff --git a/core/keys/global.go b/core/keys/global.go index f8fa5eba1..354ad27bc 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -30,6 +30,9 @@ const ( Telemetry = "telemetry" Optimizer = "optimizer" DemoMode = "demoMode" + Remote = "remote" + RemoteClients = "remoteClients" + RemoteLastSeen = "remoteLastSeen" AuthDisabled = "authDisabled" AuthProviders = "authProviders" ) diff --git a/go.mod b/go.mod index c597feb3e..27a5a776b 100644 --- a/go.mod +++ b/go.mod @@ -54,6 +54,7 @@ require ( github.com/gregdel/pushover v1.4.0 github.com/grid-x/modbus v0.0.0-20260325140807-cf9e1b9daae0 github.com/hashicorp/go-version v1.9.0 + github.com/hashicorp/yamux v0.1.2 github.com/hasura/go-graphql-client v0.16.0 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 github.com/influxdata/influxdb-client-go/v2 v2.14.0 @@ -85,6 +86,7 @@ require ( github.com/robertkrimen/otto v0.5.1 github.com/samber/lo v1.53.0 github.com/sandrolain/httpcache v1.4.0 + github.com/sethvargo/go-password v0.3.1 github.com/sirupsen/logrus v1.9.4 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/smallnest/chanx v1.2.0 diff --git a/go.sum b/go.sum index 405e56c3c..427b45073 100644 --- a/go.sum +++ b/go.sum @@ -391,6 +391,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hasura/go-graphql-client v0.16.0 h1:DQLfp+djj4j5NPdJkGYym8J55hpm5etML1zqgco78Qc= github.com/hasura/go-graphql-client v0.16.0/go.mod h1:z/sO2T0zI+HnPNIevQcs+7xA6/gDOc8hgHMrNBzfL2c= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= @@ -686,6 +688,8 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sethvargo/go-password v0.3.1 h1:WqrLTjo7X6AcVYfC6R7GtSyuUQR9hGyAj/f1PYQZCJU= +github.com/sethvargo/go-password v0.3.1/go.mod h1:rXofC1zT54N7R8K/h1WDUdkf9BOx5OptoxrMBcrXzvs= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= diff --git a/i18n/de.json b/i18n/de.json index 4e34cb620..3e24344f7 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -113,6 +113,7 @@ "title": "Währung" }, "deviceValue": { + "activeClients": "Aktive Clients", "amount": "Anzahl", "broker": "Broker", "bucket": "Bucket", @@ -144,6 +145,7 @@ "hemsActiveLimit": "Aktives Limit", "hemsType": "Kommunikation", "identifier": "RFID-Kennung", + "loginBlocked": "Login-Limit erreicht", "max": "max", "messengers": "Dienste", "no": "Nein", @@ -631,6 +633,38 @@ "titleAdd": "PV-Anlage hinzufügen", "titleEdit": "PV-Zähler bearbeiten" }, + "remote": { + "active": "Aktiv", + "addClient": "Client hinzufügen", + "addClientDescription": "Zugangsdaten werden ausschließlich lokal auf deiner evcc-Instanz gespeichert und überprüft.", + "addClientTitle": "Remote-Client hinzufügen", + "clientCreated": "Client erstellt", + "clients": "Clients", + "confirmDelete": "Client löschen?", + "connected": "Verbunden", + "createClient": "Client erstellen", + "description": "Externer Zugriff auf die evcc-Installation über die evcc-App. Ohne Portfreigabe oder VPN.", + "deviceName": "Gerätename", + "disconnected": "Nicht verbunden", + "done": "Fertig", + "enableLabel": "Remotezugriff aktivieren", + "expiration": "Ablauf", + "expirationNone": "Nie", + "expired": "abgelaufen", + "expiresIn": "läuft {time} ab", + "lastActive": "aktiv {time}", + "loginBlocked": "Remote-Logins sind wegen zu vieler fehlgeschlagener Anmeldeversuche für eine Minute gesperrt.", + "manualLogin": "Oder melde dich manuell unter {url} in deinem Browser mit diesen Zugangsdaten an:", + "noClients": "Noch keine Clients. Niemand kann sich verbinden.", + "password": "Passwort", + "passwordOnce": "Dieses Passwort wird nur einmal angezeigt. QR-Code scannen oder jetzt kopieren. Es wird später nicht mehr angezeigt.", + "qrInstall": "Installiere die evcc-App für {ios} oder {android}.", + "qrScan": "Scanne den Code mit der Kamera deines Smartphones, um dich zu verbinden. Klicke ihn an, wenn du bereits dein Handy nutzt.", + "removeClient": "Client entfernen", + "title": "Remotezugriff", + "url": "Öffentliche URL", + "username": "Benutzername" + }, "section": { "additionalMeter": "Zusätzliche Zähler", "general": "Allgemein", diff --git a/i18n/en.json b/i18n/en.json index f87439e35..b17667740 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -113,6 +113,7 @@ "title": "Currency" }, "deviceValue": { + "activeClients": "Active clients", "amount": "Amount", "broker": "Broker", "bucket": "Bucket", @@ -126,6 +127,7 @@ "chargedEnergy": "Charged", "co2": "Grid CO₂", "configured": "Configured", + "connected": "Connected", "connections": "Connections", "controllable": "Controllable", "currency": "Currency", @@ -144,6 +146,7 @@ "hemsActiveLimit": "Active limit", "hemsType": "Communication", "identifier": "RFID-Identifier", + "loginBlocked": "Login limit reached", "max": "max", "messengers": "Services", "no": "no", @@ -631,6 +634,38 @@ "titleAdd": "Add Solar Meter", "titleEdit": "Edit Solar Meter" }, + "remote": { + "active": "Active", + "addClient": "Add client", + "addClientDescription": "Credentials are stored and verified only locally on your evcc instance.", + "addClientTitle": "Add Remote Client", + "clientCreated": "Client created", + "clients": "Clients", + "confirmDelete": "Delete client?", + "connected": "Connected", + "createClient": "Create client", + "description": "Access your evcc installation from anywhere using the evcc mobile app. No port forwarding or VPN required.", + "deviceName": "Device name", + "disconnected": "Disconnected", + "done": "Done", + "enableLabel": "Enable remote access", + "expiration": "Expiration", + "expirationNone": "Never", + "expired": "expired", + "expiresIn": "expires {time}", + "lastActive": "active {time}", + "loginBlocked": "Remote logins are blocked for one minute due to too many failed login attempts.", + "manualLogin": "Or sign in manually at {url} in your browser using these credentials:", + "noClients": "No clients yet. No one can connect yet.", + "password": "Password", + "passwordOnce": "This password is shown only once. Scan the QR code or copy it now. You won't be able to see it again.", + "qrInstall": "Install the evcc app for {ios} or {android}.", + "qrScan": "Scan the code with your phone's camera to connect. Click it, if you're already using your phone.", + "removeClient": "Remove client", + "title": "Remote Access", + "url": "Public URL", + "username": "Username" + }, "section": { "additionalMeter": "Additional meters", "general": "General", diff --git a/package-lock.json b/package-lock.json index 829f0c83a..55d5578b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "dayjs": "^1.11.20", "echarts": "^6.0.0", "monaco-editor": "0.52.2", + "qrcode": "^1.5.4", "smoothscroll-polyfill": "^0.4.4", "snarkdown": "^2.0.0", "vue": "^3.5.31", @@ -40,6 +41,7 @@ "@types/bootstrap": "^5.2.10", "@types/canvas-confetti": "^1.9.0", "@types/kill-port": "^2.0.3", + "@types/qrcode": "^1.5.6", "@types/smoothscroll-polyfill": "^0.3.4", "@types/wait-on": "^5.3.4", "@types/ws": "^8.18.1", @@ -3435,6 +3437,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/readable-stream": { "version": "4.0.23", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", @@ -4439,7 +4451,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4449,7 +4460,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5115,6 +5125,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001780", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", @@ -5324,7 +5343,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5337,7 +5355,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -5655,6 +5672,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -5790,6 +5816,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6993,7 +7025,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -7686,7 +7717,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9165,6 +9195,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -9215,7 +9254,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9346,6 +9384,15 @@ "node": ">=18" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -9702,6 +9749,173 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -9997,6 +10211,21 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -10298,6 +10527,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -12470,6 +12705,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/package.json b/package.json index 17d725fde..ee0fe7d6f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dayjs": "^1.11.20", "echarts": "^6.0.0", "monaco-editor": "0.52.2", + "qrcode": "^1.5.4", "smoothscroll-polyfill": "^0.4.4", "snarkdown": "^2.0.0", "vue": "^3.5.31", @@ -62,6 +63,7 @@ "@types/bootstrap": "^5.2.10", "@types/canvas-confetti": "^1.9.0", "@types/kill-port": "^2.0.3", + "@types/qrcode": "^1.5.6", "@types/smoothscroll-polyfill": "^0.3.4", "@types/wait-on": "^5.3.4", "@types/ws": "^8.18.1", diff --git a/server/http.go b/server/http.go index a0c4cfe4f..a39d2d45a 100644 --- a/server/http.go +++ b/server/http.go @@ -15,6 +15,7 @@ import ( "github.com/evcc-io/evcc/hems/shm" "github.com/evcc-io/evcc/server/assets" "github.com/evcc-io/evcc/server/eebus" + "github.com/evcc-io/evcc/server/remote" "github.com/evcc-io/evcc/server/service" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/auth" @@ -225,7 +226,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { } // RegisterSystemHandler provides system level handlers -func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *util.ParamCache, auth auth.Auth, shutdown func(), configFile string) { +func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *util.ParamCache, auth auth.Auth, shutdown func(), configFile string, remoteAccess *remote.Remote) { router := s.Server.Handler.(*mux.Router) // api @@ -299,6 +300,13 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti "optimizer": {"POST", "/optimizer/{value:[01truefalse]+}", boolHandler(setOptimizer(pub), getOptimizer)}, } + if remoteAccess != nil { + routes["remote"] = route{"POST", "/remote/{value:[01truefalse]+}", boolHandler(remoteAccess.Enable, remoteAccess.Enabled)} + routes["remoteclients"] = route{"GET", "/remote/clients", remoteClientsHandler(remoteAccess)} + routes["createremoteclient"] = route{"POST", "/remote/clients", createRemoteClientHandler(remoteAccess)} + routes["deleteremoteclient"] = route{"DELETE", "/remote/clients", deleteRemoteClientHandler(remoteAccess)} + } + // yaml handlers for key, fun := range map[string]func() (any, any){ keys.Hems: func() (any, any) { return map[string]any{}, config.Typed{} }, diff --git a/server/http_remote_handler.go b/server/http_remote_handler.go new file mode 100644 index 000000000..5b7b417a2 --- /dev/null +++ b/server/http_remote_handler.go @@ -0,0 +1,62 @@ +package server + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/evcc-io/evcc/server/remote" +) + +// remoteClientsHandler returns the list of remote tunnel clients. +func remoteClientsHandler(r *remote.Remote) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + jsonWrite(w, r.Clients()) + } +} + +// createRemoteClientHandler creates a new tunnel client and returns the +// cleartext password (shown to the user only once). +func createRemoteClientHandler(r *remote.Remote) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + var body struct { + Username string `json:"username"` + ExpiresIn int64 `json:"expiresIn"` // seconds; 0 = never + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + client, password, err := r.CreateClient(body.Username, time.Duration(body.ExpiresIn)*time.Second) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + jsonWrite(w, struct { + Username string `json:"username"` + Password string `json:"password"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + }{ + Username: client.Username, + Password: password, + CreatedAt: client.CreatedAt, + ExpiresAt: client.ExpiresAt, + }) + } +} + +// deleteRemoteClientHandler removes a tunnel client by username. +// Username is passed as a query parameter to allow arbitrary characters. +func deleteRemoteClientHandler(r *remote.Remote) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + username := req.URL.Query().Get("username") + if err := r.DeleteClient(username); err != nil { + jsonError(w, http.StatusNotFound, err) + return + } + jsonWrite(w, true) + } +} diff --git a/server/remote/clients.go b/server/remote/clients.go new file mode 100644 index 000000000..f4907aec6 --- /dev/null +++ b/server/remote/clients.go @@ -0,0 +1,166 @@ +package remote + +import ( + "crypto/rand" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/server/db/settings" + "github.com/samber/lo" + "github.com/sethvargo/go-password/password" + "golang.org/x/crypto/bcrypt" +) + +// dummyHash is a bcrypt hash of a random value, used to make the +// "unknown user" path take the same time as a real password check and +// prevent username enumeration via timing side channels. +var dummyHash []byte + +func init() { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + h, err := bcrypt.GenerateFromPassword(buf, bcrypt.DefaultCost) + if err != nil { + panic(err) + } + dummyHash = h +} + +// Client is a single tunnel basic-auth credential used by a remote client. +type Client struct { + Username string `json:"username"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + +type persistedClient struct { + Client + Hash string `json:"hash"` +} + +// loadClients reads the persisted client list. +func loadClients() []persistedClient { + var res []persistedClient + _ = settings.Json(keys.RemoteClients, &res) + return res +} + +// saveClients persists the given client list. +func saveClients(list []persistedClient) error { + return settings.SetJson(keys.RemoteClients, list) +} + +// generatePassword returns a crypto-random alphanumeric password +// with 20 characters including 4 digits (~96 bits of entropy). +func generatePassword() (string, error) { + return password.Generate(20, 4, 0, false, false) +} + +// Clients returns the list of configured clients (without password hashes). +func (r *Remote) Clients() []Client { + return lo.Map(loadClients(), func(c persistedClient, _ int) Client { + return c.Client + }) +} + +// CreateClient creates a new client with an auto-generated password. +// expiresIn <= 0 means the client never expires. +// Returns the cleartext password (shown to the user only once). +func (r *Remote) CreateClient(username string, expiresIn time.Duration) (Client, string, error) { + username = strings.TrimSpace(username) + if username == "" { + return Client{}, "", errors.New("username required") + } + // RFC 7617: ":" is the basic-auth separator; reject control chars too. + for _, r := range username { + if r == ':' || r < 0x20 || r == 0x7f { + return Client{}, "", errors.New("username contains invalid characters") + } + } + + var expires *time.Time + if expiresIn > 0 { + expires = new(time.Now().Add(expiresIn)) + } + + r.mu.Lock() + defer r.mu.Unlock() + + list := loadClients() + for _, c := range list { + if c.Username == username { + return Client{}, "", fmt.Errorf("client %q already exists", username) + } + } + + password, err := generatePassword() + if err != nil { + return Client{}, "", err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return Client{}, "", err + } + + c := persistedClient{ + Client: Client{ + Username: username, + CreatedAt: time.Now(), + ExpiresAt: expires, + }, + Hash: string(hash), + } + list = append(list, c) + if err := saveClients(list); err != nil { + return Client{}, "", err + } + + return c.Client, password, nil +} + +// DeleteClient removes a client by username. +func (r *Remote) DeleteClient(username string) error { + r.mu.Lock() + defer r.mu.Unlock() + + list := loadClients() + idx := slices.IndexFunc(list, func(c persistedClient) bool { + return c.Username == username + }) + + if idx == -1 { + return fmt.Errorf("client %s not found", username) + } + + return saveClients(slices.Delete(list, idx, idx+1)) +} + +// Authenticate validates basic-auth credentials. Always runs bcrypt +// (against a dummy hash on miss) to prevent username enumeration via timing. +func (r *Remote) Authenticate(username, password string) bool { + hash := dummyHash + var found *persistedClient + for _, c := range loadClients() { + if c.Username == username { + found = &c + hash = []byte(c.Hash) + break + } + } + + valid := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil + if !valid || found == nil { + return false + } + if found.ExpiresAt != nil && time.Now().After(*found.ExpiresAt) { + return false + } + return true +} diff --git a/server/remote/ratelimit.go b/server/remote/ratelimit.go new file mode 100644 index 000000000..76c43930b --- /dev/null +++ b/server/remote/ratelimit.go @@ -0,0 +1,51 @@ +package remote + +import ( + "sync" + "time" +) + +// authRateLimiter tracks failed authentication attempts in a sliding window. +// When the failure count exceeds the threshold, further attempts are blocked +// to prevent brute-force attacks. +type authRateLimiter struct { + mu sync.Mutex + failures []time.Time + window time.Duration + max int + now func() time.Time +} + +func newAuthRateLimiter() *authRateLimiter { + return &authRateLimiter{ + window: time.Minute, + max: 10, + now: time.Now, + } +} + +// allow checks whether an authentication attempt should proceed. +func (rl *authRateLimiter) allow() bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + cutoff := rl.now().Add(-rl.window) + + // prune old entries + valid := rl.failures[:0] + for _, t := range rl.failures { + if t.After(cutoff) { + valid = append(valid, t) + } + } + rl.failures = valid + + return len(rl.failures) < rl.max +} + +// fail records a failed authentication attempt. +func (rl *authRateLimiter) fail() { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.failures = append(rl.failures, rl.now()) +} diff --git a/server/remote/ratelimit_test.go b/server/remote/ratelimit_test.go new file mode 100644 index 000000000..f0f1a7077 --- /dev/null +++ b/server/remote/ratelimit_test.go @@ -0,0 +1,70 @@ +package remote + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAuthRateLimiter(t *testing.T) { + t.Run("allows requests under threshold", func(t *testing.T) { + rl := newAuthRateLimiter() + + for range rl.max { + assert.True(t, rl.allow()) + rl.fail() + } + }) + + t.Run("blocks after threshold", func(t *testing.T) { + rl := newAuthRateLimiter() + + for range rl.max { + rl.fail() + } + + assert.False(t, rl.allow()) + }) + + t.Run("recovers after window expires", func(t *testing.T) { + now := time.Now() + var mu sync.Mutex + + rl := newAuthRateLimiter() + rl.now = func() time.Time { + mu.Lock() + defer mu.Unlock() + return now + } + + for range rl.max { + rl.fail() + } + + assert.False(t, rl.allow()) + + // advance past window + mu.Lock() + now = now.Add(rl.window + time.Second) + mu.Unlock() + + assert.True(t, rl.allow()) + }) + + t.Run("successful auth does not count as failure", func(t *testing.T) { + rl := newAuthRateLimiter() + + // fill up to max-1 failures + for range rl.max - 1 { + rl.fail() + } + + // allow should still work (no fail() call = successful auth) + assert.True(t, rl.allow()) + + // still under threshold + assert.True(t, rl.allow()) + }) +} diff --git a/server/remote/remote.go b/server/remote/remote.go new file mode 100644 index 000000000..80158dc10 --- /dev/null +++ b/server/remote/remote.go @@ -0,0 +1,228 @@ +package remote + +import ( + "fmt" + "net/http" + "sync" + "time" + + "github.com/evcc-io/evcc/api/globalconfig" + "github.com/evcc-io/evcc/cmd/shutdown" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/server/db/settings" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/sponsor" +) + +// Settings is the persisted remote access configuration. +type Settings struct { + Enabled bool `json:"enabled"` + URL string `json:"url,omitempty"` + Token string `json:"token,omitempty"` + TunnelURL string `json:"tunnelUrl,omitempty"` +} + +// Remote manages the remote access tunnel lifecycle. +type Remote struct { + mu sync.Mutex + cloudHost string + settings Settings + tunnel *Tunnel + httpHandler http.Handler + log *util.Logger + publisher chan<- util.Param + lastSeen map[string]time.Time // persisted: username → last activity + connected map[string]int // in-memory: active connection count per user +} + +// New creates a new Remote manager, loads persisted settings, and connects if enabled. +func New(cloudHost string, httpHandler http.Handler, valueChan chan<- util.Param) *Remote { + r := &Remote{ + cloudHost: cloudHost, + httpHandler: httpHandler, + log: util.NewLogger("remote"), + publisher: valueChan, + lastSeen: make(map[string]time.Time), + connected: make(map[string]int), + } + + // load saved settings + _ = settings.Json(keys.Remote, &r.settings) + _ = settings.Json(keys.RemoteLastSeen, &r.lastSeen) + + if r.settings.Enabled && r.settings.Token != "" { + go r.connect() + } + + shutdown.Register(r.disconnect) + + go func() { + for range time.Tick(time.Minute) { + r.publish() + } + }() + + return r +} + +// Enable enables or disables remote access. When enabling for the first time, +// it registers with the cloud to obtain a URL and token. +func (r *Remote) Enable(enable bool) error { + r.mu.Lock() + r.settings.Enabled = enable + r.saveSettings() + r.mu.Unlock() + + if enable { + // TODO why do we need a go routine for this? + go r.connect() + } else { + r.disconnect() + } + + r.publish() + return nil +} + +// Enabled returns whether remote access is enabled. +func (r *Remote) Enabled() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.settings.Enabled +} + +func (r *Remote) connect() { + r.mu.Lock() + token := r.settings.Token + r.mu.Unlock() + + if token == "" { + if err := r.register(); err != nil { + r.log.ERROR.Printf("registration failed: %v", err) + return + } + } + + r.log.INFO.Printf("remote access via %s", r.settings.URL) + + tunnel := NewTunnel(r.settings.TunnelURL, r.settings.Token, r.httpHandler, r.Authenticate, r.TrackActivity, r.log, r.publish) + + r.mu.Lock() + r.tunnel = tunnel + r.mu.Unlock() + + // blocks until disconnected + tunnel.run() +} + +func (r *Remote) disconnect() { + r.mu.Lock() + defer r.mu.Unlock() + + if r.tunnel != nil { + r.tunnel.Close() + r.tunnel = nil + } +} + +type registerRequest struct { + SponsorToken string `json:"sponsorToken"` +} + +type registerResponse struct { + URL string `json:"url"` + Token string `json:"token"` + TunnelURL string `json:"tunnelUrl"` +} + +// register calls the cloud registration endpoint and persists the result. +func (r *Remote) register() error { + uri := fmt.Sprintf("https://%s/api/register", r.cloudHost) + data := registerRequest{SponsorToken: sponsor.Token} + req, _ := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding) + + var res registerResponse + + client := request.NewHelper(r.log) + if err := client.DoJSON(req, &res); err != nil { + return err + } + + r.mu.Lock() + r.settings.URL = res.URL + r.settings.Token = res.Token + r.settings.TunnelURL = res.TunnelURL + r.saveSettings() + r.mu.Unlock() + + r.log.INFO.Printf("registered as %s", res.URL) + return nil +} + +// TrackActivity tracks remote client connections and disconnections. +func (r *Remote) TrackActivity(username string, active bool) { + r.mu.Lock() + defer r.mu.Unlock() + if active { + r.lastSeen[username] = time.Now() + r.connected[username]++ + } else if r.connected[username] > 0 { + r.connected[username]-- + } +} + +// saveSettings persists the current settings. Must be called with mu held. +func (r *Remote) saveSettings() { + if err := settings.SetJson(keys.Remote, r.settings); err != nil { + r.log.ERROR.Println(err) + } +} + +// ConfigStatus returns the current remote access config and status. +func (r *Remote) ConfigStatus() globalconfig.ConfigStatus { + r.mu.Lock() + defer r.mu.Unlock() + + connected := r.tunnel != nil && r.tunnel.IsConnected() + loginBlocked := r.tunnel != nil && r.tunnel.LoginBlocked() + + return globalconfig.ConfigStatus{ + Config: struct { + Enabled bool `json:"enabled"` + }{ + Enabled: r.settings.Enabled, + }, + Status: struct { + Connected bool `json:"connected"` + URL string `json:"url,omitempty"` + LoginBlocked bool `json:"loginBlocked"` + LastSeen map[string]time.Time `json:"lastSeen,omitempty"` + }{ + Connected: connected, + URL: r.settings.URL, + LoginBlocked: loginBlocked, + LastSeen: r.lastSeen, + }, + } +} + +// publish sends the current status to the UI via the value channel. +func (r *Remote) publish() { + if r.publisher == nil { + return + } + + // refresh lastSeen for open connections (auth only fires once) + r.mu.Lock() + now := time.Now() + for user, count := range r.connected { + if count > 0 { + r.lastSeen[user] = now + } + } + _ = settings.SetJson(keys.RemoteLastSeen, r.lastSeen) + r.mu.Unlock() + + r.publisher <- util.Param{Key: keys.Remote, Val: r.ConfigStatus()} +} diff --git a/server/remote/tunnel.go b/server/remote/tunnel.go new file mode 100644 index 000000000..303acf92b --- /dev/null +++ b/server/remote/tunnel.go @@ -0,0 +1,199 @@ +package remote + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/coder/websocket" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/sponsor" + "github.com/hashicorp/yamux" +) + +// Tunnel manages a WebSocket+yamux tunnel to the cloud proxy. +type Tunnel struct { + tunnelURL string + token string + httpHandler http.Handler + authenticate func(user, pass string) bool + trackActivity func(username string, active bool) + log *util.Logger + cancel func() + onStateChange func() + rateLimiter *authRateLimiter + + mu sync.Mutex + session *yamux.Session +} + +// NewTunnel creates a new tunnel client. +func NewTunnel(tunnelURL, token string, httpHandler http.Handler, authenticate func(user, pass string) bool, trackActivity func(string, bool), log *util.Logger, onStateChange func()) *Tunnel { + return &Tunnel{ + tunnelURL: tunnelURL, + token: token, + httpHandler: httpHandler, + authenticate: authenticate, + trackActivity: trackActivity, + log: log, + onStateChange: onStateChange, + rateLimiter: newAuthRateLimiter(), + } +} + +// run establishes the tunnel and reconnects on failure. +func (t *Tunnel) run() { + bo := backoff.NewExponentialBackOff( + backoff.WithInitialInterval(time.Second), + backoff.WithMaxInterval(60*time.Second), + backoff.WithMaxElapsedTime(0), + ) + + ctx, cancel := context.WithCancel(context.Background()) + t.cancel = cancel + + for { + ok, err := t.connect(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + t.log.ERROR.Printf("tunnel: %v", err) + } + + // reset backoff after successful connection + if ok { + bo.Reset() + } + + select { + case <-ctx.Done(): + return + case <-time.After(bo.NextBackOff()): + } + } +} + +func (t *Tunnel) connect(ctx context.Context) (bool, error) { + conn, _, err := websocket.Dial(ctx, t.tunnelURL, &websocket.DialOptions{ + HTTPHeader: http.Header{ + "Authorization": []string{"Bearer " + t.token}, + "X-Sponsor-Token": []string{sponsor.Token}, + }, + }) + if err != nil { + return false, fmt.Errorf("websocket dial: %w", err) + } + + netConn := websocket.NetConn(ctx, conn, websocket.MessageBinary) + + config := yamux.DefaultConfig() + config.LogOutput = t.log.TRACE.Writer() + + session, err := yamux.Client(netConn, config) + if err != nil { + netConn.Close() // closes the underlying socket connection + return false, fmt.Errorf("yamux client: %w", err) + } + + t.changeState(session, nil) + + // accept streams from the proxy + srv := &http.Server{ + Handler: t.basicAuthMiddleware(t.httpHandler), + } + + if err := srv.Serve(session); err != nil { + t.changeState(nil, err) + } + + return true, nil +} + +func (t *Tunnel) changeState(session *yamux.Session, err error) { + t.mu.Lock() + t.session = session + t.mu.Unlock() + + if t.onStateChange != nil { + t.onStateChange() + } + + if session != nil { + t.log.INFO.Println("tunnel connected") + } else { + if errors.Is(err, context.Canceled) || errors.Is(err, io.EOF) { + t.log.INFO.Println("tunnel disconnected") + } else { + t.log.INFO.Println("tunnel disconnected:", err) + } + } +} + +// IsConnected returns whether the tunnel is currently connected. +func (t *Tunnel) IsConnected() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.session != nil +} + +// LoginBlocked returns whether login attempts are currently blocked by the rate limiter. +func (t *Tunnel) LoginBlocked() bool { + return !t.rateLimiter.allow() +} + +// Close tears down the tunnel. +func (t *Tunnel) Close() { + t.mu.Lock() + defer t.mu.Unlock() + + // close websocket; produces io.EOF in yamux which it handles silently + if t.session != nil { + t.session.Close() // closes the underlying socket connection + } + + if t.cancel != nil { + t.cancel() + t.cancel = nil + } +} + +// basicAuthMiddleware wraps a handler with HTTP basic auth, validating +// credentials against the given authenticate function per request. +// It rate-limits failed attempts to prevent brute-force attacks. +func (t *Tunnel) basicAuthMiddleware(next http.Handler) http.Handler { + rejectAuth := func(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="evcc"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || t.authenticate == nil { + rejectAuth(w) + return + } + + if !t.rateLimiter.allow() { + t.log.INFO.Printf("login blocked for %q (rate limited)", user) + http.Error(w, "Too many failed login attempts. Try again in 1 minute.", http.StatusTooManyRequests) + return + } + + if !t.authenticate(user, pass) { + t.rateLimiter.fail() + t.log.INFO.Printf("failed login attempt for %q", user) + rejectAuth(w) + return + } + + if t.trackActivity != nil { + t.trackActivity(user, true) + defer t.trackActivity(user, false) // long-running requests (ws) + } + + next.ServeHTTP(w, r) + }) +} From 4c44ebc5d5e1fe07d9a35282e22b1f4aa9b372da Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 10:51:30 +0200 Subject: [PATCH 0325/1580] chore: fix connected --- charger/smart-evse.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charger/smart-evse.go b/charger/smart-evse.go index 901d26985..ac01f3755 100644 --- a/charger/smart-evse.go +++ b/charger/smart-evse.go @@ -46,7 +46,7 @@ type smartEvseRestSettings struct { ModeID int `json:"mode_id"` CarConnected bool `json:"car_connected"` Evse struct { - Connected int `json:"connected"` + Connected bool `json:"connected"` Access int `json:"access"` Mode int `json:"mode"` ChargeTimer int `json:"charge_timer"` @@ -413,7 +413,7 @@ func (wb *SmartEVSE3) Diagnose() { fmt.Printf("\tMode: %s (%d)\n", res.Mode, res.ModeID) fmt.Printf("\tCar connected: %t\n", res.CarConnected) - fmt.Printf("\tEVSE connected: %d\n", res.Evse.Connected) + fmt.Printf("\tEVSE connected: %t\n", res.Evse.Connected) fmt.Printf("\tEVSE access: %d\n", res.Evse.Access) fmt.Printf("\tEVSE mode: %d\n", res.Evse.Mode) fmt.Printf("\tEVSE charge timer: %d\n", res.Evse.ChargeTimer) From b45edd218784482179e10fd685b37a0f31b42f9d Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 10:58:23 +0200 Subject: [PATCH 0326/1580] Mqtt: reset full/empty times (#29050) --- core/site_optimizer.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 8ce7eb9b0..ab6da5d6d 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -70,8 +70,8 @@ type batteryDetail struct { type batteryResult struct { batteryDetail - Full *time.Time `json:"full"` - Empty *time.Time `json:"empty"` + Full time.Time `json:"full,omitzero"` + Empty time.Time `json:"empty,omitzero"` } // func (br batteryResult) MarshalJSON() ([]byte, error) { @@ -471,15 +471,15 @@ func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measureme return bat, detail } -func matchSoc(ts []float32, fun func(float32) bool) *time.Time { +func matchSoc(ts []float32, fun func(float32) bool) time.Time { for i, soc := range ts { if fun(soc) { // TODO first slot - return new(time.Now().Add(time.Duration(i+1) * tariff.SlotDuration)) + return time.Now().Add(time.Duration(i+1) * tariff.SlotDuration) } } - return nil + return time.Time{} } // continuousDemand creates a slice of power demands depending on loadpoint mode From 4b528cd355ee7a4d32503382d8ce429d421ab2ac Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 11:39:43 +0200 Subject: [PATCH 0327/1580] EEBus: handle negative production limits (#29104) --- hems/eebus/eebus.go | 29 ++++++++++++++++++----------- hems/eebus/events.go | 2 +- meter/eebus.go | 4 ++-- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/hems/eebus/eebus.go b/hems/eebus/eebus.go index a9ef56405..ccbe35fbf 100644 --- a/hems/eebus/eebus.go +++ b/hems/eebus/eebus.go @@ -38,7 +38,7 @@ type EEBus struct { smartgridProductionId uint productionLimit ucapi.LoadLimit productionLimitActivated time.Time - failsafeProductionLimit float64 + failsafeProductionLimit *float64 heartbeat *util.Value[struct{}] interval time.Duration @@ -49,7 +49,7 @@ type Limits struct { FailsafeConsumptionActivePowerLimit float64 ProductionNominalMax float64 - FailsafeProductionActivePowerLimit float64 + FailsafeProductionActivePowerLimit *float64 FailsafeDurationMinimum time.Duration } @@ -67,7 +67,7 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*E FailsafeConsumptionActivePowerLimit: 4200, ProductionNominalMax: 0, - FailsafeProductionActivePowerLimit: 0, + FailsafeProductionActivePowerLimit: nil, // 0 is a valid limit FailsafeDurationMinimum: 2 * time.Hour, }, @@ -144,8 +144,8 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(b if err := c.cs.CsLPPInterface.SetProductionNominalMax(limits.ProductionNominalMax); err != nil { c.log.ERROR.Println("CS LPP SetProductionNominalMax:", err) } - if c.failsafeProductionLimit > 0 { - if err := c.cs.CsLPPInterface.SetFailsafeProductionActivePowerLimit(c.failsafeProductionLimit, true); err != nil { + if c.failsafeProductionLimit != nil && *c.failsafeProductionLimit >= 0 { + if err := c.cs.CsLPPInterface.SetFailsafeProductionActivePowerLimit(*c.failsafeProductionLimit, true); err != nil { c.log.ERROR.Println("CS LPP SetFailsafeProductionActivePowerLimit:", err) } } @@ -181,7 +181,14 @@ func (c *EEBus) run() error { if heartbeatErr != nil && c.status != StatusFailsafe { // LPC-914/2 c.log.WARN.Println("missing heartbeat- entering failsafe mode") - c.setStatusAndLimit(StatusFailsafe, c.failsafeConsumptionLimit, c.failsafeProductionLimit) + c.setStatus(StatusFailsafe) + + c.setConsumptionLimit(c.failsafeConsumptionLimit) + + if c.failsafeProductionLimit != nil { + // production limit is negative, failsafe limits are always positive + c.setProductionLimit(-*c.failsafeProductionLimit, true) + } return nil } @@ -193,7 +200,10 @@ func (c *EEBus) run() error { } c.log.DEBUG.Println("heartbeat returned or failsafe duration exceeded- leaving failsafe mode") - c.setStatusAndLimit(StatusNormal, 0, 0) + c.setStatus(StatusNormal) + + c.setConsumptionLimit(0) + c.setProductionLimit(0, false) } // LPC-914/1 @@ -227,12 +237,9 @@ func (c *EEBus) run() error { return nil } -func (c *EEBus) setStatusAndLimit(status status, consumption, production float64) { +func (c *EEBus) setStatus(status status) { c.status = status c.statusUpdated = time.Now() - - c.setConsumptionLimit(consumption) - c.setProductionLimit(production, true) } func (c *EEBus) setConsumptionLimit(limit float64) { diff --git a/hems/eebus/events.go b/hems/eebus/events.go index 3dbd6bfda..beca70ba8 100644 --- a/hems/eebus/events.go +++ b/hems/eebus/events.go @@ -183,7 +183,7 @@ func (c *EEBus) updateFailsafeProductionActivePowerLimit() { c.mux.Lock() defer c.mux.Unlock() - c.failsafeProductionLimit = limit + c.failsafeProductionLimit = new(limit) } func (c *EEBus) updateFailsafeConsumptionDurationMinimum() { diff --git a/meter/eebus.go b/meter/eebus.go index 6b2cfaf3c..f2e4310c3 100644 --- a/meter/eebus.go +++ b/meter/eebus.go @@ -249,8 +249,8 @@ func (c *EEBus) Curtailed() (bool, error) { return false, err } - // Check if limit is active and has a valid power value - return limit.IsActive && limit.Value > 0, nil + // Check if limit is active and has a valid power value (valid is zero or negative) + return limit.IsActive && limit.Value <= 0, nil } // Curtail implements the api.Curtailer interface From 794a26d9166ebb23fe25deaa96a7305e3325fb05 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 13:10:42 +0200 Subject: [PATCH 0328/1580] chore: fix naming (#29106) --- i18n/de.json | 2 +- i18n/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/de.json b/i18n/de.json index 3e24344f7..bbf0a02a9 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -341,7 +341,7 @@ "defaultsHintLink": "Einstellungen anpassen", "delete": "Löschen", "electricalSubtitle": "Im Zweifelsfall Elektriker fragen.", - "electricalTitle": "Elektrisch", + "electricalTitle": "Elektrik", "energyMeterHelp": "Zusätzlicher Zähler, wenn die Wallbox keinen integrierten hat.", "energyMeterLabel": "Energiezähler", "estimateLabel": "Ladestand zwischen API-Updates interpolieren", diff --git a/i18n/en.json b/i18n/en.json index b17667740..e4e669582 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -342,7 +342,7 @@ "defaultsHintLink": "Adjust settings", "delete": "Delete", "electricalSubtitle": "When in doubt, ask your electrician.", - "electricalTitle": "Electrical", + "electricalTitle": "Electrics", "energyMeterHelp": "Additional meter if the charger doesn't have an integrated one.", "energyMeterLabel": "Energy meter", "estimateLabel": "Interpolate charge level between API updates", From 812ffa47b8d9a36a6d46aedf5edbaffab8bfd86a Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 14 Apr 2026 16:46:59 +0200 Subject: [PATCH 0329/1580] Forecast UI: add feed-in, edge-to-edge layout, design improvements (#29090) --- assets/css/app.css | 9 +- assets/js/colors.ts | 3 + assets/js/components/Forecast/Co2Chart.vue | 2 +- assets/js/components/Forecast/Co2Details.vue | 57 ++++++ assets/js/components/Forecast/GridDetails.vue | 107 ++++++++++ assets/js/components/Forecast/PriceChart.vue | 141 +++++++------ assets/js/components/Forecast/SolarChart.vue | 2 +- .../js/components/Forecast/SolarDetails.vue | 79 ++++++++ assets/js/components/Forecast/chartStyles.css | 1 + assets/js/components/Forecast/echarts.ts | 10 +- assets/js/mixins/formatter.ts | 5 + assets/js/settings.ts | 4 + assets/js/utils/forecast.test.ts | 30 ++- assets/js/utils/forecast.ts | 8 +- assets/js/views/Forecast.vue | 191 ++++++++---------- i18n/en.json | 11 +- tests/config-grid.spec.ts | 2 +- 17 files changed, 484 insertions(+), 178 deletions(-) create mode 100644 assets/js/components/Forecast/Co2Details.vue create mode 100644 assets/js/components/Forecast/GridDetails.vue create mode 100644 assets/js/components/Forecast/SolarDetails.vue diff --git a/assets/css/app.css b/assets/css/app.css index cc748436c..15171e613 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -21,7 +21,7 @@ --evcc-darkest-green: #076f20ff; --evcc-darkest-green-rgb: 7, 111, 32; --evcc-yellow: #faf000; - --evcc-dark-yellow: #bbb400; + --evcc-dark-yellow: #f6bb0f; --evcc-orange: #ff9000; --evcc-orange-rgb: 255, 144, 0; --evcc-red: #fc440f; @@ -39,6 +39,7 @@ --evcc-pv: var(--evcc-dark-green); --evcc-battery: var(--evcc-darker-green); --evcc-export: var(--evcc-yellow); + --evcc-export-contrast: var(--evcc-dark-yellow); --evcc-price: #ff912fff; --evcc-co2: #00916eff; --evcc-background: var(--bs-gray-bright); @@ -100,6 +101,8 @@ :root.dark { --evcc-grid: var(--bs-gray-medium); + --evcc-export: var(--evcc-yellow); + --evcc-export-contrast: var(--evcc-yellow); --evcc-background: var(--bs-gray-deep); --evcc-box: var(--bs-gray-dark); --evcc-box-border: var(--bs-gray-darker); @@ -186,6 +189,10 @@ h5 { color: var(--evcc-co2) !important; } +.text-export { + color: var(--evcc-export-contrast) !important; +} + a { color: var(--bs-primary); } diff --git a/assets/js/colors.ts b/assets/js/colors.ts index f47242ec5..a98765010 100644 --- a/assets/js/colors.ts +++ b/assets/js/colors.ts @@ -17,6 +17,7 @@ const colors: { pricePerKWh: string | null; price: string | null; co2: string | null; + export: string | null; background: string | null; light: string | null; selfPalette: string[]; @@ -31,6 +32,7 @@ const colors: { pricePerKWh: null, price: null, co2: null, + export: null, background: null, light: null, selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"], @@ -75,6 +77,7 @@ export function updateCssColors() { colors.grid = style.getPropertyValue("--evcc-grid"); colors.price = style.getPropertyValue("--evcc-price"); colors.co2 = style.getPropertyValue("--evcc-co2"); + colors.export = style.getPropertyValue("--evcc-export-contrast"); colors.background = style.getPropertyValue("--evcc-background"); colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium"); colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium"); diff --git a/assets/js/components/Forecast/Co2Chart.vue b/assets/js/components/Forecast/Co2Chart.vue index ea688deea..5a3d15d3d 100644 --- a/assets/js/components/Forecast/Co2Chart.vue +++ b/assets/js/components/Forecast/Co2Chart.vue @@ -95,7 +95,7 @@ export default defineComponent({ { type: "line", data: this.slots.map((s) => [s.start, s.value]), - smooth: 0.05, + smooth: true, symbol: "circle", symbolSize: 6, showSymbol: false, diff --git a/assets/js/components/Forecast/Co2Details.vue b/assets/js/components/Forecast/Co2Details.vue new file mode 100644 index 000000000..3358f0b5d --- /dev/null +++ b/assets/js/components/Forecast/Co2Details.vue @@ -0,0 +1,57 @@ + + + diff --git a/assets/js/components/Forecast/GridDetails.vue b/assets/js/components/Forecast/GridDetails.vue new file mode 100644 index 000000000..70d5cecb0 --- /dev/null +++ b/assets/js/components/Forecast/GridDetails.vue @@ -0,0 +1,107 @@ + + + diff --git a/assets/js/components/Forecast/PriceChart.vue b/assets/js/components/Forecast/PriceChart.vue index 00298fa81..6a57b9019 100644 --- a/assets/js/components/Forecast/PriceChart.vue +++ b/assets/js/components/Forecast/PriceChart.vue @@ -7,6 +7,7 @@ diff --git a/assets/js/components/Forecast/SolarChart.vue b/assets/js/components/Forecast/SolarChart.vue index 528279bef..ba97ee967 100644 --- a/assets/js/components/Forecast/SolarChart.vue +++ b/assets/js/components/Forecast/SolarChart.vue @@ -103,7 +103,7 @@ export default defineComponent({ { type: "line", data, - smooth: 0.05, + smooth: true, symbol: "circle", symbolSize: 6, showSymbol: false, diff --git a/assets/js/components/Forecast/SolarDetails.vue b/assets/js/components/Forecast/SolarDetails.vue new file mode 100644 index 000000000..32497c190 --- /dev/null +++ b/assets/js/components/Forecast/SolarDetails.vue @@ -0,0 +1,79 @@ + + + diff --git a/assets/js/components/Forecast/chartStyles.css b/assets/js/components/Forecast/chartStyles.css index 5b9f3e23e..10f4a53a4 100644 --- a/assets/js/components/Forecast/chartStyles.css +++ b/assets/js/components/Forecast/chartStyles.css @@ -1,6 +1,7 @@ .forecast-chart-scroll { overflow-x: auto; padding-bottom: 4px; + margin-bottom: 0.5rem; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index d56556b54..9fde86ba1 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -53,7 +53,7 @@ export function markPointLabel( animation: true, clip: true, symbol: "rect", - symbolSize: 1, + symbolSize: 0, label: { show: true, position: "top", @@ -63,7 +63,7 @@ export function markPointLabel( color: colors.background, backgroundColor: color, borderRadius: 4, - padding: [5, 10], + padding: [5, 15], offset: [0, -2], formatter: (p: { data: { value: string } }) => p.data.value, }, @@ -111,7 +111,7 @@ export function tooltipStyle( } export function forecastGrid() { - return { top: 36, right: 16, bottom: 4, left: 34, borderWidth: 0 }; + return { top: 36, right: 16, bottom: 16, left: 24, borderWidth: 0 }; } export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: Date) => string) { @@ -167,13 +167,13 @@ export function forecastYAxis(overrides: Record = {}) { axisLine: { show: false }, axisTick: { show: false }, splitLine: { - showMinLine: false, + showMinLine: true, showMaxLine: false, lineStyle: { color: colors.border || "" }, }, axisLabel: { fontSize: 10, - opacity: 0.5, + opacity: 0.75, ...(axisLabel as Record), }, ...rest, diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts index 8562f5972..9489dd492 100644 --- a/assets/js/mixins/formatter.ts +++ b/assets/js/mixins/formatter.ts @@ -221,6 +221,11 @@ export default defineComponent({ weekday: "short", }).format(date); }, + weekdayLong(date: Date) { + return new Intl.DateTimeFormat(this.$i18n?.locale, { + weekday: "long", + }).format(date); + }, fmtAbsoluteDate(date: Date) { const weekday = this.weekdayPrefix(date); const hour = new Intl.DateTimeFormat(this.$i18n?.locale, { diff --git a/assets/js/settings.ts b/assets/js/settings.ts index fcd200779..9ea7fcff8 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -21,6 +21,7 @@ const SESSIONS_GROUP = "sessions_group"; const SESSIONS_TYPE = "sessions_type"; const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted"; const SETTINGS_PRICE_ZOOM = "settings_price_zoom"; +const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin"; const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit"; const LAST_TARGET_TIME = "last_target_time"; const LAST_SOC_GOAL = "last_soc_goal"; @@ -124,6 +125,7 @@ export interface Settings { sessionsType: string; solarAdjusted: boolean; priceZoom: boolean; + hideFeedin: boolean; loadpoints: Record; lastBatterySmartCostLimit: number | undefined; lastTargetTime: string | null; @@ -151,6 +153,7 @@ const settings: Settings = reactive({ sessionsType: read(SESSIONS_TYPE), solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), priceZoom: readBool(SETTINGS_PRICE_ZOOM), + hideFeedin: readBool(SETTINGS_HIDE_FEEDIN), loadpoints: readJSON(LOADPOINTS), lastBatterySmartCostLimit: readNumber(LAST_BATTERY_SMART_COST_LIMIT), lastTargetTime: read(LAST_TARGET_TIME), @@ -177,6 +180,7 @@ watch(() => settings.sessionsGroup, save(SESSIONS_GROUP)); watch(() => settings.sessionsType, save(SESSIONS_TYPE)); watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED)); watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM)); +watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN)); watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true }); watch(() => settings.lastBatterySmartCostLimit, saveNumber(LAST_BATTERY_SMART_COST_LIMIT)); watch(() => settings.lastTargetTime, save(LAST_TARGET_TIME)); diff --git a/assets/js/utils/forecast.test.ts b/assets/js/utils/forecast.test.ts index 7a1ba0265..f6f1b6b74 100644 --- a/assets/js/utils/forecast.test.ts +++ b/assets/js/utils/forecast.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { findLowestSumSlotIndex } from "./forecast"; +import { findLowestSumSlotIndex, isStaticTariff } from "./forecast"; describe("findLowestSumSlotIndex", () => { test("finds lowest sum with span of 4", () => { @@ -55,3 +55,31 @@ describe("findLowestSumSlotIndex", () => { expect(findLowestSumSlotIndex(slots, 2)).toBe(0); }); }); + +describe("isStaticTariff", () => { + test("returns false for undefined", () => { + expect(isStaticTariff(undefined)).toBe(false); + }); + + test("returns false for empty array", () => { + expect(isStaticTariff([])).toBe(false); + }); + + test("returns true when all values are identical", () => { + const slots = [ + { start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 }, + { start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.25 }, + { start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 }, + ]; + expect(isStaticTariff(slots)).toBe(true); + }); + + test("returns false when values differ", () => { + const slots = [ + { start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 }, + { start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.3 }, + { start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 }, + ]; + expect(isStaticTariff(slots)).toBe(false); + }); +}); diff --git a/assets/js/utils/forecast.ts b/assets/js/utils/forecast.ts index 66314158e..e18719493 100644 --- a/assets/js/utils/forecast.ts +++ b/assets/js/utils/forecast.ts @@ -1,4 +1,4 @@ -import type { TimeseriesEntry, SolarDetails } from "../components/Forecast/types"; +import type { ForecastSlot, TimeseriesEntry, SolarDetails } from "../components/Forecast/types"; import deepCopy from "./deepClone"; export enum ForecastType { @@ -65,6 +65,12 @@ export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined { return result; } +export function isStaticTariff(slots?: ForecastSlot[]): boolean { + if (!Array.isArray(slots) || slots.length === 0) return false; + const firstValue = slots[0].value; + return slots.every((slot) => slot.value === firstValue); +} + export interface SlotWithValue { start: string | Date; value: number; diff --git a/assets/js/views/Forecast.vue b/assets/js/views/Forecast.vue index 2410839d4..c1cd0b682 100644 --- a/assets/js/views/Forecast.vue +++ b/assets/js/views/Forecast.vue @@ -41,19 +41,10 @@
-
+
-
-

- {{ - $t("forecast.type.solar") - }} - {{ - solarSubtitle - }} -

+
+

{{ $t("forecast.type.solar") }}

- +
+ +
+
-
-
-

- {{ - $t("forecast.type.price") - }} - {{ - priceSubtitle - }} -

+
+
+

{{ $t("forecast.type.price") }}

- + +
+
-
-

- {{ - $t("forecast.type.co2") - }} - {{ - co2Subtitle - }} -

- +
+

{{ $t("forecast.type.co2") }}

+
+ +
+
@@ -149,17 +143,18 @@ import { defineComponent } from "vue"; import Header from "../components/Top/Header.vue"; import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue"; import SolarChart from "../components/Forecast/SolarChart.vue"; +import SolarDetails from "../components/Forecast/SolarDetails.vue"; import PriceChart from "../components/Forecast/PriceChart.vue"; +import GridDetails from "../components/Forecast/GridDetails.vue"; import Co2Chart from "../components/Forecast/Co2Chart.vue"; -import formatter, { POWER_UNIT } from "@/mixins/formatter"; +import Co2Details from "../components/Forecast/Co2Details.vue"; +import formatter from "@/mixins/formatter"; import settings from "@/settings"; import store from "../store"; -import { adjustedSolar, ForecastType } from "@/utils/forecast"; -import type { ForecastSlot } from "../components/Forecast/types"; +import { adjustedSolar, ForecastType, isStaticTariff } from "@/utils/forecast"; const MIN_HOURS = 76; const MAX_HOURS = 96; -const SLOTS_PER_HOUR = 4; export default defineComponent({ name: "Forecast", @@ -167,8 +162,11 @@ export default defineComponent({ TopHeader: Header, DynamicPriceIcon, SolarChart, + SolarDetails, PriceChart, + GridDetails, Co2Chart, + Co2Details, }, mixins: [formatter], data() { @@ -233,6 +231,17 @@ export default defineComponent({ priceZoom() { return settings.priceZoom; }, + showFeedin() { + return !settings.hideFeedin; + }, + isGridStatic(): boolean { + if (!this.forecast.grid) return false; + if (!isStaticTariff(this.forecast.grid)) return false; + if (this.forecast.feedin?.length) { + return isStaticTariff(this.forecast.feedin); + } + return true; + }, showSolarAdjust() { return !!this.forecast.solar && this.experimental; }, @@ -241,48 +250,16 @@ export default defineComponent({ ? adjustedSolar(this.forecast.solar) : this.forecast.solar; }, - solarAdjustText() { - const text = this.$t("forecast.solarAdjustShort"); + solarAdjustPercent(): string { const scale = this.forecast.solar?.scale || 1; const percentDiff = scale * 100 - 100; - return `${text} (${this.fmtPercentage(percentDiff, 0, true)})`; + return this.fmtPercentage(percentDiff, 0, true); }, - solarSubtitle(): string { - const s = this.solar; - if (!s) return ""; - const today = s.today?.energy ? this.fmtWh(s.today.energy, POWER_UNIT.AUTO) : ""; - const tomorrow = s.tomorrow?.energy - ? this.fmtWh(s.tomorrow.energy, POWER_UNIT.AUTO) - : ""; - if (!today && !tomorrow) return ""; - const parts = []; - if (today) parts.push(`${today} ${this.$t("forecast.solar.remaining")}`); - if (tomorrow) parts.push(`${tomorrow} ${this.$t("forecast.solar.tomorrow")}`); - return parts.join(" ・ "); + solarAdjustTextShort(): string { + return `${this.$t("forecast.solarAdjustShort")} (${this.solarAdjustPercent})`; }, - priceSubtitle(): string { - const slots = this.upcomingSlots(this.forecast.grid); - if (slots.length === 0) return ""; - const values = slots.map((s) => s.value); - const min = Math.min(...values); - const max = Math.max(...values); - const avg = values.reduce((a, b) => a + b, 0) / values.length; - const fmtMin = this.fmtPricePerKWh(min, this.currency, false, false); - const fmtMax = this.fmtPricePerKWh(max, this.currency, false, true); - const fmtAvg = this.fmtPricePerKWh(avg, this.currency, false, true); - return `⌀ ${fmtAvg} ・ ${fmtMin} – ${fmtMax}`; - }, - co2Subtitle(): string { - const slots = this.upcomingSlots(this.forecast.co2); - if (slots.length === 0) return ""; - const values = slots.map((s) => s.value); - const min = Math.min(...values); - const max = Math.max(...values); - const avg = values.reduce((a, b) => a + b, 0) / values.length; - const fmtMin = this.fmtNumber(min, 0); - const fmtMax = this.fmtCo2Medium(max); - const fmtAvg = this.fmtCo2Medium(avg); - return `⌀ ${fmtAvg} ・ ${fmtMin} – ${fmtMax}`; + solarAdjustTextMedium(): string { + return `${this.$t("forecast.solarAdjustMedium")} (${this.solarAdjustPercent})`; }, }, methods: { @@ -292,6 +269,9 @@ export default defineComponent({ togglePriceZoom() { settings.priceZoom = !settings.priceZoom; }, + toggleFeedin() { + settings.hideFeedin = !settings.hideFeedin; + }, onChartScroll(val: number) { if (this.isScrolling) return; this.isScrolling = true; @@ -300,13 +280,6 @@ export default defineComponent({ this.isScrolling = false; }); }, - upcomingSlots(slots?: ForecastSlot[]): ForecastSlot[] { - if (!Array.isArray(slots)) return []; - const now = new Date(); - return slots - .filter((slot) => new Date(slot.end) > now) - .slice(0, MAX_HOURS * SLOTS_PER_HOUR); - }, }, }); @@ -321,4 +294,10 @@ export default defineComponent({ border-radius: 2rem; max-width: 480px; } +@media (max-width: 575.98px) { + .chart-edge { + margin-left: -1.5rem; + margin-right: -1.5rem; + } +} diff --git a/i18n/en.json b/i18n/en.json index e4e669582..deed0f3f9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -796,8 +796,8 @@ "titleChoice": "What Do You Want To Add?", "type": { "co2": "CO₂ Intensity", - "feedIn": "Feed-In Price", - "grid": "Grid Consumption Price", + "feedIn": "Grid Export Price", + "grid": "Grid Import Price", "planner": "Planner", "solar": "Solar" }, @@ -972,6 +972,7 @@ "setup": "Set up tariffs and forecasts", "solar": "See expected solar production for today and the coming days. Will also be used for automatic charging optimization in the future." }, + "hideLine": "hide line", "modalTitle": "Forecast", "price": { "average": "Average", @@ -980,6 +981,7 @@ "range": "Range" }, "priceZoom": "zoom in", + "showLine": "show line", "solar": { "dayAfterTomorrow": "Day after tomorrow", "partly": "partly", @@ -988,7 +990,8 @@ "tomorrow": "Tomorrow" }, "solarAdjust": "Adjust solar forecast based on real production data{percent}.", - "solarAdjustShort": "real data adjust", + "solarAdjustMedium": "real data adjust", + "solarAdjustShort": "adjust", "type": { "co2": "CO₂ Emissions", "price": "Grid Price", @@ -1153,7 +1156,7 @@ "batteryTooltip": "{energy} of {total} ({soc})", "forecast": "Forecast: ", "forecastTooltip": "forecast: remaining solar production today", - "gridImport": "Grid use", + "gridImport": "Grid import", "homePower": "Consumption", "loadpoints": "Charger| Charger | {count} chargers", "loadpointsLimit": "{limit} limit", diff --git a/tests/config-grid.spec.ts b/tests/config-grid.spec.ts index 924cbcc73..6ce902306 100644 --- a/tests/config-grid.spec.ts +++ b/tests/config-grid.spec.ts @@ -62,7 +62,7 @@ test.describe("grid meter", async () => { // check in main ui await page.goto("/"); await page.getByTestId("visualization").click(); - await expect(page.getByTestId("energyflow")).toContainText(["Grid use", "5.0 kW"].join("")); + await expect(page.getByTestId("energyflow")).toContainText(["Grid import", "5.0 kW"].join("")); // delete #1 await page.goto("/#/config"); From d50e6be4680028397b3fee9f48449a765089e565 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 17:06:51 +0200 Subject: [PATCH 0330/1580] chore: remove duplicate serial_number (#29107) --- templates/definition/charger/volttime.yaml | 9 ++++----- util/templates/defaults.yaml | 5 ----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/templates/definition/charger/volttime.yaml b/templates/definition/charger/volttime.yaml index 03789fe6f..5dad5d255 100644 --- a/templates/definition/charger/volttime.yaml +++ b/templates/definition/charger/volttime.yaml @@ -20,13 +20,12 @@ params: help: de: API Token ([developer.volttime.com](https://developer.volttime.com/api-reference/authentication#personal-access-tokens)) en: API Token ([developer.volttime.com](https://developer.volttime.com/api-reference/authentication#personal-access-tokens)) - - name: serial_number + - name: serial required: true - help: - de: Seriennummer (z. B. 1008621) - en: Serial number (e.g. 1008621) + - name: serial_number + deprecated: true render: | type: plugchoice token: {{ .token }} - identity: {{ if .serial_number }}VT_{{ .serial_number }}{{ end }} + identity: {{ if (or .serial .serial_number) }}VT_{{ or .serial .serial_number }}{{ end }} connector: 1 diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index 698cd85e5..c51caa320 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -407,11 +407,6 @@ params: mask: true description: generic: PIN - - name: serial_number - private: true - description: - en: Serial Number - de: Seriennummer - name: watchdog description: generic: Watchdog From 0f65fd960f937711353465e4a7bfadd871bb5c9c Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 19:14:03 +0200 Subject: [PATCH 0331/1580] EEBus: fix deadlock on save after validate (#29119) --- server/eebus/eebus.go | 18 ++++++++----- server/eebus/eebus_test.go | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/server/eebus/eebus.go b/server/eebus/eebus.go index 8cf5696a2..38a66dfc0 100644 --- a/server/eebus/eebus.go +++ b/server/eebus/eebus.go @@ -237,17 +237,21 @@ func (c *EEBus) UnregisterDevice(ski string, device Device) { c.log.TRACE.Printf("unregistering ski: %s", ski) c.mux.Lock() - defer c.mux.Unlock() - if idx := slices.Index(c.clients[ski], device); idx != -1 { c.clients[ski] = slices.Delete(c.clients[ski], idx, idx+1) - } - // only tear down SHIP connection when no more clients need it - if len(c.clients[ski]) == 0 { - delete(c.clients, ski) - c.service.UnregisterRemoteSKI(ski) + if len(c.clients[ski]) == 0 { + delete(c.clients, ski) + + // Tear down the SHIP session after releasing the mutex: ship-go's CloseConnection + // on a non-Complete state synchronously invokes HandleConnectionClosed, + // which calls back into evcc's connect(ski, false) — and that needs to + // acquire c.mux. Holding c.mux across this cross-layer call would + // deadlock the same goroutine on its own non-reentrant mutex. See #28942. + defer c.service.UnregisterRemoteSKI(ski) + } } + c.mux.Unlock() } func (c *EEBus) CustomerEnergyManagement() *CustomerEnergyManagement { diff --git a/server/eebus/eebus_test.go b/server/eebus/eebus_test.go index 12ae581e1..c9d3a3957 100644 --- a/server/eebus/eebus_test.go +++ b/server/eebus/eebus_test.go @@ -2,7 +2,12 @@ package eebus import ( "testing" + "time" + eebusapi "github.com/enbility/eebus-go/api" + eebusmocks "github.com/enbility/eebus-go/mocks" + spineapi "github.com/enbility/spine-go/api" + "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/require" "go.yaml.in/yaml/v4" ) @@ -23,3 +28,50 @@ certificate: var res Config require.NoError(t, yaml.Unmarshal([]byte(conf), &res)) } + +// mockDevice implements Device for testing +type mockDevice struct{} + +func (d *mockDevice) Connect(connected bool) {} +func (d *mockDevice) UseCaseEvent(_ spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event eebusapi.EventType) { +} + +var _ Device = (*mockDevice)(nil) + +// TestUnregisterDevice_MutexNotHeldDuringShipCall is the regression guard +// for issue #28942. It asserts that c.mux is NOT held at the point +// UnregisterRemoteSKI is called. The pre-fix code held c.mux across that +// cross-layer call, and ship-go's synchronous HandleConnectionClosed +// callback chain re-entered connect(ski, false) on the same goroutine, +// which then deadlocked on c.mux.Lock() (Go mutexes are non-reentrant). +// +// The assertion uses a goroutine that tries to briefly acquire c.mux from +// inside the mock's UnregisterRemoteSKI implementation; if the lock is +// held, the acquisition times out and the test fails. +func TestUnregisterDevice_MutexNotHeldDuringShipCall(t *testing.T) { + dev := &mockDevice{} + c := &EEBus{ + log: util.NewLogger("test"), + clients: map[string][]Device{"aabbcc": {dev}}, + } + + service := eebusmocks.NewServiceInterface(t) + service.EXPECT().UnregisterRemoteSKI("aabbcc").Run(func(string) { + acquired := make(chan struct{}) + go func() { + c.mux.Lock() + defer c.mux.Unlock() + close(acquired) + }() + select { + case <-acquired: + // good — mutex was free + case <-time.After(100 * time.Millisecond): + t.Errorf("c.mux was held while UnregisterRemoteSKI was called — " + + "regression to the cross-layer lock hold that caused #28942") + } + }).Once() + c.service = service + + c.UnregisterDevice("aabbcc", dev) +} From 0b198ff0dab8a473190065ef0a5eb23e2635ac2e Mon Sep 17 00:00:00 2001 From: Matthias Fechner Date: Wed, 15 Apr 2026 10:55:05 +0200 Subject: [PATCH 0332/1580] chore: allow the TAG_NAME and SHA Makefile variable to be overridden (#29123) --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index fc1168092..ee3298c54 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # build vars -TAG_NAME := $(shell test -d .git && git describe --abbrev=0 --tags) -SHA := $(shell test -d .git && git rev-parse --short HEAD) +TAG_NAME ?= $(shell test -d .git && git describe --abbrev=0 --tags) +SHA ?= $(shell test -d .git && git rev-parse --short HEAD) COMMIT := $(SHA) # hide commit for releases ifeq ($(RELEASE),1) From b6fda3212838830c3923c900fc4601a2e2516725 Mon Sep 17 00:00:00 2001 From: Cornelis Stuurman <56003577+corstuur@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:05:07 +0200 Subject: [PATCH 0333/1580] Enever: fix time parsing (#29129) --- templates/definition/tariff/enever.yaml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/templates/definition/tariff/enever.yaml b/templates/definition/tariff/enever.yaml index 880900eb8..2b3829df5 100644 --- a/templates/definition/tariff/enever.yaml +++ b/templates/definition/tariff/enever.yaml @@ -37,6 +37,14 @@ params: "ZP", ] required: true + - name: resolution + description: + en: Price resolution + de: Preisauflösung + type: choice + choice: ["hourly", "quarterly"] + default: quarterly + required: true - preset: tariff-base - preset: tariff-features - name: interval @@ -56,7 +64,7 @@ render: | type: string config: source: http - uri: https://enever.nl/apiv3/stroomprijs_vandaag.php?token={{ .token }} + uri: https://enever.nl/apiv3/stroomprijs_vandaag.php?token={{ .token }}{{ if eq .resolution "quarterly" }}&resolution=15{{ end }} jq: | [ .data.[] | { @@ -64,7 +72,7 @@ render: | "end": ( (.datum[0:19]) as $dt | (.datum[19:]) as $tz | - ($dt | strptime("%FT%T") | mktime + 900 | strflocaltime("%FT%T")) + $tz + ($dt | strptime("%FT%T") | mktime + {{ if eq .resolution "quarterly" }}900{{ else }}3600{{ end }} | strftime("%FT%T")) + $tz ), "value": .prijs{{ .provider }} | tonumber } @@ -73,7 +81,7 @@ render: | type: string config: source: http - uri: https://enever.nl/apiv3/stroomprijs_morgen.php?token={{ .token }} + uri: https://enever.nl/apiv3/stroomprijs_morgen.php?token={{ .token }}{{ if eq .resolution "quarterly" }}&resolution=15{{ end }} jq: | [ .data.[] | { @@ -81,9 +89,9 @@ render: | "end": ( (.datum[0:19]) as $dt | (.datum[19:]) as $tz | - ($dt | strptime("%FT%T") | mktime + 900 | strflocaltime("%FT%T")) + $tz + ($dt | strptime("%FT%T") | mktime + {{ if eq .resolution "quarterly" }}900{{ else }}3600{{ end }} | strftime("%FT%T")) + $tz ), "value": .prijs{{ .provider }} | tonumber - } + } ] | tostring interval: {{ .interval }} From 81b78db887e67aab77ebbc5fece156c1657193bd Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 15 Apr 2026 13:34:28 +0200 Subject: [PATCH 0334/1580] Collect 15min energy metrics (#23185) --- assets/js/components/BottomTabs/MoreMenu.vue | 5 +- assets/js/components/History/Chart.vue | 177 ++++++++++++++ assets/js/components/History/EnergyChart.vue | 170 ++++++++++++++ assets/js/components/Top/Navigation.vue | 5 + assets/js/router.ts | 5 + assets/js/views/History.vue | 103 ++++++++ core/meterenergy.go | 60 ----- core/metrics/accumulator.go | 126 ++++++++++ .../accumulator_test.go} | 24 +- core/metrics/collector.go | 126 ++++++++++ core/metrics/collector_test.go | 132 +++++++++++ core/metrics/db.go | 220 ++++++++++++++++-- core/metrics/db_test.go | 102 ++++++++ core/site.go | 81 +++---- core/site_optimizer.go | 3 +- core/site_optimizer_test.go | 85 ------- core/site_tariffs.go | 16 +- core/site_test.go | 35 --- server/http.go | 1 + server/http_history_handler.go | 51 ++++ tests/energy-history.spec.ts | 72 ++++++ tests/energy-history.sql | 42 ++++ 22 files changed, 1375 insertions(+), 266 deletions(-) create mode 100644 assets/js/components/History/Chart.vue create mode 100644 assets/js/components/History/EnergyChart.vue create mode 100644 assets/js/views/History.vue delete mode 100644 core/meterenergy.go create mode 100644 core/metrics/accumulator.go rename core/{meterenergy_test.go => metrics/accumulator_test.go} (53%) create mode 100644 core/metrics/collector.go create mode 100644 core/metrics/collector_test.go create mode 100644 core/metrics/db_test.go create mode 100644 server/http_history_handler.go create mode 100644 tests/energy-history.spec.ts create mode 100644 tests/energy-history.sql diff --git a/assets/js/components/BottomTabs/MoreMenu.vue b/assets/js/components/BottomTabs/MoreMenu.vue index 53b374709..f27d2704d 100644 --- a/assets/js/components/BottomTabs/MoreMenu.vue +++ b/assets/js/components/BottomTabs/MoreMenu.vue @@ -55,7 +55,10 @@ to="/optimize" active-class="active" > - Optimize + Optimize 🧪 + + + History 🧪 diff --git a/assets/js/components/History/Chart.vue b/assets/js/components/History/Chart.vue new file mode 100644 index 000000000..874388cf4 --- /dev/null +++ b/assets/js/components/History/Chart.vue @@ -0,0 +1,177 @@ + + + diff --git a/assets/js/components/History/EnergyChart.vue b/assets/js/components/History/EnergyChart.vue new file mode 100644 index 000000000..59705dc45 --- /dev/null +++ b/assets/js/components/History/EnergyChart.vue @@ -0,0 +1,170 @@ + + + diff --git a/assets/js/components/Top/Navigation.vue b/assets/js/components/Top/Navigation.vue index 3db3845c3..11ddb133d 100644 --- a/assets/js/components/Top/Navigation.vue +++ b/assets/js/components/Top/Navigation.vue @@ -89,6 +89,11 @@ Optimize 🧪 +
  • + + History 🧪 + +
  • diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 68fe825e5..e6fba841d 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -427,8 +427,8 @@ - - + + diff --git a/i18n/de.json b/i18n/de.json index 7af4256b2..bbf0a02a9 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -602,9 +602,8 @@ "urlHelp": "Kopiere diese URL in die Konfiguration deiner Wallbox. Details findest du im Handbuch des Herstellers. Die Wallbox sollte automatisch ihre eindeutige Kennung (Station-ID) an die URL anhängen. In seltenen Fällen musst du die Kennung manuell angeben. Beispiel: `{url}`" }, "optimizer": { - "description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen. Berechnet und visualisiert aktuell nur. Steuert noch keine Geräte.", + "description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen.", "enable": "Optimizer aktivieren", - "info": "Es kann einige Minuten dauern, bis der Optimizer-Menüeintrag sichtbar wird. Bei neuen Installationen kann es bis zu 24 Stunden dauern, bis evcc genügend Daten gesammelt hat.", "title": "Optimizer" }, "options": { diff --git a/i18n/en.json b/i18n/en.json index 8d852e51c..deed0f3f9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -603,9 +603,8 @@ "urlHelp": "Copy this URL into your charger's configuration. Check the manufacturer's manual for details. The charger is expected to automatically append its unique identifier (station ID) to the url. In rare cases, you may need to manually specify the identifier. Example: `{url}`" }, "optimizer": { - "description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing. Currently only calculates and visualizes. Does not control devices yet.", + "description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing.", "enable": "Enable Optimizer", - "info": "It may take a few minutes for the optimizer menu entry to become visible. For new installations, it may take up to 24 hours until evcc has collected enough data.", "title": "Optimizer" }, "options": { From 643f1dcb2eac361774995c667c81651580df5bb6 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 17 Apr 2026 14:22:27 +0200 Subject: [PATCH 0346/1580] chore: open source --- charger/smart-evse.go | 5 ----- templates/definition/charger/smart-evse.yaml | 1 - 2 files changed, 6 deletions(-) diff --git a/charger/smart-evse.go b/charger/smart-evse.go index ac01f3755..1f93831b5 100644 --- a/charger/smart-evse.go +++ b/charger/smart-evse.go @@ -26,7 +26,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/util/sponsor" ) // SmartEVSE-3.5 REST API charger implementation @@ -138,10 +137,6 @@ func NewSmartEVSE3(uri string, cache time.Duration) (api.Charger, error) { curr: 60, // 6 A in 1/10 A } - if !sponsor.IsAuthorized() { - return nil, api.ErrSponsorRequired - } - wb.apiG = util.ResettableCached(func() (smartEvseRestSettings, error) { var res smartEvseRestSettings err := wb.GetJSON(wb.uri+"/settings", &res) diff --git a/templates/definition/charger/smart-evse.yaml b/templates/definition/charger/smart-evse.yaml index c15db7708..60b52e2a2 100644 --- a/templates/definition/charger/smart-evse.yaml +++ b/templates/definition/charger/smart-evse.yaml @@ -5,7 +5,6 @@ products: generic: Smart Evse v3 / v3.5 capabilities: ["mA", "rfid", "1p3p"] requirements: - evcc: ["sponsorship"] description: de: | Anbindung über die REST API der Smart Evse Firmware (FW >= 3.6.0). From a0989c18b4001220abdd528c7d68ed2a9bf16129 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 17 Apr 2026 14:23:39 +0200 Subject: [PATCH 0347/1580] Reapply "Config UI: better explain optimizer (#29141)" This reverts commit f474245bd9b8b0c6a9cbc12b419ab365a28b4fd5. --- assets/js/components/Config/OptimizerModal.vue | 16 ++++++++++++++-- assets/js/components/Config/TelemetryModal.vue | 12 +++--------- assets/js/views/Config.vue | 4 ++-- i18n/de.json | 3 ++- i18n/en.json | 3 ++- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/assets/js/components/Config/OptimizerModal.vue b/assets/js/components/Config/OptimizerModal.vue index a5a84aa56..23b95bb6d 100644 --- a/assets/js/components/Config/OptimizerModal.vue +++ b/assets/js/components/Config/OptimizerModal.vue @@ -1,7 +1,7 @@ @@ -32,6 +37,7 @@ import { defineComponent } from "vue"; import GenericModal from "../Helper/GenericModal.vue"; import ErrorMessage from "../Helper/ErrorMessage.vue"; +import SponsorTokenRequired from "./DeviceModal/SponsorTokenRequired.vue"; import api from "@/api"; import store from "@/store"; import { docsPrefix } from "@/i18n"; @@ -39,7 +45,10 @@ import type { AxiosError } from "axios"; export default defineComponent({ name: "OptimizerModal", - components: { GenericModal, ErrorMessage }, + components: { GenericModal, ErrorMessage, SponsorTokenRequired }, + props: { + isSponsor: Boolean, + }, data() { return { error: null as string | null, @@ -49,6 +58,9 @@ export default defineComponent({ enabled(): boolean { return !!store.state?.optimizer; }, + hasEvopt(): boolean { + return !!store.state?.evopt; + }, docsLink(): string { return `${docsPrefix()}/docs/features/optimizer`; }, diff --git a/assets/js/components/Config/TelemetryModal.vue b/assets/js/components/Config/TelemetryModal.vue index c318bea75..0c1e20fa5 100644 --- a/assets/js/components/Config/TelemetryModal.vue +++ b/assets/js/components/Config/TelemetryModal.vue @@ -7,27 +7,21 @@ data-testid="telemetry-modal" >

    {{ $t("config.telemetry.description") }}

    - + diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index e6fba841d..68fe825e5 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -427,8 +427,8 @@ - - + + diff --git a/i18n/de.json b/i18n/de.json index bbf0a02a9..7af4256b2 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -602,8 +602,9 @@ "urlHelp": "Kopiere diese URL in die Konfiguration deiner Wallbox. Details findest du im Handbuch des Herstellers. Die Wallbox sollte automatisch ihre eindeutige Kennung (Station-ID) an die URL anhängen. In seltenen Fällen musst du die Kennung manuell angeben. Beispiel: `{url}`" }, "optimizer": { - "description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen.", + "description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen. Berechnet und visualisiert aktuell nur. Steuert noch keine Geräte.", "enable": "Optimizer aktivieren", + "info": "Es kann einige Minuten dauern, bis der Optimizer-Menüeintrag sichtbar wird. Bei neuen Installationen kann es bis zu 24 Stunden dauern, bis evcc genügend Daten gesammelt hat.", "title": "Optimizer" }, "options": { diff --git a/i18n/en.json b/i18n/en.json index deed0f3f9..8d852e51c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -603,8 +603,9 @@ "urlHelp": "Copy this URL into your charger's configuration. Check the manufacturer's manual for details. The charger is expected to automatically append its unique identifier (station ID) to the url. In rare cases, you may need to manually specify the identifier. Example: `{url}`" }, "optimizer": { - "description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing.", + "description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing. Currently only calculates and visualizes. Does not control devices yet.", "enable": "Enable Optimizer", + "info": "It may take a few minutes for the optimizer menu entry to become visible. For new installations, it may take up to 24 hours until evcc has collected enough data.", "title": "Optimizer" }, "options": { From bb93ebf98bcff1b197ff3cb467746a3ac9c85483 Mon Sep 17 00:00:00 2001 From: RenatusRo <126881912+RenatusRo@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:06:28 +0200 Subject: [PATCH 0348/1580] Optimizer: wording on debug view (#29179) --- assets/js/views/Optimize.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/views/Optimize.vue b/assets/js/views/Optimize.vue index ab82e483e..37990f5a8 100644 --- a/assets/js/views/Optimize.vue +++ b/assets/js/views/Optimize.vue @@ -34,7 +34,7 @@ true ) }} - saved + net cost Date: Sat, 18 Apr 2026 09:27:18 +0200 Subject: [PATCH 0349/1580] Optimizer: don't send empty requests --- core/site_optimizer.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 03d3c697d..f175fff9e 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -221,6 +221,11 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { add(site.batteryRequest(dev, b, grid, minLen, firstSlotDuration)) } + // empty request- all loadpoints disabled + if len(req.Batteries) == 0 { + return nil + } + httpClient := request.NewClient(site.log) httpClient.Timeout = 30 * time.Second From 11ec83716d803824307642eaf6831643021ecf35 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 18 Apr 2026 09:37:29 +0200 Subject: [PATCH 0350/1580] Translations update from Hosted Weblate (#28648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dominik Trzebiński Co-authored-by: Jonas Gate Co-authored-by: Frits van Proosdij Co-authored-by: noxaos Co-authored-by: amtssp Co-authored-by: RTTTC <94727758+RTTTC@users.noreply.github.com> Co-authored-by: uqlel Co-authored-by: Joni Lehtola Co-authored-by: MobilettoSoft Co-authored-by: Filipe Lima Co-authored-by: aerucu Co-authored-by: bittin1ddc447d824349b2 Co-authored-by: Jaap Broekhuizen Co-authored-by: Jan --- i18n/da.json | 100 ++++++++++++++++++++++++++++++++++++++++++++++----- i18n/de.json | 15 +++++--- i18n/fi.json | 49 +++++++++++++++++++++++-- i18n/fr.json | 62 ++++++++++++++++++++++++++++---- i18n/it.json | 45 ++++++++++++++++++++++- i18n/lb.json | 64 ++++++++++++++++++++++++++++----- i18n/lt.json | 54 +++++++++++++++++++++++++--- i18n/nl.json | 75 ++++++++++++++++++++++++++++++++++---- i18n/no.json | 2 +- i18n/pl.json | 4 ++- i18n/pt.json | 62 ++++++++++++++++++++++++++++---- i18n/sv.json | 79 +++++++++++++++++++++++++++++++++++----- i18n/tr.json | 57 +++++++++++++++++++++++++---- 13 files changed, 602 insertions(+), 66 deletions(-) diff --git a/i18n/da.json b/i18n/da.json index 5adbf533f..df6b04bc6 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -40,6 +40,7 @@ "legendTopName": "Batteri-understøttet opladning af køretøj", "legendTopSubline": "når husbatteriet er over {soc}.", "modalTitle": "Hus batteri", + "noBattery": "Ingen batterier er konfigurerede.", "usageTab": "Batteri anvendelse" }, "config": { @@ -112,6 +113,7 @@ "title": "Møntfod" }, "deviceValue": { + "activeClients": "Aktive klienter", "amount": "Mængde", "broker": "Broker", "bucket": "Bucket", @@ -125,6 +127,7 @@ "chargedEnergy": "Opladet", "co2": "Elnettet CO₂", "configured": "Konfigureret", + "connected": "Forbundet", "connections": "Tilslutninger", "controllable": "Kan styres", "currency": "Møntenhed", @@ -143,6 +146,7 @@ "hemsActiveLimit": "Aktiv begrænsning", "hemsType": "Kommunikation", "identifier": "RFID-identifikator", + "loginBlocked": "Grænsen for loginforsøg er nået", "max": "max", "messengers": "Tjenester", "no": "nej", @@ -601,6 +605,12 @@ "url": "Server URL", "urlHelp": "Kopier denne URL til din laders opsætning. Tjek producentens vejledning for detaljer. Laderen tilføjer normalt automatisk sit unikke ID (stations-id) til URL’en. I sjældne tilfælde skal ID’et angives manuelt. Eksempel: {url}" }, + "optimizer": { + "description": "Analyserer sol-prognose, el-priser, og forbrugsmønstre for at optimere batteri og opladning. Data sendes til evcc optimerings-service for at blive beregnet. Aktuelt beregnes og vises data kun, der er ingen styring af enheder endnu.", + "enable": "Aktiver Optimering", + "info": "Det kan tage et par minutter for optimerings-menuen er synlig. For nye installationer kan det tage op til 24 timer før der er opsamlet tilstrækkelig med data.", + "title": "Optimering" + }, "options": { "boolean": { "no": "nej", @@ -628,6 +638,38 @@ "titleAdd": "Tilføj solmåler", "titleEdit": "Rediger solmåler" }, + "remote": { + "active": "Aktiv", + "addClient": "Tilføj klient", + "addClientDescription": "Oplysningerne gemmes og verificeres kun lokalt på din evcc-enhed.", + "addClientTitle": "Tilføj fjernklient", + "clientCreated": "Klient oprettet", + "clients": "Klienter", + "confirmDelete": "Slet klient?", + "connected": "Forbundet", + "createClient": "Opret klient", + "description": "Få adgang til din evcc-installation hvor som helst via evcc-mobilappen. Ingen viderestilling af porte eller VPN er krævet.", + "deviceName": "Navn på enhed", + "disconnected": "Afbrudt", + "done": "Færdig", + "enableLabel": "Tillad fjernadgang", + "expiration": "Udløbet", + "expirationNone": "Aldrig", + "expired": "Udløbet", + "expiresIn": "udløber {time}", + "lastActive": "aktiv {time}", + "loginBlocked": "Fjernadgang blokeret i ét minut på grund af for mange loginforsøg.", + "manualLogin": "Eller login manuelt ved {url} i din browser med disse oplysninger:", + "noClients": "Ingen klient endnu. Ingen kan forbindes endnu.", + "password": "Kode", + "passwordOnce": "Denne kode vises kun én gang. Skan QR koden eller kopier den nu. Den kan ikke vises igen.", + "qrInstall": "Installer evcc appen for {ios} eller {android}.", + "qrScan": "Skan koden med telefonens kamera for at forbinde. Klik på det hvis du allerede bruger telefonen.", + "removeClient": "Fjern klient", + "title": "Fjernadgang", + "url": "Offentlig URL", + "username": "Brugernavn" + }, "section": { "additionalMeter": "Tilføj ekstra målere", "general": "Generelle", @@ -760,7 +802,7 @@ "type": { "co2": "CO₂ Intensitet", "feedIn": "Indfødningspris", - "grid": "Forbrugspris fra elnettet", + "grid": "Købspris fra elnettet", "planner": "Planlægning", "solar": "Sol" }, @@ -854,6 +896,15 @@ "configurePriceCo2": "Lær hvordan man indstiller pris og CO₂ data.", "footerLong": "{percent} Solenergi", "footerShort": "{percent} Solenergi", + "indicator": { + "co2": "CO₂ emission", + "co2saved": "CO₂ sparet", + "none": "ingen", + "price": "energipris", + "savings": "sparet", + "solar": "solenergi" + }, + "indicatorLabel": "Overskriftsinformation", "modalTitle": "Oversigt over ladeenergi", "moneySaved": "{value} sparet", "percentGrid": "{grid} kWh elnettet", @@ -865,10 +916,11 @@ "thisYear": "dette år", "total": "hele tiden" }, - "periodLabel": "Periode:", + "periodLabel": "Periode", "priceTitle": "Energipris", "referenceGrid": "elnet", - "referenceLabel": "Referencedata:", + "referenceLabel": "Referencedata", + "sessionInfo": "Baseret på gennemførte opladninger.", "tabTitle": "Mine data" }, "sponsor": { @@ -894,15 +946,26 @@ }, "version": { "availableLong": "ny version tilgængelig", + "community": "evcc-fællesskabet", + "labelRelease": "Udgivet", + "labelVersion": "Version", + "labelWebsite": "Hjemmeside", + "latestVersion": "nyeste version", + "madeByCommunity": "Lavet af {0}.", "modalCancel": "Annuller", "modalDownload": "Hent", "modalInstalledVersion": "Installeret version", + "modalLatest": "Du bruger den nyeste version.", + "modalNextRelease": "Hvad er i næste udgave", "modalNoReleaseNotes": "Der er ingen tilgængelige release notes. Mere info om den nye version:", "modalTitle": "Ny version tilgængelig", "modalUpdate": "Installere", "modalUpdateNow": "Installer nu", "modalUpdateStarted": "Starter den nye version af evcc…", - "modalUpdateStatusStart": "Installation startede:" + "modalUpdateStatusStart": "Installation startede:", + "modalViewOnGitHub": "Se på GitHub", + "openSource": "open source", + "poweredByOpenSource": "Drevet af {0}." } }, "forecast": { @@ -912,6 +975,13 @@ "lowestHour": "Reneste time", "range": "spændvidde" }, + "empty": { + "co2": "Se når strøm i din region er grøn. Opladningsplaner bliver optimeret til lav-emission og sparet CO₂ beregnes.", + "price": "Konfigurér din dynamiske eltarif for automatisk at optimere ladeplaner og beregne besparelser.", + "setup": "Indstil tariffer og prognoser", + "solar": "Vis forventet solenergi produceret i dag og de nærmeste dage. Bliver også brugt til automatisk optimering af opladninger i fremtiden." + }, + "hideLine": "skjul linjen", "modalTitle": "Prognose", "price": { "average": "Gennemsnit", @@ -919,6 +989,8 @@ "lowestHour": "Billigste time", "range": "Spændvidde" }, + "priceZoom": "zoom ind", + "showLine": "vis linjen", "solar": { "dayAfterTomorrow": "I overmorgen", "partly": "delvis", @@ -927,10 +999,12 @@ "tomorrow": "I morgen" }, "solarAdjust": "Juster solprognosen på baggrund af produktions data{percent}.", + "solarAdjustMedium": "Justering baseret på faktiske data", + "solarAdjustShort": "justering", "type": { - "co2": "CO₂", - "price": "Pris", - "solar": "Sol" + "co2": "CO₂ Emissioner", + "price": "Strømpris", + "solar": "Solenergiproduktion" } }, "general": { @@ -1099,7 +1173,7 @@ "batteryTooltip": "{energy} af {total} ({soc})", "forecast": "Prognose: ", "forecastTooltip": "prognose: resterende solenergi produktion i dag", - "gridImport": "Forbrug fra elnet", + "gridImport": "Import fra elnet", "homePower": "Forbrug", "loadpoints": "Oplader| Oplader | {count} opladere", "loadpointsLimit": "{limit} begrænsning", @@ -1144,6 +1218,7 @@ "once": "Boost er aktiveret for denne opladning.", "stateActive": "Batteriboost er aktiv", "stateBelowLimit": "Batteriniveau er for lav til boost", + "stateHold": "Batteriet er låst", "stateReady": "Batteriboost er klar" }, "batteryUsage": "Husbatteri", @@ -1253,6 +1328,7 @@ "batteryBoostBelowLimit": "Batteriniveau er for lav til boost.", "batteryBoostDisabled": "Batteriboost er deaktiveret.", "batteryBoostEnabled": "Boost indtil batteri er på {limit}.", + "batteryBoostHold": "Batteriet låst. Boost er ikke muligt.", "charging": "Oplader…", "cheapEnergyCharging": "Billig energi er tilgængelig.", "cheapEnergyNextStart": "Billig energi i {duration}.", @@ -1492,5 +1568,13 @@ "lineErrorLink": "linje {0}", "restartButton": "Genstart", "title": "Fejl ved opstart" + }, + "tabBar": { + "battery": "Batteri", + "charge": "Oplader", + "comingSoon": "Siden under opbygning.", + "forecast": "Prognose", + "more": "Mere", + "sessions": "Sessioner" } } diff --git a/i18n/de.json b/i18n/de.json index 7af4256b2..aec63eea6 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -127,6 +127,7 @@ "chargedEnergy": "Geladen", "co2": "Netz-CO₂", "configured": "Konfiguriert", + "connected": "Verbunden", "connections": "Verbindungen", "controllable": "Steuerbar", "currency": "Währung", @@ -946,6 +947,7 @@ "modalCancel": "Abbrechen", "modalDownload": "Download", "modalInstalledVersion": "Installierte Version", + "modalLatest": "Du verwendest bereits die aktuellste Version.", "modalNextRelease": "Was ist neu im nächsten Release", "modalNoReleaseNotes": "Keine Versionshinweise verfügbar. Mehr Informationen zur neuen Version:", "modalTitle": "Neue Version verfügbar", @@ -955,7 +957,7 @@ "modalUpdateStatusStart": "Installation gestartet:", "modalViewOnGitHub": "Auf GitHub ansehen", "openSource": "Open Source", - "poweredByOpenSource": "Powered by {0}." + "poweredByOpenSource": "Unterstützt von {0}." } }, "forecast": { @@ -971,6 +973,7 @@ "setup": "Tarife und Vorhersagen einrichten", "solar": "Erwartete Solarproduktion für heute und die nächsten Tage. Wird in Zukunft auch für die automatische Ladeoptimierung verwendet." }, + "hideLine": "Linie ausblenden", "modalTitle": "Vorhersage", "price": { "average": "Durchschnitt", @@ -978,6 +981,8 @@ "lowestHour": "Günstigste Stunde", "range": "Bereich" }, + "priceZoom": "vergrößern", + "showLine": "Linie anzeigen", "solar": { "dayAfterTomorrow": "Übermorgen", "partly": "teilweise", @@ -986,10 +991,12 @@ "tomorrow": "Morgen" }, "solarAdjust": "Solarprognose anhand der realen Produktionsdaten anpassen{percent}.", + "solarAdjustMedium": "an reale Produktionsdaten anpassen", + "solarAdjustShort": "anpassen", "type": { - "co2": "CO₂", - "price": "Preis", - "solar": "Solar" + "co2": "CO₂-Emissionen", + "price": "Netzpreis", + "solar": "Solarproduktion" } }, "general": { diff --git a/i18n/fi.json b/i18n/fi.json index dc602412f..9d76ab6c1 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -113,6 +113,7 @@ "title": "Valuutta" }, "deviceValue": { + "activeClients": "Aktiiviset asiakasyhteydet", "amount": "Määrä", "broker": "Välittäjä (Broker)", "bucket": "Tietosäiliö (Bucket)", @@ -126,6 +127,7 @@ "chargedEnergy": "Ladattu", "co2": "Verkon CO₂", "configured": "Määritetty", + "connected": "Yhdistetty", "connections": "Yhteydet", "controllable": "Hallittava", "currency": "Valuutta", @@ -144,6 +146,7 @@ "hemsActiveLimit": "Aktiivinen raja", "hemsType": "Kommunikaatiomenetelmä", "identifier": "RFID-tunniste", + "loginBlocked": "Sisäänkirjoitumisyritysten raja saavutettu", "max": "maksimi", "messengers": "Palvelut", "no": "ei", @@ -602,6 +605,11 @@ "url": "Palvelimen URL", "urlHelp": "Kopioi tämä URL laturisi asetuksiin. Tarkista laturivalmistajan ohjeista yksityiskohdat. Laturin odotetaan lisäävän URL-osoitteeseen yksilöllinen tunnus (aseman ID). Joissain harvoissa tapauksissa sinun täytyy käsin lisätä tämä tunnus. Esim: `{url}`" }, + "optimizer": { + "description": "Analysoi aurinkoennustetta, sähkönhintaa ja kulutustottumuksiasi optimoidakseen akun ja latauspisteiden käyttöä. Tiedot lähetetään käsiteltäväksi evcc:n optimointipalveluun.", + "enable": "Käytä optimointia", + "title": "Optimointi" + }, "options": { "boolean": { "no": "ei", @@ -629,6 +637,38 @@ "titleAdd": "Lisää aurinkovoimalan mittari", "titleEdit": "Muokkaa aurinkovoimalan mittaria" }, + "remote": { + "active": "Aktiivinen", + "addClient": "Lisää etäkäyttäjä", + "addClientDescription": "Käyttäjätunnustiedot tallennetaan ja varmennetaan vain paikallisesti sinun evcc-asennuksessasi.", + "addClientTitle": "Lisää etäkäyttäjäasiakas", + "clientCreated": "Etäkäyttäjä luotu", + "clients": "Etäkäyttäjät", + "confirmDelete": "Poista etäkäyttäjä?", + "connected": "Yhdistetty", + "createClient": "Luo etäkäyttäjäasiakas", + "description": "Pääse käsiksi evcc-asennukseesi mistä vain käyttäen evcc-kännykkäsovellusta. Ei vaadi VPN:ää tai reititinohjausasetuksia.", + "deviceName": "Laitteen nimi", + "disconnected": "Ei yhteydessä", + "done": "Valmis", + "enableLabel": "Salli etäkäyttö", + "expiration": "Umpeutuu", + "expirationNone": "Ei koskaan", + "expired": "umpeutunut", + "expiresIn": "umpeutuu {time}", + "lastActive": "aktiivinen {time}", + "loginBlocked": "Etäkirjautumiset on estetty minuutiksi, koska liian monta kirjautumisyritystä on epäonnistunut.", + "manualLogin": "tai kirjaudu {url} selaimessa käyttäen seuraavia tunnuksia:", + "noClients": "Ei vielä etäyhteysasiakkaita. Kukaan ei vielä voi kirjautua etänä.", + "password": "Salasana", + "passwordOnce": "Tämä salasana näytetään vain kerran. Skannaa QR-koodi tai kopioi se, sillä et voi nähdä sitä enää uudelleen.", + "qrInstall": "Asenna evcc-sovellus {ios}:sta tai {android}:sta.", + "qrScan": "Skannaa koodi puhelimesi kameralla yhdistääksesi. Klikkaa linkkiä, jos jo käytät puhelimesi kameraa.", + "removeClient": "Poista etäyhteysasiakas", + "title": "Etäyhteys", + "url": "Julkinen URL-osoite", + "username": "Käyttäjätunnus" + }, "section": { "additionalMeter": "Ylimääräiset mittarit", "general": "Yleinen", @@ -759,8 +799,8 @@ "titleChoice": "Mitä haluat lisätä?", "type": { "co2": "CO₂-intensiteetti", - "feedIn": "Syöttöhinta", - "grid": "Verkkosähkön hinta", + "feedIn": "Sähkön myyntihinta verkkoon", + "grid": "Verkkosähkön ostohinta", "planner": "Suunnitelma", "solar": "Aurinko" }, @@ -939,6 +979,7 @@ "setup": "Määritä hinnat ja ennusteet", "solar": "Katso aurinkotuotannon ennuste täksi ja tuleviksi päiviksi. Käytetään tulevaisuudessa myös latauksien optimoitiin." }, + "hideLine": "piilota viiva", "modalTitle": "Ennuste", "price": { "average": "Keskimääräinen", @@ -947,6 +988,7 @@ "range": "Toimintamatka" }, "priceZoom": "lähennä", + "showLine": "näytä viiva", "solar": { "dayAfterTomorrow": "Ylihuomenna", "partly": "osittain", @@ -955,7 +997,8 @@ "tomorrow": "Huomenna" }, "solarAdjust": "Mukauta aurinkosääennustettu todellisten tuotantotietojen perusteella{percent}.", - "solarAdjustShort": "mitatun datan korjaus", + "solarAdjustMedium": "mitatun datan korjaus", + "solarAdjustShort": "korjaus", "type": { "co2": "CO₂-päästöt", "price": "Sähkön hinta", diff --git a/i18n/fr.json b/i18n/fr.json index 0011cf86f..40649f246 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -40,6 +40,7 @@ "legendTopName": "Charge véhicule par la batterie", "legendTopSubline": "lorsque la batterie domestique est au-dessus de {soc}.", "modalTitle": "Batterie domestique", + "noBattery": "Aucune batterie n'est configurée.", "usageTab": "Utilisation batterie" }, "config": { @@ -130,8 +131,9 @@ "currency": "Devise", "current": "Courant", "currentRange": "Courant", + "curtailed": "Limitation de l'injection", "detected": "Détecté", - "dimmed": "Atténué", + "dimmed": "Consommation limitée", "enabled": "Activé", "energy": "Énergie", "events": "Evénements", @@ -600,6 +602,11 @@ "url": "URL du Serveur", "urlHelp": "Copiez cette URL dans la configuration de votre chargeur. Consultez le manuel du fabricant pour plus de détails. Le chargeur devrait ajouter automatiquement son identifiant unique (ID de station) à l'URL. Dans de rares cas, vous devrez peut-être spécifier manuellement l'identifiant. Exemple : `{url}`" }, + "optimizer": { + "description": "Analyse les prévisions d'ensoleillement, les prix de l'électricité et vos habitudes de consommation afin d'optimiser la stratégie de gestion de la batterie et de recharge. Les données sont transmises au service d'optimisation evcc pour y être traitées.", + "enable": "Activer l'optimiseur", + "title": "Optimiseur" + }, "options": { "boolean": { "no": "non", @@ -642,11 +649,13 @@ "cardTitle": "Sunny Home Manager", "description": "evcc est équipé d'une intégration pour le SMA Sunny Home Manager (SHM) via le protocole SEMP. S'il fonctionne sur le même réseau, après vous être connecté à votre compte Sunny Portal, vous devriez automatiquement avoir la possibilité d'ajouter tous les chargeurs configurés dans evcc en tant que nouveaux consommateurs détectés. Tout devrait être prêt à l'emploi immédiatement, sans qu'aucun réglage ci-dessous ne soit nécessaire.", "descriptionDeviceId": "Chaîne HEX de 12 caractères. Préfixe pour tous les appareils (point de charge, etc.).", + "descriptionDeviceSerial": "Chaîne de 12 caractères en hexadécimal. Numéro de série de base pour tous les appareils (borne de recharge, etc.). Par défaut, evcc le déduit de l'adresse MAC de l'hôte.", "descriptionIdPattern": "Schéma d'identification", "descriptionIds": "Dans Sunny Portal, chaque appareil consommateur doit disposer d'un identifiant unique. evcc génère un identifiant unique basé sur votre matériel. Si vous migrez evcc vers un autre matériel, ces identifiants peuvent changer. Si vous souhaitez conserver l'historique, vous pouvez remplacer les identifiants générés ici. Ouvrez l'URL SEMP (/semp) pour vérifier vos identifiants actuels.", "descriptionSempUrl": "URL SEMP", "descriptionVendorId": "Chaîne HEX de 8 caractères. Préfixe général de toutes les entités. Par défaut, evcc utilisera son propre identifiant fournisseur interne.", "labelDeviceId": "Identifiant de l'appareil", + "labelDeviceSerial": "Numéro de série de l'appareil", "labelVendorId": "Identifiant du fournisseur", "title": "SMA Sunny Home Manager" }, @@ -850,6 +859,15 @@ "configurePriceCo2": "Infos sur la configuration du prix et des données de CO₂.", "footerLong": "{percent} énergie solaire", "footerShort": "{percent} solaire", + "indicator": { + "co2": "Émissions de CO₂", + "co2saved": "Économies de CO₂", + "none": "aucun", + "price": "prix de l'énergie", + "savings": "économies", + "solar": "énergie solaire" + }, + "indicatorLabel": "Informations d'en-tête", "modalTitle": "Aperçu de l'énergie de charge", "moneySaved": "{value} économisés", "percentGrid": "{grid} kWh réseau", @@ -861,10 +879,11 @@ "thisYear": "cette année", "total": "depuis le début" }, - "periodLabel": "Période :", + "periodLabel": "Période", "priceTitle": "Prix de l'énergie", "referenceGrid": "réseau électrique", - "referenceLabel": "Données de référence :", + "referenceLabel": "Données de référence", + "sessionInfo": "Sur la base des sessions de recharge terminées.", "tabTitle": "Mes données" }, "sponsor": { @@ -890,15 +909,26 @@ }, "version": { "availableLong": "mise à jour disponible", + "community": "Communauté evcc", + "labelRelease": "Lancement", + "labelVersion": "Version", + "labelWebsite": "Site web", + "latestVersion": "dernière version", + "madeByCommunity": "Réalisé par {0}.", "modalCancel": "Annuler", "modalDownload": "Télécharger", "modalInstalledVersion": "Version installée", + "modalLatest": "Vous utilisez la dernière version.", + "modalNextRelease": "Nouveautés de la prochaine version", "modalNoReleaseNotes": "Aucune note de version disponible. Plus d'informations sur la nouvelle version disponibles ici :", "modalTitle": "Mise à jour disponible", "modalUpdate": "Installer", "modalUpdateNow": "Installer maintenant", "modalUpdateStarted": "Démarrage de la nouvelle version d’evcc…", - "modalUpdateStatusStart": "L'installation a commencé :" + "modalUpdateStatusStart": "L'installation a commencé :", + "modalViewOnGitHub": "Voir sur GitHub", + "openSource": "open source", + "poweredByOpenSource": "Propulsé par {0}." } }, "forecast": { @@ -908,6 +938,12 @@ "lowestHour": "Heure la plus propre", "range": "Amplitude" }, + "empty": { + "co2": "Découvrez quand l'électricité du réseau dans votre région est verte. Les plans de recharge seront optimisés pour réduire les émissions et calculeront les économies de CO₂ réalisées.", + "price": "Configurez votre tarif d'électricité dynamique pour optimiser automatiquement vos plans de recharge et calculer vos économies.", + "setup": "Définir les tarifs et les prévisions", + "solar": "Consultez les prévisions de production solaire pour aujourd'hui et les prochains jours. Ces données serviront également, à l'avenir, à optimiser automatiquement la recharge." + }, "modalTitle": "Prévisions", "price": { "average": "Moyenne", @@ -915,6 +951,7 @@ "lowestHour": "Heure la moins chère", "range": "Amplitude" }, + "priceZoom": "zoomer", "solar": { "dayAfterTomorrow": "Après-demain", "partly": "partiellement", @@ -923,10 +960,11 @@ "tomorrow": "Demain" }, "solarAdjust": "Ajuster la prévision solaire se basant sur la production réelle{percent}.", + "solarAdjustShort": "ajuster les données réelles", "type": { - "co2": "CO₂", - "price": "Prix", - "solar": "Solaire" + "co2": "Émissions de CO₂", + "price": "Prix du réseau", + "solar": "Production solaire" } }, "general": { @@ -1140,6 +1178,7 @@ "once": "Boost actif pour cette session de charge.", "stateActive": "Boost batterie activé", "stateBelowLimit": "Niveau de batterie trop bas pour le boost", + "stateHold": "Batterie verrouillée", "stateReady": "Boost de batterie prêt" }, "batteryUsage": "Batterie domestique", @@ -1249,6 +1288,7 @@ "batteryBoostBelowLimit": "Niveau de batterie trop bas pour boost.", "batteryBoostDisabled": "Boost batterie désactivé.", "batteryBoostEnabled": "Boost batterie jusqu'à {limit}.", + "batteryBoostHold": "Batterie verrouillée. Mode Boost indisponible.", "charging": "En charge…", "cheapEnergyCharging": "Énergie bon marché disponible.", "cheapEnergyNextStart": "Énergie bon marché dans {duration}.", @@ -1488,5 +1528,13 @@ "lineErrorLink": "Ligne {0}", "restartButton": "Redémarrer", "title": "Erreur au démarrage" + }, + "tabBar": { + "battery": "Batterie", + "charge": "Charger", + "comingSoon": "Cette page est en cours de construction.", + "forecast": "Prévisions", + "more": "Plus", + "sessions": "Sessions" } } diff --git a/i18n/it.json b/i18n/it.json index da6463a12..940edfa9a 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -40,6 +40,7 @@ "legendTopName": "Ricarica del veicolo assistita dalla batteria", "legendTopSubline": "quando la batteria supera il {soc}.", "modalTitle": "Batteria di casa", + "noBattery": "Nessuna batteria configurata.", "usageTab": "Uso della batteria" }, "config": { @@ -601,6 +602,11 @@ "url": "URL del server", "urlHelp": "Copia questo URL nella configurazione del caricabatterie. Per ulteriori dettagli, consulta il manuale del produttore. Il caricabatterie dovrebbe aggiungere automaticamente il proprio identificativo univoco (ID stazione) all'URL. In rari casi, potrebbe essere necessario specificare manualmente l'identificativo. Esempio: `{url}`" }, + "optimizer": { + "description": "Analizza le previsioni solari, i prezzi dell'elettricità e i tuoi pattern di consumo per ottimizzare l'uso della batteria e la strategia di carica. I dati sono inviati al servizio di ottimizzazione di evcc per l'elaborazione.", + "enable": "Abilita Ottimizzatore", + "title": "Ottimizzatore" + }, "options": { "boolean": { "no": "no", @@ -853,6 +859,14 @@ "configurePriceCo2": "Scopri come configurare i dati su prezzi e CO₂.", "footerLong": "{percent} di energia solare", "footerShort": "{percent} solare", + "indicator": { + "co2": "Emissioni CO₂", + "co2saved": "CO₂ risparmiata", + "none": "nessuno", + "price": "prezzo elettricità", + "savings": "risparmi", + "solar": "energia solare" + }, "modalTitle": "Riepilogo energia caricata", "moneySaved": "{value} di risparmio", "percentGrid": "{grid} kWh rete", @@ -868,6 +882,7 @@ "priceTitle": "Prezzo energia", "referenceGrid": "rete", "referenceLabel": "Dati di riferimento:", + "sessionInfo": "In base alle sessioni di ricarica completate.", "tabTitle": "I miei dati" }, "sponsor": { @@ -893,15 +908,25 @@ }, "version": { "availableLong": "aggiornamento disponibile", + "community": "community di evcc", + "labelRelease": "Release", + "labelVersion": "Versione", + "labelWebsite": "Sito web", + "latestVersion": "ultima versione", + "madeByCommunity": "Creato da {0}.", "modalCancel": "Annulla", "modalDownload": "Download", "modalInstalledVersion": "Versione correntemente installata", + "modalLatest": "Stai utilizzando la versione più recente.", + "modalNextRelease": "Cosa c'è nella prossima release", "modalNoReleaseNotes": "Non ci sono note di rilascio disponibili. Altre informazioni circa la nuova versione si trovano qui:", "modalTitle": "Aggiornamento disponibile", "modalUpdate": "Installare", "modalUpdateNow": "Installa ora", "modalUpdateStarted": "Evcc ripartirà dopo l'aggiornamento…", - "modalUpdateStatusStart": "Installazione avviata:" + "modalUpdateStatusStart": "Installazione avviata:", + "modalViewOnGitHub": "Vedi su GitHub", + "openSource": "open source" } }, "forecast": { @@ -911,6 +936,12 @@ "lowestHour": "Ora più ecologica", "range": "Intervallo" }, + "empty": { + "co2": "Controlla quando l'energia della rete nella tua regione è prodotta da fonti pulite. I piani di ricarica saranno ottimizzati per ridurre le emissioni e terranno conto del risparmio di CO₂.", + "price": "Configura la tua tariffa dinamica dell'elettricità in modo da ottimizzare i piani di ricarica e calcolare il risparmio economico.", + "setup": "Imposta le tariffe e le previsioni", + "solar": "Visualizza la produzione solare attesa per oggi e per i giorni seguenti. Sarà utilizzato anche per l'ottimizzazione automatica della ricarica futura." + }, "modalTitle": "Previsioni", "price": { "average": "Media", @@ -918,6 +949,7 @@ "lowestHour": "Ora più economica", "range": "Intervallo" }, + "priceZoom": "ingrandisci", "solar": { "dayAfterTomorrow": "Dopodomani", "partly": "parzialmente", @@ -926,6 +958,7 @@ "tomorrow": "Domani" }, "solarAdjust": "Modifica la previsione sulla base dei dati reali{percent}.", + "solarAdjustShort": "correzione con dati reali", "type": { "co2": "CO₂", "price": "Prezzo", @@ -1137,6 +1170,7 @@ "once": "Boost attivo per questa sessione di ricarica.", "stateActive": "Boost da batteria attivo", "stateBelowLimit": "Batteria troppo scarica per il boost", + "stateHold": "Batteria bloccata", "stateReady": "Battery boost pronto" }, "batteryUsage": "Batteria di casa", @@ -1246,6 +1280,7 @@ "batteryBoostBelowLimit": "Batteria troppo scarica per il boost", "batteryBoostDisabled": "Battery boost disabilitato.", "batteryBoostEnabled": "Boost fino al {limit}.", + "batteryBoostHold": "Batteria bloccata. Boost non disponibile.", "charging": "In carica…", "cheapEnergyCharging": "Corrente economica disponibile.", "cheapEnergyNextStart": "Corrente economica in {duration}.", @@ -1485,5 +1520,13 @@ "lineErrorLink": "linea {0}", "restartButton": "Ricomincia", "title": "Errore di avvio" + }, + "tabBar": { + "battery": "Batteria", + "charge": "Carica", + "comingSoon": "Questa pagina è in costruzione.", + "forecast": "Previsione", + "more": "Di più", + "sessions": "Sessioni" } } diff --git a/i18n/lb.json b/i18n/lb.json index 4f5acc722..27f3c437b 100644 --- a/i18n/lb.json +++ b/i18n/lb.json @@ -40,6 +40,7 @@ "legendTopName": "Batterie-ënnerstëtzt Opluede vum Gefier", "legendTopSubline": "wann d'Hausbatterie iwwer {soc} ass.", "modalTitle": "Hausbatterie", + "noBattery": "Keng Batterien konfiguréiert.", "usageTab": "Batterieverbrauch" }, "config": { @@ -130,8 +131,9 @@ "currency": "Wärung", "current": "Stroum", "currentRange": "Stroum", + "curtailed": "Einspeisung limitéiert", "detected": "Erkannt", - "dimmed": "Gedimmt", + "dimmed": "Verbrauch limitéiert", "enabled": "Ageschalt", "energy": "Energie", "events": "Evenementer", @@ -600,6 +602,11 @@ "url": "Server-URL", "urlHelp": "Kopéier dës URL fir d'Konfiguratioun vun denger Wallbox. Kuck am Handbuch vum Hiersteller no Detailer. Et gëtt erwaart, datt d'Wallbox automatesch säin eenzegaartegen Identifikator (Statiouns-ID) un d'URL bäifügt. A rare Fäll musst Dir den Identifikator eventuell manuell spezifizéieren. Beispill: `{url}`" }, + "optimizer": { + "description": "Analyséiert d'Solarprognos, d'Stroumpräisser an Är Verbrauchssmuster, fir d'Batterie- a Luedstrategie ze optiméieren. D'Donnéeë ginn un den evcc-Optimiséierungsservice fir d'Berechnen geschéckt.", + "enable": "Optimizer aktivéieren", + "title": "Optimizer" + }, "options": { "boolean": { "no": "nee", @@ -642,11 +649,13 @@ "cardTitle": "Sunny Home Manager", "description": "Den evcc ass mat enger Integratioun fir den SMA Sunny Home Manager (SHM) iwwert den SEMP-Protokoll ausgestatt. Wann den SHM am selwechte Netzwierk leeft, sollt Dir no der Aschreiwung an Ärem Sunny Portal-Kont automatesch d'Méiglechkeet kréien, all an evcc konfiguréiert Luedpunkten als nei detektéiert Laaschten derbäizesetzen. Alles sollt direkt gebrauchsfäeg sinn, ouni weider Upassungen.", "descriptionDeviceId": "12-stellegen HEX-String. Präfix fir all Geräter (Luedpunkt, ..).", + "descriptionDeviceSerial": "HEX-String mat 12-Zeechen. Basis-Seriennummer fir all Apparater (Luedpunkt, ...). Standardméisseg kritt den evcc dës aus der MAC-Adress vum Host.", "descriptionIdPattern": "Identifikatiounsmuster", "descriptionIds": "Am Sunny Portal brauch all Ladung (Luedpunkt, etc.) eng eenzegaarteg Identifikatioun. evcc generéiert eng eenzegaarteg Identifikatioun, déi op Ärer Hardware baséiert. Wann Dir evcc op aner Hardware migréiert, kann dësen Identifikator sech änneren. Fir den Historique ze späicheren, kënnt Dir déi generéiert Identifikatoren hei iwwerschreiwen. Maacht d'SEMP URL op (/semp) fir Är aktuell Identifikatoren ze kontrolléieren.", "descriptionSempUrl": "SEMP-URL", "descriptionVendorId": "8-stellegen HEX-String. Allgemenge Präfix fir all Entiéiten. Standardméisseg verwennt evcc seng eegen intern Hiersteller-ID.", "labelDeviceId": "Geräter-ID", + "labelDeviceSerial": "Apparat-Seriennummer", "labelVendorId": "Hiersteller-ID", "title": "SMA Sunny Home Manager" }, @@ -833,7 +842,7 @@ }, "footer": { "community": { - "greenEnergy": "Solarenergie", + "greenEnergy": "Solar", "greenEnergySub1": "opgelueden mat evcc", "greenEnergySub2": "zënter Oktober 2022", "greenShare": "Sonnenundeel", @@ -850,6 +859,15 @@ "configurePriceCo2": "Léiert wéi een de Präis an d'CO₂-Daten konfiguréiert.", "footerLong": "{percent} Solarenergie", "footerShort": "{percent} Solar", + "indicator": { + "co2": "CO₂-Emissiounen", + "co2saved": "CO₂ agespuert", + "none": "keng", + "price": "Energiepräis", + "savings": "Erspuernesser", + "solar": "Solarenergie" + }, + "indicatorLabel": "Header-Info", "modalTitle": "Iwwersiicht vum opgeluedene Stroum", "moneySaved": "{value} gespuert", "percentGrid": "{grid} kWh Stroumnetz", @@ -861,10 +879,11 @@ "thisYear": "dëst Joer", "total": "gesamt" }, - "periodLabel": "Zäitraum:", + "periodLabel": "Zäitraum", "priceTitle": "Energiepräis", "referenceGrid": "Netz", - "referenceLabel": "Referenzdaten:", + "referenceLabel": "Referenzdaten", + "sessionInfo": "Baséiert op ofgeschlossen Opluedsessiounen.", "tabTitle": "Meng Daten" }, "sponsor": { @@ -890,15 +909,26 @@ }, "version": { "availableLong": "nei Versioun verfügbar", + "community": "evcc Communautéit", + "labelRelease": "Verëffentlechung", + "labelVersion": "Versioun", + "labelWebsite": "Websäit", + "latestVersion": "lescht Versioun", + "madeByCommunity": "Entwéckelt vun der {0}.", "modalCancel": "Ofbriechen", "modalDownload": "Download", "modalInstalledVersion": "Aktuell installéiert Versioun", + "modalLatest": "Dir benotzt déi lescht Versioun.", + "modalNextRelease": "Wat gëtt et an der nächster Versioun", "modalNoReleaseNotes": "Keng Versiounshiweiser verfügbar. Méi Informatioun iwwert déi nei Versioun:", "modalTitle": "Aktualiséierung verfügbar", "modalUpdate": "Installéieren", "modalUpdateNow": "Elo installéieren", "modalUpdateStarted": "Déi nei Versioun vun evcc starten…", - "modalUpdateStatusStart": "Installatioun ugefaang:" + "modalUpdateStatusStart": "Installatioun ugefaang:", + "modalViewOnGitHub": "Op GitHub kucken", + "openSource": "Open Source", + "poweredByOpenSource": "Bedriwwen vun {0}." } }, "forecast": { @@ -908,6 +938,12 @@ "lowestHour": "Déi properst Stonn", "range": "Beräich" }, + "empty": { + "co2": "Iwwerpréiwt, wéini d'Netzenergie an Ärer Regioun propper ass. Luedpläng ginn optimiséiert fir d'Emissiounen ze reduzéieren an CO₂ anzespueren.", + "price": "Konfiguréiert Ären dynameschen Stromtarif, fir d'Luedpläng automatesch ze optiméieren an d'Erspuernësser ze berechnen.", + "setup": "Tariffer a Prognosen opstellen", + "solar": "Kuckt déi erwaart Solarproduktioun fir haut an déi nächst Deeg. Et gëtt och an der Zukunft fir d'automatiséiert Luedoptimiséierung benotzt." + }, "modalTitle": "Prognose", "price": { "average": "Duerchschnitt", @@ -915,6 +951,7 @@ "lowestHour": "Bëllegst Stonn", "range": "Beräich" }, + "priceZoom": "erazoomen", "solar": { "dayAfterTomorrow": "Iwwermuer", "partly": "deelweis", @@ -923,10 +960,11 @@ "tomorrow": "Muer" }, "solarAdjust": "Solarprognose unhand vun de reale Produktionsdaten upassen{percent}.", + "solarAdjustShort": "u reell Daten upassen", "type": { - "co2": "CO₂", - "price": "Präis", - "solar": "Solar" + "co2": "CO₂-Emissiounen", + "price": "Netzpräis", + "solar": "Solar-Productioun" } }, "general": { @@ -1139,6 +1177,7 @@ "once": "Boost ass dir dës Luedsessioun aktivéiert.", "stateActive": "Batterieboost aktiv", "stateBelowLimit": "Batterie ze niddereg fir de Boost", + "stateHold": "Batterie gespaart", "stateReady": "Batterieboost bereet" }, "batteryUsage": "Hausbatterie", @@ -1248,6 +1287,7 @@ "batteryBoostBelowLimit": "Batterie ze niddereg fir de Boost.", "batteryBoostDisabled": "Batterieboost deaktivéiert.", "batteryBoostEnabled": "Boost bis d'Batterie bei {limit} ass.", + "batteryBoostHold": "Batterie gespaart. Boost net verfügbar.", "charging": "Luet…", "cheapEnergyCharging": "Gënschteg Energie verfügbar.", "cheapEnergyNextStart": "Gënschteg Energie an {duration}.", @@ -1487,5 +1527,13 @@ "lineErrorLink": "Linn {0}", "restartButton": "Neistart", "title": "Startup Feeler" + }, + "tabBar": { + "battery": "Batterie", + "charge": "Lueden", + "comingSoon": "Dës Säit ass amgaang gebaut ze ginn.", + "forecast": "Viraussoen", + "more": "Méi", + "sessions": "Sessiounen" } } diff --git a/i18n/lt.json b/i18n/lt.json index 674207c0f..09adec291 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -113,6 +113,7 @@ "title": "Valiuta" }, "deviceValue": { + "activeClients": "Aktyvūs vartotojai", "amount": "Kiekis", "broker": "Broker sistema", "bucket": "Bucket", @@ -126,6 +127,7 @@ "chargedEnergy": "Įkrauta", "co2": "Tinklo CO₂", "configured": "Sukonfigūruota", + "connected": "Prisijungta", "connections": "Jungtys", "controllable": "Valdomas", "currency": "Valiuta", @@ -144,6 +146,7 @@ "hemsActiveLimit": "Aktyvus limitas", "hemsType": "Komunikacija", "identifier": "RFID-Identifikatorius", + "loginBlocked": "Pasiektas prisijungimų limitas", "max": "max", "messengers": "Paslaugos", "no": "ne", @@ -206,7 +209,7 @@ }, "experimental": { "description": "Suteikia išankstinę prieigą prie funkcijų, kurios vis dar testuojamos. Jos gali būti nestabilios bei gali pasikeisti arba būti pašalintos būsimose versijose.", - "title": "Eksperimentinis" + "title": "Eksperimentinės" }, "ext": { "description": "Įrašo nekontroliuojamų įrenginių (pvz., šaldytuvo, skalbimo mašinos ir kt.) energijos vertes statistikos tikslais. Šie skaitikliai taip pat gali būti naudojami apkrovos valdymui (pvz., paskirstymui).", @@ -238,7 +241,7 @@ "dragItem": "Galima vilkti: {title}", "dragList": "Galima sukeisti vietomis", "error": "Klaida", - "experimental": "Eksperimentinis", + "experimental": "Eksperimentinės", "forceSave": "Vistiek įrašyti", "fromYamlHint": "Pastaba: Sukonfigūruota naudojant evcc.yaml. Pašalinkite įrašą iš failo, kad būtų galima redaguoti čia.", "hideAdvancedSettings": "Slėpti išplėstinius nustatymus", @@ -602,6 +605,12 @@ "url": "Serverio URL", "urlHelp": "Nukopijuokite šį URL į savo įkroviklio konfigūraciją. Išsamesnės informacijos ieškokite gamintojo vadove. Įkroviklis turėtų automatiškai pridėti savo unikalų identifikatorių (stotelės ID) prie URL. Retais atvejais gali tekti rankiniu būdu nurodyti identifikatorių. Pavyzdys: `{url}`" }, + "optimizer": { + "description": "Analizuoja saulės energijos prognozę, elektros energijos kainas ir jūsų vartojimo įpročius, kad optimizuotų kaupiklį ir įkrovimo strategiją. Duomenys siunčiami apdorojimui į evcc optimizavimo paslaugą. Šiuo metu tik apskaičiuoja ir vizualizuoja, įrenginių kol kas nevaldo.", + "enable": "Įjungti Optimizavimą", + "info": "Gali užtrukti kelias minutes, kol meniu pasirodys optimizatoriaus eilutė. Naujoms evcc instaliacijoms gali užtrukti iki 24 valandų, kol evcc turės pakankamai duomenų veikimui.", + "title": "Optimizavimas" + }, "options": { "boolean": { "no": "ne", @@ -629,6 +638,38 @@ "titleAdd": "Pridėti saulės skaitiklį", "titleEdit": "Redaguoti saulės skaitiklį" }, + "remote": { + "active": "Aktyvus", + "addClient": "Pridėti vartotoją", + "addClientDescription": "Įgaliojimai saugomi ir tikrinami tik lokaliai, jūsų evcc egzemplioriuje.", + "addClientTitle": "Pridėti nutolusį vartotoją", + "clientCreated": "Vartotojas sukurtas", + "clients": "Vartotojai", + "confirmDelete": "Ištrinti vartotoją?", + "connected": "Prijungta", + "createClient": "Sukurti vartotoją", + "description": "Pasiekite savo evcc sistemą iš bet kurios vietos, naudodami evcc mobiliąją programėlę. Nereikia jokio prievado peradresavimo ar VPN.", + "deviceName": "Įrenginio pavadinimas", + "disconnected": "Atjungta", + "done": "Atlikta", + "enableLabel": "Įgalinti nuotolinę prieigą", + "expiration": "Galiojimo laikas", + "expirationNone": "Niekada", + "expired": "pasibaigęs", + "expiresIn": "pasibaigs {time}", + "lastActive": "aktyvus {time}", + "loginBlocked": "Nuotoliniai prisijungimai blokuojami vienai minutei dėl per daug nepavykusių prisijungimo bandymų.", + "manualLogin": "Arba prisijunkite rankiniu būdu adresu {url} naršyklėje naudodami šiuos prisijungimo duomenis:", + "noClients": "Kol kas nėra vartotojų. Niekas dar negali prisijungti.", + "password": "Slaptažodis", + "passwordOnce": "Šis slaptažodis rodomas tik vieną kartą. Nuskaitykite QR kodą arba nukopijuokite jį dabar. Daugiau jo nebegalėsite pamatyti.", + "qrInstall": "Įdiekite evcc programėlę, skirtą {ios} arba {android}.", + "qrScan": "Nuskaitykite kodą telefono kamera, kad prisijungtumėte. Bakstelėkite ant jo, jei jau naudojate telefoną.", + "removeClient": "Pašalinti vartotoją", + "title": "Nuotolinė Prieiga", + "url": "Viešas URL", + "username": "Vartotojo vardas" + }, "section": { "additionalMeter": "Papildomi skaitikliai", "general": "Pagrindiniai", @@ -904,7 +945,7 @@ "optInSponsorship": "Gali tik rėmėjai." }, "version": { - "availableLong": "yra naujesnė versija", + "availableLong": "(yra naujesnė versija)", "community": "evcc bendruomenė", "labelRelease": "Leidimas", "labelVersion": "Versija", @@ -940,6 +981,7 @@ "setup": "Sukonfigūruoti tarifus ir prognozes", "solar": "Prognozuojama saulės energijos gamyba šiandienai ir ateinančiomis dienomis. Ateityje taip pat bus naudojama automatiniam įkrovimo optimizavimui." }, + "hideLine": "paslėpti liniją", "modalTitle": "Prognozė", "price": { "average": "Vidurkis", @@ -948,6 +990,7 @@ "range": "Diapazonas" }, "priceZoom": "priartinti", + "showLine": "rodyti liniją", "solar": { "dayAfterTomorrow": "Poryt", "partly": "dalinai", @@ -956,7 +999,8 @@ "tomorrow": "Rytoj" }, "solarAdjust": "Pakoreguoti saulės prognozę remiantis faktiškais gamybos duomenimis{percent}.", - "solarAdjustShort": "koreguoti pagal faktinius duomenis", + "solarAdjustMedium": "koreguoti pagal faktinius duomenis", + "solarAdjustShort": "koreguoti", "type": { "co2": "CO₂ Išmetimai", "price": "Pirkimo Kaina", @@ -1001,7 +1045,7 @@ }, "restartButton": "Restartuoti", "restartDescription": "Ar pabandėte išjungti ir vėl įjungti?", - "secondaryActions": "Vis dar nepavyksta išspręsti problemos? Čia rasite drastiškesnes opcijas." + "secondaryActions": "Vis dar nepavyksta išspręsti problemos? Čia rasite sudėtingesnes opcijas." }, "issue": { "additional": { diff --git a/i18n/nl.json b/i18n/nl.json index d6a7cbd57..4b9d5bd1c 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -126,6 +126,7 @@ "chargedEnergy": "Geladen", "co2": "Stroomnet-CO₂", "configured": "Geconfigureerd", + "connected": "Verbonden", "connections": "Verbindingen", "controllable": "Stuurbaar", "currency": "Valuta", @@ -144,6 +145,7 @@ "hemsActiveLimit": "Actieve limiet", "hemsType": "Communicatie", "identifier": "RFID-Nummer", + "loginBlocked": "Inloglimiet bereikt", "max": "max", "messengers": "Diensten", "no": "nee", @@ -602,6 +604,11 @@ "url": "Server URL", "urlHelp": "Kopieer deze URL naar de configuratie van uw lader. Raadpleeg de handleiding van de fabrikant voor meer informatie. De lader voegt naar verwachting automatisch zijn unieke identificatiecode (station-ID) toe aan de URL. In zeldzame gevallen moet u de identificatiecode mogelijk handmatig ingeven. Voorbeeld: `{url}`" }, + "optimizer": { + "description": "Analyseert zonne-energieprognoses, elektriciteitsprijzen en je verbruikspatronen om de batterij- en laadstrategie te optimaliseren. Gegevens worden naar de evcc-optimalisatiedienst gestuurd voor verwerking.", + "enable": "Activeer optimizer", + "title": "Optimizer" + }, "options": { "boolean": { "no": "nee", @@ -629,6 +636,29 @@ "titleAdd": "Meter Zonnepanelen Toevoegen", "titleEdit": "Meter Zonnepanelen Bewerken" }, + "remote": { + "active": "Actief", + "addClient": "Client toevoegen", + "addClientTitle": "Remote client toevoegen", + "clientCreated": "Client toegevoegd", + "clients": "Clients", + "confirmDelete": "Client verwijderen?", + "connected": "Verbonden", + "createClient": "Client aanmaken", + "description": "Toegang tot uw evcc installatie via de evcc mobile app. Zonder port-forwarding en zonder VPN.", + "deviceName": "Apparaat naam", + "disconnected": "Niet verbonden", + "done": "Klaar", + "enableLabel": "Toegang op afstand inschakelen", + "expirationNone": "Nooit", + "expired": "verlopen", + "expiresIn": "verloopt {time}", + "lastActive": "actief {time}", + "loginBlocked": "Inloggen op afstand is voor een minuut geblokkeerd, omdat er te veel mislukte inlogpogingen waren.", + "manualLogin": "Of log handmatig in via uw browser op {url}, met de volgende credentials:", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, "section": { "additionalMeter": "Extra meters", "general": "Algemeen", @@ -862,6 +892,7 @@ "savings": "Besparing", "solar": "Zonne-Energie" }, + "indicatorLabel": "Header info", "modalTitle": "Oplaadenergie-overzicht", "moneySaved": "{value} bespaard", "percentGrid": "{grid} kWh net", @@ -873,10 +904,11 @@ "thisYear": "dit jaar", "total": "aller tijden" }, - "periodLabel": "Periode:", + "periodLabel": "Periode", "priceTitle": "Energieprijs", "referenceGrid": "net", - "referenceLabel": "Referentiedata:", + "referenceLabel": "Referentiedata", + "sessionInfo": "Op basis van voltooide laadsessies.", "tabTitle": "Mijn data" }, "sponsor": { @@ -902,15 +934,26 @@ }, "version": { "availableLong": "nieuwe versie beschikbaar", + "community": "evcc coomunity", + "labelRelease": "Release", + "labelVersion": "Versie", + "labelWebsite": "Website", + "latestVersion": "Nieuwste versie", + "madeByCommunity": "Gemaakt door de {0}.", "modalCancel": "Annuleer", "modalDownload": "Download", "modalInstalledVersion": "Geïnstalleerde versie", + "modalLatest": "U heeft de nieuwste versie.", + "modalNextRelease": "Wat zit er in de volgende release", "modalNoReleaseNotes": "Geen release notes beschikbaar. Meer info over de nieuwe versie:", "modalTitle": "Nieuwe versie beschikbaar", "modalUpdate": "Installeren", "modalUpdateNow": "Nu installeren", "modalUpdateStarted": "Starten van de nieuwe versie van evcc…", - "modalUpdateStatusStart": "Installatie gestart:" + "modalUpdateStatusStart": "Installatie gestart:", + "modalViewOnGitHub": "Op GitHub bekijken", + "openSource": "open source", + "poweredByOpenSource": "Powered by {0}." } }, "forecast": { @@ -920,6 +963,13 @@ "lowestHour": "Schoonste uur", "range": "Bereik" }, + "empty": { + "co2": "Kijk wanneer de netstroom in uw regio schoon is. Laadplanning wordt geoptimaliseerd voor minimale emissie en CO₂ besparing wordt berekend.", + "price": "Stel uw dynamische stroomprijzen in om laadplannen te optimaliseren en om uw besparingen mee te berekenen.", + "setup": "Tarieven en voorspellingen instellen", + "solar": "Bekijk de verwachtte zonne opbrengt voor vandaag en de komende dagen. Wordt ook gebruikt voor automatische laadplan optimalisatie in de toekomst." + }, + "hideLine": "regel verbergen", "modalTitle": "Voorspelling", "price": { "average": "Gemiddeld", @@ -927,6 +977,8 @@ "lowestHour": "Goedkoopste uur", "range": "Bereik" }, + "priceZoom": "zoom in", + "showLine": "regel tonen", "solar": { "dayAfterTomorrow": "Overmorgen", "partly": "gedeeltelijk", @@ -935,10 +987,11 @@ "tomorrow": "Morgen" }, "solarAdjust": "Pas de zonne-energie voorspelling aan op basis van echte productiegegevens{percent}.", + "solarAdjustShort": "aanpassen", "type": { - "co2": "CO₂", - "price": "Prijs", - "solar": "Zonne-energie" + "co2": "CO₂ Uitstoot", + "price": "Net-Prijs", + "solar": "Zonne-energie opbrengst" } }, "general": { @@ -1152,6 +1205,7 @@ "once": "Boost geactiveerd voor deze laadsessie.", "stateActive": "Batterijboost actief", "stateBelowLimit": "Batterij te laag voor boost", + "stateHold": "Batterij vergrendeld", "stateReady": "Batterijboost klaar" }, "batteryUsage": "Thuisbatterij", @@ -1261,6 +1315,7 @@ "batteryBoostBelowLimit": "Batterij te laag voor boost.", "batteryBoostDisabled": "Batterijboost uitgeschakeld.", "batteryBoostEnabled": "Boost de batterij tot {limit}.", + "batteryBoostHold": "Batterij vergrendeld. Boost niet beschikbaar.", "charging": "Opladen…", "cheapEnergyCharging": "Goedkope energie beschikbaar.", "cheapEnergyNextStart": "Goedkope energie in {duration}.", @@ -1500,5 +1555,13 @@ "lineErrorLink": "lijn {0}", "restartButton": "Herstart", "title": "Opstart fout" + }, + "tabBar": { + "battery": "Batterij", + "charge": "Laden", + "comingSoon": "Deze pagina is in ontwikkeling.", + "forecast": "Voorspelling", + "more": "Meer", + "sessions": "Sessies" } } diff --git a/i18n/no.json b/i18n/no.json index 41f73b942..92658c4e5 100644 --- a/i18n/no.json +++ b/i18n/no.json @@ -28,7 +28,7 @@ }, "capacity": "{energy} av {total}", "control": "Batterikontroll", - "discharge": "Forhindre utladning i hurtigmodus og planlagt lading", + "discharge": "Forhindre utladning i hurtigmodus og planlagt lading.", "disclaimerHint": "Merk:", "disclaimerText": "Kun relevant i PV-modus. Ladeadferden justeres deretter.", "gridChargeTab": "Nettlading", diff --git a/i18n/pl.json b/i18n/pl.json index ad3e1e7f9..fc1db3a70 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -40,6 +40,7 @@ "legendTopName": "Ładowanie pojazdu z wykorzystaniem domowego magazynu energii", "legendTopSubline": "gdy poziom naładowania domowego magazynu energii jest wyższy niż {soc}.", "modalTitle": "Domowy magazyn energii", + "noBattery": "Brak skonfigurowanego magazynu energii.", "usageTab": "Użycie magazynu" }, "config": { @@ -446,7 +447,7 @@ "guest": { "messageDefault": "Nieznany pojazd, połączony gość?", "title": "Kiedy podłączy się nieznany samochód", - "titleDefault": "Nieznany samochód" + "titleDefault": "Nieznany pojazd" }, "planoverrun": { "messageDefault": "{vehicleTitle}: Plan zostanie przekroczony.", @@ -861,6 +862,7 @@ "priceTitle": "Cena energii", "referenceGrid": "sieć", "referenceLabel": "Dane referencyjne:", + "sessionInfo": "Oparte na zakończonych sesjach ładowania.", "tabTitle": "Moje dane" }, "sponsor": { diff --git a/i18n/pt.json b/i18n/pt.json index fc18636f4..7f654842a 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -40,6 +40,7 @@ "legendTopName": "Carregamento de veículos com a bateria", "legendTopSubline": "quando a bateria da casa está acima de {soc}.", "modalTitle": "Bateria da Casa", + "noBattery": "Não há baterias configuradas.", "usageTab": "Gestão da bateria" }, "config": { @@ -130,8 +131,9 @@ "currency": "Moeda", "current": "Corrente", "currentRange": "Corrente", + "curtailed": "Limitação da injeção na rede", "detected": "Detectado", - "dimmed": "Escurecido", + "dimmed": "Consumo limitado", "enabled": "Ativado", "energy": "Energia", "events": "Eventos", @@ -600,6 +602,11 @@ "url": "URL do Servidor", "urlHelp": "Copie este URL para a configuração do seu carregador. Consulte o manual do fabricante para obter mais detalhes. O carregador deve anexar automaticamente o seu identificador único (ID da estação) ao URL. Em casos raros, pode ser necessário especificar manualmente o identificador. Exemplo: `{url}`" }, + "optimizer": { + "description": "Analisa as previsões de energia solar, os preços da eletricidade e os seus padrões de consumo para otimizar a estratégia de utilização da bateria e de carregamento. Os dados são enviados para o serviço de otimização evcc para serem processados.", + "enable": "Ativar o Otimizador", + "title": "Otimizador" + }, "options": { "boolean": { "no": "não", @@ -642,11 +649,13 @@ "cardTitle": "Sunny Home Manager", "description": "evcc tem integração com SMA Sunny Manager (SHM) via protocolo SEMP. Se estiver a ser executado na mesma rede, depois de aceder à sua conta no Sunny Portal, deverá aparecer automaticamente a opção de adicionar todos os Carregadores configurados no evcc como novos clientes. Tudo deverá ficar pronto a usar imediatamente sem necessidade de quaisquer ajustes.", "descriptionDeviceId": "String 12 caracteres HEX. Prefixo para todos os dispositivos (ponto de carregamento, ..).", + "descriptionDeviceSerial": "Cadeia HEX de 12 caracteres. Número de série padrão para todos os dispositivos (ponto de carregamento, etc.). Por predefinição, o evcc obtém este valor a partir do endereço MAC do host.", "descriptionIdPattern": "Padrão de identidade", "descriptionIds": "No Sunny Portal cada dispositivo necessita duma identidade única. O evcc gera uma identidade única com base no seu hardware. Se migrar o evcc para outro hardware estas identidades podem mudar. Se quiser manter o histórico pode substituir as identidades geradas aqui. Abra o SEMP URL(/semp) para ver as identidade atuais.", "descriptionSempUrl": "SEMP URL", "descriptionVendorId": "string 8 caratecteres HEX. Prefixo geral de todas as entidades. O evcc usa o seu próprio vendor ID interno Por defeito.", "labelDeviceId": "Device ID", + "labelDeviceSerial": "Número de série do equipamento", "labelVendorId": "Vendor ID", "title": "SMA Sunny Home Manager" }, @@ -850,6 +859,15 @@ "configurePriceCo2": "Saiba como configurar os dados de preço e CO₂.", "footerLong": "{percent} energia solar", "footerShort": "{percent} Sol", + "indicator": { + "co2": "Emissões de CO₂", + "co2saved": "CO₂ poupado", + "none": "nenhum", + "price": "preço da energia", + "savings": "poupança", + "solar": "energia solar" + }, + "indicatorLabel": "Informações do cabeçalho", "modalTitle": "Dados da energia carregada", "moneySaved": "{value} poupado", "percentGrid": "{grid} kWh Rede", @@ -861,10 +879,11 @@ "thisYear": "este ano", "total": "total" }, - "periodLabel": "Período:", + "periodLabel": "Período", "priceTitle": "Preço da energia", "referenceGrid": "Rede", - "referenceLabel": "Dados de referência:", + "referenceLabel": "Dados de referência", + "sessionInfo": "Com base nas sessões de carregamento concluídas.", "tabTitle": "Os meus dados" }, "sponsor": { @@ -890,15 +909,26 @@ }, "version": { "availableLong": "nova versão disponível", + "community": "comunidade evcc", + "labelRelease": "Lançamento", + "labelVersion": "Versão", + "labelWebsite": "Website", + "latestVersion": "versão mais recente", + "madeByCommunity": "Realizado por {0}.", "modalCancel": "Cancelar", "modalDownload": "Download", "modalInstalledVersion": "Versão instalada", + "modalLatest": "Está a utilizar a versão mais recente.", + "modalNextRelease": "O que está previsto para a próxima versão", "modalNoReleaseNotes": "Sem notas da versão disponível. Mais informações sobre a nova versão:", "modalTitle": "Nova versão disponível", "modalUpdate": "Instalar", "modalUpdateNow": "Instalar agora", "modalUpdateStarted": "Iniciando a nova versão do evcc…", - "modalUpdateStatusStart": "Instalação iniciada:" + "modalUpdateStatusStart": "Instalação iniciada:", + "modalViewOnGitHub": "Ver no GitHub", + "openSource": "código aberto", + "poweredByOpenSource": "Desenvolvido por {0}." } }, "forecast": { @@ -908,6 +938,12 @@ "lowestHour": "Horário mais \"Verde\"", "range": "Intervalo" }, + "empty": { + "co2": "Veja quando a energia da rede na sua região é verde. Os planos de carregamento serão otimizados para reduzir as emissões e calcular a redução de CO₂.", + "price": "Configure a sua tarifa de eletricidade dinâmica para otimizar automaticamente os planos de carregamento e calcular as poupanças.", + "setup": "Definir tarifas e previsões", + "solar": "Veja a produção solar prevista para hoje e para os próximos dias. Esta informação será também utilizada para a otimização automática do carregamento no futuro." + }, "modalTitle": "Previsões", "price": { "average": "Média", @@ -915,6 +951,7 @@ "lowestHour": "Horário mais barato", "range": "Intervalo" }, + "priceZoom": "ampliar", "solar": { "dayAfterTomorrow": "Depois de amanhã", "partly": "parcialmente", @@ -923,10 +960,11 @@ "tomorrow": "Amanhã" }, "solarAdjust": "Ajustar a previsão solar com base em dados reais de produção{percent}.", + "solarAdjustShort": "ajuste com dados reais", "type": { - "co2": "CO₂", - "price": "Preço", - "solar": "Solar" + "co2": "Emissões de CO₂", + "price": "Preço da rede", + "solar": "Produção solar" } }, "general": { @@ -1139,6 +1177,7 @@ "once": "Boost ativo para esta sessão de carga.", "stateActive": "Boost de bateria ativo", "stateBelowLimit": "Bateria com carga insuficiente para o boost", + "stateHold": "Bateria bloqueada", "stateReady": "Boost da bateria pronto" }, "batteryUsage": "Bateria de casa", @@ -1248,6 +1287,7 @@ "batteryBoostBelowLimit": "Bateria com carga insuficiente para boost.", "batteryBoostDisabled": "Boost da bateria desativado.", "batteryBoostEnabled": "Boost até a bateria atingir {limit}.", + "batteryBoostHold": "Bateria bloqueada. Função Boost não disponível.", "charging": "A carregar…", "cheapEnergyCharging": "Energia barata disponível.", "cheapEnergyNextStart": "Energia barata em {duration}.", @@ -1487,5 +1527,13 @@ "lineErrorLink": "linha {0}", "restartButton": "Reiniciar", "title": "Erro ao iniciar" + }, + "tabBar": { + "battery": "Bateria", + "charge": "Carga", + "comingSoon": "Esta página está em construção.", + "forecast": "Previsão", + "more": "Mais", + "sessions": "Sessões" } } diff --git a/i18n/sv.json b/i18n/sv.json index 628d98a07..3ed0c7dc9 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -113,6 +113,7 @@ "title": "Valuta" }, "deviceValue": { + "activeClients": "Aktiva klienter", "amount": "Belopp", "broker": "Broker", "bucket": "Bucket", @@ -126,6 +127,7 @@ "chargedEnergy": "Laddad", "co2": "Elnät CO₂", "configured": "Konfigurerad", + "connected": "Ansluten", "connections": "Anslutningar", "controllable": "Kontrollerbar", "currency": "Valuta", @@ -144,6 +146,7 @@ "hemsActiveLimit": "Aktiv begränsning", "hemsType": "Kommunikation", "identifier": "RFID-identifierare", + "loginBlocked": "Inloggningsgräns nådd", "max": "max", "messengers": "Tjänster", "no": "nej", @@ -339,7 +342,7 @@ "defaultsHintLink": "Ändra inställningar", "delete": "Radera", "electricalSubtitle": "Om du är osäker, kontakta elektriker.", - "electricalTitle": "Elektrisk", + "electricalTitle": "Elektricitet", "energyMeterHelp": "Extern mätare (Om laddaren inte har en integrerad mätare).", "energyMeterLabel": "Energimätare", "estimateLabel": "Interpolera laddningsnivå mellan API uppdateringar", @@ -602,6 +605,12 @@ "url": "Server URL", "urlHelp": "Kopiera denna URL till laddarens konfiguration. Se tillverkarens manual för mer information. Laddaren ska automatiskt lägga till sin unika identifierare (stations-ID) till URL:en. I sällsynta fall kan du behöva ange identifieraren manuellt. Exempel: `{url}`Kopiera denna URL till laddarens konfiguration. Se tillverkarens manual för mer information. Laddaren ska automatiskt lägga till sin unika identifierare (stations-ID) till URL:en. I sällsynta fall kan du behöva ange identifieraren manuellt. Exempel: `{url}`" }, + "optimizer": { + "description": "Analyserar solprognoser, elpriser och din typiska förbrukning för att optimera batteri- och laddningsstrategi. Data överförs till evcc optimeringstjänst för beräkning.", + "enable": "Aktivera optimerare", + "info": "Det kan ta några minuter innan optimeringsmenyn blir synlig. För nya installationer kan det ta upp till 24 timmar innan evcc har samlat in tillräckligt med data.", + "title": "Optimerare" + }, "options": { "boolean": { "no": "nej", @@ -629,6 +638,38 @@ "titleAdd": "Lägg till solcellsmätare", "titleEdit": "Ändra solcellsmätare" }, + "remote": { + "active": "Aktiv", + "addClient": "Lägg till klient", + "addClientDescription": "Inloggningsuppgifter lagras och verifieras endast lokalt på din evcc-instans.", + "addClientTitle": "Lägg till fjärklient", + "clientCreated": "Klient skapad", + "clients": "Klienter", + "confirmDelete": "Ta bort klient?", + "connected": "Ansluten", + "createClient": "Skapa klient", + "description": "Nå din evcc-installation från var som helst med evcc-mobilappen. Du behöver varken portvidarebefordran eller VPN.", + "deviceName": "Enhetsnamn", + "disconnected": "Frånkopplad", + "done": "Klar", + "enableLabel": "Aktivera fjärråtkomst", + "expiration": "Upphör", + "expirationNone": "Aldrig", + "expired": "utgången", + "expiresIn": "upphör {time}", + "lastActive": "activ {time}", + "loginBlocked": "Fjärrinloggningar blockeras i en minut efter för många misslyckade inloggningsförsök.", + "manualLogin": "Eller logga in manuellt på {url} i din webbläsare med dessa inloggningsuppgifter:", + "noClients": "Det finns inga klienter än. Ingen kan ansluta just nu.", + "password": "Lösenord", + "passwordOnce": "Det här lösenordet visas bara en gång. Skanna QR-koden eller kopiera den nu. Du kommer inte att kunna se den igen.", + "qrInstall": "Installera evcc-appen för {ios} eller {android}.", + "qrScan": "Skanna koden med din telefons kamera för att ansluta. Klicka på den om du redan använder din telefon.", + "removeClient": "Ta bort klient", + "title": "Fjärråtkomst", + "url": "Publik URL", + "username": "Användarnamn" + }, "section": { "additionalMeter": "Ytterligare mätare", "general": "Allmänna inställningar", @@ -875,10 +916,10 @@ "thisYear": "detta år", "total": "totalt" }, - "periodLabel": "Period:", + "periodLabel": "Period", "priceTitle": "Energipris", "referenceGrid": "nät", - "referenceLabel": "Referensdata:", + "referenceLabel": "Referensdata", "sessionInfo": "Baserat på avslutade laddsessioner.", "tabTitle": "Mina uppgifter" }, @@ -922,7 +963,9 @@ "modalUpdateNow": "Installera nu", "modalUpdateStarted": "Starta den nya versionen av evcc…", "modalUpdateStatusStart": "Installationen har börjat:", - "openSource": "open source" + "modalViewOnGitHub": "Se på GitHub", + "openSource": "open source", + "poweredByOpenSource": "Powered by {0}." } }, "forecast": { @@ -934,8 +977,12 @@ }, "empty": { "co2": "Se när elen i ditt område är ren. Laddningsscheman optimeras för låga utsläpp och CO₂-besparingar beräknas.", - "price": "Konfigurera rörligt elpris för att automatiskt optimera laddningsscheman och beräkna besparingar." + "price": "Konfigurera rörligt elpris för att automatiskt optimera laddningsscheman och beräkna besparingar.", + "setup": "Lägg till tariffer och prognoser", + "solar": "Förväntat solproduktion idag och imorgon. Kommer i framtiden även att användas för automatisk laddningsoptimering.", + "title": "Tariffer & prognoser" }, + "hideLine": "göm linje", "modalTitle": "Prognos", "price": { "average": "Genomsnitt", @@ -943,18 +990,22 @@ "lowestHour": "Billigaste timmen", "range": "Intervall" }, + "priceZoom": "zooma in", + "showLine": "visa linje", "solar": { "dayAfterTomorrow": "I övermorgon", "partly": "delvis", "remaining": "återstående", "today": "Idag", - "tomorrow": "I morgon" + "tomorrow": "i morgon" }, "solarAdjust": "Justera solprognosen baserat på verkliga produktionsvärden{percent}.", + "solarAdjustMedium": "Justera med verkliga data", + "solarAdjustShort": "justera", "type": { - "co2": "CO₂", - "price": "Pris", - "solar": "Sol" + "co2": "CO₂-utsläpp", + "price": "Nätpris", + "solar": "Solenergi produktion" } }, "general": { @@ -1168,6 +1219,7 @@ "once": "Boost är aktiverat för denna laddning.", "stateActive": "Batteriboost aktiv", "stateBelowLimit": "Batterinivå för låg för boost", + "stateHold": "Batteri låst", "stateReady": "Beredd för batteriboost" }, "batteryUsage": "Hembatteri", @@ -1277,6 +1329,7 @@ "batteryBoostBelowLimit": "Batterinivå för låg för boost.", "batteryBoostDisabled": "Batteriboost avaktiverad.", "batteryBoostEnabled": "Boost tills batteriet är på {limit}.", + "batteryBoostHold": "Batteri låst. Boost ej tillgänglig.", "charging": "Laddar…", "cheapEnergyCharging": "Billig energi är tillgänglig.", "cheapEnergyNextStart": "Billig energi om {duration}.", @@ -1516,5 +1569,13 @@ "lineErrorLink": "linje {0}", "restartButton": "Starta om", "title": "Startfel" + }, + "tabBar": { + "battery": "Batteri", + "charge": "Ladda", + "comingSoon": "Sida under uppbyggnad.", + "forecast": "Prognos", + "more": "Mer", + "sessions": "Laddningar" } } diff --git a/i18n/tr.json b/i18n/tr.json index a9bbc9d00..52f20a9c8 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -40,6 +40,7 @@ "legendTopName": "Batarya destekli araç doldurma", "legendTopSubline": "ev bataryası {soc} seviyesinin üzerindeyse.", "modalTitle": "Ev Bataryası", + "noBattery": "Batarya yapılandırılmamış.", "usageTab": "Batarya kullanımı" }, "config": { @@ -601,6 +602,11 @@ "url": "Sunucu URL'si", "urlHelp": "Bu URL'yi doldrma cihazının yapılandırmasına kopyala. Ayrıntılar için üreticinin kılavuzuna bak. Doldurma cihazının, URL'ye benzersiz tanımlayıcısını (istasyon kimliği) otomatik olarak eklemesi beklenir. Nadir durumlarda, tanımlayıcıyı elle belirtmen gerekebilir. Örnek: `{url}`" }, + "optimizer": { + "description": "Güneş enerjisi tahminlerini, elektrik fiyatlarını ve tüketim alışkanlıklarınızı analiz ederek batarya ve doldurma stratejisini iyileştirir. Veriler, işlenmek üzere evcc iyileştirme hizmetine gönderilir.", + "enable": "İyileştiriciyi Etkinleştir", + "title": "İyileştirici" + }, "options": { "boolean": { "no": "“hayır”", @@ -853,6 +859,15 @@ "configurePriceCo2": "Fiyat ve CO₂ emisyonlarını yapılandır.", "footerLong": "{percent} güneş enerjisi", "footerShort": "{percent} güneş", + "indicator": { + "co2": "CO₂ emisyonları", + "co2saved": "Azaltılan CO₂ miktarı", + "none": "yok", + "price": "enerji fiyatı", + "savings": "tasarruf", + "solar": "güneş enerjisi" + }, + "indicatorLabel": "Başlık bilgisi", "modalTitle": "Doldurma Enerjisi Genel Bakışı", "moneySaved": "{value} tasarruf edildi", "percentGrid": "{grid} kWh şebekeden", @@ -864,10 +879,11 @@ "thisYear": "“bu yıl”", "total": "tüm zaman" }, - "periodLabel": "Zaman aralığı:", + "periodLabel": "Zaman aralığı", "priceTitle": "Enerji fiyatı", "referenceGrid": "şebeke", - "referenceLabel": "Referans verileri:", + "referenceLabel": "Kaynak verileri", + "sessionInfo": "Tamamlanmış doldurma işlemlerine göre.", "tabTitle": "Verilerim" }, "sponsor": { @@ -893,15 +909,26 @@ }, "version": { "availableLong": "yeni sürüm mevcut", + "community": "evcc topluluğu", + "labelRelease": "Yayın", + "labelVersion": "Sürüm", + "labelWebsite": "Web sitesi", + "latestVersion": "en son sürüm", + "madeByCommunity": "{0} tarafından hazırlandı.", "modalCancel": "İptal", "modalDownload": "İndir", "modalInstalledVersion": "Kurulu sürüm", + "modalLatest": "En son sürümü kullanıyorsun.", + "modalNextRelease": "Bir sonraki yayında neler var", "modalNoReleaseNotes": "Sürüm bilgileri mevcut değil. Yeni sürüm hakkında bilgiler:", "modalTitle": "Yeni sürüm mevcut", "modalUpdate": "Kur", "modalUpdateNow": "Şimdi kur", "modalUpdateStarted": "evcc'nin yeni sürümü başlatılıyor…", - "modalUpdateStatusStart": "Kurulum başladı:" + "modalUpdateStatusStart": "Kurulum başladı:", + "modalViewOnGitHub": "GitHub'da görüntüle", + "openSource": "açık kaynak", + "poweredByOpenSource": "{0} tarafından desteklenmekte." } }, "forecast": { @@ -911,6 +938,12 @@ "lowestHour": "“En temiz saat”", "range": "“Aralık”" }, + "empty": { + "co2": "Bölgendeki şebeke enerjisinin ne zaman temiz olduğunu gör. Doldurma planları, düşük emisyonlara göre iyileştirilecek ve CO₂ tasarrufu hesaplanacak.", + "price": "Dinamik elektrik tarifeni yapılandırarak doldurma planlarını otomatik olarak iyileştir ve tasarrufları hesapla.", + "setup": "Fiyatlar ve tahminler oluştur", + "solar": "Bugün ve önümüzdeki günler için beklenen güneş enerjisi üretimini gör. Bu veriler gelecekte otomatik doldurma iyileştirmesi için de kullanılacak." + }, "modalTitle": "“Öngörü”", "price": { "average": "“Ortalama”", @@ -918,6 +951,7 @@ "lowestHour": "“En ucuz saat”", "range": "“Aralık”" }, + "priceZoom": "büyüt", "solar": { "dayAfterTomorrow": "“Yarından sonra”", "partly": "“kısmen”", @@ -926,10 +960,11 @@ "tomorrow": "“Yarın”" }, "solarAdjust": "Güneş enerjisi tahminlerini gerçek üretim verilerine göre ayarla{percent}.", + "solarAdjustShort": "gerçek verilere göre ayarlama", "type": { - "co2": "CO₂", - "price": "“Fiyat”", - "solar": "“Güneş”" + "co2": "CO₂ salımları", + "price": "Şebeke Fiyatı", + "solar": "Güneş Enerjisi Üretimi" } }, "general": { @@ -1142,6 +1177,7 @@ "once": "Bu doldurma oturumu için takviye etkin.", "stateActive": "Batarya desteği etkin", "stateBelowLimit": "Desdek için batarya çok düşük", + "stateHold": "Batarya kilitli", "stateReady": "Batarya desteği hazır" }, "batteryUsage": "“Ev Bataryası”", @@ -1251,6 +1287,7 @@ "batteryBoostBelowLimit": "Desdek için batarya çok düşük.", "batteryBoostDisabled": "Batarya desteği devre dışı bırakıldı.", "batteryBoostEnabled": "Batarya {limit} seviyesine kadar destek.", + "batteryBoostHold": "Batarya kilitli. Hızlandırma kullanılamıyor.", "charging": "doluyor…", "cheapEnergyCharging": "Ucuz enerji mevcut.", "cheapEnergyNextStart": "{duration} içinde ucuz enerji.", @@ -1490,5 +1527,13 @@ "lineErrorLink": "{0}. satır", "restartButton": "Yeniden Başlat", "title": "Başlama Hatası" + }, + "tabBar": { + "battery": "Batarya", + "charge": "Doldurma", + "comingSoon": "Bu sayfa yapım aşamasında.", + "forecast": "Tahmin", + "more": "Daha fazla", + "sessions": "Oturumlar" } } From 2f106579e78e38c2f88e3c031c50dc942255bfaa Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 19 Apr 2026 13:55:54 +0200 Subject: [PATCH 0351/1580] Add Fritz smarthome REST API support (FritzOS 8.2+) (#29013) --- charger/fritzdect.go | 65 ++--- .../fritzdect.go => fritz/aha/aha.go} | 108 +++----- meter/fritz/aha/types.go | 15 + meter/fritz/api.go | 16 ++ meter/fritz/smarthome/smarthome.go | 258 ++++++++++++++++++ meter/fritz/smarthome/types.go | 58 ++++ meter/fritz/types.go | 71 +++++ meter/fritzdect.go | 14 +- templates/definition/charger/fritzdect.yaml | 10 + templates/definition/meter/fritzdect.yaml | 10 + templates/definition/meter/fritzgrid.yaml | 1 + 11 files changed, 516 insertions(+), 110 deletions(-) rename meter/{fritzdect/fritzdect.go => fritz/aha/aha.go} (54%) create mode 100644 meter/fritz/aha/types.go create mode 100644 meter/fritz/api.go create mode 100644 meter/fritz/smarthome/smarthome.go create mode 100644 meter/fritz/smarthome/types.go create mode 100644 meter/fritz/types.go diff --git a/charger/fritzdect.go b/charger/fritzdect.go index 1b68bbc2c..7bef93c02 100644 --- a/charger/fritzdect.go +++ b/charger/fritzdect.go @@ -1,20 +1,20 @@ package charger import ( - "errors" - "strconv" - "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/meter/fritzdect" + "github.com/evcc-io/evcc/meter/fritz" + "github.com/evcc-io/evcc/meter/fritz/aha" + "github.com/evcc-io/evcc/meter/fritz/smarthome" "github.com/evcc-io/evcc/util" ) // FRITZ! FritzBox AHA interface specifications: // https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AHA-HTTP-Interface.pdf +// https://fritz.support/resources/SmarthomeRestApiFRITZOS82.html (REST API for FritzOS 8.2+) // FritzDECT charger implementation type FritzDECT struct { - conn *fritzdect.Connection + conn fritz.Switch *switchSocket } @@ -25,9 +25,9 @@ func init() { // NewFritzDECTFromConfig creates a fritzdect charger from generic config func NewFritzDECTFromConfig(other map[string]any) (api.Charger, error) { var cc struct { - embed `mapstructure:",squash"` - fritzdect.Settings `mapstructure:",squash"` - StandbyPower float64 + embed `mapstructure:",squash"` + fritz.Settings `mapstructure:",squash"` + StandbyPower float64 } if err := util.DecodeOther(other, &cc); err != nil { @@ -38,12 +38,20 @@ func NewFritzDECTFromConfig(other map[string]any) (api.Charger, error) { return nil, api.ErrMissingCredentials } - return NewFritzDECT(cc.embed, cc.URI, cc.AIN, cc.User, cc.Password, cc.StandbyPower) + return NewFritzDECT(cc.embed, cc.URI, cc.AIN, cc.User, cc.Password, cc.StandbyPower, cc.Firmware82) } // NewFritzDECT creates a new connection with standbypower for charger -func NewFritzDECT(embed embed, uri, ain, user, password string, standbypower float64) (*FritzDECT, error) { - conn, err := fritzdect.NewConnection(uri, ain, user, password) +func NewFritzDECT(embed embed, uri, ain, user, password string, standbypower float64, firmware82 bool) (*FritzDECT, error) { + var conn fritz.Switch + var err error + + // Use new REST API if firmware82 is set, otherwise use legacy LUA API + if firmware82 { + conn, err = smarthome.NewConnection(uri, ain, user, password) + } else { + conn, err = aha.NewConnection(uri, ain, user, password) + } if err != nil { return nil, err } @@ -59,18 +67,13 @@ func NewFritzDECT(embed embed, uri, ain, user, password string, standbypower flo // Status implements the api.Charger interface func (c *FritzDECT) Status() (api.ChargeStatus, error) { - resp, err := c.conn.ExecCmd("getswitchpresent") + present, err := c.conn.SwitchPresent() if err != nil { return api.StatusNone, err } - present, err := strconv.ParseBool(resp) - if err == nil && !present { - err = api.ErrNotAvailable - } - - if err != nil { - return api.StatusNone, err + if !present { + return api.StatusNone, api.ErrNotAvailable } return c.switchSocket.Status() @@ -78,33 +81,15 @@ func (c *FritzDECT) Status() (api.ChargeStatus, error) { // Enabled implements the api.Charger interface func (c *FritzDECT) Enabled() (bool, error) { - resp, err := c.conn.ExecCmd("getswitchstate") - if err != nil { - return false, err - } - - return strconv.ParseBool(resp) + return c.conn.SwitchState() } // Enable implements the api.Charger interface func (c *FritzDECT) Enable(enable bool) error { - cmd := "setswitchoff" if enable { - cmd = "setswitchon" + return c.conn.SwitchOn() } - - // on 0/1 - DECT Switch state off/on (empty if unknown or error) - resp, err := c.conn.ExecCmd(cmd) - if err != nil { - return err - } - - on, err := strconv.ParseBool(resp) - if err == nil && enable != on { - err = errors.New("switch failed") - } - - return err + return c.conn.SwitchOff() } var _ api.MeterEnergy = (*FritzDECT)(nil) diff --git a/meter/fritzdect/fritzdect.go b/meter/fritz/aha/aha.go similarity index 54% rename from meter/fritzdect/fritzdect.go rename to meter/fritz/aha/aha.go index ef28e850d..e98542f47 100644 --- a/meter/fritzdect/fritzdect.go +++ b/meter/fritz/aha/aha.go @@ -1,9 +1,6 @@ -package fritzdect +package aha import ( - "crypto/md5" - "encoding/hex" - "encoding/xml" "errors" "fmt" "net/url" @@ -12,44 +9,24 @@ import ( "time" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/meter/fritz" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/transport" - "golang.org/x/text/encoding/unicode" ) // FRITZ! FritzBox AHA interface and authentication specifications: // https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AHA-HTTP-Interface.pdf // https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AVM_Technical_Note_-_Session_ID.pdf -// FritzDECT settings -type Settings struct { - URI, AIN, User, Password string -} - // FritzDECT connection type Connection struct { *request.Helper - *Settings + *fritz.Settings SID string updated time.Time } -// https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AVM_Technical_Note_-_Session_ID_english_2021-05-03.pdf -const sessionTimeout = 15 * time.Minute - -// Devicestats structures getbasicdevicesstats command response (AHA-HTTP-Interface) -type Devicestats struct { - XMLName xml.Name `xml:"devicestats"` - Energy Energy `xml:"energy"` -} - -// Energy structures getbasicdevicesstats command energy response (AHA-HTTP-Interface) -type Energy struct { - XMLName xml.Name `xml:"energy"` - Values []string `xml:"stats"` -} - // NewConnection creates FritzDECT connection func NewConnection(uri, ain, user, password string) (*Connection, error) { if uri == "" { @@ -60,7 +37,7 @@ func NewConnection(uri, ain, user, password string) (*Connection, error) { return nil, errors.New("missing ain") } - settings := &Settings{ + settings := &fritz.Settings{ URI: strings.TrimRight(uri, "/"), AIN: ain, User: user, @@ -82,11 +59,13 @@ func NewConnection(uri, ain, user, password string) (*Connection, error) { // ExecCmd execautes an FritzDECT AHA-HTTP-Interface command func (c *Connection) ExecCmd(function string) (string, error) { // refresh Fritzbox session id - if time.Since(c.updated) >= sessionTimeout { - if err := c.getSessionID(); err != nil { + if time.Since(c.updated) >= fritz.SessionTimeout { + sid, err := c.GetSessionID(c.Helper) + if err != nil { return "", err } // update session timestamp + c.SID = sid c.updated = time.Now() } @@ -123,7 +102,7 @@ func (c *Connection) CurrentPower() (float64, error) { var _ api.MeterEnergy = (*Connection)(nil) -// CurrentPower implements the api.MeterEnergy interface +// TotalEnergy implements the api.MeterEnergy interface func (c *Connection) TotalEnergy() (float64, error) { // Energy value in Wh (total switch energy, refresh approximately every 2 minutes) resp, err := c.ExecCmd("getswitchenergy") @@ -136,53 +115,48 @@ func (c *Connection) TotalEnergy() (float64, error) { return energy / 1000, err // Wh ==> KWh } -// Fritzbox helpers (credits to https://github.com/rsdk/ahago) +// SwitchPresent checks if the device is connected +func (c *Connection) SwitchPresent() (bool, error) { + resp, err := c.ExecCmd("getswitchpresent") + if err != nil { + return false, err + } + return strconv.ParseBool(resp) +} -// getSessionID fetches a session-id based on the username and password in the connection struct -func (c *Connection) getSessionID() error { - uri := fmt.Sprintf("%s/login_sid.lua", c.URI) - body, err := c.GetBody(uri) +// SwitchState returns the current switch state +func (c *Connection) SwitchState() (bool, error) { + resp, err := c.ExecCmd("getswitchstate") + if err != nil { + return false, err + } + return strconv.ParseBool(resp) +} + +// SwitchOn turns the switch on +func (c *Connection) SwitchOn() error { + resp, err := c.ExecCmd("setswitchon") if err != nil { return err } - var v struct { - SID string - Challenge string - BlockTime string + on, err := strconv.ParseBool(resp) + if err == nil && !on { + err = errors.New("switch on failed") } - - if err = xml.Unmarshal(body, &v); err == nil && v.SID == "0000000000000000" { - var challresp string - if challresp, err = createChallengeResponse(v.Challenge, c.Password); err == nil { - params := url.Values{ - "username": {c.User}, - "response": {challresp}, - } - - if body, err = c.GetBody(uri + "?" + params.Encode()); err == nil { - err = xml.Unmarshal(body, &v) - if v.SID == "0000000000000000" { - return errors.New("invalid user or password") - } - c.SID = v.SID - } - } - } - return err } -// createChallengeResponse creates the Fritzbox challenge response string -func createChallengeResponse(challenge, pass string) (string, error) { - encoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder() - utf16le, err := encoder.String(challenge + "-" + pass) +// SwitchOff turns the switch off +func (c *Connection) SwitchOff() error { + resp, err := c.ExecCmd("setswitchoff") if err != nil { - return "", err + return err } - hash := md5.Sum([]byte(utf16le)) - md5hash := hex.EncodeToString(hash[:]) - - return challenge + "-" + md5hash, nil + off, err := strconv.ParseBool(resp) + if err == nil && off { + err = errors.New("switch off failed") + } + return err } diff --git a/meter/fritz/aha/types.go b/meter/fritz/aha/types.go new file mode 100644 index 000000000..0af22c3d6 --- /dev/null +++ b/meter/fritz/aha/types.go @@ -0,0 +1,15 @@ +package aha + +import "encoding/xml" + +// Devicestats structures getbasicdevicesstats command response (AHA-HTTP-Interface) +type Devicestats struct { + XMLName xml.Name `xml:"devicestats"` + Energy Energy `xml:"energy"` +} + +// Energy structures getbasicdevicesstats command energy response (AHA-HTTP-Interface) +type Energy struct { + XMLName xml.Name `xml:"energy"` + Values []string `xml:"stats"` +} diff --git a/meter/fritz/api.go b/meter/fritz/api.go new file mode 100644 index 000000000..371f241ac --- /dev/null +++ b/meter/fritz/api.go @@ -0,0 +1,16 @@ +package fritz + +// Meter defines the interface for Fritz connections (both legacy LUA and REST) +type Meter interface { + CurrentPower() (float64, error) + TotalEnergy() (float64, error) +} + +// Switch extends Meter with switch control capabilities +type Switch interface { + Meter + SwitchPresent() (bool, error) + SwitchState() (bool, error) + SwitchOn() error + SwitchOff() error +} diff --git a/meter/fritz/smarthome/smarthome.go b/meter/fritz/smarthome/smarthome.go new file mode 100644 index 000000000..624af3daf --- /dev/null +++ b/meter/fritz/smarthome/smarthome.go @@ -0,0 +1,258 @@ +package smarthome + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/meter/fritz" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" +) + +// FRITZ! Smarthome REST API (FritzOS 8.2+) +// https://fritz.support/resources/SmarthomeRestApiFRITZOS82.html + +// Connection implements the new REST API for Fritz smarthome devices +type Connection struct { + *request.Helper + *fritz.Settings + SID string + UID string // device UID (AIN with space) + updated time.Time + unitG util.Cacheable[Unit] +} + +// NewConnection creates a new REST API connection +func NewConnection(uri, ain, user, password string) (*Connection, error) { + if uri == "" { + uri = "https://fritz.box" + } + + if ain == "" { + return nil, errors.New("missing ain") + } + + settings := &fritz.Settings{ + URI: strings.TrimRight(uri, "/"), + AIN: ain, + User: user, + Password: password, + } + + log := util.NewLogger("fritzsmarthome").Redact(password) + + conn := &Connection{ + Helper: request.NewHelper(log), + Settings: settings, + UID: ainToUID(ain), + } + + conn.Client.Transport = request.NewTripper(log, transport.Insecure()) + + // cache unit data for 2 seconds to avoid excessive API calls + conn.unitG = util.ResettableCached(func() (Unit, error) { + return conn.getUnit() + }, 2*time.Second) + + return conn, nil +} + +// ainToUID converts AIN format to UID format by adding space +// AIN: "116300015376" -> UID: "11630 0015376" +func ainToUID(ain string) string { + // Remove any existing spaces first + ain = strings.ReplaceAll(ain, " ", "") + if len(ain) >= 5 { + return ain[:5] + " " + ain[5:] + } + return ain +} + +// getUnit fetches unit data from REST API +func (c *Connection) getUnit() (Unit, error) { + if err := c.refreshSession(); err != nil { + return Unit{}, err + } + + // Try to get the specific unit first + uri := fmt.Sprintf("%s/api/v0/smarthome/overview/units/%s", c.URI, url.PathEscape(c.UID)) + + req, _ := request.New("GET", uri, nil, map[string]string{ + "Authorization": "AVM-SID " + c.SID, + }, request.AcceptJSON) + + var unit Unit + if err := c.DoJSON(req, &unit); err != nil { + // Fall back to getting all units and finding ours + return c.findUnit() + } + + if !unit.IsConnected { + return unit, api.ErrNotAvailable + } + + return unit, nil +} + +// findUnit searches for our unit in the list of all units +func (c *Connection) findUnit() (Unit, error) { + uri := fmt.Sprintf("%s/api/v0/smarthome/overview/units", c.URI) + + req, err := request.New("GET", uri, nil, map[string]string{ + "Authorization": "AVM-SID " + c.SID, + }, request.AcceptJSON) + if err != nil { + return Unit{}, err + } + + var units []Unit + if err := c.DoJSON(req, &units); err != nil { + return Unit{}, err + } + + // Search for matching unit by UID or AIN + for _, unit := range units { + unitAIN := strings.ReplaceAll(unit.UID, " ", "") + if unit.UID == c.UID || unitAIN == c.AIN { + if !unit.IsConnected { + return unit, api.ErrNotAvailable + } + return unit, nil + } + } + + return Unit{}, fmt.Errorf("unit not found: %s", c.AIN) +} + +// CurrentPower implements the api.Meter interface +func (c *Connection) CurrentPower() (float64, error) { + unit, err := c.unitG.Get() + if err != nil { + return 0, err + } + + if unit.Statistics != nil && len(unit.Statistics.Powers) > 0 { + if stats := unit.Statistics.Powers[0].Values; len(stats) > 0 { + return (float64)(stats[0]) / 1000, nil + } + } + + return 0, api.ErrNotAvailable +} + +var _ api.MeterEnergy = (*Connection)(nil) + +// TotalEnergy implements the api.MeterEnergy interface +func (c *Connection) TotalEnergy() (float64, error) { + unit, err := c.unitG.Get() + if err != nil { + return 0, err + } + + if unit.Statistics != nil && len(unit.Statistics.Energies) > 0 { + if stats := unit.Statistics.Energies[len(unit.Statistics.Energies)-1].Values; len(stats) > 0 { + return (float64)(stats[0]) / 1000, nil + } + } + + return 0, api.ErrNotAvailable +} + +// SwitchPresent checks if the device is connected +func (c *Connection) SwitchPresent() (bool, error) { + unit, err := c.unitG.Get() + if err != nil { + if errors.Is(err, api.ErrNotAvailable) { + return false, nil + } + return false, err + } + return unit.IsConnected, nil +} + +// SwitchState returns the current switch state +func (c *Connection) SwitchState() (bool, error) { + unit, err := c.unitG.Get() + if err != nil { + return false, err + } + + if unit.Interfaces.OnOffInterface == nil { + return false, errors.New("device has no switch") + } + + return unit.Interfaces.OnOffInterface.State == "on", nil +} + +// SwitchOn turns the switch on +func (c *Connection) SwitchOn() error { + return c.setSwitch(true) +} + +// SwitchOff turns the switch off +func (c *Connection) SwitchOff() error { + return c.setSwitch(false) +} + +// setSwitch sets the switch state via REST API +func (c *Connection) setSwitch(on bool) error { + if err := c.refreshSession(); err != nil { + return err + } + + state := "off" + if on { + state = "on" + } + + uri := fmt.Sprintf("%s/api/v0/smarthome/overview/units/%s", c.URI, url.PathEscape(c.UID)) + + data := map[string]any{ + "onOffInterface": map[string]string{ + "state": state, + }, + } + + req, _ := request.New("PUT", uri, request.MarshalJSON(data), map[string]string{ + "Authorization": "AVM-SID " + c.SID, + }, request.JSONEncoding) + + var unit Unit + if err := c.DoJSON(req, &unit); err != nil { + return err + } + + // Reset cache after state change + c.unitG.Reset() + + // Verify state was changed + if unit.Interfaces.OnOffInterface != nil { + actualState := unit.Interfaces.OnOffInterface.State == "on" + if actualState != on { + return errors.New("switch state change failed") + } + } + + return nil +} + +// refreshSession ensures we have a valid session ID +func (c *Connection) refreshSession() error { + // refresh Fritzbox session id + if time.Since(c.updated) >= fritz.SessionTimeout { + sid, err := c.GetSessionID(c.Helper) + if err != nil { + return err + } + // update session timestamp + c.SID = sid + c.updated = time.Now() + } + + return nil +} diff --git a/meter/fritz/smarthome/types.go b/meter/fritz/smarthome/types.go new file mode 100644 index 000000000..9b74a48cc --- /dev/null +++ b/meter/fritz/smarthome/types.go @@ -0,0 +1,58 @@ +package smarthome + +// Unit represents a smarthome unit with its interfaces +type Unit struct { + GroupUID string `json:"groupUid,omitempty"` + UID string `json:"UID,omitempty"` + DeviceUID string `json:"deviceUid"` + UnitType string `json:"unitType"` + IsConnected bool `json:"isConnected"` + Statistics *Statistics `json:"statistics,omitempty"` + Interfaces *Interfaces `json:"interfaces,omitempty"` +} + +type Interfaces struct { + MultimeterInterface *MultimeterInterface `json:"multimeterInterface,omitempty"` + OnOffInterface *OnOffInterface `json:"onOffInterface,omitempty"` + TemperatureInterface *TemperatureInterface `json:"temperatureInterface,omitempty"` +} + +type Statistics struct { + Temperatures []ElementFloat `json:"temperatures,omitempty"` + Powers []Element `json:"powers,omitempty"` + Voltages []Element `json:"voltages,omitempty"` + Energies []Element `json:"energies,omitempty"` +} + +type ElementFloat struct { + Interval int64 `json:"interval"` + StasticsState string `json:"statisticsState"` + Period string `json:"period"` + Values []float64 `json:"values,omitempty"` +} + +type Element struct { + Interval int64 `json:"interval"` + StasticsState string `json:"statisticsState"` + Period string `json:"period"` + Values []int64 `json:"values,omitempty"` +} + +// MultimeterInterface contains power/energy measurements +type MultimeterInterface struct { + State string `json:"state"` + Power float64 `json:"power"` // W + Voltage float64 `json:"voltage"` // V + Current float64 `json:"current"` // A + Energy float64 `json:"energy"` // Wh +} + +// OnOffInterface contains switch state +type OnOffInterface struct { + State string `json:"state"` // "on" or "off" +} + +type TemperatureInterface struct { + State string `json:"state"` // "on" or "off" + Celsius float64 `json:"celsius"` +} diff --git a/meter/fritz/types.go b/meter/fritz/types.go new file mode 100644 index 000000000..7b5599517 --- /dev/null +++ b/meter/fritz/types.go @@ -0,0 +1,71 @@ +package fritz + +import ( + "crypto/md5" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "net/url" + "time" + + "github.com/evcc-io/evcc/util/request" + "golang.org/x/text/encoding/unicode" +) + +// https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AVM_Technical_Note_-_Session_ID_english_2021-05-03.pdf +const SessionTimeout = 15 * time.Minute + +// FritzDECT settings +type Settings struct { + URI, AIN, User, Password string + Firmware82 bool // use new REST API (FritzOS 8.2+) +} + +// Fritzbox helpers (credits to https://github.com/rsdk/ahago) + +// getSessionID fetches a session-id based on the username and password in the connection struct +func (s Settings) GetSessionID(c *request.Helper) (string, error) { + uri := fmt.Sprintf("%s/login_sid.lua", s.URI) + body, err := c.GetBody(uri) + if err != nil { + return "", err + } + + var v struct { + SID string + Challenge string + } + + if err = xml.Unmarshal(body, &v); err == nil && v.SID == "0000000000000000" { + var challresp string + if challresp, err = s.createChallengeResponse(v.Challenge); err == nil { + params := url.Values{ + "username": {s.User}, + "response": {challresp}, + } + + if body, err = c.GetBody(uri + "?" + params.Encode()); err == nil { + if err = xml.Unmarshal(body, &v); err == nil && v.SID == "0000000000000000" { + return "", errors.New("invalid user or password") + } + } + } + } + + return v.SID, err +} + +// createChallengeResponse creates the Fritzbox challenge response string +func (s Settings) createChallengeResponse(challenge string) (string, error) { + encoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder() + utf16le, err := encoder.String(challenge + "-" + s.Password) + if err != nil { + return "", err + } + + hash := md5.Sum([]byte(utf16le)) + md5hash := hex.EncodeToString(hash[:]) + + return challenge + "-" + md5hash, nil +} diff --git a/meter/fritzdect.go b/meter/fritzdect.go index 1a3f97d6c..f6e5a2627 100644 --- a/meter/fritzdect.go +++ b/meter/fritzdect.go @@ -2,12 +2,15 @@ package meter import ( "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/meter/fritzdect" + "github.com/evcc-io/evcc/meter/fritz" + "github.com/evcc-io/evcc/meter/fritz/aha" + "github.com/evcc-io/evcc/meter/fritz/smarthome" "github.com/evcc-io/evcc/util" ) // AVM FritzBox AHA interface specifications: // https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/AHA-HTTP-Interface.pdf +// https://fritz.support/resources/SmarthomeRestApiFRITZOS82.html (REST API for FritzOS 8.2+) func init() { registry.Add("fritzdect", NewFritzDECTFromConfig) @@ -15,7 +18,7 @@ func init() { // NewFritzDECTFromConfig creates a fritzdect meter from generic config func NewFritzDECTFromConfig(other map[string]any) (api.Meter, error) { - var cc fritzdect.Settings + var cc fritz.Settings if err := util.DecodeOther(other, &cc); err != nil { return nil, err } @@ -24,5 +27,10 @@ func NewFritzDECTFromConfig(other map[string]any) (api.Meter, error) { return nil, api.ErrMissingCredentials } - return fritzdect.NewConnection(cc.URI, cc.AIN, cc.User, cc.Password) + // Use new REST API if firmware82 is set, otherwise use legacy LUA API + if cc.Firmware82 { + return smarthome.NewConnection(cc.URI, cc.AIN, cc.User, cc.Password) + } + + return aha.NewConnection(cc.URI, cc.AIN, cc.User, cc.Password) } diff --git a/templates/definition/charger/fritzdect.yaml b/templates/definition/charger/fritzdect.yaml index 8099513bf..922f4d127 100644 --- a/templates/definition/charger/fritzdect.yaml +++ b/templates/definition/charger/fritzdect.yaml @@ -25,6 +25,15 @@ params: required: true - name: ain required: true + - name: firmware82 + advanced: true + type: bool + description: + de: Neue REST-API verwenden (FritzOS 8.2+) + en: Use new REST API (FritzOS 8.2+) + help: + de: Verwende die neue REST-API für FritzOS ab Version 8.2 + en: Use the new REST API for FritzOS version 8.2 and later - preset: switchsocket render: | type: fritzdect @@ -32,4 +41,5 @@ render: | user: {{ .user }} password: {{ .password }} ain: {{ .ain }} # switch actor identification number without blanks (see AIN number on switch sticker) + firmware82: {{ .firmware82 }} {{ include "switchsocket" . }} diff --git a/templates/definition/meter/fritzdect.yaml b/templates/definition/meter/fritzdect.yaml index 98dbb6934..caab3a1b8 100644 --- a/templates/definition/meter/fritzdect.yaml +++ b/templates/definition/meter/fritzdect.yaml @@ -27,9 +27,19 @@ params: required: true - name: ain required: true + - name: firmware82 + advanced: true + type: bool + description: + de: Neue REST-API verwenden (FritzOS 8.2+) + en: Use new REST API (FritzOS 8.2+) + help: + de: Verwende die neue REST-API für FritzOS ab Version 8.2 + en: Use the new REST API for FritzOS version 8.2 and later render: | type: fritzdect uri: {{ .uri }} user: {{ .user }} password: {{ .password }} ain: {{ .ain }} # switch actor identification number without blanks (see AIN number on switch sticker) + firmware82: {{ .firmware82 }} diff --git a/templates/definition/meter/fritzgrid.yaml b/templates/definition/meter/fritzgrid.yaml index 0d11df4d1..f67125749 100644 --- a/templates/definition/meter/fritzgrid.yaml +++ b/templates/definition/meter/fritzgrid.yaml @@ -20,3 +20,4 @@ render: | user: {{ .user }} password: {{ .password }} ain: {{ .ain }} # switch actor identification number without blanks (see AIN number on switch sticker) + firmware82: true From cd176a43998e619861143014c9908e6c634e4b7b Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 19 Apr 2026 14:18:21 +0200 Subject: [PATCH 0352/1580] chore: migrate metrics columns either or, not both --- core/metrics/db.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/core/metrics/db.go b/core/metrics/db.go index e1ef96150..06809f30f 100644 --- a/core/metrics/db.go +++ b/core/metrics/db.go @@ -187,17 +187,17 @@ func SetupSchema() error { if err := m.RenameColumn(new(meter), old, "import"); err != nil { return err } - } - - // meter: split energy direction #2 - if old := "pos"; m.HasColumn(new(meter), old) { - if err := m.RenameColumn(new(meter), old, "import"); err != nil { - return err + } else { + // meter: split energy direction #2 + if old := "pos"; m.HasColumn(new(meter), old) { + if err := m.RenameColumn(new(meter), old, "import"); err != nil { + return err + } } - } - if old := "neg"; m.HasColumn(new(meter), old) { - if err := m.RenameColumn(new(meter), old, "export"); err != nil { - return err + if old := "neg"; m.HasColumn(new(meter), old) { + if err := m.RenameColumn(new(meter), old, "export"); err != nil { + return err + } } } From c968d68947e71f6b2368ca75afbe4972e97ade09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Sun, 19 Apr 2026 14:26:07 +0200 Subject: [PATCH 0353/1580] Add domain knowledge docs for AI agents (#29228) --- AGENTS.md | 24 +++ docs/agents/core-domain.md | 147 +++++++++++++++ docs/agents/easee-architecture.md | 269 +++++++++++++++++++++++++++ docs/agents/hardware-integrations.md | 148 +++++++++++++++ docs/agents/plugin-system.md | 61 ++++++ docs/agents/web-ui-api.md | 87 +++++++++ 6 files changed, 736 insertions(+) create mode 100644 docs/agents/core-domain.md create mode 100644 docs/agents/easee-architecture.md create mode 100644 docs/agents/hardware-integrations.md create mode 100644 docs/agents/plugin-system.md create mode 100644 docs/agents/web-ui-api.md diff --git a/AGENTS.md b/AGENTS.md index 75476d4a0..f7a467b3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,30 @@ This file provides guidance to AI coding agents when working with code in this r - `evcc --template-type [type] --template [file]` - test device templates - `make docs` - generate template documentation +## Domain Knowledge + +Deep documentation on specific subsystems is available in `docs/agents/`. Load what you need based on the task: + +| File | When to load | +|------|-------------| +| [Core Domain](docs/agents/core-domain.md) | Control loop, loadpoint logic, PV surplus, charge modes, tariffs, interfaces | +| [Hardware Integrations](docs/agents/hardware-integrations.md) | Charger/meter/vehicle implementations, adding new devices | +| [Easee Architecture](docs/agents/easee-architecture.md) | Easee charger (REST+SignalR, async correlation, concurrency) | +| [Plugin System](docs/agents/plugin-system.md) | Plugin layer (HTTP, MQTT, Modbus, SunSpec, JS) | +| [Web UI & API](docs/agents/web-ui-api.md) | REST API, WebSocket, Vue frontend, authentication | + +### Loading guide by task type + +- **Charger implementation** — hardware-integrations + core-domain +- **Easee charger work** — easee-architecture + core-domain +- **Meter implementation** — hardware-integrations + plugin-system +- **Vehicle implementation** — hardware-integrations +- **UI/frontend work** — web-ui-api +- **API endpoint work** — web-ui-api + core-domain +- **Config/template work** — plugin-system +- **Control loop / charging logic** — core-domain +- **Bug in any area** — core-domain + relevant topic file(s) + ## Architecture Guidelines ### Core Components diff --git a/docs/agents/core-domain.md b/docs/agents/core-domain.md new file mode 100644 index 000000000..42ba9ac3f --- /dev/null +++ b/docs/agents/core-domain.md @@ -0,0 +1,147 @@ +# Core Domain: Site, Loadpoint, and the Control Loop + +## Object Hierarchy + +``` +Site (orchestrator — core/site.go) +├── Meters: Grid, PV[], Battery[], Auxiliary[], External[] +├── Tariffs: Grid, FeedIn, CO2, Solar +├── Coordinator (vehicle <-> loadpoint assignment) +├── Prioritizer (power allocation fairness) +└── Loadpoints[] (core/loadpoint.go) + ├── Charger (api.Charger — hardware controller) + ├── Vehicle (api.Vehicle — EV battery state via cloud API) + ├── ChargeMeter (api.Meter — AC power at charger) + └── Circuit (optional — electrical domain limits) +``` + +## Key Interfaces (api/api.go) + +### Meter +- `Meter` — `CurrentPower() (float64, error)` — watts +- `MeterEnergy` — `TotalEnergy() (float64, error)` — kWh +- `PhaseCurrents` / `PhaseVoltages` / `PhasePowers` — per-phase readings + +### Battery +- `Battery` — `Soc() (float64, error)` — 0-100% +- `BatteryCapacity` — kWh +- `BatteryController` — set charge/discharge/hold mode + +### Charger +- `Charger` — `Status()`, `Enabled()`, `Enable(bool)`, `MaxCurrent(int64)` +- `ChargerEx` — milliamp-precision current via `MaxCurrentMillis(float64)` +- `PhaseSwitcher` — `Phases1p3p(int) error` +- `ChargeRater` — `ChargedEnergy() (float64, error)` +- `ChargeTimer` — `ChargeDuration() (time.Duration, error)` + +### Vehicle +- `Vehicle` — `Soc()`, `Capacity()`, `Identifiers()`, `Phases()`, `OnIdentified()` +- `VehicleRange`, `VehicleOdometer`, `VehicleClimater`, `VehicleFinishTimer`, `VehiclePosition` +- `ChargeController` — remote start/stop on vehicle +- `CurrentLimiter` — `GetMinMaxCurrent()` for vehicle-side current limits +- `CurrentController` — some vehicles (Tesla, Fiat) also implement `MaxCurrent()` to set charge current from the vehicle side + +## Charge Modes + +| Mode | Behavior | +|------|----------| +| `OFF` | Disabled (unless welcome charge) | +| `NOW` | Max current immediately | +| `MINPV` | Min current when PV surplus; fast if cheap tariff | +| `PV` | Ramp current proportional to available solar | + +## Charge States (IEC 61851) + +- `A` — not connected +- `B` — connected, not charging +- `C` — connected, charging + +## The Control Loop (Site.update — runs every N seconds) + +``` +1. Update all meters (grid, PV, battery, aux) +2. For each loadpoint: UpdateChargePowerAndCurrents() +3. Calculate site power balance: + sitePower = gridPower + batteryPower + excessDCPower + + residualPower - auxPower - flexiblePower +4. Apply battery priority rules (prioritySoc, bufferSoc) +5. Get tariff rates +6. For EACH loadpoint: Update(sitePower, ...) + ├── Read charger status + ├── Detect/identify vehicle + ├── Check plan requirements (minSOC, target time) + ├── Check limits (limitSOC, limitEnergy) + ├── MODE switch -> calculate target current + ├── Cap at maxCurrent, respect circuit limits + ├── Send MaxCurrent() to charger + └── Record metrics +7. Push updates to WebSocket + metrics +``` + +The loop is stateless per cycle: always re-reads actual state, calculates +optimal current, sends single command. Resilient to restarts and missed updates. + +## PV Surplus Charging (pvMaxCurrent in core/loadpoint.go) + +``` +1. Read effective min/max current limits +2. Reduce sitePower by battery boost power +3. Consider phase switching (1p <-> 3p) if supported +4. deltaCurrent = powerToCurrent(-sitePower, activePhases) + targetCurrent = effectiveCurrent + deltaCurrent +5. Below minCurrent -> start disable timer (default 3 min) +6. Surplus returns -> start enable timer (default 1 min) +7. Cap at maxCurrent +``` + +## Battery Priority Rules + +| Setting | Effect | +|---------|--------| +| `prioritySoc` | Below this: battery charges first, EV gets 0 | +| `bufferSoc` | Above this: EV can draw from battery reserves | +| `bufferStartSoc` | Above this: EV charging can begin even if importing | + +## Effective Price Calculation + +``` +greenShare = (max(pvPower,0) + max(batteryPower,0)) / totalChargePower +effectivePrice = gridPrice * (1 - greenShare) + feedInPrice * greenShare +``` + +## Concurrency Model + +- **Site** owns `RWMutex` for its state (meters, battery, tariffs) +- **Loadpoint** owns `RWMutex` for its state (charger, vehicle, current) +- **Coordinator** owns `RWMutex` for vehicle <-> loadpoint tracking +- No global locks — ordering prevents deadlocks + +### Channels + +| Channel | Scope | Buffer | Purpose | +|---------|-------|--------|---------| +| `valueChan` | Site | Unbounded (`chanx.NewUnboundedChan`) | State changes -> DB + UI (ordering) | +| `lpUpdateChan` | Site | 1 | Early loadpoint update requests | +| `pushChan` | Loadpoint | Buffered | User notifications | + +## Tariff Integration + +Types: `TariffUsageGrid`, `TariffUsageFeedIn`, `TariffUsageCo2`, `TariffUsagePlanner`, `TariffUsageSolar` + +### Smart Features +- **Cheap-tariff override** — rate below threshold -> fast charge +- **Smart feed-in** — feed-in rate above threshold -> prioritize export +- **Planner** (`core/planner/planner.go`) — finds cheapest time slots for target SOC/energy by deadline + - `optimalPlan()` — cheapest non-contiguous slots + - `continuousPlan()` — cheapest continuous window (fallback) + +## Key File Locations + +- `api/api.go` — all core interfaces +- `core/site.go` — Site orchestrator + control loop +- `core/loadpoint.go` — Loadpoint state machine (pvMaxCurrent, mode switch) +- `core/site_battery.go` — battery priority logic +- `core/site_tariffs.go` — tariff integration +- `core/planner/planner.go` — charge time optimization +- `core/prioritizer/prioritizer.go` — power allocation across loadpoints +- `core/circuit/circuit.go` — electrical domain limits diff --git a/docs/agents/easee-architecture.md b/docs/agents/easee-architecture.md new file mode 100644 index 000000000..1890e5e24 --- /dev/null +++ b/docs/agents/easee-architecture.md @@ -0,0 +1,269 @@ +# Easee Charger Architecture + +The Easee integration communicates with the Easee cloud over two distinct channels: + +1. **REST API** (`https://api.easee.com/api`) — synchronous control commands and configuration. +2. **SignalR WebSocket** (`https://streams.easee.com/hubs/chargers`) — asynchronous real-time state updates and command confirmations. + +Commands are sent via REST; their acknowledgement and state changes are delivered asynchronously via SignalR. + +## Authentication + +### Flow + +Authentication uses username/password credentials against: +``` +POST https://api.easee.com/api/accounts/login +``` + +Returns a `Token` struct with `accessToken` (short-lived JWT), `refreshToken`, `expiresIn`, and `tokenType`. + +### Token Lifecycle + +Wrapped in `oauth2.TokenSource` via `oauth.RefreshTokenSource`. Near expiry, automatically calls: +``` +POST https://api.easee.com/api/accounts/refresh_token +``` +Falls back to full re-login if refresh fails. + +### Token Caching + +`TokenSource` is shared per user via `cache.New[oauth2.TokenSource]()`. Multiple `Easee` instances with the same user email share a single token source, preventing redundant re-authentication. + +## Initialization Sequence + +`NewEasee` performs these steps in order: + +1. **Charger Discovery** — If no serial provided, queries `GET /api/chargers` and expects exactly one charger. +2. **Site and Circuit Discovery** — `GET /api/chargers/{chargerID}/site`. Searches for a single-charger circuit for circuit-level phase control. +3. **SignalR Connection** — Creates client with `WithMaxElapsedTime(0)` to retry forever (default 15-min cap would silently stop updates). +4. **Subscription** — On every `ClientConnected`, sends `SubscribeWithCurrentState(chargerID, true)` to replay full current state before switching to push-on-change. +5. **Startup Gate** — Blocks until `CHARGER_OP_MODE` is received (one-shot `sync.OnceFunc`). +6. **Optional State Wait** — Waits up to 3s for `SESSION_ENERGY`, `LIFETIME_ENERGY`, `TOTAL_POWER`. WARN if missing but initialization succeeds. + +## SignalR Back-Channel + +### Why SignalR is Required + +1. **Commands are fire-and-forget at HTTP level.** HTTP response only confirms cloud received the request. Success/failure arrives via SignalR `CommandResponse`. +2. **State is event-driven, not pollable.** No REST endpoint streams charger state. +3. **Ticks correlation only works with a live connection.** If SignalR drops mid-command, the waiter times out. + +### Server -> Client Methods + +#### `ProductUpdate(json.RawMessage)` +Primary state channel. Carries a single `Observation` with `ID` (ObservationID), `Value`, `DataType`, and `Timestamp`. + +- **Timestamp deduplication**: older timestamps for the same ID are silently dropped. +- **Non-blocking fan-out**: observation sent on `obsC` via non-blocking select. + +#### `CommandResponse(json.RawMessage)` +Async acknowledgement for REST commands. Contains `Ticks` (correlation key), `WasAccepted`, `ResultCode`, and `ID` (ObservationID). + +Routes through three maps in order: +1. `pendingTicks[res.Ticks]` — primary correlation for async (HTTP 202) commands +2. `pendingByID[ObservationID(res.ID)]` — fallback when Ticks mismatch +3. `expectedOrphans[ObservationID(res.ID)]` — counter for sync (HTTP 200) endpoints that still produce a CommandResponse + +Unmatched responses are logged as WARN (rogue response from external system). + +#### `ChargerUpdate` / `SubscribeToMyProduct` +Logged at TRACE, not processed further. + +## Command Flow and Async Correlation + +### REST Command Endpoints + +``` +POST /api/chargers/{chargerID}/commands/{action} (start/stop/pause/resume) +POST /api/chargers/{chargerID}/settings (enable, DCC, PhaseMode, SmartCharging) +POST /api/sites/{siteID}/circuits/{circuitID}/settings (dynamic circuit currents) +``` + +### Response Handling + +| HTTP Status | Meaning | Behavior | +|-------------|---------|----------| +| `200` | Synchronous / already applied | Returns immediately | +| `202` | Asynchronous, Ticks provided | Waits for matching CommandResponse | +| other | Error | Returns error | + +### Ticks Correlation + +On 202, the body contains `RestCommandResponse` with a `Ticks` field (.NET DateTime.Ticks). If `Ticks == 0`, the command was a no-op. + +Each in-flight command creates a **buffered channel** (capacity 1), registered in both `pendingTicks` and `pendingByID`, cleaned up via `defer`. + +### The Sync/Async Mismatch + +Some endpoints return HTTP `200` but still fire a `CommandResponse` via SignalR. The observed case is circuit settings (`POST /api/sites/{siteID}/circuits/{circuitID}/settings`) which returns `200` but generates `CommandResponse` with `ID=22` (`CIRCUIT_MAX_CURRENT_P1`). + +Handled via the **expected-orphan counter**: +```go +expectedOrphans map[easee.ObservationID]int // protected by cmdMu +``` + +Before a POST to a known 200-returning endpoint, increment the counter. When `CommandResponse` arrives with no pending match, decrement and silently consume. Counter at 0 means genuinely rogue. + +## State Management + +### Internal State Fields (all protected by `sync.RWMutex`) + +| Field | Observation | Notes | +|-------|------------|-------| +| `opMode` | `CHARGER_OP_MODE` (109) | Central state machine | +| `chargerEnabled` | `IS_ENABLED` (31) | Hardware enable state | +| `smartCharging` | `SMART_CHARGING` (102) | LED color mode | +| `currentPower` | `TOTAL_POWER` (120) | Watts (API sends kW, multiplied by 1000) | +| `sessionEnergy` | `SESSION_ENERGY` (121) | kWh, special zero-handling | +| `totalEnergy` | `LIFETIME_ENERGY` (124) | kWh, updated ~hourly | +| `currentL1/L2/L3` | `IN_CURRENT_T3/T4/T5` (183/184/185) | Phase currents in A | +| `phaseMode` | `PHASE_MODE` (38) | 1=single, 2=auto, 3=locked 3-phase | +| `dynamicCircuitCurrent[3]` | `DYNAMIC_CIRCUIT_CURRENT_P1/P2/P3` (111/112/113) | Per-phase circuit limit | +| `maxChargerCurrent` | `MAX_CHARGER_CURRENT` (47) | Hardware max (non-volatile) | +| `dynamicChargerCurrent` | `DYNAMIC_CHARGER_CURRENT` (48) | Volatile current limit | +| `reasonForNoCurrent` | `REASON_FOR_NO_CURRENT` (96) | Debug enum | +| `pilotMode` | `PILOT_MODE` (100) | CP signal state A-F | +| `rfid` | `USER_IDTOKEN` (128) | Last scanned RFID token | + +### Session Energy Zero-value Protection + +`sessionEnergy` is never set to `0` from a `ProductUpdate` — the API sends spurious zeros erratically. Session reset is driven by op-mode transition: when `CHARGER_OP_MODE` transitions from disconnected to awaiting-start, `sessionEnergy` resets to `0` with a fresh timestamp. + +## Charger Operation Modes + +``` +0 = Offline — no cloud connection +1 = Disconnected — no car plugged in +2 = AwaitingStart — car plugged, waiting for authorization/start +3 = Charging — actively charging +4 = Completed — car full or finished, cable still plugged +5 = Error — fault condition +6 = ReadyToCharge — ready, current available +7 = AwaitingAuthentication — RFID auth required +8 = Deauthenticating — finishing authentication teardown +``` + +### Mapping to evcc Status + +| opMode | evcc Status | +|--------|------------| +| 1 (Disconnected) | A | +| 2, 4, 6, 7, 8 | B | +| 3 (Charging) | C | +| 0, 5 and others | error | + +## Enable/Disable Flow + +### Enable = true + +1. If `chargerEnabled == false`: POST settings `{ enabled: true }` and wait. +2. If `opMode == Disconnected`: return (no cable). +3. If `opMode == AwaitingAuthentication && authorize`: action = `start_charging`. +4. Otherwise: action = `resume_charging`. +5. POST `/commands/{action}` and wait. +6. Wait for `opMode` to reach enabled state. +7. Wait for `dynamicChargerCurrent` to reach `32` (Easee sets this on resume). +8. Call `MaxCurrent(c.current)` to restore previous setpoint. + +### Enable = false + +1. If disconnected or (awaiting auth && !authorize): return. +2. POST `/commands/pause_charging` and wait. +3. Wait for `opMode` to reach disabled state. +4. Wait for `dynamicChargerCurrent` to reach `0`. + +### State Waiting Pattern + +Both `waitForChargerEnabledState` and `waitForDynamicChargerCurrent` use: +1. Short-circuit check: if already in target state, return immediately. +2. Open a timer. +3. Loop on `obsC` channel. +4. On timer expiry: **one final check** before returning `api.ErrTimeout`. + +The final check handles the race where the state update arrived between the last channel read and the timer fire. + +## Phase Control + +### Circuit-Level (preferred, when circuit is known) + +Phase switching by zeroing dynamic circuit current on unused phases: +``` +POST /api/sites/{siteID}/circuits/{circuitID}/settings +``` + +For 1-phase: set P2=0, P3=0. For 3-phase: restore all three. + +This POST returns HTTP `200` but still fires a `CommandResponse` with `ID=22` (expected orphan). + +### Charger-Level (fallback) + +Uses `PhaseMode` setting: `1` for single-phase, `2` (auto) for 3-phase. +After changing PhaseMode, `Enable(false)` is called — the loadpoint then re-enables, because PhaseMode changes only take effect after a charging cycle restart. + +## Authorization Mode (`authorize`) + +When `authorize: true`, evcc sends `start_charging` to authorize sessions when the charger enters `ModeAwaitingAuthentication`. This enables fully unattended operation but is incompatible with RFID-based vehicle identification. + +When `authorize: false`, evcc does nothing in mode 7 — the charger waits for external authorization (RFID card or app). + +Setting `authorize: true` also prevents the charger from auto-starting at 32A on plug-in, giving evcc full control from the first amp. + +## Concurrency Model + +### Mutexes + +| Mutex | Type | Protects | +|-------|------|----------| +| `c.mux` | `sync.RWMutex` | All charger state fields | +| `dispatcher.mu` | `sync.Mutex` | `pendingTicks`, `pendingByID`, `expectedOrphans` maps (inside `CommandDispatcher`) | + +Command dispatch was extracted into `charger/easee/dispatcher.go` (`CommandDispatcher` struct). The two mutexes are intentionally separate to prevent the SignalR receive loop from blocking on command dispatch operations. + +### Observation Channel + +`obsC chan Observation` is unbuffered. `ProductUpdate` sends via non-blocking select — if no waiter is listening, the notification is dropped. The authoritative state is always in the struct fields; the channel is only a notification mechanism. + +**Design constraint**: any waiter on `obsC` must include a final state check after timer expiry before returning `api.ErrTimeout`. + +## Known Design Concerns + +1. **SESSION_ENERGY zero-value protection** — defensive measure based on field observations; root cause unverified. +2. **LIFETIME_ENERGY** — inaccurate by design, API pushes updates ~hourly. +3. **current vs dynamicChargerCurrent drift** — evcc's desired setpoint and charger's confirmed value can drift around pause/resume cycles. Resynced via `MaxCurrent(c.current)` after resume. +4. **Multi-charger circuits** — only circuit-level phase control when charger is alone on its circuit. Multi-charger circuits fall back to less precise charger-level control. +5. **Stale CommandResponses after reconnect** — if SignalR drops mid-command, the response may arrive after reconnect with no pending entry, triggering a false-positive rogue WARN. Acceptable trade-off. + +## API Endpoints Summary + +| Method | Endpoint | Used For | +|--------|----------|----------| +| `POST` | `/accounts/login` | Initial authentication | +| `POST` | `/accounts/refresh_token` | Token refresh | +| `GET` | `/chargers` | Auto-discover charger ID | +| `GET` | `/chargers/{id}/site` | Discover site and circuit | +| `POST` | `/chargers/{id}/settings` | Enable/disable, DCC, PhaseMode, SmartCharging | +| `POST` | `/chargers/{id}/commands/{action}` | start/stop/pause/resume charging | +| `GET` | `/sites/{siteId}/circuits/{circuitId}/settings` | Read max circuit currents | +| `POST` | `/sites/{siteId}/circuits/{circuitId}/settings` | Set dynamic circuit currents (phase switching) | + +### SignalR Hub + +| Endpoint | `https://streams.easee.com/hubs/chargers` | +|----------|------------------------------------------| +| Client -> Server | `SubscribeWithCurrentState(chargerID, true)` | +| Server -> Client | `ProductUpdate`, `ChargerUpdate`, `SubscribeToMyProduct`, `CommandResponse` | + +## Configuration + +| Parameter | Required | Default | Notes | +|-----------|----------|---------|-------| +| `user` | yes | | Easee account email | +| `password` | yes | | Easee account password | +| `charger` | no | | Charger serial; auto-detected if exactly one on account | +| `timeout` | no | `20s` | HTTP timeout for all API calls and command waits | +| `authorize` | no | `false` | If true, evcc sends `start_charging` to authorize sessions | + +Supported products: Easee Home, Easee Charge, Easee Charge Lite, Easee Charge Core. +Declared capabilities: `1p3p` (phase switching), `rfid` (RFID identification). +Requires evcc sponsorship. diff --git a/docs/agents/hardware-integrations.md b/docs/agents/hardware-integrations.md new file mode 100644 index 000000000..2fddf3fd5 --- /dev/null +++ b/docs/agents/hardware-integrations.md @@ -0,0 +1,148 @@ +# Hardware Integrations: Chargers, Meters, Vehicles + +## Integration Pattern + +All device types use a registry-based factory pattern: + +```go +// Self-registration in init() +func init() { + registry.AddCtx("typename", NewFromConfig) +} + +func NewFromConfig(ctx context.Context, other map[string]interface{}) (api.Charger, error) { + // Parse config, create client, return implementation +} +``` + +Optional interfaces are added via the decorator pattern: +```go +//go:generate decorate -f decorateXxx -b *Xxx -t "api.PhaseSwitcher,Phases1p3p,func(int) error" +``` + +## Charger Implementations + +### By Protocol + +| Protocol | Examples | +|----------|---------| +| HTTP/REST | Easee (REST+SignalR), Wallbox, go-e, OpenWB, Shelly | +| Modbus RTU/TCP | KEBA, Wallbe, CFOS, Bender, Delta, Mennekes | +| OCPP 1.6 | Generic charge point server | +| EEBus/ISO 15118 | EEBus SPINE protocol | +| UDP/Custom | KEBA UDP, OpenEVSE, Wattpilot, NRGKick | +| MQTT | OpenWB, Tasmota, Shelly | +| Smart Socket | Shelly, Tapo, TP-Link, FritzDECT | + +### Required Charger Interface + +```go +type Charger interface { + ChargeState + Enabled() (bool, error) + Enable(enable bool) error + CurrentController +} +``` + +Where `ChargeState` provides `Status() (ChargeStatus, error)` (A/B/C) and +`CurrentController` provides `MaxCurrent(current int64) error`. + +### Optional Charger Interfaces + +- `ChargerEx` — `MaxCurrentMillis(float64)` for milliamp precision +- `PhaseSwitcher` — `Phases1p3p(int)` to switch 1p/3p +- `Meter` / `MeterEnergy` — built-in power/energy measurement +- `PhaseCurrents` / `PhaseVoltages` — per-phase readings +- `ChargeRater` — `ChargedEnergy()` for session energy +- `ChargeTimer` — `ChargeDuration()` for session time +- `Identifier` — `Identify()` for RFID/vehicle identification + +### Key Implementations + +- **Easee** — REST + async SignalR; see `docs/agents/easee-architecture.md` for full detail +- **OCPP** (`charger/ocpp.go`) — Full 1.6 with charge point management +- **go-e** — Dual API (v1 local HTTP, v2 cloud); phase switching on v2 +- **EEBus** — Complex SPINE protocol with USE cases (CEM, EV, EVCC) +- **Generic configurable** (`charger/charger.go`) — plugin-driven via YAML template + +### Adding a New Charger + +1. Create `charger/xxx.go` (or YAML template in `templates/definition/charger/`) +2. Implement `NewXxxFromConfig()` returning `api.Charger` +3. Required: `Status()`, `Enabled()`, `Enable()`, `MaxCurrent()` +4. Register: `registry.AddCtx("xxx", factory)` in `init()` +5. Optional: add decorator for PhaseSwitcher, Meter, etc. +6. Add template: `templates/definition/charger/xxx.yaml` for UI metadata + +## Meter Implementations + +### By Category + +| Category | Examples | +|----------|---------| +| Modbus/SunSpec | SDM630, SMA, Fronius, Victron | +| HTTP/REST | Homewizard, Shelly Gen3, E3DC | +| Smart Home | HomeAssistant entities, Homematic | +| Battery/Storage | Tesla Powerwall, LG ESS, Zendure | + +### Required Meter Interface + +```go +type Meter interface { + CurrentPower() (float64, error) // watts +} +``` + +### Optional: `MeterEnergy`, `PhaseCurrents/Voltages/Powers`, `Battery`, `BatteryCapacity` + +### Key Implementations +- **mbmd** (`meter/mbmd.go`) — RS485 device library with auto-detection +- **SunSpec** (`plugin/sunspec.go`) — Modbus model-based point queries +- **Generic** — plugin-driven (HTTP, Modbus, MQTT sources) + +## Vehicle Integrations + +| Manufacturer | API Type | +|-------------|----------| +| Tesla | Fleet API + vehicle-command proxy | +| VW Group | WeConnect (VW, Audi, Skoda, Seat, Cupra) | +| Hyundai/Kia | BlueLink (regional variants) | +| BMW/Mini | ConnectedDrive v2 | +| Mercedes | Official API | +| Renault/Nissan | Renault API + Carwings | +| Ford | FordConnect (US/EU) | +| Porsche | Porsche Connect | +| PSA Group | Peugeot, Citroen, DS, Opel | +| Generic | OVMS, Tronity | + +### Required Vehicle Interface + +```go +type Vehicle interface { + Battery // Soc() (float64, error) + BatteryCapacity // Capacity() float64 + IconDescriber // Icon() string + FeatureDescriber // Features() []Feature + PhaseDescriber // Phases() int + TitleDescriber // GetTitle() string + SetTitle(string) + Identifiers() []string + OnIdentified() ActionConfig +} +``` + +### Optional: `SocLimiter`, `ChargeState`, `VehicleRange`, `VehicleOdometer`, `VehicleClimater`, `VehicleFinishTimer`, `VehiclePosition`, `CurrentLimiter`, `CurrentController`, `ChargeController`, `Resurrector` + +### Polling Strategy + +Configurable: always / while charging / while connected. Interval-based caching +to avoid excessive cloud API calls. OAuth2 token handling built into each provider. + +## Auto-Detection (`cmd/detect/`) + +Task-based parallel IP scanning: +`ping` -> `tcp_http` -> `tcp_modbus` -> `sunspec` -> device-specific probes + +Detects: OpenWB, SMA, KEBA, E3DC, Sonnen, Tesla Powerwall, Wallbe, Fronius, +Tasmota, Shelly, Phoenix, and many more. diff --git a/docs/agents/plugin-system.md b/docs/agents/plugin-system.md new file mode 100644 index 000000000..c37a76922 --- /dev/null +++ b/docs/agents/plugin-system.md @@ -0,0 +1,61 @@ +# Plugin System + +The plugin system (`plugin/`) provides protocol-level abstraction for device +communication. Plugins implement typed getter/setter interfaces and are composed +into charger, meter, or vehicle implementations via configuration. + +## Plugin Types + +| Plugin | Protocol | Key Config | +|--------|----------|------------| +| `http` | HTTP/REST | `uri`, `method`, `headers`, `auth`, `cache`, `timeout` | +| `mqtt` | MQTT | `topic`, `retained`, `payload` template, `timeout` | +| `modbus` | Modbus TCP/RTU | `uri`, `register`, `scale`, `baudrate`, `rtu` | +| `sunspec` | SunSpec/Modbus | Model-based point queries via device tree | +| `js` | JavaScript/WASM | Inline script evaluation | +| `go` | Go runtime | Dynamic Go code | +| `gpio` | Linux GPIO | Digital I/O for relays | + +## Getter/Setter Interfaces + +```go +type StringGetter func() (string, error) +type FloatGetter func() (float64, error) +type IntGetter func() (int64, error) +type BoolGetter func() (bool, error) +// + corresponding Setter types +``` + +## Pipeline Transforms + +Plugins support chained transforms: `scale`, `offset`, `lookup`, `regex`. + +## Template-Based Device Configuration + +Devices can be defined entirely via YAML templates using plugins: + +```yaml +# templates/definition/charger/example.yaml +status: + source: http + uri: http://{{ .host }}/status +enable: + source: http + uri: http://{{ .host }}/enable + method: POST +maxcurrent: + source: http + uri: http://{{ .host }}/current/{{ .maxcurrent }} +``` + +The generic configurable charger (`charger/charger.go`) wires these plugin +configs into the `api.Charger` interface at runtime. + +## Key Files + +- `plugin/config.go` — plugin registry and config types +- `plugin/http.go` — HTTP plugin +- `plugin/mqtt.go` — MQTT plugin +- `plugin/modbus.go` — Modbus plugin +- `plugin/sunspec.go` — SunSpec plugin +- `charger/charger.go` — generic configurable charger using plugins diff --git a/docs/agents/web-ui-api.md b/docs/agents/web-ui-api.md new file mode 100644 index 000000000..fed6d2331 --- /dev/null +++ b/docs/agents/web-ui-api.md @@ -0,0 +1,87 @@ +# Web UI & REST API + +## Server Architecture + +- **Router:** gorilla/mux, strict slash +- **Middleware:** GZIP, CORS (`*`), ETag caching, request logging, JSON headers, JWT auth +- **Timeouts:** Read 5s, Write 10s, Idle 120s +- **Static assets:** embedded in binary (`fs.FS`) +- **Default port:** 7070 + +## REST API (base `/api/`) + +### Site-level +- `POST /buffersoc/{value}`, `/prioritysoc/{value}`, `/residualpower/{value}` etc. +- `GET /tariff/{tariff}` — tariff rates +- `GET /sessions` — charging history +- `GET /state` — complete system state (supports jq filtering) + +### Per-loadpoint (`/loadpoints/{id}/...`) +- `POST mode/{value}` — off/now/minpv/pv +- `POST limitsoc/{value}`, `limitenergy/{value}` — charge limits +- `POST mincurrent/{value}`, `maxcurrent/{value}` — current limits +- `POST phases/{value}` — phase config +- `POST priority/{value}`, `batteryboost/{value}` +- `POST plan/energy/{value}/{time}` — schedule plan +- `POST vehicle/{name}` — select vehicle +- `POST smartcostlimit/{value}` — smart cost threshold + +### Configuration (`/config/...`, auth required) +- CRUD for devices (chargers, meters, vehicles, tariffs) +- Template browsing and testing +- Site, loadpoint, circuit, HEMS, messaging config +- `GET /config/evcc.yaml` — YAML export + +### System (`/system/...`, auth required) +- Log viewing, cache clear, DB backup/restore/reset, shutdown + +### Handler Pattern +Generic `handler[T]` with type conversion, setter, getter. +Specialized: `floatHandler`, `intHandler`, `boolHandler`, `durationHandler`. + +## WebSocket (`/ws`) + +- `coder/websocket` (RFC 6455) +- Pub/sub via `SocketHub` +- Buffered channels (1024 per subscriber) +- Welcome message with full state snapshot +- Incremental updates as JSON key-value pairs with dot-notation keys: + ```json + {"loadpoints.1.mode": "solar", "site.gridPower": 1234} + ``` +- Write timeout: 10s, compression (disabled for Safari) + +## State Flow + +1. WS connects -> receives welcome with full state +2. App emits `util.Param` on changes +3. Hub broadcasts to subscribers +4. Frontend `store.update(msg)` merges via dot-notation +5. Components reactively re-render + +## Authentication + +- JWT, 90-day lifetime +- HttpOnly cookie (`auth`) with `SameSite=Strict` +- Also accepts `Authorization: Bearer ` header +- Modes: Disabled, Locked (demo), Configured (password) +- Protects `/api/config` and `/api/system` + +## MQTT Integration + +- Publishes state changes to configurable broker +- Subscribes to control topics +- Retained messages for state persistence + +## Key Files + +- `server/http.go` — router setup +- `server/http_auth.go` — authentication +- `server/http_site_handler.go` — state + request handlers +- `server/http_config_*.go` — config endpoints +- `server/http_loadpoint_handler.go` — per-loadpoint endpoints +- `server/socket.go` — WebSocket pub/sub +- `assets/js/app.ts` — Vue app entry +- `assets/js/store.ts` — reactive state store +- `assets/js/api.ts` — Axios clients +- `assets/js/router.ts` — route definitions From 6414d8add33b2f5d08e18797917ff69fdda37e95 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 19 Apr 2026 14:42:03 +0200 Subject: [PATCH 0354/1580] Forecast: temporarily remove adjustment (#29244) --- assets/js/components/Forecast/ForecastModal.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/js/components/Forecast/ForecastModal.vue b/assets/js/components/Forecast/ForecastModal.vue index ab5df04fc..c0769fd1b 100644 --- a/assets/js/components/Forecast/ForecastModal.vue +++ b/assets/js/components/Forecast/ForecastModal.vue @@ -95,7 +95,8 @@ export default defineComponent({ return settings.solarAdjusted; }, showSolarAdjust() { - return !!this.forecast.solar && this.experimental; + // TODO fix https://github.com/evcc-io/evcc/issues/29165 + return !!this.forecast.solar && this.experimental && false; }, solar() { if (this.showSolarAdjust && this.solarAdjusted) { From b751090b03828aa2ca346820e0b5684e68d0b7bd Mon Sep 17 00:00:00 2001 From: Mathias Walter Date: Sun, 19 Apr 2026 17:25:45 +0200 Subject: [PATCH 0355/1580] Solarprognose: deprecate forecast_days (#29249) --- .../definition/tariff/solarprognose.yaml | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/templates/definition/tariff/solarprognose.yaml b/templates/definition/tariff/solarprognose.yaml index 00103c9eb..85a07da51 100644 --- a/templates/definition/tariff/solarprognose.yaml +++ b/templates/definition/tariff/solarprognose.yaml @@ -39,13 +39,7 @@ params: de: Prognosealgorithmus (mosmix, own-v1 oder clearsky) advanced: true - name: forecast_days - description: - en: Number of additional days to forecast (1-6) - de: Anzahl zusätzlicher Tage für die Prognose (1-6) - help: - en: "Number of additional days to forecast (1-6), excluding today. Remember: evcc calculates energy for today, tomorrow, and the day after tomorrow, effectively handling up to three days of solar forecast data, but the UI shows up to 96 hours." - de: "Anzahl der zusätzlichen Tage für die Prognose (1-6), ohne den heutigen Tag. Bedenke: evcc berechnet die Energie für heute, morgen und übermorgen und verarbeitet dabei effektiv bis zu drei Tage Solarprognosedaten, zeigt jedoch bis zu 96 Stunden an." - default: 4 + deprecated: true advanced: true - name: interval default: 1h @@ -57,7 +51,7 @@ render: | forecast: source: http # Base URL with required params (format, type, access token), then optional item-based parameters - uri: https://www.solarprognose.de/web/solarprediction/api/v1?_format=json&type=hourly&project=https://evcc.io&access-token={{ .token }}{{ if .item }}&item={{ .item }}{{ if .id }}&id={{ .id }}{{ else }}&token={{ unquote .uniquetoken }}{{ end }}{{ end }}{{ if .algorithm }}&algorithm={{ .algorithm }}{{ end }}{{ if .forecast_days }}&end_day={{ .forecast_days }}{{ end }} + uri: https://www.solarprognose.de/web/solarprediction/api/v1?_format=json&type=hourly&project=https://evcc.io&access-token={{ .token }}{{ if .item }}&item={{ .item }}{{ if .id }}&id={{ .id }}{{ else }}&token={{ unquote .uniquetoken }}{{ end }}{{ end }}{{ if .algorithm }}&algorithm={{ .algorithm }}{{ end }} jq: | def step: 3600; # hourly interval @@ -65,7 +59,10 @@ render: | (.data // {}) | if type == "object" and length > 0 then to_entries - | map({key, value: (.value[0] * 1000)}) + | map( + select(.value | type == "array" and length > 0) + | {key, value: ((.value[0] // 0) * 1000)} + ) | from_entries else {} @@ -74,19 +71,21 @@ render: | . as $root | ($root | lookup) as $L | ($L | keys | map(tonumber) | sort) as $ts - | ($ts[0]) as $min - | ($ts[-1]) as $max - | reduce range($min; $max + step; step) as $t - ( { last: null, out: [] }; - ($L[($t|tostring)]?) as $v - | .last = (if $v == null then .last else $v end) - | .out += - [ { - start: ($t | strftime("%FT%TZ")), - end: (($t + step) | strftime("%FT%TZ")), - value: (.last // 0) - } ] - ) - | .out + | if ($ts | length) == 0 then + [] + else + reduce range($ts[0]; $ts[-1] + step; step) as $t + ( { last: null, out: [] }; + ($L[($t|tostring)]?) as $v + | .last = (if $v == null then .last else $v end) + | .out += + [ { + start: ($t | strftime("%FT%TZ")), + end: (($t + step) | strftime("%FT%TZ")), + value: (.last // 0) + } ] + ) + | .out + end | tostring interval: {{ .interval }} From 9138ef81f6c80dccba4b4ab2e952cfcbcabb2ae9 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 19 Apr 2026 17:35:45 +0200 Subject: [PATCH 0356/1580] Fix metrics table migration (#29250) --- core/metrics/db.go | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/core/metrics/db.go b/core/metrics/db.go index 06809f30f..d84fcdffa 100644 --- a/core/metrics/db.go +++ b/core/metrics/db.go @@ -182,23 +182,24 @@ func SetupSchema() error { } } + rename := func(from, to string) error { + if table := new(meter); m.HasColumn(table, from) && !m.HasColumn(table, to) { + return m.RenameColumn(table, from, to) + } + return nil + } + // meter: split energy direction - if old := "val"; m.HasColumn(new(meter), old) { - if err := m.RenameColumn(new(meter), old, "import"); err != nil { - return err - } - } else { - // meter: split energy direction #2 - if old := "pos"; m.HasColumn(new(meter), old) { - if err := m.RenameColumn(new(meter), old, "import"); err != nil { - return err - } - } - if old := "neg"; m.HasColumn(new(meter), old) { - if err := m.RenameColumn(new(meter), old, "export"); err != nil { - return err - } - } + if err := rename("val", "import"); err != nil { + return err + } + + // meter: split energy direction #2 + if err := rename("pos", "import"); err != nil { + return err + } + if err := rename("neg", "export"); err != nil { + return err } return db.Instance.AutoMigrate(new(meter)) From 1617f4ef544b767705b727f48af544f6c0b65cf0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:09:36 +0000 Subject: [PATCH 0357/1580] Remove YAML-specific device template input hints (#29259) --- templates/definition/charger/evbox-livo.yaml | 4 ++-- templates/definition/meter/solax-hybrid-cloud.yaml | 4 ++-- templates/definition/meter/solax-inverter-cloud.yaml | 4 ++-- util/templates/defaults.yaml | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/templates/definition/charger/evbox-livo.yaml b/templates/definition/charger/evbox-livo.yaml index cb6d119e7..5c5f35cc6 100644 --- a/templates/definition/charger/evbox-livo.yaml +++ b/templates/definition/charger/evbox-livo.yaml @@ -6,8 +6,8 @@ products: requirements: evcc: ["eebus"] description: - de: Das Gerät benötigt eine feste IP Adresse. Es ist wichtig, zuerst EEBus einzurichten. Danach erkennt das Ladegerät evcc als HEMS-Gerät im Netzwerk. Verwende das Installationstool, um evcc als HEMS auszuwählen. Kopiere anschließend den angegebenen SKI aus der Installations-App und fügen ihn zu Ihrem YAML hinzu. - en: The device requires a fixed IP addres. It's important to set up EEBus first. After setting up EEBus the charger will recognize evcc as a HEMS device on the network. Please use the installer tool to select evcc as HEMS. After this has been done, copy the given SKI from the Install app and add it to your yaml. + de: Das Gerät benötigt eine feste IP Adresse. Es ist wichtig, zuerst EEBus einzurichten. Danach erkennt das Ladegerät evcc als HEMS-Gerät im Netzwerk. Verwende das Installationstool, um evcc als HEMS auszuwählen. Kopiere anschließend den angegebenen SKI aus der Installations-App und füge ihn zur Konfiguration hinzu. + en: The device requires a fixed IP address. It's important to set up EEBus first. After setting up EEBus the charger will recognize evcc as a HEMS device on the network. Please use the installer tool to select evcc as HEMS. After this has been done, copy the given SKI from the Install app and add it to the configuration. params: - preset: eebus render: | diff --git a/templates/definition/meter/solax-hybrid-cloud.yaml b/templates/definition/meter/solax-hybrid-cloud.yaml index 680d2565c..693f13833 100644 --- a/templates/definition/meter/solax-hybrid-cloud.yaml +++ b/templates/definition/meter/solax-hybrid-cloud.yaml @@ -22,8 +22,8 @@ params: description: generic: SolaxCloud TokenID help: - de: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Drittanbieter-Ökosystem (alte Website) oder Dienst -> API (neue Website), den Wert von `tokenID` hier in Anführungszeichen eintragen (Beispiel: '20241028488283838')" - en: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Third-party Ecology (old site) or Service -> API (new site), put the value of `tokenID` here in single quotes (Example: '20241028488283838')" + de: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Drittanbieter-Ökosystem (alte Website) oder Dienst -> API (neue Website), den Wert von `tokenID` hier eintragen (Beispiel: 20241028488283838)" + en: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Third-party Ecology (old site) or Service -> API (new site), enter the value of `tokenID` here (Example: 20241028488283838)" - name: serial required: true description: diff --git a/templates/definition/meter/solax-inverter-cloud.yaml b/templates/definition/meter/solax-inverter-cloud.yaml index d43952b51..86d5c8b1d 100644 --- a/templates/definition/meter/solax-inverter-cloud.yaml +++ b/templates/definition/meter/solax-inverter-cloud.yaml @@ -22,8 +22,8 @@ params: description: generic: SolaxCloud TokenID help: - de: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Drittanbieter-Ökosystem (alte Website) oder Dienst -> API (neue Website), den Wert von `tokenID` hier in Anführungszeichen eintragen (Beispiel: '20241028488283838')" - en: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Third-party Ecology (old site) or Service -> API (new site), put the value of `tokenID` here in single quotes (Example: '20241028488283838')" + de: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Drittanbieter-Ökosystem (alte Website) oder Dienst -> API (neue Website), den Wert von `tokenID` hier eintragen (Beispiel: 20241028488283838)" + en: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Third-party Ecology (old site) or Service -> API (new site), enter the value of `tokenID` here (Example: 20241028488283838)" - name: serial required: true description: diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index 576df08c3..3b98dec06 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -59,8 +59,8 @@ params: de: Passwort en: Password help: - de: Bei führenden Nullen bitte in einfache Hochkommata setzen - en: Use single quotes in case of leading zeros + de: Zugangspasswort des Dienstes + en: Service account password mask: true - name: capacity unit: kWh @@ -292,8 +292,8 @@ params: en: Actor Identification Number (AIN) de: Aktoridentifikationsnummer (AIN) help: - en: Printed on the type label on the back of the device. Embed it in double quotes in case of leading zeroes. - de: Ist auf dem Typenschild auf der Geräterückseite aufgedruckt. Bei führenden Nullen bitte in doppelte Hochkommata setzen. + en: Printed on the type label on the back of the device. + de: Ist auf dem Typenschild auf der Geräterückseite aufgedruckt. - name: coarsecurrent type: bool description: From 63459e18e99a16eac473b49625425d820cd318d8 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 20 Apr 2026 09:02:53 +0200 Subject: [PATCH 0358/1580] Forecast: temporarily remove adjustment (2) (#29260) --- assets/js/settings.ts | 2 +- assets/js/views/Forecast.vue | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/assets/js/settings.ts b/assets/js/settings.ts index 9ea7fcff8..1657c0bc1 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -151,7 +151,7 @@ const settings: Settings = reactive({ savingsIndicator: read(SAVINGS_INDICATOR), sessionsGroup: read(SESSIONS_GROUP), sessionsType: read(SESSIONS_TYPE), - solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), + solarAdjusted: false, //readBool(SETTINGS_SOLAR_ADJUSTED), # temporarily disable, https://github.com/evcc-io/evcc/issues/29165 priceZoom: readBool(SETTINGS_PRICE_ZOOM), hideFeedin: readBool(SETTINGS_HIDE_FEEDIN), loadpoints: readJSON(LOADPOINTS), diff --git a/assets/js/views/Forecast.vue b/assets/js/views/Forecast.vue index c1cd0b682..d15863a4a 100644 --- a/assets/js/views/Forecast.vue +++ b/assets/js/views/Forecast.vue @@ -243,7 +243,8 @@ export default defineComponent({ return true; }, showSolarAdjust() { - return !!this.forecast.solar && this.experimental; + // TODO fix https://github.com/evcc-io/evcc/issues/29165 + return !!this.forecast.solar && this.experimental && false; }, solar() { return this.showSolarAdjust && this.solarAdjusted From 0918c62b44ecb7f698a5ae946f04af247ce7779a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:16:02 +0200 Subject: [PATCH 0359/1580] Sungrow iHomeManager: fix voltage and per-phase active power decoding (#27985) --- templates/definition/meter/sungrow-ihm.yaml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/templates/definition/meter/sungrow-ihm.yaml b/templates/definition/meter/sungrow-ihm.yaml index 06cb6d664..d58e1f131 100644 --- a/templates/definition/meter/sungrow-ihm.yaml +++ b/templates/definition/meter/sungrow-ihm.yaml @@ -36,21 +36,21 @@ render: | - source: modbus {{- include "modbus" . | indent 2 }} register: - address: 8554 # Register 8555 (A-phase voltage) + address: 4356 # Register 4357 (Phase A voltage, phase-to-neutral) type: input decode: uint16 scale: 0.1 # Factor 0.1 V - source: modbus {{- include "modbus" . | indent 2 }} register: - address: 8555 # Register 8556 (B-phase voltage) + address: 4358 # Register 4359 (Phase B voltage, phase-to-neutral) type: input decode: uint16 scale: 0.1 - source: modbus {{- include "modbus" . | indent 2 }} register: - address: 8556 # Register 8557 (C-phase voltage) + address: 4360 # Register 4361 (Phase C voltage, phase-to-neutral) type: input decode: uint16 scale: 0.1 @@ -58,24 +58,21 @@ render: | - source: modbus {{- include "modbus" . | indent 2 }} register: - address: 8558 # Register 8559 (Phase A active power, U32) + address: 8558 # Register 8559 (Phase A active power, S32, signed import/export) type: input - decode: uint32s - scale: 1 # Factor 1 W + decode: int32s - source: modbus {{- include "modbus" . | indent 2 }} register: address: 8560 # Register 8561 (Phase B active power) type: input - decode: uint32s - scale: 1 + decode: int32s - source: modbus {{- include "modbus" . | indent 2 }} register: address: 8562 # Register 8563 (Phase C active power) type: input - decode: uint32s - scale: 1 + decode: int32s {{- end }} {{- if eq .usage "pv" }} type: custom From 4bfc1ba6e9b95b6be6546bcdcf25e0c437e9d6ea Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 20 Apr 2026 16:33:33 +0200 Subject: [PATCH 0360/1580] myPV AC Thor: support extra heater on relay (#29266) --- charger/mypv.go | 67 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/charger/mypv.go b/charger/mypv.go index cf6fc386b..67982419f 100644 --- a/charger/mypv.go +++ b/charger/mypv.go @@ -46,13 +46,18 @@ type MyPv struct { } const ( - elwaRegSetPower = 1000 - elwaRegTempLimit = 1002 - elwaRegStatus = 1003 - elwaRegLoadState = 1059 - elwaRegPower = 1000 // https://github.com/evcc-io/evcc/issues/18020#issuecomment-2585300804 - elwaRegOperationState = 1077 - elwaERegOperationState = elwaRegStatus // same register for elwa-e operation state + elwaRegSetPower = 1000 + elwaRegTempLimit = 1002 + elwaRegStatus = 1003 + elwaRegLoadState = 1059 + elwaRegPower = 1000 // https://github.com/evcc-io/evcc/issues/18020#issuecomment-2585300804 + elwaRegOperationState = 1077 + elwaERegOperationState = elwaRegStatus // same register for elwa-e operation state + elwaRegRelayState = 1058 + elwaRegVoltage = 1061 + elwaRegOperationMode = 1065 // https://github.com/evcc-io/evcc/discussions/23708 + elwaRegMaxControlledPower = 1014 // max. power for linear controlled output + elwaRegMaxCombinedPower = 1071 // (max. power for linear controlled output + configured relais power) * 1.10 ) var elwaTemp = []uint16{1001, 1030, 1031} @@ -266,6 +271,7 @@ func (wb *MyPv) MaxCurrentMillis(current float64) error { phases = p } } + power := uint16(voltage * current * float64(phases)) err := wb.setPower(power) @@ -285,7 +291,52 @@ func (wb *MyPv) CurrentPower() (float64, error) { return 0, err } - return float64(binary.BigEndian.Uint16(b)), nil + res := float64(binary.BigEndian.Uint16(b)) + if wb.name != "ac-thor" { + return res, nil + } + + c, err := wb.conn.ReadHoldingRegisters(elwaRegOperationMode, 1) + if err != nil { + return 0, err + } + wb.log.TRACE.Printf("operation mode %d", binary.BigEndian.Uint16(c)) + + // AC Thor operation mode != 3 + if binary.BigEndian.Uint16(c) != 3 { + return res, nil + } + + // AC Thor operation mode == 3 "Warm water 9 + 9kW" + // with extra heater on internal relay + // see https://github.com/evcc-io/evcc/discussions/23708 + f, err := wb.conn.ReadHoldingRegisters(elwaRegRelayState, 1) + if err != nil { + return 0, err + } + + // relay inactive + if binary.BigEndian.Uint16(f) != 1 { + return res, nil + } + + // get power of heater on relay as set in web interface + // (scale factor must be used for correct setting in web interface) + d, err := wb.conn.ReadHoldingRegisters(elwaRegMaxControlledPower, 1) + if err != nil { + return 0, err + } + + e, err := wb.conn.ReadHoldingRegisters(elwaRegMaxCombinedPower, 1) + if err != nil { + return 0, err + } + wb.log.TRACE.Printf("max. power: controlled %.0f W / combined %.0f W", float64(binary.BigEndian.Uint16(d)), float64(binary.BigEndian.Uint16(e))) + + // relay power = combined power - controlled power, finally corrected with 110% factor + res += float64(int(binary.BigEndian.Uint16(e))-int(binary.BigEndian.Uint16(d))) / wb.scale / 1.1 + + return res, nil } var _ api.Battery = (*MyPv)(nil) From 7d2f507f3a15647446a0270d406b680a222cce3c Mon Sep 17 00:00:00 2001 From: Mathias Walter Date: Tue, 21 Apr 2026 05:54:45 +0200 Subject: [PATCH 0361/1580] Log UI: sort the log area multiselect naturally (#29289) --- assets/js/views/Log.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/assets/js/views/Log.vue b/assets/js/views/Log.vue index 601be30af..436e8e0a7 100644 --- a/assets/js/views/Log.vue +++ b/assets/js/views/Log.vue @@ -171,7 +171,10 @@ export default defineComponent({ }); }, areaOptions() { - return this.availableAreas.map((area) => ({ name: area, value: area })); + return this.availableAreas + .slice() + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) + .map((area) => ({ name: area, value: area })); }, areasLabel() { if (this.areas.length === 0) { From 1d3a963dc82d3f8fd540daa147b7b8f4e71d90fb Mon Sep 17 00:00:00 2001 From: VallahDieWaldfee <164005630+VallahDieWaldfee@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:35:57 +0200 Subject: [PATCH 0362/1580] Nibe S-series: update docs (#29293) --- .../definition/charger/nibe-s-series.yaml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/templates/definition/charger/nibe-s-series.yaml b/templates/definition/charger/nibe-s-series.yaml index c9cae6d39..d943e48ce 100644 --- a/templates/definition/charger/nibe-s-series.yaml +++ b/templates/definition/charger/nibe-s-series.yaml @@ -7,16 +7,21 @@ group: heating requirements: description: de: | - Modbus Verbindung zur NIBE S Wärmepumpe. Firmware 4.7.5 oder neuer benötigt. - In der Inneneinheit muss im Menü 7.5.9 Modbus aktiviert sein. - Zur Verwendung der Leistungssteuerung muss im Menü 7.4 unter dem Punkt AUX von Modbus ext. Leistungsbegrenzung ausgewählt sein. - Es muss einmalig im Holding Register 3032 der Wert `1` (uint8) geschrieben werden. + Modbus Verbindung zur NIBE S Wärmepumpe. + Firmware 4.7.5 oder neuer benötigt. + Benötigte Einstellungen in der Steuereinheit: + - In der Inneneinheit muss im Menü 7.5.9 Modbus aktiviert sein. + - Zur Verwendung der Leistungssteuerung muss im Menü 7.4 unter dem Punkt AUX von Modbus ext. Leistungsbegrenzung ausgewählt sein. In den Ladepunkteinstellungen muss eine minimale Leistung von 1,2kW (1-phasig) eingestellt werden. Sollte der Normalverbrauch höher sein, muss dieser als minimale Leistung eingestellt werden. + - Im Menü 7.4 dürfen die Optionen SG-Ready A und SG-Ready B nicht vergeben sein. + - Es muss einmalig im Holding Register 3032 der Wert `1` (uint8) geschrieben werden. en: | Modbus connection to the NIBE S heat pump. Firmware version 4.7.5 or newer is required. - In the indoor unit, Modbus must be enabled in menu 7.5.9 - To use the power control function, external power limitation must be selected under Menu 7.4, item AUX from modbus. - The value `1` (uint8) must be written once to holding register 3032. + Required settings in the control unit: + - In the indoor unit, Modbus must be enabled in menu 7.5.9 + - To use power control, “external power limitation via Modbus” must be selected under AUX in menu 7.4. In the charging point settings, a minimum power of 1.2 kW (single-phase) must be configured. If the base load is higher, it must be set as the minimum power. + - In menu 7.4, the options “SG-Ready A” and “SG-Ready B” must not be assigned. + - The value `1` (uint8) must be written once to holding register 3032. params: - name: modbus choice: ["tcpip"] From 2fb900c8eae7dc06482384a64d7c3dd30c4d2390 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 21 Apr 2026 10:31:19 +0200 Subject: [PATCH 0363/1580] History UI: small fixes (#29138) --- assets/js/components/History/Chart.vue | 14 +++++++---- assets/js/views/History.vue | 4 ++++ core/metrics/db.go | 7 +++--- core/metrics/db_test.go | 33 ++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/assets/js/components/History/Chart.vue b/assets/js/components/History/Chart.vue index 874388cf4..4e71801fc 100644 --- a/assets/js/components/History/Chart.vue +++ b/assets/js/components/History/Chart.vue @@ -72,10 +72,14 @@ export default defineComponent({ const color = colors.palette[i % colors.palette.length]; const fill = dimColor(color); - const points = s.data.map((slot) => ({ - x: new Date(slot.start).getTime(), - y: slotPower(slot, "import") - slotPower(slot, "export"), - })); + const points = s.data.map((slot) => { + const start = new Date(slot.start).getTime(); + const end = new Date(slot.end).getTime(); + return { + x: (start + end) / 2, + y: slotPower(slot, "import") - slotPower(slot, "export"), + }; + }); return { label: s.name, @@ -85,7 +89,7 @@ export default defineComponent({ fill: true, pointRadius: 0, borderWidth: 1.5, - tension: 0.3, + tension: 0.05, }; }); diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index 4d818ebca..a346f2d97 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -1,6 +1,10 @@ @@ -31,6 +32,8 @@ import Item from "./Item.vue"; import MoreIcon from "../MaterialIcon/More.vue"; import MoreMenu from "./MoreMenu.vue"; import { isUserConfigError } from "@/utils/fatal"; +import { isNewVersionAvailable, isNewVersionUnacknowledged } from "@/utils/version"; +import settings from "@/settings"; import type { FatalError, Sponsor, EvOpt, AuthProviders } from "@/types/evcc"; export default defineComponent({ @@ -46,6 +49,7 @@ export default defineComponent({ evopt: { type: Object as PropType, required: false }, installed: String, commit: String, + availableVersion: String, }, data() { return { open: false }; @@ -69,14 +73,27 @@ export default defineComponent({ showConfigBadge() { return this.sponsorExpires || isUserConfigError(this.fatal); }, + newVersionAvailable() { + return isNewVersionAvailable(this.installed, this.availableVersion); + }, + showVersionBadge() { + return isNewVersionUnacknowledged( + this.installed, + this.availableVersion, + settings.lastAcknowledgedVersion + ); + }, showRootBadge() { - return this.authorizationRequired || this.showConfigBadge; + return this.authorizationRequired || this.showConfigBadge || this.showVersionBadge; }, badgeClass() { if (this.fatal.length > 0) { return "bg-danger"; } - return "bg-warning"; + if (this.authorizationRequired || this.showConfigBadge) { + return "bg-warning"; + } + return "bg-darker-green"; }, }, methods: { diff --git a/assets/js/components/BottomTabs/MoreMenu.vue b/assets/js/components/BottomTabs/MoreMenu.vue index f27d2704d..1749ae2d3 100644 --- a/assets/js/components/BottomTabs/MoreMenu.vue +++ b/assets/js/components/BottomTabs/MoreMenu.vue @@ -11,8 +11,14 @@ class="dropdown-item d-flex align-items-center" @click="openAboutModal" > + evcc {{ versionLabel }} + +
    + + +
    @@ -68,11 +94,21 @@ import "@h2d2/shopicons/es/filled/lightning"; import Loadpoint from "./Loadpoint.vue"; import { defineComponent, type PropType } from "vue"; -import type { UiLoadpoint, SMART_COST_TYPE, Timeout, Vehicle, BATTERY_MODE } from "@/types/evcc"; +import type { + UiLoadpoint, + SMART_COST_TYPE, + Timeout, + Vehicle, + BATTERY_MODE, + Forecast, + CURRENCY, +} from "@/types/evcc"; +import ChargingPlanModal from "../ChargingPlans/ChargingPlanModal.vue"; +import SettingsModal from "../Loadpoints/SettingsModal.vue"; export default defineComponent({ name: "Loadpoints", - components: { Loadpoint }, + components: { Loadpoint, ChargingPlanModal, SettingsModal }, props: { loadpoints: { type: Array as PropType, default: () => [] }, vehicles: { type: Array as PropType }, @@ -82,14 +118,14 @@ export default defineComponent({ tariffGrid: Number, tariffCo2: Number, tariffFeedIn: Number, - currency: String, + currency: String as PropType, selectedId: String, gridConfigured: Boolean, pvConfigured: Boolean, batteryConfigured: Boolean, batterySoc: Number, batteryMode: String as PropType, - forecast: Object, // as PropType, + forecast: Object as PropType, }, emits: ["id-changed"], data() { @@ -188,6 +224,25 @@ export default defineComponent({ } }, 1000); }, + openChargingPlanModal(loadpointId: string, openArrivalTab = false) { + const modal = this.$refs["chargingPlanModal"] as + | InstanceType + | undefined; + + if (openArrivalTab) { + modal?.showArrivalTab(); + } else { + modal?.showDepartureTab(); + } + + modal?.open(loadpointId); + }, + openSettingsModal(loadpointId: string) { + const modal = this.$refs["settingsModal"] as + | InstanceType + | undefined; + modal?.open(loadpointId); + }, }, }); diff --git a/assets/js/components/Loadpoints/SettingsModal.vue b/assets/js/components/Loadpoints/SettingsModal.vue index 34d254b93..e3a11a253 100644 --- a/assets/js/components/Loadpoints/SettingsModal.vue +++ b/assets/js/components/Loadpoints/SettingsModal.vue @@ -1,30 +1,31 @@ + + diff --git a/assets/js/components/Sessions/EnergyGroupedChart.vue b/assets/js/components/Sessions/EnergyGroupedChart.vue index 18c39eb21..31e69bd9c 100644 --- a/assets/js/components/Sessions/EnergyGroupedChart.vue +++ b/assets/js/components/Sessions/EnergyGroupedChart.vue @@ -1,7 +1,7 @@