From d4d8109a63b9a20f476d3fc05e734c3be8ce718e Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 5 May 2026 17:16:18 +0200 Subject: [PATCH 0001/1128] Battery UI: 100% buffersoc resets bufferstart (#29669) --- assets/js/components/Battery/BatteryUsageSettings.vue | 4 +++- tests/battery-settings.spec.ts | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/assets/js/components/Battery/BatteryUsageSettings.vue b/assets/js/components/Battery/BatteryUsageSettings.vue index 636c734bf..40139f147 100644 --- a/assets/js/components/Battery/BatteryUsageSettings.vue +++ b/assets/js/components/Battery/BatteryUsageSettings.vue @@ -379,7 +379,9 @@ export default defineComponent({ }, async changeBufferSoc($event: Event) { const soc = parseInt(($event.target as HTMLInputElement).value, 10); - if (soc > this.bufferStartSoc && this.bufferStartSoc > 0) { + if (soc === 100) { + await this.setBufferStartSoc(0); + } else if (soc > this.bufferStartSoc && this.bufferStartSoc > 0) { await this.setBufferStartSoc(soc); } await this.saveBufferSoc(soc); diff --git a/tests/battery-settings.spec.ts b/tests/battery-settings.spec.ts index ce43ab357..d7e96449a 100644 --- a/tests/battery-settings.spec.ts +++ b/tests/battery-settings.spec.ts @@ -38,16 +38,21 @@ test.describe("battery settings", async () => { const topRow = page.getByText("Battery-supported vehicle charging"); const bufferSoc = topRow.getByRole("combobox").filter({ hasText: "disabled" }); + const bufferStart = page.locator("#batterySettingsBufferStart"); await expect(bufferSoc).toHaveValue("100"); await expect(page.getByText("Start automatically")).toBeHidden(); await bufferSoc.selectOption({ label: "when above 80%" }); - await expect(bufferSoc).toHaveValue("80"); await expect(page.getByText("Start automatically")).toBeVisible(); + await bufferStart.selectOption({ label: "when above 90%." }); + await bufferSoc.selectOption({ label: "disabled" }); await expect(page.getByText("Start automatically")).toBeHidden(); + + await bufferSoc.selectOption({ label: "when above 80%" }); + await expect(topRow).toContainText("only with enough surplus."); }); test("grid charging", async ({ page }) => { From 338e8b4a461f5e9b26d615d4921c3c23ae4938ad Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 5 May 2026 17:49:33 +0200 Subject: [PATCH 0002/1128] Session energy: latch baseline late if meter not ready at start (#29656) --- core/wrapper/chargerater.go | 30 ++++++++++++----- core/wrapper/chargerater_test.go | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/core/wrapper/chargerater.go b/core/wrapper/chargerater.go index 349d58a01..939f234f9 100644 --- a/core/wrapper/chargerater.go +++ b/core/wrapper/chargerater.go @@ -21,7 +21,7 @@ type ChargeRater struct { meter api.Meter charging bool start time.Time - startEnergy float64 + startEnergy *float64 // nil until baseline successfully read from meter chargedEnergy float64 } @@ -47,11 +47,12 @@ func (cr *ChargeRater) StartCharge(continued bool) { // time is needed if MeterEnergy is not supported cr.start = cr.clck.Now() + cr.startEnergy = nil // get end energy amount if m, ok := api.Cap[api.MeterEnergy](cr.meter); ok { if f, err := m.TotalEnergy(); err == nil { - cr.startEnergy = f + cr.startEnergy = &f cr.log.DEBUG.Printf("charge start energy: %.3fkWh", f) } else if !loadpoint.AcceptableError(err) { cr.log.ERROR.Printf("charge total import: %v", err) @@ -76,7 +77,9 @@ func (cr *ChargeRater) StopCharge() { // get end energy amount if m, ok := api.Cap[api.MeterEnergy](cr.meter); ok { if f, err := m.TotalEnergy(); err == nil { - cr.chargedEnergy += f - cr.startEnergy + if cr.startEnergy != nil { + cr.chargedEnergy += f - *cr.startEnergy + } cr.log.DEBUG.Printf("charge final energy: %.3fkWh", cr.chargedEnergy) } else if !loadpoint.AcceptableError(err) { cr.log.ERROR.Printf("charge total import: %v", err) @@ -94,10 +97,12 @@ func (cr *ChargeRater) ResetCharge() { // get end energy amount if m, ok := api.Cap[api.MeterEnergy](cr.meter); ok { if f, err := m.TotalEnergy(); err == nil { - cr.chargedEnergy += f - cr.startEnergy - cr.log.DEBUG.Printf("charge final energy: %.3fkWh", cr.chargedEnergy) + if cr.startEnergy != nil { + cr.chargedEnergy += f - *cr.startEnergy + cr.log.DEBUG.Printf("charge final energy: %.3fkWh", cr.chargedEnergy) + } - cr.startEnergy = f + cr.startEnergy = &f } else if !loadpoint.AcceptableError(err) { cr.log.ERROR.Printf("charge total import: %v", err) } @@ -139,11 +144,18 @@ func (cr *ChargeRater) ChargedEnergy() (float64, error) { // get current energy amount if m, ok := api.Cap[api.MeterEnergy](cr.meter); ok { f, err := m.TotalEnergy() - if err == nil { - return cr.chargedEnergy + f - cr.startEnergy, nil + if err != nil { + return 0, fmt.Errorf("charge total import: %v", err) } - return 0, fmt.Errorf("charge total import: %v", err) + // late-latch baseline if StartCharge could not read TotalEnergy + // (e.g. OCPP transaction recovery before first MeterValues frame) + if cr.startEnergy == nil { + cr.startEnergy = &f + cr.log.DEBUG.Printf("charge start energy: %.3fkWh", f) + } + + return cr.chargedEnergy + f - *cr.startEnergy, nil } // return charged energy sofar if meter is not used diff --git a/core/wrapper/chargerater_test.go b/core/wrapper/chargerater_test.go index 8af799d1a..9bc6c7628 100644 --- a/core/wrapper/chargerater_test.go +++ b/core/wrapper/chargerater_test.go @@ -1,6 +1,7 @@ package wrapper import ( + "errors" "testing" "time" @@ -110,3 +111,60 @@ func TestWrappedMeter(t *testing.T) { t.Errorf("energy: %.1f %v", f, err) } } + +// TestDeferredBaseline covers the OCPP transaction-recovery case: the meter is +// not yet readable when StartCharge fires, so the baseline must be latched on +// the first successful TotalEnergy() read instead of defaulting to zero +// (which would cause the lifetime register to be reported as session energy). +func TestDeferredBaseline(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mm := api.NewMockMeter(ctrl) + me := api.NewMockMeterEnergy(ctrl) + + type EnergyDecorator struct { + api.Meter + api.MeterEnergy + } + + cm := &EnergyDecorator{Meter: mm, MeterEnergy: me} + + cr := NewChargeRater(util.NewLogger("foo"), cm) + clck := clock.NewMock() + cr.clck = clck + + // meter not yet available at StartCharge — recovered transaction before first MeterValues + me.EXPECT().TotalEnergy().Return(0.0, errors.New("not available")) + + cr.StartCharge(true) + + // first read also fails — must surface the error, not a bogus delta + me.EXPECT().TotalEnergy().Return(0.0, errors.New("not available")) + + if _, err := cr.ChargedEnergy(); err == nil { + t.Errorf("expected error while meter unavailable") + } + + // first successful read latches the baseline (lifetime register, e.g. 939 kWh) + me.EXPECT().TotalEnergy().Return(939.080, nil) + + if f, err := cr.ChargedEnergy(); f != 0 || err != nil { + t.Errorf("expected 0 on baseline-latch read, got %.3f %v", f, err) + } + + // subsequent reads return delta against the latched baseline + me.EXPECT().TotalEnergy().Return(942.080, nil) + + if f, err := cr.ChargedEnergy(); f != 3 || err != nil { + t.Errorf("expected 3kWh delta, got %.3f %v", f, err) + } + + me.EXPECT().TotalEnergy().Return(944.080, nil) + + cr.StopCharge() + + if f, err := cr.ChargedEnergy(); f != 5 || err != nil { + t.Errorf("final energy: %.1f %v", f, err) + } +} From 2c149b908e5f38ee86de2305231df29a7b2fd365 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 5 May 2026 18:11:05 +0200 Subject: [PATCH 0003/1128] Fix comment-only yaml in device config validation (#29673) --- server/http_config_helper.go | 4 ++-- util/error_test.go | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/server/http_config_helper.go b/server/http_config_helper.go index 06cd9797b..846a17da9 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -466,9 +466,9 @@ func decodeDeviceConfig(r io.Reader) (configReq, error) { return configReq{}, errors.New("invalid config: cannot mix yaml and other") } - // validate yaml syntax + // validate yaml syntax; tolerate whitespace/comment-only input var tmp map[string]any - if err := yaml.Unmarshal([]byte(res.Yaml), &tmp); err != nil && err != io.EOF { + if err := yaml.Unmarshal([]byte(res.Yaml), &tmp); err != nil && !strings.Contains(err.Error(), "no documents in stream") { return configReq{}, err } diff --git a/util/error_test.go b/util/error_test.go index b62222d83..f8c0b9ac4 100644 --- a/util/error_test.go +++ b/util/error_test.go @@ -13,6 +13,21 @@ func TestYamlFloat(t *testing.T) { require.NoError(t, yaml.Unmarshal([]byte(b), &res)) } +func TestYamlEmpty(t *testing.T) { + var res map[string]any + err := yaml.Unmarshal([]byte(""), &res) + require.ErrorContains(t, err, "no documents in stream") +} + +func TestYamlCommentsOnly(t *testing.T) { + b := `# just a comment +# another comment +` + var res map[string]any + err := yaml.Unmarshal([]byte(b), &res) + require.ErrorContains(t, err, "no documents in stream") +} + func TestYamlError(t *testing.T) { b := `block: data: foo From abe86f666aa75236d1436d8a4a0b4ccf3574adea Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 5 May 2026 19:18:12 +0200 Subject: [PATCH 0004/1128] infra: fix demo deploy --- packaging/fly.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/fly.toml b/packaging/fly.toml index 8da38149d..9ea70cc11 100644 --- a/packaging/fly.toml +++ b/packaging/fly.toml @@ -9,7 +9,7 @@ image = "evcc/evcc:latest" [experimental] auto_rollback = true -cmd = ["evcc", "--demo", "--metrics", "--profile", "--mcp"] +cmd = ["evcc", "--demo", "--metrics", "--profile"] [[services]] http_checks = [] From efe8ee75280db0c62f0b204ef133eb02d769fe06 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 5 May 2026 22:02:59 +0200 Subject: [PATCH 0005/1128] Energyflow: show all loadpoints (#29677) --- .../Energyflow/Energyflow.stories.ts | 84 +++++++++++++++---- .../js/components/Energyflow/Energyflow.vue | 6 +- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/assets/js/components/Energyflow/Energyflow.stories.ts b/assets/js/components/Energyflow/Energyflow.stories.ts index 34af80544..05695887c 100644 --- a/assets/js/components/Energyflow/Energyflow.stories.ts +++ b/assets/js/components/Energyflow/Energyflow.stories.ts @@ -26,7 +26,7 @@ GridAndPV.args = { { icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 1000, connected: true, vehicleName: "", @@ -36,7 +36,7 @@ GridAndPV.args = { { icon: "bike", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 1000, connected: true, vehicleName: "", @@ -46,7 +46,7 @@ GridAndPV.args = { { icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 2200, connected: true, vehicleName: "", @@ -68,6 +68,54 @@ GridAndPV.args = { }, } as any; +export const LoadpointCharging = Template.bind({}); +LoadpointCharging.args = { + gridConfigured: true, + pvConfigured: true, + pvPower: 0, + gridPower: 4200, + homePower: 800, + loadpoints: [ + { + icon: "heatpump", + displayTitle: "Heizung", + charging: false, + enabled: false, + connected: true, + chargePower: 600, + vehicleName: "", + vehicleSoc: 61.4, + chargerIcon: "heatpump", + chargerFeatureHeating: true, + }, + { + icon: "car", + displayTitle: "blue Honda", + charging: true, + enabled: true, + connected: true, + chargePower: 2800, + vehicleName: "honda", + vehicleSoc: 42, + chargerFeatureHeating: false, + }, + { + icon: "car", + displayTitle: "white Lotus", + charging: false, + enabled: false, + connected: false, + chargePower: 0, + vehicleName: "lotus", + vehicleSoc: 0, + chargerFeatureHeating: false, + }, + ], + tariffGrid: 0.25, + currency: CURRENCY.EUR, + pv: [], +} as any; + function hoursFromNow(h: number): string { return new Date(Date.now() + h * 60 * 60 * 1000).toISOString(); } @@ -173,7 +221,7 @@ BatteryCharging.args = { { icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 1400, connected: true, vehicleName: "", @@ -208,7 +256,7 @@ BatteryThresholds.args = { { icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 5000, connected: true, vehicleName: "", @@ -218,7 +266,7 @@ BatteryThresholds.args = { { icon: "bus", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 2500, connected: true, vehicleName: "", @@ -242,7 +290,7 @@ PVThresholds.args = { { icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 5000, connected: true, vehicleName: "", @@ -252,7 +300,7 @@ PVThresholds.args = { { icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 1600, connected: true, vehicleName: "", @@ -276,7 +324,7 @@ GridOnly.args = { { icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 5500, connected: true, vehicleName: "", @@ -286,7 +334,7 @@ GridOnly.args = { { icon: "car", charging: false, - title: "Garage", + displayTitle: "Garage", chargePower: 0, connected: false, vehicleName: "", @@ -296,7 +344,7 @@ GridOnly.args = { { icon: "car", charging: false, - title: "Garage", + displayTitle: "Garage", chargePower: 0, connected: false, vehicleName: "", @@ -306,7 +354,7 @@ GridOnly.args = { { icon: "car", charging: false, - title: "Garage", + displayTitle: "Garage", chargePower: 0, connected: false, vehicleName: "", @@ -344,7 +392,7 @@ CO2.args = { { icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 1000, connected: true, vehicleName: "", @@ -354,7 +402,7 @@ CO2.args = { { icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 1000, connected: true, vehicleName: "", @@ -364,7 +412,7 @@ CO2.args = { { icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 2200, connected: true, vehicleName: "", @@ -390,7 +438,7 @@ UnknownInput.args = { { icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", chargePower: 1000, connected: true, vehicleName: "", @@ -422,7 +470,7 @@ UnknownOutput.args = { chargePower: 1700, icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", connected: true, vehicleName: "", vehicleSoc: 50, @@ -442,7 +490,7 @@ UnknownOutputLessThan10Percent.args = { chargePower: 1800, icon: "car", charging: true, - title: "Garage", + displayTitle: "Garage", connected: true, vehicleName: "", vehicleSoc: 50, diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 49fd96245..832a5369a 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -229,9 +229,9 @@ @details-clicked="toggleCo2" @toggle="toggleLoadpoints" > - Date: Sat, 6 Jun 2026 10:25:35 +0200 Subject: [PATCH 0266/1128] Charger (Plugchoice): trim whitespace before parsing values (#30559) --- charger/plugchoice.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/charger/plugchoice.go b/charger/plugchoice.go index 2478f5414..862722016 100644 --- a/charger/plugchoice.go +++ b/charger/plugchoice.go @@ -247,12 +247,15 @@ func (c *Plugchoice) CurrentPower() (float64, error) { return 0, err } + // the API may return values with surrounding whitespace (e.g. " 0.0") + kwVal := strings.TrimSpace(res.KW) + // Handle the case where power value is "-" - if res.KW == "-" { + if kwVal == "-" { return 0, nil } - kw, err := strconv.ParseFloat(res.KW, 64) + kw, err := strconv.ParseFloat(kwVal, 64) if err != nil { return 0, err } @@ -271,6 +274,8 @@ func (c *Plugchoice) Currents() (float64, float64, float64, error) { // Helper function to parse current values, handling "-" as 0 parsePhaseValue := func(val string, phase string) (float64, error) { + // the API may return values with surrounding whitespace (e.g. " 0.0") + val = strings.TrimSpace(val) if val == "-" { return 0, nil } From ead9296337a1811758b313c35c276e86eee22f86 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sat, 6 Jun 2026 11:14:46 +0200 Subject: [PATCH 0267/1128] Add long-lived api key (BC) (#29431) --- AGENTS.md | 2 + assets/css/app.css | 2 +- .../components/Config/BackupRestoreModal.vue | 22 +- assets/js/components/Config/GeneralConfig.vue | 11 +- .../Config/Security/ApiKeyModal.vue | 211 +++++++++++ .../Config/Security/SecurityModal.vue | 98 +++++ .../components/Helper/PlaceholderButton.vue | 45 +++ assets/js/utils/native.ts | 6 +- assets/js/views/Config.vue | 11 +- core/keys/auth.go | 1 + docs/agents/api-security.md | 116 ++++++ docs/agents/web-ui-api.md | 8 +- i18n/de.json | 23 ++ i18n/en.json | 24 +- server/http.go | 24 +- server/http_auth.go | 113 +++++- server/http_site_handler.go | 53 +-- server/mcp/openapi.json | 358 ++++++++++++++++++ server/mcp/openapi.md | 128 +++++++ server/openapi.yaml | 226 +++++++++++ tests/api-key.spec.ts | 176 +++++++++ tests/auth.spec.ts | 16 +- tests/backup-restore.spec.ts | 7 +- util/auth/auth.go | 56 ++- util/auth/auth_test.go | 32 +- 25 files changed, 1657 insertions(+), 112 deletions(-) create mode 100644 assets/js/components/Config/Security/ApiKeyModal.vue create mode 100644 assets/js/components/Config/Security/SecurityModal.vue create mode 100644 assets/js/components/Helper/PlaceholderButton.vue create mode 100644 docs/agents/api-security.md create mode 100644 tests/api-key.spec.ts diff --git a/AGENTS.md b/AGENTS.md index d28bf397e..38fffe45b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w | [Easee Architecture](docs/agents/easee-architecture.md) | Easee charger (REST+SignalR, async correlation, concurrency) | | [Plugin System](docs/agents/plugin-system.md) | Plugin layer (HTTP, MQTT, Modbus, SunSpec, JS) | | [Web UI & API](docs/agents/web-ui-api.md) | REST API, WebSocket, Vue frontend, authentication | +| [API Security](docs/agents/api-security.md) | Auth modes, JWT/API key/session, two-tier checks, credential storage | ### Loading guide by task type @@ -44,6 +45,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w - **Vehicle implementation** — hardware-integrations - **UI/frontend work** — web-ui-api - **API endpoint work** — web-ui-api + core-domain +- **Auth / login / API key / permissions** — api-security + web-ui-api - **Config/template work** — plugin-system - **Control loop / charging logic** — core-domain - **Bug in any area** — core-domain + relevant topic file(s) diff --git a/assets/css/app.css b/assets/css/app.css index b3f0acf97..944048c48 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -214,7 +214,7 @@ a:hover { } .btn { - --bs-btn-border-width: 2px; + --bs-btn-border-width: 1px; } .btn-reset { diff --git a/assets/js/components/Config/BackupRestoreModal.vue b/assets/js/components/Config/BackupRestoreModal.vue index 67d673c02..1beea8c5b 100644 --- a/assets/js/components/Config/BackupRestoreModal.vue +++ b/assets/js/components/Config/BackupRestoreModal.vue @@ -297,16 +297,13 @@ export default defineComponent({ return r; }, async downloadBackup() { - if (dispatchDownload("/api/system/backup", "POST", { password: this.password })) { + const headers = { "X-Admin-Password": this.password }; + if (dispatchDownload("/api/db/backup", headers)) { this.closeConfirmModal(); return; } const res = await this.call( - api.post( - "/system/backup", - { password: this.password }, - { responseType: "blob", validateStatus } - ) + api.get("/db/backup", { headers, responseType: "blob", validateStatus }) ); if (res) { this.closeConfirmModal(); @@ -314,11 +311,13 @@ export default defineComponent({ } }, async restoreDatabase() { + const headers = { "X-Admin-Password": this.password }; const formData = new FormData(); - formData.append("password", this.password); formData.append("file", this.file!); - const res = await this.call(api.post("/system/restore", formData, { validateStatus })); + const res = await this.call( + api.post("/db/restore", formData, { headers, validateStatus }) + ); if (res) { this.hideBackupRestoreModal = true; @@ -328,12 +327,9 @@ export default defineComponent({ } }, async resetDatabase() { + const headers = { "X-Admin-Password": this.password }; const res = await this.call( - api.post( - "/system/reset", - { password: this.password, ...this.selectedReset }, - { validateStatus } - ) + api.post("/db/reset", this.selectedReset, { headers, validateStatus }) ); if (res) { diff --git a/assets/js/components/Config/GeneralConfig.vue b/assets/js/components/Config/GeneralConfig.vue index 9939f537e..8f6b98782 100644 --- a/assets/js/components/Config/GeneralConfig.vue +++ b/assets/js/components/Config/GeneralConfig.vue @@ -9,10 +9,10 @@ + +
+ {{ $t("config.security.authDisabledHint") }} +
+ + + +
+

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

+ +
+ + +

{{ passwordError }}

+
+ +
+ +
+
+ + +
+ + + + + diff --git a/assets/js/components/Config/Security/SecurityModal.vue b/assets/js/components/Config/Security/SecurityModal.vue new file mode 100644 index 000000000..df303daa5 --- /dev/null +++ b/assets/js/components/Config/Security/SecurityModal.vue @@ -0,0 +1,98 @@ + + + diff --git a/assets/js/components/Helper/PlaceholderButton.vue b/assets/js/components/Helper/PlaceholderButton.vue new file mode 100644 index 000000000..eb2ce2c0d --- /dev/null +++ b/assets/js/components/Helper/PlaceholderButton.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/assets/js/utils/native.ts b/assets/js/utils/native.ts index a93b334fd..b378a84a8 100644 --- a/assets/js/utils/native.ts +++ b/assets/js/utils/native.ts @@ -15,7 +15,7 @@ export function hasAppCapability(capability: string): boolean { type AppMessage = | { type: "online" | "offline" | "settings" } - | { type: "download"; url: string; method?: string; body?: unknown }; + | { type: "download"; url: string; headers?: Record }; export function sendToApp(data: AppMessage) { window.ReactNativeWebView?.postMessage(JSON.stringify(data)); @@ -27,9 +27,9 @@ export function handleDownloadClick(event: Event, url: string) { } } -export function dispatchDownload(url: string, method?: string, body?: unknown): boolean { +export function dispatchDownload(url: string, headers?: Record): boolean { if (!hasAppCapability("download")) return false; const absolute = new URL(url, window.location.href).toString(); - sendToApp({ type: "download", url: absolute, method, body }); + sendToApp({ type: "download", url: absolute, headers }); return true; } diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 6bab9c62d..d858b4071 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -451,6 +451,8 @@ /> + + @@ -549,6 +551,8 @@ import BackupRestoreModal from "@/components/Config/BackupRestoreModal.vue"; import WelcomeBanner from "../components/Config/WelcomeBanner.vue"; import AuthSuccessBanner from "../components/Config/AuthSuccessBanner.vue"; import PasswordModal from "../components/Auth/PasswordModal.vue"; +import SecurityModal from "../components/Config/Security/SecurityModal.vue"; +import ApiKeyModal from "../components/Config/Security/ApiKeyModal.vue"; import AuthProvidersCard from "../components/Config/AuthProvidersCard.vue"; export default defineComponent({ @@ -606,6 +610,8 @@ export default defineComponent({ WelcomeBanner, AuthSuccessBanner, PasswordModal, + SecurityModal, + ApiKeyModal, AuthProvidersCard, }, mixins: [formatter, collector], @@ -873,9 +879,12 @@ export default defineComponent({ Object.values(store.state.messagingEvents ?? {}).some((e) => !e.disabled) ); }, + authDisabled() { + return store.state?.authDisabled || false; + }, backupRestoreProps() { return { - authDisabled: store.state?.authDisabled || false, + authDisabled: this.authDisabled, }; }, circuitsRoot() { diff --git a/core/keys/auth.go b/core/keys/auth.go index 0cd323617..d742c4014 100644 --- a/core/keys/auth.go +++ b/core/keys/auth.go @@ -3,4 +3,5 @@ package keys const ( AdminPassword = "adminPassword" JwtSecret = "jwtSecretKey" + ApiKey = "apiKey" ) diff --git a/docs/agents/api-security.md b/docs/agents/api-security.md new file mode 100644 index 000000000..519d577c6 --- /dev/null +++ b/docs/agents/api-security.md @@ -0,0 +1,116 @@ +# API Security & Authentication + +How evcc authenticates HTTP requests and how endpoints are classified by +sensitivity. + +## Threat Model + +evcc is designed for use within a trusted home network. The auth layer +protects credential management, configuration changes, and system operations +(logs, backup/restore/reset, shutdown). Read-only state and basic charging +controls are intentionally unauthenticated. + +## Auth Modes + +| Mode | Trigger | Behavior | +|------------|-----------------------|---------------------------------------------------| +| `Enabled` | default | password required; JWT or API key accepted | +| `Disabled` | `--disable-auth` flag | all auth checks skipped | +| `Locked` | demo mode | mutating endpoints return 403; reads still work | + +Mode is fixed at startup. The frontend mirrors the mode so admin actions can +be greyed out and a banner shown. + +## Endpoint Sensitivity Tiers + +Three tiers, by what the caller has to prove: + +**Public.** No auth. State, loadpoint controls, login. Anyone on the +network can read and operate. + +**Secure.** Requires a valid session: either the auth cookie (browser, JWT) +or an API key in the `Authorization: Bearer …` header (automation). Used +for configuration and system administration. + +**Critical.** Secure plus an additional admin-password check inside the +handler. Used for destructive or credential-scoped operations. + +For some Critical endpoints (backup, restore, reset) the password check is +**skipped when the caller is authenticated via API key**, so unattended automation +doesn't need to embed the admin password. For credential-management +endpoints (rotate API key, change admin password) the password check is +**strict**: a leaked API key must not be able to rotate itself or change +the admin password. + +Disabling auth short-circuits all checks. + +## Sessions + +Two transports, no overlap: + +- Browsers use a session cookie (JWT, 90-day TTL, issued on login). +- Automation uses an API key in the `Authorization: Bearer …` header. + +API keys are random alphanumeric strings prefixed `evcc_`. The prefix makes +leaked keys recognizable to secret-scanning tools. + +A single API key per installation; regenerating replaces the previous one. +Plaintext is shown to the user **once** at generation time and cannot be +retrieved afterwards. + +## Credential Storage + +Admin password and API key are stored as bcrypt hashes. The JWT signing +secret is a per-installation random value. Plaintext credentials are never +persisted. + +Removing the admin password (CLI recovery) also clears the JWT secret and the +API key, which invalidates all outstanding sessions and any previously-issued +API key. Regenerating the API key replaces the stored hash; the previous key +stops working immediately. + +## API Key Lifecycle + +Two operations: + +- **Status.** Whether a key is configured. Secure tier; never returns + plaintext. +- **Regenerate.** Critical tier, strict password check. Returns the new + plaintext key exactly once. + +There is no delete operation: regenerating and discarding the new key +achieves the same effect (the previous key stops working immediately). + +## Endpoint Matrix + +| Endpoint category | Tier | Additional Requirements | +|----------------------------------------------|-----------|------------------------------------| +| State / read-only / basic charging control | Public | | +| Set or update admin password | Public | admin password | +| Configuration | Secure | | +| System: logs, cache, shutdown | Secure | | +| API key status | Secure | | +| System: backup / restore / reset | Critical | api key or admin password | +| API key regenerate | Critical | admin password | + +**Public** endpoints accept any caller. **Secure** endpoints require a +valid session (cookie or API key). **Critical** endpoints require extra +authentication in the form of an admin password (or, for some, an API +key). + +## Frontend + +Auth management lives under **General Config → Security**, which links to +two sub-flows: change admin password, and manage the API key. The API key +flow has a reveal view that shows the plaintext exactly once with a +copy-to-clipboard link. + +When auth is disabled, the security modals show a warning banner and +disable all action buttons. This is UI-only; the backend still accepts the +underlying calls so legitimate automation against a disabled-auth instance +keeps working. + +## OpenAPI + +The OpenAPI spec declares two security schemes (cookie and bearer); +protected operations accept either. diff --git a/docs/agents/web-ui-api.md b/docs/agents/web-ui-api.md index fed6d2331..612e562cf 100644 --- a/docs/agents/web-ui-api.md +++ b/docs/agents/web-ui-api.md @@ -33,7 +33,11 @@ - `GET /config/evcc.yaml` — YAML export ### System (`/system/...`, auth required) -- Log viewing, cache clear, DB backup/restore/reset, shutdown +- Log viewing (`/log`, `/log/areas`), cache clear, shutdown + +### Database (`/db/...`, auth + second factor required) +- Backup download, restore from file, selective reset +- Second factor: admin password in request body, or API key via Bearer token (bypasses password check) ### Handler Pattern Generic `handler[T]` with type conversion, setter, getter. @@ -65,7 +69,7 @@ Specialized: `floatHandler`, `intHandler`, `boolHandler`, `durationHandler`. - HttpOnly cookie (`auth`) with `SameSite=Strict` - Also accepts `Authorization: Bearer ` header - Modes: Disabled, Locked (demo), Configured (password) -- Protects `/api/config` and `/api/system` +- Protects `/api/config`, `/api/system`, and `/api/db` ## MQTT Integration diff --git a/i18n/de.json b/i18n/de.json index b9c5fc0b8..d07d8fb52 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -51,6 +51,21 @@ "hex": "Hex-Farbe" }, "config": { + "apiKey": { + "description": "Gibt Skripten und Automatisierungsaufgaben wie geplanten Backups sicheren Zugriff, ohne dein Administrator-Passwort zu teilen.", + "exampleLabel": "Ausprobieren: Backup mit curl herunterladen", + "generate": "API-Schlüssel erstellen", + "generateConfirm": "Gib dein Administrator-Passwort ein, um einen neuen API-Schlüssel zu erstellen.", + "keyLabel": "API-Schlüssel", + "regenerate": "API-Schlüssel neu erstellen", + "regenerateConfirm": "Gib dein Administrator-Passwort ein, um den API-Schlüssel neu zu erstellen.", + "regenerateLink": "Neu erstellen", + "regenerateWarning": "Beim Neuerstellen wird der bestehende Schlüssel ungültig und Automatisierungen, die ihn nutzen, funktionieren nicht mehr. Fortfahren?", + "revealSuccess": "Dein neuer API-Schlüssel ist einsatzbereit.", + "revealTitle": "API-Schlüssel erstellt", + "shownOnce": "Dieser Schlüssel wird nur einmal angezeigt. Bewahre ihn sicher auf. Du kannst ihn später nicht erneut abrufen.", + "title": "API-Schlüssel" + }, "aux": { "description": "Gerät, das seinen Verbrauch basierend auf verfügbarem Überschuss (z. B. smarter Heizstab) selbstständig anpasst. evcc erwartet, dass dieses Gerät selbstständig seine Leistungsaufnahme reduziert, wenn es notwendig ist.", "titleAdd": "Intelligenten Verbraucher hinzufügen", @@ -695,6 +710,14 @@ "system": "System", "vehicles": "Fahrzeuge" }, + "security": { + "authDisabledHint": "Authentifizierung ist deaktiviert. Anmeldedaten können nicht verwaltet werden.", + "description": "Verwalte Authentifizierung und Anmeldedaten dieser Instanz.", + "passwordDescription": "Aktualisiere das Passwort, mit dem du dich an der Konfigurationsoberfläche anmeldest.", + "passwordTitle": "Administrator-Passwort", + "title": "Sicherheit", + "updatePassword": "Passwort ändern" + }, "shm": { "cardTitle": "Sunny Home Manager", "description": "evcc ist mit einer Integration für den SMA Sunny Home Manager (SHM) mittels SEMP-Protokoll ausgestattet. Wenn dieser im selben Netzwerk läuft, sollte dir nach der Anmeldung in deinem Sunny Portal-Konto automatisch angeboten werden, alle in evcc konfigurierten Ladepunkte als neu erkannte Verbraucher hinzuzufügen. Alles sollte sofort einsatzbereit sein, ohne dass hier weitere Anpassungen erforderlich sind.", diff --git a/i18n/en.json b/i18n/en.json index 95624ac96..3f70071b1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -51,6 +51,21 @@ "hex": "Hex color" }, "config": { + "apiKey": { + "description": "Give scripts and automation tasks like scheduled backups secure access without sharing your admin password.", + "exampleLabel": "Try it: download a backup with curl", + "generate": "Generate API Key", + "generateConfirm": "Enter your admin password to generate a new API key.", + "keyLabel": "API Key", + "regenerate": "Regenerate API Key", + "regenerateConfirm": "Enter your admin password to regenerate the API key.", + "regenerateLink": "Regenerate", + "regenerateWarning": "Regenerating will invalidate the existing key and any automation using it will stop working. Continue?", + "revealSuccess": "Your new API key is ready to use.", + "revealTitle": "API Key Created", + "shownOnce": "This key is shown only once. Store it somewhere safe. You cannot retrieve it later.", + "title": "API Key" + }, "aux": { "description": "Device that adjusts its consumption based on available surplus (like smart water heaters). evcc expects that this device reduces its power consumption if needed.", "titleAdd": "Add Self-Regulating Consumer", @@ -260,7 +275,6 @@ "noFileSelected": "No file selected.", "off": "off", "on": "on", - "password": "Password", "readFromFile": "Read from file", "remove": "Remove", "required": "required", @@ -695,6 +709,14 @@ "system": "System", "vehicles": "Vehicles" }, + "security": { + "authDisabledHint": "Authentication is disabled. Credential management is unavailable.", + "description": "Manage authentication and credentials for this instance.", + "passwordDescription": "Update the password used to log in to the configuration UI.", + "passwordTitle": "Admin password", + "title": "Security", + "updatePassword": "Update password" + }, "shm": { "cardTitle": "Sunny Home Manager", "description": "evcc is equipped with integration for the SMA Sunny Home Manager (SHM) via SEMP protocol. If it is running on the same network, after logging into your Sunny Portal account, you should automatically be offered to add all chargers configured in evcc as newly discovered consumers. Everything should be ready to use immediately, without any adjustments required below.", diff --git a/server/http.go b/server/http.go index 9c71d21d3..264530b40 100644 --- a/server/http.go +++ b/server/http.go @@ -275,6 +275,11 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti for _, r := range routes { api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc) } + + // API key endpoints require an authenticated session. + ensureAuth := ensureAuthHandler(auth) + api.Methods("GET").Path("/apikey").Handler(ensureAuth(apiKeyStatusHandler(auth))) + api.Methods("POST").Path("/apikey").Handler(ensureAuth(regenerateApiKeyHandler(auth))) } { // api/config @@ -374,14 +379,10 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti api := api.PathPrefix("/system").Subrouter() api.Use(ensureAuthHandler(auth)) - // system api routes := map[string]route{ "log": {"GET", "/log", logHandler}, "logareas": {"GET", "/log/areas", logAreasHandler}, "clearcache": {"DELETE", "/cache", clearCacheHandler}, - "backup": {"POST", "/backup", getBackup(auth)}, - "restore": {"POST", "/restore", restoreDatabase(auth, shutdown)}, - "reset": {"POST", "/reset", resetDatabase(auth, shutdown)}, "shutdown": {"POST", "/shutdown", func(w http.ResponseWriter, r *http.Request) { shutdown() w.WriteHeader(http.StatusNoContent) @@ -392,4 +393,19 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc) } } + + { // api/db — destructive DB operations; require session+X-Admin-Password or API key + api := api.PathPrefix("/db").Subrouter() + api.Use(ensureDbAuth(auth)) + + routes := map[string]route{ + "backup": {"GET", "/backup", getBackup()}, + "restore": {"POST", "/restore", restoreDatabase(shutdown)}, + "reset": {"POST", "/reset", resetDatabase(shutdown)}, + } + + for _, r := range routes { + api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc) + } + } } diff --git a/server/http_auth.go b/server/http_auth.go index 4b89a8f26..04cf7c936 100644 --- a/server/http_auth.go +++ b/server/http_auth.go @@ -66,22 +66,38 @@ func updatePasswordHandler(authObject auth.Auth) http.HandlerFunc { } } -// read jwt from header and cookie -func jwtFromRequest(r *http.Request) string { - // read from header - authHeader := r.Header.Get("Authorization") - if token, ok := strings.CutPrefix(authHeader, "Bearer "); ok { - return token - } +// apiKeyFromRequest returns the API key from the Authorization: Bearer header, or "" if absent +func apiKeyFromRequest(r *http.Request) string { + token, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + return token +} - // read from cookie +// jwtFromCookie returns the session JWT from the auth cookie, or "" if absent +func jwtFromCookie(r *http.Request) string { if cookie, _ := r.Cookie(authCookieName); cookie != nil { return cookie.Value } - return "" } +// validateAuth accepts a valid API key from the Authorization header, or a valid session JWT from the auth cookie +func validateAuth(authObject auth.Auth, r *http.Request) bool { + if key := apiKeyFromRequest(r); key != "" { + return authObject.ValidateApiKey(key) + } + return authObject.ValidateJwtToken(jwtFromCookie(r)) +} + +// requireAdminPassword passes when --disable-auth is set or the supplied password matches. +// Writes 401 and returns false otherwise. +func requireAdminPassword(w http.ResponseWriter, authObject auth.Auth, password string) bool { + if authObject.GetAuthMode() == auth.Disabled || authObject.IsAdminPasswordValid(password) { + return true + } + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return false +} + // authStatusHandler login status (true/false) based on jwt token. Error if admin password is not configured func authStatusHandler(authObject auth.Auth) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -101,12 +117,11 @@ func authStatusHandler(authObject auth.Auth) http.HandlerFunc { } w.Header().Set("Content-Type", "application/json") - ok, err := authObject.ValidateJwtToken(jwtFromRequest(r)) - if err != nil || !ok { + if validateAuth(authObject, r) { + w.Write([]byte("true")) + } else { w.Write([]byte("false")) - return } - w.Write([]byte("true")) } } @@ -179,14 +194,78 @@ func ensureAuthHandler(authObject auth.Auth) mux.MiddlewareFunc { return } - // check jwt token - ok, err := authObject.ValidateJwtToken(jwtFromRequest(r)) - if !ok || err != nil { + if !validateAuth(authObject, r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func apiKeyStatusHandler(authObject auth.Auth) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + jsonWrite(w, map[string]bool{"configured": authObject.IsApiKeyConfigured()}) + } +} + +// regenerateApiKeyHandler creates or rotates the API key. Requires the admin password (a leaked API key cannot self-rotate) +func regenerateApiKeyHandler(authObject auth.Auth) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !requireAdminPassword(w, authObject, req.Password) { + return + } + + key, err := authObject.SetApiKey() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + jsonWrite(w, map[string]string{"key": key}) + } +} + +// ensureDbAuth guards /db/ endpoints: API key Bearer passes directly; +// session users must also supply the admin password in X-Admin-Password header. +func ensureDbAuth(authObject auth.Auth) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if authObject.GetAuthMode() == auth.Disabled { + next.ServeHTTP(w, r) + return + } + + if authObject.GetAuthMode() == auth.Locked { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if key := apiKeyFromRequest(r); key != "" { + if authObject.ValidateApiKey(key) { + next.ServeHTTP(w, r) + return + } + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if !authObject.ValidateJwtToken(jwtFromCookie(r)) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if !authObject.IsAdminPasswordValid(r.Header.Get("X-Admin-Password")) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - // all clear, continue next.ServeHTTP(w, r) }) } diff --git a/server/http_site_handler.go b/server/http_site_handler.go index 2cd0dd9de..dfd705e8e 100644 --- a/server/http_site_handler.go +++ b/server/http_site_handler.go @@ -20,7 +20,6 @@ import ( "github.com/evcc-io/evcc/server/db" "github.com/evcc-io/evcc/server/db/settings" "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/auth" "github.com/evcc-io/evcc/util/encode" "github.com/evcc-io/evcc/util/jq" "github.com/evcc-io/evcc/util/logstash" @@ -317,24 +316,8 @@ func logHandler(w http.ResponseWriter, r *http.Request) { jsonWrite(w, log) } -// adminPasswordValid validates the admin password and returns true if valid -func adminPasswordValid(authObject auth.Auth, password string) bool { - return authObject.GetAuthMode() == auth.Disabled || authObject.IsAdminPasswordValid(password) -} - -func getBackup(authObject auth.Auth) http.HandlerFunc { +func getBackup() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req loginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if !adminPasswordValid(authObject, req.Password) { - http.Error(w, "Invalid password", http.StatusUnauthorized) - return - } - if err := settings.Persist(); err != nil { http.Error(w, "Synching DB failed", http.StatusInternalServerError) return @@ -378,7 +361,7 @@ func createLocalDatabaseBackup(ctx context.Context) error { return db.Backup(ctx, db.FilePath()+".bak") } -func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc { +func restoreDatabase(shutdown func()) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // cap upload size to bound disk usage r.Body = http.MaxBytesReader(w, r.Body, 256<<20) @@ -389,10 +372,7 @@ func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc { return } - var ( - password string - tmpName string - ) + var tmpName string for { part, err := mr.NextPart() @@ -405,15 +385,6 @@ func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc { } switch part.FormName() { - case "password": - b, err := io.ReadAll(io.LimitReader(part, 1<<10)) - part.Close() - if err != nil { - http.Error(w, "Upload failed", http.StatusBadRequest) - return - } - password = string(b) - case "file": tmpFile, err := os.CreateTemp("", "evcc-restore-*.db") if err != nil { @@ -437,11 +408,6 @@ func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc { } } - if !adminPasswordValid(authObject, password) { - http.Error(w, "Invalid password", http.StatusUnauthorized) - return - } - if tmpName == "" { http.Error(w, "Missing file", http.StatusBadRequest) return @@ -472,24 +438,17 @@ func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc { } } -func resetDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc { +func resetDatabase(shutdown func()) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { - Password string `json:"password"` - Sessions bool `json:"sessions"` - Settings bool `json:"settings"` + Sessions bool `json:"sessions"` + Settings bool `json:"settings"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, http.StatusBadRequest, err) return } - if !adminPasswordValid(authObject, req.Password) { - http.Error(w, "Invalid password", http.StatusUnauthorized) - return - } - settings.Persist() if err := createLocalDatabaseBackup(r.Context()); err != nil { diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index 1b32a4197..98a477bc5 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -1,6 +1,14 @@ { "components": { "parameters": { + "adminPassword": { + "description": "Admin password (required for session auth, not needed for API key)", + "in": "header", + "name": "X-Admin-Password", + "schema": { + "$ref": "#/components/schemas/Password" + } + }, "batteryMode": { "in": "path", "name": "batteryMode", @@ -749,6 +757,11 @@ } }, "securitySchemes": { + "bearerAuth": { + "description": "Long-lived API key (prefixed `evcc_`, managed via /auth/apikey).\nGrants access to /api/db/* without requiring the X-Admin-Password header.\n", + "scheme": "bearer", + "type": "http" + }, "cookieAuth": { "in": "cookie", "name": "auth", @@ -766,6 +779,100 @@ }, "openapi": "3.1.0", "paths": { + "/auth/apikey": { + "get": { + "description": "Reports whether an API key has been generated. The key itself is\nonly returned once at creation time (POST), never on subsequent\nreads.\n", + "operationId": "getApiKeyStatus", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "configured": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Success" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "API key status", + "tags": [ + "auth" + ] + }, + "post": { + "description": "Generates a fresh API key, replacing any existing one. The\nreturned key is shown only once; store it immediately.\n\nEven when the request is authenticated via API key (Bearer), the\nadmin password must be supplied in the request body to prevent a\nleaked key from rotating itself. The password check is skipped\nwhen the server is started with `--disable-auth`.\n", + "operationId": "regenerateApiKey", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "password": { + "$ref": "#/components/schemas/Password" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "key": { + "description": "The new API key (cleartext, shown once).", + "example": "evcc_aB3xYz7k0Pq2sN5mWtR4", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Success" + }, + "401": { + "description": "Invalid admin password" + }, + "403": { + "description": "Forbidden in demo mode" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Generate or rotate the API key", + "tags": [ + "auth" + ] + } + }, "/auth/login": { "post": { "description": "Administrator login. Returns authorization cookie required for all protected endpoints.", @@ -1033,6 +1140,150 @@ ] } }, + "/db/backup": { + "get": { + "description": "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.", + "operationId": "downloadBackup", + "parameters": [ + { + "$ref": "#/components/parameters/adminPassword" + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "SQLite database file" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Download database backup", + "tags": [ + "db" + ] + } + }, + "/db/reset": { + "post": { + "description": "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.", + "operationId": "resetDatabase", + "parameters": [ + { + "$ref": "#/components/parameters/adminPassword" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "sessions": { + "description": "Delete all charging sessions", + "type": "boolean" + }, + "settings": { + "description": "Delete all settings, configs, and meters", + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Reset successful, instance is restarting" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Reset database", + "tags": [ + "db" + ] + } + }, + "/db/restore": { + "post": { + "description": "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.", + "operationId": "restoreBackup", + "parameters": [ + { + "$ref": "#/components/parameters/adminPassword" + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "file": { + "description": "SQLite database backup file", + "format": "binary", + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Restore successful, instance is restarting" + }, + "400": { + "description": "Invalid file or missing fields" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Restore database backup", + "tags": [ + "db" + ] + } + }, "/gridsessions": { "get": { "description": "Returns a list of HEMS grid limitation events.", @@ -1089,6 +1340,95 @@ ] } }, + "/history/energy": { + "get": { + "description": "Returns aggregated energy history data. Aggregate granularity defaults to 15 minutes. Supports CSV export.", + "operationId": "getEnergyHistory", + "parameters": [ + { + "description": "Start time (RFC3339)", + "in": "query", + "name": "from", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "description": "End time (RFC3339)", + "in": "query", + "name": "to", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "description": "Aggregation interval. Examples: 15m, 1h, day, month", + "in": "query", + "name": "aggregate", + "schema": { + "default": "15m", + "type": "string" + } + }, + { + "description": "Group results by loadpoint", + "in": "query", + "name": "grouped", + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "description": "Response format", + "in": "query", + "name": "format", + "schema": { + "default": "json", + "enum": [ + "json", + "csv" + ], + "type": "string" + } + }, + { + "description": "Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header.", + "in": "query", + "name": "lang", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + }, + "text/csv": { + "schema": { + "type": "string" + } + } + }, + "description": "Energy history data" + }, + "400": { + "description": "Invalid parameters or database offline" + } + }, + "summary": "Energy history", + "tags": [ + "experimental" + ] + } + }, "/loadpoints/{id}/batteryboost/{enable}": { "post": { "description": "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).", @@ -2218,6 +2558,9 @@ "security": [ { "cookieAuth": [] + }, + { + "bearerAuth": [] } ], "summary": "Clear cache", @@ -2294,6 +2637,9 @@ "security": [ { "cookieAuth": [] + }, + { + "bearerAuth": [] } ], "summary": "Logs", @@ -2329,6 +2675,9 @@ "security": [ { "cookieAuth": [] + }, + { + "bearerAuth": [] } ], "summary": "List of all log areas", @@ -2352,6 +2701,9 @@ "security": [ { "cookieAuth": [] + }, + { + "bearerAuth": [] } ], "summary": "Shutdown evcc", @@ -2644,6 +2996,12 @@ { "name": "battery" }, + { + "name": "db" + }, + { + "name": "experimental" + }, { "name": "general" }, diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md index 12bea73bb..d576736ff 100644 --- a/server/mcp/openapi.md +++ b/server/mcp/openapi.md @@ -26,6 +26,15 @@ call changePassword { } ``` +## 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. @@ -58,6 +67,33 @@ 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 @@ -210,6 +246,98 @@ call setResidualPower { } ``` +## 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) | +| grouped | boolean | Group results by loadpoint | +| lang | string | Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header. | +| to | string | End time (RFC3339) | + +**Example call:** + +```json +call getEnergyHistory { + "aggregate": "example", + "format": "example", + "from": "example", + "grouped": true, + "lang": "example", + "to": "example" +} +``` + ## getState Returns the complete state of the system. This structure is used by the UI. It can be filtered by JQ to only return a subset of the data. diff --git a/server/openapi.yaml b/server/openapi.yaml index 6ccd7ee0c..4e51f4b1b 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -10,6 +10,8 @@ servers: tags: - name: auth - name: battery + - name: db + - name: experimental - name: general - name: loadpoints - name: sessions @@ -98,6 +100,72 @@ paths: enum: - "true" - "false" + /auth/apikey: + get: + operationId: getApiKeyStatus + summary: API key status + description: | + 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 + security: + - cookieAuth: [] + - bearerAuth: [] + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + configured: + type: boolean + "401": + $ref: "#/components/responses/Unauthorized" + post: + operationId: regenerateApiKey + summary: Generate or rotate the API key + description: | + 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 + security: + - cookieAuth: [] + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + password: + $ref: "#/components/schemas/Password" + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + key: + type: string + description: The new API key (cleartext, shown once). + example: evcc_aB3xYz7k0Pq2sN5mWtR4 + "401": + description: Invalid admin password + "403": + description: Forbidden in demo mode /batterydischargecontrol/{enable}: post: operationId: setBatteryDischargeControl @@ -896,6 +964,7 @@ paths: url: https://docs.evcc.io/en/docs/reference/configuration/log security: - cookieAuth: [] + - bearerAuth: [] tags: - system parameters: @@ -940,6 +1009,7 @@ paths: description: "Returns a list of all log areas (e.g. `lp-1`, `site`, `db`)." security: - cookieAuth: [] + - bearerAuth: [] tags: - system responses: @@ -961,6 +1031,7 @@ paths: description: "Clears all cached data. This resets all cached values from tariffs, vehicle APIs, and other components that use caching." security: - cookieAuth: [] + - bearerAuth: [] tags: - system responses: @@ -975,6 +1046,7 @@ paths: description: "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." security: - cookieAuth: [] + - bearerAuth: [] tags: - system responses: @@ -1149,6 +1221,148 @@ paths: properties: result: $ref: "#/components/schemas/PlanStrategy" + /db/backup: + get: + operationId: downloadBackup + summary: Download database backup + description: "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 + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/adminPassword" + responses: + "200": + description: SQLite database file + content: + application/octet-stream: + schema: + type: string + format: binary + "401": + $ref: "#/components/responses/Unauthorized" + /db/restore: + post: + operationId: restoreBackup + summary: Restore database backup + description: "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 + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/adminPassword" + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: binary + description: SQLite database backup file + responses: + "204": + description: Restore successful, instance is restarting + "400": + description: Invalid file or missing fields + "401": + $ref: "#/components/responses/Unauthorized" + /db/reset: + post: + operationId: resetDatabase + summary: Reset database + description: "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 + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/adminPassword" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + sessions: + type: boolean + description: Delete all charging sessions + settings: + type: boolean + description: Delete all settings, configs, and meters + responses: + "204": + description: Reset successful, instance is restarting + "401": + $ref: "#/components/responses/Unauthorized" + /history/energy: + get: + operationId: getEnergyHistory + summary: Energy history + description: "Returns aggregated energy history data. Aggregate granularity defaults to 15 minutes. Supports CSV export." + tags: + - experimental + parameters: + - name: from + in: query + description: Start time (RFC3339) + schema: + type: string + format: date-time + - name: to + in: query + description: End time (RFC3339) + schema: + type: string + format: date-time + - name: aggregate + in: query + description: "Aggregation interval. Examples: 15m, 1h, day, month" + schema: + type: string + default: 15m + - name: grouped + in: query + description: Group results by loadpoint + schema: + type: boolean + default: false + - name: format + in: query + description: Response format + schema: + type: string + enum: + - json + - csv + default: json + - name: lang + in: query + description: Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header. + schema: + type: string + responses: + "200": + description: Energy history data + content: + application/json: + schema: + type: object + text/csv: + schema: + type: string + "400": + description: Invalid parameters or database offline components: schemas: BatteryMode: @@ -1445,6 +1659,12 @@ components: minimum: 0 maximum: 6 parameters: + adminPassword: + name: X-Admin-Password + in: header + description: Admin password (required for session auth, not needed for API key) + schema: + $ref: "#/components/schemas/Password" id: name: id description: Loadpoint index starting at 1 @@ -1693,3 +1913,9 @@ components: type: apiKey in: cookie name: auth + bearerAuth: + type: http + scheme: bearer + description: | + Long-lived API key (prefixed `evcc_`, managed via /auth/apikey). + Grants access to /api/db/* without requiring the X-Admin-Password header. diff --git a/tests/api-key.spec.ts b/tests/api-key.spec.ts new file mode 100644 index 000000000..cd503e8a2 --- /dev/null +++ b/tests/api-key.spec.ts @@ -0,0 +1,176 @@ +import { test, expect, type Page, type Locator } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; +import { expectModalHidden, expectModalVisible } from "./utils"; + +test.use({ baseURL: baseUrl() }); + +const BASIC = "basics.evcc.yaml"; +const PASSWORD = "secret"; + +async function loginAndOpenApiKey(page: Page): Promise { + await page.goto("/#/config"); + + const loginModal = page.getByTestId("login-modal"); + await expectModalVisible(loginModal); + await loginModal.getByLabel("Administrator Password").fill(PASSWORD); + await loginModal.getByRole("button", { name: "Login" }).click(); + await expectModalHidden(loginModal); + + return openApiKeyModal(page); +} + +async function openApiKeyModal(page: Page): Promise { + await page.getByTestId("generalconfig-security").getByRole("button", { name: "edit" }).click(); + const securityModal = page.getByTestId("security-modal"); + await expectModalVisible(securityModal); + await securityModal.getByRole("button", { name: "Generate API Key" }).click(); + + const apiKeyModal = page.getByTestId("api-key-modal"); + await expectModalVisible(apiKeyModal); + return apiKeyModal; +} + +async function generateKey( + page: Page, + modal: Locator, + action: "Generate API Key" | "Regenerate API Key" +): Promise { + if (action === "Regenerate API Key") { + page.once("dialog", (dialog) => dialog.accept()); + } + await modal.getByLabel("Administrator Password").fill(PASSWORD); + await modal.getByRole("button", { name: action, exact: true }).click(); + + const keyInput = modal.getByLabel("API Key", { exact: true }); + await expect(keyInput).toBeVisible(); + const key = await keyInput.inputValue(); + expect(key).toMatch(/^evcc_/); + expect(key.length).toBeGreaterThan(10); + return key; +} + +test("generate first key", async ({ page }) => { + await start(BASIC, "password.sql", ""); + const modal = await loginAndOpenApiKey(page); + + await expect(modal.getByRole("button", { name: "Generate API Key" })).toBeVisible(); + + const key = await generateKey(page, modal, "Generate API Key"); + expect(key).toMatch(/^evcc_/); + + await modal.getByRole("button", { name: "Close" }).last().click(); + await expectModalHidden(modal); + + // closing reveal returns to security modal — now offering Regenerate + const securityModal = page.getByTestId("security-modal"); + await expectModalVisible(securityModal); + await expect(securityModal.getByRole("button", { name: "Regenerate" })).toBeVisible(); + + await stop(); +}); + +test("regenerate replaces old key", async ({ page, request }) => { + await start(BASIC, "password.sql", ""); + const modal = await loginAndOpenApiKey(page); + + const first = await generateKey(page, modal, "Generate API Key"); + await modal.getByRole("button", { name: "Close" }).last().click(); + await expectModalHidden(modal); + + const securityModal = page.getByTestId("security-modal"); + await expectModalVisible(securityModal); + await securityModal.getByRole("button", { name: "Regenerate" }).click(); + await expectModalVisible(modal); + const second = await generateKey(page, modal, "Regenerate API Key"); + await modal.getByRole("button", { name: "Close" }).last().click(); + expect(second).not.toBe(first); + + const oldRes = await request.get("/api/config/site", { + headers: { Authorization: `Bearer ${first}` }, + }); + expect(oldRes.status()).toBe(401); + + const newRes = await request.get("/api/config/site", { + headers: { Authorization: `Bearer ${second}` }, + }); + expect(newRes.status()).toBe(200); + + await stop(); +}); + +test("api key authenticates protected endpoints and bypasses backup pw", async ({ + page, + request, +}) => { + await start(BASIC, "password.sql", ""); + const modal = await loginAndOpenApiKey(page); + const key = await generateKey(page, modal, "Generate API Key"); + + const ok = await request.get("/api/config/site", { + headers: { Authorization: `Bearer ${key}` }, + }); + expect(ok.status()).toBe(200); + + // backup without X-Admin-Password, bypass via API key + const backup = await request.get("/api/db/backup", { + headers: { Authorization: `Bearer ${key}` }, + }); + expect(backup.status()).toBe(200); + expect(backup.headers()["content-disposition"] || "").toContain("evcc-backup-"); + + const unauth = await request.get("/api/config/site"); + expect(unauth.status()).toBe(401); + + await stop(); +}); + +test("api key cannot rotate itself without admin password", async ({ page, request }) => { + await start(BASIC, "password.sql", ""); + const modal = await loginAndOpenApiKey(page); + const key = await generateKey(page, modal, "Generate API Key"); + + const bad = await request.post("/api/auth/apikey", { + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + data: { password: "" }, + }); + expect(bad.status()).toBe(401); + + const good = await request.post("/api/auth/apikey", { + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + data: { password: PASSWORD }, + }); + expect(good.status()).toBe(200); + const body = await good.json(); + expect(body.key).toMatch(/^evcc_/); + expect(body.key).not.toBe(key); + + await stop(); +}); + +test("api key cannot change admin password without correct current", async ({ page, request }) => { + await start(BASIC, "password.sql", ""); + const modal = await loginAndOpenApiKey(page); + const key = await generateKey(page, modal, "Generate API Key"); + + const bad = await request.put("/api/auth/password", { + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + data: { current: "", new: "anything" }, + }); + expect(bad.status()).toBe(400); + + await stop(); +}); + +test("disable-auth shows banner and disables actions", async ({ page }) => { + await start(BASIC, null, "--disable-auth"); + await page.goto("/#/config"); + + await page.getByTestId("generalconfig-security").getByRole("button", { name: "edit" }).click(); + const security = page.getByTestId("security-modal"); + await expectModalVisible(security); + await expect(security.getByText(/Authentication is disabled/i)).toBeVisible(); + await expect(security.getByRole("button", { name: "Update password" })).toBeDisabled(); + await expect(security.getByRole("button", { name: "Generate API Key" })).toBeDisabled(); + + await stop(); +}); diff --git a/tests/auth.spec.ts b/tests/auth.spec.ts index 0ec4a5dda..5bbf80bef 100644 --- a/tests/auth.spec.ts +++ b/tests/auth.spec.ts @@ -108,8 +108,11 @@ test("update password", async ({ page }) => { await loginModal.getByRole("button", { name: "Login" }).click(); await expectModalHidden(loginModal); - // update password - await page.getByTestId("generalconfig-password").getByRole("button", { name: "edit" }).click(); + // open security overview, then change password + await page.getByTestId("generalconfig-security").getByRole("button", { name: "edit" }).click(); + const securityModal = page.getByTestId("security-modal"); + await expectModalVisible(securityModal); + await securityModal.getByRole("button", { name: "Update password" }).click(); const modal = page.getByTestId("password-update-modal"); await expectModalVisible(modal); await expect(modal.getByRole("heading", { name: "Update Administrator Password" })).toBeVisible(); @@ -121,6 +124,11 @@ test("update password", async ({ page }) => { modal.getByRole("heading", { name: "Update Administrator Password" }) ).not.toBeVisible(); + // close security modal that reappears underneath + await expectModalVisible(securityModal); + await page.keyboard.press("Escape"); + await expectModalHidden(securityModal); + // logout const menu = await openMoreMenu(page); await menu.getByRole("button", { name: "Logout" }).click(); @@ -140,7 +148,9 @@ test("update password", async ({ page }) => { await expectModalHidden(loginNew); // revert to old password - await page.getByTestId("generalconfig-password").getByRole("button", { name: "edit" }).click(); + await page.getByTestId("generalconfig-security").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(securityModal); + await securityModal.getByRole("button", { name: "Update password" }).click(); await expectModalVisible(modal); await modal.getByLabel("Current password").fill(newPassword); await modal.getByLabel("New password").fill(oldPassword); diff --git a/tests/backup-restore.spec.ts b/tests/backup-restore.spec.ts index 8b9c02333..8d5615f3b 100644 --- a/tests/backup-restore.spec.ts +++ b/tests/backup-restore.spec.ts @@ -255,7 +255,7 @@ test.describe("backup and restore", async () => { }); test.describe("backup in app context", async () => { - test("download backup dispatches POST event with password body", async ({ page }) => { + test("download backup dispatches GET event with X-Admin-Password header", async ({ page }) => { await enableAppContext(page); await start(); await page.goto("/#/config"); @@ -271,9 +271,8 @@ test.describe("backup in app context", async () => { await backupConfirmModal.getByRole("button", { name: "Download backup" }).click(); expect(await expectAppEvent(page)).toMatchObject({ type: "download", - url: expect.stringContaining("/api/system/backup"), - method: "POST", - body: { password: "" }, + url: expect.stringContaining("/api/db/backup"), + headers: { "X-Admin-Password": "" }, }); await stop(); }); diff --git a/util/auth/auth.go b/util/auth/auth.go index aec57202e..35a3ea218 100644 --- a/util/auth/auth.go +++ b/util/auth/auth.go @@ -9,9 +9,12 @@ import ( "github.com/evcc-io/evcc/core/keys" "github.com/evcc-io/evcc/server/db/settings" "github.com/golang-jwt/jwt/v5" + "github.com/sethvargo/go-password/password" "golang.org/x/crypto/bcrypt" ) +const ApiKeyPrefix = "evcc_" + const admin = "admin" // Possible authentication modes @@ -29,10 +32,14 @@ type Auth interface { SetAdminPassword(string) error IsAdminPasswordValid(string) bool GenerateJwtToken(time.Duration) (string, error) - ValidateJwtToken(string) (bool, error) + ValidateJwtToken(string) bool IsAdminPasswordConfigured() bool SetAuthMode(AuthMode) GetAuthMode() AuthMode + + SetApiKey() (string, error) + IsApiKeyConfigured() bool + ValidateApiKey(string) bool } type auth struct { @@ -64,6 +71,7 @@ func (a *auth) getAdminPasswordHash() string { func (a *auth) RemoveAdminPassword() { a.settings.SetString(keys.AdminPassword, "") a.settings.SetString(keys.JwtSecret, "") + a.settings.SetString(keys.ApiKey, "") } // IsAdminPasswordConfigured checks if the admin password is already set @@ -136,21 +144,17 @@ func (a *auth) GenerateJwtToken(lifetime time.Duration) (string, error) { } // ValidateJwtToken validates the given JWT token -func (a *auth) ValidateJwtToken(tokenString string) (bool, error) { +func (a *auth) ValidateJwtToken(tokenString string) bool { jwtSecret, err := a.getJwtSecret() if err != nil { - return false, err + return false } - // read token var claims jwt.RegisteredClaims - if _, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (any, error) { + _, err = jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (any, error) { return jwtSecret, nil - }, jwt.WithSubject(admin)); err != nil { - return false, err - } - - return true, nil + }, jwt.WithSubject(admin)) + return err == nil } func (a *auth) SetAuthMode(authMode AuthMode) { @@ -160,3 +164,35 @@ func (a *auth) SetAuthMode(authMode AuthMode) { func (a *auth) GetAuthMode() AuthMode { return a.authMode } + +// IsApiKeyConfigured reports whether an API key has been generated +func (a *auth) IsApiKeyConfigured() bool { + hash, _ := a.settings.String(keys.ApiKey) + return hash != "" +} + +// SetApiKey generates a new API key, stores its hash, and returns the cleartext key +func (a *auth) SetApiKey() (string, error) { + secret, err := password.Generate(30, 6, 0, false, false) + if err != nil { + return "", err + } + key := ApiKeyPrefix + secret + + hashed, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost) + if err != nil { + return "", err + } + + a.settings.SetString(keys.ApiKey, string(hashed)) + return key, nil +} + +// ValidateApiKey returns true if the given token matches the stored key +func (a *auth) ValidateApiKey(token string) bool { + hash, err := a.settings.String(keys.ApiKey) + if err != nil || hash == "" { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(token)) == nil +} diff --git a/util/auth/auth_test.go b/util/auth/auth_test.go index feee9a2bd..5a40d7709 100644 --- a/util/auth/auth_test.go +++ b/util/auth/auth_test.go @@ -1,6 +1,7 @@ package auth import ( + "strings" "testing" "time" @@ -31,6 +32,7 @@ func TestRemoveAdminPassword(t *testing.T) { mock.EXPECT().SetString(keys.JwtSecret, "") mock.EXPECT().SetString(keys.AdminPassword, "") + mock.EXPECT().SetString(keys.ApiKey, "") auth.RemoveAdminPassword() } @@ -59,6 +61,33 @@ func TestIsAdminPasswordValid(t *testing.T) { assert.False(t, auth.IsAdminPasswordValid(invalidPw)) } +func TestApiKey(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mock := settings.NewMockAPI(ctrl) + auth := NewMock(mock) + + // not configured + mock.EXPECT().String(keys.ApiKey).Return("", nil).Times(1) + assert.False(t, auth.IsApiKeyConfigured()) + + // generate + var storedHash string + mock.EXPECT().SetString(keys.ApiKey, gomock.Not(gomock.Eq(""))). + Do(func(_, hash string) { storedHash = hash }) + key, err := auth.SetApiKey() + assert.Nil(t, err) + assert.True(t, strings.HasPrefix(key, ApiKeyPrefix), "key should carry the evcc_ prefix") + assert.Greater(t, len(key), len(ApiKeyPrefix)+15) + + // validate the generated key + mock.EXPECT().String(keys.ApiKey).Return(storedHash, nil).AnyTimes() + assert.True(t, auth.ValidateApiKey(key)) + assert.False(t, auth.ValidateApiKey(key+"x")) + assert.False(t, auth.ValidateApiKey("evcc_wrong")) +} + func TestJwtToken(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -73,6 +102,5 @@ func TestJwtToken(t *testing.T) { assert.Nil(t, err, "token generation failed") assert.NotEmpty(t, tokenString, "token is empty") - ok, err := auth.ValidateJwtToken(tokenString) - assert.True(t, ok && err == nil, "token is invalid") + assert.True(t, auth.ValidateJwtToken(tokenString), "token is invalid") } From abc66802ad36a23dea0162e1d5e3d433dfc91549 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sat, 6 Jun 2026 17:22:52 +0200 Subject: [PATCH 0268/1128] chore: fix flaky sponsor and ext meter order tests (#30567) --- tests/config-ext-meter.spec.ts | 4 ++-- tests/issue.spec.ts | 2 +- tests/redact.evcc.yaml | 26 ++++++++++++++++++++++++++ tests/sponsor.evcc.yaml | 14 ++++++++------ tests/sponsor.sql | 2 +- util/sponsor/auth.go | 9 +++++++++ 6 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 tests/redact.evcc.yaml diff --git a/tests/config-ext-meter.spec.ts b/tests/config-ext-meter.spec.ts index 698cd6051..d35b7b3ff 100644 --- a/tests/config-ext-meter.spec.ts +++ b/tests/config-ext-meter.spec.ts @@ -198,8 +198,8 @@ test.describe("ext meter order", async () => { // Restart and check order is preserved in both UIs await restart(CONFIG_BASICS); - // Check config UI - await page.goto("/#/config"); + // Check config UI, reload to reconnect websocket + await page.reload(); await expect(extMeters).toHaveCount(3); await expect(extMeters.nth(0)).toContainText("Meter 1"); await expect(extMeters.nth(1)).toContainText("Meter 2"); diff --git a/tests/issue.spec.ts b/tests/issue.spec.ts index 8f3fc99df..5b80d6527 100644 --- a/tests/issue.spec.ts +++ b/tests/issue.spec.ts @@ -17,7 +17,7 @@ test.afterEach(async () => { await stop(); }); -const REDACT_CONFIG = "sponsor.evcc.yaml"; +const REDACT_CONFIG = "redact.evcc.yaml"; const CONFIG = "issue.evcc.yaml"; test.describe("issue creation", () => { diff --git a/tests/redact.evcc.yaml b/tests/redact.evcc.yaml new file mode 100644 index 000000000..ea3f8d6e4 --- /dev/null +++ b/tests/redact.evcc.yaml @@ -0,0 +1,26 @@ +# expired sponsor token +sponsortoken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJldmNjLmlvIiwic3ViIjoidHJpYWwiLCJleHAiOjE3NTQ5OTI4MDAsImlhdCI6MTc1MzY5NjgwMCwic3BlIjp0cnVlLCJzcmMiOiJtYSJ9.XKa5DHT-icCM9awcX4eS8feW0J_KIjsx2IxjcRRQOcQ + +site: + title: Redact Test + meters: + pv: shelly_pv + +loadpoints: + - title: Carport + charger: charger + +chargers: + - name: charger + type: template + template: demo-charger + +meters: + # local device with credentials to verify redaction + - name: shelly_pv + type: template + template: shelly-1pm + usage: pv + host: localhost + user: test@example.org + password: none diff --git a/tests/sponsor.evcc.yaml b/tests/sponsor.evcc.yaml index 25877d7d4..ffe070656 100644 --- a/tests/sponsor.evcc.yaml +++ b/tests/sponsor.evcc.yaml @@ -6,12 +6,14 @@ site: loadpoints: - title: Carport - charger: easee_charger + charger: alfen_charger chargers: - - name: easee_charger + # sponsorship-required charger without cloud communication + - name: alfen_charger type: template - template: easee - user: test@example.org - password: none - charger: EH123456 + template: alfen + modbus: tcpip + host: localhost + port: 502 + id: 1 diff --git a/tests/sponsor.sql b/tests/sponsor.sql index 47f6b4b8a..d7c98cd83 100644 --- a/tests/sponsor.sql +++ b/tests/sponsor.sql @@ -20,7 +20,7 @@ CREATE TABLE `configs` ( INSERT INTO settings("key", value) VALUES('sponsorToken', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJldmNjLmlvIiwic3ViIjoidHJpYWwiLCJleHAiOjE3NTQ5OTI4MDAsImlhdCI6MTc1MzY5NjgwMCwic3BlIjp0cnVlLCJzcmMiOiJtYSJ9.XKa5DHT-icCM9awcX4eS8feW0J_KIjsx2IxjcRRQOcQ'); -- loadpoint with charger that requires sponsorship -INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(3, 1, 'template', '', '', 'Easee Home', '{"charger":"EH123456","password":"none","template":"easee","timeout":"20s","user":"test@example.org"}'); +INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(3, 1, 'template', '', '', 'Alfen Eve', '{"host":"localhost","id":1,"modbus":"tcpip","port":502,"template":"alfen"}'); INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(4, 5, '', '', '', '', '{"charger":"db:3","circuit":"","meter":"","phasesConfigured":0,"soc":{"poll":{"mode":"charging","interval":3600000000000},"estimate":true},"thresholds":{"enable":{"delay":60000000000,"threshold":0},"disable":{"delay":180000000000,"threshold":0}},"title":"Carport","vehicle":""}'); COMMIT; \ No newline at end of file diff --git a/util/sponsor/auth.go b/util/sponsor/auth.go index 0991a5bcf..1bcf3d03f 100644 --- a/util/sponsor/auth.go +++ b/util/sponsor/auth.go @@ -19,6 +19,7 @@ package sponsor import ( "context" + "errors" "fmt" "os" "strings" @@ -28,6 +29,7 @@ import ( "github.com/evcc-io/evcc/api/proto/pb" "github.com/evcc-io/evcc/util/cloud" "github.com/evcc-io/evcc/util/machine" + "github.com/golang-jwt/jwt/v5" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -111,6 +113,13 @@ func ConfigureSponsorship(token string) error { Token = token + // check expiry locally to avoid cloud roundtrip + var claims jwt.RegisteredClaims + if _, _, err := jwt.NewParser().ParseUnverified(token, &claims); err == nil && + claims.ExpiresAt != nil && claims.ExpiresAt.Before(time.Now()) { + return errors.New("token is expired - get a fresh one from https://sponsor.evcc.io") + } + conn, err := cloud.Connection() if err != nil { return err From 8a2c8c56dc813a6559d52c29d609e5387b2202dd Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sat, 6 Jun 2026 18:07:00 +0200 Subject: [PATCH 0269/1128] chore: fix flaky pv meter and soc range tests (#30570) --- assets/js/components/Helper/GenericModal.vue | 8 ++++++-- tests/simulator.ts | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/assets/js/components/Helper/GenericModal.vue b/assets/js/components/Helper/GenericModal.vue index 381fc5319..413358a91 100644 --- a/assets/js/components/Helper/GenericModal.vue +++ b/assets/js/components/Helper/GenericModal.vue @@ -96,8 +96,12 @@ export default defineComponent({ this.$emit("opened"); if (this.autofocus) { this.$nextTick(() => { - const firstInput = - this.$refs["modalBody"]?.querySelector("input, select, button"); + const modalBody = this.$refs["modalBody"]; + // don't steal focus if user already interacts with the modal content + if (modalBody?.contains(document.activeElement)) { + return; + } + const firstInput = modalBody?.querySelector("input, select, button"); if (firstInput instanceof HTMLElement) { firstInput.focus(); } diff --git a/tests/simulator.ts b/tests/simulator.ts index bdc0078a0..73e1f36be 100644 --- a/tests/simulator.ts +++ b/tests/simulator.ts @@ -49,7 +49,8 @@ export function simulatorConfig() { const input = "./tests/simulator.evcc.yaml"; const content = fs.readFileSync(input, "utf8"); const result = content.replace(/localhost:7072/g, simulatorHost()); - const resultName = "simulator.evcc.generated.yaml"; + // per-worker file name, multiple workers write their own port concurrently + const resultName = `simulator-${workerPort()}.evcc.generated.yaml`; const resultPath = path.join(os.tmpdir(), resultName); fs.writeFileSync(resultPath, result); return resultPath; From b4f7a42306c78b91c91e510c7c09d46eef904790 Mon Sep 17 00:00:00 2001 From: Lars Gellrich Date: Sat, 6 Jun 2026 19:52:07 +0200 Subject: [PATCH 0270/1128] AlphaESS: add returnEnergy support (#30568) --- templates/definition/meter/alpha-ess-smile.yaml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/templates/definition/meter/alpha-ess-smile.yaml b/templates/definition/meter/alpha-ess-smile.yaml index 01350deaf..c5c7b5e4b 100644 --- a/templates/definition/meter/alpha-ess-smile.yaml +++ b/templates/definition/meter/alpha-ess-smile.yaml @@ -34,7 +34,14 @@ render: | {{- include "modbus" . | indent 2 }} register: address: 18 # 0x12 Total energy consumed from grid (Grid) - # 0x10 (address 16) Total energy feed to grid (Grid) - for future grid energy import/export split + type: holding + decode: uint32 + scale: 0.01 + returnenergy: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 16 # 0x10 Total energy feed to grid (Grid) type: holding decode: uint32 scale: 0.01 @@ -133,6 +140,14 @@ render: | type: holding decode: uint32 scale: 0.1 + returnenergy: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 288 # 0x120 Battery charge energy + type: holding + decode: uint32 + scale: 0.1 soc: source: modbus {{- include "modbus" . | indent 2 }} From 4dcff007d705429c8d582df3e4c78d414251e5f0 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 7 Jun 2026 11:35:52 +0200 Subject: [PATCH 0271/1128] aa55 udp: dedupe concurrent block reads with single flight (#30589) --- plugin/aa55/aa55_test.go | 59 ++++++++++++++++++++++++++++++++++++++++ plugin/aa55/aa55udp.go | 36 +++++++++++++----------- plugin/aa55/cache.go | 33 ++++++++++++++++++++-- 3 files changed, 110 insertions(+), 18 deletions(-) diff --git a/plugin/aa55/aa55_test.go b/plugin/aa55/aa55_test.go index 04f3301a2..217e03566 100644 --- a/plugin/aa55/aa55_test.go +++ b/plugin/aa55/aa55_test.go @@ -4,6 +4,8 @@ import ( "encoding/binary" "encoding/hex" "math" + "sync" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -320,6 +322,63 @@ func TestCache_PutGet(t *testing.T) { assert.Equal(t, []byte{1, 2, 3}, got) } +// TestCache_FetchSingleFlight verifies that concurrent fetches for the same key +// collapse into a single load (one UDP exchange), all observe the same payload, +// and the cache is left warm for subsequent reads. +func TestCache_FetchSingleFlight(t *testing.T) { + c := newResponseCache() + key := []byte("10.0.0.1:8899/f703891c007d") + want := []byte{0xde, 0xad, 0xbe, 0xef} + + var calls atomic.Int32 + var once sync.Once + entered := make(chan struct{}) + release := make(chan struct{}) + load := func() ([]byte, error) { + calls.Add(1) + once.Do(func() { close(entered) }) + <-release // hold the flight open while followers pile up + return want, nil + } + + const n = 8 + var wg sync.WaitGroup + payloads := make([][]byte, n) + + wg.Go(func() { + got, _, err := c.fetch(key, load) + assert.NoError(t, err) + payloads[0] = got + }) + + <-entered // ensure the flight is open before followers join + + for i := 1; i < n; i++ { + wg.Go(func() { + got, _, err := c.fetch(key, load) + assert.NoError(t, err) + payloads[i] = got + }) + } + + close(release) + wg.Wait() + + assert.Equal(t, int32(1), calls.Load(), "concurrent reads of the same block must share one exchange") + for i := range n { + assert.Equal(t, want, payloads[i]) + } + + // cache is now warm: a subsequent read hits without loading again + got, ok, err := c.fetch(key, func() ([]byte, error) { + t.Fatal("must not load on warm cache") + return nil, nil + }) + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, want, got) +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/plugin/aa55/aa55udp.go b/plugin/aa55/aa55udp.go index d99b82374..a93b2b0fe 100644 --- a/plugin/aa55/aa55udp.go +++ b/plugin/aa55/aa55udp.go @@ -127,30 +127,34 @@ func (p *AA55UDP) query() (float64, error) { return v * p.scale, nil } -// fetch returns the response payload, using caching for block-read mode. +// fetch returns the response payload. In block-read mode the shared cache +// serves and de-duplicates requests. func (p *AA55UDP) fetch() ([]byte, error) { - if p.cacheKey != nil { - if payload, ok := cache.get(p.cacheKey); ok { - p.log.TRACE.Printf("cache hit for %s pdu=%x", p.conn.RemoteAddr(), p.pdu) - return payload, nil - } + // Register mode: single targeted read, no caching. + if p.cacheKey == nil { + return p.exchange() } + payload, ok, err := cache.fetch(p.cacheKey, p.exchange) + if err != nil { + return nil, err + } + if ok { + p.log.TRACE.Printf("cache hit for %s pdu=%x", p.conn.RemoteAddr(), p.pdu) + } + return payload, nil +} + +// exchange performs one request/response round trip and returns the response +// payload with the AA55 header stripped. It is the cache-miss path shared by +// single flight in block-read mode. +func (p *AA55UDP) exchange() ([]byte, error) { packet := append(p.pdu, modbusCRC16(p.pdu)...) raw, err := p.sendRecv(packet) if err != nil { return nil, err } - - payload, err := stripHeader(raw) - if err != nil { - return nil, err - } - - if p.cacheKey != nil { - cache.put(p.cacheKey, payload) - } - return payload, nil + return stripHeader(raw) } // sendRecv sends packet over p.conn and returns the raw response bytes. diff --git a/plugin/aa55/cache.go b/plugin/aa55/cache.go index aeb38e457..c9f1c6788 100644 --- a/plugin/aa55/cache.go +++ b/plugin/aa55/cache.go @@ -3,6 +3,8 @@ package aa55 import ( "sync" "time" + + "golang.org/x/sync/singleflight" ) const cacheTTL = 2 * time.Second @@ -23,14 +25,41 @@ type cacheEntry struct { } type responseCache struct { - mu sync.Mutex - data map[string]cacheEntry + mu sync.Mutex + data map[string]cacheEntry + flight singleflight.Group } func newResponseCache() *responseCache { return &responseCache{data: make(map[string]cacheEntry)} } +// fetch returns the cached payload for key if it is fresh. On a miss, load is +// invoked exactly once across all concurrent callers sharing the same key. +func (c *responseCache) fetch(key []byte, load func() ([]byte, error)) ([]byte, bool, error) { + if payload, ok := c.get(key); ok { + return payload, true, nil + } + + payload, err, _ := c.flight.Do(string(key), func() (any, error) { + // re-check under the flight: a prior flight may have populated the + // cache between our miss above and acquiring the call. + if payload, ok := c.get(key); ok { + return payload, nil + } + payload, err := load() + if err != nil { + return nil, err + } + c.put(key, payload) + return payload, nil + }) + if err != nil { + return nil, false, err + } + return payload.([]byte), false, nil +} + // get returns the cached payload if it exists and is fresh, or (nil, false) // otherwise. Expired entries are deleted on access. The map lookup // m[string(key)] is alloc-free — the Go compiler elides the conversion. From 2f7126b005e1a2a595c69e76500ee739c0ebad96 Mon Sep 17 00:00:00 2001 From: mfuchs1984 <57141790+mfuchs1984@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:42:27 +0200 Subject: [PATCH 0272/1128] Cardata: support "New Class" vehicles (#29709) --- templates/definition/vehicle/cardata.yaml | 8 +- vehicle/bmw/cardata/api.go | 2 + vehicle/bmw/cardata/provider.go | 35 ++++--- vehicle/bmw/cardata/provider_test.go | 114 ++++++++++++++++++++++ 4 files changed, 146 insertions(+), 13 deletions(-) diff --git a/templates/definition/vehicle/cardata.yaml b/templates/definition/vehicle/cardata.yaml index 3f1db2255..e025862c0 100644 --- a/templates/definition/vehicle/cardata.yaml +++ b/templates/definition/vehicle/cardata.yaml @@ -9,7 +9,7 @@ products: requirements: description: de: | - Benötigt CarData Einrichtung im BMW/Mini portal. Die folgenden Datenpunkte müssen für Streaming konfiguriert werden: + Benötigt CarData Einrichtung im BMW/Mini portal. Die folgenden Datenpunkte müssen für Streaming konfiguriert werden (die Verfügbarkeit der Datenpunkte kann je nach Fahrzeugmodell variieren): ``` vehicle.body.chargingPort.status @@ -21,7 +21,9 @@ requirements: vehicle.drivetrain.electricEngine.charging.status vehicle.drivetrain.electricEngine.charging.timeRemaining vehicle.drivetrain.electricEngine.kombiRemainingElectricRange + vehicle.drivetrain.lastRemainingRange vehicle.powertrain.electric.battery.stateOfCharge.target + vehicle.powertrain.electric.battery.stateOfCharge.displayed vehicle.vehicle.preConditioning.activity vehicle.vehicle.travelledDistance ``` @@ -29,7 +31,7 @@ requirements: Aktualisierung der Daten erfolgt einmalig bei Neustart und wenn Streamingdaten eingehen. Dies ist ausschließlich der Fall, wenn das Fahrzeug aktiv Daten erzeugt. en: | - Requires CarData activation in BMW/Mini portal. The following data points need to be configured for streaming access: + Requires CarData activation in BMW/Mini portal. The following data points need to be configured for streaming access (the availability of data points may vary depending on the vehicle model): ``` vehicle.body.chargingPort.status @@ -41,7 +43,9 @@ requirements: vehicle.drivetrain.electricEngine.charging.status vehicle.drivetrain.electricEngine.charging.timeRemaining vehicle.drivetrain.electricEngine.kombiRemainingElectricRange + vehicle.drivetrain.lastRemainingRange vehicle.powertrain.electric.battery.stateOfCharge.target + vehicle.powertrain.electric.battery.stateOfCharge.displayed vehicle.vehicle.preConditioning.activity vehicle.vehicle.travelledDistance ``` diff --git a/vehicle/bmw/cardata/api.go b/vehicle/bmw/cardata/api.go index 11f5e1188..4165dea39 100644 --- a/vehicle/bmw/cardata/api.go +++ b/vehicle/bmw/cardata/api.go @@ -25,6 +25,8 @@ var requiredKeys = []string{ "vehicle.drivetrain.electricEngine.charging.status", "vehicle.drivetrain.electricEngine.charging.timeRemaining", "vehicle.drivetrain.electricEngine.kombiRemainingElectricRange", + "vehicle.drivetrain.lastRemainingRange", + "vehicle.powertrain.electric.battery.stateOfCharge.displayed", "vehicle.powertrain.electric.battery.stateOfCharge.target", "vehicle.vehicle.preConditioning.activity", "vehicle.vehicle.travelledDistance", diff --git a/vehicle/bmw/cardata/provider.go b/vehicle/bmw/cardata/provider.go index 9bfbd7cdb..21a27b7f4 100644 --- a/vehicle/bmw/cardata/provider.go +++ b/vehicle/bmw/cardata/provider.go @@ -141,10 +141,14 @@ func (v *Provider) any(key string) (any, error) { return nil, api.ErrNotAvailable } +func isNilOrEmtpy(val any) bool { + return val == nil || val == "" +} + func (v *Provider) String(key string) (string, error) { res, err := v.any(key) - if err != nil { - return "", err + if err != nil || isNilOrEmtpy(res) { + return "", api.ErrNotAvailable } return cast.ToStringE(res) @@ -152,8 +156,8 @@ func (v *Provider) String(key string) (string, error) { func (v *Provider) Int(key string) (int64, error) { res, err := v.any(key) - if err != nil { - return 0, err + if err != nil || isNilOrEmtpy(res) { + return 0, api.ErrNotAvailable } return cast.ToInt64E(res) @@ -161,8 +165,8 @@ func (v *Provider) Int(key string) (int64, error) { func (v *Provider) Float(key string) (float64, error) { res, err := v.any(key) - if err != nil { - return 0, err + if err != nil || isNilOrEmtpy(res) { + return 0, api.ErrNotAvailable } return cast.ToFloat64E(res) @@ -172,7 +176,10 @@ var _ api.Battery = (*Provider)(nil) // Soc implements the api.Vehicle interface func (v *Provider) Soc() (float64, error) { - return v.Float("vehicle.drivetrain.batteryManagement.header") + if res, err := v.Float("vehicle.drivetrain.batteryManagement.header"); err == nil { + return res, nil + } + return v.Float("vehicle.powertrain.electric.battery.stateOfCharge.displayed") } var _ api.ChargeState = (*Provider)(nil) @@ -193,7 +200,7 @@ func (v *Provider) Status() (api.ChargeStatus, error) { // mqtt, while hvStatus might only be available through rest // (https://github.com/evcc-io/evcc/pull/26235) cs, err := v.String("vehicle.drivetrain.electricEngine.charging.status") - if err != nil || cs == "" { + if err != nil { cs, err = v.String("vehicle.drivetrain.electricEngine.charging.hvStatus") } @@ -212,14 +219,20 @@ var _ api.VehicleFinishTimer = (*Provider)(nil) // FinishTime implements the api.VehicleFinishTimer interface func (v *Provider) FinishTime() (time.Time, error) { res, err := v.Int("vehicle.drivetrain.electricEngine.charging.timeRemaining") - return time.Now().Add(time.Duration(res) * time.Minute), err + if err != nil { + return time.Time{}, err + } + return time.Now().Add(time.Duration(res) * time.Minute), nil } var _ api.VehicleRange = (*Provider)(nil) // Range implements the api.VehicleRange interface func (v *Provider) Range() (int64, error) { - return v.Int("vehicle.drivetrain.electricEngine.kombiRemainingElectricRange") + if res, err := v.Int("vehicle.drivetrain.electricEngine.kombiRemainingElectricRange"); err == nil { + return res, nil + } + return v.Int("vehicle.drivetrain.lastRemainingRange") } var _ api.VehicleOdometer = (*Provider)(nil) @@ -243,7 +256,7 @@ func (v *Provider) Climater() (bool, error) { activeStates := []string{"HEATING", "COOLING", "VENTILATION", "DEFROST"} res, err := v.String("vehicle.cabin.hvac.preconditioning.status.comfortState") - if err == nil && res != "" { + if err == nil { return slices.Contains(activeStates, strings.TrimPrefix(strings.ToUpper(res), "COMFORT_")), nil } diff --git a/vehicle/bmw/cardata/provider_test.go b/vehicle/bmw/cardata/provider_test.go index 601f21c30..4b813596b 100644 --- a/vehicle/bmw/cardata/provider_test.go +++ b/vehicle/bmw/cardata/provider_test.go @@ -47,3 +47,117 @@ func TestCardataStreaming(t *testing.T) { require.NoError(t, err) require.Equal(t, 47.0, soc) } + +func TestSocFallback(t *testing.T) { + ctx := t.Context() + + p := NewProvider(ctx, util.NewLogger("foo"), nil, oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: "at", + }), "client", "vin", 0) + + // prevent container panic + p.updated = time.Now() + + keySocOld := "vehicle.drivetrain.batteryManagement.header" + keySocNew := "vehicle.powertrain.electric.battery.stateOfCharge.displayed" + + // Case 1: Old key is missing, new key is present + p.rest = map[string]TelematicData{ + keySocNew: {Value: "80"}, + } + soc, err := p.Soc() + require.NoError(t, err) + require.Equal(t, 80.0, soc) + + // Case 2: Old key is empty (null in JSON), new key is present + p.rest = map[string]TelematicData{ + keySocOld: {Value: ""}, + keySocNew: {Value: "90"}, + } + soc, err = p.Soc() + require.NoError(t, err) + require.Equal(t, 90.0, soc) + + // Case 3: Old key is nil in streaming, new key is present + p.rest = nil + p.streaming = map[string]StreamingData{ + keySocOld: {Value: nil}, + keySocNew: {Value: 95.0}, + } + soc, err = p.Soc() + require.NoError(t, err) + require.Equal(t, 95.0, soc) + + // Case 4: Old key is present, new key is also present, old key is preferred + p.rest = map[string]TelematicData{ + keySocOld: {Value: "42"}, + keySocNew: {Value: "90"}, + } + p.streaming = nil + soc, err = p.Soc() + require.NoError(t, err) + require.Equal(t, 42.0, soc) + + // Case 5: Both keys are absent, returns error + p.rest = map[string]TelematicData{} + soc, err = p.Soc() + require.Error(t, err) + require.Equal(t, 0.0, soc) +} + +func TestRangeFallback(t *testing.T) { + ctx := t.Context() + + p := NewProvider(ctx, util.NewLogger("foo"), nil, oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: "at", + }), "client", "vin", 0) + + // prevent container panic + p.updated = time.Now() + + keyRangeOld := "vehicle.drivetrain.electricEngine.kombiRemainingElectricRange" + keyRangeNew := "vehicle.drivetrain.lastRemainingRange" + + // Case 1: Old key is missing, new key is present + p.rest = map[string]TelematicData{ + keyRangeNew: {Value: "200"}, + } + rng, err := p.Range() + require.NoError(t, err) + require.Equal(t, int64(200), rng) + + // Case 2: Old key is empty (null in JSON), new key is present + p.rest = map[string]TelematicData{ + keyRangeOld: {Value: ""}, + keyRangeNew: {Value: "150"}, + } + rng, err = p.Range() + require.NoError(t, err) + require.Equal(t, int64(150), rng) + + // Case 3: Old key is nil in streaming, new key is present + p.rest = nil + p.streaming = map[string]StreamingData{ + keyRangeOld: {Value: nil}, + keyRangeNew: {Value: 120.0}, + } + rng, err = p.Range() + require.NoError(t, err) + require.Equal(t, int64(120), rng) + + // Case 4: Old key is present, new key is also present, old key is preferred + p.rest = map[string]TelematicData{ + keyRangeOld: {Value: "300"}, + keyRangeNew: {Value: "150"}, + } + p.streaming = nil + rng, err = p.Range() + require.NoError(t, err) + require.Equal(t, int64(300), rng) + + // Case 5: Both keys are absent, returns error + p.rest = map[string]TelematicData{} + rng, err = p.Range() + require.Error(t, err) + require.Equal(t, int64(0), rng) +} From 9e9a07221b60ad136864ace10550ba18459ab0e3 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 7 Jun 2026 12:09:24 +0200 Subject: [PATCH 0273/1128] chore: fix flaky OCPP test bind on fixed port 8887 (#30590) --- charger/ocpp/instance.go | 11 +++++++++++ charger/ocpp/instance_test.go | 9 +++++++++ charger/ocpp_test.go | 23 +++++++++++++++++++---- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/charger/ocpp/instance.go b/charger/ocpp/instance.go index 1185988f9..624ad2ad2 100644 --- a/charger/ocpp/instance.go +++ b/charger/ocpp/instance.go @@ -28,9 +28,18 @@ var ( once sync.Once instance *CS port = 8887 + boundPort int externalUrl string ) +// Port returns the TCP port the central system is bound to. With the default +// configuration this equals the configured port; when port 0 is configured +// (as in tests) it is the OS-assigned ephemeral port. It returns 0 while the +// server is not bound. +func Port() int { + return boundPort +} + // GetStatus returns the OCPP runtime status func GetStatus() Status { if instance == nil { @@ -111,6 +120,8 @@ func Instance() *CS { return } } + + boundPort = server.Addr().Port }) return instance diff --git a/charger/ocpp/instance_test.go b/charger/ocpp/instance_test.go index 9f8a62d7d..a476c6db0 100644 --- a/charger/ocpp/instance_test.go +++ b/charger/ocpp/instance_test.go @@ -1,9 +1,18 @@ package ocpp import ( + "os" "testing" ) +func TestMain(m *testing.M) { + // bind the central system to an ephemeral port so this test binary does not + // contend with the charger package test binary for the fixed default port + // when both run in parallel under `go test ./...` + Init(Config{Port: 0}, "") + os.Exit(m.Run()) +} + func TestExternalUrl(t *testing.T) { tests := []struct{ input, expected string }{ {"", ""}, diff --git a/charger/ocpp_test.go b/charger/ocpp_test.go index 55908ee75..64c486689 100644 --- a/charger/ocpp_test.go +++ b/charger/ocpp_test.go @@ -2,6 +2,8 @@ package charger import ( "errors" + "fmt" + "os" "testing" "time" @@ -22,10 +24,20 @@ import ( "github.com/stretchr/testify/suite" ) -const ( - ocppTestUrl = "ws://localhost:8887" - ocppTestConnectTimeout = 10 * time.Second -) +const ocppTestConnectTimeout = 10 * time.Second + +// ocppTestUrl is derived from the actual bound port in SetupSuite: the central +// system binds an ephemeral port to avoid bind failures when the fixed default +// port is already in use on the CI runner. +var ocppTestUrl string + +func TestMain(m *testing.M) { + // bind the OCPP central system to an ephemeral port so this test binary + // does not contend with the charger/ocpp package test binary for the fixed + // default port when both run in parallel under `go test ./...` + ocpp.Init(ocpp.Config{Port: 0}, "") + os.Exit(m.Run()) +} func TestOcpp(t *testing.T) { suite.Run(t, new(ocppTestSuite)) @@ -49,6 +61,9 @@ func (suite *ocppTestSuite) SetupSuite() { suite.logger = &ocppLogger{t: suite.T()} ocppj.SetLogger(suite.logger) + suite.Require().NotZero(ocpp.Port(), "central system did not bind") + ocppTestUrl = fmt.Sprintf("ws://localhost:%d", ocpp.Port()) + suite.clock = clock.NewMock() suite.NotNil(ocpp.Instance()) } From 0fa8b97daf20461ebf805ec270492b135e46cdba Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 7 Jun 2026 12:56:28 +0200 Subject: [PATCH 0274/1128] OCPP UI: remove wss assumption (#28996) --- charger/ocpp/instance.go | 3 +-- charger/ocpp/instance_test.go | 8 +------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/charger/ocpp/instance.go b/charger/ocpp/instance.go index 624ad2ad2..a3a61f5ff 100644 --- a/charger/ocpp/instance.go +++ b/charger/ocpp/instance.go @@ -59,8 +59,7 @@ func ExternalUrl() string { return "" } - // Replace protocol: http -> ws, https -> wss - u.Scheme = strings.Replace(u.Scheme, "http", "ws", 1) + u.Scheme = "ws" u.Host = fmt.Sprintf("%s:%d", strings.Split(u.Host, ":")[0], 8887) // deliberately fixed, port configurability only for testing return u.String() diff --git a/charger/ocpp/instance_test.go b/charger/ocpp/instance_test.go index a476c6db0..1e8ffefab 100644 --- a/charger/ocpp/instance_test.go +++ b/charger/ocpp/instance_test.go @@ -16,15 +16,9 @@ func TestMain(m *testing.M) { func TestExternalUrl(t *testing.T) { tests := []struct{ input, expected string }{ {"", ""}, - {"http://example.com:7070", "ws://example.com:8887"}, - {"https://example.com:443", "wss://example.com:8887"}, {"http://example.com", "ws://example.com:8887"}, - {"https://example.com", "wss://example.com:8887"}, + {"https://example.com:443", "ws://example.com:8887"}, {"http://10.20.30.40:7070/path", "ws://10.20.30.40:8887/path"}, - {"https://example.com/path", "wss://example.com:8887/path"}, - {"ws://example.com", "ws://example.com:8887"}, - {"wss://example.com:9000", "wss://example.com:8887"}, - {"strange://example.com", "strange://example.com:8887"}, } for _, tt := range tests { From a8361b575bd6143d1c9d29e21ec5d9f4f9b0408f Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 7 Jun 2026 13:11:20 +0200 Subject: [PATCH 0275/1128] Metrics: store entity title on lazy-create (#30196) --- assets/js/components/History/GroupChart.vue | 16 +++--- assets/js/store.ts | 19 +------ assets/js/views/History.vue | 20 +++---- charger/ocpp.go | 5 +- cmd/metrics_battery.go | 8 +-- cmd/metrics_battery_test.go | 6 +-- cmd/metrics_data.go | 12 +++-- cmd/metrics_data_test.go | 7 ++- cmd/metrics_forecast_test.go | 8 +-- cmd/setup.go | 16 +++++- core/helper.go | 8 +-- core/metrics/collector.go | 31 +++++++---- core/metrics/collector_test.go | 58 ++++++++++++++++++--- core/metrics/db.go | 3 +- core/metrics/db_entities.go | 6 ++- core/metrics/db_history.go | 55 +++++++++++-------- core/metrics/db_history_test.go | 26 ++++----- core/metrics/db_test.go | 22 ++++---- core/site.go | 14 ++--- core/site_optimizer.go | 3 +- tests/energy-history.spec.ts | 8 +-- 21 files changed, 209 insertions(+), 142 deletions(-) diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index 0a59e33be..91caefdd7 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -27,7 +27,7 @@ export interface HistorySlot { } export interface HistorySeries { - name: string; + title: string; group: string; data: HistorySlot[]; // Marks a synthetic / derived series (e.g. "other consumers"). Gets a neutral @@ -203,14 +203,14 @@ export default defineComponent({ const mutedColor = colors.muted || this.color; const titles: string[] = []; for (const s of this.series) { - if (!s.virtual && !titles.includes(s.name)) titles.push(s.name); + if (!s.virtual && !titles.includes(s.title)) titles.push(s.title); } const palette = resolveColors(titles, deviceColorMap(store.state.deviceColors)); return this.series.map((s) => { // Virtual "other consumers" entity renders in a neutral gray to set // it apart from explicit meter entities. if (s.virtual) return mutedColor; - return palette[s.name] || this.color; + return palette[s.title] || this.color; }); } if (this.series.length <= 1) return [this.color]; @@ -512,7 +512,7 @@ export default defineComponent({ ? [this.focusedEntity] : this.series.map((s, i) => s.paletteIndex ?? i); const nameByIdx = new Map( - this.series.map((s, i) => [s.paletteIndex ?? i, s.name]) + this.series.map((s, i) => [s.paletteIndex ?? i, s.title]) ); const showName = this.series.length > 1 && this.focusedEntity === null; @@ -692,15 +692,15 @@ export default defineComponent({ directionLabel(s: HistorySeries, dir: "energy" | "returnEnergy"): string { const key = `main.history.direction.${s.group}.${dir}`; const label = this.$t(key); - if (label === key) return s.name; - if (this.series.length > 1) return `${s.name} ${label}`; + if (label === key) return s.title; + if (this.series.length > 1) return `${s.title} ${label}`; return String(label); }, singleEntityName(s: HistorySeries): string { - if (this.series.length > 1) return s.name; + if (this.series.length > 1) return s.title; const key = `main.history.group.${s.group}`; const label = this.$t(key); - return label === key ? s.name : String(label); + return label === key ? s.title : String(label); }, }, }); diff --git a/assets/js/store.ts b/assets/js/store.ts index 54ea0e355..8e6224552 100644 --- a/assets/js/store.ts +++ b/assets/js/store.ts @@ -1,4 +1,4 @@ -import { computed, reactive } from "vue"; +import { reactive } from "vue"; import type { State } from "./types/evcc"; import { convertToUiLoadpoints } from "./uiLoadpoints"; import { useDebouncedComputed } from "./utils/useDebouncedComputed"; @@ -43,25 +43,9 @@ const uiLoadpoints = useDebouncedComputed( 50 ); -// name → title lookup for loadpoints and meters -const deviceTitles = computed(() => { - const map: Record = {}; - const add = (e: { name?: string; title?: string } | undefined) => { - if (e?.name && e.title) map[e.name] = e.title; - }; - state.loadpoints?.forEach(add); - state.pv?.forEach(add); - state.battery?.devices?.forEach(add); - state.aux?.forEach(add); - state.ext?.forEach(add); - add(state.grid); - return map; -}); - export interface Store { state: State; // raw state from websocket uiLoadpoints: typeof uiLoadpoints; - deviceTitles: typeof deviceTitles; offline(value: boolean): void; update(msg: any): void; reset(): void; @@ -70,7 +54,6 @@ export interface Store { const store: Store = { state, uiLoadpoints, - deviceTitles, offline(value: boolean) { state.offline = value; }, diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index edac95fc2..15a92c990 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -245,12 +245,10 @@ export default defineComponent({ return `${this.aggregate}|${this.from.getTime()}|${this.to.getTime()}`; }, seriesByGroup(): Record { - const titles = store.deviceTitles.value; const map: Record = {}; for (const s of this.rawSeries) { if (!s.group) continue; - if (!map[s.group]) map[s.group] = []; - map[s.group]!.push({ ...s, name: titles[s.name] || s.name }); + (map[s.group] ||= []).push(s); } return map; }, @@ -284,7 +282,7 @@ export default defineComponent({ const activeMeters = meters .map((s, i) => ({ ...s, paletteIndex: i })) .filter(hasEnergy); - if (!home) return activeMeters; + if (!home || activeMeters.length === 0) return activeMeters; const meterTotals = new Map(); // Net per slot is computed from all meters (incl. inactive ones), so // dropping inactive entries from the display doesn't shift the @@ -296,7 +294,7 @@ export default defineComponent({ } } const other: HistorySeries = { - name: this.$t("main.history.otherConsumers") as string, + title: this.$t("main.history.otherConsumers") as string, group: "meter", virtual: true, // Use meters.length as a stable paletteIndex that can never @@ -309,9 +307,7 @@ export default defineComponent({ }), }; if (!hasEnergy(other)) return activeMeters; - // First entry in the array = bottom of the stack, so "Other consumers" - // always sits underneath the explicit meters. - return [other, ...activeMeters]; + return [...activeMeters, other]; }; }, hasForecast(): boolean { @@ -417,14 +413,14 @@ export default defineComponent({ if (isPickGroup) { const titles: string[] = []; for (const s of list) { - if (!s.virtual && !titles.includes(s.name)) titles.push(s.name); + if (!s.virtual && !titles.includes(s.title)) titles.push(s.title); } palette = resolveColors(titles, this.deviceColors); } const colorFor = (i: number, s: HistorySeries) => { if (s.virtual) return colors.muted || baseColor; - if (isPickGroup) return palette[s.name] || baseColor; + if (isPickGroup) return palette[s.title] || baseColor; return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1))); }; return list.map((s, i) => { @@ -435,10 +431,10 @@ export default defineComponent({ // Use stable paletteIndex as the focus identifier so that the // selected entity keeps its identity across period navigations. entityIndex: s.paletteIndex ?? i, - label: s.name, + label: s.title, color: colorFor(i, s), value: this.fmtWh(watts, POWER_UNIT.AUTO), - id: isPickGroup && !s.virtual ? s.name : undefined, + id: isPickGroup && !s.virtual ? s.title : undefined, }; }); }, diff --git a/charger/ocpp.go b/charger/ocpp.go index 9a19f474e..36c466121 100644 --- a/charger/ocpp.go +++ b/charger/ocpp.go @@ -33,7 +33,6 @@ import ( "github.com/evcc-io/evcc/util/sponsor" "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" - "github.com/samber/lo" ) // OCPP charger implementation @@ -138,7 +137,7 @@ func NewOCPP(ctx context.Context, forcePowerCtrl, stackLevelZero, profileKindRelative, remoteStart, noChangeAvailability bool, connectTimeout time.Duration, ) (*OCPP, error) { - log := util.NewLogger(fmt.Sprintf("%s-%d", lo.CoalesceOrEmpty(id, "ocpp"), connector)) + log := util.NewLogger(fmt.Sprintf("%s-%d", cmp.Or(id, "ocpp"), connector)) cp, err := ocpp.Instance().RegisterChargepoint(id, func() *ocpp.CP { @@ -167,7 +166,7 @@ func NewOCPP(ctx context.Context, } if remoteStart { - idTag = lo.CoalesceOrEmpty(idTag, cp.IdTag, defaultIdTag) + idTag = cmp.Or(idTag, cp.IdTag, defaultIdTag) } conn, err := ocpp.NewConnector(ctx, log, connector, cp, idTag, meterInterval) diff --git a/cmd/metrics_battery.go b/cmd/metrics_battery.go index 7f136ba66..351c2b31e 100644 --- a/cmd/metrics_battery.go +++ b/cmd/metrics_battery.go @@ -78,19 +78,19 @@ type batteryTotals struct { discharge float64 } -// metricsBatteryTotals sums charge and discharge energy per battery entity. +// metricsBatteryTotals sums charge and discharge energy per battery title. func metricsBatteryTotals(series []metrics.Series) map[string]batteryTotals { res := make(map[string]batteryTotals) for _, s := range series { if s.Group != metrics.Battery { continue } - t := res[s.Name] + t := res[s.Title] for _, slot := range s.Data { t.charge += slot.Energy t.discharge += slot.ReturnEnergy } - res[s.Name] = t + res[s.Title] = t } return res } @@ -103,7 +103,7 @@ func metricsWriteBatteryTable(w io.Writer, selected []metrics.EntityInfo, totals fmt.Fprintln(tw, "name\ttitle\tcharge\tdischarge\tefficiency") for _, e := range selected { - t := totals[e.Name] + t := totals[title(e.Group, e.Name)] efficiency := "" if t.charge > 0 { diff --git a/cmd/metrics_battery_test.go b/cmd/metrics_battery_test.go index bbc61d730..29f6080f3 100644 --- a/cmd/metrics_battery_test.go +++ b/cmd/metrics_battery_test.go @@ -11,12 +11,12 @@ import ( func TestMetricsBatteryTotals(t *testing.T) { series := []metrics.Series{ - {Group: metrics.Battery, Name: "bat", Data: []metrics.Slot{ + {Group: metrics.Battery, Title: "bat", Data: []metrics.Slot{ {Energy: 1.0, ReturnEnergy: 0.4}, {Energy: 2.0, ReturnEnergy: 1.6}, }}, // non-battery series must be ignored - {Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{ + {Group: metrics.Grid, Title: "grid", Data: []metrics.Slot{ {Energy: 5.0, ReturnEnergy: 3.0}, }}, } @@ -33,7 +33,7 @@ func TestMetricsWriteBatteryTable(t *testing.T) { {Group: metrics.Battery, Name: "bat2"}, } totals := map[string]batteryTotals{ - "bat1": {charge: 10.0, discharge: 9.0}, + "Home": {charge: 10.0, discharge: 9.0}, // bat2 deliberately absent: no data in the timeframe } title := func(group, name string) string { diff --git a/cmd/metrics_data.go b/cmd/metrics_data.go index 270c705e8..b0072e6d1 100644 --- a/cmd/metrics_data.go +++ b/cmd/metrics_data.go @@ -72,13 +72,19 @@ func runMetricsData(cmd *cobra.Command, args []string) { byEntity := make(map[string]metrics.Series, len(series)) for _, s := range series { - byEntity[s.Group+"/"+s.Name] = s + byEntity[s.Group+"/"+s.Title] = s } if asCSV, _ := cmd.Flags().GetBool("csv"); asCSV { var out metrics.SeriesCSV + seen := make(map[string]bool, len(selected)) for _, e := range selected { - if s, ok := byEntity[e.Group+"/"+e.Name]; ok { + key := e.Group + "/" + title(e.Group, e.Name) + if seen[key] { + continue + } + seen[key] = true + if s, ok := byEntity[key]; ok { out = append(out, s) } } @@ -236,7 +242,7 @@ func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[ } for _, spec := range specs { - s, ok := byEntity[spec.entity.Group+"/"+spec.entity.Name] + s, ok := byEntity[spec.entity.Group+"/"+title(spec.entity.Group, spec.entity.Name)] if !ok { continue } diff --git a/cmd/metrics_data_test.go b/cmd/metrics_data_test.go index 8df630378..836cb2239 100644 --- a/cmd/metrics_data_test.go +++ b/cmd/metrics_data_test.go @@ -93,10 +93,10 @@ func TestMetricsWriteTable(t *testing.T) { {Group: metrics.Grid, Name: "grid"}, } byEntity := map[string]metrics.Series{ - metrics.Loadpoint + "/lp-1": {Group: metrics.Loadpoint, Name: "lp-1", Data: []metrics.Slot{ + metrics.Loadpoint + "/Carport": {Group: metrics.Loadpoint, Title: "Carport", Data: []metrics.Slot{ {Start: h0, Energy: 1.84}, }}, - metrics.Grid + "/grid": {Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{ + metrics.Grid + "/grid": {Group: metrics.Grid, Title: "grid", Data: []metrics.Slot{ {Start: h0, Energy: 0.412}, {Start: h1, Energy: 0.38, ReturnEnergy: 0.05}, }}, @@ -105,6 +105,9 @@ func TestMetricsWriteTable(t *testing.T) { if group == metrics.Loadpoint { return "Carport" } + if group == metrics.Grid { + return "grid" + } return "" } diff --git a/cmd/metrics_forecast_test.go b/cmd/metrics_forecast_test.go index da0e69284..aff763104 100644 --- a/cmd/metrics_forecast_test.go +++ b/cmd/metrics_forecast_test.go @@ -11,18 +11,18 @@ import ( func TestMetricsForecastTotals(t *testing.T) { series := []metrics.Series{ - {Group: metrics.Forecast, Name: "forecast", Data: []metrics.Slot{ + {Group: metrics.Forecast, Title: "forecast", Data: []metrics.Slot{ {Energy: 10.0}, {Energy: 5.0}, }}, // actual PV production is summed across all pv entities - {Group: metrics.PV, Name: "pv1", Data: []metrics.Slot{ + {Group: metrics.PV, Title: "pv1", Data: []metrics.Slot{ {Energy: 4.0}, {Energy: 3.0}, }}, - {Group: metrics.PV, Name: "pv2", Data: []metrics.Slot{ + {Group: metrics.PV, Title: "pv2", Data: []metrics.Slot{ {Energy: 2.0}, }}, // other groups are ignored - {Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{ + {Group: metrics.Grid, Title: "grid", Data: []metrics.Slot{ {Energy: 99.0}, }}, } diff --git a/cmd/setup.go b/cmd/setup.go index 02947b145..c9601b1f9 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1305,12 +1305,24 @@ func configureSite(conf map[string]any, loadpoints []*core.Loadpoint, tariffs *t func newLoadpoint(idx int, name string, other map[string]any, settingsFn func(*util.Logger) coresettings.Settings) (*core.Loadpoint, error) { log := util.NewLoggerWithLoadpoint("lp-"+strconv.Itoa(idx), idx) - collector, err := metrics.NewCollector(metrics.Loadpoint, name) + collector, err := metrics.NewCollector(metrics.Loadpoint, name, "") if err != nil { return nil, err } - return core.NewLoadpointFromConfig(log, settingsFn(log), collector, other) + lp, err := core.NewLoadpointFromConfig(log, settingsFn(log), collector, other) + if err != nil { + return lp, err + } + + // lazily update entity title + if title := lp.GetTitle(); title != "" { + if err := collector.UpdateTitle(title); err != nil { + return lp, err + } + } + + return lp, nil } func configureLoadpoints(conf globalconfig.All) error { diff --git a/core/helper.go b/core/helper.go index 2db51326e..e0093e1fe 100644 --- a/core/helper.go +++ b/core/helper.go @@ -1,6 +1,7 @@ package core import ( + "cmp" "fmt" "slices" @@ -64,12 +65,7 @@ func deviceProperties[T any](dev config.Device[T]) config.Properties { // deviceTitleOrName returns device title or name func deviceTitleOrName[T any](dev config.Device[T]) string { - if d, ok := dev.(config.ConfigurableDevice[T]); ok { - if title := d.Properties().Title; title != "" { - return title - } - } - return dev.Config().Name + return cmp.Or(deviceProperties(dev).Title, dev.Config().Name) } // circuitMaxPower returns a circuits power limit diff --git a/core/metrics/collector.go b/core/metrics/collector.go index 1b9c29216..02abf8c3b 100644 --- a/core/metrics/collector.go +++ b/core/metrics/collector.go @@ -24,8 +24,8 @@ type Collector struct { started time.Time } -func NewCollector(group, name string, opt ...func(*Accumulator)) (*Collector, error) { - entity, err := createEntity(group, name) +func NewCollector(group, name, title string, opt ...func(*Accumulator)) (*Collector, error) { + entity, err := createEntity(group, name, title) if err != nil { return nil, err } @@ -38,17 +38,30 @@ func NewCollector(group, name string, opt ...func(*Accumulator)) (*Collector, er return c, nil } -func createEntity(group, name string) (entity, error) { - entity := entity{ - Group: group, - Name: name, +// createEntity ensures the entity row exists and refreshes its title. +func createEntity(group, name, title string) (entity, error) { + e := entity{Group: group, Name: name} + + if err := db.Instance.Where(&e).Attrs(entity{Title: title}).FirstOrCreate(&e).Error; err != nil { + return e, err } - if err := db.Instance.Where(&entity).FirstOrCreate(&entity).Error; err != nil { - return entity, err + return e, e.updateTitle(title) +} + +// updateTitle refreshes the entity's stored title if it changed +func (e *entity) updateTitle(title string) error { + if title == "" || e.Title == title { + return nil } - return entity, nil + e.Title = title + return db.Instance.Model(e).UpdateColumn("title", title).Error +} + +// UpdateTitle refreshes the collector entity's stored title if it changed. +func (c *Collector) UpdateTitle(title string) error { + return c.entity.updateTitle(title) } func (c *Collector) process(fun func()) error { diff --git a/core/metrics/collector_test.go b/core/metrics/collector_test.go index 703179df4..0937d8931 100644 --- a/core/metrics/collector_test.go +++ b/core/metrics/collector_test.go @@ -15,7 +15,7 @@ func TestCollectorAddEnergy(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("foo", "foo", WithClock(clock)) + col, err := NewCollector("foo", "foo", "", WithClock(clock)) require.NoError(t, err) require.True(t, col.accu.updated.IsZero()) @@ -42,7 +42,7 @@ func TestCollectorAddEnergyWithImportMeter(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("bar", "bar", WithClock(clock)) + col, err := NewCollector("bar", "bar", "", WithClock(clock)) require.NoError(t, err) // first call: seeds meter, no delta yet @@ -67,7 +67,7 @@ func TestCollectorAddEnergyWithImportMeterAndExport(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("baz", "baz", WithClock(clock)) + col, err := NewCollector("baz", "baz", "", WithClock(clock)) require.NoError(t, err) // seed import meter @@ -93,7 +93,7 @@ func TestCollectorAddEnergyWithExportMeterAndImport(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("baz2", "baz2", WithClock(clock)) + col, err := NewCollector("baz2", "baz2", "", WithClock(clock)) require.NoError(t, err) // seed export meter @@ -119,7 +119,7 @@ func TestCollectorAddEnergyWithBothMeters(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("qux", "qux", WithClock(clock)) + col, err := NewCollector("qux", "qux", "", WithClock(clock)) require.NoError(t, err) // seed both meters @@ -139,7 +139,7 @@ func TestCollectorSetImportAndExportMeterTotal(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("set", "set", WithClock(clock)) + col, err := NewCollector("set", "set", "", WithClock(clock)) require.NoError(t, err) // seed both import and export @@ -163,7 +163,7 @@ func TestCollectorSkipsPartialFirstSlot(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) require.NoError(t, SetupSchema()) - col, err := NewCollector("partial", "partial", WithClock(clk)) + col, err := NewCollector("partial", "partial", "", WithClock(clk)) require.NoError(t, err) // first update mid-slot (00:05) - slot 00:00 is only partially covered @@ -191,3 +191,47 @@ func TestCollectorSkipsPartialFirstSlot(t *testing.T) { require.NoError(t, db.Instance.First(&m).Error) require.Equal(t, int64(15*60), m.Timestamp, "persisted slot should start at 00:15") } + +// TestCreateEntityRefreshesTitle verifies that a second call to createEntity +// with a non-empty title fills in (or updates) the title on an existing row, +// and that passing an empty title never clears a previously stored value. +func TestCreateEntityRefreshesTitle(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + // existing row with empty title (simulates pre-upgrade data) + first, err := createEntity("grid", "grid", "") + require.NoError(t, err) + require.Empty(t, first.Title) + + // lazy-create with a real title must persist it + second, err := createEntity("grid", "grid", "House meter") + require.NoError(t, err) + require.Equal(t, first.Id, second.Id, "should be the same row") + require.Equal(t, "House meter", second.Title) + + var stored entity + require.NoError(t, db.Instance.First(&stored, first.Id).Error) + require.Equal(t, "House meter", stored.Title, "title must be persisted") + + // subsequent call with empty title must not clear the existing one + third, err := createEntity("grid", "grid", "") + require.NoError(t, err) + require.Equal(t, "House meter", third.Title) + + require.NoError(t, db.Instance.First(&stored, first.Id).Error) + require.Equal(t, "House meter", stored.Title, "title must survive empty re-create") + + // changing the title overwrites the stored value + fourth, err := createEntity("grid", "grid", "Grid") + require.NoError(t, err) + require.Equal(t, "Grid", fourth.Title) + + require.NoError(t, db.Instance.First(&stored, first.Id).Error) + require.Equal(t, "Grid", stored.Title) + + // only one row exists despite four createEntity calls with varying titles + var count int64 + require.NoError(t, db.Instance.Model(new(entity)).Where("\"group\" = ? AND name = ?", "grid", "grid").Count(&count).Error) + require.EqualValues(t, 1, count, "must not duplicate existing rows") +} diff --git a/core/metrics/db.go b/core/metrics/db.go index bbaf6b3fb..40cf4dc0e 100644 --- a/core/metrics/db.go +++ b/core/metrics/db.go @@ -23,6 +23,7 @@ type entity struct { Id int `gorm:"column:id;primarykey"` Group string `gorm:"column:group;uniqueIndex:entities_group_name"` Name string `gorm:"column:name;uniqueIndex:entities_group_name"` + Title string `gorm:"column:title"` } func init() { @@ -41,7 +42,7 @@ func SetupSchema() error { } // ensure home entity exists (reserves id=1 for legacy meter FK references) - if _, err := createEntity(Home, Home); err != nil { + if _, err := createEntity(Home, Home, Home); err != nil { return err } diff --git a/core/metrics/db_entities.go b/core/metrics/db_entities.go index 34ad12614..9543dbe9a 100644 --- a/core/metrics/db_entities.go +++ b/core/metrics/db_entities.go @@ -11,6 +11,7 @@ import ( type EntityInfo struct { Group string Name string + Title string // human-readable title as captured at the last lazy-create Slots int // number of persisted 15min slots First time.Time // start of the earliest slot, zero if no data Last time.Time // start of the latest slot, zero if no data @@ -23,6 +24,7 @@ func ListEntities() ([]EntityInfo, error) { type row struct { Group string Name string + Title string Slots int First sql.NullInt64 Last sql.NullInt64 @@ -30,7 +32,7 @@ func ListEntities() ([]EntityInfo, error) { var rows []row if err := db.Instance.Table("entities e"). - Select(`e."group" AS "group", e.name AS name, + Select(`e."group" AS "group", e.name AS name, e.title AS title, COUNT(m.ts) AS slots, MIN(m.ts) AS first, MAX(m.ts) AS last`). Joins("LEFT JOIN meters m ON m.meter = e.id"). @@ -41,7 +43,7 @@ func ListEntities() ([]EntityInfo, error) { res := make([]EntityInfo, 0, len(rows)) for _, r := range rows { - e := EntityInfo{Group: r.Group, Name: r.Name, Slots: r.Slots} + e := EntityInfo{Group: r.Group, Name: r.Name, Title: r.Title, Slots: r.Slots} if r.First.Valid { e.First = time.Unix(r.First.Int64, 0) } diff --git a/core/metrics/db_history.go b/core/metrics/db_history.go index 7f31df1e2..350881d33 100644 --- a/core/metrics/db_history.go +++ b/core/metrics/db_history.go @@ -27,9 +27,9 @@ func roundEnergy(v float64) float64 { return max(0, math.Round(v*1000)/1000) } -// Series represents a named series of energy slots +// Series represents an energy series for one title group or one entity group. type Series struct { - Name string `json:"name,omitempty"` + Title string `json:"title,omitempty"` Group string `json:"group"` Data []Slot `json:"data"` } @@ -55,7 +55,7 @@ var aggregateDurations = map[string]func(time.Time) time.Time{ "month": func(t time.Time) time.Time { return t.AddDate(0, 1, 0) }, } -// QueryEnergy returns aggregated energy data, per entity or per group. +// QueryEnergy returns aggregated energy data, per title or per group. func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, error) { addDuration := aggregateDurations[aggregate] @@ -64,13 +64,18 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, return nil, errors.New("invalid aggregate value") } - groupCols := `e."group", ` + fmt.Sprintf(`strftime('%s', m.ts, 'unixepoch', 'localtime')`, format) - if !grouped { - groupCols = "e.name, " + groupCols + titleExpr := `COALESCE(NULLIF(e.title,''), e.name)` + timeCol := fmt.Sprintf(`strftime('%s', m.ts, 'unixepoch', 'localtime')`, format) + + selectTitle := titleExpr + ` AS title` + groupCols := titleExpr + `, e."group", ` + timeCol + if grouped { + selectTitle = `'' AS title` + groupCols = `e."group", ` + timeCol } type row struct { - Name string + Title string Group string Start SqlTime Energy float64 @@ -78,7 +83,7 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, } tx := db.Instance.Table("meters m"). - Select(`e.name, e."group", + Select(selectTitle + `, e."group", MIN(m.ts) AS start, COALESCE(SUM(m.energy), 0) AS energy, COALESCE(SUM(m.return_energy), 0) AS return_energy`). @@ -100,13 +105,8 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, var res []Series for _, r := range rows { - name := r.Name - if grouped { - name = "" - } - - if n := len(res); n == 0 || res[n-1].Name != name || res[n-1].Group != r.Group { - res = append(res, Series{Name: name, Group: r.Group}) + if n := len(res); n == 0 || res[n-1].Title != r.Title || res[n-1].Group != r.Group { + res = append(res, Series{Title: r.Title, Group: r.Group}) } s := &res[len(res)-1] @@ -180,19 +180,30 @@ func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error { tsSet := make(map[int64]time.Time) endByStart := make(map[int64]time.Time) + label := func(e *Series, g string) string { + if e.Title != "" { + return e.Title + } + return g + } + + prefix := func(g, l string) string { + if l == g { + return g + } + return g + "." + l + } + for _, g := range groups { entities := byGroup[g] - sort.Slice(entities, func(i, j int) bool { return entities[i].Name < entities[j].Name }) + sort.Slice(entities, func(i, j int) bool { return label(entities[i], g) < label(entities[j], g) }) for _, e := range entities { - name := e.Name - if name == "" { - name = g - } - header = append(header, g+"."+name+".energy.Wh") + p := prefix(g, label(e, g)) + header = append(header, p+".energy.Wh") cols = append(cols, col{series: e, returnEnergy: false}) if hasReturnEnergy(g) { - header = append(header, g+"."+name+".returnEnergy.Wh") + header = append(header, p+".returnEnergy.Wh") cols = append(cols, col{series: e, returnEnergy: true}) } for _, slot := range e.Data { diff --git a/core/metrics/db_history_test.go b/core/metrics/db_history_test.go index bf39d74ab..04a900b0a 100644 --- a/core/metrics/db_history_test.go +++ b/core/metrics/db_history_test.go @@ -21,11 +21,11 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) { t1 := t0.Add(15 * time.Minute) series := SeriesCSV{ - {Group: PV, Name: "pv", Data: []Slot{mkSlot(t0, 2.5, 0), mkSlot(t1, 3.0, 0)}}, - {Group: Battery, Name: "battery-home", Data: []Slot{mkSlot(t0, 0, 0.7800), mkSlot(t1, 0.5300, 0)}}, - {Group: Battery, Name: "battery-garage", Data: []Slot{mkSlot(t0, 0, 1.2500), mkSlot(t1, 0, 0)}}, - {Group: Grid, Name: "grid", Data: []Slot{mkSlot(t0, 0, 0.4123), mkSlot(t1, 0.1, 0)}}, - {Group: Home, Name: "home", Data: []Slot{mkSlot(t0, 0.3661, 0), mkSlot(t1, 0.4, 0)}}, + {Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2.5, 0), mkSlot(t1, 3.0, 0)}}, + {Group: Battery, Title: "battery-home", Data: []Slot{mkSlot(t0, 0, 0.7800), mkSlot(t1, 0.5300, 0)}}, + {Group: Battery, Title: "battery-garage", Data: []Slot{mkSlot(t0, 0, 1.2500), mkSlot(t1, 0, 0)}}, + {Group: Grid, Title: "grid", Data: []Slot{mkSlot(t0, 0, 0.4123), mkSlot(t1, 0.1, 0)}}, + {Group: Home, Title: "home", Data: []Slot{mkSlot(t0, 0.3661, 0), mkSlot(t1, 0.4, 0)}}, } var buf bytes.Buffer @@ -42,13 +42,13 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) { header := rows[0] expected := []string{ "time.start", "time.end", - "pv.pv.energy.Wh", + "pv.energy.Wh", "battery.battery-garage.energy.Wh", "battery.battery-garage.returnEnergy.Wh", "battery.battery-home.energy.Wh", "battery.battery-home.returnEnergy.Wh", - "grid.grid.energy.Wh", "grid.grid.returnEnergy.Wh", - "home.home.energy.Wh", + "grid.energy.Wh", "grid.returnEnergy.Wh", + "home.energy.Wh", } - require.Equal(t, expected, header, "header order: GROUP_ORDER, alphabetical entities; returnEnergy only for grid/battery") + require.Equal(t, expected, header, "single-entity groups omit the title level; multi-entity groups include the title") // time.end = time.start + slot length (15 min in mkSlot) require.Equal(t, t0.Local().Format("2006-01-02 15:04:05"), rows[1][0]) @@ -68,7 +68,7 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) { func TestSeriesCSV_GermanLocale(t *testing.T) { t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) series := SeriesCSV{ - {Group: PV, Name: "pv", Data: []Slot{mkSlot(t0, 2.5, 0)}}, + {Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2.5, 0)}}, } var buf bytes.Buffer @@ -91,8 +91,8 @@ func TestSeriesCSV_MissingSlotIsEmpty(t *testing.T) { // Two entities, second one only has data for the first timestamp → second // timestamp must produce an empty cell rather than 0.000. series := SeriesCSV{ - {Group: PV, Name: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}}, - {Group: PV, Name: "b", Data: []Slot{mkSlot(t0, 3, 0)}}, + {Group: PV, Title: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}}, + {Group: PV, Title: "b", Data: []Slot{mkSlot(t0, 3, 0)}}, } var buf bytes.Buffer @@ -119,7 +119,7 @@ func TestSeriesCSV_BatteryHasReturnEnergyColumn(t *testing.T) { // returnEnergy column. t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) series := SeriesCSV{ - {Group: Battery, Name: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}}, + {Group: Battery, Title: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}}, } var buf bytes.Buffer diff --git a/core/metrics/db_test.go b/core/metrics/db_test.go index 8d796dff5..7aa6e166e 100644 --- a/core/metrics/db_test.go +++ b/core/metrics/db_test.go @@ -101,7 +101,7 @@ func TestQueryEnergyGrouped(t *testing.T) { require.NoError(t, err) require.Len(t, res, 1) require.Equal(t, Grid, res[0].Group) - require.Empty(t, res[0].Name) + require.Empty(t, res[0].Title) require.Len(t, res[0].Data, 2) require.InDelta(t, 1+2, res[0].Data[0].Energy, 0.001) require.InDelta(t, 3+4, res[0].Data[1].Energy, 0.001) @@ -138,19 +138,19 @@ func TestQueryEnergyMultipleSeries(t *testing.T) { require.NoError(t, err) require.Len(t, res, 3) - byName := map[string]Series{} + byTitle := map[string]Series{} for _, s := range res { require.Len(t, s.Data, 2) - byName[s.Name] = s + byTitle[s.Title] = s } - require.Equal(t, Grid, byName[Grid].Group) - require.Equal(t, PV, byName["pv1"].Group) - require.Equal(t, PV, byName["pv2"].Group) + require.Equal(t, Grid, byTitle[Grid].Group) + require.Equal(t, PV, byTitle["pv1"].Group) + require.Equal(t, PV, byTitle["pv2"].Group) - require.InDelta(t, 1, byName[Grid].Data[0].Energy, 0.001) - require.InDelta(t, 2, byName[Grid].Data[1].Energy, 0.001) - require.InDelta(t, 10, byName["pv1"].Data[0].ReturnEnergy, 0.001) - require.InDelta(t, 21, byName["pv2"].Data[1].ReturnEnergy, 0.001) + require.InDelta(t, 1, byTitle[Grid].Data[0].Energy, 0.001) + require.InDelta(t, 2, byTitle[Grid].Data[1].Energy, 0.001) + require.InDelta(t, 10, byTitle["pv1"].Data[0].ReturnEnergy, 0.001) + require.InDelta(t, 21, byTitle["pv2"].Data[1].ReturnEnergy, 0.001) // grouped: 2 series, pv summed per bucket res, err = QueryEnergy(from, to, "hour", true) @@ -159,7 +159,7 @@ func TestQueryEnergyMultipleSeries(t *testing.T) { byGroup := map[string]Series{} for _, s := range res { - require.Empty(t, s.Name) + require.Empty(t, s.Title) require.Len(t, s.Data, 2) byGroup[s.Group] = s } diff --git a/core/site.go b/core/site.go index 63dc02bfe..d972774ff 100644 --- a/core/site.go +++ b/core/site.go @@ -136,7 +136,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.prioritizer = prioritizer.New(log) site.stats = NewStats() - me, err := metrics.NewCollector(metrics.Home, metrics.Home) + me, err := metrics.NewCollector(metrics.Home, metrics.Home, metrics.Home) if err != nil { return err } @@ -188,7 +188,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif return errors.New("missing grid meter instance") } - me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef) + me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, deviceTitleOrName(dev)) if err != nil { return err } @@ -204,7 +204,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.pvMeters = append(site.pvMeters, dev) // energy collector (for history persistence and forecast scaling) - me, err := metrics.NewCollector(metrics.PV, ref) + me, err := metrics.NewCollector(metrics.PV, ref, deviceTitleOrName(dev)) if err != nil { return err } @@ -212,7 +212,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif } // solar forecast collector (mirrors PV history shape, used for scale lookup) - fc, err := metrics.NewCollector(metrics.Forecast, metrics.Forecast) + fc, err := metrics.NewCollector(metrics.Forecast, metrics.Forecast, metrics.Forecast) if err != nil { return err } @@ -226,7 +226,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif } site.batteryMeters = append(site.batteryMeters, dev) - me, err := metrics.NewCollector(metrics.Battery, ref) + me, err := metrics.NewCollector(metrics.Battery, ref, deviceTitleOrName(dev)) if err != nil { return err } @@ -241,7 +241,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif } site.extMeters = append(site.extMeters, dev) - me, err := metrics.NewCollector(metrics.Meter, ref) + me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev)) if err != nil { return err } @@ -256,7 +256,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif } site.auxMeters = append(site.auxMeters, dev) - me, err := metrics.NewCollector(metrics.Meter, ref) + me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev)) if err != nil { return err } diff --git a/core/site_optimizer.go b/core/site_optimizer.go index b886f6689..d0f6edaf5 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -1,6 +1,7 @@ package core import ( + "cmp" "context" "encoding/json" "errors" @@ -121,7 +122,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { minLen = min(minLen, len(solar)) } - uri := lo.CoalesceOrEmpty(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI) + uri := cmp.Or(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI) if uri == OPTIMIZER_URI { // limit to 2 days for sake of performance minLen = min(2*96, minLen) diff --git a/tests/energy-history.spec.ts b/tests/energy-history.spec.ts index acb021827..d4a48f2a8 100644 --- a/tests/energy-history.spec.ts +++ b/tests/energy-history.spec.ts @@ -23,8 +23,8 @@ test.describe("api", () => { const data = await res.json(); expect(data).toHaveLength(2); - const grid = data.find((s: { name: string }) => s.name === "grid"); - const home = data.find((s: { name: string }) => s.name === "home"); + const grid = data.find((s: { title: string }) => s.title === "grid"); + const home = data.find((s: { title: string }) => s.title === "home"); expect(grid).toBeDefined(); expect(home).toBeDefined(); @@ -46,8 +46,8 @@ test.describe("api", () => { const data = await res.json(); expect(data).toHaveLength(2); - const grid = data.find((s: { name: string }) => s.name === "grid"); - const home = data.find((s: { name: string }) => s.name === "home"); + const grid = data.find((s: { title: string }) => s.title === "grid"); + const home = data.find((s: { title: string }) => s.title === "home"); expect(grid).toBeDefined(); expect(home).toBeDefined(); From 3758ce1957462b84a1dd9830f1291557d8421d1f Mon Sep 17 00:00:00 2001 From: Alexandre JARDON <28548335+webalexeu@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:17:54 +0200 Subject: [PATCH 0276/1128] Add OCPP forwarder (#29154) --- AGENTS.md | 1 + assets/css/app.css | 3 - assets/js/components/Config/JsonModal.vue | 3 +- .../components/Config/OcppForwarderButton.vue | 107 +++ .../components/Config/OcppForwarderModal.vue | 280 +++++++ assets/js/components/Config/OcppModal.vue | 160 ++-- .../js/components/Config/StatusIndicator.vue | 75 ++ .../MaterialIcon/OcppForwardStatus.vue | 37 + assets/js/configModal.ts | 20 +- assets/js/types/evcc.ts | 19 + assets/js/views/Config.vue | 17 +- charger/ocpp/cs.go | 7 + charger/ocpp/forwarder.go | 746 ++++++++++++++++++ charger/ocpp/instance.go | 67 +- cmd/root.go | 19 +- cmd/setup.go | 11 + core/keys/global.go | 1 + docs/agents/ocpp-forwarder.md | 44 ++ i18n/de.json | 35 +- i18n/en.json | 35 +- i18n/fr.json | 38 + server/http.go | 3 + server/http_ocppforwarder_handler.go | 45 ++ tests/config-ocpp.spec.ts | 165 +++- tests/simulator/api.ts | 34 +- tests/simulator/ocppClient.ts | 103 ++- tests/simulator/ocppServer.ts | 128 +++ tests/simulator/src/Simulator.vue | 116 +++ 28 files changed, 2182 insertions(+), 137 deletions(-) create mode 100644 assets/js/components/Config/OcppForwarderButton.vue create mode 100644 assets/js/components/Config/OcppForwarderModal.vue create mode 100644 assets/js/components/Config/StatusIndicator.vue create mode 100644 assets/js/components/MaterialIcon/OcppForwardStatus.vue create mode 100644 charger/ocpp/forwarder.go create mode 100644 docs/agents/ocpp-forwarder.md create mode 100644 server/http_ocppforwarder_handler.go create mode 100644 tests/simulator/ocppServer.ts diff --git a/AGENTS.md b/AGENTS.md index 38fffe45b..e78c50b56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w | [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 | diff --git a/assets/css/app.css b/assets/css/app.css index 944048c48..22caccc07 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -73,9 +73,6 @@ --bs-danger: var(--evcc-red); --bs-danger-rgb: var(--evcc-red-rgb); - --bs-danger: var(--evcc-red); - --bs-danger-rgb: var(--evcc-red-rgb); - --bs-form-invalid-border-color: var(--bs-danger); --bs-body-font-size: 14px; diff --git a/assets/js/components/Config/JsonModal.vue b/assets/js/components/Config/JsonModal.vue index 4db6182f9..d62696227 100644 --- a/assets/js/components/Config/JsonModal.vue +++ b/assets/js/components/Config/JsonModal.vue @@ -166,7 +166,8 @@ export default { if (shouldClose) { await closeModal(); } else { - await this.load(); + // keep open: saved values become the new baseline (no longer dirty) + this.serverValues = deepClone(this.values); } } if (res.status === 400) { diff --git a/assets/js/components/Config/OcppForwarderButton.vue b/assets/js/components/Config/OcppForwarderButton.vue new file mode 100644 index 000000000..8e3f12d38 --- /dev/null +++ b/assets/js/components/Config/OcppForwarderButton.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/assets/js/components/Config/OcppForwarderModal.vue b/assets/js/components/Config/OcppForwarderModal.vue new file mode 100644 index 000000000..f82f4b71d --- /dev/null +++ b/assets/js/components/Config/OcppForwarderModal.vue @@ -0,0 +1,280 @@ + + + diff --git a/assets/js/components/Config/OcppModal.vue b/assets/js/components/Config/OcppModal.vue index 1a41b2533..9ac301e1e 100644 --- a/assets/js/components/Config/OcppModal.vue +++ b/assets/js/components/Config/OcppModal.vue @@ -23,52 +23,40 @@
-
+
{{ $t("config.ocpp.noChargers") }}
- -
-
{{ $t("config.ocpp.connectionStatus") }}
-

{{ $t("config.ocpp.connectionStatusHelp") }}

+ +
+
{{ $t("config.ocpp.stations") }}
+

{{ $t("config.ocpp.stationsHelp") }}

-
    +
    • - {{ station.id }} - - {{ $t(`config.ocpp.status.${station.status}`) }} - -
    • -
    -
- - -
-
{{ $t("config.ocpp.detectedChargers") }}
-

{{ $t("config.ocpp.detectedHelp") }}

- -
    -
  • - {{ station.id }} - - {{ $t(`config.ocpp.status.${station.status}`) }} - + +
    +
    + {{ entry.title }} +
    + {{ + entry.id + }} +
    +
@@ -81,8 +69,25 @@ import { defineComponent, type PropType } from "vue"; import GenericModal from "../Helper/GenericModal.vue"; import FormRow from "./FormRow.vue"; import Markdown from "./Markdown.vue"; -import type { Ocpp, OcppStationStatus } from "@/types/evcc"; +import OcppForwarderButton from "./OcppForwarderButton.vue"; +import StatusIndicator from "./StatusIndicator.vue"; +import type { + Ocpp, + OcppForwarderRule, + OcppForwarderSession, + OcppStationStatus, +} from "@/types/evcc"; +import { OCPP_STATION_STATUS } from "@/types/evcc"; import { getOcppUrl, getOcppUrlWithStationId } from "@/utils/ocpp"; +import store from "@/store"; + +type StationEntry = { + id: string; + status: OcppStationStatus["status"]; + title?: string; + rule?: OcppForwarderRule; + error?: string; +}; export default defineComponent({ name: "OcppModal", @@ -90,46 +95,74 @@ export default defineComponent({ GenericModal, FormRow, Markdown, + OcppForwarderButton, + StatusIndicator, }, props: { ocpp: { type: Object as PropType, default: () => ({ config: { port: 0 }, status: { stations: [] } }), }, + stationTitles: { + type: Object as PropType>, + default: () => ({}), + }, }, computed: { - status() { - return this.ocpp.status; - }, - stations() { - return this.status.stations; - }, ocppUrl(): string { return getOcppUrl(this.ocpp); }, ocppUrlWithStationId(): string { return getOcppUrlWithStationId(this.ocpp); }, - connectedStations(): OcppStationStatus[] { - return this.stations.filter( - (s) => s.status === "connected" || s.status === "configured" - ); + rules(): OcppForwarderRule[] { + return store.state?.ocppforwarder?.config || []; }, - detectedStations(): OcppStationStatus[] { - return this.stations.filter((s) => s.status === "unknown"); + sessions(): OcppForwarderSession[] { + return store.state?.ocppforwarder?.status || []; + }, + // merge of published stations and configured forwarder rules, keyed by id. + // a rule without a matching station is shown as "unknown". + entries(): StationEntry[] { + const byId = new Map(); + const published = new Set(); + for (const station of this.ocpp.status.stations) { + byId.set(station.id, { + id: station.id, + status: station.status, + title: this.stationTitles[station.id], + }); + published.add(station.id); + } + for (const rule of this.rules) { + if (rule.stationId === "*") continue; + const entry = byId.get(rule.stationId) || { + id: rule.stationId, + status: OCPP_STATION_STATUS.UNKNOWN, + title: this.stationTitles[rule.stationId], + }; + entry.rule = rule; + entry.error = this.sessions.find((s) => s.chargerId === rule.stationId)?.error; + byId.set(rule.stationId, entry); + } + // published stations first, then rule-only entries; each sorted by id + const byIdAlpha = (a: StationEntry, b: StationEntry) => a.id.localeCompare(b.id); + const all = [...byId.values()]; + return [ + ...all.filter((e) => published.has(e.id)).sort(byIdAlpha), + ...all.filter((e) => !published.has(e.id)).sort(byIdAlpha), + ]; }, }, methods: { - statusBadgeClass(status: string): string { + statusVariant(status: string): "success" | "warning" | "muted" { switch (status) { case "connected": - return "bg-success"; + return "success"; case "configured": - return "bg-warning"; - case "unknown": - return "bg-secondary"; + return "warning"; default: - return "bg-secondary"; + return "muted"; } }, }, @@ -142,7 +175,12 @@ export default defineComponent({ padding-left: 0; } -.list-group-item code { - font-size: 0.9rem; +.station-bar { + --bs-border-color: var(--bs-gray-light); +} + +/* min-width:0 lets the identity actually truncate */ +.station-identity { + min-width: 0; } diff --git a/assets/js/components/Config/StatusIndicator.vue b/assets/js/components/Config/StatusIndicator.vue new file mode 100644 index 000000000..17c92d037 --- /dev/null +++ b/assets/js/components/Config/StatusIndicator.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/assets/js/components/MaterialIcon/OcppForwardStatus.vue b/assets/js/components/MaterialIcon/OcppForwardStatus.vue new file mode 100644 index 000000000..97b844beb --- /dev/null +++ b/assets/js/components/MaterialIcon/OcppForwardStatus.vue @@ -0,0 +1,37 @@ + + + diff --git a/assets/js/configModal.ts b/assets/js/configModal.ts index 715020b3c..68f1256a6 100644 --- a/assets/js/configModal.ts +++ b/assets/js/configModal.ts @@ -7,6 +7,7 @@ export interface ModalEntry { id?: number; type?: string; choices?: string[]; + station?: string; } export interface ModalResult { @@ -106,7 +107,12 @@ function syncAllModals(): void { // Parse brackets: "meter[type:grid]" => { name: "meter", type: "grid" } // "meter[choices:pv,battery]" => { name: "meter", choices: ["pv", "battery"] } -export function parseKey(key: string): { name: string; type?: string; choices?: string[] } { +export function parseKey(key: string): { + name: string; + type?: string; + choices?: string[]; + station?: string; +} { const bracketMatch = key.match(/^([^[]+)\[([^\]]+)\]$/); if (!bracketMatch) { return { name: key }; @@ -126,6 +132,9 @@ export function parseKey(key: string): { name: string; type?: string; choices?: if (paramKey === "choices") { return { name, choices: paramValue.split(",") }; } + if (paramKey === "station") { + return { name, station: paramValue }; + } return { name }; } @@ -158,6 +167,7 @@ export function parseQueryString(queryString: string): ModalEntry[] { } if (parsed.type) entry.type = parsed.type; if (parsed.choices) entry.choices = parsed.choices; + if (parsed.station) entry.station = parsed.station; entries.push(entry); } return entries; @@ -172,6 +182,8 @@ export function buildQuery(stack: ModalEntry[]): Record { key += `[type:${entry.type}]`; } else if (entry.choices?.length) { key += `[choices:${entry.choices.join(",")}]`; + } else if (entry.station) { + key += `[station:${entry.station}]`; } query[key] = entry.id !== undefined ? String(entry.id) : ""; } @@ -225,7 +237,7 @@ export function initConfigModal(router: Router): void { export function openModal( name: string, - params?: { id?: number; type?: string; choices?: string[] } + params?: { id?: number; type?: string; choices?: string[]; station?: string } ): Promise { if (!_router) { return Promise.resolve({ action: "cancelled" }); @@ -235,6 +247,7 @@ export function openModal( if (params?.id !== undefined) entry.id = params.id; if (params?.type) entry.type = params.type; if (params?.choices) entry.choices = params.choices; + if (params?.station) entry.station = params.station; const newStack = [...configModal.stack, entry]; const query = buildQuery(newStack); @@ -273,7 +286,7 @@ export async function closeModal(result?: ModalResult): Promise { export function replaceModal( name: string, - params?: { id?: number; type?: string; choices?: string[] } + params?: { id?: number; type?: string; choices?: string[]; station?: string } ): void { if (!_router) return; @@ -281,6 +294,7 @@ export function replaceModal( if (params?.id !== undefined) entry.id = params.id; if (params?.type) entry.type = params.type; if (params?.choices) entry.choices = params.choices; + if (params?.station) entry.station = params.station; const newStack = [...configModal.stack.slice(0, -1), entry]; const query = buildQuery(newStack); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index c5cbdc156..9d4a3439c 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -121,6 +121,7 @@ export interface State { config?: string; database?: string; ocpp?: Ocpp; + ocppforwarder?: ConfigStatus; optimizer?: boolean; mcp?: boolean; } @@ -137,6 +138,24 @@ export interface OcppConfig { port: number; } +export interface OcppForwarderRule { + stationId: string; + upstreamUrl: string; + password?: string; + upstreamStationId?: string; + username?: string; + insecure?: boolean; + caCert?: string; + readOnly?: boolean; +} + +export interface OcppForwarderSession { + chargerId: string; + upstreamUrl: string; + upstreamConnected: boolean; + error?: string; +} + export interface OcppStatus { externalUrl?: string; stations: OcppStationStatus[]; diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index d858b4071..20d9f2d6c 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -449,7 +449,8 @@ :yamlSource="eebus?.yamlSource" @changed="loadDirty" /> - + + @@ -479,6 +480,7 @@ import EebusIcon from "../components/MaterialIcon/Eebus.vue"; import EebusModal from "../components/Config/EebusModal.vue"; import OcppIcon from "../components/MaterialIcon/Ocpp.vue"; import OcppModal from "../components/Config/OcppModal.vue"; +import OcppForwarderModal from "../components/Config/OcppForwarderModal.vue"; import formatter from "../mixins/formatter"; import GeneralConfig from "../components/Config/GeneralConfig.vue"; import HemsIcon from "../components/MaterialIcon/Hems.vue"; @@ -571,6 +573,7 @@ export default defineComponent({ EebusModal, OcppIcon, OcppModal, + OcppForwarderModal, GeneralConfig, HemsIcon, HemsModal, @@ -856,6 +859,18 @@ export default defineComponent({ } return { configured: { value: false } }; }, + // maps an OCPP station id to its loadpoint title (fallback: charger title) + stationTitles(): Record { + const map: Record = {}; + this.chargers.forEach((charger) => { + const stationId = charger.config?.["stationid"]; + if (typeof stationId !== "string" || !stationId) return; + const loadpoint = this.loadpoints.find((lp) => lp.charger === charger.name); + const title = loadpoint?.title || charger.config?.title; + if (title) map[stationId] = title; + }); + return map; + }, messagingTags(): DeviceTags { if (this.messagingUiConfigured) { const events = store.state?.messagingEvents || []; diff --git a/charger/ocpp/cs.go b/charger/ocpp/cs.go index bd582892c..beca61356 100644 --- a/charger/ocpp/cs.go +++ b/charger/ocpp/cs.go @@ -9,6 +9,7 @@ import ( "github.com/evcc-io/evcc/util" ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ws" ) type registration struct { @@ -29,6 +30,12 @@ type CS struct { regs map[string]*registration // guarded by mu mutex txnId atomic.Int64 publishFunc func() + server ws.Server // raw server, used by the forwarder to write frames +} + +// Write sends a raw OCPP frame to the charger with the given station ID. +func (cs *CS) Write(id string, data []byte) error { + return cs.server.Write(id, data) } type stationStatus struct { diff --git a/charger/ocpp/forwarder.go b/charger/ocpp/forwarder.go new file mode 100644 index 000000000..7f5e4d0d6 --- /dev/null +++ b/charger/ocpp/forwarder.go @@ -0,0 +1,746 @@ +package ocpp + +// Hybrid OCPP proxy. Chargers connect to evcc's central system; for each charger +// with a matching rule a "sidecar" WebSocket to the upstream OCPP server runs in +// parallel. Billing-critical Calls (actionsRelayedToUpstream) are relayed to +// upstream as the authoritative responder and evcc's handler is bypassed; all +// other messages are processed by evcc and mirrored to upstream for observation. +// Upstream Calls are injected into the charger unless the rule is read-only. +// +// Design: docs/agents/ocpp-forwarder.md + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/evcc-io/evcc/util" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocppj" + "github.com/lorenzodonini/ocpp-go/ws" +) + +// forwarder rules, guarded by forwarderMu +var ( + forwarderMu sync.RWMutex + forwarderRules []ForwarderRule +) + +// ForwarderEnabled returns true when at least one rule is configured. +func ForwarderEnabled() bool { + forwarderMu.RLock() + defer forwarderMu.RUnlock() + return len(forwarderRules) > 0 +} + +// ApplyForwarderRules replaces the forwarding rules at runtime and republishes status. +func ApplyForwarderRules(rules []ForwarderRule) { + forwarderMu.Lock() + forwarderRules = rules + forwarderMu.Unlock() + + valid := make(map[string]bool, len(rules)) + hasWildcard := false + for _, r := range rules { + valid[r.StationID] = true + if r.StationID == "*" { + hasWildcard = true + } + } + + // drop sidecars/errors for removed rules + var stale []*sidecar + sidecarsMu.Lock() + for id, sc := range sidecars { + if !valid[id] && !hasWildcard { + stale = append(stale, sc) + delete(sidecars, id) + } + } + for id := range forwarderErrors { + if !valid[id] && !hasWildcard { + delete(forwarderErrors, id) + } + } + sidecarsMu.Unlock() + for _, sc := range stale { + sc.conn.CloseNow() + } + + // connected charger: (re)dial sidecar; otherwise test-dial to surface config errors + for _, r := range rules { + if r.StationID == "*" || r.UpstreamURL == "" { + continue + } + sidecarsMu.Lock() + sc, active := sidecars[r.StationID] + connected := connectedChargers[r.StationID] + var changed *sidecar + if active && !sc.rule.sameConnection(r) { + changed = sc + delete(sidecars, r.StationID) + active = false + } + sidecarsMu.Unlock() + if changed != nil { + changed.conn.CloseNow() + } + if active { + continue + } + if connected { + pendingMu.Lock() + pendingMsgs[r.StationID] = nil + pendingMu.Unlock() + go dialUpstreamSidecar(r.StationID, r) + } else { + go validateUpstream(r.StationID, r) + } + } + + notifyUpdated() +} + +// validateUpstream test-dials a rule's upstream and records/clears the error so +// the UI reflects unreachable hosts. +func validateUpstream(id string, rule ForwarderRule) { + upstreamBase := strings.TrimRight(rule.UpstreamURL, "/") + upstreamPath := rule.upstreamPath(id) + + tlsConfig := &tls.Config{InsecureSkipVerify: rule.Insecure} + if rule.CaCert != "" { + caCertPool := x509.NewCertPool() + if ok := caCertPool.AppendCertsFromPEM([]byte(rule.CaCert)); !ok { + recordForwarderError(id, "invalid CA certificate") + notifyUpdated() + return + } + tlsConfig.RootCAs = caCertPool + } + + var header http.Header + if rule.Username != "" || rule.Password != "" { + header = authHeader(rule.Username, rule.Password) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + conn, _, err := websocket.Dial(ctx, upstreamBase+upstreamPath, &websocket.DialOptions{ + Subprotocols: []string{"ocpp1.6"}, + HTTPHeader: header, + HTTPClient: &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}, + }) + cancel() + + // a charger may have connected meanwhile; its sidecar is authoritative + sidecarsMu.Lock() + _, active := sidecars[id] + sidecarsMu.Unlock() + if active { + if conn != nil { + conn.CloseNow() + } + return + } + + if err != nil { + recordForwarderError(id, err.Error()) + notifyUpdated() + return + } + conn.Close(websocket.StatusNormalClosure, "") + if clearForwarderError(id) { + notifyUpdated() + } +} + +// ForwarderRules returns the current forwarding rules. +func ForwarderRules() []ForwarderRule { + forwarderMu.RLock() + defer forwarderMu.RUnlock() + return forwarderRules +} + +// StartForwarder is a no-op; hooks fire on every charger connection. +func StartForwarder() {} + +// actionsRelayedToUpstream lists actions for which upstream is the authoritative +// Central System: evcc's handler is bypassed and upstream's reply relayed back. +var actionsRelayedToUpstream = map[string]bool{ + "Authorize": true, + "StartTransaction": true, + "StopTransaction": true, + "DataTransfer": true, +} + +var forwarderLog = util.NewLogger("ocpp-forwarder") + +// init wires the forwarder hooks declared in instance.go. +func init() { + chargerConnectHook = onChargerConnect + chargerDisconnectHook = onChargerDisconnect + chargerMessageHook = onChargerMessage +} + +// sidecar holds an upstream connection for a single charger. +type sidecar struct { + chargerID string + upstreamURL string + rule ForwarderRule // rule used to dial; detects param changes + conn *websocket.Conn + + // message IDs of upstream-initiated Calls; the charger's reply is routed back to upstream + pendingUpstreamCallsMu sync.Mutex + pendingUpstreamCalls map[string]struct{} + + // message IDs of charger Calls whose evcc handler was bypassed; upstream's reply is relayed to the charger + pendingChargerCallsMu sync.Mutex + pendingChargerCalls map[string]struct{} + + // when > 0, forward at most one MeterValues per interval to upstream; evcc still sees every frame + meterInterval time.Duration + lastMeterFwdMu sync.Mutex + lastMeterFwd time.Time +} + +var ( + sidecarsMu sync.Mutex + sidecars = make(map[string]*sidecar) + + // connected charger ids so a runtime-added rule can start a sidecar; guarded by sidecarsMu + connectedChargers = make(map[string]bool) + + // last upstream failure per charger, surfaced to the UI; guarded by sidecarsMu + forwarderErrors = make(map[string]string) + + // raw frames buffered per charger while the sidecar dials; flushed in order + // on connect so BootNotification reaches upstream + pendingMu sync.Mutex + pendingMsgs = make(map[string][][]byte) +) + +// resolveRule returns the forwarding rule for chargerID, or the "*" fallback. +func resolveRule(chargerID string) (ForwarderRule, bool) { + forwarderMu.RLock() + rules := forwarderRules + forwarderMu.RUnlock() + var fallback ForwarderRule + var hasFallback bool + for _, r := range rules { + if r.StationID == chargerID { + return r, true + } + if r.StationID == "*" { + fallback = r + hasFallback = true + } + } + return fallback, hasFallback +} + +// onChargerConnect dials a sidecar for the connecting charger when a rule matches. +func onChargerConnect(ch ws.Channel) { + id := ch.ID() + + sidecarsMu.Lock() + connectedChargers[id] = true + sidecarsMu.Unlock() + + rule, ok := resolveRule(id) + if !ok { + return + } + // open the buffer before dialling so early messages are captured, not dropped + pendingMu.Lock() + pendingMsgs[id] = nil + pendingMu.Unlock() + + go dialUpstreamSidecar(id, rule) +} + +func dialUpstreamSidecar(id string, rule ForwarderRule) { + upstreamBase := strings.TrimRight(rule.UpstreamURL, "/") + upstreamPath := rule.upstreamPath(id) + + var header http.Header + if rule.Username != "" || rule.Password != "" { + header = authHeader(rule.Username, rule.Password) + } + + tlsConfig := &tls.Config{InsecureSkipVerify: rule.Insecure} + if rule.CaCert != "" { + caCertPool := x509.NewCertPool() + if ok := caCertPool.AppendCertsFromPEM([]byte(rule.CaCert)); !ok { + forwarderLog.WARN.Printf("forwarder: failed to parse CA cert for %s; forwarding disabled", id) + recordForwarderError(id, "invalid CA certificate") + notifyUpdated() + drainPendingWithErrors(id, nil) + return + } + tlsConfig.RootCAs = caCertPool + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + conn, _, err := websocket.Dial(ctx, upstreamBase+upstreamPath, &websocket.DialOptions{ + Subprotocols: []string{"ocpp1.6"}, + HTTPHeader: header, + HTTPClient: &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}, + }) + cancel() + if err != nil { + forwarderLog.WARN.Printf("forwarder: dial upstream for %s: %v; forwarding disabled", id, err) + recordForwarderError(id, err.Error()) + notifyUpdated() + drainPendingWithErrors(id, nil) + return + } + conn.SetReadLimit(-1) // no limit; OCPP frames can be large + + sc := &sidecar{ + chargerID: id, + upstreamURL: upstreamBase, + rule: rule, + conn: conn, + pendingUpstreamCalls: make(map[string]struct{}), + pendingChargerCalls: make(map[string]struct{}), + } + + // install sidecar and drain the pending buffer + sidecarsMu.Lock() + pendingMu.Lock() + sidecars[id] = sc + buffered := pendingMsgs[id] + delete(pendingMsgs, id) + pendingMu.Unlock() + sidecarsMu.Unlock() + + // flush buffered Calls; register relay actions so upstream's reply routes back. + // CallResults/Errors are skipped: evcc already answered them. + flushed := 0 + for _, frame := range buffered { + msgType, msgID, action, err := parseOCPPFrame(frame) + if err != nil || msgType != ocppj.CALL { + continue + } + if actionsRelayedToUpstream[action] { + sc.pendingChargerCallsMu.Lock() + sc.pendingChargerCalls[msgID] = struct{}{} + sc.pendingChargerCallsMu.Unlock() + } + if err := conn.Write(context.Background(), websocket.MessageText, frame); err != nil { + forwarderLog.ERROR.Printf("forwarder: write buffered frame to upstream for %s: %v", id, err) + conn.CloseNow() + recordForwarderError(id, err.Error()) + notifyUpdated() + return + } + flushed++ + } + if flushed > 0 { + forwarderLog.DEBUG.Printf("forwarder: flushed %d call(s) to upstream for %s (skipped %d non-call frame(s))", + flushed, id, len(buffered)-flushed) + } + + clearForwarderError(id) + notifyUpdated() + forwarderLog.INFO.Printf("forwarder: %s → %s", id, upstreamBase+upstreamPath) + + sc.readFromUpstream(rule.ReadOnly) +} + +// drainPendingWithErrors discards charger id's pending buffer, sending a CallError +// to the charger for each buffered relay Call so it is not left hanging. sc may be nil. +func drainPendingWithErrors(id string, sc *sidecar) { + pendingMu.Lock() + buffered := pendingMsgs[id] + delete(pendingMsgs, id) + pendingMu.Unlock() + + cs := Instance() + + for _, frame := range buffered { + msgType, msgID, action, err := parseOCPPFrame(frame) + if err != nil || msgType != ocppj.CALL || !actionsRelayedToUpstream[action] { + continue + } + errFrame, _ := (&ocppj.CallError{ + MessageTypeId: ocppj.CALL_ERROR, + UniqueId: msgID, + ErrorCode: ocppj.GenericError, + ErrorDescription: "Upstream OCPP server unavailable", + }).MarshalJSON() + if writeErr := cs.Write(id, errFrame); writeErr != nil { + forwarderLog.WARN.Printf("forwarder: send error for pending call %s to %s: %v", msgID, id, writeErr) + } + } + + // error out tracked pendingChargerCalls (sidecar dropped mid-session) + if sc != nil { + sc.pendingChargerCallsMu.Lock() + for msgID := range sc.pendingChargerCalls { + errFrame, _ := (&ocppj.CallError{ + MessageTypeId: ocppj.CALL_ERROR, + UniqueId: msgID, + ErrorCode: ocppj.GenericError, + ErrorDescription: "Upstream OCPP server disconnected", + }).MarshalJSON() + if writeErr := cs.Write(sc.chargerID, errFrame); writeErr != nil { + forwarderLog.WARN.Printf("forwarder: send disconnect error for %s to %s: %v", msgID, sc.chargerID, writeErr) + } + } + sc.pendingChargerCalls = make(map[string]struct{}) + sc.pendingChargerCallsMu.Unlock() + } +} + +// onChargerDisconnect closes the charger's sidecar connection. +func onChargerDisconnect(ch ws.Channel) { + id := ch.ID() + + // discard pending buffer + pendingMu.Lock() + delete(pendingMsgs, id) + pendingMu.Unlock() + + sidecarsMu.Lock() + delete(connectedChargers, id) + sc, ok := sidecars[id] + if ok { + delete(sidecars, id) + } + _, hadErr := forwarderErrors[id] + delete(forwarderErrors, id) + sidecarsMu.Unlock() + if ok { + sc.conn.CloseNow() + forwarderLog.DEBUG.Printf("forwarder: %s upstream connection closed", id) + } + if ok || hadErr { + notifyUpdated() + } +} + +// onChargerMessage handles a raw OCPP frame from a charger and reports whether +// evcc's handler should be bypassed (upstream is the authoritative responder). +// Relay-action Calls are forwarded and bypassed; other Calls are forwarded and +// also handled by evcc; CallResults/Errors are forwarded only when they answer +// an upstream-initiated Call. +func onChargerMessage(ch ws.Channel, data []byte) bool { + id := ch.ID() + + msgType, msgID, action, err := parseOCPPFrame(data) + if err != nil { + return false + } + + sidecarsMu.Lock() + sc := sidecars[id] + sidecarsMu.Unlock() + + switch msgType { + case ocppj.CALL: + relay := actionsRelayedToUpstream[action] + + if sc != nil { + // throttle MeterValues to upstream; evcc still processes every frame + if action == "MeterValues" && sc.meterInterval > 0 { + sc.lastMeterFwdMu.Lock() + elapsed := time.Since(sc.lastMeterFwd) + if elapsed < sc.meterInterval { + sc.lastMeterFwdMu.Unlock() + return false // evcc handles normally; skip upstream + } + sc.lastMeterFwd = time.Now() + sc.lastMeterFwdMu.Unlock() + } + if relay { + sc.pendingChargerCallsMu.Lock() + sc.pendingChargerCalls[msgID] = struct{}{} + sc.pendingChargerCallsMu.Unlock() + } + if err := sc.conn.Write(context.Background(), websocket.MessageText, data); err != nil { + forwarderLog.ERROR.Printf("forwarder: write to upstream for %s: %v", id, err) + } + return relay + } + + // sidecar not ready: buffer if a pending slot exists + pendingMu.Lock() + _, hasPending := pendingMsgs[id] + if hasPending { + pendingMsgs[id] = append(pendingMsgs[id], slices.Clone(data)) + } + pendingMu.Unlock() + + // bypass evcc for relay actions while buffering; upstream answers after flush + return relay && hasPending + + case ocppj.CALL_RESULT, ocppj.CALL_ERROR: + // forward only if this answers an upstream-initiated Call + if sc == nil { + return false + } + sc.pendingUpstreamCallsMu.Lock() + _, isUpstream := sc.pendingUpstreamCalls[msgID] + if isUpstream { + delete(sc.pendingUpstreamCalls, msgID) + } + sc.pendingUpstreamCallsMu.Unlock() + if !isUpstream { + return false + } + if err := sc.conn.Write(context.Background(), websocket.MessageText, data); err != nil { + forwarderLog.ERROR.Printf("forwarder: write upstream reply for %s: %v", id, err) + } + return false // evcc may also see it; harmless for unknown IDs + } + return false +} + +// readFromUpstream relays frames from upstream: Calls are injected into the +// charger (reply routed back), responses to bypassed charger Calls are relayed +// to the charger, others discarded. Calls are rejected in read-only mode. +func (sc *sidecar) readFromUpstream(readOnly bool) { + defer func() { + sidecarsMu.Lock() + if sidecars[sc.chargerID] == sc { + delete(sidecars, sc.chargerID) + } + sidecarsMu.Unlock() + // error out pending relayed calls; upstream is gone + drainPendingWithErrors(sc.chargerID, sc) + sc.conn.CloseNow() + notifyUpdated() + }() + + for { + _, msg, err := sc.conn.Read(context.Background()) + if err != nil { + forwarderLog.DEBUG.Printf("forwarder: upstream disconnected for %s: %v", sc.chargerID, err) + // charger disconnect deletes the sidecar first, so a still-current sidecar means upstream dropped + sidecarsMu.Lock() + upstreamDrop := sidecars[sc.chargerID] == sc + sidecarsMu.Unlock() + if upstreamDrop { + recordForwarderError(sc.chargerID, err.Error()) + } + return + } + + msgType, msgID, action, err := parseOCPPFrame(msg) + if err != nil { + forwarderLog.WARN.Printf("forwarder: upstream parse error for %s: %v", sc.chargerID, err) + continue + } + + switch msgType { + case ocppj.CALL: + if readOnly { + forwarderLog.DEBUG.Printf("forwarder: blocking upstream call %s in read-only session %s", msgID, sc.chargerID) + errFrame, _ := (&ocppj.CallError{ + MessageTypeId: ocppj.CALL_ERROR, + UniqueId: msgID, + ErrorCode: ocppj.SecurityError, + ErrorDescription: "Charger control not allowed: forwarder is in read-only mode", + }).MarshalJSON() + _ = sc.conn.Write(context.Background(), websocket.MessageText, errFrame) + continue + } + + // absorb upstream's MeterValueSampleInterval as our throttle; reply + // Accepted without touching the charger's config + if action == "ChangeConfiguration" { + if interval, ok := extractMeterValueSampleInterval(msg); ok { + sc.lastMeterFwdMu.Lock() + sc.meterInterval = interval + sc.lastMeterFwdMu.Unlock() + forwarderLog.DEBUG.Printf("forwarder: upstream set MeterValueSampleInterval=%v for %s", interval, sc.chargerID) + accepted, _ := (&ocppj.CallResult{ + MessageTypeId: ocppj.CALL_RESULT, + UniqueId: msgID, + Payload: core.NewChangeConfigurationConfirmation(core.ConfigurationStatusAccepted), + }).MarshalJSON() + _ = sc.conn.Write(context.Background(), websocket.MessageText, accepted) + continue + } + } + + // track id so the charger's reply is forwarded back to upstream + sc.pendingUpstreamCallsMu.Lock() + sc.pendingUpstreamCalls[msgID] = struct{}{} + sc.pendingUpstreamCallsMu.Unlock() + + if err := Instance().Write(sc.chargerID, msg); err != nil { + forwarderLog.ERROR.Printf("forwarder: inject upstream call into charger %s: %v", sc.chargerID, err) + } + + case ocppj.CALL_RESULT, ocppj.CALL_ERROR: + // upstream's authoritative response to a bypassed charger Call? + sc.pendingChargerCallsMu.Lock() + _, isChargerCall := sc.pendingChargerCalls[msgID] + if isChargerCall { + delete(sc.pendingChargerCalls, msgID) + } + sc.pendingChargerCallsMu.Unlock() + + if isChargerCall { + // relay to charger; its handler was bypassed and it awaits this reply + if err := Instance().Write(sc.chargerID, msg); err != nil { + forwarderLog.ERROR.Printf("forwarder: relay upstream response to charger %s: %v", sc.chargerID, err) + } + continue + } + // discard: evcc already replied for non-relay Calls + } + } +} + +// ForwarderSessionStatus is the observable state of one forwarder session. +type ForwarderSessionStatus struct { + ChargerID string `json:"chargerId"` + UpstreamURL string `json:"upstreamUrl"` + UpstreamConnected bool `json:"upstreamConnected"` + Error string `json:"error,omitempty"` +} + +// recordForwarderError stores a charger's last upstream failure; caller must notifyUpdated. +func recordForwarderError(id, msg string) { + sidecarsMu.Lock() + forwarderErrors[id] = msg + sidecarsMu.Unlock() +} + +// clearForwarderError drops a charger's stored error, reporting whether one existed; caller must notifyUpdated. +func clearForwarderError(id string) bool { + sidecarsMu.Lock() + _, ok := forwarderErrors[id] + delete(forwarderErrors, id) + sidecarsMu.Unlock() + return ok +} + +var ( + forwarderCbMu sync.Mutex + forwarderUpdatedCb func() +) + +func notifyUpdated() { + status := GetForwarderStatus() + forwarderLog.DEBUG.Printf("forwarder: notifyUpdated sessions=%d", len(status)) + forwarderCbMu.Lock() + cb := forwarderUpdatedCb + forwarderCbMu.Unlock() + if cb != nil { + cb() + } +} + +// SetForwarderUpdated registers a callback fired when a session connects or disconnects. +func SetForwarderUpdated(cb func()) { + forwarderCbMu.Lock() + forwarderUpdatedCb = cb + forwarderCbMu.Unlock() +} + +// GetForwarderStatus returns a snapshot of all active forwarder sessions. +func GetForwarderStatus() []ForwarderSessionStatus { + forwarderMu.RLock() + rules := append([]ForwarderRule(nil), forwarderRules...) + forwarderMu.RUnlock() + + sidecarsMu.Lock() + defer sidecarsMu.Unlock() + out := make([]ForwarderSessionStatus, 0, len(rules)) + for _, r := range rules { + if r.StationID == "*" { + continue + } + st := ForwarderSessionStatus{ + ChargerID: r.StationID, + UpstreamURL: strings.TrimRight(r.UpstreamURL, "/"), + } + if _, ok := sidecars[r.StationID]; ok { + st.UpstreamConnected = true + } else if msg, ok := forwarderErrors[r.StationID]; ok { + st.Error = msg + } + out = append(out, st) + } + return out +} + +// sameConnection reports whether two rules dial upstream identically (no reconnect +// needed). ReadOnly is excluded; it applies live per message. +func (r ForwarderRule) sameConnection(o ForwarderRule) bool { + return r.UpstreamURL == o.UpstreamURL && + r.UpstreamStationID == o.UpstreamStationID && + r.Username == o.Username && + r.Password == o.Password && + r.Insecure == o.Insecure && + r.CaCert == o.CaCert +} + +// upstreamPath returns the upstream WebSocket path, defaulting to the charger's own ID. +func (r ForwarderRule) upstreamPath(chargerID string) string { + sid := r.UpstreamStationID + if sid == "" { + sid = chargerID + } + return "/" + strings.TrimLeft(sid, "/") +} + +// authHeader returns a Basic Auth header for the given credentials. +func authHeader(username, password string) http.Header { + creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + h := make(http.Header) + h.Set("Authorization", "Basic "+creds) + return h +} + +// extractMeterValueSampleInterval returns the interval from a ChangeConfiguration +// Call for key MeterValueSampleInterval. +func extractMeterValueSampleInterval(msg []byte) (time.Duration, bool) { + var frame []json.RawMessage + if err := json.Unmarshal(msg, &frame); err != nil || len(frame) < 4 { + return 0, false + } + var req core.ChangeConfigurationRequest + if err := json.Unmarshal(frame[3], &req); err != nil { + return 0, false + } + if req.Key != "MeterValueSampleInterval" { + return 0, false + } + secs, err := strconv.Atoi(req.Value) + if err != nil || secs <= 0 { + return 0, false + } + return time.Duration(secs) * time.Second, true +} + +// parseOCPPFrame extracts the message type, id and (for Calls) action from a raw frame. +func parseOCPPFrame(msg []byte) (msgType ocppj.MessageType, msgID string, action string, err error) { + var frame []json.RawMessage + if err = json.Unmarshal(msg, &frame); err != nil || len(frame) < 2 { + return 0, "", "", fmt.Errorf("invalid OCPP frame") + } + if err = json.Unmarshal(frame[0], &msgType); err != nil { + return 0, "", "", fmt.Errorf("invalid message type: %w", err) + } + if err = json.Unmarshal(frame[1], &msgID); err != nil { + return 0, "", "", fmt.Errorf("invalid message id: %w", err) + } + if msgType == ocppj.CALL && len(frame) >= 3 { + _ = json.Unmarshal(frame[2], &action) // best-effort; empty string if missing + } + return msgType, msgID, action, nil +} diff --git a/charger/ocpp/instance.go b/charger/ocpp/instance.go index a3a61f5ff..dfb3aadeb 100644 --- a/charger/ocpp/instance.go +++ b/charger/ocpp/instance.go @@ -24,6 +24,24 @@ type Config struct { Port int `json:"port"` } +// ForwarderRule maps a station ID (or "*" for all chargers) to an upstream OCPP server URL. +type ForwarderRule struct { + StationID string `json:"stationId" yaml:"stationId"` + UpstreamURL string `json:"upstreamUrl" yaml:"upstreamUrl"` + Password string `json:"password,omitempty" yaml:"password,omitempty"` + UpstreamStationID string `json:"upstreamStationId,omitempty" yaml:"upstreamStationId,omitempty"` + Username string `json:"username,omitempty" yaml:"username,omitempty"` + Insecure bool `json:"insecure,omitempty" yaml:"insecure,omitempty"` + CaCert string `json:"caCert,omitempty" yaml:"caCert,omitempty"` + ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` +} + +func (r ForwarderRule) Redacted() ForwarderRule { + r.Password = util.Masked(r.Password) + r.CaCert = util.Masked(r.CaCert) + return r +} + var ( once sync.Once instance *CS @@ -32,6 +50,47 @@ var ( externalUrl string ) +// Forwarder hooks, nil unless the forwarder is built in (set once in init() +// before any charger connects, so reads need no lock). +var ( + chargerConnectHook func(ws.Channel) + chargerDisconnectHook func(ws.Channel) + chargerMessageHook func(ws.Channel, []byte) bool +) + +// interceptingServer routes connect/disconnect/message events through the +// forwarder hooks. The message hook returns true to bypass evcc's OCPP handler. +type interceptingServer struct { + ws.Server +} + +func (s *interceptingServer) SetMessageHandler(handler ws.MessageHandler) { + s.Server.SetMessageHandler(func(ch ws.Channel, data []byte) error { + if chargerMessageHook != nil && chargerMessageHook(ch, data) { + return nil + } + return handler(ch, data) + }) +} + +func (s *interceptingServer) SetNewClientHandler(handler ws.ConnectedHandler) { + s.Server.SetNewClientHandler(func(ch ws.Channel) { + if chargerConnectHook != nil { + chargerConnectHook(ch) + } + handler(ch) + }) +} + +func (s *interceptingServer) SetDisconnectedClientHandler(handler func(ws.Channel)) { + s.Server.SetDisconnectedClientHandler(func(ch ws.Channel) { + if chargerDisconnectHook != nil { + chargerDisconnectHook(ch) + } + handler(ch) + }) +} + // Port returns the TCP port the central system is bound to. With the default // configuration this equals the configured port; when port 0 is configured // (as in tests) it is the OS-assigned ephemeral port. It returns 0 while the @@ -65,6 +124,11 @@ func ExternalUrl() string { return u.String() } +// CurrentConfig returns the current runtime OCPP configuration. +func CurrentConfig() Config { + return Config{Port: port} +} + // Init initializes the OCPP server func Init(cfg Config, networkExternalUrl string) { port = cfg.Port @@ -75,7 +139,7 @@ func Instance() *CS { once.Do(func() { log := util.NewLogger("ocpp") - server := ws.NewServer() + server := &interceptingServer{Server: ws.NewServer()} server.SetCheckOriginHandler(func(r *http.Request) bool { return true }) dispatcher := ocppj.NewDefaultServerDispatcher(ocppj.NewFIFOQueueMap(0)) @@ -93,6 +157,7 @@ func Instance() *CS { log: log, regs: make(map[string]*registration), CentralSystem: cs, + server: server, } instance.txnId.Store(time.Now().UTC().Unix()) diff --git a/cmd/root.go b/cmd/root.go index 8960eabe4..780e08216 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -32,6 +32,7 @@ import ( "github.com/evcc-io/evcc/util/telemetry" _ "github.com/joho/godotenv/autoload" "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/samber/lo" "github.com/spf13/cast" "github.com/spf13/cobra" vpr "github.com/spf13/viper" @@ -204,7 +205,7 @@ func runRoot(cmd *cobra.Command, args []string) { ocppCS.SetUpdated(func() { // republish when OCPP state updates valueChan <- util.Param{Key: keys.Ocpp, Val: globalconfig.ConfigStatus{ - Config: conf.Ocpp, + Config: ocpp.CurrentConfig(), Status: ocpp.GetStatus(), }} }) @@ -212,6 +213,16 @@ func runRoot(cmd *cobra.Command, args []string) { if ocpp.ExternalUrl() != "" { log.INFO.Printf("OCPP external url: %s/", ocpp.ExternalUrl()) } + // register the callback even with no rules so runtime additions are pushed + ocpp.SetForwarderUpdated(func() { + valueChan <- util.Param{Key: keys.OcppForwarder, Val: globalconfig.ConfigStatus{ + Config: lo.Map(ocpp.ForwarderRules(), func(r ocpp.ForwarderRule, _ int) ocpp.ForwarderRule { return r.Redacted() }), + Status: ocpp.GetForwarderStatus(), + }} + }) + if ocpp.ForwarderEnabled() { + log.INFO.Printf("OCPP forwarder: %d rule(s) active", len(ocpp.ForwarderRules())) + } // value cache cache := util.NewParamCache() @@ -376,9 +387,13 @@ func runRoot(cmd *cobra.Command, args []string) { valueChan <- util.Param{Key: keys.Mqtt, Val: conf.Mqtt} valueChan <- util.Param{Key: keys.Network, Val: conf.Network} valueChan <- util.Param{Key: keys.Ocpp, Val: globalconfig.ConfigStatus{ - Config: conf.Ocpp, + Config: ocpp.CurrentConfig(), Status: ocpp.GetStatus(), }} + valueChan <- util.Param{Key: keys.OcppForwarder, Val: globalconfig.ConfigStatus{ + Config: lo.Map(ocpp.ForwarderRules(), func(r ocpp.ForwarderRule, _ int) ocpp.ForwarderRule { return r.Redacted() }), + Status: ocpp.GetForwarderStatus(), + }} valueChan <- util.Param{Key: keys.Sponsor, Val: globalconfig.ConfigStatus{ Status: sponsor.RedactedStatus(), YamlSource: yamlSource.sponsor, diff --git a/cmd/setup.go b/cmd/setup.go index c9601b1f9..1cf9d1fe0 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -842,7 +842,18 @@ func configureMDNS(conf globalconfig.Network) error { // setup OCPP func configureOCPP(cfg *ocpp.Config, externalUrl string) { + if settings.Exists(keys.Ocpp) { + if err := settings.Json(keys.Ocpp, cfg); err != nil { + log.WARN.Printf("ocpp: failed to load settings: %v", err) + } + } ocpp.Init(*cfg, externalUrl) + + // Load proxy forwarding rules from DB if present. + var rules []ocpp.ForwarderRule + if err := settings.Json(keys.OcppForwarder, &rules); err == nil { + ocpp.ApplyForwarderRules(rules) + } } // setup EEBus diff --git a/core/keys/global.go b/core/keys/global.go index 695a92bb3..232ec18b4 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -16,6 +16,7 @@ const ( MessagingEvents = "messagingEvents" ModbusProxy = "modbusproxy" Ocpp = "ocpp" + OcppForwarder = "ocppforwarder" Tariffs = "tariffs" TariffRefs = "tariffRefs" Version = "version" diff --git a/docs/agents/ocpp-forwarder.md b/docs/agents/ocpp-forwarder.md new file mode 100644 index 000000000..173bf356a --- /dev/null +++ b/docs/agents/ocpp-forwarder.md @@ -0,0 +1,44 @@ +# OCPP Forwarder Architecture + +The OCPP forwarder (`charger/ocpp/forwarder.go`) is a hybrid proxy that lets a charger talk to evcc and an upstream OCPP server at the same time. Chargers connect directly to evcc's central system on the normal port. For each charger with a matching `ForwarderRule`, a "sidecar" WebSocket connection to the upstream server is opened and kept in parallel for the lifetime of the charger connection. + +The forwarder is opt-in: hooks (`chargerConnectHook`, `chargerDisconnectHook`, `chargerMessageHook` in `instance.go`) are nil unless a rule matches, so a charger without a rule behaves exactly as before. + +## Forwarding modes + +Two modes apply at the same time, selected per message by its action. + +### Transparent relay (billing-critical) + +For the actions in `actionsRelayedToUpstream` (`Authorize`, `StartTransaction`, `StopTransaction`, `DataTransfer`), upstream is the authoritative Central System: + +1. Charger sends the Call to evcc. +2. The message hook forwards it to the upstream sidecar and bypasses evcc's OCPP handler. +3. Upstream's `CallResult`/`CallError` is relayed back to the charger. + +evcc's handler is never invoked for these. This lets the pay backend control authorization, issue its own transaction IDs, and see consistent Start/Stop pairs. + +### Sidecar observation (informational) + +For all other messages (`BootNotification`, `StatusNotification`, `MeterValues`, `Heartbeat`, etc.): + +1. Charger sends the Call to evcc, which processes it normally. +2. The same frame is also mirrored to the upstream sidecar. + +Upstream observes the session while evcc manages the charger as usual. + +## Upstream to charger (commands) + +Calls (type 2) initiated by upstream are injected into the charger via `CS.Write`. The charger's `CallResult`/`CallError` is routed back to upstream. Examples: `RemoteStartTransaction`, `RemoteStopTransaction`, `GetConfiguration`, `ChangeConfiguration`, `TriggerMessage`, `SetChargingProfile`. + +`ChangeConfiguration` for `MeterValueSampleInterval` is intercepted: the forwarder absorbs it as a local throttle on `MeterValues` forwarded to upstream and replies `Accepted` without touching the charger's own config. evcc still processes every `MeterValues` frame for energy management. + +## Read-only mode + +When a rule sets `ReadOnly`, upstream may observe but cannot control the charger. Any incoming Call from upstream is answered with a `SecurityError` and not forwarded. `ReadOnly` is applied live per message, so toggling it does not require reconnecting the sidecar. + +## Connection lifecycle + +Frames that arrive from a charger before its sidecar finishes dialling are buffered (`pendingMsgs`) and flushed in order once the sidecar connects, so early messages such as `BootNotification` still reach upstream. If the dial fails or upstream drops mid-session, any buffered or in-flight relay Calls are answered to the charger with a `CallError` so it is not left hanging, and the failure is surfaced to the UI via `forwarderErrors`. + +Rules can be changed at runtime through `ApplyForwarderRules`. Sidecars for removed rules are closed, rules with changed connection parameters are re-dialled, and rules for chargers that are not connected are test-dialled to surface unreachable hosts immediately. diff --git a/i18n/de.json b/i18n/de.json index d07d8fb52..427c973ac 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -618,13 +618,12 @@ "warningUrlPath": "Die URL benötigt normalerweise keinen Pfad. Bist du dir sicher?" }, "ocpp": { - "connectedChargers": "Verbundene Wallboxen", - "connectionStatus": "Konfigurierte Station-IDs", - "connectionStatusHelp": "Verbindungsstatus der konfigurierten Wallboxen.", - "detectedChargers": "Erkannte Station-IDs", - "detectedHelp": "Diese Wallboxen haben versucht, sich mit evcc zu verbinden. Um eine Wallbox zu verwenden, erstelle einen Ladepunkt mit ihrer Station-ID.", + "forwardingConfigured": "Weiterleitung konfiguriert", + "forwardingError": "Weiterleitungsfehler", + "forwardingOff": "Keine Weiterleitung konfiguriert", "noChargers": "Keine OCPP-Wallboxen erkannt.", - "noStations": "Keine Stationen verbunden", + "stations": "Station-IDs", + "stationsHelp": "Konfigurierte und erkannte OCPP-Wallboxen. Zusätzlich zur lokalen Steuerung kannst du eine Nachrichtenweiterleitung an einen externen Server (z. B. für die Abrechnung) konfigurieren.", "status": { "configured": "Nicht verbunden", "connected": "Verbunden", @@ -634,6 +633,30 @@ "url": "Server-URL", "urlHelp": "Kopiere diese URL in die Konfiguration deiner Wallbox. Details findest du im Handbuch des Herstellers. Die Wallbox sollte automatisch ihre eindeutige Kennung (Station-ID) an die URL anhängen. In seltenen Fällen musst du die Kennung manuell angeben. Beispiel: `{url}`" }, + "ocppforwarder": { + "description": "Leite die OCPP-Nachrichten dieser Wallbox zusätzlich zu evcc an einen externen Server weiter (Abrechnungsplattform, Netzbetreiber usw.).", + "editTitle": "OCPP-Weiterleitung", + "labelCaCert": "Serverzertifikat (CA)", + "labelCheckInsecure": "Selbstsignierte Zertifikate erlauben", + "labelInsecure": "Zertifikatsprüfung", + "password": "Passwort", + "passwordHelp": "Für HTTP-Basic-Auth am Upstream-Server.", + "readOnly": { + "check": "Befehle vom Upstream-Server blockieren", + "help": { + "false": "Der Upstream kann über evcc Befehle an die Wallbox senden.", + "true": "Der Upstream empfängt alle Wallbox-Nachrichten, kann aber keine Befehle senden. evcc behält die alleinige Kontrolle." + }, + "label": "Upstream-Befehle" + }, + "status": "Status", + "upstreamStationId": "Station-ID", + "upstreamStationIdHelp": "Wallbox-Kennung, die an den Upstream-Server gesendet wird.", + "upstreamUrl": "Upstream-Server-URL", + "upstreamUrlHelp": "OCPP-WebSocket-URL des externen Servers.", + "username": "Benutzername", + "usernameHelp": "Für HTTP-Basic-Auth am Upstream-Server." + }, "optimizer": { "description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen. Berechnet und visualisiert aktuell nur. Steuert noch keine Geräte.", "enable": "Optimizer aktivieren", diff --git a/i18n/en.json b/i18n/en.json index 3f70071b1..405cd2120 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -617,13 +617,12 @@ "warningUrlPath": "The URL usually doesn't need a path. Are you sure this is correct?" }, "ocpp": { - "connectedChargers": "Connected chargers", - "connectionStatus": "Configured station IDs", - "connectionStatusHelp": "Connection status of configured chargers.", - "detectedChargers": "Detected station IDs", - "detectedHelp": "These chargers have tried to connect to evcc. To use a charger, create a loadpoint with its station ID.", + "forwardingConfigured": "Forwarding configured", + "forwardingError": "Forwarding error", + "forwardingOff": "No forwarding configured", "noChargers": "No OCPP chargers detected.", - "noStations": "No stations connected", + "stations": "Station IDs", + "stationsHelp": "Configured and detected OCPP chargers. In addition to local control, you can forward messages to an external server (e.g. for billing).", "status": { "configured": "Not connected", "connected": "Connected", @@ -633,6 +632,30 @@ "url": "Server URL", "urlHelp": "Copy this URL into your charger's configuration. Check the manufacturer's manual for details. The charger is expected to automatically append its unique identifier (station ID) to the url. In rare cases, you may need to manually specify the identifier. Example: `{url}`" }, + "ocppforwarder": { + "description": "Forward this charger's OCPP messages to an external server (billing platform, DSO, etc.) in addition to evcc.", + "editTitle": "OCPP Forward", + "labelCaCert": "Server certificate (CA)", + "labelCheckInsecure": "Allow self-signed certificates", + "labelInsecure": "Certificate validation", + "password": "Password", + "passwordHelp": "For HTTP Basic Auth to the upstream server.", + "readOnly": { + "check": "Block commands from the upstream server", + "help": { + "false": "Upstream can send commands to the charger via evcc.", + "true": "Upstream receives all charger messages but cannot send commands. evcc retains exclusive control." + }, + "label": "Upstream commands" + }, + "status": "Status", + "upstreamStationId": "Station ID", + "upstreamStationIdHelp": "Charger identifier sent to the upstream server.", + "upstreamUrl": "Upstream server URL", + "upstreamUrlHelp": "OCPP WebSocket URL of the external server.", + "username": "Username", + "usernameHelp": "For HTTP Basic Auth to the upstream server." + }, "optimizer": { "description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing. Currently only calculates and visualizes. Does not control devices yet.", "enable": "Enable Optimizer", diff --git a/i18n/fr.json b/i18n/fr.json index b9f934f8a..7dbc2947c 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -622,6 +622,44 @@ "url": "URL du Serveur", "urlHelp": "Copiez cette URL dans la configuration de votre chargeur. Consultez le manuel du fabricant pour plus de détails. Le chargeur devrait ajouter automatiquement son identifiant unique (ID de station) à l'URL. Dans de rares cas, vous devrez peut-être spécifier manuellement l'identifiant. Exemple : `{url}`" }, + "ocppforwarder": { + "add": "Ajouter une règle", + "charger": "Chargeur", + "connectionStatus": "État de connexion", + "description": "Transmet les appels OCPP à un serveur externe (plateforme de facturation, DSO, etc.) en plus d'evcc. Utilisez \"*\" comme identifiant de station pour correspondre à tous les chargeurs.", + "labelCaCert": "Certificat serveur (CA)", + "labelCheckInsecure": "Autoriser les certificats auto-signés", + "labelInsecure": "Validation du certificat", + "noConnections": "Aucune connexion de chargeur active.", + "option": { + "false": "non", + "true": "oui" + }, + "password": "Mot de passe en amont", + "passwordHelp": "Mot de passe optionnel pour l'authentification HTTP Basic vers le serveur en amont. L'identifiant de station en amont est utilisé comme nom d'utilisateur.", + "readOnly": { + "help": { + "false": "Le serveur en amont peut envoyer des commandes au chargeur via evcc.", + "true": "Le serveur en amont reçoit tous les messages du chargeur mais ne peut pas envoyer de commandes. evcc conserve le contrôle exclusif." + }, + "label": "Lecture seule" + }, + "rule": "Règle {number}", + "stationId": "Identifiant de station", + "stationIdHelp": "Identifiant de station du chargeur, ou \"*\" pour correspondre à tous les chargeurs sans règle spécifique.", + "title": "Transmetteur OCPP", + "upstream": "En amont", + "upstreamConnected": "Connecté", + "upstreamConnectedHelp": "Un chargeur est connecté et la liaison en amont est active.", + "upstreamDisconnected": "Déconnecté", + "upstreamDisconnectedHelp": "Un chargeur est connecté mais la connexion en amont a échoué.", + "upstreamIdle": "Aucun chargeur", + "upstreamIdleHelp": "Le transmetteur est actif mais aucun chargeur ne s'est encore connecté via cette règle.", + "upstreamStationId": "Identifiant de station en amont", + "upstreamStationIdHelp": "Remplace l'identifiant de station envoyé au serveur en amont. Laisser vide pour utiliser l'identifiant du chargeur.", + "upstreamUrl": "URL du serveur en amont", + "upstreamUrlHelp": "URL WebSocket OCPP du serveur externe (ex. wss://facturation.exemple.com/ocpp)." + }, "optimizer": { "description": "Analyse les prévisions d'ensoleillement, les prix de l'électricité et vos habitudes de consommation afin d'optimiser la stratégie de gestion de la batterie et de recharge. Les données sont transmises au service d'optimisation evcc pour y être traitées. Se contente actuellement de calculer est visualiser les données, il n’y a pas encore de contrôle d’appareils possible.", "enable": "Activer l'optimiseur", diff --git a/server/http.go b/server/http.go index 264530b40..c38531cca 100644 --- a/server/http.go +++ b/server/http.go @@ -339,6 +339,9 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti routes["delete"+key] = route{Method: "DELETE", Pattern: "/" + key, HandlerFunc: settingsDeleteJsonHandler(key, pub, fun())} } + // ocpp forwarder rules apply at runtime and republish via the ocpp package + routes["updateocppforwarder"] = route{Method: "POST", Pattern: "/ocppforwarder", HandlerFunc: updateOcppForwarderHandler} + for _, r := range routes { api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc) } diff --git a/server/http_ocppforwarder_handler.go b/server/http_ocppforwarder_handler.go new file mode 100644 index 000000000..2ffa4c38c --- /dev/null +++ b/server/http_ocppforwarder_handler.go @@ -0,0 +1,45 @@ +package server + +import ( + "encoding/json" + "net/http" + + "github.com/evcc-io/evcc/charger/ocpp" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/server/db/settings" +) + +// updateOcppForwarderHandler persists the OCPP forwarder rules, restoring masked +// secrets from the stored rules by station id, and applies them at runtime. +func updateOcppForwarderHandler(w http.ResponseWriter, r *http.Request) { + var rules []ocpp.ForwarderRule + if err := json.NewDecoder(r.Body).Decode(&rules); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + // restore masked secrets (password, caCert) from stored rules by station id + var old []ocpp.ForwarderRule + if err := settings.Json(keys.OcppForwarder, &old); err == nil { + stored := make(map[string]ocpp.ForwarderRule, len(old)) + for _, o := range old { + stored[o.StationID] = o + } + for i := range rules { + if o, ok := stored[rules[i].StationID]; ok { + if err := mergeMaskedAny(&o, &rules[i]); err != nil { + jsonError(w, http.StatusInternalServerError, err) + return + } + } + } + } + + if err := settings.SetJson(keys.OcppForwarder, rules); err != nil { + jsonError(w, http.StatusInternalServerError, err) + return + } + ocpp.ApplyForwarderRules(rules) + + jsonWrite(w, true) +} diff --git a/tests/config-ocpp.spec.ts b/tests/config-ocpp.spec.ts index 62b86eef9..afb9bbca8 100644 --- a/tests/config-ocpp.spec.ts +++ b/tests/config-ocpp.spec.ts @@ -1,6 +1,6 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { startSimulator, stopSimulator, simulatorUrl } from "./simulator"; +import { startSimulator, stopSimulator, simulatorUrl, simulatorHost } from "./simulator"; import { expectModalVisible } from "./utils"; import axios from "axios"; @@ -8,6 +8,66 @@ test.use({ baseURL: baseUrl() }); const OCPP_STATION_ID = "test-station-001"; +// open the OCPP modal on the evcc config page +async function openOcppModal(page: Page) { + await page.goto("/#/config"); + const ocppModal = page.getByTestId("ocpp-modal"); + await page.getByTestId("ocpp").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(ocppModal); + return ocppModal; +} + +async function evccServerUrl(page: Page) { + const ocppModal = await openOcppModal(page); + return ocppModal.getByLabel("Server URL").inputValue(); +} + +// connect a charger to evcc via the simulator UI +async function connectCharger(page: Page, serverUrl: string) { + await page.goto(simulatorUrl()); + const card = page.getByTestId("ocpp-add-client"); + await card.getByLabel("Server URL").fill(serverUrl); + await card.getByLabel("Station ID").fill(OCPP_STATION_ID); + await card.getByRole("button", { name: "Connect" }).click(); +} + +// enable/disable the mock upstream OCPP server via the simulator UI. the toggle +// button name reflects the live state, so getByRole auto-waits past the initial fetch. +async function setMockServer( + page: Page, + opts: { enabled: boolean; username?: string; password?: string } +) { + await page.goto(simulatorUrl()); + const server = page.getByTestId("ocpp-server"); + if (opts.enabled) { + if (opts.username) await server.getByLabel("Username").fill(opts.username); + if (opts.password) await server.getByLabel("Password").fill(opts.password); + await server.getByRole("button", { name: "Enable server" }).click(); + await expect(server.getByRole("button", { name: "Disable server" })).toBeVisible(); + } else { + await server.getByRole("button", { name: "Disable server" }).click(); + await expect(server.getByRole("button", { name: "Enable server" })).toBeVisible(); + } +} + +// open the forwarder editor for the given station (whatever the button state) +async function openForwarderEditor(page: Page, stationId: string) { + const ocppModal = await openOcppModal(page); + const station = ocppModal.getByTestId("ocpp-station").filter({ hasText: stationId }); + await station.getByRole("button").click(); + const forwarderModal = page.getByTestId("ocppforwarder-modal"); + await expectModalVisible(forwarderModal); + return { ocppModal, station, forwarderModal }; +} + +// enable the mock upstream server, connect a charger, open the forwarder editor +async function startForwarding(page: Page, creds?: { username: string; password: string }) { + const serverUrl = await evccServerUrl(page); + await setMockServer(page, { enabled: true, ...creds }); + await connectCharger(page, serverUrl); + return openForwarderEditor(page, OCPP_STATION_ID); +} + test.beforeEach(async () => { await startSimulator(); await start(); @@ -45,8 +105,10 @@ test.describe("ocpp", () => { await page.goto("/#/config"); await ocppCard.getByRole("button", { name: "edit" }).click(); await expectModalVisible(ocppModal); - await expect(ocppModal).toContainText("Detected station IDs"); - await expect(ocppModal).toContainText([OCPP_STATION_ID, "Unknown"].join("")); + await expect(ocppModal).toContainText("Station IDs"); + const station = ocppModal.getByTestId("ocpp-station"); + await expect(station).toContainText(OCPP_STATION_ID); + await expect(station.getByRole("img", { name: "Unknown" })).toBeVisible(); await expect(ocppModal).not.toContainText("No OCPP chargers detected."); }); @@ -91,3 +153,98 @@ test.describe("ocpp", () => { await expect(testResult).toContainText("No sponsor token configured."); }); }); + +test.describe("ocpp forwarder", () => { + test("unreachable upstream shows error", async ({ page }) => { + await connectCharger(page, await evccServerUrl(page)); + const { ocppModal, station, forwarderModal } = await openForwarderEditor(page, OCPP_STATION_ID); + + await forwarderModal.getByLabel("Upstream server URL").fill("ws://localhost:1/ocpp"); + await forwarderModal.getByRole("button", { name: "Save" }).click(); + + // modal stays open; upstream is unreachable, so the error surfaces in place + await expectModalVisible(forwarderModal); + await expect(forwarderModal.getByTestId("ocppforwarder-error")).toBeVisible(); + + // remove the rule from the still-open modal + await forwarderModal.getByRole("button", { name: "Remove" }).click(); + await expectModalVisible(ocppModal); + await expect(station.getByRole("button", { name: "No forwarding configured" })).toBeVisible(); + }); + + test("connects and drops when upstream stops", async ({ page }) => { + const { forwarderModal } = await startForwarding(page); + + await forwarderModal.getByLabel("Upstream server URL").fill(`ws://${simulatorHost()}`); + await forwarderModal.getByRole("button", { name: "Save" }).click(); + await expect(forwarderModal.getByTestId("ocppforwarder-status")).toContainText("Connected"); + + // simulator UI shows our station as the active upstream connection + await page.goto(simulatorUrl()); + const lastStation = page.getByTestId("ocpp-server-last-station"); + await expect(lastStation).toContainText(OCPP_STATION_ID); + await expect(lastStation).toContainText("active"); + + // disabling the upstream server drops the forwarder connection + await setMockServer(page, { enabled: false }); + const reopened = await openForwarderEditor(page, OCPP_STATION_ID); + await expect(reopened.forwarderModal.getByTestId("ocppforwarder-status")).toContainText( + "Not connected" + ); + }); + + test("basic auth", async ({ page }) => { + const { forwarderModal } = await startForwarding(page, { + username: "user", + password: "secret", + }); + + await forwarderModal.getByLabel("Upstream server URL").fill(`ws://${simulatorHost()}`); + await forwarderModal.getByLabel("Username").fill("user"); + await forwarderModal.getByLabel("Password").fill("secret"); + await forwarderModal.getByRole("button", { name: "Save" }).click(); + await expect(forwarderModal.getByTestId("ocppforwarder-status")).toContainText("Connected"); + + // simulator UI shows our station as the active upstream connection + await page.goto(simulatorUrl()); + await expect(page.getByTestId("ocpp-server-last-station")).toContainText(OCPP_STATION_ID); + }); + + test("param change reconnects", async ({ page }) => { + const { forwarderModal } = await startForwarding(page, { + username: "user", + password: "secret", + }); + const status = forwarderModal.getByTestId("ocppforwarder-status"); + + // wrong password is rejected + await forwarderModal.getByLabel("Upstream server URL").fill(`ws://${simulatorHost()}`); + await forwarderModal.getByLabel("Username").fill("user"); + await forwarderModal.getByLabel("Password").fill("wrong"); + await forwarderModal.getByRole("button", { name: "Save" }).click(); + await expect(forwarderModal.getByTestId("ocppforwarder-error")).toBeVisible(); + await expect(status).toContainText("Not connected"); + + // fixing the password re-establishes the connection + await forwarderModal.getByLabel("Password").fill("secret"); + await forwarderModal.getByRole("button", { name: "Save" }).click(); + await expect(status).toContainText("Connected"); + }); + + test("removing rule stops forwarding", async ({ page }) => { + const { ocppModal, station, forwarderModal } = await startForwarding(page); + + await forwarderModal.getByLabel("Upstream server URL").fill(`ws://${simulatorHost()}`); + await forwarderModal.getByRole("button", { name: "Save" }).click(); + await expect(forwarderModal.getByTestId("ocppforwarder-status")).toContainText("Connected"); + + // removing the rule tears down forwarding + await forwarderModal.getByRole("button", { name: "Remove" }).click(); + await expectModalVisible(ocppModal); + await expect(station.getByRole("button", { name: "No forwarding configured" })).toBeVisible(); + + // simulator UI no longer shows an active upstream connection + await page.goto(simulatorUrl()); + await expect(page.getByTestId("ocpp-server-last-station")).not.toContainText("active"); + }); +}); diff --git a/tests/simulator/api.ts b/tests/simulator/api.ts index ae8f105f4..c46395d0f 100644 --- a/tests/simulator/api.ts +++ b/tests/simulator/api.ts @@ -2,6 +2,7 @@ import bodyParser from "body-parser"; import type { Connect, ViteDevServer } from "vite"; import type { ServerResponse } from "http"; import { OcppClient } from "./ocppClient"; +import { ocppServer } from "./ocppServer"; const ocppClients = new Map(); @@ -47,6 +48,7 @@ const stateApiMiddleware = ( res.end(); process.exit(); } else if (req.originalUrl === "/api/state") { + updateOcppState(); res.end(JSON.stringify(state)); } else { next(); @@ -169,22 +171,19 @@ const ocppMiddleware = ( const client = new OcppClient(stationId, serverUrl); ocppClients.set(stationId, client); - - client - .connect() - .then(() => client.bootNotification()) - .then((response) => { - console.log("[simulator] OCPP BootNotification response:", response); - updateOcppState(); - res.end(JSON.stringify({ status: "connected", stationId, response })); - }) - .catch((error) => { - console.error("[simulator] OCPP connection error:", error); - ocppClients.delete(stationId); - updateOcppState(); - res.statusCode = 500; - res.end(JSON.stringify({ error: error.message })); - }); + // fire-and-forget: the client retries until connected and boots itself on open + client.connect(); + updateOcppState(); + console.log(`[simulator] OCPP client ${stationId} connecting to ${serverUrl}`); + res.end(JSON.stringify({ status: "connecting", stationId })); + } else if (req.method === "POST" && req.originalUrl === "/api/ocpp/server") { + console.log("[simulator] POST /api/ocpp/server"); + // @ts-expect-error Property 'body' does not exist on type 'IncomingMessage' + const { enabled, username, password } = req.body; + ocppServer.configure({ enabled: !!enabled, username, password }); + res.end(JSON.stringify(ocppServer.status())); + } else if (req.method === "GET" && req.originalUrl === "/api/ocpp/server") { + res.end(JSON.stringify(ocppServer.status())); } else if (req.method === "POST" && req.originalUrl === "/api/ocpp/disconnect") { console.log("[simulator] POST /api/ocpp/disconnect"); // @ts-expect-error Property 'body' does not exist on type 'IncomingMessage' @@ -216,6 +215,9 @@ export default () => ({ enforce: "pre", configureServer(server: ViteDevServer) { console.log("[simulator] configured"); + if (server.httpServer) { + ocppServer.attach(server.httpServer); + } return () => { server.middlewares.use(loggingMiddleware); server.middlewares.use(bodyParser.json()); diff --git a/tests/simulator/ocppClient.ts b/tests/simulator/ocppClient.ts index eae7b22e1..3dddbc8c9 100644 --- a/tests/simulator/ocppClient.ts +++ b/tests/simulator/ocppClient.ts @@ -1,5 +1,7 @@ import WebSocket from "ws"; +const RECONNECT_DELAY = 2000; + export class OcppClient { private ws: WebSocket | null = null; private messageId = 0; @@ -7,47 +9,66 @@ export class OcppClient { private connected = false; private stationId: string; private serverUrl: string; + private shouldReconnect = false; + private reconnectTimer: ReturnType | null = null; constructor(stationId: string, serverUrl: string) { this.stationId = stationId; this.serverUrl = serverUrl; } - async connect(): Promise { - return new Promise((resolve, reject) => { - const url = `${this.serverUrl}${this.stationId}`; - console.log(`[OCPP Client] Connecting to ${url}`); + // connect starts the connection loop and returns immediately. The client keeps + // re-dialing every RECONNECT_DELAY until disconnect() is called, so it survives + // an unreachable server or a dropped connection (e.g. evcc restart). + connect(): void { + this.shouldReconnect = true; + this.dial(); + } - this.ws = new WebSocket(url, ["ocpp1.6"]); + private dial(): void { + const url = `${this.serverUrl}${this.stationId}`; + console.log(`[OCPP Client] Connecting to ${url}`); - this.ws.on("open", () => { - console.log(`[OCPP Client] Connected as ${this.stationId}`); - this.connected = true; - resolve(); - }); + const ws = new WebSocket(url, ["ocpp1.6"]); + this.ws = ws; - this.ws.on("message", (data: WebSocket.Data) => { - const message = JSON.parse(data.toString()); - this.handleMessage(message); - }); + // force a close if the handshake never completes, so the retry loop kicks in + const handshakeTimer = setTimeout(() => { + if (!this.connected) { + console.log(`[OCPP Client] ${this.stationId} handshake timeout`); + ws.terminate(); + } + }, 5000); - this.ws.on("error", (error) => { - console.error(`[OCPP Client] Error:`, error); - reject(error); - }); + ws.on("open", () => { + clearTimeout(handshakeTimer); + console.log(`[OCPP Client] Connected as ${this.stationId}`); + this.connected = true; + // boot, then report the connector available, like a real charger would + this.bootNotification() + .then(() => this.statusNotification(1, "Available")) + .catch((error) => console.error(`[OCPP Client] init failed:`, error)); + }); - this.ws.on("close", () => { - console.log(`[OCPP Client] Disconnected`); - this.connected = false; - }); + ws.on("message", (data: WebSocket.Data) => { + const message = JSON.parse(data.toString()); + this.handleMessage(message); + }); - // Timeout after 5 seconds - setTimeout(() => { - if (!this.connected) { - this.disconnect(); - reject(new Error("Connection timeout")); - } - }, 5000); + ws.on("error", (error) => { + console.error(`[OCPP Client] Error:`, (error as Error).message || error); + }); + + ws.on("close", () => { + clearTimeout(handshakeTimer); + this.connected = false; + if (this.ws === ws) this.ws = null; + if (this.shouldReconnect) { + console.log( + `[OCPP Client] ${this.stationId} disconnected, retrying in ${RECONNECT_DELAY}ms` + ); + this.reconnectTimer = setTimeout(() => this.dial(), RECONNECT_DELAY); + } }); } @@ -92,6 +113,9 @@ export class OcppClient { ], }; break; + case "ChangeConfiguration": + response = { status: "Accepted" }; + break; case "ChangeAvailability": response = { status: "Accepted" }; break; @@ -102,9 +126,17 @@ export class OcppClient { response = { status: "Accepted" }; break; case "TriggerMessage": - // Handle trigger and send the requested message - if (payload.requestedMessage === "StatusNotification") { - this.statusNotification(1, "Available"); + // actually send the requested message so evcc's setup wait completes + switch (payload.requestedMessage) { + case "StatusNotification": + this.statusNotification(1, "Available"); + break; + case "MeterValues": + this.meterValues(1, 0, 0); + break; + case "BootNotification": + this.bootNotification(); + break; } response = { status: "Accepted" }; break; @@ -200,11 +232,16 @@ export class OcppClient { } disconnect(): void { + this.shouldReconnect = false; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } if (this.ws) { this.ws.close(); this.ws = null; - this.connected = false; } + this.connected = false; } isConnected(): boolean { diff --git a/tests/simulator/ocppServer.ts b/tests/simulator/ocppServer.ts new file mode 100644 index 000000000..6bd0b7174 --- /dev/null +++ b/tests/simulator/ocppServer.ts @@ -0,0 +1,128 @@ +import { WebSocketServer, WebSocket } from "ws"; +import type { IncomingMessage } from "http"; +import type { Duplex } from "stream"; + +// minimal surface we need from the shared http(s)/http2 server +type UpgradableServer = { + on( + event: "upgrade", + listener: (req: IncomingMessage, socket: Duplex, head: Buffer) => void + ): unknown; +}; + +// OcppServer is a minimal upstream OCPP server used to test the evcc forwarder. +// It is NOT spec compliant: it accepts a WebSocket connection (optionally behind +// HTTP Basic Auth) and answers Calls with canned CallResults so the connection +// stays alive. It shares the simulator's HTTP port via the vite httpServer. +class OcppServer { + // echo the ocpp1.6 subprotocol so clients that require negotiation (evcc's + // coder/websocket dialer) complete the handshake + private wss = new WebSocketServer({ noServer: true, handleProtocols: () => "ocpp1.6" }); + private sockets = new Set(); + + enabled = false; + username = ""; + password = ""; + lastStationId: string | null = null; + + // attach hooks the WebSocket upgrade on the shared http server. OCPP upgrades + // are recognised by the "ocpp1.6" subprotocol; everything else (e.g. vite HMR) + // is left for other listeners. + attach(httpServer: UpgradableServer) { + httpServer.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => { + const protocols = String(req.headers["sec-websocket-protocol"] || ""); + if (!protocols.includes("ocpp1.6")) return; // not an OCPP upgrade (e.g. vite HMR) + + // server off: reject the OCPP upgrade so the client errors out and retries, + // rather than leaving the handshake hanging with no response + if (!this.enabled) { + socket.destroy(); + return; + } + + if ((this.username || this.password) && !this.checkAuth(req)) { + console.log("[ocpp-server] rejected: bad credentials"); + socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="ocpp"\r\n\r\n'); + socket.destroy(); + return; + } + + this.wss.handleUpgrade(req, socket, head, (ws) => this.onConnection(ws, req)); + }); + } + + configure(opts: { enabled: boolean; username?: string; password?: string }) { + this.enabled = opts.enabled; + this.username = opts.username || ""; + this.password = opts.password || ""; + if (!this.enabled) this.closeAll(); + } + + status() { + return { + enabled: this.enabled, + username: this.username, + password: this.password, + lastStationId: this.lastStationId, + connections: this.sockets.size, + }; + } + + private checkAuth(req: IncomingMessage): boolean { + const header = String(req.headers["authorization"] || ""); + const expected = "Basic " + Buffer.from(`${this.username}:${this.password}`).toString("base64"); + return header === expected; + } + + private onConnection(ws: WebSocket, req: IncomingMessage) { + const path = (req.url || "/").split("?")[0]; + const stationId = decodeURIComponent(path.replace(/^\/+/, "")) || "(root)"; + this.lastStationId = stationId; + this.sockets.add(ws); + console.log(`[ocpp-server] ${stationId} connected (${this.sockets.size} active)`); + + ws.on("message", (data) => this.handleMessage(ws, data.toString())); + ws.on("close", () => { + this.sockets.delete(ws); + console.log(`[ocpp-server] ${stationId} disconnected (${this.sockets.size} active)`); + }); + } + + // answer charger Calls (messageType 2) with a CallResult (messageType 3) + private handleMessage(ws: WebSocket, raw: string) { + let msg: unknown; + try { + msg = JSON.parse(raw); + } catch { + return; + } + if (!Array.isArray(msg) || msg[0] !== 2) return; // only respond to Calls + const [, messageId, action] = msg as [number, string, string, unknown]; + ws.send(JSON.stringify([3, messageId, this.resultFor(action)])); + } + + private resultFor(action: string): Record { + const now = new Date().toISOString(); + switch (action) { + case "BootNotification": + return { status: "Accepted", currentTime: now, interval: 300 }; + case "Heartbeat": + return { currentTime: now }; + case "Authorize": + return { idTagInfo: { status: "Accepted" } }; + case "StartTransaction": + return { transactionId: 1, idTagInfo: { status: "Accepted" } }; + case "StopTransaction": + return { idTagInfo: { status: "Accepted" } }; + default: + return {}; // StatusNotification, MeterValues, DataTransfer, ... + } + } + + private closeAll() { + for (const ws of this.sockets) ws.close(); + this.sockets.clear(); + } +} + +export const ocppServer = new OcppServer(); diff --git a/tests/simulator/src/Simulator.vue b/tests/simulator/src/Simulator.vue index cfb9f5c57..c09b6673e 100644 --- a/tests/simulator/src/Simulator.vue +++ b/tests/simulator/src/Simulator.vue @@ -343,6 +343,74 @@
+ +
+
+
+
OCPP Server
+ + {{ ocppServer.enabled ? "Listening" : "Off" }} + +
+

+ Upstream server for forwarder testing. Point a rule at + ws://{{ host }}/<stationId> +

+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+ {{ ocppServer.lastStationId || "—" }} + + ({{ ocppServer.connections }} active) + +
+
+ +
+
+
@@ -381,14 +449,32 @@ export default defineComponent({ ocppServerUrl: "ws://127.0.0.1:8887/", ocppStationId: "", connecting: false, + ocppServer: { + enabled: false, + username: "", + password: "", + lastStationId: null as string | null, + connections: 0, + }, + ocppServerPoll: undefined as ReturnType | undefined, }; }, + computed: { + host(): string { + return window.location.host; + }, + }, mounted() { this.checkMockLoginMode(); if (!this.mockLoginMode) { this.load(); + this.refreshOcppServer(); + this.ocppServerPoll = setInterval(() => this.refreshOcppServer(), 2000); } }, + beforeUnmount() { + clearInterval(this.ocppServerPoll); + }, methods: { checkMockLoginMode() { const urlParams = new URLSearchParams(window.location.search); @@ -435,6 +521,36 @@ export default defineComponent({ this.connecting = false; } }, + // refresh server status without clobbering credentials the user is editing + async refreshOcppServer() { + try { + const { data } = await axios.get("/api/ocpp/server"); + this.ocppServer.enabled = data.enabled; + this.ocppServer.lastStationId = data.lastStationId; + this.ocppServer.connections = data.connections; + if (data.enabled) { + this.ocppServer.username = data.username; + this.ocppServer.password = data.password; + } + } catch (error) { + console.error("Failed to read OCPP server status:", error); + } + }, + async toggleOcppServer() { + try { + const { data } = await axios.post("/api/ocpp/server", { + enabled: !this.ocppServer.enabled, + username: this.ocppServer.username, + password: this.ocppServer.password, + }); + this.ocppServer.enabled = data.enabled; + this.ocppServer.lastStationId = data.lastStationId; + this.ocppServer.connections = data.connections; + } catch (error) { + console.error("Failed to toggle OCPP server:", error); + alert("Failed to toggle OCPP server"); + } + }, async disconnectOcpp(stationId: string) { try { await axios.post("/api/ocpp/disconnect", { stationId }); From 8106dc8b763799b078a83938d94605cc7b8a9bc5 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 7 Jun 2026 13:22:03 +0200 Subject: [PATCH 0277/1128] Refactor dim/curtail handling- split hems and circuit (BC) (#30284) --- api/api.go | 19 +- api/mock.go | 174 ++++++++++------- assets/js/components/Config/CircuitTags.vue | 13 +- assets/js/components/Config/DeviceCard.vue | 37 +++- assets/js/components/Config/DeviceTags.vue | 7 +- assets/js/components/Config/MeterCard.vue | 4 + .../components/Config/defaultYaml/hems.yaml | 18 ++ assets/js/components/HemsWarning.vue | 63 +++++- assets/js/components/Site/Site.vue | 8 +- assets/js/types/evcc.ts | 13 +- assets/js/views/Config.vue | 54 ++++-- cmd/root.go | 24 ++- cmd/setup.go | 2 + core/circuit/circuit.go | 85 +++----- core/circuit/circuit_test.go | 36 +++- core/helper.go | 16 +- core/loadpoint.go | 4 +- core/loadpoint_phases_test.go | 4 +- core/loadpoint_test.go | 30 +-- core/loadpoint_vehicle_test.go | 4 +- core/site.go | 10 +- core/site/api.go | 2 +- core/site_api.go | 17 +- core/site_battery.go | 6 +- core/site_circuits.go | 16 +- hems/eebus/eebus.go | 95 ++++++--- hems/eebus/eebus_test.go | 110 +++++++---- hems/fnn/fnn.go | 144 ++++++++------ hems/hems/api.go | 6 +- hems/relay/relay.go | 70 +++++-- hems/smartgrid/circuit.go | 42 ---- i18n/bg.json | 7 - i18n/bs.json | 9 - i18n/cs.json | 7 - i18n/da.json | 7 - i18n/de.json | 15 +- i18n/el.json | 7 - i18n/en.json | 15 +- i18n/es.json | 7 - i18n/et.json | 2 - i18n/fi.json | 7 - i18n/fr.json | 7 - i18n/hr.json | 7 - i18n/hu.json | 7 - i18n/it.json | 7 - i18n/ja.json | 7 - i18n/lb.json | 7 - i18n/lt.json | 7 - i18n/nl.json | 7 - i18n/no.json | 7 - i18n/pl.json | 7 - i18n/pt.json | 7 - i18n/ro.json | 7 - i18n/ru.json | 7 - i18n/sl.json | 7 - i18n/sv.json | 7 - i18n/ta.json | 7 - i18n/tr.json | 7 - i18n/uk.json | 7 - i18n/zh-Hans.json | 1 - server/eebus/test/cs_test.go | 6 +- server/http_config_helper.go | 6 +- templates/definition/meter/demo-meter.yaml | 19 ++ tests/config-custom-meter.spec.ts | 2 +- tests/config-loadpoint.spec.ts | 6 +- tests/hems-grid.evcc.yaml | 8 + tests/hems.spec.ts | 181 +++++++++++++++--- tests/simulator/api.ts | 2 +- tests/simulator/src/Simulator.vue | 42 ++-- tests/utils.ts | 16 +- 70 files changed, 960 insertions(+), 671 deletions(-) delete mode 100644 hems/smartgrid/circuit.go diff --git a/api/api.go b/api/api.go index 61768c299..4bfeae8e4 100644 --- a/api/api.go +++ b/api/api.go @@ -9,7 +9,7 @@ import ( "golang.org/x/oauth2" ) -//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,Tariff +//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff // Meter provides total active power in W type Meter interface { @@ -273,7 +273,7 @@ type Circuit interface { SetTitle(string) GetParent() Circuit RegisterChild(child Circuit) - Wrap(parent Circuit) error + SetHEMS(HEMS) HasMeter() bool GetMaxPower() float64 GetMaxCurrent() float64 @@ -282,14 +282,15 @@ type Circuit interface { Update([]CircuitLoad) error ValidateCurrent(old, new float64) float64 ValidatePower(old, new float64) float64 +} - // EnWG §14a - reduce demand/consumption - Dim(bool) - Dimmed() *bool - - // EEG §9 - reduce feed-in to the grid - Curtail(bool) - Curtailed() *bool +// HEMS exposes the runtime state of the home energy management system. +type HEMS interface { + SetUpdated(func()) + Dimmed() bool + Curtailed() bool + MaxConsumptionPower() float64 // 0 = no limit + MaxProductionPower() *float64 // nil = no limit } // Redactor is an interface to redact sensitive data diff --git a/api/mock.go b/api/mock.go index 6adc62aaf..7042443d3 100644 --- a/api/mock.go +++ b/api/mock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,Tariff) +// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff) // // Generated by this command: // -// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,Tariff +// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff // // Package api is a generated GoMock package. @@ -895,58 +895,6 @@ func (m *MockCircuit) EXPECT() *MockCircuitMockRecorder { return m.recorder } -// Curtail mocks base method. -func (m *MockCircuit) Curtail(arg0 bool) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "Curtail", arg0) -} - -// Curtail indicates an expected call of Curtail. -func (mr *MockCircuitMockRecorder) Curtail(arg0 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Curtail", reflect.TypeOf((*MockCircuit)(nil).Curtail), arg0) -} - -// Curtailed mocks base method. -func (m *MockCircuit) Curtailed() *bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Curtailed") - ret0, _ := ret[0].(*bool) - return ret0 -} - -// Curtailed indicates an expected call of Curtailed. -func (mr *MockCircuitMockRecorder) Curtailed() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Curtailed", reflect.TypeOf((*MockCircuit)(nil).Curtailed)) -} - -// Dim mocks base method. -func (m *MockCircuit) Dim(arg0 bool) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "Dim", arg0) -} - -// Dim indicates an expected call of Dim. -func (mr *MockCircuitMockRecorder) Dim(arg0 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dim", reflect.TypeOf((*MockCircuit)(nil).Dim), arg0) -} - -// Dimmed mocks base method. -func (m *MockCircuit) Dimmed() *bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Dimmed") - ret0, _ := ret[0].(*bool) - return ret0 -} - -// Dimmed indicates an expected call of Dimmed. -func (mr *MockCircuitMockRecorder) Dimmed() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dimmed", reflect.TypeOf((*MockCircuit)(nil).Dimmed)) -} - // GetChargePower mocks base method. func (m *MockCircuit) GetChargePower() float64 { m.ctrl.T.Helper() @@ -1057,6 +1005,18 @@ func (mr *MockCircuitMockRecorder) RegisterChild(child any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterChild", reflect.TypeOf((*MockCircuit)(nil).RegisterChild), child) } +// SetHEMS mocks base method. +func (m *MockCircuit) SetHEMS(arg0 HEMS) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetHEMS", arg0) +} + +// SetHEMS indicates an expected call of SetHEMS. +func (mr *MockCircuitMockRecorder) SetHEMS(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetHEMS", reflect.TypeOf((*MockCircuit)(nil).SetHEMS), arg0) +} + // SetMaxCurrent mocks base method. func (m *MockCircuit) SetMaxCurrent(arg0 float64) { m.ctrl.T.Helper() @@ -1135,20 +1095,6 @@ func (mr *MockCircuitMockRecorder) ValidatePower(old, new any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidatePower", reflect.TypeOf((*MockCircuit)(nil).ValidatePower), old, new) } -// Wrap mocks base method. -func (m *MockCircuit) Wrap(parent Circuit) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Wrap", parent) - ret0, _ := ret[0].(error) - return ret0 -} - -// Wrap indicates an expected call of Wrap. -func (mr *MockCircuitMockRecorder) Wrap(parent any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wrap", reflect.TypeOf((*MockCircuit)(nil).Wrap), parent) -} - // MockDimmer is a mock of Dimmer interface. type MockDimmer struct { ctrl *gomock.Controller @@ -1202,6 +1148,98 @@ func (mr *MockDimmerMockRecorder) Dimmed() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dimmed", reflect.TypeOf((*MockDimmer)(nil).Dimmed)) } +// MockHEMS is a mock of HEMS interface. +type MockHEMS struct { + ctrl *gomock.Controller + recorder *MockHEMSMockRecorder + isgomock struct{} +} + +// MockHEMSMockRecorder is the mock recorder for MockHEMS. +type MockHEMSMockRecorder struct { + mock *MockHEMS +} + +// NewMockHEMS creates a new mock instance. +func NewMockHEMS(ctrl *gomock.Controller) *MockHEMS { + mock := &MockHEMS{ctrl: ctrl} + mock.recorder = &MockHEMSMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockHEMS) EXPECT() *MockHEMSMockRecorder { + return m.recorder +} + +// Curtailed mocks base method. +func (m *MockHEMS) Curtailed() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Curtailed") + ret0, _ := ret[0].(bool) + return ret0 +} + +// Curtailed indicates an expected call of Curtailed. +func (mr *MockHEMSMockRecorder) Curtailed() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Curtailed", reflect.TypeOf((*MockHEMS)(nil).Curtailed)) +} + +// Dimmed mocks base method. +func (m *MockHEMS) Dimmed() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Dimmed") + ret0, _ := ret[0].(bool) + return ret0 +} + +// Dimmed indicates an expected call of Dimmed. +func (mr *MockHEMSMockRecorder) Dimmed() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dimmed", reflect.TypeOf((*MockHEMS)(nil).Dimmed)) +} + +// MaxConsumptionPower mocks base method. +func (m *MockHEMS) MaxConsumptionPower() float64 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MaxConsumptionPower") + ret0, _ := ret[0].(float64) + return ret0 +} + +// MaxConsumptionPower indicates an expected call of MaxConsumptionPower. +func (mr *MockHEMSMockRecorder) MaxConsumptionPower() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MaxConsumptionPower", reflect.TypeOf((*MockHEMS)(nil).MaxConsumptionPower)) +} + +// MaxProductionPower mocks base method. +func (m *MockHEMS) MaxProductionPower() *float64 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MaxProductionPower") + ret0, _ := ret[0].(*float64) + return ret0 +} + +// MaxProductionPower indicates an expected call of MaxProductionPower. +func (mr *MockHEMSMockRecorder) MaxProductionPower() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MaxProductionPower", reflect.TypeOf((*MockHEMS)(nil).MaxProductionPower)) +} + +// SetUpdated mocks base method. +func (m *MockHEMS) SetUpdated(arg0 func()) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetUpdated", arg0) +} + +// SetUpdated indicates an expected call of SetUpdated. +func (mr *MockHEMSMockRecorder) SetUpdated(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUpdated", reflect.TypeOf((*MockHEMS)(nil).SetUpdated), arg0) +} + // MockTariff is a mock of Tariff interface. type MockTariff struct { ctrl *gomock.Controller diff --git a/assets/js/components/Config/CircuitTags.vue b/assets/js/components/Config/CircuitTags.vue index 92d1fc1d0..efa9bfd31 100644 --- a/assets/js/components/Config/CircuitTags.vue +++ b/assets/js/components/Config/CircuitTags.vue @@ -2,7 +2,7 @@