+
{{ error }}
-
diff --git a/assets/js/components/Config/VehicleModal.vue b/assets/js/components/Config/VehicleModal.vue
index f0172ad21..1b69291ef 100644
--- a/assets/js/components/Config/VehicleModal.vue
+++ b/assets/js/components/Config/VehicleModal.vue
@@ -165,7 +165,9 @@
:is-deletable="isDeletable"
:test-state="test"
:is-saving="saving"
- @save="isNew ? create() : update()"
+ :is-succeeded="succeeded"
+ :is-new="isNew"
+ @save="(force) => (isNew ? create(force) : update(force))"
@remove="remove"
@test="testManually"
/>
@@ -186,6 +188,7 @@ import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSe
import DeviceModalActions from "./DeviceModal/Actions.vue";
import YamlEntry from "./DeviceModal/YamlEntry.vue";
import { initialTestState, performTest } from "./utils/test";
+import sleep from "@/utils/sleep";
import { ConfigType } from "@/types/evcc";
import {
handleError,
@@ -200,10 +203,6 @@ import defaultYaml from "./defaultYaml/vehicle.yaml?raw";
const initialValues = { type: ConfigType.Template, icon: "car" };
const device = createDeviceUtils("vehicle");
-function sleep(ms: number) {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
const CUSTOM_FIELDS = ["minCurrent", "maxCurrent", "priority", "identifiers", "phases", "mode"];
type VehicleDeviceValues = DeviceValues & {
@@ -241,6 +240,7 @@ export default defineComponent({
templateName: null as string | null,
template: null as Template | null,
saving: false,
+ succeeded: false,
loadingTemplate: false,
values: { ...initialValues } as VehicleDeviceValues,
test: initialTestState(),
@@ -356,6 +356,7 @@ export default defineComponent({
this.reset();
this.templateName = "offline";
this.test = initialTestState();
+ this.succeeded = false;
this.loadProducts();
if (this.id !== undefined) {
this.loadConfiguration();
@@ -413,25 +414,29 @@ export default defineComponent({
}
this.loadingTemplate = false;
},
- async create() {
+ async create(force = false) {
// persist selected template product
if (this.template && this.$refs["templateSelect"]) {
this.values.deviceProduct = (this.$refs["templateSelect"] as any).getProductName();
}
- if (this.test.isUnknown) {
+ if (this.test.isUnknown && !force) {
const success = await performTest(this.test, this.testVehicle, this.$refs["form"]);
- if (!success) return;
- await sleep(100);
+ if (!success) {
+ return;
+ }
}
this.saving = true;
try {
- const { name } = await device.create(this.apiData);
+ const { name } = await device.create(this.apiData, force);
+ this.saving = false;
+ this.succeeded = true;
+ await sleep(1000);
this.$emit("vehicle-changed", name);
this.closed();
} catch (e) {
handleError(e, "create failed");
+ this.saving = false;
}
- this.saving = false;
},
async testManually() {
await performTest(this.test, this.testVehicle, this.$refs["form"]);
@@ -439,21 +444,25 @@ export default defineComponent({
async testVehicle() {
return device.test(this.id, this.apiData);
},
- async update() {
- if (this.test.isUnknown) {
+ async update(force = false) {
+ if (this.test.isUnknown && !force) {
const success = await performTest(this.test, this.testVehicle, this.$refs["form"]);
- if (!success) return;
- await sleep(250);
+ if (!success) {
+ return;
+ }
}
this.saving = true;
try {
- await device.update(this.id as number, this.apiData);
+ await device.update(this.id as number, this.apiData, force);
+ this.saving = false;
+ this.succeeded = true;
+ await sleep(1000);
this.$emit("vehicle-changed");
this.closed();
} catch (e) {
handleError(e, "update failed");
+ this.saving = false;
}
- this.saving = false;
},
async remove() {
try {
diff --git a/assets/js/components/Config/utils/test.ts b/assets/js/components/Config/utils/test.ts
index 045297f26..7a0e1723c 100644
--- a/assets/js/components/Config/utils/test.ts
+++ b/assets/js/components/Config/utils/test.ts
@@ -1,4 +1,5 @@
import type { AxiosResponse } from "axios";
+import sleep from "@/utils/sleep";
export type TestState = {
isUnknown: boolean;
@@ -20,6 +21,8 @@ export const initialTestState = (): TestState => ({
errorLine: null,
});
+const MIN_TEST_DURATION = 500;
+
export const performTest = async (
state: TestState,
api: () => Promise
>,
@@ -28,12 +31,13 @@ export const performTest = async (
if (form && !(form as HTMLFormElement).reportValidity()) return false;
state.isUnknown = false;
state.isSuccess = false;
- state.isError = false;
state.isRunning = true;
- state.error = null;
- state.errorLine = null;
+ const startTime = Date.now();
try {
const res = await api();
+ state.isError = false;
+ state.error = null;
+ state.errorLine = null;
for (const [key, value] of Object.entries(res.data)) {
const { error } = value as { error?: string };
if (error) {
@@ -50,6 +54,11 @@ export const performTest = async (
state.error = e.response?.data?.error || e.message;
state.errorLine = e.response?.data?.line || null;
} finally {
+ const elapsed = Date.now() - startTime;
+ const remainingTime = MIN_TEST_DURATION - elapsed;
+ if (remainingTime > 0) {
+ await sleep(remainingTime);
+ }
state.isRunning = false;
}
return false;
diff --git a/assets/js/utils/sleep.ts b/assets/js/utils/sleep.ts
new file mode 100644
index 000000000..1fc86a2c4
--- /dev/null
+++ b/assets/js/utils/sleep.ts
@@ -0,0 +1 @@
+export default (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/i18n/de.json b/i18n/de.json
index 32f21013e..8a84364cb 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -165,6 +165,7 @@
"dragItem": "Verschiebbar: {title}",
"dragList": "Sortierbare Liste",
"experimental": "Experimentell",
+ "forceSave": "Trotzdem speichern",
"hideAdvancedSettings": "Erweiterte Einstellungen ausblenden",
"invalidFileSelected": "Ungültige Datei ausgewählt",
"noFileSelected": "Keine Datei ausgewählt.",
@@ -176,6 +177,7 @@
"required": "erforderlich",
"reset": "Zurücksetzen",
"save": "Speichern",
+ "saved": "Gespeichert.",
"selectFile": "Durchsuchen",
"showAdvancedSettings": "Erweiterte Einstellungen anzeigen",
"telemetry": "Telemetrie",
diff --git a/i18n/en.json b/i18n/en.json
index 708c08914..d987a4b5e 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -165,6 +165,7 @@
"dragItem": "Draggable: {title}",
"dragList": "Reorderable list",
"experimental": "Experimental",
+ "forceSave": "Save anyway",
"hideAdvancedSettings": "Hide advanced settings",
"invalidFileSelected": "Invalid file selected",
"noFileSelected": "No file selected.",
@@ -176,6 +177,7 @@
"required": "required",
"reset": "Reset",
"save": "Save",
+ "saved": "Saved.",
"selectFile": "Browse",
"showAdvancedSettings": "Show advanced settings",
"telemetry": "Telemetry",
diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go
index ea08bb163..e131d6970 100644
--- a/server/http_config_device_handler.go
+++ b/server/http_config_device_handler.go
@@ -254,9 +254,9 @@ func deviceStatusHandler(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, testInstance(instance))
}
-func newDevice[T any](ctx context.Context, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T]) (*config.Config, error) {
+func newDevice[T any](ctx context.Context, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T], force bool) (*config.Config, error) {
instance, err := newFromConf(ctx, req.Type, req.Other)
- if err != nil {
+ if err != nil && !force {
return nil, err
}
@@ -287,20 +287,22 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
var conf *config.Config
ctx, cancel, done := startDeviceTimeout()
+ force := r.URL.Query().Get("force") == "true"
+
switch class {
case templates.Charger:
- conf, err = newDevice(ctx, class, req, charger.NewFromConfig, config.Chargers())
+ conf, err = newDevice(ctx, class, req, charger.NewFromConfig, config.Chargers(), force)
case templates.Meter:
- conf, err = newDevice(ctx, class, req, meter.NewFromConfig, config.Meters())
+ conf, err = newDevice(ctx, class, req, meter.NewFromConfig, config.Meters(), force)
case templates.Vehicle:
- conf, err = newDevice(ctx, class, req, vehicle.NewFromConfig, config.Vehicles())
+ conf, err = newDevice(ctx, class, req, vehicle.NewFromConfig, config.Vehicles(), force)
case templates.Circuit:
conf, err = newDevice(ctx, class, req, func(ctx context.Context, _ string, other map[string]interface{}) (api.Circuit, error) {
return circuit.NewFromConfig(ctx, util.NewLogger("circuit"), other)
- }, config.Circuits())
+ }, config.Circuits(), force)
}
if err != nil {
@@ -325,10 +327,13 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, res)
}
-func updateDevice[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T]) error {
+func updateDevice[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T], force bool) error {
dev, instance, merged, err := deviceInstanceFromMergedConfig(ctx, id, class, req, newFromConf, h)
if err != nil {
- return err
+ // allow force-updating if merged config exists
+ if !force || merged == nil {
+ return err
+ }
}
configurable, ok := dev.(config.ConfigurableDevice[T])
@@ -363,20 +368,22 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel, done := startDeviceTimeout()
+ force := r.URL.Query().Get("force") == "true"
+
switch class {
case templates.Charger:
- err = updateDevice(ctx, id, class, req, charger.NewFromConfig, config.Chargers())
+ err = updateDevice(ctx, id, class, req, charger.NewFromConfig, config.Chargers(), force)
case templates.Meter:
- err = updateDevice(ctx, id, class, req, meter.NewFromConfig, config.Meters())
+ err = updateDevice(ctx, id, class, req, meter.NewFromConfig, config.Meters(), force)
case templates.Vehicle:
- err = updateDevice(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles())
+ err = updateDevice(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles(), force)
case templates.Circuit:
err = updateDevice(ctx, id, class, req, func(ctx context.Context, _ string, other map[string]interface{}) (api.Circuit, error) {
return circuit.NewFromConfig(ctx, util.NewLogger("circuit"), other)
- }, config.Circuits())
+ }, config.Circuits(), force)
}
setConfigDirty()
diff --git a/server/http_site_handler.go b/server/http_site_handler.go
index 21e591725..33f658913 100644
--- a/server/http_site_handler.go
+++ b/server/http_site_handler.go
@@ -91,10 +91,12 @@ func jsonError(w http.ResponseWriter, status int, err error) {
w.WriteHeader(status)
res := struct {
- Error string `json:"error"`
- Line int `json:"line,omitempty"`
+ Error string `json:"error"`
+ Line int `json:"line,omitempty"`
+ IsAuthError bool `json:"isAuthError,omitempty"`
}{
- Error: err.Error(),
+ Error: err.Error(),
+ IsAuthError: errors.Is(err, api.ErrLoginRequired) || errors.Is(err, api.ErrMissingToken),
}
var (
diff --git a/tests/config-pv.spec.ts b/tests/config-pv.spec.ts
index d25925527..6da782c0d 100644
--- a/tests/config-pv.spec.ts
+++ b/tests/config-pv.spec.ts
@@ -70,4 +70,38 @@ test.describe("pv meter", async () => {
await page.reload();
await expect(page.getByTestId("pv")).toHaveCount(0);
});
+
+ test("create broken pv meter with validation failure", async ({ page }) => {
+ await page.goto("/#/config");
+ await enableExperimental(page, false);
+
+ await expect(page.getByTestId("pv")).toHaveCount(0);
+
+ // create broken meter
+ await page.getByRole("button", { name: "Add solar or battery" }).click();
+
+ const meterModal = page.getByTestId("meter-modal");
+ await expectModalVisible(meterModal);
+ await meterModal.getByRole("button", { name: "Add solar meter" }).click();
+ await meterModal.getByLabel("Title").fill("Broken PV");
+ await meterModal.getByLabel("Manufacturer").selectOption("SunSpec Inverter");
+ await meterModal.getByLabel("IP address or hostname").fill("0.0.0.0");
+ await meterModal.getByRole("button", { name: "Validate & save" }).click();
+
+ // wait for validation to complete and check failure
+ const testResult = meterModal.getByTestId("test-result");
+ await expect(testResult).toContainText("Status: failed");
+ await expect(testResult).toContainText("connection refused");
+
+ // verify "Save anyway" button is now visible
+ await expect(meterModal.getByRole("button", { name: "Save anyway" })).toBeVisible();
+
+ // save anyway
+ await meterModal.getByRole("button", { name: "Save anyway" }).click();
+ await expectModalHidden(meterModal);
+
+ // verify broken meter is visible in list
+ await expect(page.getByTestId("pv")).toBeVisible();
+ await expect(page.getByTestId("pv")).toContainText("Broken PV");
+ });
});