diff --git a/assets/js/components/Sessions/SessionDetailsModal.vue b/assets/js/components/Sessions/SessionDetailsModal.vue
index c44a373ed..59a655922 100644
--- a/assets/js/components/Sessions/SessionDetailsModal.vue
+++ b/assets/js/components/Sessions/SessionDetailsModal.vue
@@ -121,12 +121,48 @@
{{ socRange }}
-
- |
+ |
+ |
{{ $t("session.odometer") }}
|
- {{ formatKm(session.odometer) }}
+
+
+
+
+ {{ distanceUnitLabel }}
+
+
+
+
|
@@ -181,7 +217,7 @@ import formatter from "@/mixins/formatter";
import Options from "../Vehicles/Options.vue";
import CustomSelect from "../Helper/CustomSelect.vue";
import GenericModal from "../Helper/GenericModal.vue";
-import { distanceUnit, distanceValue } from "@/units";
+import { distanceUnit, distanceValue, distanceValueReverse } from "@/units";
import api from "@/api";
import { defineComponent, type PropType } from "vue";
import type { Session } from "./types";
@@ -198,7 +234,17 @@ export default defineComponent({
loadpoints: { type: Array as PropType, default: () => [] },
},
emits: ["session-changed"],
+ data() {
+ return {
+ editingOdometer: false,
+ odometerInput: "" as number | string,
+ odometerOriginal: "" as number | string,
+ };
+ },
computed: {
+ distanceUnitLabel(): string {
+ return distanceUnit();
+ },
chargedEnergy() {
return this.session.chargedEnergy * 1e3;
},
@@ -246,6 +292,26 @@ export default defineComponent({
formatKm(value: number) {
return `${this.fmtNumber(distanceValue(value), 0)} ${distanceUnit()}`;
},
+ startEditOdometer() {
+ this.odometerInput = this.session.odometer
+ ? Math.round(distanceValue(this.session.odometer))
+ : "";
+ this.odometerOriginal = this.odometerInput;
+ this.editingOdometer = true;
+ this.$nextTick(() => (this.$refs["odometerInput"] as HTMLInputElement)?.focus());
+ },
+ cancelOdometer() {
+ this.editingOdometer = false;
+ },
+ async saveOdometer() {
+ if (!this.editingOdometer) return;
+ this.editingOdometer = false;
+ const value = this.odometerInput;
+ if (value === this.odometerOriginal) return;
+ // empty input clears the stored value
+ const odometer = value === "" ? null : distanceValueReverse(Number(value));
+ await this.updateSession({ odometer });
+ },
async changeVehicle(title: string) {
await this.updateSession({ vehicle: title });
},
@@ -255,7 +321,9 @@ export default defineComponent({
async changeLoadpoint(title: string) {
await this.updateSession({ loadpoint: title });
},
- async updateSession(data: Partial | { vehicle: null }) {
+ async updateSession(
+ data: Partial | { vehicle: null } | { odometer: number | null }
+ ) {
try {
await api.put("session/" + this.session.id, data);
this.$emit("session-changed");
@@ -284,4 +352,23 @@ export default defineComponent({
.options .loadpoint-name {
text-decoration: underline;
}
+
+.odometer-cell {
+ /* reserve edit-input height so the row doesn't jump between view/edit */
+ min-height: 2rem;
+}
+
+.odometer-input .form-control {
+ /* fit ~7 digits (e.g. 128222) */
+ width: calc(7ch + 1rem);
+ -moz-appearance: textfield;
+ appearance: textfield;
+}
+
+.odometer-input .form-control::-webkit-inner-spin-button,
+.odometer-input .form-control::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ appearance: none;
+ margin: 0;
+}
diff --git a/assets/js/units.ts b/assets/js/units.ts
index cf50379be..f57f236b0 100644
--- a/assets/js/units.ts
+++ b/assets/js/units.ts
@@ -11,6 +11,10 @@ export function distanceValue(value: number) {
return isMiles() ? value * MILES_FACTOR : value;
}
+export function distanceValueReverse(value: number) {
+ return isMiles() ? value / MILES_FACTOR : value;
+}
+
export function distanceUnit() {
return isMiles() ? "mi" : "km";
}
diff --git a/i18n/de.json b/i18n/de.json
index e0ebf2a22..24b083f13 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -1403,6 +1403,7 @@
"updatePassword": "Passwort ändern"
},
"session": {
+ "addOdometer": "Wert eintragen",
"cancel": "Abbrechen",
"co2": "CO₂",
"date": "Zeitraum",
diff --git a/i18n/en.json b/i18n/en.json
index 73f528edd..5fff29c9b 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1403,6 +1403,7 @@
"updatePassword": "Update password"
},
"session": {
+ "addOdometer": "Add value",
"cancel": "Cancel",
"co2": "CO₂",
"date": "Period",
diff --git a/server/http_session_handler.go b/server/http_session_handler.go
index 6eda64752..242dca7c3 100644
--- a/server/http_session_handler.go
+++ b/server/http_session_handler.go
@@ -119,22 +119,30 @@ func updateSessionHandler(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
- var data struct {
- Vehicle, Loadpoint *string
- }
-
- if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+ var body map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
- if data.Vehicle == nil && data.Loadpoint == nil {
+ // only update fields present in the request; a null value clears the column
+ updates := map[string]any{}
+ if v, ok := body["vehicle"]; ok {
+ updates["vehicle"] = v
+ }
+ if v, ok := body["loadpoint"]; ok {
+ updates["loadpoint"] = v
+ }
+ if v, ok := body["odometer"]; ok {
+ updates["odometer"] = v
+ }
+
+ if len(updates) == 0 {
jsonError(w, http.StatusBadRequest, errors.New("nothing to update"))
return
}
- // https://github.com/evcc-io/evcc/issues/13738#issuecomment-2094070362
- if txn := db.Instance.Table("sessions").Where("id = ?", id).Updates(data); txn.Error != nil {
+ if txn := db.Instance.Table("sessions").Where("id = ?", id).Updates(updates); txn.Error != nil {
jsonError(w, http.StatusBadRequest, txn.Error)
return
}
diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json
index 2004cfd96..f08dfbfb5 100644
--- a/server/mcp/openapi.json
+++ b/server/mcp/openapi.json
@@ -405,9 +405,7 @@
"type": "number"
},
"odometer": {
- "description": "Vehicle odometer reading in kilometers",
- "nullable": true,
- "type": "number"
+ "$ref": "#/components/schemas/Odometer"
},
"vehicle": {
"$ref": "#/components/schemas/VehicleName"
@@ -561,6 +559,11 @@
],
"type": "string"
},
+ "Odometer": {
+ "description": "Vehicle odometer reading in kilometers",
+ "nullable": true,
+ "type": "number"
+ },
"Password": {
"description": "Admin password",
"type": "string"
@@ -1939,7 +1942,7 @@
}
],
"put": {
- "description": "Update vehicle of charging session.",
+ "description": "Update vehicle, loadpoint or odometer of a charging session. Only provided fields are changed; a null odometer clears the stored value.",
"externalDocs": {
"url": "https://docs.evcc.io/en/docs/features/sessions"
},
@@ -1952,6 +1955,9 @@
"loadpoint": {
"$ref": "#/components/schemas/LoadpointName"
},
+ "odometer": {
+ "$ref": "#/components/schemas/Odometer"
+ },
"vehicle": {
"$ref": "#/components/schemas/VehicleName"
}
@@ -1967,7 +1973,7 @@
"description": "Success"
}
},
- "summary": "Update vehicle of charging session",
+ "summary": "Update charging session",
"tags": [
"sessions"
]
diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md
index d657b8a6e..12bea73bb 100644
--- a/server/mcp/openapi.md
+++ b/server/mcp/openapi.md
@@ -946,7 +946,7 @@ call getSessions {
## updateSession
-Update vehicle of charging session.
+Update vehicle, loadpoint or odometer of a charging session. Only provided fields are changed; a null odometer clears the stored value.
**Tags:** sessions
diff --git a/server/openapi.yaml b/server/openapi.yaml
index 26ca0be20..3c5e387c7 100644
--- a/server/openapi.yaml
+++ b/server/openapi.yaml
@@ -696,8 +696,8 @@ paths:
- $ref: "#/components/parameters/id"
put:
operationId: updateSession
- summary: Update vehicle of charging session
- description: "Update vehicle of charging session."
+ summary: Update charging session
+ description: "Update vehicle, loadpoint or odometer of a charging session. Only provided fields are changed; a null odometer clears the stored value."
externalDocs:
url: https://docs.evcc.io/en/docs/features/sessions
tags:
@@ -713,6 +713,8 @@ paths:
$ref: "#/components/schemas/VehicleName"
loadpoint:
$ref: "#/components/schemas/LoadpointName"
+ odometer:
+ $ref: "#/components/schemas/Odometer"
responses:
"200":
description: Success
@@ -1182,9 +1184,7 @@ components:
vehicle:
$ref: "#/components/schemas/VehicleName"
odometer:
- nullable: true
- type: "number"
- description: Vehicle odometer reading in kilometers
+ $ref: "#/components/schemas/Odometer"
meterStart:
nullable: true
type: "number"
@@ -1305,6 +1305,10 @@ components:
- "now"
- "minpv"
- "pv"
+ Odometer:
+ nullable: true
+ type: "number"
+ description: Vehicle odometer reading in kilometers
Password:
description: Admin password
type: string
diff --git a/tests/sessions.spec.ts b/tests/sessions.spec.ts
index ee2010db0..dd812f7dc 100644
--- a/tests/sessions.spec.ts
+++ b/tests/sessions.spec.ts
@@ -1,6 +1,12 @@
import { test, expect, devices, type Page } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
-import { expectModalVisible, expectModalHidden, enableAppContext, expectAppEvent } from "./utils";
+import {
+ expectModalVisible,
+ expectModalHidden,
+ enableAppContext,
+ expectAppEvent,
+ openMoreMenu,
+} from "./utils";
test.use({ baseURL: baseUrl() });
@@ -347,7 +353,7 @@ test.describe("session details", async () => {
await expect(modal.getByTestId("session-details-solar")).toContainText("0.0% (0.0 kWh)");
await expect(modal.getByTestId("session-details-price")).toContainText("2.50 € 50.0 ct/kWh");
await expect(modal.getByTestId("session-details-co2")).toHaveCount(0);
- await expect(modal.getByTestId("session-details-odometer")).toHaveCount(0);
+ await expect(modal.getByTestId("session-details-odometer")).toContainText("Add value");
await expect(modal.getByTestId("session-details-meter")).toHaveCount(0);
await expect(modal.getByTestId("session-details-delete")).toContainText("Delete");
});
@@ -431,3 +437,78 @@ test.describe("session details", async () => {
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
});
});
+
+test.describe("edit odometer", async () => {
+ async function openFirstSession(page: Page) {
+ await page.getByTestId("sessions-entry").nth(0).click();
+ await expectModalVisible(page.getByTestId("session-details"));
+ }
+
+ async function closeSession(page: Page) {
+ const modal = page.getByTestId("session-details");
+ await modal.getByRole("button", { name: "Close" }).click();
+ await expectModalHidden(modal);
+ }
+
+ async function setDistanceUnit(page: Page, unit: string) {
+ await openMoreMenu(page);
+ await page.getByRole("button", { name: "User Interface", exact: true }).click();
+ const settings = page.getByTestId("global-settings-modal");
+ await expectModalVisible(settings);
+ await settings.getByRole("radio", { name: `Units: ${unit}` }).click();
+ await settings.getByRole("button", { name: "Close" }).click();
+ await expectModalHidden(settings);
+ }
+
+ test("edit existing value and convert between km and miles (session 1)", async ({ page }) => {
+ const odometer = page.getByTestId("session-details").getByLabel("Mileage");
+
+ await page.goto("/#/sessions?year=2023&month=3");
+ await openFirstSession(page);
+ await expect(odometer).toContainText("12,345 km");
+ await odometer.click();
+ await expect(odometer).toHaveValue("12345");
+ await odometer.fill("100000");
+ await odometer.press("Enter");
+ // value reloads from the server after save
+ await expect(odometer).toContainText("100,000 km");
+
+ // switch to miles, value converts on display (100000 km -> 62137 mi)
+ await closeSession(page);
+ await setDistanceUnit(page, "miles");
+ await openFirstSession(page);
+ await expect(odometer).toContainText("62,137 mi");
+
+ // edit in miles
+ await odometer.click();
+ await expect(odometer).toHaveValue("62137");
+ await odometer.fill("50000");
+ await odometer.press("Enter");
+ await expect(odometer).toContainText("50,000 mi");
+
+ // switch back to km, value round-trips (50000 mi -> ~80,467 km)
+ await closeSession(page);
+ await setDistanceUnit(page, "km");
+ await openFirstSession(page);
+ await expect(odometer).toContainText("80,467 km");
+ });
+
+ test("add missing value (session 4)", async ({ page }) => {
+ const odometer = page.getByTestId("session-details").getByLabel("Mileage");
+
+ await page.goto("/#/sessions?year=2023&month=5");
+ await openFirstSession(page);
+ await expect(odometer).toContainText("Add value");
+ await odometer.click();
+ await expect(odometer).toHaveValue("");
+ await odometer.fill("1000");
+ await odometer.press("Enter");
+ await expect(odometer).toContainText("1,000 km");
+
+ // clearing the field deletes the value
+ await odometer.click();
+ await odometer.fill("");
+ await odometer.press("Enter");
+ await expect(odometer).toContainText("Add value");
+ });
+});