From fd72f8d25600ce2da2f0135cf0262d42d7d5d066 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 30 Jul 2026 22:23:49 +0200 Subject: [PATCH 001/331] Cardata: delete obsolete streaming containers (#32314) --- vehicle/bmw/cardata/api.go | 5 ++++- vehicle/bmw/cardata/mqtt.go | 2 +- vehicle/bmw/cardata/provider.go | 21 +++++++++++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/vehicle/bmw/cardata/api.go b/vehicle/bmw/cardata/api.go index 219baa824..a8857708c 100644 --- a/vehicle/bmw/cardata/api.go +++ b/vehicle/bmw/cardata/api.go @@ -33,7 +33,10 @@ var requiredKeys = []string{ "vehicle.vehicle.travelledDistance", } -const requiredVersion = "v6" +const ( + containerName = "evcc.io" + requiredVersion = "v6" +) type API struct { *request.Helper diff --git a/vehicle/bmw/cardata/mqtt.go b/vehicle/bmw/cardata/mqtt.go index 8b0801139..94fb9cf6c 100644 --- a/vehicle/bmw/cardata/mqtt.go +++ b/vehicle/bmw/cardata/mqtt.go @@ -205,7 +205,7 @@ func (v *MqttConnector) handler(_ mqtt.Client, m mqtt.Message) { return } - v.log.TRACE.Println("recv: " + string(m.Payload())) + v.log.TRACE.Printf("recv %s: %s", m.Topic(), string(m.Payload())) v.mu.RLock() defer v.mu.RUnlock() diff --git a/vehicle/bmw/cardata/provider.go b/vehicle/bmw/cardata/provider.go index cf9ab0e93..dd47620f1 100644 --- a/vehicle/bmw/cardata/provider.go +++ b/vehicle/bmw/cardata/provider.go @@ -71,14 +71,17 @@ func (v *Provider) findOrCreateContainer() (string, error) { return "", err } + // obsolete containers keep streaming, resulting in duplicate messages + defer v.deleteObsoleteContainers(containers) + if i := slices.IndexFunc(containers, func(c Container) bool { - return c.Name == "evcc.io" && c.Purpose == requiredVersion + return c.Name == containerName && c.Purpose == requiredVersion }); i >= 0 { return containers[i].ContainerId, nil } res, err := v.api.CreateContainer(CreateContainer{ - Name: "evcc.io", + Name: containerName, Purpose: requiredVersion, TechnicalDescriptors: requiredKeys, }) @@ -86,6 +89,20 @@ func (v *Provider) findOrCreateContainer() (string, error) { return res.ContainerId, err } +func (v *Provider) deleteObsoleteContainers(containers []Container) { + for _, c := range containers { + if c.Name != containerName || c.Purpose == requiredVersion { + continue + } + + v.log.DEBUG.Printf("deleting obsolete container %s (%s)", c.ContainerId, c.Purpose) + + if err := v.api.DeleteContainer(c.ContainerId); err != nil { + v.log.WARN.Printf("delete container %s: %v", c.ContainerId, err) + } + } +} + func (v *Provider) setupContainer() error { container, err := v.findOrCreateContainer() if err != nil { From f70a33e4666f45df539f531e6dc2062c91d2f109 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 31 Jul 2026 09:48:53 +0200 Subject: [PATCH 002/331] chore: pin clock in device colors test (#32325) --- tests/device-colors.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/device-colors.spec.ts b/tests/device-colors.spec.ts index 19357b457..5c13d51bb 100644 --- a/tests/device-colors.spec.ts +++ b/tests/device-colors.spec.ts @@ -33,6 +33,10 @@ function chartSection(page: Page, heading: string): Locator { } test("device colors: autoassign, override, persistence", async ({ page }) => { + // fixture sessions are dated 2026-05. Sessions.vue ranks loadpoints by energy + // over the last three months relative to now, so pin the clock to keep them in range + await page.clock.setFixedTime(new Date("2026-05-15T12:00:00Z")); + // ---------- Step 1 — Sessions, by-vehicle view ---------- await page.goto("/#/sessions?year=2026&month=5"); await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible(); From 3c75a656519dcdf221dd108ff14a25a2410ae8f8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 31 Jul 2026 09:55:29 +0200 Subject: [PATCH 003/331] Viessmann: discover installation id and gateway serial (#32308) --- charger/viessmann.go | 5 ++ charger/viessmann/api.go | 50 +++++++++++ charger/viessmann/api_test.go | 51 +++++++++++ charger/viessmann/oauth.go | 55 ++++++++++++ charger/viessmann/service.go | 94 +++++++++++++++++++++ charger/viessmann/service_test.go | 36 ++++++++ plugin/auth/viessmann.go | 50 ----------- templates/definition/charger/viessmann.yaml | 18 +++- 8 files changed, 305 insertions(+), 54 deletions(-) create mode 100644 charger/viessmann.go create mode 100644 charger/viessmann/api.go create mode 100644 charger/viessmann/api_test.go create mode 100644 charger/viessmann/oauth.go create mode 100644 charger/viessmann/service.go create mode 100644 charger/viessmann/service_test.go delete mode 100644 plugin/auth/viessmann.go diff --git a/charger/viessmann.go b/charger/viessmann.go new file mode 100644 index 000000000..8663fe315 --- /dev/null +++ b/charger/viessmann.go @@ -0,0 +1,5 @@ +package charger + +// Viessmann heat pumps are configured by template using the http plugin. +// The device package provides the OAuth token source and the config service. +import _ "github.com/evcc-io/evcc/charger/viessmann" diff --git a/charger/viessmann/api.go b/charger/viessmann/api.go new file mode 100644 index 000000000..e47ff7489 --- /dev/null +++ b/charger/viessmann/api.go @@ -0,0 +1,50 @@ +package viessmann + +import ( + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "golang.org/x/oauth2" +) + +// Gateway is a Viessmann communication module. +type Gateway struct { + Serial string `json:"serial"` +} + +// Installation is a Viessmann installation including its gateways. +type Installation struct { + ID int `json:"id"` + Gateways []Gateway `json:"gateways"` +} + +// API is the Viessmann IoT API client. +type API struct { + *request.Helper + uri string +} + +// NewAPI creates a new api client +func NewAPI(log *util.Logger, uri string, ts oauth2.TokenSource) *API { + v := &API{ + Helper: request.NewHelper(log), + uri: uri, + } + + v.Client.Transport = &oauth2.Transport{ + Source: ts, + Base: v.Client.Transport, + } + + return v +} + +// Installations returns the account's installations including their gateways. +func (v *API) Installations() ([]Installation, error) { + var res struct { + Data []Installation `json:"data"` + } + + err := v.GetJSON(v.uri+"/equipment/installations?includeGateways=true", &res) + + return res.Data, err +} diff --git a/charger/viessmann/api_test.go b/charger/viessmann/api_test.go new file mode 100644 index 000000000..ddba76141 --- /dev/null +++ b/charger/viessmann/api_test.go @@ -0,0 +1,51 @@ +package viessmann + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/evcc-io/evcc/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func testAPI(t *testing.T, handler http.HandlerFunc) *API { + t.Helper() + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "token"}) + + return NewAPI(util.NewLogger("viessmann"), srv.URL, ts) +} + +func TestInstallations(t *testing.T) { + api := testAPI(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/equipment/installations", r.URL.Path) + require.Equal(t, "true", r.URL.Query().Get("includeGateways")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[ + {"id":3242119,"gateways":[{"serial":"7472258009383262"},{"serial":"7472258009383263"}]}, + {"id":1000001,"gateways":[{"serial":"9999999999999999"}]} + ]}`)) + }) + + res, err := api.Installations() + require.NoError(t, err) + require.Len(t, res, 2) + assert.Equal(t, 3242119, res[0].ID) + assert.Len(t, res[0].Gateways, 2) + assert.Equal(t, "7472258009383262", res[0].Gateways[0].Serial) +} + +func TestInstallationsError(t *testing.T) { + api := testAPI(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + }) + + _, err := api.Installations() + require.Error(t, err) +} diff --git a/charger/viessmann/oauth.go b/charger/viessmann/oauth.go new file mode 100644 index 000000000..e5c804ae9 --- /dev/null +++ b/charger/viessmann/oauth.go @@ -0,0 +1,55 @@ +package viessmann + +import ( + "context" + + "github.com/evcc-io/evcc/plugin/auth" + "github.com/evcc-io/evcc/util" + "golang.org/x/oauth2" +) + +const ( + // OAuthURI is the Viessmann IAM base URL. + OAuthURI = "https://iam.viessmann-climatesolutions.com/idp/v3" + // ApiURI is the Viessmann IoT API base URL. + ApiURI = "https://api.viessmann-climatesolutions.com/iot/v2" +) + +func init() { + auth.Register("viessmann", func(other map[string]any) (oauth2.TokenSource, error) { + var cc struct { + ClientID string + RedirectURI string + Gateway string `mapstructure:"gateway_serial"` + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + log := util.NewLogger("viessmann").Redact(cc.ClientID) + ctx := util.WithLogger(context.Background(), log) + + return NewOAuth(ctx, cc.ClientID, cc.RedirectURI, cc.Gateway) + }) +} + +// OAuthConfig returns the Viessmann IoT API OAuth2 config. +func OAuthConfig(clientID, redirectURI string) *oauth2.Config { + return &oauth2.Config{ + ClientID: clientID, + RedirectURL: redirectURI, + Endpoint: oauth2.Endpoint{ + AuthURL: OAuthURI + "/authorize", + TokenURL: OAuthURI + "/token", + AuthStyle: oauth2.AuthStyleInHeader, + }, + Scopes: []string{"IoT User", "offline_access"}, + } +} + +// NewOAuth creates the Viessmann IoT API token source using the authorization +// code flow. The user authorizes interactively via the evcc UI. +func NewOAuth(ctx context.Context, clientID, redirectURI, device string) (oauth2.TokenSource, error) { + return auth.NewOAuth(ctx, "Viessmann", device, OAuthConfig(clientID, redirectURI)) +} diff --git a/charger/viessmann/service.go b/charger/viessmann/service.go new file mode 100644 index 000000000..60de9b122 --- /dev/null +++ b/charger/viessmann/service.go @@ -0,0 +1,94 @@ +package viessmann + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "slices" + "strconv" + + "github.com/evcc-io/evcc/server/service" + "github.com/evcc-io/evcc/util" +) + +var serviceMux = http.NewServeMux() + +func init() { + serviceMux.HandleFunc("GET /equipment", getEquipment) + + service.Register("viessmann", serviceMux) +} + +// errUnauthorized indicates that the user has not (yet) authorized. The config +// UI polls the service while the form is being filled, hence this is expected. +var errUnauthorized = errors.New("unauthorized") + +// equipment reuses the OAuth instance created for the same client, so results +// appear once the user has authorized via the UI. +func equipment(req *http.Request) ([]Installation, error) { + q := req.URL.Query() + clientID, redirectURI := q.Get("clientid"), q.Get("redirecturi") + + if clientID == "" { + return nil, errUnauthorized + } + + log := util.NewLogger("viessmann").Redact(clientID) + ctx := util.WithLogger(context.Background(), log) + + ts, err := NewOAuth(ctx, clientID, redirectURI, q.Get("gateway_serial")) + if err != nil { + return nil, err + } + + // no values until the user has authorized + if _, err := ts.Token(); err != nil { + return nil, errUnauthorized + } + + return NewAPI(log, ApiURI, ts).Installations() +} + +// values extracts the installation ids or, with gateways set, the deduplicated +// gateway serials of the given installations. +func values(installations []Installation, gateways bool) []string { + res := []string{} + + for _, inst := range installations { + if !gateways { + res = append(res, strconv.Itoa(inst.ID)) + continue + } + + for _, gw := range inst.Gateways { + if !slices.Contains(res, gw.Serial) { + res = append(res, gw.Serial) + } + } + } + + slices.Sort(res) + + return res +} + +// getEquipment lists the account's installation ids or, with detail=gateways, +// their gateway serials, driving the respective selection in the template. +func getEquipment(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + + res := []string{} + defer func() { _ = json.NewEncoder(w).Encode(res) }() + + installations, err := equipment(req) + if err != nil { + // unexpected errors are worth logging, missing authorization is not + if !errors.Is(err, errUnauthorized) { + util.NewLogger("viessmann").ERROR.Println(err) + } + return + } + + res = values(installations, req.URL.Query().Get("detail") == "gateways") +} diff --git a/charger/viessmann/service_test.go b/charger/viessmann/service_test.go new file mode 100644 index 000000000..82f6b67ed --- /dev/null +++ b/charger/viessmann/service_test.go @@ -0,0 +1,36 @@ +package viessmann + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValues(t *testing.T) { + installations := []Installation{ + {ID: 3242119, Gateways: []Gateway{{Serial: "7472258009383263"}, {Serial: "7472258009383262"}}}, + {ID: 1000001, Gateways: []Gateway{{Serial: "7472258009383262"}}}, + } + + assert.Equal(t, []string{"1000001", "3242119"}, values(installations, false)) + assert.Equal(t, []string{"7472258009383262", "7472258009383263"}, values(installations, true)) + + // empty list, not null + assert.Equal(t, []string{}, values(nil, false)) + assert.Equal(t, []string{}, values(nil, true)) +} + +// The service handler must return an empty JSON list when the user has not +// authorized yet, as the config UI polls it while the form is being filled. +func TestServiceHandlerUnauthorized(t *testing.T) { + for _, query := range []string{"", "&detail=gateways"} { + req := httptest.NewRequest(http.MethodGet, "/equipment?clientid=&redirecturi="+query, nil) + w := httptest.NewRecorder() + serviceMux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, query) + assert.JSONEq(t, "[]", w.Body.String(), query) + } +} diff --git a/plugin/auth/viessmann.go b/plugin/auth/viessmann.go deleted file mode 100644 index 0c7a8a98b..000000000 --- a/plugin/auth/viessmann.go +++ /dev/null @@ -1,50 +0,0 @@ -package auth - -import ( - "context" - - "dario.cat/mergo" - "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/request" - "golang.org/x/oauth2" -) - -const OAuthURI = "https://iam.viessmann-climatesolutions.com/idp/v3" - -var oauthConfig = oauth2.Config{ - Endpoint: oauth2.Endpoint{ - AuthURL: OAuthURI + "/authorize", - TokenURL: OAuthURI + "/token", - AuthStyle: oauth2.AuthStyleInHeader, - }, - Scopes: []string{"IoT User", "offline_access"}, -} - -func init() { - registry.AddCtx("viessmann", NewViessmannFromConfig) -} - -func NewViessmannFromConfig(ctx context.Context, other map[string]any) (oauth2.TokenSource, error) { - var cc struct { - ClientID string - RedirectURI string - Gateway string `mapstructure:"gateway_serial"` - } - - if err := util.DecodeOther(other, &cc); err != nil { - return nil, err - } - - log := util.NewLogger("viessmann").Redact(cc.ClientID) - ctx = context.WithValue(ctx, oauth2.HTTPClient, request.NewClient(log)) - - oc := oauth2.Config{ - ClientID: cc.ClientID, - RedirectURL: cc.RedirectURI, - } - if err := mergo.Merge(&oc, oauthConfig); err != nil { - return nil, err - } - - return NewOAuth(ctx, "Viessmann", cc.Gateway, &oc) -} diff --git a/templates/definition/charger/viessmann.yaml b/templates/definition/charger/viessmann.yaml index c099583b9..aa2eb257a 100644 --- a/templates/definition/charger/viessmann.yaml +++ b/templates/definition/charger/viessmann.yaml @@ -28,20 +28,24 @@ params: service: auth/redirecturi - name: gateway_serial required: true + service: viessmann/equipment?detail=gateways&clientid={clientid}&redirecturi={redirecturi} description: de: Gateway Serial en: Gateway Serial help: - de: Seriennummer des VitoConnect modul (VitoCare App -> Einstellungen -> Kommunikationsmodul -> Seriennummer) - en: VitoConnect serial number (VitoCare App -> Settings -> Communication module -> Serial number) + de: Wird nach der Anmeldung automatisch ermittelt. Alternativ manuell eintragen (VitoCare App -> Einstellungen -> Kommunikationsmodul -> Seriennummer) + en: Determined automatically after login. Alternatively enter manually (VitoCare App -> Settings -> Communication module -> Serial number) - name: installation_id required: true + service: viessmann/equipment?clientid={clientid}&redirecturi={redirecturi} description: de: Installation ID en: Installation ID help: de: | - Leider kann man die Installation ID nicht einfach in der Viessmann App einsehen - stattdessen müssen wir die folgenden Kommandos in der Kommandozeile ausführen. Es ist uns bewusst, dass das nicht für jeden Benutzer einfach umsetzbar ist, aber bisher haben wir leider keinen besseren Ablauf...
+ Wird nach der Anmeldung automatisch ermittelt. + +
Fallback: Installation ID per Kommandozeile ermitteln Vorraussetzungen: curl, jq, und die folgenden Umgebungsvariblen: @@ -82,8 +86,12 @@ params: https://api.viessmann-climatesolutions.com/iot/v2/equipment/installations?includeGateways=true \ | jq '.data[].id' ``` + +
en: | - Unfortunately you cannot simply lookup this number in the Viessmann app - instead you need to use the following commands on the command line... we're aware this is not for every user, but currently we don't have a better workflow...
+ Determined automatically after login. + +
Fallback: determine the installation id on the command line Prerequisites: curl, jq, and the following parameters: @@ -124,6 +132,8 @@ params: https://api.viessmann-climatesolutions.com/iot/v2/equipment/installations?includeGateways=true \ | jq '.data[].id' ``` + +
- name: device_id required: true description: From 828f4ab2f2b14600d46fa68eea1ac11f35908137 Mon Sep 17 00:00:00 2001 From: TS-2002 <96687785+TS-2002@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:43:18 +0200 Subject: [PATCH 004/331] Fronius: add Argeno and Smart Meter IP meter templates (#32329) --- .../definition/meter/fronius-argeno.yaml | 41 +++++++ .../meter/fronius-smart-meter-ip.yaml | 105 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 templates/definition/meter/fronius-argeno.yaml create mode 100644 templates/definition/meter/fronius-smart-meter-ip.yaml diff --git a/templates/definition/meter/fronius-argeno.yaml b/templates/definition/meter/fronius-argeno.yaml new file mode 100644 index 000000000..6b36bb851 --- /dev/null +++ b/templates/definition/meter/fronius-argeno.yaml @@ -0,0 +1,41 @@ +template: fronius-argeno +products: + - brand: Fronius + description: + generic: Argeno +requirements: + description: + de: | + Modbus TCP muss am Wechselrichter aktiviert sein (Konfiguration → Kommunikation → Ethernet → Modbus TCP/UDP). Für den Netzzähler siehe Template „Fronius Smart Meter IP" — der Argeno nutzt den Smart Meter IP als eigenständiges Netzwerkgerät. + en: | + Modbus TCP must be enabled on the inverter (Configuration → Communication → Ethernet → Modbus TCP/UDP). For the grid meter see the "Fronius Smart Meter IP" template — the Argeno uses the Smart Meter IP as a standalone network device. +params: + - name: usage + choice: ["pv"] + - name: host + - name: port + default: 502 + - name: maxacpower +render: | + type: custom + # Fronius Argeno: AC output via SunSpec inverter model 113 (float, primary) + # on unit 1; 103 (int+sf) as fallback for potential firmware variants. Unlike GEN24, PV must NOT be + # read from model 160 DC channels: the Argeno's modular power stages report + # only a fraction of plant output there (verified on-site: 160-based read + # showed 6.5 kW while actual AC output was 31.7 kW). + power: + source: sunspec + uri: {{ joinHostPort .host .port }} + id: 1 + value: + - 113:W + - 103:W + energy: + source: sunspec + uri: {{ joinHostPort .host .port }} + id: 1 + value: + - 113:WH + - 103:WH + scale: 0.001 + maxacpower: {{ .maxacpower }} # W diff --git a/templates/definition/meter/fronius-smart-meter-ip.yaml b/templates/definition/meter/fronius-smart-meter-ip.yaml new file mode 100644 index 000000000..754e7a241 --- /dev/null +++ b/templates/definition/meter/fronius-smart-meter-ip.yaml @@ -0,0 +1,105 @@ +template: fronius-smart-meter-ip +products: + - brand: Fronius + description: + generic: Smart Meter IP +requirements: + description: + de: | + Modbus TCP muss in der Weboberfläche des Zählers aktiviert sein (Einstellungen → Datenschnittstellen → Modbus (TCP und RTU)). Der Zähler wird direkt über seine eigene IP-Adresse ausgelesen, nicht über den Wechselrichter. + en: | + Modbus TCP must be enabled in the meter's web interface (Settings → Data interfaces → Modbus (TCP and RTU)). The meter is read directly via its own IP address, not through the inverter. +params: + - name: usage + choice: ["grid"] + - name: host + - name: port + default: 502 + - name: id + default: 1 + advanced: true +render: | + type: custom + # Fronius Smart Meter IP (63A-1, 63A-3, 5kA-3): standalone LAN/WLAN meter. + # SunSpec model 213 (float, primary) on unit 1; 203 (int+sf) as fallback + # for potential firmware variants. + power: + source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:W + - 203:W + energy: + source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:TotWhImp + - 203:TotWhImp + scale: 0.001 + returnenergy: + source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:TotWhExp + - 203:TotWhExp + scale: 0.001 + currents: + - source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:AphA + - 203:AphA + - source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:AphB + - 203:AphB + - source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:AphC + - 203:AphC + voltages: + - source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:PhVphA + - 203:PhVphA + - source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:PhVphB + - 203:PhVphB + - source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:PhVphC + - 203:PhVphC + powers: + - source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:WphA + - 203:WphA + - source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:WphB + - 203:WphB + - source: sunspec + uri: {{ joinHostPort .host .port }} + id: {{ .id }} + value: + - 213:WphC + - 203:WphC From d07a0741ecb8481ad7a7405bcf34664703950981 Mon Sep 17 00:00:00 2001 From: Maschga <88616799+Maschga@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:43:57 +0200 Subject: [PATCH 005/331] chore: use vite+ (#32297) Co-authored-by: Michael Geers --- .github/actions/build-toolchain/action.yml | 4 +- .github/workflows/cli-docs.yml | 7 +- .github/workflows/default.yml | 16 +- .prettierignore | 1 - .vscode/extensions.json | 1 + AGENTS.md | 36 +- CONTRIBUTING.md | 13 +- Dockerfile | 2 +- Makefile | 10 +- assets/js/colors.test.ts | 2 +- .../Battery/BatteryUsageSettings.vue | 2 +- assets/js/components/Battery/history.test.ts | 2 +- .../components/ChargingPlans/Preview.test.ts | 2 +- .../js/components/ChargingPlans/Preview.vue | 2 +- .../Config/DeviceModal/index.test.ts | 2 +- assets/js/components/Config/EebusModal.vue | 2 +- .../Config/Messaging/MessengerModal.vue | 2 +- assets/js/components/Config/PropertyEntry.vue | 2 +- .../js/components/Energyflow/Energyflow.vue | 2 +- assets/js/components/Forecast/Chart.vue | 2 +- assets/js/components/Forecast/PriceChart.vue | 2 +- assets/js/components/Forecast/ValueChart.vue | 2 +- assets/js/components/History/GroupChart.vue | 31 +- assets/js/components/Issue/format.test.ts | 2 +- assets/js/components/Issue/template.test.ts | 2 +- .../js/components/Loadpoints/Loadpoints.vue | 21 +- assets/js/components/Savings/Savings.vue | 10 +- .../components/Sessions/CostHistoryChart.vue | 2 +- .../Sessions/EnergyHistoryChart.vue | 2 +- assets/js/components/Top/Header.vue | 3 +- assets/js/components/Vehicles/Status.test.ts | 2 +- assets/js/components/Vehicles/Vehicle.vue | 3 +- assets/js/configModal.test.ts | 2 +- assets/js/mixins/breakpoint.ts | 2 +- assets/js/mixins/formatter.test.ts | 2 +- assets/js/router.test.ts | 2 +- assets/js/types/evcc.ts | 8 +- assets/js/types/vue.d.ts | 2 +- assets/js/utils/circuits.test.ts | 2 +- assets/js/utils/cleanYaml.test.ts | 2 +- assets/js/utils/extractDomain.test.ts | 2 +- assets/js/utils/forecast.test.ts | 2 +- assets/js/utils/placeholder.test.ts | 2 +- assets/js/utils/tariffSlots.test.ts | 2 +- env.d.ts | 6 + eslint.config.mts | 39 - oxfmt.config.ts | 6 + package-lock.json | 4712 +++++++---------- package.json | 46 +- prettier.config.js | 11 - tests/simulator.ts | 2 +- tests/simulator/vite.config.ts | 2 +- tsconfig.json | 4 +- vite.config.ts | 52 +- vitest.config.ts | 4 +- 55 files changed, 2176 insertions(+), 2932 deletions(-) delete mode 100644 .prettierignore delete mode 100644 eslint.config.mts create mode 100644 oxfmt.config.ts delete mode 100644 prettier.config.js diff --git a/.github/actions/build-toolchain/action.yml b/.github/actions/build-toolchain/action.yml index 032cdad61..7d0f17453 100644 --- a/.github/actions/build-toolchain/action.yml +++ b/.github/actions/build-toolchain/action.yml @@ -18,10 +18,10 @@ runs: go-version-file: go.mod cache: false # managed explicitly below to keep the scoped namespaces - - uses: actions/setup-node@v6 + - uses: voidzero-dev/setup-vp@v1 with: node-version: "26" - cache: "npm" + cache: true # Writes are namespaced by cache-scope so a PR build cannot poison the # shared "main" cache; PRs still read it through the fallback restore-key. diff --git a/.github/workflows/cli-docs.yml b/.github/workflows/cli-docs.yml index 981f424d1..a55b077f7 100644 --- a/.github/workflows/cli-docs.yml +++ b/.github/workflows/cli-docs.yml @@ -26,11 +26,16 @@ jobs: with: go-version-file: go.mod + - uses: voidzero-dev/setup-vp@v1 + with: + node-version: "26" + cache: true + - name: Generate CLI docs run: mkdir dist && touch dist/empty && go run main.go gendoc ./cli-docs - name: Format - run: npx prettier@3.9.4 --no-config --write "./cli-docs/**/*.md" + run: vp fmt "./cli-docs/**/*.md" - name: Deploy CLI docs to docs repo uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4 diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 151a2281c..0584a8fcf 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -76,10 +76,10 @@ jobs: ${{ runner.os }}-go-build- ${{ runner.os }}-go- - - uses: actions/setup-node@v6 + - uses: voidzero-dev/setup-vp@v1 with: node-version: "26" - cache: "npm" + cache: true - run: mkdir dist && touch dist/empty @@ -168,10 +168,10 @@ jobs: with: persist-credentials: false - - uses: actions/setup-node@v6 + - uses: voidzero-dev/setup-vp@v1 with: node-version: "26" - cache: "npm" + cache: true - name: Install run: make install-ui @@ -230,10 +230,10 @@ jobs: ${{ runner.os }}-go-integration- ${{ runner.os }}-go- - - uses: actions/setup-node@v6 + - uses: voidzero-dev/setup-vp@v1 with: node-version: "26" - cache: "npm" + cache: true - name: Build UI run: make install-ui ui @@ -254,11 +254,11 @@ jobs: - name: Install Playwright (browsers + deps) if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npx playwright install --with-deps chromium + run: vpx playwright install --with-deps chromium timeout-minutes: 5 - name: Run tests - run: npx playwright test + run: vpx playwright test timeout-minutes: 20 env: TZ: Europe/Berlin diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 7020916a6..000000000 --- a/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -tests/custom-css.css \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 7e9078b78..ec190d867 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -5,5 +5,6 @@ "golang.go", "esbenp.prettier-vscode", "yoavbls.pretty-ts-errors", + "VoidZero.vite-plus-extension-pack" ] } diff --git a/AGENTS.md b/AGENTS.md index 9ab6288d6..04919edff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,13 +14,13 @@ This file provides guidance to AI coding agents when working with code in this r - `make build` - build Go binary only - `make ui` - build UI assets only - `make install` - install Go tools and dependencies -- `make install-ui` - install Node.js dependencies (`npm ci`) +- `make install-ui` - install Node.js dependencies (`vp install`) - `make test` - run Go tests - `make test-ui` - run frontend tests - `make lint` - run Go linting (golangci-lint) - `make lint-ui` - run frontend linting -- `npm run dev` - start Vue dev server (http://127.0.0.1:7071) -- `npm run playwright` - run integration tests +- `vp run dev` - start Vue dev server (http://127.0.0.1:7071) +- `vp run playwright` - run integration tests - `evcc --template-type [type] --template [file]` - test device templates - `make docs` - generate template documentation @@ -28,15 +28,15 @@ This file provides guidance to AI coding agents when working with code in this r 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) | -| [OCPP Forwarder](docs/agents/ocpp-forwarder.md) | OCPP proxy/forwarder (sidecar relay to upstream OCPP server, read-only mode) | -| [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 | -| [API Security](docs/agents/api-security.md) | Auth modes, JWT/API key/session, two-tier checks, credential storage | +| 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) | +| [OCPP Forwarder](docs/agents/ocpp-forwarder.md) | OCPP proxy/forwarder (sidecar relay to upstream OCPP server, read-only mode) | +| [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 | +| [API Security](docs/agents/api-security.md) | Auth modes, JWT/API key/session, two-tier checks, credential storage | ### Loading guide by task type @@ -98,7 +98,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w ## Comment Style -- Prefer self-documenting code over comments; comment the *why*, not the *what* +- Prefer self-documenting code over comments; comment the _why_, not the _what_ - Default to no comment. Only add one for a non-obvious constraint, invariant, workaround, or surprising behavior. Keep it to one line, two if necessary - Skip refs to the current task, PR, issue, or caller ("added for X flow", "see #1234"). Git history covers that - Exception: Go exported identifiers follow godoc convention. Short `// FuncName does X` summary starting with the identifier name @@ -122,7 +122,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w - `_enumer.go` - generated enum code - `*_decorators.go` - generated decorator pattern implementations - Validate interface implementations: `var _ Interface = (*Type)(nil)` -- Capabilities: register via `implement.Has`/`May` only when a capability is *conditional* (runtime/config detection, e.g. `if cp.PhaseSwitching { implement.Has(...) }`). For capabilities present on every code path, declare a plain exported method plus `var _ api.Interface = (*Type)(nil)` instead. `api.Cap` resolves static methods via direct type assertion, so unconditional `implement.Has` is redundant. A type with no conditional capabilities needs neither the `implement.Caps` embed nor `implement.New()` +- Capabilities: register via `implement.Has`/`May` only when a capability is _conditional_ (runtime/config detection, e.g. `if cp.PhaseSwitching { implement.Has(...) }`). For capabilities present on every code path, declare a plain exported method plus `var _ api.Interface = (*Type)(nil)` instead. `api.Cap` resolves static methods via direct type assertion, so unconditional `implement.Has` is redundant. A type with no conditional capabilities needs neither the `implement.Caps` embed nor `implement.New()` ### Error Handling @@ -236,9 +236,9 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w ### Essential Commands - Must build before testing executing playwright `make ui build` since it uses the binary. For manual testing assets are build and reloaded automatically (vite dev). -- Run tests: `npm run playwright` or `npx playwright test` -- Debug: `npx playwright test --debug` -- Specific test: `npx playwright test tests/config-loadpoint.spec.ts` +- Run tests: `vp run playwright` or `vpx playwright test` +- Debug: `vpx playwright test --debug` +- Specific test: `vpx playwright test tests/config-loadpoint.spec.ts` ### Selector Strategy @@ -270,7 +270,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w - Device types: chargers, meters, vehicles, tariffs - Plugin protocols: Modbus, HTTP, MQTT, JavaScript, Go - Define device capabilities and configuration in templates at `templates/definition/[type]/` -- Don't restate param properties that `util/templates/defaults.yaml` already defines for that param name. Properties (description, help, type, unit, default, example, required, advanced, mask, private, usages, …) are inherited from defaults; only specify a property in a template to give it a *different* value. Restating the same value is redundant duplication: reference the param by `name` alone. +- Don't restate param properties that `util/templates/defaults.yaml` already defines for that param name. Properties (description, help, type, unit, default, example, required, advanced, mask, private, usages, …) are inherited from defaults; only specify a property in a template to give it a _different_ value. Restating the same value is redundant duplication: reference the param by `name` alone. - Test templates: `evcc --template-type [type] --template [file]` - Update docs after template changes: `make docs` - When implementing or debugging against a third-party device library (eebus-go/ship/spine-go, ocpp-go, modbus/SunSpec), consult the library's current upstream documentation before coding rather than relying on recalled API details diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a04cfe985..4c37bf915 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ ### Development environment -Developing evcc requires [Go][1] and [Node][2]. We recommend VSCode with the [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and [Vetur](https://marketplace.visualstudio.com/items?itemName=octref.vetur) extensions. +Developing evcc requires [Go][1] [Node][2] and [Vite+][3]. We recommend VSCode with the [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and [Vetur](https://marketplace.visualstudio.com/items?itemName=octref.vetur) extensions. Alternatively, if you use VS Code and [devcontainers](https://code.visualstudio.com/docs/devcontainers/containers), you can use the "Dev containers: Clone repository in container volume" action. This will create a devcontainer with the required toolchain and install the prerequisites as explained below. Wait until the startup log says "Done. Press any key to close the terminal." and check for any errors. @@ -98,8 +98,8 @@ You can adjust the referred configuration as needed to e.g. use your live config For frontend development start the Vue toolchain in dev-mode. Open http://127.0.0.1:7071/ to get to the live reloading development server. It pulls its data from port 7070 (see above). ```sh -npm install -npm run dev +vp install +vp run dev ``` ### Storybook @@ -107,7 +107,7 @@ npm run dev We're using storybook to develop and visualize UI components in different states. Running the command below will open your browser at http://127.0.0.1:6006/. ```sh -npm run storybook +vp run storybook ``` ### Integration testing @@ -116,7 +116,7 @@ We use Playwright for end-to-end integration tests. They start a local evcc inst ```sh make ui build -npm run playwright +vp run playwright ``` ### Simulating device state @@ -124,7 +124,7 @@ npm run playwright Since we don't want to run tests against real devices or cloud services, we've build a simple simulator that lets you emulated meters, vehicles and loadpoints. The simulators web interface runs on http://localhost:7072. ``` -npm run simulator +vp run simulator ``` Run an evcc instance that uses simulator data. This configuration runs with a very high refresh interval to speed up testing. @@ -185,6 +185,7 @@ Note: To ensure the build succeeds after creating new translations, make sure to [1]: https://go.dev [2]: https://nodejs.org/ +[3]: https://viteplus.dev/guide/#install-vp ## Documentation, Website and iOS/Android App diff --git a/Dockerfile b/Dockerfile index 23c079cdf..0b88e958a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # STEP 1 build ui FROM --platform=$BUILDPLATFORM node:26-alpine AS node -RUN apk update && apk add --no-cache make +RUN apk update && apk add --no-cache make && curl -fsSL https://vite.plus | bash WORKDIR /build diff --git a/Makefile b/Makefile index 46af8a6fd..736483078 100644 --- a/Makefile +++ b/Makefile @@ -40,10 +40,10 @@ install:: go install tool install-ui:: - npm ci + vp install ui:: - npm run build + vp run build assets:: go generate ./... @@ -59,7 +59,7 @@ modernize: go tool modernize -test -fix -stringsbuilder=false -omitzero=false ./... lint-ui:: - npm run lint + vp run lint license:: go run github.com/google/go-licenses/v2@latest check \ @@ -74,10 +74,10 @@ license:: ./... license-ui:: - npm run license + vp run license test-ui:: - npm test + vp run test test:: @echo "Running testsuite" diff --git a/assets/js/colors.test.ts b/assets/js/colors.test.ts index b90da7864..17973155b 100644 --- a/assets/js/colors.test.ts +++ b/assets/js/colors.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from "vite-plus/test"; import colors, { dimColor, lighterColor, diff --git a/assets/js/components/Battery/BatteryUsageSettings.vue b/assets/js/components/Battery/BatteryUsageSettings.vue index 40139f147..c40f8067c 100644 --- a/assets/js/components/Battery/BatteryUsageSettings.vue +++ b/assets/js/components/Battery/BatteryUsageSettings.vue @@ -313,7 +313,7 @@ export default defineComponent({ }, batteryDetails() { if (!this.batteryDevices.length) { - return; + return ""; } const multipleBatteries = this.batteryDevices.length > 1; return this.batteryDevices diff --git a/assets/js/components/Battery/history.test.ts b/assets/js/components/Battery/history.test.ts index 07ec65001..82c708c5d 100644 --- a/assets/js/components/Battery/history.test.ts +++ b/assets/js/components/Battery/history.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from "vite-plus/test"; import { historyToSeries, forecastToSeries, buildChartBatteries } from "./history"; import type { BatteryMeter, EvOpt } from "@/types/evcc"; import type { BatteryHistorySeries } from "./types"; diff --git a/assets/js/components/ChargingPlans/Preview.test.ts b/assets/js/components/ChargingPlans/Preview.test.ts index edbe1c5dc..ab724aa69 100644 --- a/assets/js/components/ChargingPlans/Preview.test.ts +++ b/assets/js/components/ChargingPlans/Preview.test.ts @@ -1,5 +1,5 @@ import { mount, config } from "@vue/test-utils"; -import { beforeAll, describe, expect, test } from "vitest"; +import { beforeAll, describe, expect, test } from "vite-plus/test"; import Preview from "./Preview.vue"; import type { Slot } from "@/types/evcc"; diff --git a/assets/js/components/ChargingPlans/Preview.vue b/assets/js/components/ChargingPlans/Preview.vue index 1c3e5820a..c6994da2a 100644 --- a/assets/js/components/ChargingPlans/Preview.vue +++ b/assets/js/components/ChargingPlans/Preview.vue @@ -126,7 +126,7 @@ export default defineComponent({ return null; }, targetOffset(): number | undefined { - if (!this.targetTime) return; + if (!this.targetTime) return undefined; const start = new Date(this.startTime); start.setMinutes(start.getMinutes() - (start.getMinutes() % 15)); start.setSeconds(0); diff --git a/assets/js/components/Config/DeviceModal/index.test.ts b/assets/js/components/Config/DeviceModal/index.test.ts index 27585a31a..3d15a86f1 100644 --- a/assets/js/components/Config/DeviceModal/index.test.ts +++ b/assets/js/components/Config/DeviceModal/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { createServiceEndpoints, type TemplateParam } from "./index"; const buildParam = (name: string, service?: string): TemplateParam => ({ diff --git a/assets/js/components/Config/EebusModal.vue b/assets/js/components/Config/EebusModal.vue index ef8b601d0..b36745922 100644 --- a/assets/js/components/Config/EebusModal.vue +++ b/assets/js/components/Config/EebusModal.vue @@ -186,7 +186,7 @@ import type { PropType } from "vue"; import QRCode from "qrcode"; import "@h2d2/shopicons/es/regular/trash"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars +// oxlint-disable-next-line @typescript-eslint/no-unused-vars import type { EebusConfig, EebusPairing, EebusStatus, YamlSource } from "@/types/evcc"; import api from "@/api"; import JsonModal from "./JsonModal.vue"; diff --git a/assets/js/components/Config/Messaging/MessengerModal.vue b/assets/js/components/Config/Messaging/MessengerModal.vue index 3627d5fef..78b7d349a 100644 --- a/assets/js/components/Config/Messaging/MessengerModal.vue +++ b/assets/js/components/Config/Messaging/MessengerModal.vue @@ -58,7 +58,7 @@ export default defineComponent({ }, { label: "primary", - options: [...products.filter((p: Product) => p.group !== "generic")], + options: products.filter((p: Product) => p.group !== "generic"), }, ]; }, diff --git a/assets/js/components/Config/PropertyEntry.vue b/assets/js/components/Config/PropertyEntry.vue index dfbc1cfcf..7ef34e012 100644 --- a/assets/js/components/Config/PropertyEntry.vue +++ b/assets/js/components/Config/PropertyEntry.vue @@ -25,7 +25,7 @@ diff --git a/assets/js/components/BottomTabs/MoreMenu.vue b/assets/js/components/BottomTabs/MoreMenu.vue index 2eae0f827..2ed8516d0 100644 --- a/assets/js/components/BottomTabs/MoreMenu.vue +++ b/assets/js/components/BottomTabs/MoreMenu.vue @@ -115,10 +115,7 @@ export default defineComponent({ }, emits: ["close"], data() { - return { - isApp: isApp(), - onClickOutside: undefined as ((e: MouseEvent) => void) | undefined, - }; + return { isApp: isApp() }; }, computed: { providers() { @@ -168,19 +165,6 @@ export default defineComponent({ return Object.keys(this.vehicles).length > 0; }, }, - mounted() { - this.onClickOutside = (e: MouseEvent) => { - if (this.open && !this.$el.contains(e.target as Node)) { - this.$emit("close"); - } - }; - document.addEventListener("click", this.onClickOutside, true); - }, - unmounted() { - if (this.onClickOutside) { - document.removeEventListener("click", this.onClickOutside, true); - } - }, methods: { handleAuthRequired() { this.$router.push({ path: "/config" }); diff --git a/tests/navigation.spec.ts b/tests/navigation.spec.ts index bf73f34d2..113d69882 100644 --- a/tests/navigation.spec.ts +++ b/tests/navigation.spec.ts @@ -30,9 +30,16 @@ test.describe("bottom navigation", async () => { await expect(tabMore).toBeVisible(); await expect(tabBattery).toHaveCount(0); + // more menu opens and closes on the same tab + const configLink = tabMore.getByRole("link", { name: "Configuration" }); + await tabMore.click(); + await expect(configLink).toBeVisible(); + await tabMore.click(); + await expect(configLink).not.toBeVisible(); + // navigate to config via More menu await tabMore.click(); - await tabMore.getByRole("link", { name: "Configuration" }).click(); + await configLink.click(); await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible(); // create battery meter From 6fd294750916c1bca114478e962cdb5f3b2ccd39 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 3 Aug 2026 08:59:44 +0200 Subject: [PATCH 052/331] UI: apply 5% floor to vehicle limit slider (#32462) --- assets/js/components/Vehicles/Soc.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/components/Vehicles/Soc.vue b/assets/js/components/Vehicles/Soc.vue index bb2b8830b..11eb93f9b 100644 --- a/assets/js/components/Vehicles/Soc.vue +++ b/assets/js/components/Vehicles/Soc.vue @@ -324,7 +324,7 @@ export default defineComponent({ movedLimitSoc(e: Event) { const value = parseInt((e.target as HTMLInputElement).value, 10); e.stopPropagation(); - const minLimit = this.heating ? this.lowerBound : 20; + const minLimit = this.heating ? this.lowerBound : 5; if (value < minLimit) { (e.target as HTMLInputElement).value = minLimit.toString(); this.selectedLimitSoc = value; From edae1cc3075bf7d61a9db94b7b92aedd07163eeb Mon Sep 17 00:00:00 2001 From: GurliGebis Date: Mon, 3 Aug 2026 10:39:02 +0200 Subject: [PATCH 053/331] Meter (FoxESS H3-Pro/Smart): add battery control (#32301) --- .../definition/meter/fox-ess-h3-smart.yaml | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/templates/definition/meter/fox-ess-h3-smart.yaml b/templates/definition/meter/fox-ess-h3-smart.yaml index d0ee8e6c8..2ca4d9123 100644 --- a/templates/definition/meter/fox-ess-h3-smart.yaml +++ b/templates/definition/meter/fox-ess-h3-smart.yaml @@ -12,6 +12,8 @@ params: id: 247 - name: maxacpower - preset: battery-params + - name: maxchargepower + required: true render: | type: custom {{- if eq .usage "grid" }} @@ -115,5 +117,132 @@ render: | address: 46611 # Minimum SoC OnGrid type: writesingle decode: uint16 + batterymode: + source: watchdog + timeout: 30s + reset: 1 # reset to normal mode + set: + source: switch + switch: + - case: 1 # normal - disable remote control + set: + source: sequence + set: + - source: const + value: 0 # Disabled + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 46001 # Remote Control + type: writesingle + encoding: uint16 + - source: const + value: 1 # Self Use + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 49203 # Work Mode + type: writesingle + encoding: uint16 + - source: const + value: {{ .minsoc }} + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 46609 # Minimum SoC + type: writesingle + encoding: uint16 + - source: const + value: {{ .maxsoc }} + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 46610 # Maximum SoC + type: writesingle + encoding: uint16 + - case: 2 # hold - stop battery discharge + set: + source: sequence + set: + - source: const + value: 2 # Feed-in First, fallback if the remote control watchdog expires + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 49203 # Work Mode + type: writesingle + encoding: uint16 + - source: const + value: 60 # Remote timeout 60 seconds + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 46002 # Remote Timeout Set + type: writesingle + encoding: uint16 + - source: const + value: 1 # Enabled + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 46001 # Remote Control + type: writesingle + encoding: uint16 + - source: const + value: 0 # W + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 46003 # Remote Active Power, int32 across 46003 (high word) and 46004 (low word) + type: writemultiple + encoding: int32 + - case: 3 # charge - force battery charge + set: + source: sequence + set: + - source: const + value: 3 # Back-up, fallback if the remote control watchdog expires + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 49203 # Work Mode + type: writesingle + encoding: uint16 + - source: const + value: 60 # Remote timeout 60 seconds + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 46002 # Remote Timeout Set + type: writesingle + encoding: uint16 + - source: const + value: 1 # Enabled + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 46001 # Remote Control + type: writesingle + encoding: uint16 + - source: const + value: {{ mul .maxchargepower -1 }} # negative power means charging + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 46003 # Remote Active Power, int32 across 46003 (high word) and 46004 (low word) + type: writemultiple + encoding: int32 {{- include "battery-params" . }} {{- end }} From 4ca64eaf49bf3078c6e92dfbceecfbed6bcdeb54 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 3 Aug 2026 12:12:13 +0200 Subject: [PATCH 054/331] Warn on repeated requests only when they succeed [0.313 backport] (#32472) Co-authored-by: andig --- plugin/http.go | 19 +++++++++++-------- plugin/http_test.go | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/plugin/http.go b/plugin/http.go index 5d54ec8b3..67088e348 100644 --- a/plugin/http.go +++ b/plugin/http.go @@ -226,22 +226,25 @@ func (p *HTTP) request(url string, body string) ([]byte, error) { return nil, err } + val, err := request.ReadBody(resp) + if err != nil { + if err2 := knownErrors(val); err2 != nil { + err = err2 + } + + return val, err + } + // warn on uncached GET polling: a repeated roundtrip means neither a configured // cache nor the device's own response headers spared it. cache hits are exempt. + // only successful responses count, a failed one is retried by the caller if p.method == http.MethodGet && p.mu == nil && resp.Header.Get(httpcache.XFromCache) == "" { if repeatedGet(url, time.Now()) { p.log.WARN.Printf("uncached request repeated within 1s, please report at https://github.com/evcc-io/evcc/issues: %s", url) } } - val, err := request.ReadBody(resp) - if err != nil { - if err2 := knownErrors(val); err2 != nil { - err = err2 - } - } - - return val, err + return val, nil } type httpAccess struct { diff --git a/plugin/http_test.go b/plugin/http_test.go index e71c65da1..543a80257 100644 --- a/plugin/http_test.go +++ b/plugin/http_test.go @@ -164,6 +164,27 @@ func (suite *httpTestSuite) TestSetPath() { suite.Require().Equal("/foo/bar/4711", suite.h.req.URL.String()) } +func TestRepeatedGetFailed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + defer srv.Close() + + p := NewHTTP(util.NewLogger("foo"), http.MethodGet, srv.URL, false, 0) + g, err := p.StringGetter() + require.NoError(t, err) + + // a failing request is retried by the caller, it must not count as a sighting + for range 2 { + _, err := g() + require.Error(t, err) + } + + httpSeenMu.Lock() + defer httpSeenMu.Unlock() + require.NotContains(t, httpSeen, srv.URL) +} + func TestRepeatedGet(t *testing.T) { url := "http://repeated.test/uncached" t0 := time.Now() From c7c744f54e54368616ba5288958083b2bbb425da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:14:51 +0000 Subject: [PATCH 055/331] Charger (Plugchoice): fix CurrentPower reading 0 for externally started sessions (#32420) --- charger/plugchoice.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/charger/plugchoice.go b/charger/plugchoice.go index 862722016..231f39b20 100644 --- a/charger/plugchoice.go +++ b/charger/plugchoice.go @@ -237,11 +237,6 @@ var _ api.Meter = (*Plugchoice)(nil) // CurrentPower implements the api.Meter interface func (c *Plugchoice) CurrentPower() (float64, error) { - // Should be zero if not enabled - if !c.enabled { - return 0, nil - } - res, err := c.powerG.Get() if err != nil { return 0, err From c3f3384bfc842a35fb295ea14cbdb03451aa7ddf Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 3 Aug 2026 13:06:37 +0200 Subject: [PATCH 056/331] API: generate /api/state documentation from UI types (#32431) Co-authored-by: Michael Geers --- .github/workflows/default.yml | 3 + .github/workflows/documentation.yml | 2 +- .github/workflows/openapi-validate.yml | 3 +- .gitignore | 1 + Dockerfile | 1 - Makefile | 5 +- .../components/ChargingPlans/ChargingPlan.vue | 4 +- .../ChargingPlans/ChargingPlanModal.vue | 8 +- .../components/ChargingPlans/PlanStrategy.vue | 2 +- .../ChargingPlans/PlansRepeatingSettings.vue | 2 +- .../ChargingPlans/PlansSettings.vue | 16 +- .../js/components/ChargingPlans/Warnings.vue | 3 +- assets/js/components/ChargingPlans/types.d.ts | 39 - assets/js/components/Forecast/Chart.vue | 3 +- assets/js/components/Forecast/GridDetails.vue | 3 +- assets/js/components/Forecast/PriceChart.vue | 3 +- assets/js/components/Forecast/SolarChart.vue | 2 +- .../js/components/Forecast/SolarDetails.vue | 2 +- assets/js/components/Forecast/ValueChart.vue | 2 +- .../js/components/Forecast/ValueDetails.vue | 2 +- assets/js/components/Forecast/echarts.ts | 2 +- assets/js/components/Forecast/types.ts | 27 - assets/js/components/Loadpoints/Loadpoint.vue | 2 +- .../js/components/Tariff/SmartCostLimit.vue | 3 +- assets/js/components/Vehicles/Title.vue | 2 +- assets/js/components/Vehicles/Vehicle.vue | 2 +- assets/js/types/evcc.ts | 645 +- assets/js/utils/forecast.ts | 2 +- cmd/openapi/openapi.go | 28 - go.mod | 1 - package-lock.json | 392 +- package.json | 6 + scripts/state-schema/index.ts | 33 + scripts/state-schema/openapi.ts | 38 + scripts/state-schema/schemas.ts | 221 + scripts/state-schema/validate.ts | 114 + server/mcp/openapi.json | 8462 +++++++++++------ server/mcp/openapi.md | 44 +- server/openapi.go | 3 +- server/openapi.state.yaml | 1829 ++++ server/openapi.yaml | 111 +- server/openapi_test.go | 1 + tests/state-api.spec.ts | 28 + tsconfig.json | 2 +- 44 files changed, 8757 insertions(+), 3347 deletions(-) delete mode 100644 assets/js/components/ChargingPlans/types.d.ts delete mode 100644 assets/js/components/Forecast/types.ts delete mode 100644 cmd/openapi/openapi.go create mode 100644 scripts/state-schema/index.ts create mode 100644 scripts/state-schema/openapi.ts create mode 100644 scripts/state-schema/schemas.ts create mode 100644 scripts/state-schema/validate.ts create mode 100644 server/openapi.state.yaml create mode 100644 tests/state-api.spec.ts diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 7bd731a3c..895bbe2df 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -176,6 +176,9 @@ jobs: - name: Install run: make install-ui + - name: OpenAPI + run: make openapi + - name: Lint run: make lint-ui diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index f9ada3d4a..387a43baa 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -58,7 +58,7 @@ jobs: - name: Prepare OpenAPI spec run: | mkdir -p ./openapi-deploy - cp ./server/openapi.yaml ./openapi-deploy/openapi.yaml + npx --yes @redocly/cli@2 bundle ./server/openapi.yaml -o ./openapi-deploy/openapi.yaml - name: Deploy OpenAPI spec to docs repo uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4 diff --git a/.github/workflows/openapi-validate.yml b/.github/workflows/openapi-validate.yml index 1067bacf7..aa3281018 100644 --- a/.github/workflows/openapi-validate.yml +++ b/.github/workflows/openapi-validate.yml @@ -8,6 +8,7 @@ on: pull_request: paths: - "server/openapi.yaml" + - "server/openapi.state.yaml" - ".github/workflows/openapi-validate.yml" jobs: @@ -22,4 +23,4 @@ jobs: with: go-version: stable - name: Validate OpenAPI spec - run: go run github.com/getkin/kin-openapi/cmd/validate@v0.133.0 -- server/openapi.yaml + run: go run github.com/getkin/kin-openapi/cmd/validate@v0.133.0 -ext -- server/openapi.yaml diff --git a/.gitignore b/.gitignore index 48573ca2a..dd0405372 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ testdata/**/* !assets/js/**/*.yaml !package*.json !evcc.dist.yaml +!server/openapi.state.yaml !.coderabbit.yaml !tests/**/*.evcc.yaml !tests/**/*.tpl.yaml diff --git a/Dockerfile b/Dockerfile index 02a63044e..e224d4f30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,7 +48,6 @@ RUN --mount=type=cache,target=${GOMODCACHE} go mod download # install tools COPY Makefile . COPY cmd/implement/ cmd/implement/ -COPY cmd/openapi/ cmd/openapi/ COPY api/ api/ RUN --mount=type=cache,target=${GOMODCACHE} make install diff --git a/Makefile b/Makefile index 736483078..7f2e3838e 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ CURRDIR := $(shell pwd) default:: ui build -all:: clean install install-ui ui assets lint test-ui lint-ui test build +all:: clean install install-ui ui assets openapi lint test-ui lint-ui test build clean:: rm -rf dist/ @@ -48,6 +48,9 @@ ui:: assets:: go generate ./... +openapi:: + vp run openapi + docs:: go generate github.com/evcc-io/evcc/util/templates/... diff --git a/assets/js/components/ChargingPlans/ChargingPlan.vue b/assets/js/components/ChargingPlans/ChargingPlan.vue index ab3edd481..96eeda0bc 100644 --- a/assets/js/components/ChargingPlans/ChargingPlan.vue +++ b/assets/js/components/ChargingPlans/ChargingPlan.vue @@ -38,9 +38,7 @@ import formatter from "@/mixins/formatter"; import minuteTicker from "@/mixins/minuteTicker"; import { optionStep, fmtEnergy } from "@/utils/energyOptions.ts"; import { defineComponent, type PropType } from "vue"; -import type { CURRENCY, Vehicle } from "@/types/evcc"; -import type { PlanStrategy } from "./types"; -import type { Forecast } from "@/types/evcc.ts"; +import type { CURRENCY, Forecast, PlanStrategy, Vehicle } from "@/types/evcc"; export default defineComponent({ name: "ChargingPlan", diff --git a/assets/js/components/ChargingPlans/ChargingPlanModal.vue b/assets/js/components/ChargingPlans/ChargingPlanModal.vue index bc019dd27..9a4784ebe 100644 --- a/assets/js/components/ChargingPlans/ChargingPlanModal.vue +++ b/assets/js/components/ChargingPlans/ChargingPlanModal.vue @@ -47,13 +47,17 @@ import GenericModal from "../Helper/GenericModal.vue"; import PlansSettings from "./PlansSettings.vue"; import api from "@/api"; import type { + CURRENCY, + Forecast, PlanStrategy, RepeatingPlan, + SMART_COST_TYPE, StaticEnergyPlan, StaticPlan, StaticSocPlan, -} from "./types"; -import type { CURRENCY, Forecast, SMART_COST_TYPE, UiLoadpoint, Vehicle } from "@/types/evcc"; + UiLoadpoint, + Vehicle, +} from "@/types/evcc"; export default defineComponent({ name: "ChargingPlanModal", diff --git a/assets/js/components/ChargingPlans/PlanStrategy.vue b/assets/js/components/ChargingPlans/PlanStrategy.vue index a8014a76b..0e63f3f83 100644 --- a/assets/js/components/ChargingPlans/PlanStrategy.vue +++ b/assets/js/components/ChargingPlans/PlanStrategy.vue @@ -64,7 +64,7 @@ + + diff --git a/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue b/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue index 9a79fb49c..a379ea644 100644 --- a/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue +++ b/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue @@ -4,7 +4,7 @@ +
@@ -40,29 +48,11 @@ {{ $t(`main.targetCharge.${noActivePlan ? "preview" : "currentPlan"}`) }} - {{ $t("main.targetCharge.preview") }} #1 + {{ $t("main.targetCharge.preview") }}: #1 {{ $t("main.targetCharge.goalReached") }} {{ nextPlanTitle }} - - @@ -88,6 +78,7 @@ import type { PlanStrategy, PlanWrapper, RepeatingPlan, + SMART_COST_TYPE, StaticEnergyPlan, StaticPlan, StaticSocPlan, @@ -117,7 +108,7 @@ export default defineComponent({ socBasedPlanning: Boolean, socPerKwh: Number, rangePerSoc: Number, - smartCostType: String, + smartCostType: String as PropType, currency: String as PropType, mode: String, capacity: Number, @@ -138,7 +129,6 @@ export default defineComponent({ plan: {} as PlanWrapper, activeTab: "time", nextPlanId: 0, - strategyOpen: false, updatePlanPreviewDebounced: null as any as () => void, updateActivePlanDebounced: null as any as () => void, }; @@ -170,7 +160,7 @@ export default defineComponent({ return this.plan.duration === 0; }, nextPlanTitle(): string { - return `${this.$t("main.targetCharge.nextPlan")} #${this.nextPlanId}`; + return `${this.$t("main.targetCharge.nextPlan")}: #${this.nextPlanId}`; }, strategyDisabled(): boolean { // options only make sense if there are variable prices @@ -327,7 +317,7 @@ export default defineComponent({ h5 { position: relative; display: flex; - top: -33px; + top: -26px; margin-bottom: -0.5rem; padding: 0 0.5rem; justify-content: center; diff --git a/i18n/de.json b/i18n/de.json index 2bdca69d2..664677db3 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1280,24 +1280,33 @@ "goal": "Ladeziel", "modalTitle": "Ladeplanung", "none": "keiner", + "once": "einmalig", "optimization": { "cheapest": "günstigst", + "cheapestDescription": "Wählt die günstigsten Zeitfenster. Kann zu mehreren Starts und Stopps führen.", + "cleanest": "sauberst", + "cleanestDescription": "Wählt die saubersten Zeitfenster. Kann zu mehreren Starts und Stopps führen.", "continuous": "kontinuierlich", + "continuousCo2Description": "Wählt die sauberste unterbrechungsfreie Folge von Zeitfenstern.", + "continuousPriceDescription": "Wählt die günstigste unterbrechungsfreie Folge von Zeitfenstern.", "label": "Optimierung" }, "planNumber": "Plan {number}", "precondition": { - "description": "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung.", + "description": "Verlagert einen Teil des Ladens direkt vor die Abfahrt: warme Batterie, weniger Zeit mit hohem Ladestand.", "label": "Spätes Laden", "optionAll": "alles", - "optionNo": "nein" + "optionNo": "nein", + "summary": "{precondition} vorher" }, "remove": "Entfernen", "repeating": "wiederholend", "repeatingPlans": "Wiederholende Pläne", "selectAll": "Alle wählen", + "strategy": { + "label": "Strategie" + }, "strategyDisabledDescription": "Das Laden startet so spät wie möglich und wird rechtzeitig zur Abfahrt abgeschlossen. Mit dynamischen Netzpreisen oder einem CO₂-Tarif stehen hier weitere Optionen zur Verfügung.", - "strategySettings": "Strategie-Einstellungen", "time": "Zeit", "title": "Plan", "unsavedChanges": "Ungespeicherte Änderungen vorhanden. Jetzt anwenden?", diff --git a/i18n/en.json b/i18n/en.json index b608c7a20..b973ff243 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1280,24 +1280,33 @@ "goal": "Charging goal", "modalTitle": "Charging Plan", "none": "none", + "once": "once", "optimization": { "cheapest": "cheapest", + "cheapestDescription": "Selects the cheapest slots. May result in multiple starts and stops.", + "cleanest": "cleanest", + "cleanestDescription": "Selects the cleanest slots. May result in multiple starts and stops.", "continuous": "continuous", + "continuousCo2Description": "Selects the cleanest uninterrupted series of slots.", + "continuousPriceDescription": "Selects the cheapest uninterrupted series of slots.", "label": "Optimization" }, "planNumber": "Plan {number}", "precondition": { - "description": "Charge {duration} before departure for battery preconditioning.", + "description": "Moves part of the charging right before departure: warm battery, less time at high charge level.", "label": "Late Charging", "optionAll": "everything", - "optionNo": "no" + "optionNo": "no", + "summary": "{precondition} before" }, "remove": "Remove", "repeating": "repeating", "repeatingPlans": "Repeating plans", "selectAll": "Select all", + "strategy": { + "label": "Strategy" + }, "strategyDisabledDescription": "Charging starts as late as possible to finish just in time for departure. With dynamic grid prices or CO₂ tariff, more options are available here.", - "strategySettings": "Strategy settings", "time": "Time", "title": "Plan", "unsavedChanges": "There are unsaved changes. Apply now?", diff --git a/tests/plan.spec.ts b/tests/plan.spec.ts index b85d9614e..7abfc1588 100644 --- a/tests/plan.spec.ts +++ b/tests/plan.spec.ts @@ -9,6 +9,7 @@ const mobile = devices["iPhone 12 Mini"].viewport; const CONFIG = "plan.evcc.yaml"; const CONFIG_NO_TARIFF = "basics.evcc.yaml"; const CONFIG_FIXED_TARIFF = "plan-fixed-tariff.evcc.yaml"; +const CONFIG_CO2 = "battery-settings-co2.evcc.yaml"; test.beforeEach(async () => { await start(CONFIG); @@ -79,11 +80,11 @@ test.describe("basic functionality", async () => { await page.getByTestId("static-plan-day").selectOption({ index: 1 }); await page.getByTestId("static-plan-time").fill("09:30"); await page.getByTestId("static-plan-soc").selectOption("80%"); - await page.getByRole("button", { name: "Strategy settings" }).click(); + await page.getByTestId("plan-strategy").getByRole("button", { name: "cheapest" }).click(); await expect(page.getByLabel("Late Charging")).toBeVisible(); await page.getByLabel("Late Charging").selectOption("1 hour"); await page.getByTestId("static-plan-active").click(); - await page.getByRole("button", { name: "Close" }).click(); + await page.getByRole("button", { name: "Close", exact: true }).click(); const tomorrowShort = getWeekday(1, "short"); await expect(lp1.getByTestId("plan-marker")).toBeVisible(); @@ -487,23 +488,23 @@ test.describe("repeating", async () => { // with multiple plans, preview shows first plan await page.waitForLoadState("networkidle"); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan #1"); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan: #1"); await expect(modal.getByTestId("target-text")).toContainText("09:00"); // activate #1 - should show next plan #1 await modal.getByTestId("static-plan-active").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1"); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #1"); await expect(modal.getByTestId("target-text")).toContainText("09:00"); // deactivate #1, activate #2 - should show next plan #2 await modal.getByTestId("static-plan-active").click(); await modal.getByTestId("repeating-plan-active").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2"); await expect(modal.getByTestId("target-text")).toContainText("11:11"); // back to preview if no active plan await modal.getByTestId("repeating-plan-active").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan #1"); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan: #1"); await expect(modal.getByTestId("target-text")).toContainText("9:00"); }); @@ -550,7 +551,7 @@ test.describe("repeating", async () => { await page.waitForLoadState("networkidle"); // specific weekday and time - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2"); await expect(modal.getByTestId("target-text")).toContainText("Thu 09:22"); }); @@ -599,12 +600,12 @@ test.describe("repeating", async () => { await plan3.getByTestId("repeating-plan-active").click(); // check next plans - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #3"); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #3"); await expect(modal.getByTestId("target-text")).toContainText("9:10"); // disable plan #3 await plan3.getByTestId("repeating-plan-active").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2"); await expect(modal.getByTestId("target-text")).toContainText("9:20"); // change plan #2 to yesterday @@ -613,13 +614,13 @@ test.describe("repeating", async () => { await days2.getByRole("checkbox", { name: yesterday }).click(); // check await days2.click(); // close // no changes yet - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2"); await expect(modal.getByTestId("target-text")).toContainText("9:20"); // apply await plan2.getByTestId("repeating-plan-apply").click(); await expect(plan2.getByTestId("repeating-plan-apply")).not.toBeVisible(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1"); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #1"); await expect(modal.getByTestId("target-text")).toContainText("9:30"); // set lower targets than vehicle soc (50%) @@ -654,13 +655,14 @@ test.describe("repeating", async () => { await plan.getByRole("checkbox", { name: "Select all" }).click(); // uncheck all await plan.getByRole("checkbox", { name: tomorrow }).check(); await plan.getByTestId("repeating-plan-time").fill("09:20"); - await modal.getByRole("button", { name: "Strategy settings" }).click(); + await plan.getByTestId("repeating-plan-weekdays").click(); // close dropdown + await modal.getByTestId("plan-strategy").getByRole("button", { name: "cheapest" }).click(); await expect(modal.getByLabel("Late Charging")).toBeVisible(); await modal.getByLabel("Late Charging").selectOption("2 hours"); await plan.getByTestId("repeating-plan-active").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2"); await expect(modal.getByTestId("target-text")).toContainText("09:20"); - await modal.getByRole("button", { name: "Close" }).click(); + await modal.getByRole("button", { name: "Close", exact: true }).click(); await expect( lp1.getByTestId("charging-plan").getByRole("button", { name: `${tomorrowShort} 09:20` }) ).toBeVisible(); @@ -679,7 +681,7 @@ test.describe("repeating", async () => { .getByRole("button", { name: `${tomorrowShort} 09:20` }) .click(); await expect(modal.getByTestId("plan-entry")).toHaveCount(2); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2"); await expect(modal.getByTestId("target-text")).toContainText("09:20"); await expect(modal.getByLabel("Late Charging")).toHaveValue("7200"); }); @@ -695,12 +697,37 @@ test.describe("plan strategy", async () => { const modal = page.getByTestId("charging-plan-modal"); await expect(modal.getByTestId("static-plan-active")).toBeVisible(); - // Strategy toggle should be visible but expand to show informational note only - await expect(modal.getByRole("button", { name: "Strategy settings" })).toBeVisible(); - await modal.getByRole("button", { name: "Strategy settings" }).click(); + // Strategy line shows informational note only, not clickable + await expect(modal.getByTestId("plan-strategy")).toContainText("just in time for departure"); + await expect(modal.getByTestId("plan-strategy").getByRole("button")).toHaveCount(0); await expect(modal.getByLabel("Optimization")).not.toBeVisible(); await expect(modal.getByLabel("Late Charging")).not.toBeVisible(); - await expect(modal).toContainText("just in time for departure"); + }); + + test("co2 tariff: cleanest instead of cheapest", async ({ page }) => { + await restart(CONFIG_CO2); + await page.goto("/"); + const lp1 = await page.getByTestId("loadpoint").first(); + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + const modal = page.getByTestId("charging-plan-modal"); + + await expect(modal.getByTestId("plan-strategy")).toContainText("cleanest"); + + // collapsed selects must not be interactive + await expect(modal.getByLabel("Optimization")).not.toBeVisible(); + await expect(modal.getByLabel("Late Charging")).not.toBeVisible(); + + await modal.getByTestId("plan-strategy").getByRole("button", { name: "cleanest" }).click(); + + const optimization = modal.getByLabel("Optimization"); + await expect(optimization).toBeVisible(); + await expect(optimization.getByRole("option", { name: "cleanest" })).toHaveCount(1); + await expect(modal.getByTestId("plan-strategy")).toContainText("cleanest slots"); + + await optimization.selectOption("continuous"); + await expect(modal.getByTestId("plan-strategy")).toContainText( + "cleanest uninterrupted series" + ); }); test("visible and functional on mobile", async ({ page }) => { @@ -719,11 +746,11 @@ test.describe("plan strategy", async () => { const modal = page.getByTestId("charging-plan-modal"); - // Strategy toggle should be visible on mobile - await expect(modal.getByRole("button", { name: "Strategy settings" })).toBeVisible(); + // Strategy summary should be visible on mobile + await expect(modal.getByTestId("plan-strategy")).toContainText("cheapest"); // Open strategy panel - await modal.getByRole("button", { name: "Strategy settings" }).click(); + await modal.getByTestId("plan-strategy").getByRole("button", { name: "cheapest" }).click(); // Strategy controls should be visible and functional await expect(modal.getByLabel("Optimization")).toBeVisible(); From 45e781dac040cc5f86bd0bb484bbbb5f3fdd5a7c Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 6 Aug 2026 11:49:33 +0200 Subject: [PATCH 084/331] API: publish forecast as arrays with unix timestamps (BC) (#32391) --- .../Battery/BatteryExperimental.vue | 4 +- assets/js/components/BottomTabs/Bar.vue | 2 - .../components/ChargingPlans/ChargingPlan.vue | 4 +- .../ChargingPlans/ChargingPlanModal.vue | 4 +- .../ChargingPlans/PlansSettings.vue | 4 +- .../js/components/Energyflow/Energyflow.vue | 4 +- assets/js/components/Forecast/Chart.vue | 26 +++--- assets/js/components/Forecast/GridDetails.vue | 10 +-- assets/js/components/Forecast/PriceChart.vue | 18 ++-- assets/js/components/Forecast/SolarChart.vue | 12 +-- .../js/components/Forecast/SolarDetails.vue | 4 +- assets/js/components/Forecast/ValueChart.vue | 10 +-- .../js/components/Forecast/ValueDetails.vue | 6 +- assets/js/components/Forecast/echarts.ts | 16 ++-- assets/js/components/Loadpoints/Loadpoint.vue | 4 +- .../js/components/Loadpoints/Loadpoints.vue | 4 +- .../components/Loadpoints/SettingsModal.vue | 4 +- assets/js/components/Savings/Savings.vue | 4 +- assets/js/components/Site/Site.vue | 4 +- .../js/components/Tariff/SmartCostLimit.vue | 4 +- .../js/components/Top/TopNavigationArea.vue | 2 +- assets/js/components/Vehicles/Vehicle.vue | 4 +- assets/js/store.ts | 9 +- assets/js/types/evcc.ts | 56 ++++++------ assets/js/utils/forecast.test.ts | 85 ++++++++++++------- assets/js/utils/forecast.ts | 50 +++++++++-- assets/js/views/Battery.vue | 4 +- assets/js/views/Forecast.vue | 2 +- assets/js/views/Main.vue | 6 ++ core/site_tariffs.go | 35 +++++--- core/site_tariffs_test.go | 69 +++++++++++++++ core/solar.go | 14 +++ scripts/state-schema/schemas.ts | 7 ++ server/mcp/openapi.json | 50 +++-------- server/openapi.state.yaml | 43 +++------- 35 files changed, 361 insertions(+), 223 deletions(-) create mode 100644 core/site_tariffs_test.go diff --git a/assets/js/components/Battery/BatteryExperimental.vue b/assets/js/components/Battery/BatteryExperimental.vue index 187defdd5..e70144406 100644 --- a/assets/js/components/Battery/BatteryExperimental.vue +++ b/assets/js/components/Battery/BatteryExperimental.vue @@ -117,8 +117,8 @@ export default defineComponent({ return this.gridChargePossible || this.gridChargeLimit !== null; }, gridChargeTariff() { - const { forecast, smartCostType } = this.state; - return smartCostType === SMART_COST_TYPE.CO2 ? forecast?.co2 : forecast?.grid; + const { co2, grid } = store.uiForecast.value; + return this.state.smartCostType === SMART_COST_TYPE.CO2 ? co2 : grid; }, smartCostLimitProps() { return { diff --git a/assets/js/components/BottomTabs/Bar.vue b/assets/js/components/BottomTabs/Bar.vue index 15085db8a..ca456f318 100644 --- a/assets/js/components/BottomTabs/Bar.vue +++ b/assets/js/components/BottomTabs/Bar.vue @@ -53,7 +53,6 @@ import MoreItem from "./MoreItem.vue"; import { defineComponent, type PropType } from "vue"; import type { FatalError, - Forecast, Sponsor, EvOpt, AuthProviders, @@ -74,7 +73,6 @@ export default defineComponent({ battery: { type: Object as PropType }, batteryGridChargeActive: Boolean, batteryMode: { type: String as PropType }, - forecast: { type: Object as PropType }, vehicles: { type: Object as PropType>, default: () => ({}) }, authProviders: { type: Object as PropType, default: () => ({}) }, sponsor: { type: Object as PropType, default: () => ({}) }, diff --git a/assets/js/components/ChargingPlans/ChargingPlan.vue b/assets/js/components/ChargingPlans/ChargingPlan.vue index 96eeda0bc..2c6c29c66 100644 --- a/assets/js/components/ChargingPlans/ChargingPlan.vue +++ b/assets/js/components/ChargingPlans/ChargingPlan.vue @@ -38,7 +38,7 @@ import formatter from "@/mixins/formatter"; import minuteTicker from "@/mixins/minuteTicker"; import { optionStep, fmtEnergy } from "@/utils/energyOptions.ts"; import { defineComponent, type PropType } from "vue"; -import type { CURRENCY, Forecast, PlanStrategy, Vehicle } from "@/types/evcc"; +import type { CURRENCY, UiForecast, PlanStrategy, Vehicle } from "@/types/evcc"; export default defineComponent({ name: "ChargingPlan", @@ -71,7 +71,7 @@ export default defineComponent({ vehicleSoc: Number, vehicleLimitSoc: Number, vehicleNotReachable: Boolean, - forecast: Object as PropType, + forecast: Object as PropType, }, emits: ["open-modal"], data() { diff --git a/assets/js/components/ChargingPlans/ChargingPlanModal.vue b/assets/js/components/ChargingPlans/ChargingPlanModal.vue index 9a4784ebe..ba86cf036 100644 --- a/assets/js/components/ChargingPlans/ChargingPlanModal.vue +++ b/assets/js/components/ChargingPlans/ChargingPlanModal.vue @@ -48,7 +48,7 @@ import PlansSettings from "./PlansSettings.vue"; import api from "@/api"; import type { CURRENCY, - Forecast, + UiForecast, PlanStrategy, RepeatingPlan, SMART_COST_TYPE, @@ -70,7 +70,7 @@ export default defineComponent({ vehicles: { type: Array as PropType, default: () => [] }, smartCostType: String as PropType, currency: String as PropType, - forecast: Object as PropType, + forecast: Object as PropType, }, data() { return { diff --git a/assets/js/components/ChargingPlans/PlansSettings.vue b/assets/js/components/ChargingPlans/PlansSettings.vue index 0f28eb8ff..fe71da0c2 100644 --- a/assets/js/components/ChargingPlans/PlansSettings.vue +++ b/assets/js/components/ChargingPlans/PlansSettings.vue @@ -73,7 +73,7 @@ import { debounceLeading } from "@/utils/debounceLeading"; import { defineComponent, type PropType } from "vue"; import type { CURRENCY, - Forecast, + UiForecast, PlanResponse, PlanStrategy, PlanWrapper, @@ -115,7 +115,7 @@ export default defineComponent({ vehicle: Object as PropType, vehicleLimitSoc: Number, planOverrun: Number, - forecast: Object as PropType, + forecast: Object as PropType, }, emits: [ "static-plan-removed", diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 4ced5d80e..6113693ec 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -328,7 +328,7 @@ import { type Battery, type Meter, type CURRENCY, - type Forecast, + type UiForecast, type UiLoadpoint, } from "@/types/evcc"; @@ -371,7 +371,7 @@ export default defineComponent({ prioritySoc: { type: Number }, bufferSoc: { type: Number }, bufferStartSoc: { type: Number }, - forecast: { type: Object as PropType, default: () => ({}) }, + forecast: { type: Object as PropType, default: () => ({}) }, }, data: () => { return { diff --git a/assets/js/components/Forecast/Chart.vue b/assets/js/components/Forecast/Chart.vue index 39905f6df..27f4115e1 100644 --- a/assets/js/components/Forecast/Chart.vue +++ b/assets/js/components/Forecast/Chart.vue @@ -42,7 +42,7 @@ import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm"; import { registerChartComponents, commonOptions } from "../Sessions/chartConfig"; import formatter, { POWER_UNIT } from "@/mixins/formatter"; import colors, { lighterColor } from "@/colors"; -import type { CURRENCY, ForecastSlot, SolarDetails, TimeseriesEntry } from "@/types/evcc"; +import type { CURRENCY, UiForecastSlot, UiSolarDetails, UiTimeseriesEntry } from "@/types/evcc"; import { ForecastType, highestSlotIndexByDay } from "@/utils/forecast"; registerChartComponents([ @@ -64,9 +64,9 @@ export default defineComponent({ components: { Bar }, mixins: [formatter], props: { - grid: { type: Array as PropType }, - solar: { type: Object as PropType }, - co2: { type: Array as PropType }, + grid: { type: Array as PropType }, + solar: { type: Object as PropType }, + co2: { type: Array as PropType }, currency: { type: String as PropType }, selected: { type: String as PropType }, }, @@ -452,7 +452,7 @@ export default defineComponent({ now.setMilliseconds(0); this.startDate = now; }, - filterSlots(slots: ForecastSlot[] = []) { + filterSlots(slots: UiForecastSlot[] = []) { if (!slots) { return undefined; } @@ -462,7 +462,7 @@ export default defineComponent({ new Date(slot.end) >= this.startDate && new Date(slot.start) <= this.endDate ); }, - filterEntries(entries: TimeseriesEntry[] = []) { + filterEntries(entries: UiTimeseriesEntry[] = []) { return entries.filter( (entry) => new Date(entry.ts) >= this.startDate && new Date(entry.ts) <= this.endDate @@ -491,7 +491,7 @@ export default defineComponent({ }, 100); } }, - yMax(slots: ForecastSlot[] = []): number | undefined { + yMax(slots: UiForecastSlot[] = []): number | undefined { const max = this.maxValue(slots); if (!max) return undefined; const fixedValues = slots.every((slot) => slot.value === max); @@ -499,29 +499,29 @@ export default defineComponent({ const topSpace = fixedValues ? 3 : 1.15; return max * topSpace; }, - yMaxEntry(entries: TimeseriesEntry[] = [], scale: number = 1): number | undefined { + yMaxEntry(entries: UiTimeseriesEntry[] = [], scale: number = 1): number | undefined { const maxValue = this.maxEntryValue(entries); if (!maxValue) return undefined; // use scale and unscaled to determine max scale return Math.max(maxValue * scale, maxValue) * 1.15; }, - maxIndex(slots: ForecastSlot[] = []) { + maxIndex(slots: UiForecastSlot[] = []) { return slots.reduce((max, slot, index) => { return slot.value > (slots[max]?.value || 0) ? index : max; }, 0); }, - minIndex(slots: ForecastSlot[] = []) { + minIndex(slots: UiForecastSlot[] = []) { return slots.reduce((min, slot, index) => { return slot.value < (slots[min]?.value || 0) ? index : min; }, 0); }, - maxValue(slots: ForecastSlot[] = []) { + maxValue(slots: UiForecastSlot[] = []) { return slots[this.maxIndex(slots)]?.value || null; }, - maxEntryValue(entries: TimeseriesEntry[] = []) { + maxEntryValue(entries: UiTimeseriesEntry[] = []) { return entries[this.maxEntryIndex(entries)]?.val || null; }, - maxEntryIndex(entries: TimeseriesEntry[] = []) { + maxEntryIndex(entries: UiTimeseriesEntry[] = []) { return entries.reduce((max, entry, index) => { return entry.val > (entries[max]?.val || 0) ? index : max; }, 0); diff --git a/assets/js/components/Forecast/GridDetails.vue b/assets/js/components/Forecast/GridDetails.vue index 012febe7f..c9c567d25 100644 --- a/assets/js/components/Forecast/GridDetails.vue +++ b/assets/js/components/Forecast/GridDetails.vue @@ -50,7 +50,7 @@ diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue index 43c8f25bb..3e016e0b7 100644 --- a/assets/js/components/Config/PropertyField.vue +++ b/assets/js/components/Config/PropertyField.vue @@ -409,14 +409,6 @@ export default { diff --git a/assets/js/components/Helper/SavingIndicator.vue b/assets/js/components/Helper/SavingIndicator.vue new file mode 100644 index 000000000..ad41d94bc --- /dev/null +++ b/assets/js/components/Helper/SavingIndicator.vue @@ -0,0 +1,21 @@ + + + diff --git a/assets/js/components/Sessions/SessionDetailsModal.vue b/assets/js/components/Sessions/SessionDetailsModal.vue index 3c2ac3fba..c0322ef5b 100644 --- a/assets/js/components/Sessions/SessionDetailsModal.vue +++ b/assets/js/components/Sessions/SessionDetailsModal.vue @@ -367,14 +367,5 @@ export default defineComponent({ .odometer-input .form-control { /* fit ~7 digits (e.g. 128222) */ width: calc(7ch + 1rem); - -moz-appearance: textfield; - appearance: textfield; -} - -.odometer-input .form-control::-webkit-inner-spin-button, -.odometer-input .form-control::-webkit-outer-spin-button { - -webkit-appearance: none; - appearance: none; - margin: 0; } diff --git a/assets/js/components/Vehicles/Title.vue b/assets/js/components/Vehicles/Title.vue index 969efc59f..ff140c256 100644 --- a/assets/js/components/Vehicles/Title.vue +++ b/assets/js/components/Vehicles/Title.vue @@ -158,11 +158,6 @@ export default defineComponent({ diff --git a/i18n/check.ts b/i18n/check.ts index 3f9f6dd98..2302de8f4 100644 --- a/i18n/check.ts +++ b/i18n/check.ts @@ -11,16 +11,16 @@ const __dirname = dirname(__filename); function validateFilePath(filePath: string): string { const resolvedPath = resolve(filePath); const resolvedBaseDir = resolve(__dirname); - + if (!resolvedPath.startsWith(resolvedBaseDir)) { throw new Error(`Path traversal attempt detected: ${filePath}`); } - + // Ensure it's a JSON file if (extname(resolvedPath) !== ".json") { throw new Error(`Invalid file type: ${filePath}`); } - + return resolvedPath; } @@ -28,7 +28,7 @@ function validateFilePath(filePath: string): string { function extractPlaceholders(text: string): string[] { const matches = text.match(/\{[^}]+\}/g); if (!matches) return []; - + // Remove duplicates and sort const unique = [...new Set(matches)]; return unique.sort(); @@ -36,15 +36,18 @@ function extractPlaceholders(text: string): string[] { // Flatten nested translation object into dot-notation keys function flattenObject(obj: any, prefix = ""): Record { - return Object.entries(obj).reduce((acc, [key, val]) => { - const path = prefix ? `${prefix}.${key}` : key; - if (val && typeof val === "object") { - Object.assign(acc, flattenObject(val, path)); - } else if (typeof val === "string") { - acc[path] = val; - } - return acc; - }, {} as Record); + return Object.entries(obj).reduce( + (acc, [key, val]) => { + const path = prefix ? `${prefix}.${key}` : key; + if (val && typeof val === "object") { + Object.assign(acc, flattenObject(val, path)); + } else if (typeof val === "string") { + acc[path] = val; + } + return acc; + }, + {} as Record + ); } // Compare placeholder arrays using Set equality for better performance @@ -69,63 +72,62 @@ function loadTranslationFile(filePath: string): Record { // Get all translation files in the directory safely function getTranslationFiles(): string[] { const files = readdirSync(__dirname) - .filter(file => { + .filter((file) => { // Only allow valid filename characters and JSON extension return /^[a-zA-Z0-9_-]+\.json$/.test(file); }) - .map(file => validateFilePath(join(__dirname, file))); - + .map((file) => validateFilePath(join(__dirname, file))); + return files; } // Main validation function function validateTranslations(): void { console.log("🔍 Checking translation placeholder consistency..."); - + const sourceFile = validateFilePath(join(__dirname, "en.json")); - + if (!existsSync(sourceFile)) { console.error(`❌ Source file not found: ${sourceFile}`); process.exit(1); } - + const sourceFlat = loadTranslationFile(sourceFile); - const translationFiles = getTranslationFiles() - .filter(file => basename(file) !== "en.json"); - + const translationFiles = getTranslationFiles().filter((file) => basename(file) !== "en.json"); + if (translationFiles.length === 0) { console.log("ℹ️ No translation files to check."); process.exit(0); } - - const allErrors: Array<{file: string; key: string; expected: string[]; found: string[]}> = []; - + + const allErrors: Array<{ file: string; key: string; expected: string[]; found: string[] }> = []; + for (const file of translationFiles) { const lang = basename(file, ".json"); const targetFlat = loadTranslationFile(file); - + for (const [key, sourceStr] of Object.entries(sourceFlat)) { const sourcePlaceholders = extractPlaceholders(sourceStr); - + // Only check if source has placeholders if (sourcePlaceholders.length === 0) continue; - + // Skip if translation doesn't exist at all (fallback to English is fine) if (!(key in targetFlat)) continue; - + const targetPlaceholders = extractPlaceholders(targetFlat[key]); - + if (!placeholdersMatch(sourcePlaceholders, targetPlaceholders)) { allErrors.push({ file: lang, key, expected: sourcePlaceholders, - found: targetPlaceholders + found: targetPlaceholders, }); } } } - + // Group errors by file and output const errorsByFile = new Map(); for (const error of allErrors) { @@ -134,7 +136,7 @@ function validateTranslations(): void { } errorsByFile.get(error.file)!.push(error); } - + for (const [lang, errors] of errorsByFile) { console.log(`\n📄 i18n/${lang}.json (${errors.length} errors)`); for (const error of errors) { @@ -143,7 +145,7 @@ function validateTranslations(): void { console.log(` found: ${error.found.join(", ") || "(none)"}`); } } - + if (allErrors.length > 0) { console.log(`\n💡 ${allErrors.length} placeholder errors found.`); process.exit(1); @@ -154,4 +156,4 @@ function validateTranslations(): void { } // Run the validation -validateTranslations(); \ No newline at end of file +validateTranslations(); diff --git a/package.json b/package.json index d424e2945..57e3209ba 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "build": "vp build", "test": "cross-env TZ=Europe/Berlin NODE_OPTIONS=--no-experimental-webstorage vp test", "lint": "vp run lint:format && vp run lint:lint && vp run lint:tsc && vp run lint:i18n", - "lint:format": "vp fmt assets tests **/*.{yaml,sh} i18n/*.json --write", + "lint:format": "vp fmt assets tests i18n --write && vp fmt '**/*.{yaml,sh}' --write", "lint:lint": "vp lint --fix --max-warnings=0", "lint:tsc": "vue-tsc --noEmit", "lint:i18n": "tsx i18n/check.ts", diff --git a/tests/hems.spec.ts b/tests/hems.spec.ts index 08fd2aa8c..5d529cbb0 100644 --- a/tests/hems.spec.ts +++ b/tests/hems.spec.ts @@ -127,7 +127,10 @@ maxconsumptionpower: await expectModalHidden(hemsModal); // enable experimental - await page.getByTestId("generalconfig-experimental").getByRole("button", { name: "edit" }).click(); + await page + .getByTestId("generalconfig-experimental") + .getByRole("button", { name: "edit" }) + .click(); const experimentalModal = page.getByTestId("experimental-modal"); await expectModalVisible(experimentalModal); await experimentalModal.getByLabel("Enable experimental features.").click(); diff --git a/tests/plan.spec.ts b/tests/plan.spec.ts index 7abfc1588..eefda80ea 100644 --- a/tests/plan.spec.ts +++ b/tests/plan.spec.ts @@ -725,9 +725,7 @@ test.describe("plan strategy", async () => { await expect(modal.getByTestId("plan-strategy")).toContainText("cleanest slots"); await optimization.selectOption("continuous"); - await expect(modal.getByTestId("plan-strategy")).toContainText( - "cleanest uninterrupted series" - ); + await expect(modal.getByTestId("plan-strategy")).toContainText("cleanest uninterrupted series"); }); test("visible and functional on mobile", async ({ page }) => { From fdea008b0bf3bf8fe075278e09c10d9f89d3ca68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Sun, 9 Aug 2026 13:17:48 +0200 Subject: [PATCH 117/331] Powerwall: add Fleet API support (BC) (#32410) --- cmd/token_ford-connect.go | 6 +- cmd/token_tronity.go | 4 +- go.mod | 1 - go.sum | 2 - meter/powerwall.go | 156 ++++++------------ meter/powerwall_fleet.go | 114 +++++++++++++ meter/powerwall_test.go | 136 +++++++++++++++ .../meter/tesla-powerwall-fleet.yaml | 70 ++++++++ .../definition/meter/tesla-powerwall.yaml | 26 +-- vehicle/types.go => util/oauth/config.go | 2 +- vehicle/ford-connect-query.go | 3 +- vehicle/ford-connect.go | 5 +- vehicle/mercedes.go | 3 +- vehicle/psa.go | 3 +- vehicle/tesla.go | 47 ++---- vehicle/tesla/fleet.go | 72 ++++++++ vehicle/tesla/fleet_test.go | 67 ++++++++ vehicle/tesla/helper.go | 14 -- vehicle/tesla/identity.go | 9 +- vehicle/tronity.go | 5 +- vehicle/volvo-connected.go | 3 +- 21 files changed, 548 insertions(+), 200 deletions(-) create mode 100644 meter/powerwall_fleet.go create mode 100644 meter/powerwall_test.go create mode 100644 templates/definition/meter/tesla-powerwall-fleet.yaml rename vehicle/types.go => util/oauth/config.go (98%) create mode 100644 vehicle/tesla/fleet.go create mode 100644 vehicle/tesla/fleet_test.go diff --git a/cmd/token_ford-connect.go b/cmd/token_ford-connect.go index 22a94c13b..843dbad6b 100644 --- a/cmd/token_ford-connect.go +++ b/cmd/token_ford-connect.go @@ -6,16 +6,16 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" + "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/util/request" - "github.com/evcc-io/evcc/vehicle" "github.com/evcc-io/evcc/vehicle/ford/connect" "golang.org/x/oauth2" ) func fordConnectToken(conf config.Named) (*oauth2.Token, error) { var cc struct { - Credentials vehicle.ClientCredentials - Tokens vehicle.Tokens + Credentials oauth.ClientCredentials + Tokens oauth.Tokens Other map[string]any `mapstructure:",remain"` } diff --git a/cmd/token_tronity.go b/cmd/token_tronity.go index ed339e9f5..586a175b6 100644 --- a/cmd/token_tronity.go +++ b/cmd/token_tronity.go @@ -13,7 +13,7 @@ import ( "github.com/evcc-io/evcc/api/globalconfig" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" - "github.com/evcc-io/evcc/vehicle" + "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/vehicle/tronity" "github.com/samber/lo" "github.com/skratchdot/open-golang/open" @@ -101,7 +101,7 @@ func tronityAuthorize(addr string, oc *oauth2.Config) (*oauth2.Token, error) { func tronityToken(conf globalconfig.All, vehicleConf config.Named) (*oauth2.Token, error) { var cc struct { - Credentials vehicle.ClientCredentials + Credentials oauth.ClientCredentials RedirectURI string Other map[string]any `mapstructure:",remain"` } diff --git a/go.mod b/go.mod index d89270a01..03b91db15 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.36.4 github.com/basgys/goxml2json v1.1.0 github.com/benbjohnson/clock v1.3.5 - github.com/bogosj/tesla v1.3.2-0.20250818120641-a31b7b6396c9 github.com/cenkalti/backoff/v4 v4.3.0 github.com/cli/browser v1.3.0 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 diff --git a/go.sum b/go.sum index 259dfba03..01ce8bcc1 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,6 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/bogosj/tesla v1.3.2-0.20250818120641-a31b7b6396c9 h1:vAEi0OtVQbyRmnKXz8k350VfyoWYljOrlMDDuU1d/Ug= -github.com/bogosj/tesla v1.3.2-0.20250818120641-a31b7b6396c9/go.mod h1:nQrPioMhF8Yf36iCtta01SUbsnGN25VhBYLN4DIMBPU= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= diff --git a/meter/powerwall.go b/meter/powerwall.go index c84d1e2df..aba9f78e7 100644 --- a/meter/powerwall.go +++ b/meter/powerwall.go @@ -1,48 +1,38 @@ package meter import ( - "context" "errors" "fmt" - "math" "net/http" - "strconv" "strings" "time" "github.com/andig/go-powerwall" - "github.com/bogosj/tesla" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/implement" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" - "golang.org/x/oauth2" ) // PowerWall is the tesla powerwall meter type PowerWall struct { implement.Caps - usage string - client *powerwall.Client - meterG func() (map[string]powerwall.MeterAggregatesData, error) - energySite *tesla.EnergySite + usage string + client *powerwall.Client + meterG func() (map[string]powerwall.MeterAggregatesData, error) } -func init() { - registry.Add("tesla", NewPowerWallFromConfig) - registry.Add("powerwall", NewPowerWallFromConfig) +type powerWallConfig struct { + URI, Usage, User, Password string + Cache time.Duration + SiteId int64 // Fleet API only, deprecated for the local meter + RefreshToken_ string `mapstructure:"refreshToken"` // TODO deprecated + batterySocLimits `mapstructure:",squash"` + batteryPowerLimits `mapstructure:",squash"` } -// NewPowerWallFromConfig creates a PowerWall Powerwall Meter from generic config -func NewPowerWallFromConfig(other map[string]any) (api.Meter, error) { - cc := struct { - URI, Usage, User, Password string - Cache time.Duration - RefreshToken string - SiteId int64 - batterySocLimits `mapstructure:",squash"` - batteryPowerLimits `mapstructure:",squash"` - }{ +func defaultPowerWallConfig() powerWallConfig { + return powerWallConfig{ batterySocLimits: batterySocLimits{ MinSoc: 20, MaxSoc: 95, @@ -53,17 +43,16 @@ func NewPowerWallFromConfig(other map[string]any) (api.Meter, error) { }, Cache: time.Second, } +} - if err := util.DecodeOther(other, &cc); err != nil { - return nil, err - } - +// validate checks required parameters and maps legacy usage names +func (cc *powerWallConfig) validate() error { if cc.Usage == "" { - return nil, errors.New("missing usage") + return errors.New("missing usage") } if cc.Password == "" { - return nil, errors.New("missing password") + return errors.New("missing password") } // support default meter names @@ -74,19 +63,41 @@ func NewPowerWallFromConfig(other map[string]any) (api.Meter, error) { cc.Usage = "solar" } - return NewPowerWall(cc.URI, cc.Usage, cc.User, cc.Password, cc.Cache, cc.RefreshToken, cc.SiteId, cc.batterySocLimits, cc.batteryPowerLimits) + return nil } -// NewPowerWall creates a Tesla PowerWall Meter -func NewPowerWall(uri, usage, user, password string, cache time.Duration, refreshToken string, siteId int64, batterySocLimits batterySocLimits, batteryPowerLimits batteryPowerLimits) (api.Meter, error) { - log := util.NewLogger("powerwall").Redact(user, password, refreshToken) +func init() { + registry.Add("tesla", NewPowerWallFromConfig) + registry.Add("powerwall", NewPowerWallFromConfig) +} +// NewPowerWallFromConfig creates a PowerWall Powerwall Meter from generic config +func NewPowerWallFromConfig(other map[string]any) (api.Meter, error) { + cc := defaultPowerWallConfig() + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if err := cc.validate(); err != nil { + return nil, err + } + + log := util.NewLogger("powerwall").Redact(cc.User, cc.Password) + + if cc.RefreshToken_ != "" { + log.WARN.Println("refreshToken is deprecated, use the Powerwall (Fleet API) template for battery control") + } + + return newPowerWall(log, cc) +} + +func newPowerWall(log *util.Logger, cc powerWallConfig) (*PowerWall, error) { httpClient := &http.Client{ Transport: request.NewTripper(log, powerwall.DefaultTransport()), Timeout: time.Second * 2, // Timeout after 2 seconds } - client := powerwall.NewClient(uri, user, password, powerwall.WithHttpClient(httpClient)) + client := powerwall.NewClient(cc.URI, cc.User, cc.Password, powerwall.WithHttpClient(httpClient)) if _, err := client.GetStatus(); err != nil { return nil, err } @@ -94,62 +105,18 @@ func NewPowerWall(uri, usage, user, password string, cache time.Duration, refres m := &PowerWall{ Caps: implement.New(), client: client, - usage: strings.ToLower(usage), - meterG: util.Cached(client.GetMetersAggregates, cache), - } - - var batteryControl bool - if refreshToken != "" || siteId != 0 { - if refreshToken == "" { - return nil, errors.New("missing refresh token") - } - batteryControl = true - } - - if batteryControl { - ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log)) - - options := []tesla.ClientOption{tesla.WithToken(&oauth2.Token{ - RefreshToken: refreshToken, - Expiry: time.Now(), - })} - - cloudClient, err := tesla.NewClient(ctx, options...) - if err != nil { - return nil, err - } - - if siteId == 0 { - // auto detect energy site ID, picking first - products, err := cloudClient.Products() - if err != nil { - return nil, err - } - - for _, p := range products { - if p.EnergySiteId != 0 { - siteId = p.EnergySiteId - break - } - } - } - - log.Redact(strconv.FormatInt(siteId, 10)) - energySite, err := cloudClient.EnergySite(siteId) - if err != nil { - return nil, err - } - m.energySite = energySite + usage: strings.ToLower(cc.Usage), + meterG: util.Cached(client.GetMetersAggregates, cc.Cache), } if m.usage == "load" || m.usage == "solar" { implement.Has(m, implement.MeterEnergy(m.totalEnergy)) } - if usage == "battery" { + if m.usage == "battery" { implement.Has(m, implement.Battery(m.batterySoc)) - implement.May(m, implement.BatterySocLimiter(batterySocLimits.Decorator())) - implement.May(m, implement.BatteryPowerLimiter(batteryPowerLimits.Decorator())) + implement.May(m, implement.BatterySocLimiter(cc.batterySocLimits.Decorator())) + implement.May(m, implement.BatteryPowerLimiter(cc.batteryPowerLimits.Decorator())) res, err := m.client.GetSystemStatus() if err != nil { @@ -161,19 +128,6 @@ func NewPowerWall(uri, usage, user, password string, cache time.Duration, refres })) } - if batteryControl { - implement.May(m, implement.BatteryController(batterySocLimits.LimitController(m.socG, func(limit float64) error { - // Handle Tesla firmware 25.18.4 restrictions: - // Values between 81-99% are not allowed, only ≤80% or exactly 100% - limitUint := uint64(limit) - if limitUint > 80 && limitUint < 100 { - // Adjust to maximum allowed (80%) - limitUint = 80 - } - return m.energySite.SetBatteryReserve(limitUint) - }))) - } - return m, nil } @@ -221,15 +175,3 @@ func (m *PowerWall) batterySoc() (float64, error) { return res.Percentage, err } - -// decorate soc -func (m *PowerWall) socG() (float64, error) { - ess, err := m.energySite.EnergySiteStatus() - if err != nil { - return 0, err - } - // Fix for Tesla firmware 25.18.4: Remove the problematic +0.5 rounding logic - // that was interfering with exact 100% reserve settings. Simply return the - // actual current SOC rounded to nearest integer. - return math.Round(ess.PercentageCharged), nil -} diff --git a/meter/powerwall_fleet.go b/meter/powerwall_fleet.go new file mode 100644 index 000000000..2174e40b2 --- /dev/null +++ b/meter/powerwall_fleet.go @@ -0,0 +1,114 @@ +package meter + +import ( + "errors" + "fmt" + "math" + "strconv" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/api/implement" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/vehicle/tesla" + teslaclient "github.com/evcc-io/tesla-proxy-client" +) + +type fleetConfig struct { + powerWallConfig `mapstructure:",squash"` + tesla.FleetConfig `mapstructure:",squash"` +} + +func init() { + registry.Add("powerwall-fleet", NewPowerWallFleetFromConfig) +} + +// NewPowerWallFleetFromConfig creates a PowerWall meter with Fleet API battery control +func NewPowerWallFleetFromConfig(other map[string]any) (api.Meter, error) { + cc := fleetConfig{powerWallConfig: defaultPowerWallConfig()} + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if err := cc.validate(); err != nil { + return nil, err + } + + if err := cc.FleetConfig.Validate(); err != nil { + return nil, err + } + + log := util.NewLogger("powerwall").Redact( + cc.User, + cc.Password, + cc.Credentials.ID, + cc.Credentials.Secret, + cc.Tokens.Access, + cc.Tokens.Refresh, + ) + m, err := newPowerWall(log, cc.powerWallConfig) + if err != nil { + return nil, err + } + + energySite, err := teslaEnergySite(log, cc.FleetConfig, cc.SiteId) + if err != nil { + return nil, err + } + + implement.May(m, implement.BatteryController(cc.batterySocLimits.LimitController(func() (float64, error) { + ess, err := energySite.EnergySiteStatus() + if err != nil { + return 0, fmt.Errorf("get energy site status: %w", err) + } + + return math.Round(ess.PercentageCharged), nil + }, func(limit float64) error { + return energySite.SetBatteryReserve(teslaReserveLimit(limit)) + }))) + + return m, nil +} + +// teslaReserveLimit clamps the reserve to values accepted by Tesla firmware: up to 80 or exactly 100 +func teslaReserveLimit(limit float64) uint64 { + switch { + case limit >= 100: + return 100 + case limit > 80: + return 80 + case limit > 0: + return uint64(limit) + default: // negative or NaN + return 0 + } +} + +func teslaEnergySite(log *util.Logger, config tesla.FleetConfig, siteId int64) (*teslaclient.EnergySite, error) { + fleet, err := config.Client(log) + if err != nil { + return nil, err + } + tc := fleet.Client + + if siteId == 0 { + products, err := tc.Products() + if err != nil { + return nil, fmt.Errorf("discover energy sites: %w", err) + } + + for _, p := range products { + if p.EnergySiteId != 0 { + siteId = p.EnergySiteId + break + } + } + + if siteId == 0 { + return nil, errors.New("no energy site found") + } + } + + log.Redact(strconv.FormatInt(siteId, 10)) + + return tc.EnergySite(siteId) +} diff --git a/meter/powerwall_test.go b/meter/powerwall_test.go new file mode 100644 index 000000000..a2e8516f8 --- /dev/null +++ b/meter/powerwall_test.go @@ -0,0 +1,136 @@ +package meter + +import ( + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewPowerWallFromConfigValidation(t *testing.T) { + tests := []struct { + name string + config map[string]any + want string + }{ + { + name: "missing usage", + config: map[string]any{"password": "secret"}, + want: "missing usage", + }, + { + name: "missing password", + config: map[string]any{"usage": "battery"}, + want: "missing password", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := NewPowerWallFromConfig(tc.config) + assert.ErrorContains(t, err, tc.want) + }) + } +} + +// decodePowerWallConfig mirrors the decoding done by the meter constructors +func decodePowerWallConfig(t *testing.T, other map[string]any) powerWallConfig { + t.Helper() + + cc := defaultPowerWallConfig() + require.NoError(t, util.DecodeOther(other, &cc)) + require.NoError(t, cc.validate()) + + return cc +} + +func TestPowerWallConfigLegacyUsage(t *testing.T) { + tests := []struct { + usage string + want string + }{ + {usage: "grid", want: "site"}, + {usage: "pv", want: "solar"}, + } + + for _, tc := range tests { + t.Run(tc.usage, func(t *testing.T) { + cc := decodePowerWallConfig(t, map[string]any{ + "usage": tc.usage, + "password": "secret", + }) + assert.Equal(t, tc.want, cc.Usage) + }) + } +} + +func TestPowerWallConfigDeprecatedParams(t *testing.T) { + decodePowerWallConfig(t, map[string]any{ + "usage": "battery", "password": "secret", + "refreshToken": "token", "siteId": 123, + }) +} + +func TestNewPowerWallFleetFromConfigValidation(t *testing.T) { + tests := []struct { + name string + config map[string]any + want string + wantSentinel error + }{ + { + name: "missing client id", + config: map[string]any{ + "usage": "battery", + "password": "secret", + }, + want: "missing client id", + }, + { + name: "missing tokens", + config: map[string]any{ + "usage": "battery", + "password": "secret", + "credentials": map[string]any{ + "id": "client", + }, + }, + wantSentinel: api.ErrMissingToken, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := NewPowerWallFleetFromConfig(tc.config) + if tc.wantSentinel != nil { + assert.ErrorIs(t, err, tc.wantSentinel) + return + } + assert.ErrorContains(t, err, tc.want) + }) + } +} + +func TestTeslaReserveLimit(t *testing.T) { + tests := []struct { + name string + limit float64 + want uint64 + }{ + {name: "negative", limit: -1, want: 0}, + {name: "below cap", limit: 79.9, want: 79}, + {name: "at cap", limit: 80, want: 80}, + {name: "unsupported range start", limit: 81, want: 80}, + {name: "unsupported range end", limit: 99.9, want: 80}, + {name: "full reserve", limit: 100, want: 100}, + {name: "above full reserve", limit: 101, want: 100}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, teslaReserveLimit(tc.limit)) + }) + } +} diff --git a/templates/definition/meter/tesla-powerwall-fleet.yaml b/templates/definition/meter/tesla-powerwall-fleet.yaml new file mode 100644 index 000000000..8a9cc4edf --- /dev/null +++ b/templates/definition/meter/tesla-powerwall-fleet.yaml @@ -0,0 +1,70 @@ +template: tesla-powerwall-fleet +products: + - brand: Tesla + description: + generic: Powerwall (Fleet API) +capabilities: ["battery-control"] +requirements: + description: + de: | + Tesla bietet eine offizielle, aber kostenpflichtige Fleet API an. + Für private Nutzung kannst du dir einen Tesla Developer Account bei [developer.tesla.com](https://developer.tesla.com/) erstellen und erhältst ein monatliches API-Guthaben von 10 €. + + Die Anleitung von [myteslamate.com](https://www.myteslamate.com/tesla-api-application-registration/) erklärt den Prozess und generiert dir kostenfrei die für evcc benötigten Access- und Refresh-Token. + Mit diesem Tokenpaar und deiner im Tesla Developer Account erstellten Client ID kann evcc direkt mit der Tesla Fleet API kommunizieren. + en: | + Tesla offers an official, but paid Fleet API. + For private use, you can create a Tesla Developer Account at [developer.tesla.com](https://developer.tesla.com/) and receive a monthly API credit of $10. + + The [myteslamate.com](https://www.myteslamate.com/tesla-api-application-registration/) guide explains the process and generates a free Access and Refresh Token. + With this token pair and your Client ID created in the Tesla Developer Account, evcc can directly communicate with the Tesla Fleet API. +params: + - name: host + - name: password + required: true + help: + en: Password of the user "customer". By default this is the last 5 characters of password stated on the Tesla Gateway. + de: Passwort des Benutzers "Kunde". Default sind die letzten 5 Zeichen des auf dem Tesla Gateway genannten Passworts. + - name: clientId + required: true + help: + en: from [developer.tesla.com](https://developer.tesla.com/dashboard). + de: von [developer.tesla.com](https://developer.tesla.com/dashboard). + - name: accessToken + required: true + help: + en: from [myteslamate.com](https://app.myteslamate.com/). + de: von [myteslamate.com](https://app.myteslamate.com/). + - name: refreshToken + required: true + help: + en: from [myteslamate.com](https://app.myteslamate.com/). + de: von [myteslamate.com](https://app.myteslamate.com/). + - name: siteId + description: + generic: Site ID + help: + en: optional product identifier of the energy site, use to override autodetection + de: optionale Product ID dieser Energy Site, zum Übersteuern der automatischen Erkennung + - name: minsoc + advanced: true + - name: maxsoc + advanced: true + - name: maxchargepower + - name: maxdischargepower +render: | + type: powerwall-fleet + uri: {{ .host }} + usage: battery + user: customer + password: {{ .password }} # for user 'customer' + credentials: + id: {{ .clientId }} + tokens: + access: {{ .accessToken }} + refresh: {{ .refreshToken }} + siteId: {{ .siteId }} + minsoc: {{ .minsoc }} + maxsoc: {{ .maxsoc }} + maxchargepower: {{ .maxchargepower }} + maxdischargepower: {{ .maxdischargepower }} diff --git a/templates/definition/meter/tesla-powerwall.yaml b/templates/definition/meter/tesla-powerwall.yaml index cddf39003..99f72dc2c 100644 --- a/templates/definition/meter/tesla-powerwall.yaml +++ b/templates/definition/meter/tesla-powerwall.yaml @@ -3,23 +3,6 @@ products: - brand: Tesla description: generic: Powerwall -capabilities: ["battery-control"] -requirements: - description: - de: | - Um die optionale Entladesteuerung der Battery zu nutzen wird ein `refresh` Token für die Kommunikation mit der Tesla API benötigt. - - Folgende Apps ermöglichen das Erstellen des Tokens: - - [Auth app for Tesla (iOS)](https://apps.apple.com/us/app/auth-app-for-tesla/id1552058613#?platform=iphone) - - [Tesla Tokens (Android)](https://play.google.com/store/apps/details?id=net.leveugle.teslatokens) - - [Tesla Auth (macOS, Linux)](https://github.com/adriankumpf/tesla_auth) - en: | - To use the optional battery control you need to generate a `refresh` token for communicating with the Tesla API. - - The following apps allow to create the token: - - [Auth app for Tesla (iOS)](https://apps.apple.com/us/app/auth-app-for-tesla/id1552058613#?platform=iphone) - - [Tesla Tokens (Android)](https://play.google.com/store/apps/details?id=net.leveugle.teslatokens) - - [Tesla Auth (macOS, Linux)](https://github.com/adriankumpf/tesla_auth) params: - name: usage choice: ["grid", "pv", "battery"] @@ -30,12 +13,9 @@ params: en: Password of the user "customer". By default this is the last 5 characters of password stated on the Tesla Gateway. de: Passwort des Benutzers "Kunde". Default sind die letzten 5 Zeichen des auf dem Tesla Gateway genannten Passworts. - name: refreshToken + deprecated: true - name: siteId - description: - generic: Site ID - help: - en: optional product identifier of the energy site, use to override autodectction - de: optionale Product ID dieser Energy Site, zum Übersteuern der automatischen Erkennung + deprecated: true - name: minsoc advanced: true - name: maxsoc @@ -48,8 +28,6 @@ render: | usage: {{ .usage }} user: customer password: {{ .password }} # for user 'customer' - refreshToken: {{ .refreshToken }} - siteId: {{ .siteId }} minsoc: {{ .minsoc }} maxsoc: {{ .maxsoc }} maxchargepower: {{ .maxchargepower }} diff --git a/vehicle/types.go b/util/oauth/config.go similarity index 98% rename from vehicle/types.go rename to util/oauth/config.go index 717996123..2286ac5b4 100644 --- a/vehicle/types.go +++ b/util/oauth/config.go @@ -1,4 +1,4 @@ -package vehicle +package oauth import ( "errors" diff --git a/vehicle/ford-connect-query.go b/vehicle/ford-connect-query.go index a29a4fa01..7ea2b5ad7 100644 --- a/vehicle/ford-connect-query.go +++ b/vehicle/ford-connect-query.go @@ -6,6 +6,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/vehicle/ford/query" ) @@ -25,7 +26,7 @@ func init() { func NewFordConnectQueryFromConfig(other map[string]any) (api.Vehicle, error) { cc := struct { embed `mapstructure:",squash"` - Credentials ClientCredentials + Credentials oauth.ClientCredentials RedirectURI string VIN string Cache time.Duration diff --git a/vehicle/ford-connect.go b/vehicle/ford-connect.go index f0fb158c6..5e223de5e 100644 --- a/vehicle/ford-connect.go +++ b/vehicle/ford-connect.go @@ -5,6 +5,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/vehicle/ford/connect" ) @@ -24,8 +25,8 @@ func init() { func NewFordConnectFromConfig(other map[string]any) (api.Vehicle, error) { cc := struct { embed `mapstructure:",squash"` - Credentials ClientCredentials - Tokens Tokens + Credentials oauth.ClientCredentials + Tokens oauth.Tokens VIN string Cache time.Duration }{ diff --git a/vehicle/mercedes.go b/vehicle/mercedes.go index f23b6c547..a2df85883 100644 --- a/vehicle/mercedes.go +++ b/vehicle/mercedes.go @@ -5,6 +5,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/vehicle/mercedes" ) @@ -22,7 +23,7 @@ func init() { func NewMercedesFromConfig(other map[string]any) (api.Vehicle, error) { cc := struct { embed `mapstructure:",squash"` - Tokens Tokens + Tokens oauth.Tokens User string Account_ string `mapstructure:"account"` // TODO deprecated VIN string diff --git a/vehicle/psa.go b/vehicle/psa.go index add433085..729513acd 100644 --- a/vehicle/psa.go +++ b/vehicle/psa.go @@ -6,6 +6,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/vehicle/psa" ) @@ -40,7 +41,7 @@ func newPSA(brand, realm string, other map[string]any) (api.Vehicle, error) { User string Password string `mapstructure:"password"` Country string - Tokens Tokens + Tokens oauth.Tokens Cache time.Duration }{ Cache: interval, diff --git a/vehicle/tesla.go b/vehicle/tesla.go index 05ec3cdc6..0e6dd5a01 100644 --- a/vehicle/tesla.go +++ b/vehicle/tesla.go @@ -2,7 +2,6 @@ package vehicle import ( "context" - "errors" "time" "github.com/evcc-io/evcc/api" @@ -11,7 +10,6 @@ import ( "github.com/evcc-io/evcc/util/transport" "github.com/evcc-io/evcc/vehicle/tesla" teslaclient "github.com/evcc-io/tesla-proxy-client" - "golang.org/x/oauth2" ) // Tesla is an api.Vehicle implementation for Tesla cars using the official Tesla vehicle-command api. @@ -28,14 +26,13 @@ func init() { // NewTeslaFromConfig creates a new vehicle func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) { cc := struct { - embed `mapstructure:",squash"` - Credentials ClientCredentials - Tokens Tokens - VIN string - CommandProxy string - ProxyToken string - Cache time.Duration - Timeout time.Duration + embed `mapstructure:",squash"` + tesla.FleetConfig `mapstructure:",squash"` + VIN string + CommandProxy string + ProxyToken string + Cache time.Duration + Timeout time.Duration }{ CommandProxy: tesla.ProxyBaseUrl, Cache: interval, @@ -46,12 +43,7 @@ func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) { return nil, err } - if cc.Credentials.ID == "" { - return nil, errors.New("missing client id, see https://docs.evcc.io/en/docs/devices/vehicles#tesla") - } - - token, err := cc.Tokens.Token() - if err != nil { + if err := cc.FleetConfig.Validate(); err != nil { return nil, err } @@ -60,28 +52,11 @@ func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) { cc.Credentials.ID, cc.Credentials.Secret, ) - identity, err := tesla.NewIdentity(log, tesla.OAuth2Config(cc.Credentials.ID, cc.Credentials.Secret), token) + fleet, err := cc.FleetConfig.Client(log) if err != nil { return nil, err } - - hc := request.NewClient(log) - hc.Transport = &oauth2.Transport{ - Source: identity, - Base: hc.Transport, - } - - tc, err := teslaclient.NewClient(context.Background(), teslaclient.WithClient(hc)) - if err != nil { - return nil, err - } - - // validate base url - region, err := tc.UserRegion() - if err != nil { - return nil, err - } - tc.SetBaseUrl(region.FleetApiBaseUrl) + tc := fleet.Client vehicle, err := ensureVehicleEx( cc.VIN, tc.Vehicles, @@ -99,7 +74,7 @@ func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) { Decorator: transport.DecorateHeaders(map[string]string{ "X-Authorization": "Bearer " + cc.ProxyToken, }), - Base: hc.Transport, + Base: fleet.HTTPClient.Transport, } tcc, err := teslaclient.NewClient(context.Background(), teslaclient.WithClient(pc)) diff --git a/vehicle/tesla/fleet.go b/vehicle/tesla/fleet.go new file mode 100644 index 000000000..fad9858c3 --- /dev/null +++ b/vehicle/tesla/fleet.go @@ -0,0 +1,72 @@ +package tesla + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" + "github.com/evcc-io/evcc/util/request" + teslaclient "github.com/evcc-io/tesla-proxy-client" + "golang.org/x/oauth2" +) + +// FleetConfig contains Tesla Fleet API credentials and tokens +type FleetConfig struct { + Credentials oauth.ClientCredentials + Tokens oauth.Tokens +} + +// FleetClient provides authenticated access to the Tesla Fleet API +type FleetClient struct { + Client *teslaclient.Client + HTTPClient *http.Client +} + +// Validate checks that the required Tesla Fleet API credentials are configured +func (c FleetConfig) Validate() error { + if c.Credentials.ID == "" { + return errors.New("missing client id, see https://docs.evcc.io/en/docs/devices/vehicles#tesla") + } + if c.Tokens.Access == "" || c.Tokens.Refresh == "" { + return api.ErrMissingToken + } + + return nil +} + +// Client creates a Tesla Fleet API client for the configured account +func (c FleetConfig) Client(log *util.Logger) (*FleetClient, error) { + if err := c.Validate(); err != nil { + return nil, err + } + + token, err := c.Tokens.Token() + if err != nil { + return nil, err + } + + identity, err := NewIdentity(log, OAuth2Config(c.Credentials.ID, c.Credentials.Secret), token) + if err != nil { + return nil, fmt.Errorf("create Fleet identity: %w", err) + } + + hc := request.NewClient(log) + hc.Transport = &oauth2.Transport{Source: identity, Base: hc.Transport} + + tc, err := teslaclient.NewClient(context.Background(), teslaclient.WithClient(hc)) + if err != nil { + return nil, fmt.Errorf("create Fleet client: %w", err) + } + + region, err := tc.UserRegion() + if err != nil { + return nil, fmt.Errorf("get Fleet API region: %w", err) + } + tc.SetBaseUrl(region.FleetApiBaseUrl) + + return &FleetClient{Client: tc, HTTPClient: hc}, nil +} diff --git a/vehicle/tesla/fleet_test.go b/vehicle/tesla/fleet_test.go new file mode 100644 index 000000000..098e21a1e --- /dev/null +++ b/vehicle/tesla/fleet_test.go @@ -0,0 +1,67 @@ +package tesla + +import ( + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" + "github.com/stretchr/testify/assert" +) + +// Client must reject an incomplete config before opening any connection +func TestFleetClientValidates(t *testing.T) { + _, err := FleetConfig{}.Client(util.NewLogger("tesla")) + assert.ErrorContains(t, err, "missing client id") +} + +func TestFleetConfigValidate(t *testing.T) { + tests := []struct { + name string + config FleetConfig + want string + sentinel error + }{ + { + name: "missing client id", + want: "missing client id", + }, + { + name: "missing tokens", + config: FleetConfig{ + Credentials: oauth.ClientCredentials{ID: "client"}, + }, + sentinel: api.ErrMissingToken, + }, + { + name: "missing refresh token", + config: FleetConfig{ + Credentials: oauth.ClientCredentials{ID: "client"}, + Tokens: oauth.Tokens{Access: "access"}, + }, + sentinel: api.ErrMissingToken, + }, + { + name: "valid", + config: FleetConfig{ + Credentials: oauth.ClientCredentials{ID: "client"}, + Tokens: oauth.Tokens{Access: "access", Refresh: "refresh"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.Validate() + if tc.sentinel != nil { + assert.ErrorIs(t, err, tc.sentinel) + return + } + if tc.want != "" { + assert.ErrorContains(t, err, tc.want) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/vehicle/tesla/helper.go b/vehicle/tesla/helper.go index c35ef5fb1..77b308025 100644 --- a/vehicle/tesla/helper.go +++ b/vehicle/tesla/helper.go @@ -3,25 +3,11 @@ package tesla import ( "errors" "strings" - "sync" "github.com/evcc-io/evcc/api" "github.com/teslamotors/vehicle-command/pkg/connector/inet" ) -var ( - mu sync.Mutex - identities = make(map[string]*Identity) -) - -func getInstance(subject string) *Identity { - return identities[subject] -} - -func addInstance(subject string, identity *Identity) { - identities[subject] = identity -} - // apiError converts HTTP 408 error to ErrTimeout func apiError(err error) error { if err != nil && (errors.Is(err, inet.ErrVehicleNotAwake) || diff --git a/vehicle/tesla/identity.go b/vehicle/tesla/identity.go index b36977b97..cc16dbc73 100644 --- a/vehicle/tesla/identity.go +++ b/vehicle/tesla/identity.go @@ -14,6 +14,11 @@ import ( "golang.org/x/oauth2" ) +var ( + mu sync.Mutex + identities = make(map[string]*Identity) +) + // https://auth.tesla.com/oauth2/v3/.well-known/openid-configuration // OAuth2Config is the OAuth2 configuration for authenticating with the Tesla API. @@ -51,7 +56,7 @@ func NewIdentity(log *util.Logger, oc *oauth2.Config, token *oauth2.Token) (oaut } // reuse identity instance - if instance := getInstance(claims.Subject); instance != nil { + if instance := identities[claims.Subject]; instance != nil { return instance, nil } @@ -86,7 +91,7 @@ func NewIdentity(log *util.Logger, oc *oauth2.Config, token *oauth2.Token) (oaut v.TokenSource = oauth.RefreshTokenSource(token, v.refreshToken) // add instance - addInstance(claims.Subject, v) + identities[claims.Subject] = v return v, nil } diff --git a/vehicle/tronity.go b/vehicle/tronity.go index 50776447d..a620370ba 100644 --- a/vehicle/tronity.go +++ b/vehicle/tronity.go @@ -28,6 +28,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/implement" "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/sponsor" "github.com/evcc-io/evcc/vehicle/tronity" @@ -53,8 +54,8 @@ func init() { func NewTronityFromConfig(other map[string]any) (api.Vehicle, error) { cc := struct { embed `mapstructure:",squash"` - Credentials ClientCredentials - Tokens Tokens + Credentials oauth.ClientCredentials + Tokens oauth.Tokens VIN string Cache time.Duration }{ diff --git a/vehicle/volvo-connected.go b/vehicle/volvo-connected.go index 171aeb042..7bf6aa7cb 100644 --- a/vehicle/volvo-connected.go +++ b/vehicle/volvo-connected.go @@ -7,6 +7,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/vehicle/volvo/connected" ) @@ -26,7 +27,7 @@ func NewVolvoConnectedFromConfig(ctx context.Context, other map[string]any) (api embed `mapstructure:",squash"` VIN string VccApiKey string - Credentials ClientCredentials + Credentials oauth.ClientCredentials RedirectUri string Cache time.Duration }{ From 9ef7ea748a186e3ea6329c5b61810a8e7112baf0 Mon Sep 17 00:00:00 2001 From: mfuchs1984 <57141790+mfuchs1984@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:39:13 +0200 Subject: [PATCH 118/331] Site: respect minSoc in maxDischargePower calculation (#32664) --- core/site.go | 14 ++++++-- core/site_battery_limit_test.go | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/core/site.go b/core/site.go index ad83a1420..abe294d17 100644 --- a/core/site.go +++ b/core/site.go @@ -715,8 +715,18 @@ func (site *Site) updateBatteryMeters() { } if bpl, ok := api.Cap[api.BatteryPowerLimiter](meter); ok && maxDischargePower >= 0 { - _, discharge := bpl.GetPowerLimits() - maxDischargePower += discharge + var empty bool + if bsl, ok := api.Cap[api.BatterySocLimiter](meter); ok { + minSoc, _ := bsl.GetSocLimits() + if mm[i].Soc != nil && *mm[i].Soc <= minSoc { + empty = true + } + } + + if !empty { + _, discharge := bpl.GetPowerLimits() + maxDischargePower += discharge + } } else { maxDischargePower = -1 // any battery without a limit disables the cap } diff --git a/core/site_battery_limit_test.go b/core/site_battery_limit_test.go index 1cad1b716..036d64ac6 100644 --- a/core/site_battery_limit_test.go +++ b/core/site_battery_limit_test.go @@ -18,6 +18,10 @@ func (m *mockBatteryPowerLimiter) GetPowerLimits() (float64, float64) { return m.charge, m.discharge } +func (m *mockBatteryPowerLimiter) Soc() (float64, error) { + return 50, nil +} + type mockMeter struct { api.Meter } @@ -26,6 +30,10 @@ func (m *mockMeter) CurrentPower() (float64, error) { return 0, nil } +func (m *mockMeter) Soc() (float64, error) { + return 50, nil +} + func TestBatteryMaxDischargePowerAggregation(t *testing.T) { site := &Site{ log: util.NewLogger("foo"), @@ -53,3 +61,54 @@ func TestBatteryMaxDischargePowerAggregation(t *testing.T) { site.updateBatteryMeters() assert.Equal(t, 5000.0, site.GetBatteryMaxDischargePower()) } + +type mockBatterySocLimiter struct { + api.Meter + soc, min, max float64 +} + +func (m *mockBatterySocLimiter) Soc() (float64, error) { + return m.soc, nil +} + +func (m *mockBatterySocLimiter) GetSocLimits() (float64, float64) { + return m.min, m.max +} + +type mockLimiter struct { + mockBatterySocLimiter + discharge float64 +} + +func (m *mockLimiter) GetPowerLimits() (float64, float64) { + return 0, m.discharge +} + +func TestBatteryMaxDischargePowerWithMinSoc(t *testing.T) { + site := &Site{ + log: util.NewLogger("foo"), + } + + // one battery empty (soc <= min), one normal + m1 := &mockLimiter{mockBatterySocLimiter: mockBatterySocLimiter{Meter: &mockMeter{}, soc: 10, min: 20}, discharge: 2000} + m2 := &mockLimiter{mockBatterySocLimiter: mockBatterySocLimiter{Meter: &mockMeter{}, soc: 50, min: 20}, discharge: 3000} + + site.batteryMeters = []config.Device[api.Meter]{ + config.NewStaticDevice[api.Meter](config.Named{Name: "bat1"}, m1), + config.NewStaticDevice[api.Meter](config.Named{Name: "bat2"}, m2), + } + + site.updateBatteryMeters() + // Only m2 should contribute + assert.Equal(t, 3000.0, site.GetBatteryMaxDischargePower()) + + // Both empty + m3 := &mockLimiter{mockBatterySocLimiter: mockBatterySocLimiter{Meter: &mockMeter{}, soc: 15, min: 20}, discharge: 3000} + site.batteryMeters = []config.Device[api.Meter]{ + config.NewStaticDevice[api.Meter](config.Named{Name: "bat1"}, m1), + config.NewStaticDevice[api.Meter](config.Named{Name: "bat3"}, m3), + } + + site.updateBatteryMeters() + assert.Equal(t, 0.0, site.GetBatteryMaxDischargePower()) +} From c1fac30171e25aa3fbea8e37173ffc433e76fb66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20DAVID?= Date: Sun, 9 Aug 2026 13:39:24 +0200 Subject: [PATCH 119/331] Tesla BLE: handle sleeping vehicles with vehicle-api charger (#32615) --- charger/vehicle-api.go | 41 ++++++++++++++++++++++++++++--- charger/vehicle-api_test.go | 49 +++++++++++++++++++++++++++++++++++++ util/request/functions.go | 15 +++++++++--- 3 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 charger/vehicle-api_test.go diff --git a/charger/vehicle-api.go b/charger/vehicle-api.go index 404cbb621..61fa6c230 100644 --- a/charger/vehicle-api.go +++ b/charger/vehicle-api.go @@ -1,13 +1,17 @@ package charger import ( + "encoding/json" "errors" "math" + "net/http" + "strings" "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" ) // VehicleApi is a charger implementation that uses the vehicle api @@ -49,6 +53,28 @@ func NewVehicleApiFromConfig(other map[string]any) (api.Charger, error) { return c, nil } +// asleep maps a vehicle api's sleeping response to api.ErrAsleep so the loadpoint +// can trigger the existing wake-up logic. Proxies like TeslaBleHttpProxy answer +// HTTP 503 with a json body stating the reason. +func asleep(err error) error { + var se *request.StatusError + if !errors.As(err, &se) || !se.HasStatus(http.StatusServiceUnavailable) { + return err + } + + var res struct { + Response struct { + Reason string + } + } + + if json.Unmarshal(se.Body(), &res) == nil && strings.Contains(strings.ToLower(res.Response.Reason), "sleep") { + return api.ErrAsleep + } + + return err +} + // isVehicleAtHome checks if the vehicle is within the geofence (if enabled) func (c *VehicleApi) isVehicleAtHome(vehicle api.Vehicle) (bool, error) { if !c.geofenceEnabled { @@ -62,7 +88,7 @@ func (c *VehicleApi) isVehicleAtHome(vehicle api.Vehicle) (bool, error) { lat, lon, err := v.Position() if err != nil { - return false, err + return false, asleep(err) } return c.distance(lat, lon) <= c.radius, nil @@ -82,6 +108,11 @@ func (c *VehicleApi) Status() (api.ChargeStatus, error) { // Check if vehicle is at the charger (trying to use geofencing) atHome, err := c.isVehicleAtHome(vehicle) if err != nil { + // position unknown while asleep: report connected so the loadpoint wakes the + // vehicle, the geofence is re-evaluated once it responds again + if errors.Is(err, api.ErrAsleep) { + return api.StatusB, nil + } return api.StatusA, err } @@ -104,6 +135,10 @@ func (c *VehicleApi) Status() (api.ChargeStatus, error) { status, err := v.Status() if err != nil { + // asleep: report connected so the loadpoint's setLimit path can wake the vehicle + if errors.Is(asleep(err), api.ErrAsleep) { + return api.StatusB, nil + } return api.StatusNone, err } @@ -142,7 +177,7 @@ func (c *VehicleApi) Enable(enable bool) error { } if err := v.ChargeEnable(enable); err != nil { - return err + return asleep(err) } c.enabled = enable @@ -164,7 +199,7 @@ func (c *VehicleApi) MaxCurrent(current int64) error { return nil } - return v.MaxCurrent(current) + return asleep(v.MaxCurrent(current)) } var _ api.Resurrector = (*VehicleApi)(nil) diff --git a/charger/vehicle-api_test.go b/charger/vehicle-api_test.go new file mode 100644 index 000000000..06a051688 --- /dev/null +++ b/charger/vehicle-api_test.go @@ -0,0 +1,49 @@ +package charger + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util/request" + "github.com/stretchr/testify/require" +) + +// statusErr builds the error a plugin returns for the given response +func statusErr(t *testing.T, code int, body string) error { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, "http://vehicle.test", nil) + require.NoError(t, err) + + resp := &http.Response{ + StatusCode: code, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + } + + _, err = request.ReadBody(resp) + require.Error(t, err) + + return err +} + +func TestAsleep(t *testing.T) { + sleeping := `{"response":{"result":false,"reason":"vehicle is sleeping"}}` + + // TeslaBleHttpProxy sleeping response + require.ErrorIs(t, asleep(statusErr(t, http.StatusServiceUnavailable, sleeping)), api.ErrAsleep) + + // 503 for any other reason is not asleep + other := statusErr(t, http.StatusServiceUnavailable, `{"response":{"reason":"vehicle is offline"}}`) + require.NotErrorIs(t, asleep(other), api.ErrAsleep) + + // non-503 with sleeping body is not asleep + require.NotErrorIs(t, asleep(statusErr(t, http.StatusInternalServerError, sleeping)), api.ErrAsleep) + + // non-json body and nil error pass through + require.NotErrorIs(t, asleep(statusErr(t, http.StatusServiceUnavailable, "sleeping")), api.ErrAsleep) + require.NoError(t, asleep(nil)) +} diff --git a/util/request/functions.go b/util/request/functions.go index 8826d8f90..d3fc4fbf0 100644 --- a/util/request/functions.go +++ b/util/request/functions.go @@ -44,6 +44,7 @@ var ( // StatusError indicates unsuccessful http response type StatusError struct { resp *http.Response + body []byte } func NewStatusError(resp *http.Response) *StatusError { @@ -60,6 +61,11 @@ func (e *StatusError) Response() *http.Response { return e.resp } +// Body returns the error response body if it has been read +func (e *StatusError) Body() []byte { + return e.body +} + // StatusCode returns the response's status code func (e *StatusError) StatusCode() int { return e.resp.StatusCode @@ -82,12 +88,13 @@ func ResponseError(resp *http.Response) error { func ReadBody(resp *http.Response) ([]byte, error) { defer resp.Body.Close() - if err := ResponseError(resp); err != nil { - b, _ := io.ReadAll(resp.Body) - return b, err + b, err := io.ReadAll(resp.Body) + + // keep the body on the error so callers can inspect the device's error response + if c := resp.StatusCode; c < 200 || c >= 300 { + return b, backoff.Permanent(&StatusError{resp: resp, body: b}) } - b, err := io.ReadAll(resp.Body) if err != nil { return []byte{}, err } From 09c2ce3055fe02bf327b04228fa2ee910b7991e0 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 9 Aug 2026 15:00:31 +0200 Subject: [PATCH 120/331] chore: semver-compliant version for dev and nightly builds (#32663) --- .goreleaser-nightly.yml | 5 +- .goreleaser.yml | 3 +- Makefile | 9 ++- assets/js/components/AboutModal.stories.ts | 5 +- assets/js/components/AboutModal.vue | 39 ++++-------- assets/js/components/BottomTabs/Bar.vue | 2 - assets/js/components/BottomTabs/MoreItem.vue | 2 - assets/js/components/BottomTabs/MoreMenu.vue | 3 +- assets/js/types/evcc.ts | 1 - assets/js/utils/version.test.ts | 63 ++++++++++++++++++++ assets/js/utils/version.ts | 22 +++---- assets/js/views/App.vue | 2 - server/http_site_handler.go | 2 - util/version.go | 9 +-- 14 files changed, 103 insertions(+), 64 deletions(-) create mode 100644 assets/js/utils/version.test.ts diff --git a/.goreleaser-nightly.yml b/.goreleaser-nightly.yml index 5ee5ac045..83b2ccd9a 100644 --- a/.goreleaser-nightly.yml +++ b/.goreleaser-nightly.yml @@ -11,7 +11,7 @@ builds: - -trimpath - -tags=release ldflags: - - -X github.com/evcc-io/evcc/util.Version={{ .Tag }} -X github.com/evcc-io/evcc/util.Commit={{ .ShortCommit }} -s -w + - -X github.com/evcc-io/evcc/util.Version={{ .Version }} -X github.com/evcc-io/evcc/util.Commit={{ .ShortCommit }} -s -w env: - CGO_ENABLED=0 goos: @@ -56,7 +56,8 @@ checksum: name_template: "checksums.txt" snapshot: - version_template: '{{ .Version }}{{ if eq (len (split .Version ".")) 2 }}.0{{ end }}+{{ .Timestamp }}' + # semver pre-release of the upcoming version + version_template: "{{ incminor .Version }}-dev+{{ .ShortCommit }}" changelog: sort: asc diff --git a/.goreleaser.yml b/.goreleaser.yml index 9c1beeec9..a392aa64c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -63,7 +63,8 @@ checksum: name_template: "checksums.txt" snapshot: - version_template: "{{ .Tag }}-next" + # semver pre-release of the upcoming version + version_template: "{{ incminor .Version }}-dev+{{ .ShortCommit }}" changelog: sort: asc diff --git a/Makefile b/Makefile index 7f2e3838e..b4dc196d7 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,13 @@ # build vars -TAG_NAME ?= $(shell test -d .git && git describe --abbrev=0 --tags) +# part of the latest tag incremented for untagged builds: minor or patch +BUMP ?= minor SHA ?= $(shell test -d .git && git rev-parse --short HEAD) +GIT_TAG := $(shell test -d .git && git describe --abbrev=0 --tags) +# commits since the latest tag, empty or 0 means the commit is tagged +GIT_DIST := $(shell test -n "$(GIT_TAG)" && git rev-list --count $(GIT_TAG)..HEAD) +NEXT_TAG = $(shell echo $(GIT_TAG) | awk -F. -v b='$(BUMP)' '{if (b == "patch") $$NF++; else {$$2++; $$3=0}} 1' OFS=.) +# untagged builds are semver pre-releases of the upcoming version +TAG_NAME ?= $(if $(filter-out 0,$(GIT_DIST)),$(NEXT_TAG)-dev+$(SHA),$(GIT_TAG)) COMMIT := $(SHA) # hide commit for releases ifeq ($(RELEASE),1) diff --git a/assets/js/components/AboutModal.stories.ts b/assets/js/components/AboutModal.stories.ts index 3a9eac6ee..4b23c3fb1 100644 --- a/assets/js/components/AboutModal.stories.ts +++ b/assets/js/components/AboutModal.stories.ts @@ -47,7 +47,6 @@ export default { component: AboutModal, argTypes: { installed: { control: "text" }, - commit: { control: "text" }, availableVersion: { control: "text" }, releaseNotes: { control: "text" }, hasUpdater: { control: "boolean" }, @@ -95,9 +94,7 @@ StableUpdateWithUpdater.args = { export const Nightly = Template.bind({}); Nightly.args = { - installed: "0.303.1", - commit: "5ce7be4a9f3b2c1d", - availableVersion: "0.303.1", + installed: "0.304.0-dev+5ce7be4", }; export const DevBuild = Template.bind({}); diff --git a/assets/js/components/AboutModal.vue b/assets/js/components/AboutModal.vue index e6469e17c..b0df8929e 100644 --- a/assets/js/components/AboutModal.vue +++ b/assets/js/components/AboutModal.vue @@ -36,25 +36,14 @@ ---
- - - v{{ installed }} - - - + + v{{ installed }} + @@ -77,7 +76,6 @@ export default defineComponent({ startupCompleted: Boolean, evopt: { type: Object as PropType, required: false }, installed: String, - commit: String, availableVersion: String, customBrand: String, }, diff --git a/assets/js/components/BottomTabs/MoreItem.vue b/assets/js/components/BottomTabs/MoreItem.vue index 4b6e4f2f9..e7b0b0b9f 100644 --- a/assets/js/components/BottomTabs/MoreItem.vue +++ b/assets/js/components/BottomTabs/MoreItem.vue @@ -19,7 +19,6 @@ :auth-disabled="authDisabled" :evopt="evopt" :installed="installed" - :commit="commit" :available-version="availableVersion" :custom-brand="customBrand" @close="open = false" @@ -51,7 +50,6 @@ export default defineComponent({ authDisabled: Boolean, evopt: { type: Object as PropType, required: false }, installed: String, - commit: String, availableVersion: String, customBrand: String, }, diff --git a/assets/js/components/BottomTabs/MoreMenu.vue b/assets/js/components/BottomTabs/MoreMenu.vue index 22d32d82d..328b96ec9 100644 --- a/assets/js/components/BottomTabs/MoreMenu.vue +++ b/assets/js/components/BottomTabs/MoreMenu.vue @@ -110,7 +110,6 @@ export default defineComponent({ authDisabled: Boolean, evopt: { type: Object as PropType, required: false }, installed: String, - commit: String, availableVersion: String, customBrand: String, }, @@ -144,7 +143,7 @@ export default defineComponent({ return "bg-warning"; }, versionLabel() { - return getShortVersion(this.installed || "", this.commit); + return getShortVersion(this.installed || ""); }, newVersionAvailable() { return isNewVersionAvailable(this.installed, this.availableVersion); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 97cd268e5..1e77eb385 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -8,7 +8,6 @@ declare global { app: any; evcc?: { version: string; - commit: string; customCss: boolean; customLogo: boolean; customBrand: string; diff --git a/assets/js/utils/version.test.ts b/assets/js/utils/version.test.ts new file mode 100644 index 000000000..a48edb1c6 --- /dev/null +++ b/assets/js/utils/version.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + isDevelopment, + isNightly, + commitFromVersion, + getReleaseName, + getShortVersion, + isNewVersionAvailable, +} from "./version"; + +const DEV = "0.0.0"; +const NIGHTLY = "0.304.0-dev+abc1234"; +const STABLE = "0.303.1"; + +describe("isDevelopment", () => { + test("only dev builds", () => { + expect(isDevelopment(DEV)).toBe(true); + expect(isDevelopment(NIGHTLY)).toBe(false); + expect(isDevelopment(STABLE)).toBe(false); + }); +}); + +describe("isNightly", () => { + test("only versions with build metadata", () => { + expect(isNightly(DEV)).toBe(false); + expect(isNightly(NIGHTLY)).toBe(true); + expect(isNightly(STABLE)).toBe(false); + }); +}); + +describe("commitFromVersion", () => { + test("extracts build metadata", () => { + expect(commitFromVersion(NIGHTLY)).toBe("abc1234"); + expect(commitFromVersion(STABLE)).toBe(""); + }); +}); + +describe("getReleaseName", () => { + test("maps version to release name", () => { + expect(getReleaseName(DEV)).toBe("development"); + expect(getReleaseName(NIGHTLY)).toBe("nightly"); + expect(getReleaseName(STABLE)).toBe("stable"); + }); +}); + +describe("getShortVersion", () => { + test("formats version", () => { + expect(getShortVersion(DEV)).toBe("dev build"); + expect(getShortVersion(NIGHTLY)).toBe("v0.304.0-dev+abc1234"); + expect(getShortVersion(STABLE)).toBe("v0.303.1"); + }); +}); + +describe("isNewVersionAvailable", () => { + test("never for dev builds", () => { + expect(isNewVersionAvailable(DEV, "0.303.1")).toBe(false); + }); + test("only when available differs", () => { + expect(isNewVersionAvailable(STABLE, "0.303.1")).toBe(false); + expect(isNewVersionAvailable("0.303.0", "0.303.1")).toBe(true); + expect(isNewVersionAvailable(STABLE, undefined)).toBe(false); + }); +}); diff --git a/assets/js/utils/version.ts b/assets/js/utils/version.ts index a17fc1c0f..5fb990d3b 100644 --- a/assets/js/utils/version.ts +++ b/assets/js/utils/version.ts @@ -1,24 +1,24 @@ export function isDevelopment(version: string): boolean { - return version === "[[.Version]]" || version === "0.0.0"; + return version === "0.0.0"; } -export function isNightly(version: string, commit?: string): boolean { - return !isDevelopment(version) && !!commit; +// untagged builds carry the commit as build metadata: 0.304.0-dev+abc1234 +export function isNightly(version: string): boolean { + return version.includes("+"); } -export function getReleaseName(version: string, commit?: string): string { +export function commitFromVersion(version: string): string { + return version.split("+")[1] ?? ""; +} + +export function getReleaseName(version: string): string { if (isDevelopment(version)) return "development"; - if (isNightly(version, commit)) return "nightly"; + if (isNightly(version)) return "nightly"; return "stable"; } -export function shortCommit(commit?: string): string { - return commit?.substring(0, 7) || ""; -} - -export function getShortVersion(version: string, commit?: string): string { +export function getShortVersion(version: string): string { if (isDevelopment(version)) return "dev build"; - if (isNightly(version, commit)) return `v${version} (${shortCommit(commit)})`; return `v${version}`; } diff --git a/assets/js/views/App.vue b/assets/js/views/App.vue index bd75b19e8..44f8a4740 100644 --- a/assets/js/views/App.vue +++ b/assets/js/views/App.vue @@ -105,7 +105,6 @@ export default defineComponent({ aboutModalProps() { return { installed: window.evcc?.version, - commit: window.evcc?.commit, customLogo: this.custom.logo, customBrand: this.custom.brand, customWebsite: this.custom.website, @@ -117,7 +116,6 @@ export default defineComponent({ bottomTabBarProps() { return { installed: window.evcc?.version, - commit: window.evcc?.commit, customBrand: this.custom.brand, ...this.collectProps(BottomTabBar, this.state), }; diff --git a/server/http_site_handler.go b/server/http_site_handler.go index 1b5d70757..1bfd7daf3 100644 --- a/server/http_site_handler.go +++ b/server/http_site_handler.go @@ -45,7 +45,6 @@ func getPreferredLanguage(header string) string { func globalsJsHandler(custom Customization) http.HandlerFunc { globals := struct { Version string `json:"version"` - Commit string `json:"commit"` CustomCss bool `json:"customCss"` CustomLogo bool `json:"customLogo"` Brand string `json:"customBrand"` @@ -54,7 +53,6 @@ func globalsJsHandler(custom Customization) http.HandlerFunc { Phone string `json:"customPhone"` }{ Version: util.Version, - Commit: util.Commit, CustomCss: custom.Css != "", CustomLogo: custom.LogoLight != "", Brand: custom.Brand, diff --git a/util/version.go b/util/version.go index dceab7cf8..3feb56031 100644 --- a/util/version.go +++ b/util/version.go @@ -1,9 +1,6 @@ package util -import ( - "fmt" - "runtime" -) +import "runtime" const DevVersion = "0.0.0" @@ -15,10 +12,8 @@ var ( Commit = "" ) +// FormattedVersion returns the version, untagged builds carry the commit as build metadata func FormattedVersion() string { - if Commit != "" { - return fmt.Sprintf("%s (%s)", Version, Commit) - } return Version } From 5d1f1193bb21f29cda319ff031b5d49cb913fbd6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 9 Aug 2026 16:22:43 +0200 Subject: [PATCH 121/331] chore: drop unused generated openapi.md (#32668) --- .gitattributes | 1 - go.mod | 1 - server/mcp/openapi.md | 1450 ----------------------------------------- server/openapi.go | 3 - 4 files changed, 1455 deletions(-) delete mode 100644 server/mcp/openapi.md delete mode 100644 server/openapi.go diff --git a/.gitattributes b/.gitattributes index cdf74fc2e..5f01d71d1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ .github/workflows/*.lock.yml linguist-generated=true merge=ours server/openapi.state.yaml linguist-generated=true server/mcp/openapi.json linguist-generated=true -server/mcp/openapi.md linguist-generated=true \ No newline at end of file diff --git a/go.mod b/go.mod index 03b91db15..286ee8880 100644 --- a/go.mod +++ b/go.mod @@ -257,7 +257,6 @@ require ( tool ( github.com/dmarkham/enumer github.com/evcc-io/evcc/cmd/implement - github.com/evcc-io/openapi-mcp/cmd/openapi-mcp github.com/gokrazy/tools/cmd/gok go.uber.org/mock/mockgen golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md deleted file mode 100644 index ab749d6a8..000000000 --- a/server/mcp/openapi.md +++ /dev/null @@ -1,1450 +0,0 @@ -# MCP Tools Documentation - -**API Title:** evcc - -**Version:** 0.2.0 - -Solar charging. Super simple. - -## changePassword - -Changes the admin password. - -**Tags:** auth - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| requestBody | object | The JSON request body. | - -**Example call:** - -```json -call changePassword { - "requestBody": "..." -} -``` - -## getApiKeyStatus - -Reports whether an API key has been generated. The key itself is -only returned once at creation time (POST), never on subsequent -reads. - - -**Tags:** auth - -## getAuthStatus - -Whether the current user is logged in. - -**Tags:** auth - -## login - -Administrator login. Returns authorization cookie required for all protected endpoints. - -**Tags:** auth - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| requestBody | object | The JSON request body. | - -**Example call:** - -```json -call login { - "requestBody": "..." -} -``` - -## logout - -Logout and delete authorization cookie - -**Tags:** auth - -## regenerateApiKey - -Generates a fresh API key, replacing any existing one. The -returned key is shown only once; store it immediately. - -Even when the request is authenticated via API key (Bearer), the -admin password must be supplied in the request body to prevent a -leaked key from rotating itself. The password check is skipped -when the server is started with `--disable-auth`. - - -**Tags:** auth - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| requestBody | object | The JSON request body. | - -**Example call:** - -```json -call regenerateApiKey { - "requestBody": "..." -} -``` - -## disableExternalBatteryControl - -Default evcc control behavior is restored - -**Tags:** battery - -## removeBatteryGridChargeLimit - -Remove battery grid charge limit. - -**Tags:** battery - -## setBatteryDischargeControl - -Prevent home battery discharge during vehicle fast charging. - -**Tags:** battery - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| enable | string | Charging mode. | - -**Example call:** - -```json -call setBatteryDischargeControl { - "enable": "true" -} -``` - -## setBatteryGridChargeLimit - -Charge home battery from grid when price or emissions are below the threshold. Uses price if a dynamic tariff exists. Uses emissions if a CO₂-tariff is configured. Ignored otherwise. - -**Tags:** battery - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| cost | number | Cost limit in configured currency (default EUR) or CO2 limit in g/kWh | - -**Example call:** - -```json -call setBatteryGridChargeLimit { - "cost": 123.45 -} -``` - -## setBatteryGridDischarge - -Allow the home battery to discharge to the grid (experimental). - -**Tags:** battery - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| enable | string | Charging mode. | - -**Example call:** - -```json -call setBatteryGridDischarge { - "enable": "true" -} -``` - -## setBufferSoc - -Set battery buffer SoC. - -**Tags:** battery - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| soc | number | SOC in % | - -**Example call:** - -```json -call setBufferSoc { - "soc": 60 -} -``` - -## setBufferStartSoc - -Set battery buffer start SoC. - -**Tags:** battery - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| soc | number | SOC in % | - -**Example call:** - -```json -call setBufferStartSoc { - "soc": 60 -} -``` - -## setExternalBatteryMode - -Directly controls the mode of all controllable batteries. evcc behavior like 'price limit' or 'prevent discharge while fast charging' is overruled. External mode resets after 60s. The external system has to call this endpoint regularly. - -**Tags:** battery - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| batteryMode | string | Battery operation mode. | - -**Example call:** - -```json -call setExternalBatteryMode { - "batteryMode": "unknown" -} -``` - -## setPrioritySoc - -Set battery priority SoC. - -**Tags:** battery - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| soc | number | SOC in % | - -**Example call:** - -```json -call setPrioritySoc { - "soc": 60 -} -``` - -## setResidualPower - -Set grid connection operating point. - -**Tags:** battery - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| power | number | Power in W | - -**Example call:** - -```json -call setResidualPower { - "power": 2500 -} -``` - -## downloadBackup - -Downloads the SQLite database as a backup file. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt. - -**Tags:** db - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| X-Admin-Password | string | Admin password (required for session auth, not needed for API key) | - -**Example call:** - -```json -call downloadBackup { - "X-Admin-Password": "example" -} -``` - -## resetDatabase - -Selectively deletes sessions and/or settings from the database. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt. The instance restarts after a successful reset. - -**Tags:** db - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| X-Admin-Password | string | Admin password (required for session auth, not needed for API key) | -| requestBody | object | The JSON request body. | - -**Example call:** - -```json -call resetDatabase { - "X-Admin-Password": "example", - "requestBody": "..." -} -``` - -## restoreBackup - -Restores the database from a previously downloaded backup file. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt. The instance restarts after a successful restore. - -**Tags:** db - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| X-Admin-Password | string | Admin password (required for session auth, not needed for API key) | - -**Example call:** - -```json -call restoreBackup { - "X-Admin-Password": "example" -} -``` - -## getEnergyHistory - -Returns aggregated energy history data. Aggregate granularity defaults to 15 minutes. Supports CSV export. - -**Tags:** experimental - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| aggregate | string | Aggregation interval. Examples: 15m, 1h, day, month | -| format | string | Response format | -| from | string | Start time (RFC3339) | -| group | string | Filter by entity group | -| grouped | boolean | Group results by loadpoint | -| lang | string | Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header. | -| name | string | Filter by entity name | -| title | string | Filter by entity title | -| to | string | End time (RFC3339) | - -**Example call:** - -```json -call getEnergyHistory { - "aggregate": "1h", - "format": "json", - "from": "2026-07-01T00:00:00Z", - "group": "battery", - "grouped": false, - "lang": "de", - "name": "db:8", - "title": "Battery", - "to": "2026-07-02T00:00:00Z" -} -``` - -## setSolarAdjusted - -Adjust the solar forecast to real production data of the current day. - -**Tags:** experimental - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| enable | string | Charging mode. | - -**Example call:** - -```json -call setSolarAdjusted { - "enable": "true" -} -``` - -## removeGlobalSmartCostLimit - -Convenience method to remove limit for all loadpoints at once. Value is applied to each individual loadpoint. - -**Tags:** general - -## removeGlobalSmartFeedInPriorityLimit - -Convenience method to remove limit for all loadpoints at once. Value is applied to each individual loadpoint. - -**Tags:** general - -## setGlobalSmartCostLimit - -Convenience method to set smart charging cost limit for all loadpoints at once. Value is applied to each individual loadpoint. - -**Tags:** general - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| cost | number | Cost limit in configured currency (default EUR) or CO2 limit in g/kWh | - -**Example call:** - -```json -call setGlobalSmartCostLimit { - "cost": 123.45 -} -``` - -## setGlobalSmartFeedInPriorityLimit - -Convenience method to set smart feed-in priority limit for all loadpoints at once. Value is applied to each individual loadpoint. - -**Tags:** general - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| cost | number | Cost limit in configured currency (default EUR) or CO2 limit in g/kWh | - -**Example call:** - -```json -call setGlobalSmartFeedInPriorityLimit { - "cost": 123.45 -} -``` - -## setGridExportLimit - -Set the static grid export power limit in W used as optimizer constraint. 0 disables the limit. An active HEMS curtailment takes precedence. - -**Tags:** general - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| power | number | Power in W | - -**Example call:** - -```json -call setGridExportLimit { - "power": 2500 -} -``` - -## assignLoadpointVehicle - -Assigns vehicle to loadpoint. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| name | string | Vehicle name | - -**Example call:** - -```json -call assignLoadpointVehicle { - "id": 1, - "name": "vehicle_1" -} -``` - -## deleteLoadpointEnergyPlan - -Delete charging plan. Only available when a vehicle without SoC is connected. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call deleteLoadpointEnergyPlan { - "id": 1 -} -``` - -## deleteLoadpointSmartCostLimit - -Delete cost or emission limit for fast-charging with grid energy. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call deleteLoadpointSmartCostLimit { - "id": 1 -} -``` - -## deleteLoadpointSmartFeedInPriorityLimit - -Delete limit for feed-in priority optimization. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call deleteLoadpointSmartFeedInPriorityLimit { - "id": 1 -} -``` - -## getLoadpointPlan - -Returns the current charging plan for this loadpoint. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call getLoadpointPlan { - "id": 1 -} -``` - -## previewLoadpointEnergyPlan - -Simulate charging plan based on energy goal. Does not alter the actual charging plan. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| energy | number | Energy in kWh | -| id | integer | Loadpoint index starting at 1 | -| timestamp | string | Timestamp in RFC3339 format | - -**Example call:** - -```json -call previewLoadpointEnergyPlan { - "energy": 25.5, - "id": 1, - "timestamp": "example" -} -``` - -## previewLoadpointRepeatingPlan - -Simulate repeating charging plan and return the result. Does not alter the actual charging plan. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| hourMinuteTime | string | Time in `HOURS:MINUTES` format | -| id | integer | Loadpoint index starting at 1 | -| soc | number | SOC in % | -| timezone | string | Timezone in IANA format | -| weekdays | array | The Weekdays | - -**Example call:** - -```json -call previewLoadpointRepeatingPlan { - "hourMinuteTime": "12:30", - "id": 1, - "soc": 60, - "timezone": "Europe/Berlin", - "weekdays": [ - 1, - 3, - 4 - ] -} -``` - -## previewLoadpointSocPlan - -Simulate charging plan based on SoC goal. Does not alter the actual charging plan. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| soc | number | SOC in % | -| timestamp | string | Timestamp in RFC3339 format | - -**Example call:** - -```json -call previewLoadpointSocPlan { - "id": 1, - "soc": 60, - "timestamp": "example" -} -``` - -## removeLoadpointVehicle - -Remove vehicle from loadpoint. Connected vehicle is treated as guest vehicle. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call removeLoadpointVehicle { - "id": 1 -} -``` - -## setLoadpointBatteryBoost - -Enable or disable battery boost. When active, the maximum available home battery power is added until the home battery is drained to configured SoC limit. Note: boost will not work while the battery is on hold (e.g. during fast charging or planned charging with discharge prevention enabled). - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| enable | string | Charging mode. | -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call setLoadpointBatteryBoost { - "enable": "true", - "id": 1 -} -``` - -## setLoadpointBatteryBoostLimit - -Set the SoC limit for battery boost. Home battery will be used to support charging up to this SoC level. A value of 100 (default) disabled the boost feature in UI and API. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| soc | number | SOC in % | - -**Example call:** - -```json -call setLoadpointBatteryBoostLimit { - "id": 1, - "soc": 60 -} -``` - -## setLoadpointDisableDelay - -Delay before charging stops in solar mode. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| delay | integer | Duration in seconds. | -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call setLoadpointDisableDelay { - "delay": 60, - "id": 1 -} -``` - -## setLoadpointDisableThreshold - -Specifies the grid draw power to stop charging in solar mode. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| threshold | number | Power in W | - -**Example call:** - -```json -call setLoadpointDisableThreshold { - "id": 1, - "threshold": 2500 -} -``` - -## setLoadpointEnableDelay - -Delay before charging starts in solar mode. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| delay | integer | Duration in seconds. | -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call setLoadpointEnableDelay { - "delay": 60, - "id": 1 -} -``` - -## setLoadpointEnableThreshold - -Specifies the available surplus power to start charging in solar mode. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| threshold | number | Power in W | - -**Example call:** - -```json -call setLoadpointEnableThreshold { - "id": 1, - "threshold": 2500 -} -``` - -## setLoadpointEnergyLimit - -Updates the energy limit of the loadpoint. Only available for guest vehicles and vehicles with unknown SoC. Limit is removed on vehicle disconnect. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| energy | number | Energy in kWh | -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call setLoadpointEnergyLimit { - "energy": 25.5, - "id": 1 -} -``` - -## setLoadpointEnergyPlan - -Create charging plan with fixed time and energy target. Only available when a vehicle without SoC is connected. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| energy | number | Energy in kWh | -| id | integer | Loadpoint index starting at 1 | -| timestamp | string | Timestamp in RFC3339 format | - -**Example call:** - -```json -call setLoadpointEnergyPlan { - "energy": 25.5, - "id": 1, - "timestamp": "example" -} -``` - -## setLoadpointMaxCurrent - -Updates the maximum current of the loadpoint. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| current | number | Electric current in A | -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call setLoadpointMaxCurrent { - "current": 16, - "id": 1 -} -``` - -## setLoadpointMinCurrent - -Updates the minimum current of the loadpoint. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| current | number | Electric current in A | -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call setLoadpointMinCurrent { - "current": 16, - "id": 1 -} -``` - -## setLoadpointMinTemp - -Sets the minimum temperature for heating devices. The device is heated as fast as possible while below this value. Set to 0 to disable. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| temp | number | Temperature in °C | - -**Example call:** - -```json -call setLoadpointMinTemp { - "id": 1, - "temp": 50 -} -``` - -## setLoadpointMode - -Changes the charging behavior of the loadpoint. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| mode | string | Charging mode. | - -**Example call:** - -```json -call setLoadpointMode { - "id": 1, - "mode": "off" -} -``` - -## setLoadpointPhases - -Updates the allowed phases of the loadpoint. Selects the desired phase mode for chargers with automatic phase switching. For manual phase switching chargers (via cable or Lasttrennschalter) this value tells evcc the actual phases. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| phases | string | Number of phases. (0: auto, 1: 1-phase, 3: 3-phase) | - -**Example call:** - -```json -call setLoadpointPhases { - "id": 1, - "phases": "3" -} -``` - -## setLoadpointPlanStrategy - -Updates the charging plan strategy for the loadpoint. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| requestBody | object | The JSON request body. | - -**Example call:** - -```json -call setLoadpointPlanStrategy { - "id": 1, - "requestBody": "..." -} -``` - -## setLoadpointPriority - -Set loadpoint priority. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| priority | integer | Higher number means higher priority. | - -**Example call:** - -```json -call setLoadpointPriority { - "id": 1, - "priority": 123 -} -``` - -## setLoadpointSmartCostLimit - -Set cost or emission limit for fast-charging with grid energy. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| cost | number | Cost limit in configured currency (default EUR) or CO2 limit in g/kWh | -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call setLoadpointSmartCostLimit { - "cost": 123.45, - "id": 1 -} -``` - -## setLoadpointSmartFeedInPriorityLimit - -Set limit for feed-in priority optimization. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| cost | number | Cost limit in configured currency (default EUR) or CO2 limit in g/kWh | -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call setLoadpointSmartFeedInPriorityLimit { - "cost": 123.45, - "id": 1 -} -``` - -## setLoadpointSocLimit - -Sets the session SoC limit. Cleared on disconnect. Takes precedence over the vehicle's configured limit while set; once cleared (set to 0), the vehicle limit applies again. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| soc | number | SOC in % | - -**Example call:** - -```json -call setLoadpointSocLimit { - "id": 1, - "soc": 60 -} -``` - -## startLoadpointVehicleDetection - -Starts the automatic vehicle detection process. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call startLoadpointVehicleDetection { - "id": 1 -} -``` - -## deleteSession - -Delete charging session. - -**Tags:** sessions - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | - -**Example call:** - -```json -call deleteSession { - "id": 1 -} -``` - -## getGridSessions - -Returns a list of HEMS grid limitation events. - -**Tags:** sessions - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| format | string | Response format (default json) | -| lang | string | Language (defaults to accept header) | - -**Example call:** - -```json -call getGridSessions { - "format": "csv", - "lang": "de" -} -``` - -## getSessions - -Returns a list of charging sessions. - -**Tags:** sessions - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| format | string | Response format (default json) | -| lang | string | Language (defaults to accept header) | -| month | integer | Month filter | -| year | integer | Year filter | - -**Example call:** - -```json -call getSessions { - "format": "csv", - "lang": "de", - "month": 2, - "year": 2025 -} -``` - -## updateSession - -Update vehicle, loadpoint or odometer of a charging session. Only provided fields are changed; a null odometer clears the stored value. - -**Tags:** sessions - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| requestBody | object | The JSON request body. | - -**Example call:** - -```json -call updateSession { - "id": 1, - "requestBody": "..." -} -``` - -## getState - -Returns the complete state of the system. This structure is used by the UI and also published via websocket and MQTT. It can be filtered by JQ to only return a subset of the data. Note: the response mirrors the internal UI state and carries no compatibility promise. Fields may change or disappear between releases. - -**Tags:** state - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| jq | string | Filter the state with JQ | - -**Example call:** - -```json -call getState { - "jq": "example" -} -``` - -## clearCache - -Clears all cached data. This resets all cached values from tariffs, vehicle APIs, and other components that use caching. - -**Tags:** system - -## getLogAreas - -Returns a list of all log areas (e.g. `lp-1`, `site`, `db`). - -**Tags:** system - -## getSystemLogs - -Returns the latest log lines. - -**Tags:** system - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| areas | array | Comma-separated list of log areas | -| count | integer | Number of log lines to return | -| format | string | File type | -| level | string | Log level | - -**Example call:** - -```json -call getSystemLogs { - "areas": [ - "lp-1", - "site", - "db" - ], - "count": 123, - "format": "txt", - "level": "DEBUG" -} -``` - -## setTelemetryStatus - -Enable or disable telemetry. Note: Telemetry requires sponsorship. - -**Tags:** system - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| enable | string | Charging mode. | - -**Example call:** - -```json -call setTelemetryStatus { - "enable": "true" -} -``` - -## shutdownSystem - -Shut down instance. There is no reboot command. We expect the underlying system (docker, systemd, etc.) to restart the evcc instance once it's terminated. - -**Tags:** system - -## getTariffInfo - -Returns the prices or emission values for the upcoming hours - -**Tags:** tariffs - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| type | string | Tariff type | - -**Example call:** - -```json -call getTariffInfo { - "type": "grid" -} -``` - -## deleteVehicleMode - -Resets the vehicle charge mode to keep the last selected mode. - -**Tags:** vehicles - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| name | string | Vehicle name | - -**Example call:** - -```json -call deleteVehicleMode { - "name": "vehicle_1" -} -``` - -## deleteVehicleSocPlan - -Delete the charging plan - -**Tags:** vehicles - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| name | string | Vehicle name | - -**Example call:** - -```json -call deleteVehicleSocPlan { - "name": "vehicle_1" -} -``` - -## setVehicleMinSoc - -Vehicle will be fast-charged until this SoC is reached. - -**Tags:** vehicles - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| name | string | Vehicle name | -| soc | number | SOC in % | - -**Example call:** - -```json -call setVehicleMinSoc { - "name": "vehicle_1", - "soc": 60 -} -``` - -## setVehicleMode - -Sets the charge mode applied when this vehicle becomes active on a loadpoint. - -**Tags:** vehicles - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| mode | string | Charging mode. | -| name | string | Vehicle name | - -**Example call:** - -```json -call setVehicleMode { - "mode": "off", - "name": "vehicle_1" -} -``` - -## setVehiclePlanStrategy - -Updates the charging plan strategy for the vehicle. - -**Tags:** vehicles - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| name | string | Vehicle name | -| requestBody | object | The JSON request body. | - -**Example call:** - -```json -call setVehiclePlanStrategy { - "name": "vehicle_1", - "requestBody": "..." -} -``` - -## setVehicleSocLimit - -Charging will stop when this SoC is reached. - -**Tags:** vehicles - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| name | string | Vehicle name | -| soc | number | SOC in % | - -**Example call:** - -```json -call setVehicleSocLimit { - "name": "vehicle_1", - "soc": 60 -} -``` - -## setVehicleSocPlan - -Create charging plan with fixed time and SoC target. - -**Tags:** vehicles - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| name | string | Vehicle name | -| soc | number | SOC in % | -| timestamp | string | Timestamp in RFC3339 format | - -**Example call:** - -```json -call setVehicleSocPlan { - "name": "vehicle_1", - "soc": 60, - "timestamp": "example" -} -``` - -## updateVehicleRepeatingPlans - -Updates the repeating charging plan. - -**Tags:** vehicles - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| name | string | Vehicle name | -| requestBody | array | The JSON request body. | - -**Example call:** - -```json -call updateVehicleRepeatingPlans { - "name": "vehicle_1", - "requestBody": "..." -} -``` - diff --git a/server/openapi.go b/server/openapi.go deleted file mode 100644 index 972ae548c..000000000 --- a/server/openapi.go +++ /dev/null @@ -1,3 +0,0 @@ -package server - -//go:generate go tool openapi-mcp --doc mcp/openapi.md mcp/openapi.json From 5ab5cfc2efd2506c5202f032dc00c9bb43cbe46c Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 9 Aug 2026 16:30:34 +0200 Subject: [PATCH 122/331] Add admin api for deleting invalid metrics (#32666) --- core/metrics/db_delete_test.go | 104 +++++++++++++++++ core/metrics/db_history.go | 53 +++++++-- core/metrics/tariffs.go | 48 ++++++++ server/http.go | 8 +- server/http_db_metrics_handler.go | 88 +++++++++++++++ server/mcp/openapi.json | 181 ++++++++++++++++++++++++++++++ server/openapi.yaml | 119 ++++++++++++++++++++ 7 files changed, 589 insertions(+), 12 deletions(-) create mode 100644 core/metrics/db_delete_test.go create mode 100644 server/http_db_metrics_handler.go diff --git a/core/metrics/db_delete_test.go b/core/metrics/db_delete_test.go new file mode 100644 index 000000000..a68ee8d25 --- /dev/null +++ b/core/metrics/db_delete_test.go @@ -0,0 +1,104 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/server/db" + "github.com/stretchr/testify/require" +) + +func TestDeleteEnergy(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + ePv := entity{Id: 2, Name: "pv1", Group: PV} + require.NoError(t, db.Instance.Create(&ePv).Error) + eFc := entity{Id: 3, Name: Forecast, Group: Forecast} + require.NoError(t, db.Instance.Create(&eFc).Error) + + loc := time.Now().Location() + base := time.Date(2026, 4, 15, 16, 0, 0, 0, loc) + for i := range 4 { + ts := base.Add(time.Duration(i) * 15 * time.Minute) + require.NoError(t, persist(ePv, ts, 1, 0, nil, false)) + require.NoError(t, persist(eFc, ts, 2, 0, nil, false)) + } + + count := func() int64 { + var n int64 + require.NoError(t, db.Instance.Model(new(meter)).Count(&n).Error) + return n + } + require.Equal(t, int64(8), count()) + + // both bounds are required + _, err := DeleteEnergy(time.Time{}, base, EnergyFilter{}) + require.Error(t, err) + + // filter narrows to the matching entity, range is half-open + rows, err := DeleteEnergy(base.UTC(), base.Add(30*time.Minute).UTC(), EnergyFilter{Group: Forecast}) + require.NoError(t, err) + require.Equal(t, int64(2), rows) + require.Equal(t, int64(6), count()) + + // pv untouched + res, err := QueryEnergy(base.Add(-time.Hour).UTC(), base.Add(time.Hour).UTC(), "hour", false, EnergyFilter{Group: PV}) + require.NoError(t, err) + require.Len(t, res, 1) + require.Equal(t, 4.0, res[0].Data[0].Energy) + + // empty filter deletes across entities + rows, err = DeleteEnergy(base.UTC(), base.Add(time.Hour).UTC(), EnergyFilter{}) + require.NoError(t, err) + require.Equal(t, int64(6), rows) + require.Zero(t, count()) +} + +func TestDeleteTariffs(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, db.Instance.AutoMigrate(new(tariffValue))) + + base := time.Date(2026, 4, 15, 16, 0, 0, 0, time.UTC) + grid, co2 := 0.3, 250.0 + for i := range 4 { + require.NoError(t, PersistTariffs(base.Add(time.Duration(i)*15*time.Minute), &grid, nil, &co2, nil)) + } + + count := func() int64 { + var n int64 + require.NoError(t, db.Instance.Model(new(tariffValue)).Count(&n).Error) + return n + } + + _, err := DeleteTariffs(base, time.Time{}, "") + require.Error(t, err) + + // unknown usage never reaches the column interpolation + _, err = DeleteTariffs(base, base.Add(time.Hour), "unknown") + require.ErrorIs(t, err, ErrInvalidUsage) + require.Equal(t, int64(4), count()) + + // clearing one usage keeps the row as long as another value remains + rows, err := DeleteTariffs(base, base.Add(30*time.Minute), "grid") + require.NoError(t, err) + require.Equal(t, int64(2), rows) + require.Equal(t, int64(4), count()) + + var res tariffValue + require.NoError(t, db.Instance.Where("ts = ?", base.Unix()).First(&res).Error) + require.Nil(t, res.Grid) + require.InDelta(t, 250, *res.Co2, 0.001) + + // clearing the last usage drops the now empty rows + rows, err = DeleteTariffs(base, base.Add(30*time.Minute), "co2") + require.NoError(t, err) + require.Equal(t, int64(2), rows) + require.Equal(t, int64(2), count()) + + // no usage drops the whole row + rows, err = DeleteTariffs(base, base.Add(time.Hour), "") + require.NoError(t, err) + require.Equal(t, int64(2), rows) + require.Zero(t, count()) +} diff --git a/core/metrics/db_history.go b/core/metrics/db_history.go index 8aa93cd6f..b410c5287 100644 --- a/core/metrics/db_history.go +++ b/core/metrics/db_history.go @@ -10,6 +10,7 @@ import ( "github.com/evcc-io/evcc/server/db" "github.com/evcc-io/evcc/util/export" + "gorm.io/gorm" ) // Slot represents an aggregated energy time slot @@ -62,6 +63,47 @@ type EnergyFilter struct { Title string } +// entityQuery returns a subquery selecting the ids of the matching entities, +// nil for an empty filter. +func entityQuery(f EnergyFilter) *gorm.DB { + if f.Group == "" && f.Name == "" && f.Title == "" { + return nil + } + + tx := db.Instance.Model(new(entity)).Select("id") + if f.Group != "" { + tx = tx.Where(`"group" = ?`, f.Group) + } + if f.Name != "" { + tx = tx.Where("name = ?", f.Name) + } + if f.Title != "" { + tx = tx.Where("title = ?", f.Title) + } + + return tx +} + +// DeleteEnergy removes the slots in [from,to), narrowed to the matching +// entities. Both bounds are required, a full wipe is /api/db/reset. +func DeleteEnergy(from, to time.Time, filter ...EnergyFilter) (int64, error) { + if from.IsZero() || to.IsZero() { + return 0, errors.New("missing from/to") + } + + tx := db.Instance.Where("ts >= ? AND ts < ?", from.Unix(), to.Unix()) + + if len(filter) > 0 { + if sub := entityQuery(filter[0]); sub != nil { + tx = tx.Where("meter IN (?)", sub) + } + } + + res := tx.Delete(new(meter)) + + return res.RowsAffected, res.Error +} + // QueryEnergy returns aggregated energy data, per title or per group. func QueryEnergy(from, to time.Time, aggregate string, grouped bool, filter ...EnergyFilter) ([]Series, error) { addDuration := aggregateDurations[aggregate] @@ -114,15 +156,8 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool, filter ...E } if len(filter) > 0 { - f := filter[0] - if f.Group != "" { - tx = tx.Where(`e."group" = ?`, f.Group) - } - if f.Name != "" { - tx = tx.Where("e.name = ?", f.Name) - } - if f.Title != "" { - tx = tx.Where("e.title = ?", f.Title) + if sub := entityQuery(filter[0]); sub != nil { + tx = tx.Where("m.meter IN (?)", sub) } } diff --git a/core/metrics/tariffs.go b/core/metrics/tariffs.go index 66a04b2fb..ecd3ecfa0 100644 --- a/core/metrics/tariffs.go +++ b/core/metrics/tariffs.go @@ -1,6 +1,10 @@ package metrics import ( + "errors" + "fmt" + "slices" + "strings" "time" "github.com/evcc-io/evcc/server/db" @@ -26,6 +30,50 @@ func init() { }) } +// ErrInvalidUsage is returned for an unknown tariff usage +var ErrInvalidUsage = errors.New("invalid usage") + +// tariffUsages are the deletable usages, named after their table column +var tariffUsages = []string{"grid", "feedin", "co2", "temperature"} + +// DeleteTariffs removes the persisted values in [from,to). An empty usage drops +// the entire row, otherwise only that usage is cleared. Both bounds are +// required, a full wipe is /api/db/reset. The count is the number of affected +// rows; rows dropped by the cleanup are a subset of the cleared ones. +func DeleteTariffs(from, to time.Time, usage string) (int64, error) { + if from.IsZero() || to.IsZero() { + return 0, errors.New("missing from/to") + } + + inRange := func() *gorm.DB { + return db.Instance.Where("ts >= ? AND ts < ?", from.Unix(), to.Unix()) + } + + if usage == "" { + res := inRange().Delete(new(tariffValue)) + return res.RowsAffected, res.Error + } + + // guards the column interpolated below + if !slices.Contains(tariffUsages, usage) { + return 0, fmt.Errorf("%w: %s (valid: %s)", ErrInvalidUsage, usage, strings.Join(tariffUsages, ", ")) + } + + res := inRange().Model(new(tariffValue)). + Where(usage+" IS NOT NULL"). + Update(usage, gorm.Expr("NULL")) + if res.Error != nil { + return 0, res.Error + } + + // drop the rows that no longer hold any value + err := inRange(). + Where("grid IS NULL AND feedin IS NULL AND co2 IS NULL AND temperature IS NULL"). + Delete(new(tariffValue)).Error + + return res.RowsAffected, err +} + // PersistTariffs stores the tariff values at the given 15min boundary, nil values omitted func PersistTariffs(ts time.Time, grid, feedin, co2, temperature *float64) error { if grid == nil && feedin == nil && co2 == nil && temperature == nil { diff --git a/server/http.go b/server/http.go index b1aa3f0de..7b91dabc0 100644 --- a/server/http.go +++ b/server/http.go @@ -438,9 +438,11 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti api.Use(ensureDbAuth(auth)) routes := map[string]route{ - "backup": {"GET", "/backup", getBackup()}, - "restore": {"POST", "/restore", restoreDatabase(shutdown)}, - "reset": {"POST", "/reset", resetDatabase(shutdown)}, + "backup": {"GET", "/backup", getBackup()}, + "restore": {"POST", "/restore", restoreDatabase(shutdown)}, + "reset": {"POST", "/reset", resetDatabase(shutdown)}, + "deleteenergy": {"DELETE", "/metrics/energy", deleteEnergyHandler}, + "deletetariffs": {"DELETE", "/metrics/tariffs", deleteTariffsHandler}, } for _, r := range routes { diff --git a/server/http_db_metrics_handler.go b/server/http_db_metrics_handler.go new file mode 100644 index 000000000..05d1423c4 --- /dev/null +++ b/server/http_db_metrics_handler.go @@ -0,0 +1,88 @@ +package server + +import ( + "errors" + "net/http" + "time" + + "github.com/evcc-io/evcc/core/metrics" + "github.com/evcc-io/evcc/server/db" +) + +// deleteResult reports the number of affected rows +type deleteResult struct { + Deleted int64 `json:"deleted"` +} + +// timeRange parses the mandatory from/to query parameters +func timeRange(r *http.Request) (time.Time, time.Time, error) { + q := r.URL.Query() + + from, err := time.Parse(time.RFC3339, q.Get("from")) + if err != nil { + return time.Time{}, time.Time{}, errors.New("invalid 'from' parameter") + } + + to, err := time.Parse(time.RFC3339, q.Get("to")) + if err != nil { + return time.Time{}, time.Time{}, errors.New("invalid 'to' parameter") + } + + return from, to, nil +} + +// deleteEnergyHandler removes energy metrics. Parameters match +// /api/history/energy so the same request can be previewed before deleting. +func deleteEnergyHandler(w http.ResponseWriter, r *http.Request) { + if db.Instance == nil { + jsonError(w, http.StatusBadRequest, errors.New("database offline")) + return + } + + from, to, err := timeRange(r) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + q := r.URL.Query() + filter := metrics.EnergyFilter{ + Group: q.Get("group"), + Name: q.Get("name"), + Title: q.Get("title"), + } + + rows, err := metrics.DeleteEnergy(from, to, filter) + if err != nil { + jsonError(w, http.StatusInternalServerError, err) + return + } + + jsonWrite(w, deleteResult{rows}) +} + +// deleteTariffsHandler removes persisted tariff values +func deleteTariffsHandler(w http.ResponseWriter, r *http.Request) { + if db.Instance == nil { + jsonError(w, http.StatusBadRequest, errors.New("database offline")) + return + } + + from, to, err := timeRange(r) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + rows, err := metrics.DeleteTariffs(from, to, r.URL.Query().Get("usage")) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, metrics.ErrInvalidUsage) { + status = http.StatusBadRequest + } + jsonError(w, status, err) + return + } + + jsonWrite(w, deleteResult{rows}) +} diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index ee7fa6457..6c0f36efd 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -2244,6 +2244,171 @@ } } }, + "/db/metrics/energy": { + "delete": { + "operationId": "deleteEnergyMetrics", + "summary": "Delete energy metrics", + "description": "Deletes recorded energy slots in the given time range, optionally narrowed by entity. Filter parameters match /history/energy, so the same request can be previewed there before deleting. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt.", + "tags": [ + "db" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/adminPassword" + }, + { + "name": "from", + "in": "query", + "required": true, + "description": "Start time, inclusive (RFC3339)", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-07-01T00:00:00Z" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "description": "End time, exclusive (RFC3339)", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-07-02T00:00:00Z" + } + }, + { + "name": "group", + "in": "query", + "description": "Filter by entity group", + "schema": { + "type": "string", + "example": "forecast", + "enum": [ + "forecast", + "battery", + "grid", + "pv", + "home", + "loadpoint", + "meter", + "consumer", + "temperature" + ] + } + }, + { + "name": "name", + "in": "query", + "description": "Filter by entity name", + "schema": { + "type": "string", + "example": "db:8" + } + }, + { + "name": "title", + "in": "query", + "description": "Filter by entity title", + "schema": { + "type": "string", + "example": "Battery" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Deleted" + }, + "400": { + "description": "Invalid parameters or database offline" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/db/metrics/tariffs": { + "delete": { + "operationId": "deleteTariffMetrics", + "summary": "Delete tariff metrics", + "description": "Deletes recorded tariff values in the given time range. Without usage the entire record is removed, otherwise only that usage is cleared and records left without any value are removed. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt.", + "tags": [ + "db" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/adminPassword" + }, + { + "name": "from", + "in": "query", + "required": true, + "description": "Start time, inclusive (RFC3339)", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-07-01T00:00:00Z" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "description": "End time, exclusive (RFC3339)", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-07-02T00:00:00Z" + } + }, + { + "name": "usage", + "in": "query", + "description": "Limit deletion to a single tariff usage", + "schema": { + "type": "string", + "example": "co2", + "enum": [ + "grid", + "feedin", + "co2", + "temperature" + ] + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Deleted" + }, + "400": { + "description": "Invalid parameters or database offline" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, "/history/energy": { "get": { "operationId": "getEnergyHistory", @@ -5296,6 +5461,22 @@ } }, "responses": { + "Deleted": { + "description": "Success - number of deleted records", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deleted": { + "type": "integer", + "example": 96 + } + } + } + } + } + }, "NanoSecondsResult": { "description": "Success - Number result - Unit is **nanoseconds**", "content": { diff --git a/server/openapi.yaml b/server/openapi.yaml index d1f16eacc..3bafd8e6c 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1353,6 +1353,115 @@ paths: description: Reset successful, instance is restarting "401": $ref: "#/components/responses/Unauthorized" + /db/metrics/energy: + delete: + operationId: deleteEnergyMetrics + summary: Delete energy metrics + description: "Deletes recorded energy slots in the given time range, optionally narrowed by entity. Filter parameters match /history/energy, so the same request can be previewed there before deleting. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt." + tags: + - db + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/adminPassword" + - name: from + in: query + required: true + description: Start time, inclusive (RFC3339) + schema: + type: string + format: date-time + example: "2026-07-01T00:00:00Z" + - name: to + in: query + required: true + description: End time, exclusive (RFC3339) + schema: + type: string + format: date-time + example: "2026-07-02T00:00:00Z" + - name: group + in: query + description: Filter by entity group + schema: + type: string + example: forecast + enum: + - forecast + - battery + - grid + - pv + - home + - loadpoint + - meter + - consumer + - temperature + - name: name + in: query + description: Filter by entity name + schema: + type: string + example: db:8 + - name: title + in: query + description: Filter by entity title + schema: + type: string + example: Battery + responses: + "200": + $ref: "#/components/responses/Deleted" + "400": + description: Invalid parameters or database offline + "401": + $ref: "#/components/responses/Unauthorized" + /db/metrics/tariffs: + delete: + operationId: deleteTariffMetrics + summary: Delete tariff metrics + description: "Deletes recorded tariff values in the given time range. Without usage the entire record is removed, otherwise only that usage is cleared and records left without any value are removed. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt." + tags: + - db + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/adminPassword" + - name: from + in: query + required: true + description: Start time, inclusive (RFC3339) + schema: + type: string + format: date-time + example: "2026-07-01T00:00:00Z" + - name: to + in: query + required: true + description: End time, exclusive (RFC3339) + schema: + type: string + format: date-time + example: "2026-07-02T00:00:00Z" + - name: usage + in: query + description: Limit deletion to a single tariff usage + schema: + type: string + example: co2 + enum: + - grid + - feedin + - co2 + - temperature + responses: + "200": + $ref: "#/components/responses/Deleted" + "400": + description: Invalid parameters or database offline + "401": + $ref: "#/components/responses/Unauthorized" /history/energy: get: operationId: getEnergyHistory @@ -1816,6 +1925,16 @@ components: - "1" - "0" responses: + Deleted: + description: Success - number of deleted records + content: + application/json: + schema: + type: object + properties: + deleted: + type: integer + example: 96 NanoSecondsResult: description: Success - Number result - Unit is **nanoseconds** content: From cc9bfc25565554455b6e4de0d42d44a9ae53cf5c Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 10 Aug 2026 09:44:42 +0200 Subject: [PATCH 123/331] chore: monotonic package version for dev builds (#32679) --- .goreleaser-nightly.yml | 9 ++++++--- .goreleaser.yml | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.goreleaser-nightly.yml b/.goreleaser-nightly.yml index 83b2ccd9a..427faeedf 100644 --- a/.goreleaser-nightly.yml +++ b/.goreleaser-nightly.yml @@ -11,7 +11,8 @@ builds: - -trimpath - -tags=release ldflags: - - -X github.com/evcc-io/evcc/util.Version={{ .Version }} -X github.com/evcc-io/evcc/util.Commit={{ .ShortCommit }} -s -w + # the package version carries a timestamp for ordering, the reported version does not + - -X github.com/evcc-io/evcc/util.Version={{ if .IsSnapshot }}{{ incminor .Tag }}-dev+{{ .ShortCommit }}{{ else }}{{ .Version }}{{ end }} -X github.com/evcc-io/evcc/util.Commit={{ .ShortCommit }} -s -w env: - CGO_ENABLED=0 goos: @@ -56,8 +57,10 @@ checksum: name_template: "checksums.txt" snapshot: - # semver pre-release of the upcoming version - version_template: "{{ incminor .Version }}-dev+{{ .ShortCommit }}" + # semver pre-release of the upcoming version. The timestamp makes the package + # version increase monotonically: apt compares it numerically, while the commit + # alone would sort randomly. + version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}+{{ .ShortCommit }}" changelog: sort: asc diff --git a/.goreleaser.yml b/.goreleaser.yml index a392aa64c..56c7630cf 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -16,7 +16,8 @@ builds: - -trimpath - -tags=release ldflags: - - -X github.com/evcc-io/evcc/util.Version={{ .Version }} -s -w + # the package version carries a timestamp for ordering, the reported version does not + - -X github.com/evcc-io/evcc/util.Version={{ if .IsSnapshot }}{{ incminor .Tag }}-dev+{{ .ShortCommit }}{{ else }}{{ .Version }}{{ end }} -s -w env: - CGO_ENABLED=0 goos: @@ -63,8 +64,10 @@ checksum: name_template: "checksums.txt" snapshot: - # semver pre-release of the upcoming version - version_template: "{{ incminor .Version }}-dev+{{ .ShortCommit }}" + # semver pre-release of the upcoming version. The timestamp makes the package + # version increase monotonically: apt compares it numerically, while the commit + # alone would sort randomly. + version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}+{{ .ShortCommit }}" changelog: sort: asc From a237f698d066b9eaabde88d714aeeb4153b67986 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 10 Aug 2026 11:03:57 +0200 Subject: [PATCH 124/331] Optimizer: re-run on vehicle changes and include unmodeled loads (#32681) --- core/loadpoint.go | 13 ++++++++++++ core/loadpoint_boost_test.go | 5 +++++ core/loadpoint_vehicle.go | 3 +++ core/loadpoint_vehicle_test.go | 30 +++++++++++++++++++++++++++ core/site/api.go | 2 +- core/site_api.go | 10 ++------- core/site_optimizer.go | 37 +++++++++++++++++++++++++++++++--- core/site_optimizer_test.go | 27 +++++++++++++++++++++++++ server/http.go | 2 +- server/http_site_handler.go | 8 ++++++++ 10 files changed, 124 insertions(+), 13 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index e05835cb7..8a2aba36d 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -573,6 +573,9 @@ func (lp *Loadpoint) evVehicleConnectHandler() { // reset energy-based charging plan offset lp.planEnergyOffset = 0 + + // connect adds the loadpoint's demand + lp.triggerOptimizer() } // evVehicleDisconnectHandler sends external start event @@ -636,6 +639,16 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() { // mark plan slot as inactive // this will force a deletion of an outdated plan once plan time is expired in GetPlan() lp.setPlanActive(false) + + // disconnect removes the loadpoint's demand + lp.triggerOptimizer() +} + +// triggerOptimizer re-runs the optimizer when the loadpoint's profile changed +func (lp *Loadpoint) triggerOptimizer() { + if lp.site != nil { + lp.site.Optimize() + } } // evVehicleSocProgressHandler sends external start event diff --git a/core/loadpoint_boost_test.go b/core/loadpoint_boost_test.go index 78594c4fb..3a2fa09cc 100644 --- a/core/loadpoint_boost_test.go +++ b/core/loadpoint_boost_test.go @@ -13,6 +13,11 @@ type mockSite struct { site.API maxDischargePower float64 residualPower float64 + optimized int +} + +func (m *mockSite) Optimize() { + m.optimized++ } func (m *mockSite) GetBatteryMaxDischargePower() float64 { diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index c7d55c67d..1860df617 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -178,6 +178,9 @@ func (lp *Loadpoint) setActiveVehicle(v api.Vehicle) { // re-assigning the same default vehicle on reconnect must keep a known soc. if prev != v { lp.unpublishVehicle() + + // vehicle change alters the loadpoint's optimizer profile + lp.triggerOptimizer() } // publish effective values diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go index e03d21746..028e447c9 100644 --- a/core/loadpoint_vehicle_test.go +++ b/core/loadpoint_vehicle_test.go @@ -399,6 +399,36 @@ func TestReassignActiveVehicleKeepsSoc(t *testing.T) { assert.Equal(t, 0.0, lp.vehicleSoc, "soc must clear on vehicle change") } +// TestActiveVehicleChangeTriggersOptimizer ensures the optimizer is re-run when +// the detected vehicle changes, as the loadpoint profile depends on it. +func TestActiveVehicleChangeTriggersOptimizer(t *testing.T) { + ctrl := gomock.NewController(t) + + vehicle := api.NewMockVehicle(ctrl) + vehicle.EXPECT().GetTitle().Return("target").AnyTimes() + vehicle.EXPECT().Icon().Return("").AnyTimes() + vehicle.EXPECT().Capacity().AnyTimes() + vehicle.EXPECT().Phases().AnyTimes() + vehicle.EXPECT().OnIdentified().AnyTimes() + + lp := NewLoadpoint(util.NewLogger("foo"), settings.NewDatabaseSettingsAdapter("foo")) + s := new(mockSite) + lp.site = s + + x, y, z := createChannels(t) + attachChannels(lp, x, y, z) + + lp.setActiveVehicle(vehicle) + assert.Equal(t, 1, s.optimized, "vehicle detected") + + // re-assigning the same vehicle is not a change + lp.setActiveVehicle(vehicle) + assert.Equal(t, 1, s.optimized, "same vehicle re-assigned") + + lp.setActiveVehicle(nil) + assert.Equal(t, 2, s.optimized, "vehicle removed") +} + // integratedDeviceCharger is a minimal charger advertising the IntegratedDevice feature. type integratedDeviceCharger struct{} diff --git a/core/site/api.go b/core/site/api.go index df9a6d17f..373584e06 100644 --- a/core/site/api.go +++ b/core/site/api.go @@ -16,7 +16,7 @@ type API interface { Loadpoints() []loadpoint.API Vehicles() Vehicles - Optimize() error + Optimize() // Meta GetTitle() string diff --git a/core/site_api.go b/core/site_api.go index eb755a8cd..f25806ea3 100644 --- a/core/site_api.go +++ b/core/site_api.go @@ -13,7 +13,6 @@ import ( "github.com/evcc-io/evcc/core/site" "github.com/evcc-io/evcc/server/db/settings" "github.com/evcc-io/evcc/util/config" - "github.com/evcc-io/evcc/util/sponsor" "github.com/samber/lo" ) @@ -39,13 +38,8 @@ func filterConfigurable(ref []string) []string { } // Optimize updates the optimizer -func (site *Site) Optimize() error { - if !sponsor.IsAuthorized() || !optimizerEnabled() { - return api.ErrNotAvailable - } - - go site.optimizerUpdateAsync(optimizerDebounce) - return nil +func (site *Site) Optimize() { + go site.optimizerUpdateAsync(0) } // GetTitle returns the title diff --git a/core/site_optimizer.go b/core/site_optimizer.go index ef1a7b983..61da1a354 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -35,9 +35,6 @@ const ( // batteryPower is the default power of the battery in W batteryPower = 6000 - - // optimizerDebounce limits how often on-demand optimizer runs execute - optimizerDebounce = 2 * time.Minute ) // optimizerChargingStrategies are the valid grid charging strategies; the first @@ -512,13 +509,18 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim var batteries []optimizerBattery + // uncontrollable power of loadpoints that cannot be modelled as storage + var unmodelled float64 + for id, lp := range site.Loadpoints() { // ignore disconnected loadpoints, including StatusNone if s := lp.GetStatus(); s != api.StatusB && s != api.StatusC { continue } + // unknown vehicle capacity: account for the consumption as uncontrollable load if v := lp.GetVehicle(); v == nil || v.Capacity() == 0 { + unmodelled += unmodelledPower(lp) continue } @@ -529,6 +531,21 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim } } + // home profile subtracts all loadpoint power, so unmodelled loadpoints would + // leave the optimizer planning against surplus that is already consumed. Their + // forecast is zero, so the measured power only decays into the near slots - + // without a capacity there is no fill point to assert it any further. + if unmodelled > 0 { + load := make([]float64, minLen) + blendMeasured(load, unmodelled/slotsPerHour, optimizerDecaySlots) + + site.log.DEBUG.Printf("optimizer: home slots updated with unmodelled %.0fW loadpoint load: %.0f", unmodelled, load[:min(optimizerDecaySlots, len(load))]) + + for i, v := range prorate(load, firstSlotDuration) { + req.TimeSeries.Gt[i] += v + } + } + for i, dev := range site.batteryMeters { // measurements may lag the configured meters on an off-cycle trigger if i >= len(battery) { @@ -960,6 +977,20 @@ func loadpointProfile(lp loadpoint.API, minLen int) []float64 { return res } +// unmodelledPower returns the uncontrollable power of a connected loadpoint that +// cannot be modelled as storage because the vehicle capacity is unknown +func unmodelledPower(lp loadpoint.API) float64 { + power := lp.GetChargePower() + + // minpv keeps drawing at least min power while the vehicle is connected, + // even before the charge meter has caught up + if lp.GetMode() == api.ModeMinPV && lp.GetStatus() == api.StatusC { + power = max(power, lp.EffectiveMinPower()) + } + + return max(0, power) +} + // homeProfile returns the home base load in Wh func (site *Site) homeProfile(minLen int) ([]float64, error) { // kWh over last 30 days diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 9e8d5bbb6..a50549bff 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -69,6 +69,33 @@ func TestAsTimestamps(t *testing.T) { }, got) } +func TestUnmodelledPower(t *testing.T) { + ctrl := gomock.NewController(t) + + for _, tc := range []struct { + name string + mode api.ChargeMode + status api.ChargeStatus + power, minPower float64 + expected float64 + }{ + {"pv charging", api.ModePV, api.StatusC, 4000, 1380, 4000}, + {"pv connected", api.ModePV, api.StatusB, 0, 1380, 0}, + {"minpv floor before meter caught up", api.ModeMinPV, api.StatusC, 0, 4000, 4000}, + {"minpv floor must not lower measured", api.ModeMinPV, api.StatusC, 4000, 1000, 4000}, + {"minpv floor only applies while charging", api.ModeMinPV, api.StatusB, 0, 4000, 0}, + {"negative measurement clamped", api.ModePV, api.StatusC, -100, 0, 0}, + } { + lp := loadpoint.NewMockAPI(ctrl) + lp.EXPECT().GetMode().Return(tc.mode).AnyTimes() + lp.EXPECT().GetStatus().Return(tc.status).AnyTimes() + lp.EXPECT().GetChargePower().Return(tc.power).AnyTimes() + lp.EXPECT().EffectiveMinPower().Return(tc.minPower).AnyTimes() + + assert.Equal(t, tc.expected, unmodelledPower(lp), tc.name) + } +} + func TestBatteryForecastSocExtremes(t *testing.T) { for _, tc := range []struct { name string diff --git a/server/http.go b/server/http.go index 7b91dabc0..cb08a71dc 100644 --- a/server/http.go +++ b/server/http.go @@ -197,7 +197,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { "deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler}, "gridsessions": {"GET", "/gridsessions", gridSessionsHandler}, "energyhistory": {"GET", "/history/energy", energyHistoryHandler}, - "optimize": {"POST", "/optimize", getHandler(site.Optimize)}, + "optimize": {"POST", "/optimize", callHandler(site.Optimize)}, "telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)}, "devicecolors": {"PUT", "/devicecolors", updateDeviceColor(site)}, diff --git a/server/http_site_handler.go b/server/http_site_handler.go index 1bfd7daf3..c6446205a 100644 --- a/server/http_site_handler.go +++ b/server/http_site_handler.go @@ -191,6 +191,14 @@ func getHandler[T any](get func() T) http.HandlerFunc { } } +// callHandler invokes an api function without result +func callHandler(fun func()) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fun() + jsonWrite(w, nil) + } +} + // updateSmartCostLimit sets the smart cost limit globally func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { From 7b8e6e1f7459c2905441930fdba6ccbe25ead9d5 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 10 Aug 2026 11:10:31 +0200 Subject: [PATCH 125/331] Config UI: show static grid export limit on hems card (#32634) --- assets/js/components/Config/DeviceTags.vue | 1 + assets/js/views/Config.vue | 10 +++++++--- i18n/de.json | 1 + i18n/en.json | 1 + tests/hems.spec.ts | 6 ++++++ 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/assets/js/components/Config/DeviceTags.vue b/assets/js/components/Config/DeviceTags.vue index 260d4ae5d..3c6b65c84 100644 --- a/assets/js/components/Config/DeviceTags.vue +++ b/assets/js/components/Config/DeviceTags.vue @@ -210,6 +210,7 @@ export default { case "solarForecast": case "dimLimit": case "curtailLimit": + case "exportLimit": return this.fmtW(value); case "energy": case "returnEnergy": diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index cdbadbe52..0b6049c4d 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -846,14 +846,18 @@ export default defineComponent({ return store.state?.hems; }, hemsTags(): DeviceTags { + const result: DeviceTags = {}; + const exportLimit = store.state?.gridExportLimit || 0; + if (exportLimit > 0) { + result["exportLimit"] = { value: exportLimit }; + } if (this.hemsDevices.length === 0 && !this.hems?.config?.configured) { - return { configured: { value: false } }; + return exportLimit > 0 ? result : { configured: { value: false } }; } const status = store.state?.hems?.status; if (!status) { - return { configured: { value: true } }; + return { ...result, configured: { value: true } }; } - const result: DeviceTags = {}; if (status.dimmed && status.maxConsumptionPower) { result["dimLimit"] = { value: status.maxConsumptionPower, diff --git a/i18n/de.json b/i18n/de.json index bd29b8201..5e2974e4a 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -219,6 +219,7 @@ "enabled": "Ladebereit", "energy": "Energie", "events": "Ereignisse", + "exportLimit": "Einspeiselimit", "feedinPrice": "Einspeisevergütung", "forecast": "Vorhersage", "gridPrice": "Netzpreis", diff --git a/i18n/en.json b/i18n/en.json index 91eb2eddb..ddd122c5b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -219,6 +219,7 @@ "enabled": "Enabled", "energy": "Energy", "events": "Events", + "exportLimit": "Grid export limit", "feedinPrice": "Feed-in price", "forecast": "Forecast", "gridPrice": "Grid price", diff --git a/tests/hems.spec.ts b/tests/hems.spec.ts index 5d529cbb0..e6e923b39 100644 --- a/tests/hems.spec.ts +++ b/tests/hems.spec.ts @@ -159,6 +159,12 @@ maxconsumptionpower: await hemsModal.getByRole("button", { name: "Close" }).click(); await expectModalHidden(hemsModal); + // card shows the static limit instead of unconfigured + const hemsCard = page.getByTestId("hems"); + await expect(hemsCard).toContainText("Grid export limit"); + await expect(hemsCard).toContainText("7.0 kW"); + await expect(hemsCard).not.toContainText("Configured"); + // persisted across restart await restart(CONFIG); await page.reload(); From 6bf587097fce0ddf39c965e4ef030391e11c5d44 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 10 Aug 2026 12:17:55 +0200 Subject: [PATCH 126/331] chore: fix dev version string in Docker builds (#32683) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b4dc196d7..ff5bf1092 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ SHA ?= $(shell test -d .git && git rev-parse --short HEAD) GIT_TAG := $(shell test -d .git && git describe --abbrev=0 --tags) # commits since the latest tag, empty or 0 means the commit is tagged GIT_DIST := $(shell test -n "$(GIT_TAG)" && git rev-list --count $(GIT_TAG)..HEAD) -NEXT_TAG = $(shell echo $(GIT_TAG) | awk -F. -v b='$(BUMP)' '{if (b == "patch") $$NF++; else {$$2++; $$3=0}} 1' OFS=.) +NEXT_TAG = $(shell echo $(GIT_TAG) | awk -F. -v b='$(BUMP)' '{ if (b == "patch") { m = $$2; p = $$3 + 1 } else { m = $$2 + 1; p = 0 }; print $$1 "." m "." p }') # untagged builds are semver pre-releases of the upcoming version TAG_NAME ?= $(if $(filter-out 0,$(GIT_DIST)),$(NEXT_TAG)-dev+$(SHA),$(GIT_TAG)) COMMIT := $(SHA) From 6aea77286ead3936d3e878f1980714b80146071f Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 10 Aug 2026 12:47:57 +0200 Subject: [PATCH 127/331] chore: fix flaky aux meter e2e test (#32685) --- tests/config-grid-only.evcc.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/config-grid-only.evcc.yaml b/tests/config-grid-only.evcc.yaml index 99832e635..589f89b65 100755 --- a/tests/config-grid-only.evcc.yaml +++ b/tests/config-grid-only.evcc.yaml @@ -1,3 +1,5 @@ +interval: 0.1s + site: title: Hello World meters: From 34024cb9a6bf339a73a7c6e5f6b0ab0e2e7f6cf9 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 10 Aug 2026 16:02:34 +0200 Subject: [PATCH 128/331] chore: upgrade modules --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 286ee8880..d485e06f0 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/enbility/ship-go v0.6.1-0.20260720110450-0aa90f64ac76 github.com/enbility/spine-go v0.7.1-0.20260629113257-b3bcc643f323 github.com/evcc-io/openapi-mcp v0.6.1-0.20260701153510-26c442199ef4 - github.com/evcc-io/optimizer v0.0.0-20260808102051-bc47435b4068 + github.com/evcc-io/optimizer v0.0.0-20260808185529-a50b33da38e5 github.com/evcc-io/rct v0.2.0 github.com/evcc-io/tesla-proxy-client v0.0.0-20260722080120-05ada06c7368 github.com/fatih/structs v1.1.0 @@ -78,7 +78,7 @@ require ( github.com/nicholas-fedor/shoutrrr v0.17.0 github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/olekukonko/tablewriter v1.1.4 - github.com/philippseith/signalr v0.10.1-0.20260806083734-d62f30bf7a90 + github.com/philippseith/signalr v0.11.0 github.com/prometheus-community/pro-bing v0.9.1 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/common v0.70.1 diff --git a/go.sum b/go.sum index 01ce8bcc1..ee976279c 100644 --- a/go.sum +++ b/go.sum @@ -150,8 +150,8 @@ github.com/evcc-io/ocpp-go v0.0.0-20260727074919-195c10b8758d h1:UxgSUmwiuVURrma github.com/evcc-io/ocpp-go v0.0.0-20260727074919-195c10b8758d/go.mod h1:2kcukDdhui4u730VfnYVWuwzDLgw+mBRGDir/QAyBhg= github.com/evcc-io/openapi-mcp v0.6.1-0.20260701153510-26c442199ef4 h1:aGM3NwDsKbnmiaO10ptbvEolRAUThWJEsObiAWFDU/c= github.com/evcc-io/openapi-mcp v0.6.1-0.20260701153510-26c442199ef4/go.mod h1:YyOx4zwr6xVUcchAETHERZ+75cZMIrdcjVIZFwBlE1Q= -github.com/evcc-io/optimizer v0.0.0-20260808102051-bc47435b4068 h1:qB2mxyFU9kLz4eXtVWTLnKoW/FrPLclxtDYS21fgNfc= -github.com/evcc-io/optimizer v0.0.0-20260808102051-bc47435b4068/go.mod h1:cHMeq6GfoXQ8LxqY4LH1wn/hrgYEn+ka5RPI1CF37SY= +github.com/evcc-io/optimizer v0.0.0-20260808185529-a50b33da38e5 h1:cwp3NABx+XDPoBji5Yk/H59ZDJxpDdzg+da9UvMFFUg= +github.com/evcc-io/optimizer v0.0.0-20260808185529-a50b33da38e5/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-20260722080120-05ada06c7368 h1:Vek18PwCpXWBVaNfzq7qwru0VsQgWtlL5yIQbLqFwWg= @@ -422,8 +422,8 @@ github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8 github.com/peterbourgon/ff v1.2.0/go.mod h1:ljiF7yxtUvZaxUDyUqQa0+uiEOgwVboj+Q2S2+0nq40= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/philippseith/signalr v0.10.1-0.20260806083734-d62f30bf7a90 h1:s5PiRL6iuCdjV7iwYhi/YxrhKfdRoaIqIGSeBoBiWuo= -github.com/philippseith/signalr v0.10.1-0.20260806083734-d62f30bf7a90/go.mod h1:9VAFjplkNr3RKc3mH3L/L0KvCdF1xeV9Y7cDhNwXvZ8= +github.com/philippseith/signalr v0.11.0 h1:BqvFa+ulBjV0S8Sq4CjqYMc7jVMQY65HQDgSIOc4vgA= +github.com/philippseith/signalr v0.11.0/go.mod h1:9VAFjplkNr3RKc3mH3L/L0KvCdF1xeV9Y7cDhNwXvZ8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= From 285b01f794a400b51577a37e324878a9bada5bcd Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 10 Aug 2026 16:45:56 +0200 Subject: [PATCH 129/331] Modbus: fix missing template delay and timeout defaults (#32694) --- util/modbus/connection.go | 24 ++++++++--------- util/modbus/modbus.go | 44 +++++++++++++++++++++++++++++++ util/modbus/modbus_test.go | 30 +++++++++++++++++++++ util/templates/template_modbus.go | 9 ++++--- 4 files changed, 91 insertions(+), 16 deletions(-) diff --git a/util/modbus/connection.go b/util/modbus/connection.go index eadacafe9..c5b0c8d59 100644 --- a/util/modbus/connection.go +++ b/util/modbus/connection.go @@ -11,9 +11,9 @@ import ( type Connection struct { *logger meters.Connection - slaveID uint8 // duplicated from meters.Connection - logical meters.Logger - delay time.Duration + physical *meterConnection + slaveID uint8 // duplicated from meters.Connection + logical meters.Logger } func (c *Connection) Addr() string { @@ -24,8 +24,9 @@ func (c *Connection) Logger(logger meters.Logger) { c.logical = logger } +// Delay applies the delay to the shared physical connection func (c *Connection) Delay(delay time.Duration) { - c.delay = delay + c.physical.setDelay(delay) } func (c *Connection) Clone(slaveID uint8) *Connection { @@ -33,26 +34,23 @@ func (c *Connection) Clone(slaveID uint8) *Connection { slaveID: slaveID, Connection: c.Connection.Clone(slaveID), logger: c.logger, + physical: c.physical, } } -// TODO resolve conflicts +// ConnectDelay applies the connect delay to the shared physical connection func (c *Connection) ConnectDelay(delay time.Duration) { - if delay > 0 { - c.Connection.ConnectDelay(delay) - } + c.physical.setConnectDelay(delay) } -// TODO resolve conflicts +// Timeout applies the timeout to the shared physical connection func (c *Connection) Timeout(timeout time.Duration) { - if timeout > 0 { - _ = c.Connection.Timeout(timeout) - } + c.physical.setTimeout(timeout) } func (c *Connection) exec(fun func() ([]byte, error)) ([]byte, error) { return c.WithLogger(c.logical, func() ([]byte, error) { - time.Sleep(c.delay) + time.Sleep(c.physical.getDelay()) b, err := fun() if err != nil { diff --git a/util/modbus/modbus.go b/util/modbus/modbus.go index d8a6efaf8..c332d41f6 100644 --- a/util/modbus/modbus.go +++ b/util/modbus/modbus.go @@ -103,9 +103,52 @@ type meterConnection struct { meters.Connection proto Protocol refs int // count of references; first connection has ref count 0 + + // largest value requested by any of the sharing logical connections + delay time.Duration + connectDelay time.Duration + timeout time.Duration + *logger } +// setDelay applies the delay if larger than the current value +func (c *meterConnection) setDelay(delay time.Duration) { + mu.Lock() + defer mu.Unlock() + + c.delay = max(c.delay, delay) +} + +func (c *meterConnection) getDelay() time.Duration { + mu.Lock() + defer mu.Unlock() + + return c.delay +} + +// setConnectDelay applies the connect delay if larger than the current value +func (c *meterConnection) setConnectDelay(delay time.Duration) { + mu.Lock() + defer mu.Unlock() + + if delay > c.connectDelay { + c.connectDelay = delay + c.Connection.ConnectDelay(delay) + } +} + +// setTimeout applies the timeout if larger than the current value +func (c *meterConnection) setTimeout(timeout time.Duration) { + mu.Lock() + defer mu.Unlock() + + if timeout > c.timeout { + c.timeout = timeout + _ = c.Connection.Timeout(timeout) + } +} + var ( connections = make(map[string]*meterConnection) mu sync.Mutex @@ -176,6 +219,7 @@ func NewConnection(ctx context.Context, uri, device, comset string, baudrate int slaveID: slaveID, Connection: conn.Clone(slaveID), logger: conn.logger, + physical: conn, } return res, nil diff --git a/util/modbus/modbus_test.go b/util/modbus/modbus_test.go index ce2b3b614..7cfe597a5 100644 --- a/util/modbus/modbus_test.go +++ b/util/modbus/modbus_test.go @@ -2,10 +2,40 @@ package modbus import ( "testing" + "time" "github.com/stretchr/testify/require" ) +// TestSharedSettings ensures the largest delay and timeout wins for all +// connections sharing the same physical connection +func TestSharedSettings(t *testing.T) { + ctx := t.Context() + uri := "localhost:15020" + + c1, err := Settings{URI: uri, ID: 1, Delay: 2 * time.Second, Timeout: time.Second}.Connection(ctx) + require.NoError(t, err) + + c2, err := Settings{URI: uri, ID: 2, Delay: time.Second, Timeout: 3 * time.Second}.Connection(ctx) + require.NoError(t, err) + + // unset settings don't reset the shared values + c3, err := Settings{URI: uri, ID: 3}.Connection(ctx) + require.NoError(t, err) + + require.Same(t, c1.physical, c2.physical) + require.Same(t, c1.physical, c3.physical) + require.Same(t, c1.physical, c1.Clone(4).physical) + + for _, c := range []*Connection{c1, c2, c3} { + require.Equal(t, 2*time.Second, c.physical.getDelay()) + require.Equal(t, 3*time.Second, c.physical.timeout) + } + + // timeout has been applied to the physical connection + require.Equal(t, 3*time.Second, c1.physical.Connection.Timeout(3*time.Second)) +} + func TestParsePoint(t *testing.T) { tc := []struct { in string diff --git a/util/templates/template_modbus.go b/util/templates/template_modbus.go index 283106e28..09adcb2cf 100644 --- a/util/templates/template_modbus.go +++ b/util/templates/template_modbus.go @@ -69,9 +69,12 @@ func (t *Template) ModbusValues(renderMode int, values map[string]any) { typeParams := modbusConfig.Types[iface].Params for _, p := range typeParams { - // don't overwrite custom values - if values[p.Name] != nil { - continue + // don't overwrite custom values. Params the template deprecated in favour + // of the modbus definition are pre-populated with an empty string default. + if v := values[p.Name]; v != nil { + if s, ok := v.(string); !ok || s != "" { + continue + } } values[p.Name] = p.DefaultValue(renderMode) From 136809f9b074cc550f587d295033856d33f46063 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 10 Aug 2026 17:09:51 +0200 Subject: [PATCH 130/331] UI Sessions: migrate charts from Chart.js to ECharts (#32625) --- .../Battery/BatteryHistoryChart.vue | 89 ++-- assets/js/components/Forecast/chartMixin.ts | 36 +- assets/js/components/Forecast/echarts.ts | 2 + assets/js/components/History/GroupChart.vue | 91 ++-- .../Sessions/AvgCostGroupedChart.vue | 150 +++--- .../components/Sessions/CostGroupedChart.vue | 89 ++-- .../components/Sessions/CostHistoryChart.vue | 465 +++++++----------- .../Sessions/EnergyGroupedChart.vue | 89 +--- .../Sessions/EnergyHistoryChart.vue | 295 +++++------ .../components/Sessions/SolarGroupedChart.vue | 141 +++--- .../js/components/Sessions/SolarYearChart.vue | 144 +++--- assets/js/components/Sessions/echarts.ts | 89 ++++ assets/js/components/Sessions/historyChart.ts | 73 +++ assets/js/components/Sessions/roundChart.css | 7 + assets/js/mixins/echartsChart.ts | 59 +++ assets/js/router.ts | 8 +- assets/js/utils/swipe.ts | 9 +- assets/js/views/Main.vue | 6 +- assets/js/views/Sessions.vue | 81 +-- tests/device-colors.spec.ts | 24 +- 20 files changed, 921 insertions(+), 1026 deletions(-) create mode 100644 assets/js/components/Sessions/echarts.ts create mode 100644 assets/js/components/Sessions/historyChart.ts create mode 100644 assets/js/components/Sessions/roundChart.css create mode 100644 assets/js/mixins/echartsChart.ts diff --git a/assets/js/components/Battery/BatteryHistoryChart.vue b/assets/js/components/Battery/BatteryHistoryChart.vue index 59c16366e..cb3b3e080 100644 --- a/assets/js/components/Battery/BatteryHistoryChart.vue +++ b/assets/js/components/Battery/BatteryHistoryChart.vue @@ -3,23 +3,21 @@ + + 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 @@