From df57f1799486e6c51ea3c21e6fdd25b6ee6b84ed Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 14 Mar 2024 10:13:19 +0100 Subject: [PATCH] Add Retryable feature to indicate vehicles that are temporarily offline (#12841) --- api/feature.go | 1 + api/feature_enumer.go | 12 ++-- assets/css/app.css | 7 ++ assets/js/components/Loadpoint.vue | 5 ++ .../components/MaterialIcon/CloudOffline.vue | 17 +++++ assets/js/components/MaterialIcon/Sync.vue | 17 +++++ assets/js/components/Vehicle.vue | 1 + assets/js/components/VehicleTitle.vue | 66 +++++++++++++------ assets/js/mixins/icon.js | 22 +++++++ cmd/setup.go | 2 +- i18n/de.toml | 1 + i18n/en.toml | 1 + tests/vehicle-error.evcc.yaml | 54 +++++++++++++++ tests/vehicle-error.spec.js | 29 ++++++++ vehicle/wrapper.go | 21 ++++-- 15 files changed, 225 insertions(+), 31 deletions(-) create mode 100644 assets/js/components/MaterialIcon/CloudOffline.vue create mode 100644 assets/js/components/MaterialIcon/Sync.vue create mode 100644 assets/js/mixins/icon.js create mode 100755 tests/vehicle-error.evcc.yaml create mode 100644 tests/vehicle-error.spec.js diff --git a/api/feature.go b/api/feature.go index 1621b5d791..179ef0b579 100644 --- a/api/feature.go +++ b/api/feature.go @@ -17,4 +17,5 @@ const ( CoarseCurrent IntegratedDevice Heating + Retryable ) diff --git a/api/feature_enumer.go b/api/feature_enumer.go index faaa1c6030..8636adf68e 100644 --- a/api/feature_enumer.go +++ b/api/feature_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _FeatureName = "OfflineCoarseCurrentIntegratedDeviceHeating" +const _FeatureName = "OfflineCoarseCurrentIntegratedDeviceHeatingRetryable" -var _FeatureIndex = [...]uint8{0, 7, 20, 36, 43} +var _FeatureIndex = [...]uint8{0, 7, 20, 36, 43, 52} -const _FeatureLowerName = "offlinecoarsecurrentintegrateddeviceheating" +const _FeatureLowerName = "offlinecoarsecurrentintegrateddeviceheatingretryable" func (i Feature) String() string { i -= 1 @@ -29,9 +29,10 @@ func _FeatureNoOp() { _ = x[CoarseCurrent-(2)] _ = x[IntegratedDevice-(3)] _ = x[Heating-(4)] + _ = x[Retryable-(5)] } -var _FeatureValues = []Feature{Offline, CoarseCurrent, IntegratedDevice, Heating} +var _FeatureValues = []Feature{Offline, CoarseCurrent, IntegratedDevice, Heating, Retryable} var _FeatureNameToValueMap = map[string]Feature{ _FeatureName[0:7]: Offline, @@ -42,6 +43,8 @@ var _FeatureNameToValueMap = map[string]Feature{ _FeatureLowerName[20:36]: IntegratedDevice, _FeatureName[36:43]: Heating, _FeatureLowerName[36:43]: Heating, + _FeatureName[43:52]: Retryable, + _FeatureLowerName[43:52]: Retryable, } var _FeatureNames = []string{ @@ -49,6 +52,7 @@ var _FeatureNames = []string{ _FeatureName[7:20], _FeatureName[20:36], _FeatureName[36:43], + _FeatureName[43:52], } // FeatureString retrieves an enum value from the enum constants string name. diff --git a/assets/css/app.css b/assets/css/app.css index 1e923c6d40..05c212c288 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -508,3 +508,10 @@ html.app .modal-dialog { margin-top: env(safe-area-inset-top); margin-bottom: env(safe-area-inset-bottom); } + +.btn-neutral { + all: unset; +} +.btn-neutral:hover { + outline: revert; +} diff --git a/assets/js/components/Loadpoint.vue b/assets/js/components/Loadpoint.vue index 1a78584767..176d00f8a0 100644 --- a/assets/js/components/Loadpoint.vue +++ b/assets/js/components/Loadpoint.vue @@ -236,6 +236,11 @@ export default { vehicleHasSoc: function () { return this.vehicleKnown && !this.vehicle?.features?.includes("Offline"); }, + vehicleNotReachable: function () { + // online vehicle that was not reachable at startup + const features = this.vehicle?.features || []; + return features.includes("Offline") && features.includes("Retryable"); + }, socBasedCharging: function () { return this.vehicleHasSoc || this.vehicleSoc > 0; }, diff --git a/assets/js/components/MaterialIcon/CloudOffline.vue b/assets/js/components/MaterialIcon/CloudOffline.vue new file mode 100644 index 0000000000..75dbc8a68c --- /dev/null +++ b/assets/js/components/MaterialIcon/CloudOffline.vue @@ -0,0 +1,17 @@ + + + diff --git a/assets/js/components/MaterialIcon/Sync.vue b/assets/js/components/MaterialIcon/Sync.vue new file mode 100644 index 0000000000..7da9f2d1db --- /dev/null +++ b/assets/js/components/MaterialIcon/Sync.vue @@ -0,0 +1,17 @@ + + + diff --git a/assets/js/components/Vehicle.vue b/assets/js/components/Vehicle.vue index 7a2f3001de..31159efe7d 100644 --- a/assets/js/components/Vehicle.vue +++ b/assets/js/components/Vehicle.vue @@ -123,6 +123,7 @@ export default { vehicles: Array, vehicleSoc: Number, vehicleTargetSoc: Number, + vehicleNotReachable: Boolean, }, emits: ["limit-soc-updated", "limit-energy-updated", "change-vehicle", "remove-vehicle"], data() { diff --git a/assets/js/components/VehicleTitle.vue b/assets/js/components/VehicleTitle.vue index e413ede373..e31a099066 100644 --- a/assets/js/components/VehicleTitle.vue +++ b/assets/js/components/VehicleTitle.vue @@ -1,13 +1,15 @@ diff --git a/assets/js/mixins/icon.js b/assets/js/mixins/icon.js new file mode 100644 index 0000000000..8b997cab24 --- /dev/null +++ b/assets/js/mixins/icon.js @@ -0,0 +1,22 @@ +export default { + props: { + size: { + type: String, + validator: function (value) { + return ["s", "m", "l", "xl"].includes(value); + }, + }, + }, + computed: { + svgStyle() { + const sizes = { + s: "24px", + m: "32px", + l: "48px", + xl: "64px", + }; + const size = sizes[this.size] || sizes.s; + return { display: "block", width: size, height: size }; + }, + }, +}; diff --git a/cmd/setup.go b/cmd/setup.go index edc4752469..1447020049 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -295,7 +295,7 @@ func vehicleInstance(cc config.Named) (api.Vehicle, error) { // wrap non-config vehicle errors to prevent fatals log.ERROR.Printf("creating vehicle %s failed: %v", cc.Name, err) - instance = vehicle.NewWrapper(cc.Name, cc.Other, err) + instance = vehicle.NewWrapper(cc.Name, cc.Type, cc.Other, err) } // ensure vehicle config has title diff --git a/i18n/de.toml b/i18n/de.toml index ae46813769..5383391b01 100644 --- a/i18n/de.toml +++ b/i18n/de.toml @@ -332,6 +332,7 @@ detectionActive = "Fahrzeugerkennung läuft …" fallbackName = "Fahrzeug" moreActions = "Weitere Aktionen" none = "Kein Fahrzeug" +notReachable = "Fahrzeug war nicht erreichbar. Versuche evcc neu zu starten." targetSoc = "Ladelimit" temp = "Temperatur" tempLimit = "Zieltemp." diff --git a/i18n/en.toml b/i18n/en.toml index 64126f8c27..e67191163e 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -331,6 +331,7 @@ detectionActive = "Detecting vehicle…" fallbackName = "Vehicle" moreActions = "More actions" none = "No vehicle" +notReachable = "Vehicle was not reachable. Try restarting evcc." targetSoc = "Limit" temp = "Temp." tempLimit = "Temp. limit" diff --git a/tests/vehicle-error.evcc.yaml b/tests/vehicle-error.evcc.yaml new file mode 100755 index 0000000000..bcce474b8c --- /dev/null +++ b/tests/vehicle-error.evcc.yaml @@ -0,0 +1,54 @@ +interval: 0.1s + +site: + title: Vehicle Error + meters: + grid: grid + +meters: + - name: grid + type: custom + power: + source: js + script: | + 1000 + - name: charger_meter + type: custom + power: + source: js + script: | + 500 + +loadpoints: + - title: Carport + charger: charger + meter: charger_meter + vehicle: broken_tesla + mode: now + +chargers: + - name: charger + type: custom + enable: + source: js + script: + enabled: + source: js + script: | + true + status: + source: js + script: | + "C" + maxcurrent: + source: js + script: | + 16 + +vehicles: + - name: broken_tesla + type: template + template: tesla # not optimal, since real communication with tesla server is happening + title: Broken Tesla + accessToken: A + refreshToken: B diff --git a/tests/vehicle-error.spec.js b/tests/vehicle-error.spec.js new file mode 100644 index 0000000000..e329d3190c --- /dev/null +++ b/tests/vehicle-error.spec.js @@ -0,0 +1,29 @@ +const { test, expect } = require("@playwright/test"); +const { start, stop } = require("./evcc"); + +test.beforeAll(async () => { + await start("vehicle-error.evcc.yaml"); +}); +test.afterAll(async () => { + await stop(); +}); + +test.beforeEach(async ({ page }) => { + await page.goto("/"); +}); + +test.describe("vehicle startup error", async () => { + test("broken vehicle: normal title and 'not reachable' icon", async ({ page }) => { + await expect(page.getByTestId("vehicle-name")).toHaveText("Broken Tesla"); + await expect(page.getByTestId("vehicle-not-reachable-icon")).toBeVisible(); + }); + + test("guest vehicle: normal title and no icon", async ({ page }) => { + // switch to offline vehicle + await page.getByRole("button", { name: "Broken Tesla" }).click(); + await page.getByRole("button", { name: "Guest vehicle" }).click(); + + await expect(page.getByTestId("vehicle-name")).toHaveText("Guest vehicle"); + await expect(page.getByTestId("vehicle-not-reachable-icon")).not.toBeVisible(); + }); +}); diff --git a/vehicle/wrapper.go b/vehicle/wrapper.go index d92eb0a03f..6727185b26 100644 --- a/vehicle/wrapper.go +++ b/vehicle/wrapper.go @@ -11,11 +11,13 @@ import ( // Wrapper wraps an api.Vehicle to capture initialization errors type Wrapper struct { embed - err error + typ string + config map[string]interface{} + err error } // NewWrapper creates an offline Vehicle wrapper -func NewWrapper(name string, other map[string]interface{}, err error) api.Vehicle { +func NewWrapper(name string, typ string, other map[string]interface{}, err error) api.Vehicle { var cc struct { embed `mapstructure:",squash"` Other map[string]interface{} `mapstructure:",remain"` @@ -30,11 +32,13 @@ func NewWrapper(name string, other map[string]interface{}, err error) api.Vehicl } v := &Wrapper{ - embed: cc.embed, - err: fmt.Errorf("vehicle not available: %w", err), + embed: cc.embed, + typ: typ, + config: cc.Other, + err: fmt.Errorf("vehicle not available: %w", err), } - v.Features_ = append(v.Features_, api.Offline) + v.Features_ = append(v.Features_, api.Offline, api.Retryable) v.SetTitle(cc.Title_) return v @@ -45,11 +49,16 @@ func (v *Wrapper) Error() string { return v.err.Error() } +// Error returns the initialization error +func (v *Wrapper) Config() (string, map[string]interface{}) { + return v.typ, v.config +} + var _ api.Vehicle = (*Wrapper)(nil) // SetTitle implements the api.TitleSetter interface func (v *Wrapper) SetTitle(title string) { - v.Title_ = fmt.Sprintf("%s (unavailable)", title) + v.Title_ = title } var _ api.Battery = (*Wrapper)(nil)