Add Retryable feature to indicate vehicles that are temporarily offline (#12841)
This commit is contained in:
parent
b6bf3eaf4c
commit
df57f17994
15 changed files with 226 additions and 32 deletions
|
|
@ -17,4 +17,5 @@ const (
|
|||
CoarseCurrent
|
||||
IntegratedDevice
|
||||
Heating
|
||||
Retryable
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
17
assets/js/components/MaterialIcon/CloudOffline.vue
Normal file
17
assets/js/components/MaterialIcon/CloudOffline.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6.5 20q-2.3 0-3.9-1.6T1 14.5q0-1.925 1.188-3.425T5.25 9.15q.075-.2.15-.387t.15-.413L2.1 4.9q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l17 17q.275.275.288.688t-.288.712q-.275.275-.687.288t-.713-.263L17.15 20zm0-2h8.65L7.1 9.95q-.05.275-.075.525T7 11h-.5q-1.45 0-2.475 1.025T3 14.5q0 1.45 1.025 2.475T6.5 18m15.1.75l-1.45-1.4q.425-.35.638-.812T21 15.5q0-1.05-.725-1.775T18.5 13H17v-2q0-2.075-1.463-3.537T12 6q-.675 0-1.3.163t-1.2.512l-1.45-1.45q.875-.6 1.863-.912T12 4q2.925 0 4.963 2.038T19 11q1.725.2 2.863 1.488T23 15.5q0 .975-.375 1.813T21.6 18.75m-6.775-6.725"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import icon from "../../mixins/icon";
|
||||
|
||||
export default {
|
||||
name: "CloudOffline",
|
||||
mixins: [icon],
|
||||
};
|
||||
</script>
|
||||
17
assets/js/components/MaterialIcon/Sync.vue
Normal file
17
assets/js/components/MaterialIcon/Sync.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6 12.05q0 1.125.425 2.188T7.75 16.2l.25.25V15q0-.425.288-.712T9 14q.425 0 .713.288T10 15v4q0 .425-.288.713T9 20H5q-.425 0-.712-.288T4 19q0-.425.288-.712T5 18h1.75l-.4-.35q-1.3-1.15-1.825-2.625T4 12.05Q4 9.7 5.2 7.787T8.425 4.85q.35-.2.738-.025t.512.575q.125.375-.012.75t-.488.575q-1.45.8-2.312 2.213T6 12.05m12-.1q0-1.125-.425-2.187T16.25 7.8L16 7.55V9q0 .425-.288.713T15 10q-.425 0-.712-.288T14 9V5q0-.425.288-.712T15 4h4q.425 0 .713.288T20 5q0 .425-.288.713T19 6h-1.75l.4.35q1.225 1.225 1.788 2.663T20 11.95q0 2.35-1.2 4.263t-3.225 2.937q-.35.2-.737.025t-.513-.575q-.125-.375.013-.75t.487-.575q1.45-.8 2.313-2.212T18 11.95"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import icon from "../../mixins/icon";
|
||||
|
||||
export default {
|
||||
name: "Sync",
|
||||
mixins: [icon],
|
||||
};
|
||||
</script>
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
<template>
|
||||
<div class="d-flex justify-content-between mb-3 align-items-center" data-testid="vehicle-title">
|
||||
<h4 class="d-flex align-items-center m-0 flex-grow-1 overflow-hidden">
|
||||
<shopicon-regular-refresh
|
||||
<div
|
||||
v-if="iconType === 'refresh'"
|
||||
ref="refresh"
|
||||
data-bs-toggle="tooltip"
|
||||
:title="$t('main.vehicle.detectionActive')"
|
||||
class="me-2 flex-shrink-0 spin"
|
||||
></shopicon-regular-refresh>
|
||||
ref="refresh"
|
||||
:title="$t('main.vehicle.detectionActive')"
|
||||
data-bs-toggle="tooltip"
|
||||
>
|
||||
<Sync />
|
||||
</div>
|
||||
<VehicleIcon
|
||||
v-else-if="iconType === 'vehicle'"
|
||||
:name="icon"
|
||||
|
|
@ -26,38 +28,59 @@
|
|||
@change-vehicle="changeVehicle"
|
||||
@remove-vehicle="removeVehicle"
|
||||
>
|
||||
<span class="flex-grow-1 text-truncate vehicle-name">
|
||||
<span class="flex-grow-1 text-truncate vehicle-name" data-testid="vehicle-name">
|
||||
{{ name }}
|
||||
</span>
|
||||
</VehicleOptions>
|
||||
<span v-else class="flex-grow-1 text-truncate vehicle-name">
|
||||
<span v-else class="flex-grow-1 text-truncate vehicle-name" data-testid="vehicle-name">
|
||||
{{ name }}
|
||||
</span>
|
||||
<button
|
||||
v-if="vehicleNotReachable"
|
||||
class="ms-2 btn-neutral"
|
||||
ref="notReachable"
|
||||
data-bs-toggle="tooltip"
|
||||
:title="$t('main.vehicle.notReachable')"
|
||||
type="button"
|
||||
data-testid="vehicle-not-reachable-icon"
|
||||
@click="openHelpModal"
|
||||
>
|
||||
<CloudOffline class="evcc-gray" />
|
||||
</button>
|
||||
</h4>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/refresh";
|
||||
import "@h2d2/shopicons/es/regular/cablecharge";
|
||||
import Tooltip from "bootstrap/js/dist/tooltip";
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import VehicleIcon from "./VehicleIcon";
|
||||
import VehicleOptions from "./VehicleOptions.vue";
|
||||
import CloudOffline from "./MaterialIcon/CloudOffline.vue";
|
||||
import Sync from "./MaterialIcon/Sync.vue";
|
||||
import collector from "../mixins/collector";
|
||||
|
||||
export default {
|
||||
name: "VehicleTitle",
|
||||
components: { VehicleOptions, VehicleIcon },
|
||||
components: { VehicleOptions, VehicleIcon, Sync, CloudOffline },
|
||||
mixins: [collector],
|
||||
props: {
|
||||
connected: Boolean,
|
||||
id: [String, Number],
|
||||
vehicleDetectionActive: Boolean,
|
||||
vehicleNotReachable: Boolean,
|
||||
icon: String,
|
||||
vehicleName: String,
|
||||
vehicles: { type: Array, default: () => [] },
|
||||
title: String,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
refreshTooltip: null,
|
||||
notReachableTooltip: null,
|
||||
};
|
||||
},
|
||||
emits: ["change-vehicle", "remove-vehicle"],
|
||||
computed: {
|
||||
iconType() {
|
||||
|
|
@ -93,11 +116,11 @@ export default {
|
|||
},
|
||||
watch: {
|
||||
iconType: function () {
|
||||
this.tooltip();
|
||||
this.initTooltip();
|
||||
},
|
||||
},
|
||||
mounted: function () {
|
||||
this.tooltip();
|
||||
this.initTooltip();
|
||||
},
|
||||
methods: {
|
||||
changeVehicle(name) {
|
||||
|
|
@ -106,21 +129,28 @@ export default {
|
|||
removeVehicle() {
|
||||
this.$emit("remove-vehicle");
|
||||
},
|
||||
tooltip() {
|
||||
initTooltip() {
|
||||
this.$nextTick(() => {
|
||||
this.refreshTooltip?.dispose();
|
||||
this.notReachableTooltip?.dispose();
|
||||
if (this.$refs.refresh) {
|
||||
new Tooltip(this.$refs.refresh);
|
||||
this.refreshTooltip = new Tooltip(this.$refs.refresh);
|
||||
}
|
||||
if (this.$refs.notReachable) {
|
||||
this.notReachableTooltip = new Tooltip(this.$refs.notReachable);
|
||||
}
|
||||
});
|
||||
},
|
||||
openHelpModal() {
|
||||
const modal = Modal.getOrCreateInstance(document.getElementById("helpModal"));
|
||||
modal.show();
|
||||
this.initTooltip();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.options {
|
||||
margin-right: -0.75rem;
|
||||
}
|
||||
.vehicle-name {
|
||||
text-decoration-color: var(--evcc-gray);
|
||||
}
|
||||
|
|
@ -130,16 +160,12 @@ export default {
|
|||
.spin {
|
||||
animation: rotation 1s infinite cubic-bezier(0.37, 0, 0.63, 1);
|
||||
}
|
||||
.spin :deep(svg) {
|
||||
/* workaround to fix the not perfectly centered shopicon. Remove once its fixed in @h2d2/shopicons */
|
||||
transform: translateY(-0.7px);
|
||||
}
|
||||
@keyframes rotation {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
22
assets/js/mixins/icon.js
Normal file
22
assets/js/mixins/icon.js
Normal file
|
|
@ -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 };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
54
tests/vehicle-error.evcc.yaml
Executable file
54
tests/vehicle-error.evcc.yaml
Executable file
|
|
@ -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
|
||||
29
tests/vehicle-error.spec.js
Normal file
29
tests/vehicle-error.spec.js
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue