diff --git a/assets/js/components/ChargingPlans/Arrival.vue b/assets/js/components/ChargingPlans/Arrival.vue index c55092b64..896020a57 100644 --- a/assets/js/components/ChargingPlans/Arrival.vue +++ b/assets/js/components/ChargingPlans/Arrival.vue @@ -1,70 +1,80 @@ diff --git a/assets/js/components/ChargingPlans/ChargingPlanModal.vue b/assets/js/components/ChargingPlans/ChargingPlanModal.vue index 03c5e817b..b42572152 100644 --- a/assets/js/components/ChargingPlans/ChargingPlanModal.vue +++ b/assets/js/components/ChargingPlans/ChargingPlanModal.vue @@ -10,7 +10,7 @@ @closed="modalInvisible" >
-
@@ -91,7 +93,14 @@ import type { StaticPlan, StaticSocPlan, } from "./types"; -import type { CURRENCY, Forecast, SMART_COST_TYPE, UiLoadpoint, Vehicle } from "@/types/evcc"; +import type { + CHARGE_MODE, + CURRENCY, + Forecast, + SMART_COST_TYPE, + UiLoadpoint, + Vehicle, +} from "@/types/evcc"; export default defineComponent({ name: "ChargingPlanModal", @@ -142,16 +151,17 @@ export default defineComponent({ }, modalTitle(): string { const baseTitle = this.$t("main.chargingPlan.modalTitle"); - if (this.loadpoint?.socBasedPlanning && this.vehicle) { + const withVehicle = this.arrivalTabActive || this.loadpoint?.socBasedPlanning; + if (withVehicle && this.vehicle) { return `${baseTitle}: ${this.vehicle.title}`; } return baseTitle; }, departureTabActive(): boolean { - return this.activeTab === "departure"; + return this.activeTab === "departure" || !this.vehicle; }, arrivalTabActive(): boolean { - return this.activeTab === "arrival"; + return this.activeTab === "arrival" && !!this.vehicle; }, apiVehicle(): string { return `vehicles/${this.vehicle?.name}/`; @@ -188,6 +198,13 @@ export default defineComponent({ setLimitSoc(soc: number): void { api.post(`${this.apiVehicle}limitsoc/${soc}`); }, + setMode(mode: CHARGE_MODE | ""): void { + if (mode === "") { + api.delete(`${this.apiVehicle}mode`); + } else { + api.post(`${this.apiVehicle}mode/${mode}`); + } + }, updateStaticPlan(plan: StaticPlan): void { const timeISO = plan.time.toISOString(); if (this.loadpoint?.socBasedPlanning) { diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 13294c506..401096f91 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -677,6 +677,7 @@ export interface BatteryMeter extends Meter { export interface Vehicle { name: string; + mode?: CHARGE_MODE | ""; minSoc?: number; limitSoc?: number; plan?: StaticPlan; diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index 4950c774c..814b4dc48 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -148,7 +148,12 @@ func (lp *Loadpoint) setActiveVehicle(v api.Vehicle) { lp.publish(keys.VehicleName, vehicle.Settings(lp.log, v).Name()) lp.publish(keys.VehicleTitle, v.GetTitle()) - if mode, ok := v.OnIdentified().GetMode(); ok { + // vehicle mode overrides the yaml onIdentify action + mode, ok := v.OnIdentified().GetMode() + if m := vehicle.Settings(lp.log, v).GetMode(); m != "" { + mode, ok = m, true + } + if ok && mode != "" { lp.SetMode(mode) } diff --git a/core/site_vehicles.go b/core/site_vehicles.go index e9ab609b1..aa457808c 100644 --- a/core/site_vehicles.go +++ b/core/site_vehicles.go @@ -22,6 +22,7 @@ type vehicleStruct struct { Icon string `json:"icon,omitempty"` Capacity float64 `json:"capacity,omitempty"` Phases int `json:"phases,omitempty"` + Mode api.ChargeMode `json:"mode,omitempty"` MinSoc int `json:"minSoc,omitempty"` LimitSoc int `json:"limitSoc,omitempty"` MinCurrent float64 `json:"minCurrent,omitempty"` @@ -59,6 +60,7 @@ func (site *Site) publishVehicles() { Icon: instance.Icon(), Capacity: instance.Capacity(), Phases: instance.Phases(), + Mode: v.GetMode(), MinSoc: v.GetMinSoc(), LimitSoc: v.GetLimitSoc(), MinCurrent: ac.MinCurrent, diff --git a/core/vehicle/adapter.go b/core/vehicle/adapter.go index 2ed34e326..30f70db46 100644 --- a/core/vehicle/adapter.go +++ b/core/vehicle/adapter.go @@ -49,6 +49,21 @@ func (v *adapter) Name() string { return v.name } +// GetMode returns the charge mode +func (v *adapter) GetMode() api.ChargeMode { + if s, err := settings.String(v.key() + keys.Mode); err == nil { + return api.ChargeMode(s) + } + return "" +} + +// SetMode sets the charge mode +func (v *adapter) SetMode(mode api.ChargeMode) { + v.log.DEBUG.Printf("set %s mode: %s", v.name, mode) + settings.SetString(v.key()+keys.Mode, string(mode)) + v.publish() +} + // GetMinSoc returns the min soc func (v *adapter) GetMinSoc() int { if v, err := settings.Int(v.key() + keys.MinSoc); err == nil { diff --git a/core/vehicle/api.go b/core/vehicle/api.go index 26d1a4ff5..5c9229aea 100644 --- a/core/vehicle/api.go +++ b/core/vehicle/api.go @@ -15,6 +15,11 @@ type API interface { // Name returns the vehicle name Name() string + // GetMode returns the charge mode + GetMode() api.ChargeMode + // SetMode sets the charge mode + SetMode(api.ChargeMode) + // GetMinSoc returns the min soc GetMinSoc() int // SetMinSoc sets the min soc diff --git a/core/vehicle/dummy.go b/core/vehicle/dummy.go index fe76e9381..db1b6c3cf 100644 --- a/core/vehicle/dummy.go +++ b/core/vehicle/dummy.go @@ -20,6 +20,15 @@ func (v *dummy) Name() string { return "" } +// GetMode returns the charge mode +func (v *dummy) GetMode() api.ChargeMode { + return "" +} + +// SetMode sets the charge mode +func (v *dummy) SetMode(mode api.ChargeMode) { +} + // GetMinSoc returns the min soc func (v *dummy) GetMinSoc() int { return 0 diff --git a/core/vehicle/mock.go b/core/vehicle/mock.go index 5db465b22..e6bd78544 100644 --- a/core/vehicle/mock.go +++ b/core/vehicle/mock.go @@ -69,6 +69,20 @@ func (mr *MockAPIMockRecorder) GetMinSoc() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinSoc", reflect.TypeOf((*MockAPI)(nil).GetMinSoc)) } +// GetMode mocks base method. +func (m *MockAPI) GetMode() api.ChargeMode { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMode") + ret0, _ := ret[0].(api.ChargeMode) + return ret0 +} + +// GetMode indicates an expected call of GetMode. +func (mr *MockAPIMockRecorder) GetMode() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMode", reflect.TypeOf((*MockAPI)(nil).GetMode)) +} + // GetPlanSoc mocks base method. func (m *MockAPI) GetPlanSoc() (time.Time, int) { m.ctrl.T.Helper() @@ -164,6 +178,18 @@ func (mr *MockAPIMockRecorder) SetMinSoc(soc any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMinSoc", reflect.TypeOf((*MockAPI)(nil).SetMinSoc), soc) } +// SetMode mocks base method. +func (m *MockAPI) SetMode(arg0 api.ChargeMode) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetMode", arg0) +} + +// SetMode indicates an expected call of SetMode. +func (mr *MockAPIMockRecorder) SetMode(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMode", reflect.TypeOf((*MockAPI)(nil).SetMode), arg0) +} + // SetPlanSoc mocks base method. func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 int) error { m.ctrl.T.Helper() diff --git a/i18n/de.json b/i18n/de.json index d535f75f9..9831ce86e 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1365,7 +1365,6 @@ "batteryUsage": "Hausbatterie", "currents": "Ladestrom", "default": "default", - "disclaimerHint": "Hinweis:", "limitSoc": { "description": "Dieses Ladelimit wird verwendet, wenn das Fahrzeug angeschlossen wird.", "label": "Standard Ladelimit" @@ -1380,7 +1379,6 @@ "description": "Fahrzeug wird im PV-Modus „Schnell“ auf {0} geladen. Danach weiter mit PV-Überschuss. Nützlich, um auch an dunklen Tagen eine Mindestreichweite zu gewährleisten.", "label": "Min. Ladung %" }, - "onlyForSocBasedCharging": "Diese Optionen sind nur für Fahrzeuge mit bekanntem Ladestand verfügbar.", "phasesConfigured": { "label": "Phasen", "no1p3pSupport": "Wie ist deine Wallbox angeschlossen?", diff --git a/i18n/en.json b/i18n/en.json index 34436932a..4319bbae8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1365,7 +1365,6 @@ "batteryUsage": "Home Battery", "currents": "Charging Current", "default": "default", - "disclaimerHint": "Note:", "limitSoc": { "description": "Charging limit that is used when this vehicle is connected.", "label": "Default limit" @@ -1380,7 +1379,6 @@ "description": "The vehicle gets „fast” charged to {0} in solar mode. Then continues with solar surplus. Useful to ensure a minimum range even for darker days.", "label": "Min. charge %" }, - "onlyForSocBasedCharging": "These options are only available for vehicles with known charging level.", "phasesConfigured": { "label": "Phases", "no1p3pSupport": "How is your charger connected?", diff --git a/server/http.go b/server/http.go index 8c774b106..1fcae578e 100644 --- a/server/http.go +++ b/server/http.go @@ -173,6 +173,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { // vehicle api vehicles := map[string]route{ + "mode": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/mode/{value:[a-z]+}", vehicleModeHandler(site)}, + "modeDelete": {"DELETE", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/mode", vehicleModeHandler(site)}, "minsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/minsoc/{value:[0-9]+}", minSocHandler(site)}, "limitsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/limitsoc/{value:[0-9]+}", limitSocHandler(site)}, "plan": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc/{value:[0-9]+}/{time:[0-9TZ:.+-]+}", planSocHandler(site)}, diff --git a/server/http_vehicle_handler.go b/server/http_vehicle_handler.go index 78c88e4e0..93f99dd5c 100644 --- a/server/http_vehicle_handler.go +++ b/server/http_vehicle_handler.go @@ -69,6 +69,35 @@ func limitSocHandler(site site.API) http.HandlerFunc { } } +// vehicleModeHandler updates the vehicle charge mode (empty value clears it) +func vehicleModeHandler(site site.API) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + v, err := site.Vehicles().ByName(vars["name"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + mode, err := api.ChargeModeString(vars["value"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + v.SetMode(mode) + + res := struct { + Mode api.ChargeMode `json:"mode"` + }{ + Mode: v.GetMode(), + } + + jsonWrite(w, res) + } +} + // planSocHandler updates plan soc and time func planSocHandler(site site.API) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index f104c0ccb..905ff84a4 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -2864,6 +2864,67 @@ ] } }, + "/vehicles/{name}/mode": { + "delete": { + "description": "Resets the vehicle charge mode to keep the last selected mode.", + "externalDocs": { + "url": "https://docs.evcc.io/en/docs/features/solar-charging" + }, + "operationId": "deleteVehicleMode", + "parameters": [ + { + "$ref": "#/components/parameters/vehicleName" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/NullResult" + } + }, + "summary": "Reset charge mode", + "tags": [ + "vehicles" + ] + } + }, + "/vehicles/{name}/mode/{mode}": { + "post": { + "description": "Sets the charge mode applied when this vehicle becomes active on a loadpoint.", + "externalDocs": { + "url": "https://docs.evcc.io/en/docs/features/solar-charging" + }, + "operationId": "setVehicleMode", + "parameters": [ + { + "$ref": "#/components/parameters/vehicleName" + }, + { + "$ref": "#/components/parameters/mode" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "mode": { + "$ref": "#/components/schemas/Mode" + } + }, + "type": "object" + } + } + }, + "description": "Success" + } + }, + "summary": "Set charge mode", + "tags": [ + "vehicles" + ] + } + }, "/vehicles/{name}/plan/repeating": { "post": { "description": "Updates the repeating charging plan.", diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md index eadfa6a99..40aba839f 100644 --- a/server/mcp/openapi.md +++ b/server/mcp/openapi.md @@ -1192,6 +1192,26 @@ call getTariffInfo { } ``` +## deleteVehicleMode + +Resets the vehicle charge mode to keep the last selected mode. + +**Tags:** vehicles + +**Arguments:** + +| Name | Type | Description | +|------|------|-------------| +| name | string | Vehicle name | + +**Example call:** + +```json +call deleteVehicleMode { + "name": "vehicle_1" +} +``` + ## deleteVehicleSocPlan Delete the charging plan @@ -1234,6 +1254,28 @@ call setVehicleMinSoc { } ``` +## setVehicleMode + +Sets the charge mode applied when this vehicle becomes active on a loadpoint. + +**Tags:** vehicles + +**Arguments:** + +| Name | Type | Description | +|------|------|-------------| +| mode | string | Charging mode. | +| name | string | Vehicle name | + +**Example call:** + +```json +call setVehicleMode { + "mode": "off", + "name": "vehicle_1" +} +``` + ## setVehiclePlanStrategy Updates the charging plan strategy for the vehicle. diff --git a/server/openapi.yaml b/server/openapi.yaml index 2563cbb22..678961262 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1121,6 +1121,42 @@ paths: responses: "200": $ref: "#/components/responses/SocResult" + /vehicles/{name}/mode: + delete: + operationId: deleteVehicleMode + summary: Reset charge mode + description: "Resets the vehicle charge mode to keep the last selected mode." + externalDocs: + url: https://docs.evcc.io/en/docs/features/solar-charging + tags: + - vehicles + parameters: + - $ref: "#/components/parameters/vehicleName" + responses: + "200": + $ref: "#/components/responses/NullResult" + /vehicles/{name}/mode/{mode}: + post: + operationId: setVehicleMode + summary: Set charge mode + description: "Sets the charge mode applied when this vehicle becomes active on a loadpoint." + externalDocs: + url: https://docs.evcc.io/en/docs/features/solar-charging + tags: + - vehicles + parameters: + - $ref: "#/components/parameters/vehicleName" + - $ref: "#/components/parameters/mode" + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + mode: + $ref: "#/components/schemas/Mode" /vehicles/{name}/plan/repeating: post: operationId: updateVehicleRepeatingPlans diff --git a/tests/vehicle-settings.spec.ts b/tests/vehicle-settings.spec.ts index c3b322c60..c13df5548 100644 --- a/tests/vehicle-settings.spec.ts +++ b/tests/vehicle-settings.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; import { start, stop, restart, baseUrl } from "./evcc"; import { expectModalVisible, expectModalHidden } from "./utils"; import { @@ -104,7 +104,7 @@ test.describe("limitSoc", async () => { }); test.describe("minSoc and limitSoc", async () => { - test("disabled for offline vehicles", async ({ page }) => { + test("hidden for offline vehicles", async ({ page }) => { await page.goto("/"); // switch to offline vehicle @@ -112,19 +112,64 @@ test.describe("minSoc and limitSoc", async () => { await page.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); await page.getByRole("link", { name: "Arrival" }).click(); - await expect(page.getByRole("combobox", { name: "Min. charge %" })).toBeDisabled(); - await expect(page.getByRole("combobox", { name: "Default limit" })).toBeDisabled(); + await expect(page.getByRole("combobox", { name: "Min. charge %" })).not.toBeVisible(); + await expect(page.getByRole("combobox", { name: "Default limit" })).not.toBeVisible(); }); - test("disabled for guest vehicles", async ({ page }) => { + test("no tabs for guest vehicles", async ({ page }) => { await page.goto("/"); - // switch to offline vehicle + // switch to guest vehicle (no known vehicle) await page.getByTestId("change-vehicle").locator("select").selectOption("Guest vehicle"); await page.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - await page.getByRole("link", { name: "Arrival" }).click(); - await expect(page.getByRole("combobox", { name: "Min. charge %" })).toBeDisabled(); - await expect(page.getByRole("combobox", { name: "Default limit" })).toBeDisabled(); + const modal = page.getByTestId("charging-plan-modal"); + await expectModalVisible(modal); + await expect(modal.getByRole("link", { name: "Departure" })).toHaveCount(0); + await expect(modal.getByRole("link", { name: "Arrival" })).toHaveCount(0); + }); +}); + +test.describe("default mode", async () => { + async function setDefaultMode(page: Page, option: string) { + await page.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + const modal = page.getByTestId("charging-plan-modal"); + await expectModalVisible(modal); + await modal.getByRole("link", { name: "Arrival" }).click(); + await modal.getByRole("combobox", { name: "Default mode" }).selectOption(option); + await modal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(modal); + } + + test("apply and restart", async ({ page }) => { + await page.goto("/"); + + await setDefaultMode(page, "Fast"); + await page.waitForLoadState("networkidle"); + + await restart(simulatorConfig()); + await page.reload(); + + await page.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + await page.getByRole("link", { name: "Arrival" }).click(); + await expect(page.getByRole("combobox", { name: "Default mode" })).toHaveValue("now"); + }); + + test("applied on vehicle switch", async ({ page }) => { + await page.goto("/"); + const mode = page.getByTestId("loadpoint").first().getByTestId("mode"); + const changeVehicle = page.getByTestId("change-vehicle").locator("select"); + + // per-vehicle default modes + await setDefaultMode(page, "Solar"); + await changeVehicle.selectOption("grüner Honda e"); + await setDefaultMode(page, "Fast"); + + // switching vehicles applies the vehicle's default mode + await changeVehicle.selectOption("blauer e-Golf"); + await expect(mode.getByRole("button", { name: "Solar", exact: true })).toHaveClass(/active/); + + await changeVehicle.selectOption("grüner Honda e"); + await expect(mode.getByRole("button", { name: "Fast" })).toHaveClass(/active/); }); });