chore: Typescript: Rewrite Vehicles (#21636)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Maschga 2025-06-06 14:57:00 +02:00 • committed by GitHub
parent e3fd0817f5
commit 040e05b1eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 740 additions and 635 deletions

View file

@ -202,6 +202,8 @@ jobs:
- name: Run tests
run: npx playwright test
env:
TZ: Europe/Berlin
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}

View file

@ -6,6 +6,7 @@
tabindex="-1"
role="dialog"
aria-hidden="true"
data-testid="session-details"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div v-if="session" class="modal-content">
@ -21,7 +22,7 @@
<div class="modal-body">
<table class="table align-middle">
<tbody>
<tr>
<tr data-testid="session-details-loadpoint">
<th>
{{ $t("sessions.loadpoint") }}
</th>
@ -43,7 +44,7 @@
</CustomSelect>
</td>
</tr>
<tr>
<tr data-testid="session-details-vehicle">
<th>
{{ $t("sessions.vehicle") }}
</th>
@ -51,7 +52,7 @@
<VehicleOptions
:id="session.vehicle"
class="options"
:vehicles="vehicleOptions"
:vehicleOptions="vehicleOptions"
connected
:selected="session.vehicle"
@change-vehicle="changeVehicle"
@ -67,17 +68,21 @@
</VehicleOptions>
</td>
</tr>
<tr>
<tr data-testid="session-details-date">
<th class="align-baseline">
{{ $t("session.date") }}
</th>
<td>
{{ fmtFullDateTime(session.created, false) }}
<template v-if="session.created">
{{ fmtFullDateTime(new Date(session.created), false) }}
</template>
<br />
{{ fmtFullDateTime(session.finished, false) }}
<template v-if="session.finished">
{{ fmtFullDateTime(new Date(session.finished), false) }}
</template>
</td>
</tr>
<tr>
<tr data-testid="session-details-energy">
<th class="align-baseline">
{{ $t("sessions.energy") }}
</th>
@ -96,7 +101,10 @@
</div>
</td>
</tr>
<tr v-if="session.solarPercentage != null">
<tr
v-if="session.solarPercentage != null"
data-testid="session-details-solar"
>
<th class="align-baseline">
{{ $t("sessions.solar") }}
</th>
@ -105,7 +113,10 @@
({{ fmtWh(solarEnergy, POWER_UNIT.AUTO) }})
</td>
</tr>
<tr v-if="session.price != null">
<tr
v-if="session.price != null"
data-testid="session-details-price"
>
<th class="align-baseline">
{{ $t("session.price") }}
</th>
@ -115,7 +126,10 @@
{{ fmtPricePerKWh(session.pricePerKWh || 0, currency) }}
</td>
</tr>
<tr v-if="session.co2PerKWh != null">
<tr
v-if="session.co2PerKWh != null"
data-testid="session-details-co2"
>
<th>
{{ $t("session.co2") }}
</th>
@ -123,7 +137,7 @@
{{ fmtCo2Medium(session.co2PerKWh) }}
</td>
</tr>
<tr v-if="session.odometer">
<tr v-if="session.odometer" data-testid="session-details-odometer">
<th>
{{ $t("session.odometer") }}
</th>
@ -131,7 +145,7 @@
{{ formatKm(session.odometer) }}
</td>
</tr>
<tr v-if="session.meterStart">
<tr v-if="session.meterStart" data-testid="session-details-meter">
<th class="align-baseline">
{{ $t("session.meter") }}
</th>
@ -148,6 +162,7 @@
type="button"
class="btn btn-link text-danger"
data-bs-dismiss="modal"
data-testid="session-details-delete"
@click="openRemoveConfirmationModal"
>
{{ $t("session.delete") }}
@ -228,10 +243,10 @@ export default defineComponent({
solarEnergy() {
return this.chargedEnergy * (this.session.solarPercentage / 100);
},
vehicleOptions() {
vehicleOptions(): SelectOption<string>[] {
return this.vehicles.map((v) => ({
name: v.title,
title: v.title,
value: v.title,
}));
},
loadpointOptions(): SelectOption<string>[] {

View file

@ -1,7 +1,7 @@
export interface Session {
id: number;
created: Date;
finished: Date;
created: string;
finished: string;
loadpoint: string;
identifier: string;
vehicle: string;

View file

@ -33,13 +33,14 @@
</LabelAndValue>
</template>
<script>
<script lang="ts">
import LabelAndValue from "../Helper/LabelAndValue.vue";
import AnimatedNumber from "../Helper/AnimatedNumber.vue";
import formatter from "@/mixins/formatter";
import { estimatedSoc, energyOptions, optionStep, fmtEnergy } from "@/utils/energyOptions";
import { defineComponent } from "vue";
export default {
export default defineComponent({
name: "LimitEnergySelect",
components: { LabelAndValue, AnimatedNumber },
mixins: [formatter],
@ -69,17 +70,20 @@ export default {
},
},
methods: {
change(e) {
return this.$emit("limit-energy-updated", parseFloat(e.target.value));
change(e: Event) {
return this.$emit(
"limit-energy-updated",
parseFloat((e.target as HTMLSelectElement).value)
);
},
fmtEnergy(value) {
fmtEnergy(value: number) {
return fmtEnergy(value, this.step, this.fmtWh, this.$t("main.targetEnergy.noLimit"));
},
fmtSoc(value) {
fmtSoc(value: number) {
return `+${this.fmtPercentage(value)}`;
},
},
};
});
</script>
<style scoped>

View file

@ -19,18 +19,19 @@
</LabelAndValue>
</template>
<script>
<script lang="ts">
import { defineComponent } from "vue";
import LabelAndValue from "../Helper/LabelAndValue.vue";
import AnimatedNumber from "../Helper/AnimatedNumber.vue";
import { distanceUnit } from "@/units";
import formatter from "@/mixins/formatter";
export default {
export default defineComponent({
name: "LimitSocSelect",
components: { LabelAndValue, AnimatedNumber },
mixins: [formatter],
props: {
limitSoc: Number,
limitSoc: { type: Number, default: 0 },
rangePerSoc: Number,
heating: Boolean,
},
@ -57,23 +58,26 @@ export default {
},
},
methods: {
change(e) {
return this.$emit("limit-soc-updated", parseInt(e.target.value, 10));
change(e: Event) {
return this.$emit(
"limit-soc-updated",
parseInt((e.target as HTMLSelectElement).value, 10)
);
},
estimatedRange(soc) {
estimatedRange(soc: number) {
if (this.rangePerSoc) {
return Math.round(soc * this.rangePerSoc);
}
return null;
},
formatSoc(value) {
formatSoc(value: number) {
return this.heating ? this.fmtTemperature(value) : this.fmtPercentage(value);
},
formatKm(value) {
formatKm(value: number) {
return `${this.fmtNumber(value, 0)} ${distanceUnit()}`;
},
},
};
});
</script>
<style scoped>

View file

@ -7,12 +7,12 @@
>
<select :id="dropdownId" :value="selected" class="custom-select" @change="change">
<option
v-for="{ name, title } in vehicles"
v-for="{ name, value } in vehicleOptions"
:key="name"
:value="name"
:selected="name === selected"
>
{{ title }}
{{ value }}
</option>
<hr />
<option value="" :selected="!selected">
@ -23,13 +23,16 @@
</label>
</template>
<script>
export default {
<script lang="ts">
import type { SelectOption } from "@/types/evcc";
import { defineComponent, type PropType } from "vue";
export default defineComponent({
name: "VehicleOptions",
props: {
connected: Boolean,
id: [String, Number],
vehicles: Array,
vehicleOptions: Array as PropType<SelectOption<string>[]>,
selected: String,
},
emits: ["change-vehicle", "remove-vehicle"],
@ -39,8 +42,8 @@ export default {
},
},
methods: {
change(event) {
const name = event.target.value;
change(event: Event) {
const name = (event.target as HTMLSelectElement).value;
if (name) {
this.$emit("change-vehicle", name);
} else {
@ -48,7 +51,7 @@ export default {
}
},
},
};
});
</script>
<style scoped>
.custom-select {

View file

@ -13,7 +13,7 @@
:style="{ width: `${vehicleSocDisplayWidth}%`, ...transition }"
></div>
<div
v-if="remainingSocWidth > 0 && enabled && connected"
v-if="remainingSocWidth !== null && remainingSocWidth > 0 && enabled && connected"
class="progress-bar bg-muted"
role="progressbar"
:class="progressColor"
@ -34,7 +34,8 @@
data-bs-toggle="tooltip"
:class="{
'energy-limit-marker--active': energyLimitMarkerActive,
'energy-limit-marker--visible': energyLimitMarkerPosition < 100,
'energy-limit-marker--visible':
energyLimitMarkerPosition !== null && energyLimitMarkerPosition < 100,
}"
:style="{ left: `${energyLimitMarkerPosition}%` }"
/>
@ -69,36 +70,37 @@
</div>
</template>
<script>
<script lang="ts">
import Tooltip from "bootstrap/js/dist/tooltip";
import "@h2d2/shopicons/es/regular/clock";
import formatter from "@/mixins/formatter";
import { defineComponent } from "vue";
export default {
export default defineComponent({
name: "VehicleSoc",
mixins: [formatter],
props: {
connected: Boolean,
vehicleSoc: Number,
vehicleLimitSoc: Number,
vehicleSoc: { type: Number, default: 0 },
vehicleLimitSoc: { type: Number, default: 0 },
enabled: Boolean,
charging: Boolean,
heating: Boolean,
minSoc: Number,
effectivePlanSoc: Number,
minSoc: { type: Number, default: 0 },
effectivePlanSoc: { type: Number, default: 0 },
effectiveLimitSoc: Number,
limitEnergy: Number,
planEnergy: Number,
chargedEnergy: Number,
limitEnergy: { type: Number, default: 0 },
planEnergy: { type: Number, default: 0 },
chargedEnergy: { type: Number, default: 0 },
socBasedCharging: Boolean,
socBasedPlanning: Boolean,
},
emits: ["limit-soc-drag", "limit-soc-updated", "plan-clicked"],
data() {
return {
selectedLimitSoc: null,
selectedLimitSoc: undefined as number | undefined,
interactionStartScreenY: null,
tooltip: null,
tooltip: null as Tooltip | null,
dragging: false,
};
},
@ -131,7 +133,7 @@ export default {
vehicleLimitSocActive() {
return this.vehicleLimitSoc > 0 && this.vehicleLimitSoc > this.vehicleSoc;
},
planMarkerPosition() {
planMarkerPosition(): number {
if (this.socBasedPlanning) {
return this.effectivePlanSoc;
}
@ -217,24 +219,24 @@ export default {
this.updateTooltip();
},
methods: {
changeLimitSocStart(e) {
changeLimitSocStart(e: Event) {
this.dragging = true;
e.stopPropagation();
},
changeLimitSocEnd(e) {
changeLimitSocEnd(e: Event) {
this.dragging = false;
const value = parseInt(e.target.value, 10);
const value = parseInt((e.target as HTMLInputElement).value, 10);
// value changed
if (value !== this.effectiveLimitSoc) {
this.$emit("limit-soc-updated", value);
}
},
movedLimitSoc(e) {
const value = parseInt(e.target.value, 10);
movedLimitSoc(e: Event) {
const value = parseInt((e.target as HTMLInputElement).value, 10);
e.stopPropagation();
const minLimit = 20;
if (value < minLimit) {
e.target.value = minLimit;
(e.target as HTMLInputElement).value = minLimit.toString();
this.selectedLimitSoc = value;
e.preventDefault();
return false;
@ -246,7 +248,7 @@ export default {
},
updateTooltip() {
if (!this.tooltip) {
this.tooltip = new Tooltip(this.$refs.vehicleLimitSoc);
this.tooltip = new Tooltip(this.$refs["vehicleLimitSoc"] as HTMLElement);
}
const value = this.heating
? this.fmtTemperature(this.vehicleLimitSoc)
@ -256,7 +258,7 @@ export default {
this.tooltip.setContent({ ".tooltip-inner": content });
},
},
};
});
</script>
<style scoped>
.vehicle-soc {

View file

@ -1,7 +1,8 @@
<script setup>
<script setup lang="ts">
import Status from "./Status.vue";
import { CURRENCY } from "@/types/evcc";
function getFutureTime(hours, minutes) {
function getFutureTime(hours: number, minutes: number) {
const now = new Date();
now.setHours(now.getHours() + hours);
now.setMinutes(now.getMinutes() + minutes);
@ -135,7 +136,7 @@ const planProjectedEnd = getFutureTime(5, 43);
connected
enabled
charging
currency="CHF"
:currency="CURRENCY.EUR"
:tariffGrid="0.32"
:smartCostLimit="0.12"
smartCostType="price"
@ -143,7 +144,7 @@ const planProjectedEnd = getFutureTime(5, 43);
</Variant>
<Variant title="smart cost: cheap but not connected">
<Status
currency="EUR"
:currency="CURRENCY.EUR"
:tariffGrid="0.091"
:smartCostLimit="0.12"
smartCostType="price"
@ -154,7 +155,7 @@ const planProjectedEnd = getFutureTime(5, 43);
connected
enabled
charging
currency="CHF"
:currency="CURRENCY.EUR"
:smartCostLimit="0.12"
smartCostType="price"
:smartCostNextStart="planProjectedStart"
@ -165,7 +166,7 @@ const planProjectedEnd = getFutureTime(5, 43);
connected
enabled
charging
currency="CHF"
:currency="CURRENCY.EUR"
:tariffGrid="0.11"
:smartCostLimit="0.12"
smartCostType="price"
@ -177,7 +178,7 @@ const planProjectedEnd = getFutureTime(5, 43);
connected
enabled
charging
currency="EUR"
:currency="CURRENCY.EUR"
:smartCostLimit="0.15"
smartCostType="price"
:minSoc="20"

View file

@ -1,294 +0,0 @@
import { mount, config } from "@vue/test-utils";
import { describe, expect, test } from "vitest";
import Status from "./Status.vue";
const serializeData = (data) => (data ? `:${JSON.stringify(data)}` : "");
config.global.mocks["$t"] = (key, data) => `${key}${serializeData(data)}`;
config.global.mocks["$i18n"] = { locale: "de-DE" };
const allEntries = {
pvtimer: false,
phasetimer: false,
solar: false,
climater: false,
minsoc: false,
limit: false,
smartcost: false,
planactive: false,
planstart: false,
};
const expectEntries = (props, entries) => {
const expectedEntries = { ...allEntries, ...entries };
const wrapper = mount(Status, { props });
Object.entries(expectedEntries).forEach(([key, value]) => {
const selector = `[data-testid=vehicle-status-${key}]`;
if (typeof value === "boolean") {
expect(wrapper.find(selector).exists(), selector).eq(value);
} else {
expect(wrapper.find(selector).exists(), selector).eq(true);
expect(wrapper.find(selector).text(), selector).eq(value);
}
});
};
describe("basics", () => {
test("no vehicle is connected", () => {
expectEntries({ connected: false }, { charger: "main.vehicleStatus.disconnected" });
});
test("vehicle is connected", () => {
expectEntries({ connected: true }, { charger: "main.vehicleStatus.connected" });
});
test("show waiting for vehicle if charger is enabled but not charging", () => {
expectEntries(
{ enabled: true, connected: true },
{ charger: "main.vehicleStatus.waitForVehicle" }
);
});
test("vehicle is charging", () => {
expectEntries({ connected: true, charging: true }, { charger: "main.vehicleStatus.charging" });
});
});
describe("min charge", () => {
test("active when vehicle soc is below", () => {
expectEntries(
{ connected: true, minSoc: 20, vehicleSoc: 10 },
{ charger: "main.vehicleStatus.connected", minsoc: "20 %" }
);
});
test("not active when vehicle soc is above", () => {
expectEntries(
{ connected: true, minSoc: 20, vehicleSoc: 21 },
{ charger: "main.vehicleStatus.connected", minsoc: false }
);
});
test("not active when vehicle soc is equal", () => {
expectEntries(
{ connected: true, minSoc: 20, vehicleSoc: 20 },
{ charger: "main.vehicleStatus.connected", minsoc: false }
);
});
test("not active when limit is 0", () => {
expectEntries(
{ connected: true, minSoc: 0, vehicleSoc: 10 },
{ charger: "main.vehicleStatus.connected", minsoc: false }
);
});
});
describe("plan", () => {
const effectivePlanTime = "2020-03-16T06:00:00Z";
const planProjectedStart = "2020-03-16T02:00:00Z";
const planProjectedEnd = "2020-03-16T05:00:00Z";
test("charging if target time is set, status is charging but planned slot is not active", () => {
expectEntries(
{ effectivePlanTime, charging: true, connected: true },
{ charger: "main.vehicleStatus.charging" }
);
});
test("active if target time is set, status is charging and planned slot is active", () => {
expectEntries(
{ planProjectedEnd, planActive: true, charging: true, connected: true },
{ charger: "main.vehicleStatus.charging", planactive: "Mo 06:00" }
);
});
test("waiting for vehicle if a target time is set, the charger is enabled but not charging", () => {
expectEntries(
{ planProjectedEnd, planActive: true, enabled: true, connected: true },
{ charger: "main.vehicleStatus.waitForVehicle", planactive: "Mo 06:00" }
);
});
test("show projected start if not enabled yet", () => {
expectEntries(
{ effectivePlanTime, planProjectedStart, connected: true },
{ charger: "main.vehicleStatus.connected", planstart: "Mo 03:00" }
);
});
test("dont show plan status if plan is disabled (e.g. off, fast mode)", () => {
expectEntries(
{
effectivePlanTime,
planActive: true,
charging: true,
connected: true,
chargingPlanDisabled: true,
},
{ charger: "main.vehicleStatus.charging" }
);
expectEntries(
{
effectivePlanTime,
planActive: true,
charging: false,
enabled: true,
connected: true,
chargingPlanDisabled: true,
},
{ charger: "main.vehicleStatus.waitForVehicle" }
);
expectEntries(
{ effectivePlanTime, planProjectedStart, connected: true, chargingPlanDisabled: true },
{ charger: "main.vehicleStatus.connected" }
);
});
});
describe("climating", () => {
test("show climating status", () => {
expectEntries(
{ connected: true, enabled: true, vehicleClimaterActive: true, charging: true },
{ charger: "main.vehicleStatus.charging", climater: true }
);
expectEntries(
{ connected: true, enabled: true, vehicleClimaterActive: true, charging: false },
{ charger: "main.vehicleStatus.waitForVehicle", climater: true }
);
});
test("only show climating if enabled", () => {
expectEntries(
{ connected: true, enabled: false, vehicleClimaterActive: true, charging: false },
{ charger: "main.vehicleStatus.connected", climater: true }
);
});
});
describe("timer", () => {
test("show pv enable timer if not enabled yet and timer exists", () => {
expectEntries(
{
pvAction: "enable",
connected: true,
pvRemainingInterpolated: 90,
},
{ charger: "main.vehicleStatus.connected", pvtimer: "1:30\u202Fm" }
);
});
test("don't show pv enable timer if value is zero", () => {
expectEntries(
{
pvAction: "enable",
connected: true,
pvRemainingInterpolated: 0,
},
{ charger: "main.vehicleStatus.connected" }
);
});
test("show pv disable timer if charging and timer exists", () => {
expectEntries(
{
pvAction: "disable",
connected: true,
charging: true,
pvRemainingInterpolated: 90,
},
{ charger: "main.vehicleStatus.charging", pvtimer: "1:30\u202Fm" }
);
});
test("show phase enable timer if it exists", () => {
expectEntries(
{
phaseAction: "scale1p",
connected: true,
charging: true,
phaseRemainingInterpolated: 91,
},
{ charger: "main.vehicleStatus.charging", phasetimer: "1:31\u202Fm" }
);
});
test("show phase disable timer if it exists", () => {
expectEntries(
{
phaseAction: "scale3p",
connected: true,
charging: true,
phaseRemainingInterpolated: 91,
},
{ charger: "main.vehicleStatus.charging", phasetimer: "1:31\u202Fm" }
);
});
});
describe("vehicle target soc", () => {
test("show target reached if charger enabled but soc has reached vehicle limit", () => {
expectEntries(
{
connected: true,
enabled: true,
vehicleLimitSoc: 70,
vehicleSoc: 70,
},
{ charger: "main.vehicleStatus.finished", limit: "70 %" }
);
});
test("show reached message even if vehicle is slightly below its limit", () => {
expectEntries(
{
connected: true,
enabled: true,
vehicleLimitSoc: 70,
vehicleSoc: 69,
},
{ charger: "main.vehicleStatus.finished", limit: "70 %" }
);
});
});
describe("smart grid charging", () => {
test("show clean energy message", () => {
expectEntries(
{
connected: true,
enabled: true,
charging: true,
tariffCo2: 600,
smartCostLimit: 500,
smartCostType: "co2",
},
{ charger: "main.vehicleStatus.charging", smartcost: "≤ 500 g" }
);
});
test("show clean energy message if active", () => {
expectEntries(
{
connected: true,
enabled: true,
charging: true,
tariffCo2: 400,
smartCostLimit: 500,
smartCostType: "co2",
smartCostActive: true,
},
{ charger: "main.vehicleStatus.charging", smartcost: "400 g ≤ 500 g" }
);
});
test("show cheap energy message", () => {
expectEntries(
{
connected: true,
enabled: true,
charging: true,
tariffGrid: 0.3,
smartCostLimit: 0.29,
currency: "CHF",
},
{ charger: "main.vehicleStatus.charging", smartcost: "≤ 29,0 rp" }
);
});
test("show cheap energy message if active", () => {
expectEntries(
{
connected: true,
enabled: true,
charging: true,
tariffGrid: 0.28,
smartCostLimit: 0.29,
currency: "CHF",
smartCostActive: true,
},
{ charger: "main.vehicleStatus.charging", smartcost: "28,0 rp ≤ 29,0 rp" }
);
});
});

View file

@ -0,0 +1,298 @@
import { mount, config } from "@vue/test-utils";
import { describe, expect, test } from "vitest";
import Status from "./Status.vue";
import { CURRENCY } from "@/types/evcc";
const serializeData = (data: Object) => (data ? `:${JSON.stringify(data)}` : "");
config.global.mocks["$t"] = (key: string, data: Object) => `${key}${serializeData(data)}`;
config.global.mocks["$i18n"] = { locale: "de-DE" };
const allEntries = {
pvtimer: false,
phasetimer: false,
solar: false,
climater: false,
minsoc: false,
limit: false,
smartcost: false,
planactive: false,
planstart: false,
};
const expectEntries = (props: InstanceType<typeof Status>["$props"], entries: Object) => {
const expectedEntries = { ...allEntries, ...entries };
const wrapper = mount(Status, { props });
Object.entries(expectedEntries).forEach(([key, value]) => {
const selector = `[data-testid=vehicle-status-${key}]`;
if (typeof value === "boolean") {
expect(wrapper.find(selector).exists(), selector).eq(value);
} else {
expect(wrapper.find(selector).exists(), selector).eq(true);
expect(wrapper.find(selector).text(), selector).eq(value);
}
});
};
describe("basics", () => {
test("no vehicle is connected", () => {
expectEntries({ connected: false }, { charger: "main.vehicleStatus.disconnected" });
});
test("vehicle is connected", () => {
expectEntries({ connected: true }, { charger: "main.vehicleStatus.connected" });
});
test("show waiting for vehicle if charger is enabled but not charging", () => {
expectEntries(
{ enabled: true, connected: true },
{ charger: "main.vehicleStatus.waitForVehicle" }
);
});
test("vehicle is charging", () => {
expectEntries(
{ connected: true, charging: true },
{ charger: "main.vehicleStatus.charging" }
);
});
});
describe("min charge", () => {
test("active when vehicle soc is below", () => {
expectEntries(
{ connected: true, minSoc: 20, vehicleSoc: 10 },
{ charger: "main.vehicleStatus.connected", minsoc: "20 %" }
);
});
test("not active when vehicle soc is above", () => {
expectEntries(
{ connected: true, minSoc: 20, vehicleSoc: 21 },
{ charger: "main.vehicleStatus.connected", minsoc: false }
);
});
test("not active when vehicle soc is equal", () => {
expectEntries(
{ connected: true, minSoc: 20, vehicleSoc: 20 },
{ charger: "main.vehicleStatus.connected", minsoc: false }
);
});
test("not active when limit is 0", () => {
expectEntries(
{ connected: true, minSoc: 0, vehicleSoc: 10 },
{ charger: "main.vehicleStatus.connected", minsoc: false }
);
});
});
describe("plan", () => {
const effectivePlanTime = "2020-03-16T06:00:00Z";
const planProjectedStart = "2020-03-16T02:00:00Z";
const planProjectedEnd = "2020-03-16T05:00:00Z";
test("charging if target time is set, status is charging but planned slot is not active", () => {
expectEntries(
{ effectivePlanTime, charging: true, connected: true },
{ charger: "main.vehicleStatus.charging" }
);
});
test("active if target time is set, status is charging and planned slot is active", () => {
expectEntries(
{ planProjectedEnd, planActive: true, charging: true, connected: true },
{ charger: "main.vehicleStatus.charging", planactive: "Mo 06:00" }
);
});
test("waiting for vehicle if a target time is set, the charger is enabled but not charging", () => {
expectEntries(
{ planProjectedEnd, planActive: true, enabled: true, connected: true },
{ charger: "main.vehicleStatus.waitForVehicle", planactive: "Mo 06:00" }
);
});
test("show projected start if not enabled yet", () => {
expectEntries(
{ effectivePlanTime, planProjectedStart, connected: true },
{ charger: "main.vehicleStatus.connected", planstart: "Mo 03:00" }
);
});
test("dont show plan status if plan is disabled (e.g. off, fast mode)", () => {
expectEntries(
{
effectivePlanTime,
planActive: true,
charging: true,
connected: true,
chargingPlanDisabled: true,
},
{ charger: "main.vehicleStatus.charging" }
);
expectEntries(
{
effectivePlanTime,
planActive: true,
charging: false,
enabled: true,
connected: true,
chargingPlanDisabled: true,
},
{ charger: "main.vehicleStatus.waitForVehicle" }
);
expectEntries(
{ effectivePlanTime, planProjectedStart, connected: true, chargingPlanDisabled: true },
{ charger: "main.vehicleStatus.connected" }
);
});
});
describe("climating", () => {
test("show climating status", () => {
expectEntries(
{ connected: true, enabled: true, vehicleClimaterActive: true, charging: true },
{ charger: "main.vehicleStatus.charging", climater: true }
);
expectEntries(
{ connected: true, enabled: true, vehicleClimaterActive: true, charging: false },
{ charger: "main.vehicleStatus.waitForVehicle", climater: true }
);
});
test("only show climating if enabled", () => {
expectEntries(
{ connected: true, enabled: false, vehicleClimaterActive: true, charging: false },
{ charger: "main.vehicleStatus.connected", climater: true }
);
});
});
describe("timer", () => {
test("show pv enable timer if not enabled yet and timer exists", () => {
expectEntries(
{
pvAction: "enable",
connected: true,
pvRemainingInterpolated: 90,
},
{ charger: "main.vehicleStatus.connected", pvtimer: "1:30\u202Fm" }
);
});
test("don't show pv enable timer if value is zero", () => {
expectEntries(
{
pvAction: "enable",
connected: true,
pvRemainingInterpolated: 0,
},
{ charger: "main.vehicleStatus.connected" }
);
});
test("show pv disable timer if charging and timer exists", () => {
expectEntries(
{
pvAction: "disable",
connected: true,
charging: true,
pvRemainingInterpolated: 90,
},
{ charger: "main.vehicleStatus.charging", pvtimer: "1:30\u202Fm" }
);
});
test("show phase enable timer if it exists", () => {
expectEntries(
{
phaseAction: "scale1p",
connected: true,
charging: true,
phaseRemainingInterpolated: 91,
},
{ charger: "main.vehicleStatus.charging", phasetimer: "1:31\u202Fm" }
);
});
test("show phase disable timer if it exists", () => {
expectEntries(
{
phaseAction: "scale3p",
connected: true,
charging: true,
phaseRemainingInterpolated: 91,
},
{ charger: "main.vehicleStatus.charging", phasetimer: "1:31\u202Fm" }
);
});
});
describe("vehicle target soc", () => {
test("show target reached if charger enabled but soc has reached vehicle limit", () => {
expectEntries(
{
connected: true,
enabled: true,
vehicleLimitSoc: 70,
vehicleSoc: 70,
},
{ charger: "main.vehicleStatus.finished", limit: "70 %" }
);
});
test("show reached message even if vehicle is slightly below its limit", () => {
expectEntries(
{
connected: true,
enabled: true,
vehicleLimitSoc: 70,
vehicleSoc: 69,
},
{ charger: "main.vehicleStatus.finished", limit: "70 %" }
);
});
});
describe("smart grid charging", () => {
test("show clean energy message", () => {
expectEntries(
{
connected: true,
enabled: true,
charging: true,
tariffCo2: 600,
smartCostLimit: 500,
smartCostType: "co2",
},
{ charger: "main.vehicleStatus.charging", smartcost: "≤ 500 g" }
);
});
test("show clean energy message if active", () => {
expectEntries(
{
connected: true,
enabled: true,
charging: true,
tariffCo2: 400,
smartCostLimit: 500,
smartCostType: "co2",
smartCostActive: true,
},
{ charger: "main.vehicleStatus.charging", smartcost: "400 g ≤ 500 g" }
);
});
test("show cheap energy message", () => {
expectEntries(
{
connected: true,
enabled: true,
charging: true,
tariffGrid: 0.3,
smartCostLimit: 0.29,
currency: CURRENCY.EUR,
},
{ charger: "main.vehicleStatus.charging", smartcost: "≤ 29,0 ct" }
);
});
test("show cheap energy message if active", () => {
expectEntries(
{
connected: true,
enabled: true,
charging: true,
tariffGrid: 0.28,
smartCostLimit: 0.29,
currency: CURRENCY.EUR,
smartCostActive: true,
},
{ charger: "main.vehicleStatus.charging", smartcost: "28,0 ct ≤ 29,0 ct" }
);
});
});

View file

@ -66,7 +66,7 @@
:class="vehicleLimitClass"
data-bs-toggle="tooltip"
data-testid="vehicle-status-limit"
:role="vehicleLimitWarning ? 'button' : null"
:role="vehicleLimitWarning ? 'button' : undefined"
@click="vehicleLimitClicked"
>
<component :is="vehicleLimitIconComponent" />
@ -158,7 +158,7 @@
>
<PlanEndIcon />
<span>
{{ fmtAbsoluteDate(new Date(planProjectedEnd)) }}
{{ planProjectedEnd ? fmtAbsoluteDate(new Date(planProjectedEnd)) : "" }}
</span>
</button>
<button
@ -171,13 +171,13 @@
@click="planStartClicked"
>
<PlanStartIcon />
{{ fmtAbsoluteDate(new Date(planProjectedStart)) }}
{{ planProjectedStart ? fmtAbsoluteDate(new Date(planProjectedStart)) : "" }}
</button>
</div>
</div>
</template>
<script>
<script lang="ts">
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/eco1";
import "@h2d2/shopicons/es/regular/angledoublerightsmall";
@ -201,10 +201,12 @@ import VehicleLimitWarningIcon from "../MaterialIcon/VehicleLimitWarning.vue";
import VehicleMinSocIcon from "../MaterialIcon/VehicleMinSoc.vue";
import WelcomeIcon from "../MaterialIcon/Welcome.vue";
import BatteryBoostIcon from "../MaterialIcon/BatteryBoost.vue";
import { defineComponent, type PropType } from "vue";
import type { CURRENCY, Timeout } from "@/types/evcc";
const REASON_AUTH = "waitingforauthorization";
const REASON_DISCONNECT = "disconnectrequired";
export default {
export default defineComponent({
name: "VehicleStatus",
components: {
ClimaterIcon,
@ -223,71 +225,74 @@ export default {
},
mixins: [formatter],
props: {
vehicleSoc: Number,
vehicleSoc: { type: Number, default: 0 },
batteryBoostActive: Boolean,
charging: Boolean,
chargingPlanDisabled: Boolean,
chargerStatusReason: String,
connected: Boolean,
currency: String,
currency: String as PropType<CURRENCY>,
effectiveLimitSoc: Number,
effectivePlanSoc: Number,
effectivePlanSoc: { type: Number, default: 0 },
effectivePlanTime: String,
enabled: Boolean,
heating: Boolean,
minSoc: Number,
phaseAction: String,
minSoc: { type: Number, default: 0 },
phaseAction: { type: String, default: "" },
phaseRemainingInterpolated: Number,
planActive: Boolean,
planOverrun: Number,
planProjectedEnd: String,
planProjectedStart: String,
planTimeUnreachable: Boolean,
pvAction: String,
pvAction: { type: String, default: "" },
pvRemainingInterpolated: Number,
smartCostActive: Boolean,
smartCostDisabled: Boolean,
smartCostLimit: { type: Number, default: null },
smartCostNextStart: String,
smartCostType: String,
tariffCo2: Number,
tariffGrid: Number,
tariffCo2: { type: Number, default: 0 },
tariffGrid: { type: Number, default: 0 },
vehicleClimaterActive: Boolean,
vehicleWelcomeActive: Boolean,
vehicleLimitSoc: Number,
vehicleLimitSoc: { type: Number, default: 0 },
},
emits: ["open-loadpoint-settings", "open-minsoc-settings", "open-plan-modal"],
data() {
return {
pvTooltip: null,
phaseTooltip: null,
planStartTooltip: null,
planActiveTooltip: null,
minSocTooltip: null,
vehicleClimaterTooltip: null,
vehicleWelcomeTooltip: null,
smartCostTooltip: null,
vehicleLimitTooltip: null,
tempLimitTooltip: null,
awaitingAuthorizationTooltip: null,
disconnectRequiredTooltip: null,
batteryBoostTooltip: null,
interval: null,
planProjectedEndDuration: null,
smartCostNextStartDuration: null,
planProjectedStartDuration: null,
pvTooltip: null as Tooltip | null,
phaseTooltip: null as Tooltip | null,
planStartTooltip: null as Tooltip | null,
planActiveTooltip: null as Tooltip | null,
minSocTooltip: null as Tooltip | null,
vehicleClimaterTooltip: null as Tooltip | null,
vehicleWelcomeTooltip: null as Tooltip | null,
smartCostTooltip: null as Tooltip | null,
vehicleLimitTooltip: null as Tooltip | null,
tempLimitTooltip: null as Tooltip | null,
awaitingAuthorizationTooltip: null as Tooltip | null,
disconnectRequiredTooltip: null as Tooltip | null,
batteryBoostTooltip: null as Tooltip | null,
interval: null as Timeout,
planProjectedEndDuration: null as string | null,
smartCostNextStartDuration: null as string | null,
planProjectedStartDuration: null as string | null,
};
},
computed: {
phaseTimerActive() {
return (
this.phaseRemainingInterpolated &&
this.phaseRemainingInterpolated > 0 &&
["scale1p", "scale3p"].includes(this.phaseAction)
);
},
pvTimerActive() {
return (
this.pvRemainingInterpolated > 0 && ["enable", "disable"].includes(this.pvAction)
this.pvRemainingInterpolated &&
this.pvRemainingInterpolated > 0 &&
["enable", "disable"].includes(this.pvAction)
);
},
pvTimerVisible() {
@ -492,15 +497,15 @@ export default {
return this.$t("main.vehicleStatus.welcome");
},
chargerStatus() {
const t = (key, data) => {
const t = (key: string) => {
if (this.heating) {
// check for special heating status translation
const name = `main.heatingStatus.${key}`;
if (this.$te(name, DEFAULT_LOCALE)) {
return this.$t(name, data);
return this.$t(name);
}
}
return this.$t(`main.vehicleStatus.${key}`, data);
return this.$t(`main.vehicleStatus.${key}`);
};
if (!this.connected) {
@ -590,7 +595,9 @@ export default {
this.updateBatteryBoostTooltip();
},
beforeUnmount() {
clearInterval(this.interval);
if (this.interval) {
clearInterval(this.interval);
}
},
methods: {
updateDurations() {
@ -643,21 +650,21 @@ export default {
this.pvTooltip = this.updateTooltip(
this.pvTooltip,
this.pvTimerContent,
this.$refs.pvTimer
this.$refs["pvTimer"]
);
},
updatePhaseTooltip() {
this.phaseTooltip = this.updateTooltip(
this.phaseTooltip,
this.phaseTimerContent,
this.$refs.phaseTimer
this.$refs["phaseTimer"]
);
},
updatePlanStartTooltip() {
this.planStartTooltip = this.updateTooltip(
this.planStartTooltip,
this.planStartTooltipContent,
this.$refs.planStart,
this.$refs["planStart"],
true
);
},
@ -665,7 +672,7 @@ export default {
this.planActiveTooltip = this.updateTooltip(
this.planActiveTooltip,
this.planActiveTooltipContent,
this.$refs.planActive,
this.$refs["planActive"],
true
);
},
@ -673,7 +680,7 @@ export default {
this.minSocTooltip = this.updateTooltip(
this.minSocTooltip,
this.minSocTooltipContent,
this.$refs.minSoc,
this.$refs["minSoc"],
true
);
},
@ -681,28 +688,28 @@ export default {
this.vehicleClimaterTooltip = this.updateTooltip(
this.vehicleClimaterTooltip,
this.vehicleClimaterTooltipContent,
this.$refs.vehicleClimater
this.$refs["vehicleClimater"]
);
},
updateBatteryBoostTooltip() {
this.batteryBoostTooltip = this.updateTooltip(
this.batteryBoostTooltip,
this.batteryBoostTooltipContent,
this.$refs.batteryBoost
this.$refs["batteryBoost"]
);
},
updateVehicleWelcomeTooltip() {
this.vehicleWelcomeTooltip = this.updateTooltip(
this.vehicleWelcomeTooltip,
this.vehicleWelcomeTooltipContent,
this.$refs.vehicleWelcome
this.$refs["vehicleWelcome"]
);
},
updateSmartCostTooltip() {
this.smartCostTooltip = this.updateTooltip(
this.smartCostTooltip,
this.smartCostTooltipContent,
this.$refs.smartCost,
this.$refs["smartCost"],
true
);
},
@ -710,7 +717,7 @@ export default {
this.vehicleLimitTooltip = this.updateTooltip(
this.vehicleLimitTooltip,
this.vehicleLimitTooltipContent,
this.$refs.vehicleLimit,
this.$refs["vehicleLimit"],
true
);
},
@ -718,29 +725,34 @@ export default {
this.tempLimitTooltip = this.updateTooltip(
this.tempLimitTooltip,
this.tempLimitTooltipContent,
this.$refs.tempLimit
this.$refs["tempLimit"]
);
},
updateAwaitingAuthorizationTooltip() {
this.awaitingAuthorizationTooltip = this.updateTooltip(
this.awaitingAuthorizationTooltip,
this.awaitingAuthorizationTooltipContent,
this.$refs.awaitingAuthorization
this.$refs["awaitingAuthorization"]
);
},
updateDisconnectRequiredTooltip() {
this.updateTooltip(
this.disconnectRequiredTooltip = this.updateTooltip(
this.disconnectRequiredTooltip,
this.disconnectRequiredTooltipContent,
this.$refs.disconnectRequired
this.$refs["disconnectRequired"]
);
},
updateTooltip(instance, content, ref, hoverOnly = false) {
updateTooltip(
instance: Tooltip | null,
content: string,
ref: HTMLElement | undefined,
hoverOnly = false
): Tooltip | null {
if (!content || !ref) {
if (instance) {
instance.dispose();
}
return;
return null;
}
let newInstance = instance;
if (!newInstance) {
@ -751,7 +763,7 @@ export default {
return newInstance;
},
},
};
});
</script>
<style scoped>

View file

@ -51,7 +51,7 @@
</div>
</template>
<script>
<script lang="ts">
import "@h2d2/shopicons/es/regular/cablecharge";
import Tooltip from "bootstrap/js/dist/tooltip";
import Modal from "bootstrap/js/dist/modal";
@ -60,8 +60,10 @@ import Options from "./Options.vue";
import CloudOffline from "../MaterialIcon/CloudOffline.vue";
import Sync from "../MaterialIcon/Sync.vue";
import collector from "@/mixins/collector";
import { defineComponent, type PropType } from "vue";
import type { SelectOption, Vehicle } from "@/types/evcc";
export default {
export default defineComponent({
name: "VehicleTitle",
components: { VehicleOptions: Options, VehicleIcon, Sync, CloudOffline },
mixins: [collector],
@ -72,14 +74,14 @@ export default {
vehicleNotReachable: Boolean,
icon: String,
vehicleName: String,
vehicles: { type: Array, default: () => [] },
vehicles: { type: Array as PropType<Vehicle[]>, default: () => [] },
title: String,
},
emits: ["change-vehicle", "remove-vehicle"],
data() {
return {
refreshTooltip: null,
notReachableTooltip: null,
refreshTooltip: null as Tooltip | null,
notReachableTooltip: null as Tooltip | null,
};
},
computed: {
@ -101,6 +103,12 @@ export default {
}
return this.$t("main.vehicle.none");
},
vehicleOptions(): SelectOption<string>[] {
return this.vehicles.map((v) => ({
name: v.name,
value: v.title,
}));
},
vehicleKnown() {
return !!this.vehicleName;
},
@ -120,7 +128,7 @@ export default {
this.initTooltip();
},
methods: {
changeVehicle(name) {
changeVehicle(name: string) {
this.$emit("change-vehicle", name);
},
removeVehicle() {
@ -130,21 +138,23 @@ export default {
this.$nextTick(() => {
this.refreshTooltip?.dispose();
this.notReachableTooltip?.dispose();
if (this.$refs.refresh) {
this.refreshTooltip = new Tooltip(this.$refs.refresh);
if (this.$refs["refresh"]) {
this.refreshTooltip = new Tooltip(this.$refs["refresh"]);
}
if (this.$refs.notReachable) {
this.notReachableTooltip = new Tooltip(this.$refs.notReachable);
if (this.$refs["notReachable"]) {
this.notReachableTooltip = new Tooltip(this.$refs["notReachable"]);
}
});
},
openHelpModal() {
const modal = Modal.getOrCreateInstance(document.getElementById("helpModal"));
const modal = Modal.getOrCreateInstance(
document.getElementById("helpModal") as HTMLElement
);
modal.show();
this.initTooltip();
},
},
};
});
</script>
<style scoped>

View file

@ -1,188 +0,0 @@
import Vehicle from "./Vehicle.vue";
const hoursFromNow = (hours) => {
const now = new Date();
now.setHours(now.getHours() + hours);
return now.toISOString();
};
const baseState = {
vehicle: { title: "Mein Auto", icon: "car", capacity: 72, features: [] },
enabled: false,
connected: true,
vehicleName: "meinauto",
vehicleSoc: 42.742,
vehicleRange: 231,
limitSoc: 90,
chargedEnergy: 14123,
socBasedCharging: true,
id: 0,
};
export default {
title: "Vehicles/Vehicle",
component: Vehicle,
argTypes: {
chargedEnergy: { control: "number" },
charging: { control: "boolean" },
vehicleClimaterActive: { control: "boolean" },
vehicleWelcomeActive: { control: "boolean" },
connected: { control: "boolean" },
currency: { control: "text" },
effectiveLimitSoc: { control: "number" },
effectivePlanSoc: { control: "number" },
effectivePlanTime: { control: "text" },
batteryBoostActive: { control: "boolean" },
enabled: { control: "boolean" },
heating: { control: "boolean" },
id: { control: "text" },
integratedDevice: { control: "boolean" },
limitEnergy: { control: "number" },
mode: { control: "text" },
chargerStatusReason: { control: "text" },
vehicle: { control: "object" },
vehicleName: { control: "text" },
vehicleRange: { control: "number" },
vehicleSoc: { control: "number" },
vehicleLimitSoc: { control: "number" },
socBasedCharging: { control: "boolean" },
},
};
const Template = (args) => ({
components: { Vehicle },
setup() {
return { args };
},
template: '<Vehicle v-bind="args" />',
});
export const Disconnected = Template.bind({});
Disconnected.args = {
...baseState,
connected: false,
};
export const Connected = Template.bind({});
Connected.args = {
...baseState,
};
export const Ready = Template.bind({});
Ready.args = {
...baseState,
enabled: true,
};
export const Charging = Template.bind({});
Charging.args = {
...baseState,
enabled: true,
charging: true,
};
export const UnknownVehicle = Template.bind({});
UnknownVehicle.args = {
...baseState,
enabled: true,
charging: true,
vehicleName: "",
socBasedCharging: false,
vehicle: { ...baseState.vehicle, capacity: null },
mode: "pv",
};
export const OfflineVehicle = Template.bind({});
OfflineVehicle.args = {
...baseState,
enabled: true,
charging: true,
socBasedCharging: false,
vehicle: {
...baseState.vehicle,
title: "Opel Corsa-e",
capacity: 72,
features: ["Offline"],
},
mode: "pv",
};
export const OfflineVehicleWithTarget = Template.bind({});
OfflineVehicleWithTarget.args = {
...baseState,
enabled: true,
charging: true,
socBasedCharging: false,
vehicle: {
...baseState.vehicle,
title: "Opel Corsa-e",
capacity: 72,
features: ["Offline"],
},
targetEnergy: 30,
mode: "pv",
};
export const VehicleLimit = Template.bind({});
VehicleLimit.args = {
...baseState,
enabled: true,
charging: true,
vehicleLimitSoc: 80,
};
export const VehicleLimitReached = Template.bind({});
VehicleLimitReached.args = {
...baseState,
enabled: true,
vehicleLimitSoc: 80,
vehicleSoc: 80,
};
export const TargetChargePlanned = Template.bind({});
TargetChargePlanned.args = {
...baseState,
targetTime: hoursFromNow(34),
mode: "pv",
};
export const TargetChargeActive = Template.bind({});
TargetChargeActive.args = {
...baseState,
enabled: true,
charging: true,
targetTime: hoursFromNow(14),
mode: "pv",
};
export const SmartChargeCostLimitActive = Template.bind({});
SmartChargeCostLimitActive.args = {
...baseState,
enabled: true,
charging: true,
smartCostLimit: 0.13,
mode: "pv",
};
export const PvEnableTimer = Template.bind({});
PvEnableTimer.args = {
...baseState,
pvAction: "enable",
pvRemainingInterpolated: 32,
};
export const PvDisableTimer = Template.bind({});
PvDisableTimer.args = {
...baseState,
enabled: true,
charging: true,
pvAction: "disable",
pvRemainingInterpolated: 155,
};
export const VehicleSwitch = Template.bind({});
VehicleSwitch.args = {
...baseState,
vehicleTitle: "Blauer e-Golf",
vehicles: ["Blauer e-Golf", "Weißes Model 3"],
};

View file

@ -0,0 +1,187 @@
import { CHARGE_MODE } from "@/types/evcc";
import Vehicle from "./Vehicle.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
const baseState = {
vehicle: {
title: "Mein Auto",
icon: "car",
capacity: 72,
features: [],
name: "",
repeatingPlans: [],
},
enabled: false,
connected: true,
vehicleName: "meinauto",
vehicleSoc: 42.742,
vehicleRange: 231,
limitSoc: 90,
chargedEnergy: 14123,
socBasedCharging: true,
id: 0,
};
export default {
title: "Vehicles/Vehicle",
component: Vehicle,
argTypes: {
chargedEnergy: { control: "number" },
charging: { control: "boolean" },
vehicleClimaterActive: { control: "boolean" },
vehicleWelcomeActive: { control: "boolean" },
connected: { control: "boolean" },
currency: { control: "text" },
effectiveLimitSoc: { control: "number" },
effectivePlanSoc: { control: "number" },
effectivePlanTime: { control: "text" },
batteryBoostActive: { control: "boolean" },
enabled: { control: "boolean" },
heating: { control: "boolean" },
id: { control: "text" },
integratedDevice: { control: "boolean" },
limitEnergy: { control: "number" },
mode: { control: "text" },
chargerStatusReason: { control: "text" },
vehicle: { control: "object" },
vehicleName: { control: "text" },
vehicleRange: { control: "number" },
vehicleSoc: { control: "number" },
vehicleLimitSoc: { control: "number" },
socBasedCharging: { control: "boolean" },
},
} as Meta<typeof Vehicle>;
const Template: StoryFn<typeof Vehicle> = (args) => ({
components: { Vehicle },
setup() {
return { args };
},
template: '<Vehicle v-bind="args" />',
});
export const Disconnected = Template.bind({});
Disconnected.args = {
...baseState,
connected: false,
};
export const Connected = Template.bind({});
Connected.args = {
...baseState,
};
export const Ready = Template.bind({});
Ready.args = {
...baseState,
enabled: true,
};
export const Charging = Template.bind({});
Charging.args = {
...baseState,
enabled: true,
charging: true,
};
export const UnknownVehicle = Template.bind({});
UnknownVehicle.args = {
...baseState,
enabled: true,
charging: true,
vehicleName: "",
socBasedCharging: false,
vehicle: { ...baseState.vehicle, capacity: undefined },
mode: CHARGE_MODE.PV,
};
export const OfflineVehicle = Template.bind({});
OfflineVehicle.args = {
...baseState,
enabled: true,
charging: true,
socBasedCharging: false,
vehicle: {
...baseState.vehicle,
title: "Opel Corsa-e",
capacity: 72,
features: ["Offline"],
},
mode: CHARGE_MODE.PV,
};
export const OfflineVehicleWithTarget = Template.bind({});
OfflineVehicleWithTarget.args = {
...baseState,
enabled: true,
charging: true,
socBasedCharging: false,
vehicle: {
...baseState.vehicle,
title: "Opel Corsa-e",
capacity: 72,
features: ["Offline"],
},
mode: CHARGE_MODE.PV,
};
export const VehicleLimit = Template.bind({});
VehicleLimit.args = {
...baseState,
enabled: true,
charging: true,
vehicleLimitSoc: 80,
};
export const VehicleLimitReached = Template.bind({});
VehicleLimitReached.args = {
...baseState,
enabled: true,
vehicleLimitSoc: 80,
vehicleSoc: 80,
};
export const TargetChargePlanned = Template.bind({});
TargetChargePlanned.args = {
...baseState,
mode: CHARGE_MODE.PV,
};
export const TargetChargeActive = Template.bind({});
TargetChargeActive.args = {
...baseState,
enabled: true,
charging: true,
mode: CHARGE_MODE.PV,
};
export const SmartChargeCostLimitActive = Template.bind({});
SmartChargeCostLimitActive.args = {
...baseState,
enabled: true,
charging: true,
smartCostLimit: 0.13,
mode: CHARGE_MODE.PV,
};
export const PvEnableTimer = Template.bind({});
PvEnableTimer.args = {
...baseState,
pvAction: "enable",
pvRemainingInterpolated: 32,
};
export const PvDisableTimer = Template.bind({});
PvDisableTimer.args = {
...baseState,
enabled: true,
charging: true,
pvAction: "disable",
pvRemainingInterpolated: 155,
};
export const VehicleSwitch = Template.bind({});
VehicleSwitch.args = {
...baseState,
vehicles: ["Blauer e-Golf", "Weißes Model 3"],
};

View file

@ -67,7 +67,7 @@
</div>
</template>
<script>
<script lang="ts">
import collector from "@/mixins/collector";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import LabelAndValue from "../Helper/LabelAndValue.vue";
@ -78,8 +78,10 @@ import ChargingPlan from "../ChargingPlans/ChargingPlan.vue";
import LimitSocSelect from "./LimitSocSelect.vue";
import LimitEnergySelect from "./LimitEnergySelect.vue";
import { distanceUnit, distanceValue } from "@/units";
import { defineComponent, type PropType } from "vue";
import { CHARGE_MODE, type Forecast, type Vehicle } from "@/types/evcc";
export default {
export default defineComponent({
name: "Vehicle",
components: {
VehicleTitle: Title,
@ -92,7 +94,7 @@ export default {
},
mixins: [collector, formatter],
props: {
chargedEnergy: Number,
chargedEnergy: { type: Number, default: 0 },
charging: Boolean,
vehicleClimaterActive: Boolean,
vehicleWelcomeActive: Boolean,
@ -107,11 +109,11 @@ export default {
id: [String, Number],
integratedDevice: Boolean,
limitEnergy: Number,
mode: String,
mode: String as PropType<CHARGE_MODE>,
chargerStatusReason: String,
phaseAction: String,
phaseRemainingInterpolated: Number,
forecast: Object, // as PropType<Forecast>,
forecast: Object as PropType<Forecast>,
planActive: Boolean,
planEnergy: Number,
planProjectedStart: String,
@ -131,12 +133,12 @@ export default {
socBasedPlanning: Boolean,
tariffCo2: Number,
tariffGrid: Number,
vehicle: Object,
vehicle: Object as PropType<Vehicle>,
vehicleDetectionActive: Boolean,
vehicleName: String,
vehicleRange: Number,
vehicles: Array,
vehicleSoc: Number,
vehicleSoc: { type: Number, default: 0 },
vehicleLimitSoc: Number,
vehicleNotReachable: Boolean,
},
@ -202,23 +204,23 @@ export default {
if (this.vehicleSoc > 10 && this.range) {
return Math.round((this.range / this.vehicleSoc) * 1e2) / 1e2;
}
return null;
return undefined;
},
socPerKwh() {
if (this.capacity > 0) {
return 100 / this.capacity;
}
return null;
return 0;
},
chargedSoc() {
const value = this.socPerKwh * (this.chargedEnergy / 1e3);
return value > 1 ? `+${this.fmtPercentage(value)}` : null;
},
chargingPlanDisabled() {
return ["off", "now"].includes(this.mode);
return this.mode && [CHARGE_MODE.OFF, CHARGE_MODE.NOW].includes(this.mode);
},
smartCostDisabled() {
return ["off", "now"].includes(this.mode);
return this.chargingPlanDisabled;
},
},
watch: {
@ -227,33 +229,37 @@ export default {
},
},
methods: {
limitSocDrag(limitSoc) {
limitSocDrag(limitSoc: number) {
this.displayLimitSoc = limitSoc;
},
limitSocUpdated(limitSoc) {
limitSocUpdated(limitSoc: number) {
this.displayLimitSoc = limitSoc;
this.$emit("limit-soc-updated", limitSoc);
},
limitEnergyUpdated(limitEnergy) {
limitEnergyUpdated(limitEnergy: number) {
this.$emit("limit-energy-updated", limitEnergy);
},
changeVehicle(name) {
changeVehicle(name: string) {
this.$emit("change-vehicle", name);
},
removeVehicle() {
this.$emit("remove-vehicle");
},
fmtEnergy(value) {
fmtEnergy(value: number) {
return this.fmtWh(value, value == 0 ? POWER_UNIT.KW : POWER_UNIT.AUTO);
},
openPlanModal() {
this.$refs.chargingPlan.openPlanModal();
(
this.$refs["chargingPlan"] as InstanceType<typeof ChargingPlan> | undefined
)?.openPlanModal();
},
openMinSocSettings() {
this.$refs.chargingPlan.openPlanModal(true);
(
this.$refs["chargingPlan"] as InstanceType<typeof ChargingPlan> | undefined
)?.openPlanModal(true);
},
},
};
});
</script>
<style scoped>

View file

@ -107,6 +107,7 @@ export interface Vehicle {
title: string;
features?: string[];
capacity?: number;
icon?: string;
}
export type Timeout = ReturnType<typeof setInterval> | null;

View file

@ -288,3 +288,45 @@ test.describe("csv export", async () => {
);
});
});
test.describe("session details", async () => {
test("show session details (session 5)", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
await page.getByTestId("sessions-entry").nth(0).click();
await expect(page.getByTestId("session-details")).toBeVisible();
await expect(
page.getByTestId("session-details").getByRole("heading", { name: "Charging Session" })
).toBeVisible();
await expect(page.getByTestId("session-details-loadpoint")).toContainText("Garage");
await expect(page.getByTestId("session-details-vehicle")).toContainText("weißes Model 3");
await expect(page.getByTestId("session-details-date")).toContainText(
["Thu, May 4, 22:00", "Fri, May 5, 06:00"].join("")
);
await expect(page.getByTestId("session-details-energy")).toContainText("5.0 kWh");
await expect(page.getByTestId("session-details-energy")).toContainText("1:00");
await expect(page.getByTestId("session-details-solar")).toContainText("0.0% (0.0 kWh)");
await expect(page.getByTestId("session-details-price")).toContainText("2.50 € 50.0 ct/kWh");
await expect(page.getByTestId("session-details-co2")).toHaveCount(0);
await expect(page.getByTestId("session-details-odometer")).toHaveCount(0);
await expect(page.getByTestId("session-details-meter")).toHaveCount(0);
await expect(page.getByTestId("session-details-delete")).toContainText("Delete");
});
test("show session details with CO2 data (session 1)", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=3");
await page.getByTestId("sessions-entry").nth(0).click();
await expect(page.getByTestId("session-details")).toBeVisible();
await expect(page.getByTestId("session-details-loadpoint")).toContainText("Carport");
await expect(page.getByTestId("session-details-vehicle")).toContainText("blauer e-Golf");
await expect(page.getByTestId("session-details-date")).toContainText(
["Wed, Mar 1, 07:00", "Tue, May 2, 12:00"].join("")
);
await expect(page.getByTestId("session-details-energy")).toContainText("10.0 kWh");
await expect(page.getByTestId("session-details-energy")).toContainText("1:00");
await expect(page.getByTestId("session-details-solar")).toContainText("100.0% (10.0 kWh)");
await expect(page.getByTestId("session-details-price")).toContainText("2.00 € 20.0 ct/kWh");
await expect(page.getByTestId("session-details-co2")).toContainText("300 g/kWh");
});
});