evcc-io/assets/js/components/VehicleStatus.vue
2024-10-16 22:03:54 +02:00

742 lines
19 KiB
Vue

<template>
<div
class="d-flex justify-content-between gap-4 evcc-gray"
style="min-height: 24px"
data-testid="vehicle-status"
>
<div class="text-nowrap" data-testid="vehicle-status-charger">{{ chargerStatus }}</div>
<div class="d-flex flex-wrap justify-content-end gap-3">
<!-- pv/phase timer -->
<div
v-if="pvTimerVisible"
ref="pvTimer"
class="entry"
data-testid="vehicle-status-pvtimer"
data-bs-toggle="tooltip"
>
<SunUpIcon v-if="pvAction === 'enable'" />
<SunDownIcon v-else />
<div class="tabular">{{ fmtDuration(pvRemainingInterpolated) }}</div>
</div>
<div
v-else-if="phaseTimerVisible"
ref="phaseTimer"
class="entry"
data-testid="vehicle-status-phasetimer"
data-bs-toggle="tooltip"
>
<shopicon-regular-angledoublerightsmall
:class="phaseIconClass"
></shopicon-regular-angledoublerightsmall>
<div class="tabular">{{ fmtDuration(phaseRemainingInterpolated) }}</div>
</div>
<!-- vehicle -->
<button
v-if="minSocVisible"
ref="minSoc"
type="button"
class="entry text-danger text-decoration-underline"
data-testid="vehicle-status-minsoc"
data-bs-toggle="tooltip"
@click="openMinSocSettings"
>
<VehicleMinSocIcon />
{{ fmtPercentage(minSoc) }}
</button>
<div
v-else-if="vehicleLimitVisible"
ref="vehicleLimit"
class="entry"
:class="vehicleLimitClass"
data-bs-toggle="tooltip"
data-testid="vehicle-status-limit"
:role="vehicleLimitWarning ? 'button' : null"
@click="vehicleLimitClicked"
>
<component :is="vehicleLimitIconComponent" />
{{ fmtPercentage(vehicleLimitSoc) }}
</div>
<div
v-if="vehicleClimaterActive"
ref="vehicleClimater"
data-bs-toggle="tooltip"
class="entry"
data-testid="vehicle-status-climater"
>
<ClimaterIcon />
</div>
<div
v-if="vehicleWelcomeActive"
ref="vehicleWelcome"
data-bs-toggle="tooltip"
class="entry"
data-testid="vehicle-status-welcome"
>
<WelcomeIcon />
</div>
<div
v-if="awaitingAuthorizationVisible"
ref="awaitingAuthorization"
data-bs-toggle="tooltip"
class="entry"
data-testid="vehicle-status-awaiting-authorization"
>
<RfidWaitIcon />
</div>
<div
v-if="disconnectRequiredVisible"
ref="disconnectRequired"
class="entry text-warning"
data-bs-toggle="tooltip"
data-testid="vehicle-status-disconnect-required"
>
<ReconnectIcon />
</div>
<!-- smart cost -->
<button
v-if="smartCostVisible"
ref="smartCost"
type="button"
class="entry"
:class="smartCostClass"
data-testid="vehicle-status-smartcost"
data-bs-toggle="tooltip"
data-bs-trigger="hover"
@click="smartCostClicked"
>
<DynamicPriceIcon v-if="smartCostPrice" />
<shopicon-regular-eco1 v-else></shopicon-regular-eco1>
<div>
<span v-if="smartCostNowVisible">{{ smartCostNow }}</span>
≤ <span class="text-decoration-underline">{{ smartCostLimitFmt }}</span>
<span v-if="smartCostNextStart">
({{ fmtAbsoluteDate(new Date(smartCostNextStart)) }})
</span>
</div>
</button>
<!-- battery boost -->
<button
v-if="batteryBoostVisible"
ref="batteryBoost"
type="button"
class="entry"
data-testid="vehicle-status-batteryboost"
data-bs-toggle="tooltip"
@click="batteryBoostClicked"
>
<BatteryBoostIcon />
</button>
<!-- plan -->
<button
v-if="planActiveVisible"
ref="planActive"
type="button"
class="entry"
:class="planActiveClass"
data-testid="vehicle-status-planactive"
data-bs-toggle="tooltip"
@click="planActiveClicked"
>
<PlanEndIcon />
<span>
{{ fmtAbsoluteDate(new Date(planProjectedEnd)) }}
</span>
</button>
<button
v-else-if="planStartVisible"
ref="planStart"
type="button"
class="entry"
data-testid="vehicle-status-planstart"
data-bs-toggle="tooltip"
@click="planStartClicked"
>
<PlanStartIcon />
{{ fmtAbsoluteDate(new Date(planProjectedStart)) }}
</button>
</div>
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/eco1";
import "@h2d2/shopicons/es/regular/angledoublerightsmall";
import "@h2d2/shopicons/es/regular/clock";
import DynamicPriceIcon from "./MaterialIcon/DynamicPrice.vue";
import { DEFAULT_LOCALE } from "../i18n";
import formatter from "../mixins/formatter";
import { CO2_TYPE } from "../units";
import ClimaterIcon from "./MaterialIcon/Climater.vue";
import PlanEndIcon from "./MaterialIcon/PlanEnd.vue";
import PlanStartIcon from "./MaterialIcon/PlanStart.vue";
import ReconnectIcon from "./MaterialIcon/Reconnect.vue";
import RfidWaitIcon from "./MaterialIcon/RfidWait.vue";
import SunDownIcon from "./MaterialIcon/SunDown.vue";
import SunUpIcon from "./MaterialIcon/SunUp.vue";
import Tooltip from "bootstrap/js/dist/tooltip";
import VehicleLimitIcon from "./MaterialIcon/VehicleLimit.vue";
import VehicleLimitReachedIcon from "./MaterialIcon/VehicleLimitReached.vue";
import VehicleLimitWarningIcon from "./MaterialIcon/VehicleLimitWarning.vue";
import VehicleMinSocIcon from "./MaterialIcon/VehicleMinSoc.vue";
import WelcomeIcon from "./MaterialIcon/Welcome.vue";
import BatteryBoostIcon from "./MaterialIcon/BatteryBoost.vue";
const REASON_AUTH = "waitingforauthorization";
const REASON_DISCONNECT = "disconnectrequired";
export default {
name: "VehicleStatus",
components: {
ClimaterIcon,
DynamicPriceIcon,
PlanEndIcon,
PlanStartIcon,
ReconnectIcon,
RfidWaitIcon,
SunDownIcon,
SunUpIcon,
VehicleLimitIcon,
VehicleMinSocIcon,
WelcomeIcon,
BatteryBoostIcon,
},
mixins: [formatter],
props: {
vehicleSoc: Number,
batteryBoostActive: Boolean,
charging: Boolean,
chargingPlanDisabled: Boolean,
chargerStatusReason: String,
connected: Boolean,
currency: String,
effectiveLimitSoc: Number,
effectivePlanSoc: Number,
effectivePlanTime: String,
enabled: Boolean,
heating: Boolean,
minSoc: Number,
phaseAction: String,
phaseRemainingInterpolated: Number,
planActive: Boolean,
planOverrun: Number,
planProjectedEnd: String,
planProjectedStart: String,
planTimeUnreachable: Boolean,
pvAction: String,
pvRemainingInterpolated: Number,
smartCostActive: Boolean,
smartCostDisabled: Boolean,
smartCostLimit: { type: Number, default: null },
smartCostNextStart: String,
smartCostType: String,
tariffCo2: Number,
tariffGrid: Number,
vehicleClimaterActive: Boolean,
vehicleWelcomeActive: Boolean,
vehicleLimitSoc: Number,
},
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,
awaitingAuthorizationTooltip: null,
disconnectRequiredTooltip: null,
batteryBoostTooltip: null,
interval: null,
planProjectedEndDuration: null,
smartCostNextStartDuration: null,
planProjectedStartDuration: null,
};
},
mounted() {
this.updatePlanStartTooltip();
this.updatePlanActiveTooltip();
this.updateMinSocTooltip();
this.updatePhaseTooltip();
this.updatePvTooltip();
this.updateVehicleClimaterTooltip();
this.updateVehicleWelcomeTooltip();
this.updateSmartCostTooltip();
this.updateVehicleLimitTooltip();
this.updateAwaitingAuthorizationTooltip();
this.updateDisconnectRequiredTooltip();
this.updateDurations();
this.interval = setInterval(this.updateDurations, 1000 * 60);
this.updateBatteryBoostTooltip();
},
beforeUnmount() {
clearInterval(this.interval);
},
watch: {
planActiveTooltipContent() {
this.$nextTick(this.updatePlanActiveTooltip);
},
planStartTooltipContent() {
this.$nextTick(this.updatePlanStartTooltip);
},
minSocTooltipContent() {
this.$nextTick(this.updateMinSocTooltip);
},
phaseTimerContent() {
this.$nextTick(this.updatePhaseTooltip);
},
pvTimerContent() {
this.$nextTick(this.updatePvTooltip);
},
vehicleClimaterTooltipContent() {
this.$nextTick(this.updateVehicleClimaterTooltip);
},
vehicleWelcomeTooltipContent() {
this.$nextTick(this.updateVehicleWelcomeTooltip);
},
smartCostTooltipContent() {
this.$nextTick(this.updateSmartCostTooltip);
},
vehicleLimitTooltipContent() {
this.$nextTick(this.updateVehicleLimitTooltip);
},
awaitingAuthorizationTooltipContent() {
this.$nextTick(this.updateAwaitingAuthorizationTooltip);
},
disconnectRequiredTooltipContent() {
this.$nextTick(this.updateDisconnectRequiredTooltip);
},
batteryBoostTooltipContent() {
this.$nextTick(this.updateBatteryBoostTooltip);
},
planProjectedStart() {
this.updateDurations();
},
planProjectedEnd() {
this.updateDurations();
},
smartCostNextStart() {
this.updateDurations();
},
},
computed: {
phaseTimerActive() {
return (
this.phaseRemainingInterpolated > 0 &&
["scale1p", "scale3p"].includes(this.phaseAction)
);
},
pvTimerActive() {
return (
this.pvRemainingInterpolated > 0 && ["enable", "disable"].includes(this.pvAction)
);
},
pvTimerVisible() {
return this.pvTimerActive;
},
pvTimerContent() {
if (!this.pvTimerVisible) {
return "";
}
const key = this.pvAction === "enable" ? "pvEnable" : "pvDisable";
return this.$t(`main.vehicleStatus.${key}`);
},
phaseTimerVisible() {
return !this.pvTimerActive && this.charging && this.phaseTimerActive;
},
phaseIconClass() {
return this.phaseAction === "scale1p" ? "phaseUp" : "phaseDown";
},
phaseTimerContent() {
if (!this.phaseTimerVisible) {
return "";
}
return this.$t(`main.vehicleStatus.${this.phaseAction}`);
},
vehicleLimitVisible() {
const limit = this.effectiveLimitSoc || 100;
return this.connected && this.vehicleLimitSoc > 0 && this.vehicleLimitSoc < limit;
},
awaitingAuthorizationVisible() {
return this.chargerStatusReason === REASON_AUTH;
},
awaitingAuthorizationTooltipContent() {
if (!this.awaitingAuthorizationVisible) {
return "";
}
return this.$t("main.vehicleStatus.awaitingAuthorization");
},
disconnectRequiredVisible() {
return this.chargerStatusReason === REASON_DISCONNECT;
},
disconnectRequiredTooltipContent() {
if (!this.disconnectRequiredVisible) {
return "";
}
return this.$t("main.vehicleStatus.disconnectRequired");
},
vehicleLimitTooltipContent() {
if (!this.vehicleLimitVisible) {
return "";
}
if (this.vehicleLimitReached) {
return this.$t("main.vehicleStatus.vehicleLimitReached");
}
if (this.vehicleLimitWarning) {
return this.$t("main.targetCharge.targetIsAboveVehicleLimit");
}
return this.$t("main.vehicleStatus.vehicleLimit");
},
minSocVisible() {
return this.connected && this.minSoc > 0 && this.vehicleSoc < this.minSoc;
},
minSocTooltipContent() {
if (!this.minSocVisible) {
return "";
}
return this.$t("main.vehicleStatus.minCharge", {
soc: this.fmtPercentage(this.minSoc),
});
},
vehicleLimitReached() {
return (
!this.charging &&
this.vehicleSoc &&
this.vehicleLimitSoc &&
this.vehicleSoc >= this.vehicleLimitSoc - 1
);
},
vehicleLimitWarning() {
return this.effectivePlanSoc > this.vehicleLimitSoc;
},
vehicleLimitClass() {
if (this.vehicleLimitWarning) {
return "text-warning";
}
return "";
},
vehicleLimitIconComponent() {
if (this.vehicleLimitReached) {
return VehicleLimitReachedIcon;
}
if (this.vehicleLimitWarning) {
return VehicleLimitWarningIcon;
}
return VehicleLimitIcon;
},
planStartVisible() {
return this.planProjectedStart && !this.planActive && !this.chargingPlanDisabled;
},
batteryBoostVisible() {
return this.batteryBoostActive;
},
batteryBoostTooltipContent() {
if (!this.batteryBoostVisible) {
return "";
}
return this.$t("main.vehicleStatus.batteryBoost");
},
planStartTooltipContent() {
if (!this.planStartVisible) {
return "";
}
return this.$t("main.vehicleStatus.targetChargePlanned", {
duration: this.planProjectedStartDuration,
});
},
planActiveVisible() {
return this.planProjectedEnd && this.planActive && !this.chargingPlanDisabled;
},
planActiveClass() {
return this.planTimeUnreachable ? "text-warning" : "text-primary";
},
planActiveTooltipContent() {
if (!this.planActiveVisible) {
return "";
}
if (this.planTimeUnreachable) {
return this.$t("main.targetCharge.notReachableInTime", {
overrun: this.fmtDuration(this.planOverrun, true, "h"),
});
}
return this.$t("main.vehicleStatus.targetChargeActive", {
duration: this.planProjectedEndDuration,
});
},
smartCostVisible() {
return this.smartCostLimit !== null;
},
smartCostTooltipContent() {
if (!this.smartCostVisible) {
return "";
}
const prefix = `main.vehicleStatus.${this.smartCostPrice ? "cheap" : "clean"}`;
if (this.smartCostNowVisible) {
return this.$t(`${prefix}EnergyCharging`);
}
if (this.smartCostNextStart) {
return this.$t(`${prefix}EnergyNextStart`, {
duration: this.smartCostNextStartDuration,
});
}
return this.$t(`${prefix}EnergySet`);
},
smartCostPrice() {
return this.smartCostType !== CO2_TYPE;
},
smartCostNowVisible() {
if (this.smartCostPrice) {
return this.tariffGrid <= this.smartCostLimit;
}
return this.tariffCo2 <= this.smartCostLimit;
},
smartCostNow() {
if (this.smartCostPrice) {
return this.fmtPricePerKWh(this.tariffGrid, this.currency, true);
}
return this.fmtCo2Short(this.tariffCo2);
},
smartCostLimitFmt() {
if (this.smartCostPrice) {
return this.fmtPricePerKWh(this.smartCostLimit, this.currency, true);
}
return this.fmtCo2Short(this.smartCostLimit);
},
smartCostClass() {
if (this.smartCostDisabled) {
return "opacity-25";
}
if (this.smartCostActive) {
return "text-primary";
}
return "";
},
vehicleClimaterTooltipContent() {
if (!this.vehicleClimaterActive) {
return "";
}
return this.$t("main.vehicleStatus.climating");
},
vehicleWelcomeTooltipContent() {
if (!this.vehicleWelcomeActive) {
return "";
}
return this.$t("main.vehicleStatus.welcome");
},
chargerStatus() {
const t = (key, data) => {
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(`main.vehicleStatus.${key}`, data);
};
if (!this.connected) {
return t("disconnected");
}
if (this.enabled && !this.charging) {
if (this.vehicleLimitReached) {
return t("finished");
}
return t("waitForVehicle");
}
if (this.charging) {
return t("charging");
}
return t("connected");
},
},
methods: {
updateDurations() {
if (this.planProjectedStart) {
this.planProjectedStartDuration = this.fmtDurationToTime(
new Date(this.planProjectedStart)
);
}
if (this.planProjectedEnd) {
this.planProjectedEndDuration = this.fmtDurationToTime(
new Date(this.planProjectedEnd)
);
}
if (this.smartCostNextStart) {
this.smartCostNextStartDuration = this.fmtDurationToTime(
new Date(this.smartCostNextStart)
);
}
},
openLoadpointSettings() {
this.$emit("open-loadpoint-settings");
},
openMinSocSettings() {
this.minSocTooltip?.hide();
this.$emit("open-minsoc-settings");
},
planStartClicked() {
this.planStartTooltip?.hide();
this.$emit("open-plan-modal");
},
planActiveClicked() {
this.planActiveTooltip?.hide();
this.$emit("open-plan-modal");
},
vehicleLimitClicked() {
if (this.vehicleLimitWarning) {
this.vehicleLimitTooltip?.hide();
this.$emit("open-plan-modal");
}
},
smartCostClicked() {
this.openLoadpointSettings();
this.smartCostTooltip?.hide();
},
batteryBoostClicked() {
this.batteryBoostTooltip?.hide();
this.openLoadpointSettings();
},
updatePvTooltip() {
this.pvTooltip = this.updateTooltip(
this.pvTooltip,
this.pvTimerContent,
this.$refs.pvTimer
);
},
updatePhaseTooltip() {
this.phaseTooltip = this.updateTooltip(
this.phaseTooltip,
this.phaseTimerContent,
this.$refs.phaseTimer
);
},
updatePlanStartTooltip() {
this.planStartTooltip = this.updateTooltip(
this.planStartTooltip,
this.planStartTooltipContent,
this.$refs.planStart,
true
);
},
updatePlanActiveTooltip() {
this.planActiveTooltip = this.updateTooltip(
this.planActiveTooltip,
this.planActiveTooltipContent,
this.$refs.planActive,
true
);
},
updateMinSocTooltip() {
this.minSocTooltip = this.updateTooltip(
this.minSocTooltip,
this.minSocTooltipContent,
this.$refs.minSoc,
true
);
},
updateVehicleClimaterTooltip() {
this.vehicleClimaterTooltip = this.updateTooltip(
this.vehicleClimaterTooltip,
this.vehicleClimaterTooltipContent,
this.$refs.vehicleClimater
);
},
updateBatteryBoostTooltip() {
this.batteryBoostTooltip = this.updateTooltip(
this.batteryBoostTooltip,
this.batteryBoostTooltipContent,
this.$refs.batteryBoost
);
},
updateVehicleWelcomeTooltip() {
this.vehicleWelcomeTooltip = this.updateTooltip(
this.vehicleWelcomeTooltip,
this.vehicleWelcomeTooltipContent,
this.$refs.vehicleWelcome
);
},
updateSmartCostTooltip() {
this.smartCostTooltip = this.updateTooltip(
this.smartCostTooltip,
this.smartCostTooltipContent,
this.$refs.smartCost,
true
);
},
updateVehicleLimitTooltip() {
this.vehicleLimitTooltip = this.updateTooltip(
this.vehicleLimitTooltip,
this.vehicleLimitTooltipContent,
this.$refs.vehicleLimit,
true
);
},
updateAwaitingAuthorizationTooltip() {
this.awaitingAuthorizationTooltip = this.updateTooltip(
this.awaitingAuthorizationTooltip,
this.awaitingAuthorizationTooltipContent,
this.$refs.awaitingAuthorization
);
},
updateDisconnectRequiredTooltip() {
this.updateTooltip(
this.disconnectRequiredTooltip,
this.disconnectRequiredTooltipContent,
this.$refs.disconnectRequired
);
},
updateTooltip: function (instance, content, ref, hoverOnly = false) {
if (!content || !ref) {
if (instance) {
instance.dispose();
}
return;
}
let newInstance = instance;
if (!newInstance) {
const trigger = hoverOnly ? "hover" : "hover focus";
newInstance = new Tooltip(ref, { title: " ", trigger });
}
newInstance.setContent({ ".tooltip-inner": content });
return newInstance;
},
},
};
</script>
<style scoped>
.entry {
display: flex;
align-items: center;
flex-wrap: nowrap;
text-wrap: nowrap;
border: none;
color: inherit;
background: none;
padding: 0;
gap: 0.5rem;
transition:
color var(--evcc-transition-medium) linear,
opacity var(--evcc-transition-medium) linear;
}
.phaseUp {
transform: rotate(90deg);
}
.phaseDown {
transform: rotate(-90deg);
}
.tabular {
font-variant-numeric: tabular-nums;
}
</style>