Vehicle: add charge mode in arrival tab (#31330)

This commit is contained in:
Michael Geers 2026-07-09 19:27:36 +02:00 • committed by GitHub
parent d495d3e442
commit cc5ba51c31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 401 additions and 75 deletions

View file

@ -1,25 +1,40 @@
<template>
<div class="mt-4 container">
<div class="row">
<div class="col-6 col-lg-3 col-form-label">
<label :for="formId('minsoc')">
<div class="row mb-4">
<label :for="formId('mode')" class="col-12 col-md-4 col-lg-3 col-form-label">
{{ $t("config.loadpoint.defaultModeLabel") }}
</label>
<div class="col-12 col-md-8 col-lg-6">
<select
:id="formId('mode')"
v-model="selectedMode"
class="form-select"
@change="changeMode"
>
<option v-for="opt in modeOptions" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
<small class="form-text text-muted">{{ modeHelp }}</small>
</div>
</div>
<template v-if="socSettingsVisible">
<div class="row mb-4">
<label :for="formId('minsoc')" class="col-12 col-md-4 col-lg-3 col-form-label">
{{ $t("main.loadpointSettings.minSoc.label") }}
</label>
</div>
<div class="col-6 col-lg-3">
<div class="col-12 col-md-8 col-lg-6">
<select
:id="formId('minsoc')"
v-model.number="selectedMinSoc"
class="form-select mb-2"
:disabled="arrivalDisabled"
class="form-select"
@change="changeMinSoc"
>
<option v-for="soc in minSocOptions" :key="soc.value" :value="soc.value">
{{ soc.name }}
</option>
</select>
</div>
<small class="col-12 col-lg-6 ps-lg-4 col-form-label mb-4">
<small class="form-text text-muted">
{{
$t("main.loadpointSettings.minSoc.description", [
selectedMinSoc ? fmtPercentage(selectedMinSoc) : "x",
@ -27,44 +42,39 @@
}}
</small>
</div>
<div class="row">
<div class="col-6 col-lg-3 col-form-label">
<label :for="formId('limitsoc')">
</div>
<div class="row mb-4">
<label :for="formId('limitsoc')" class="col-12 col-md-4 col-lg-3 col-form-label">
{{ $t("main.loadpointSettings.limitSoc.label") }}
</label>
</div>
<div class="col-6 col-lg-3">
<div class="col-12 col-md-8 col-lg-6">
<select
:id="formId('limitsoc')"
v-model.number="selectedLimitSoc"
class="form-select mb-2"
:disabled="arrivalDisabled"
class="form-select"
@change="changeLimitSoc"
>
<option v-for="soc in limitSocOptions" :key="soc.value" :value="soc.value">
{{ soc.name }}
</option>
</select>
</div>
<small class="col-12 col-lg-6 ps-lg-4 col-form-label mb-4">
<small class="form-text text-muted">
{{ $t("main.loadpointSettings.limitSoc.description") }}
</small>
</div>
</div>
<div v-if="arrivalDisabled" class="mx-2 small text-muted">
<strong class="text-evcc">
{{ $t("main.loadpointSettings.disclaimerHint") }}
</strong>
{{ $t("main.loadpointSettings.onlyForSocBasedCharging") }}
</template>
</div>
</template>
<script lang="ts">
import { distanceUnit } from "@/units";
import formatter from "@/mixins/formatter";
import type { SelectOption } from "@/types/evcc";
import { CHARGE_MODE, type SelectOption } from "@/types/evcc";
import { defineComponent } from "vue";
const { OFF, PV, MINPV, NOW } = CHARGE_MODE;
export default defineComponent({
name: "ChargingPlanArrival",
mixins: [formatter],
@ -72,16 +82,35 @@ export default defineComponent({
id: [String, Number],
minSoc: { type: Number, default: 0 },
limitSoc: { type: Number, default: 0 },
mode: { type: String, default: "" },
vehicleName: String,
vehicleNotReachable: Boolean,
socBasedCharging: Boolean,
rangePerSoc: Number,
},
emits: ["minsoc-updated", "limitsoc-updated"],
emits: ["minsoc-updated", "limitsoc-updated", "mode-updated"],
data() {
return { selectedMinSoc: this.minSoc, selectedLimitSoc: this.limitSoc };
return {
selectedMinSoc: this.minSoc,
selectedLimitSoc: this.limitSoc,
selectedMode: this.mode,
};
},
computed: {
modeOptions(): SelectOption<string>[] {
return [
{ value: "", name: "---" },
...[OFF, PV, MINPV, NOW].map((mode) => ({
value: mode,
name: this.$t(`main.mode.${mode}`),
})),
];
},
modeHelp(): string {
return this.selectedMode === ""
? this.$t("config.loadpoint.defaultModeHelpKeep")
: this.$t("config.loadpoint.defaultModeHelp.charging");
},
minSocOptions(): SelectOption<number>[] {
// a list of entries from 0 to 95 with a step of 5
return Array.from(Array(20).keys())
@ -94,8 +123,8 @@ export default defineComponent({
.map((i) => i * 5)
.map(this.socOption);
},
arrivalDisabled(): boolean {
return !(this.socBasedCharging || this.vehicleNotReachable);
socSettingsVisible(): boolean {
return this.socBasedCharging || this.vehicleNotReachable;
},
},
watch: {
@ -105,6 +134,9 @@ export default defineComponent({
limitSoc(value: number): void {
this.selectedLimitSoc = value;
},
mode(value: string): void {
this.selectedMode = value;
},
},
methods: {
socOption(soc: number): SelectOption<number> {
@ -122,6 +154,9 @@ export default defineComponent({
changeLimitSoc(): void {
this.$emit("limitsoc-updated", this.selectedLimitSoc);
},
changeMode(): void {
this.$emit("mode-updated", this.selectedMode);
},
},
});
</script>

View file

@ -10,7 +10,7 @@
@closed="modalInvisible"
>
<div class="pt-2">
<ul class="nav nav-tabs">
<ul v-if="vehicle" class="nav nav-tabs">
<li class="nav-item">
<a
class="nav-link"
@ -21,7 +21,7 @@
{{ $t("main.chargingPlan.departureTab") }}
</a>
</li>
<li class="nav-item">
<li v-if="vehicle" class="nav-item">
<a
class="nav-link"
:class="{ active: arrivalTabActive }"
@ -65,12 +65,14 @@
:id="id"
:minSoc="vehicle?.minSoc"
:limitSoc="vehicle?.limitSoc"
:mode="vehicle?.mode"
:vehicleName="vehicle?.name"
:vehicleNotReachable="loadpoint?.vehicleNotReachable"
:socBasedCharging="loadpoint?.socBasedCharging"
:rangePerSoc="loadpoint?.rangePerSoc"
@minsoc-updated="setMinSoc"
@limitsoc-updated="setLimitSoc"
@mode-updated="setMode"
/>
</div>
</div>
@ -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) {

View file

@ -677,6 +677,7 @@ export interface BatteryMeter extends Meter {
export interface Vehicle {
name: string;
mode?: CHARGE_MODE | "";
minSoc?: number;
limitSoc?: number;
plan?: StaticPlan;

View file

@ -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)
}

View file

@ -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,

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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?",

View file

@ -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?",

View file

@ -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)},

View file

@ -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) {

View file

@ -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.",

View file

@ -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.

View file

@ -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

View file

@ -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/);
});
});