evcc-io/assets/js/components/Config/ChargerModal.vue

342 lines
9.9 KiB
Vue

<template>
<DeviceModalBase
:id="id"
name="charger"
device-type="charger"
:is-sponsor="isSponsor"
:modal-title="modalTitle"
:provide-template-options="provideTemplateOptions"
:initial-values="initialValues"
:is-type-deprecated="isTypeDeprecated"
:is-yaml-input-type="isYamlInput"
:transform-api-data="transformApiData"
:filter-template-params="filterTemplateParams"
:on-template-change="handleTemplateChange"
:apply-custom-defaults="applyCustomDefaults"
:custom-fields="customFields"
:get-product-name="getProductName"
:hide-template-fields="showOcppOnboarding"
@added="(name) => emitChanged('added', name)"
@updated="() => emitChanged('updated')"
@removed="() => emitChanged('removed')"
@close="$emit('close')"
@reset="reset"
>
<template v-if="isOcpp" #template-description>
<p>{{ $t("config.charger.ocppDescription") }}</p>
<ol class="mb-4">
<li>{{ $t("config.charger.ocppStep1") }}</li>
<li>{{ $t("config.charger.ocppStep2") }}</li>
<li>{{ $t("config.charger.ocppStep3") }}</li>
</ol>
</template>
<template v-if="isOcpp" #after-template-info="{ values }">
<FormRow
id="chargerOcppUrl"
:label="$t('config.charger.ocppLabel')"
:help="$t('config.charger.ocppHelp', { url: ocppUrlWithStationId })"
>
<input
id="chargerOcppUrl"
type="text"
class="form-control border"
:value="ocppUrl"
readonly
/>
</FormRow>
<div
v-if="showOcppOnboarding"
class="my-4 d-flex justify-content-end gap-2 align-items-center"
>
<span v-if="ocppStationIdDetected">{{ $t("config.charger.ocppConnected") }}</span>
<button
v-if="ocppStationIdDetected"
type="button"
class="btn btn-primary text-nowrap ms-2"
@click="
values.stationid = ocppStationIdDetected;
ocppNextStepConfirmed = true;
"
>
{{ $t("config.charger.ocppNextStep") }}
</button>
<button
v-else
type="button"
class="btn btn-outline-primary text-nowrap"
@click="confirmOcppNextStep"
>
<span
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ $t("config.charger.ocppWaiting") }}
</button>
</div>
</template>
</DeviceModalBase>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import FormRow from "./FormRow.vue";
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
import { ConfigType } from "@/types/evcc";
import { getModal } from "@/configModal";
import {
type DeviceValues,
type Template,
type Product,
type TemplateParam,
type ApiData,
customChargerName,
} from "./DeviceModal";
import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue";
import customChargerYaml from "./defaultYaml/customCharger.yaml?raw";
import customHeaterYaml from "./defaultYaml/customHeater.yaml?raw";
import heatpumpYaml from "./defaultYaml/heatpump.yaml?raw";
import switchsocketHeaterYaml from "./defaultYaml/switchsocketHeater.yaml?raw";
import switchsocketChargerYaml from "./defaultYaml/switchsocketCharger.yaml?raw";
import sgreadyYaml from "./defaultYaml/sgready.yaml?raw";
import sgreadyRelayYaml from "./defaultYaml/sgreadyRelay.yaml?raw";
import { LOADPOINT_TYPE, type LoadpointType, type Ocpp } from "@/types/evcc";
import { getOcppUrl, getOcppUrlWithStationId } from "@/utils/ocpp";
const initialValues = {
type: ConfigType.Template,
icon: undefined,
deviceProduct: undefined,
yaml: undefined,
template: null,
};
const CUSTOM_FIELDS = ["modbus"];
export default defineComponent({
name: "ChargerModal",
components: {
FormRow,
DeviceModalBase,
},
props: {
ocpp: {
type: Object as PropType<Ocpp>,
default: () => ({ config: { port: 0 }, status: { stations: [] } }),
},
isSponsor: Boolean,
},
emits: ["changed", "close"],
data() {
return {
initialValues,
customFields: CUSTOM_FIELDS,
ocppNextStepConfirmed: false,
currentTemplate: null as Template | null,
currentValues: {} as DeviceValues,
};
},
computed: {
id(): number | undefined {
return getModal("charger")?.id;
},
loadpointType(): LoadpointType | null {
return (getModal("charger")?.type as LoadpointType) || null;
},
modalTitle(): string {
if (this.isNew) {
return this.$t(`config.charger.titleAdd.${this.loadpointType}`);
}
return this.$t(`config.charger.titleEdit.${this.loadpointType}`);
},
isNew(): boolean {
return this.id === undefined;
},
isHeating(): boolean {
return this.loadpointType === LOADPOINT_TYPE.HEATING;
},
isOcpp(): boolean {
return (
this.currentTemplate !== null &&
this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "connector") &&
this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "stationid")
);
},
ocppUrl(): string | null {
return this.isOcpp ? getOcppUrl(this.ocpp) : null;
},
ocppUrlWithStationId(): string | null {
return this.isOcpp ? getOcppUrlWithStationId(this.ocpp) : null;
},
ocppStationIdDetected(): string | undefined {
if (!this.isOcpp) {
return undefined;
}
const stations = this.ocpp.status.stations;
return stations.find((station) => station.status === "unknown")?.id;
},
showOcppOnboarding(): boolean {
if (!this.isOcpp) return false;
if (this.ocppNextStepConfirmed) return false;
if (this.currentValues.stationid) return false;
return true;
},
},
methods: {
provideTemplateOptions(products: Product[]): TemplateGroup[] {
const result: TemplateGroup[] = [];
if (this.isHeating) {
result.push({
label: "generic",
options: [
...products.filter((p) => p.group === "heatinggeneric"),
...[
ConfigType.Custom,
ConfigType.SgReadyRelay,
ConfigType.SgReady,
ConfigType.Heatpump,
ConfigType.SwitchSocket,
].map((type) =>
customTemplateOption(this.$t(customChargerName(type, true)), type)
),
],
});
result.push({
label: "heatingdevices",
options: products.filter((p) => p.group === "heating"),
});
} else {
result.push({
label: "generic",
options: [
...products.filter((p) => p.group === "generic"),
...[ConfigType.Custom, ConfigType.SwitchSocket].map((type) =>
customTemplateOption(this.$t(customChargerName(type, false)), type)
),
],
});
result.push({
label: "chargers",
options: products.filter((p) => !p.group),
});
}
result.push({
label: "switchsockets",
options: products.filter((p) => p.group === "switchsockets"),
});
return result;
},
filterTemplateParams(params: TemplateParam[]): TemplateParam[] {
// HACK: soft-require stationid. Can be removed once https://github.com/evcc-io/evcc/pull/22115 is merged
const filtered = params.map((p) => {
if (p.Name === "stationid") {
return { ...p, Required: true };
}
return p;
});
return filtered.filter(
(p) =>
!CUSTOM_FIELDS.includes(p.Name) &&
(p.Usages ? p.Usages.includes("charger") : true)
);
},
transformApiData(data: ApiData): ApiData {
if (data.type && this.isYamlInput(data.type)) {
// Icon is extracted from yaml on GET for UI purpose only. Don't write it back.
delete data.icon;
}
return data;
},
isYamlInput(type: ConfigType): boolean {
return [
ConfigType.Custom,
ConfigType.Heatpump,
ConfigType.SwitchSocket,
ConfigType.SgReady,
ConfigType.SgReadyRelay,
ConfigType.SgReadyBoost, // deprecated
].includes(type);
},
isTypeDeprecated(type: ConfigType): boolean {
return type === ConfigType.SgReadyBoost;
},
handleTemplateChange(e: Event, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value as ConfigType;
if (this.isYamlInput(value)) {
values.type = value;
values.yaml = this.defaultYaml(value);
}
},
applyCustomDefaults(template: Template | null, values: DeviceValues) {
// Store template and values for ocppUrl computation
this.currentTemplate = template;
this.currentValues = values;
if (this.isHeating) {
// enable heating and integrated device params if exist
const hasParam = (name: string) => template?.Params.some((p) => p.Name === name);
["heating", "integrateddevice"].forEach((param) => {
if (hasParam(param) && values[param] === undefined) {
values[param] = true;
}
});
// default heater icon
if (hasParam("icon") && values.icon === undefined) {
values.icon = "heater";
}
}
},
defaultYaml(type: ConfigType): string {
switch (type) {
case ConfigType.Custom:
return this.isHeating ? customHeaterYaml : customChargerYaml;
case ConfigType.Heatpump:
return heatpumpYaml;
case ConfigType.SwitchSocket:
return this.isHeating ? switchsocketHeaterYaml : switchsocketChargerYaml;
case ConfigType.SgReady:
return sgreadyYaml;
case ConfigType.SgReadyRelay:
return sgreadyRelayYaml;
default: // template
return "";
}
},
getProductName(values: DeviceValues, templateName: string | null): string {
// For YAML input types, return the translated custom name
if (values.type && this.isYamlInput(values.type)) {
return this.$t(customChargerName(values.type, this.isHeating));
}
// For template types, use the standard logic (deviceProduct or templateName)
return values.deviceProduct || templateName || "";
},
confirmOcppNextStep() {
if (window.confirm(this.$t("config.charger.ocppConfirmContinue"))) {
this.ocppNextStepConfirmed = true;
}
},
async emitChanged(action: "added" | "updated" | "removed", name?: string) {
const result = { action, name };
this.$emit("changed", result);
},
reset() {
this.ocppNextStepConfirmed = false;
this.currentTemplate = null;
this.currentValues = {} as DeviceValues;
},
},
});
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
</style>