From eed3aece143221c714bcd31c6bb0500939cdfca4 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 17 Jul 2026 14:38:59 +0200 Subject: [PATCH] Config: show vehicle asleep state instead of value errors (#31859) --- assets/js/components/Config/DeviceTags.vue | 14 ++++++++---- i18n/de.json | 1 + i18n/en.json | 1 + plugin/error.go | 10 +++++++++ server/http_config_helper.go | 12 ++++++++--- server/http_config_helper_test.go | 19 ++++++++++++++++ tests/vehicle-asleep.evcc.yaml | 8 +++++++ tests/vehicle-asleep.spec.ts | 25 ++++++++++++++++++++++ 8 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 tests/vehicle-asleep.evcc.yaml create mode 100644 tests/vehicle-asleep.spec.ts diff --git a/assets/js/components/Config/DeviceTags.vue b/assets/js/components/Config/DeviceTags.vue index 8038c8de4..779f9f3a2 100644 --- a/assets/js/components/Config/DeviceTags.vue +++ b/assets/js/components/Config/DeviceTags.vue @@ -100,16 +100,16 @@ export default { !PHASE_TAGS.includes(name) && !FORECAST_TAGS.includes(name) ) - .map(([name, { value, error, warning, muted }]) => { - return { name, value, error, warning, muted }; + .map(([name, { value, error, warning, muted, asleep }]) => { + return { name, value, error, warning, muted, asleep }; }); }, phaseEntries() { return Object.entries(this.tags) .filter(([name]) => PHASE_TAGS.includes(name)) .sort(([a], [b]) => a.localeCompare(b)) - .map(([name, { value, error, warning, muted }]) => { - return { name, value, error, warning, muted }; + .map(([name, { value, error, warning, muted, asleep }]) => { + return { name, value, error, warning, muted, asleep }; }); }, hasPhaseEntries() { @@ -182,6 +182,9 @@ export default { : "text-nowrap flex-shrink-0"; }, valueClasses(entry) { + if (entry.asleep) { + return "value--muted"; + } if (entry.error) { return "value--error"; } @@ -195,6 +198,9 @@ export default { }, fmtDeviceValue(entry) { const { name, value } = entry; + if (entry.asleep) { + return this.$t("config.deviceValue.asleep"); + } if (value === null || value === undefined) { return ""; } diff --git a/i18n/de.json b/i18n/de.json index fecf47b8c..68f6dda1d 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -188,6 +188,7 @@ "deviceValue": { "activeClients": "Aktive Clients", "amount": "Anzahl", + "asleep": "schläft", "broker": "Broker", "bucket": "Bucket", "capacity": "Kapazität", diff --git a/i18n/en.json b/i18n/en.json index 9af633eba..dd19199f7 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -188,6 +188,7 @@ "deviceValue": { "activeClients": "Active clients", "amount": "Amount", + "asleep": "sleeping", "broker": "Broker", "bucket": "Bucket", "capacity": "Capacity", diff --git a/plugin/error.go b/plugin/error.go index a4aecd7c1..016f5d259 100644 --- a/plugin/error.go +++ b/plugin/error.go @@ -7,6 +7,7 @@ import ( ) type errorPlugin struct { + *getter err error } @@ -32,10 +33,19 @@ func NewErrorFromConfig(other map[string]any) (Plugin, error) { o := &errorPlugin{ err: err, } + o.getter = defaultGetters(o, 1) return o, nil } +var _ StringGetter = (*errorPlugin)(nil) + +func (o *errorPlugin) StringGetter() (func() (string, error), error) { + return func() (string, error) { + return "", o.err + }, nil +} + var _ IntSetter = (*errorPlugin)(nil) func (o *errorPlugin) IntSetter(param string) (func(int64) error, error) { diff --git a/server/http_config_helper.go b/server/http_config_helper.go index 6feb0ced8..3436ac1fb 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -245,8 +245,9 @@ func deviceInstanceFromMergedConfig[T any](ctx context.Context, id int, class te } type testResult = struct { - Value any `json:"value"` - Error string `json:"error"` + Value any `json:"value"` + Error string `json:"error"` + Asleep bool `json:"asleep,omitempty"` } func hasFeature(instance any, f api.Feature) bool { @@ -266,7 +267,12 @@ func testInstance(ctx context.Context, instance any) map[string]testResult { if errors.Is(err, api.ErrNotAvailable) { return } - tr.Error = err.Error() + // asleep is a valid vehicle state, not an error + if errors.Is(err, api.ErrAsleep) { + tr.Asleep = true + } else { + tr.Error = err.Error() + } } resMu.Lock() res[key] = tr diff --git a/server/http_config_helper_test.go b/server/http_config_helper_test.go index ee2bfe057..e0af6b674 100644 --- a/server/http_config_helper_test.go +++ b/server/http_config_helper_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/globalconfig" "github.com/evcc-io/evcc/plugin/mqtt" "github.com/evcc-io/evcc/util/config" @@ -66,6 +67,24 @@ func TestInstanceParallelProbes(t *testing.T) { require.NotContains(t, res, "power", "blocking getter must be abandoned") } +// asleepVehicle returns api.ErrAsleep from its getters. +type asleepVehicle struct{} + +func (v asleepVehicle) Soc() (float64, error) { + return 0, api.ErrAsleep +} + +func (v asleepVehicle) Range() (int64, error) { + return 0, api.ErrAsleep +} + +// TestInstanceAsleep ensures asleep is flagged as state, not as error. +func TestInstanceAsleep(t *testing.T) { + res := testInstance(context.Background(), asleepVehicle{}) + assert.Equal(t, testResult{Value: 0.0, Asleep: true}, res["soc"]) + assert.Equal(t, testResult{Value: int64(0), Asleep: true}, res["range"]) +} + func TestConfigReqUnmarshal(t *testing.T) { var req configReq require.NoError(t, json.Unmarshal([]byte(`{ diff --git a/tests/vehicle-asleep.evcc.yaml b/tests/vehicle-asleep.evcc.yaml new file mode 100644 index 000000000..4590ef886 --- /dev/null +++ b/tests/vehicle-asleep.evcc.yaml @@ -0,0 +1,8 @@ +vehicles: + - name: sleepy + type: custom + title: Sleepy Car + capacity: 68 + soc: + source: error + error: ErrAsleep diff --git a/tests/vehicle-asleep.spec.ts b/tests/vehicle-asleep.spec.ts new file mode 100644 index 000000000..ef96b6d7d --- /dev/null +++ b/tests/vehicle-asleep.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; + +test.use({ baseURL: baseUrl() }); + +test.beforeEach(async () => { + await start("vehicle-asleep.evcc.yaml"); +}); + +test.afterEach(async () => { + await stop(); +}); + +test.describe("asleep vehicle", async () => { + test("config: values show sleeping state instead of errors", async ({ page }) => { + await page.goto("/#/config"); + + const vehicle = page.getByTestId("vehicle"); + await expect(vehicle).toHaveCount(1); + await expect(vehicle).toContainText("Sleepy Car"); + await expect(vehicle.getByTestId("device-tag-capacity")).toContainText("68.0 kWh"); + await expect(vehicle.getByTestId("device-tag-soc")).toContainText("sleeping"); + await expect(page.getByTestId("header")).toBeVisible(); + }); +});