evcc-io/assets/js/components/Config/DeviceModal/DeviceModalBase.vue

899 lines
26 KiB
Vue

<template>
<GenericModal
:id="`${name}Modal`"
ref="modal"
:title="modalTitle"
:data-testid="`${name}-modal`"
:size="modalSize"
:config-modal-name="name"
@open="handleOpen"
@close="handleClose"
@visibilitychange="handleVisibilityChange"
>
<template #header-actions>
<DeviceInfoButton v-if="id && !hideInfo" :id="id" />
</template>
<form ref="form" class="container mx-0 px-0">
<slot name="pre-content" :values="values"></slot>
<template v-if="showMainContent">
<slot name="description" :values="values"></slot>
<slot name="before-template" :values="values"></slot>
<TemplateSelector
v-if="showTemplateSelector"
ref="templateSelect"
v-model="templateName"
:device-type="deviceType"
:is-new="isNew"
:product-name="productName"
:groups="computedTemplateOptions"
@change="handleTemplateChange"
>
<template v-if="$slots['template-action']" #action>
<slot name="template-action" />
</template>
</TemplateSelector>
<p v-if="showDeprecatedWarning" class="text-danger">
{{ $t("config.general.typeDeprecated", { type: values.type }) }}
</p>
<YamlEntry
v-if="showYamlInput"
v-model="values.yaml"
:type="deviceType"
:error-line="test.errorLine"
/>
<div v-else>
<p v-if="loadingTemplate">{{ $t("config.general.templateLoading") }}</p>
<SponsorTokenRequired v-if="sponsorTokenRequired" />
<slot name="template-description">
<Markdown v-if="description" :markdown="description" class="my-4" />
</slot>
<div v-if="authRequired">
<PropertyEntry
v-for="param in authNormalParams"
:id="`${deviceType}Param${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
:currency="currency"
/>
<PropertyCollapsible v-if="authAdvancedParams.length">
<template #advanced>
<PropertyEntry
v-for="param in authAdvancedParams"
:id="`${deviceType}Param${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
:currency="currency"
/>
</template>
</PropertyCollapsible>
<div v-if="auth.code">
<hr class="my-5" />
<AuthCodeDisplay
:id="`${deviceType}AuthCode`"
:code="auth.code"
:expiry="auth.expiry"
/>
</div>
<ErrorMessage :error="auth.error" />
<div
class="my-4 d-flex align-items-stretch justify-content-sm-between align-items-sm-baseline flex-column-reverse flex-sm-row gap-2"
>
<!-- delete / cancel -->
<button
v-if="isDeletable"
type="button"
class="btn btn-link text-danger align-self-start"
tabindex="0"
@click.prevent="handleRemove"
>
{{ $t("config.general.delete") }}
</button>
<button
v-else
type="button"
class="btn btn-link text-muted align-self-start"
data-bs-dismiss="modal"
tabindex="0"
>
{{ $t("config.general.cancel") }}
</button>
<!-- perform auth -->
<AuthConnectButton
:provider-url="auth.providerUrl ?? undefined"
:loading="auth.loading"
@prepare="checkAuthStatus"
/>
</div>
</div>
<div v-else>
<slot name="after-template-info" :values="values"></slot>
<div v-if="!hideTemplateFields">
<Modbus
v-if="modbus"
v-model:modbus="values['modbus']"
v-model:id="values['id']"
v-model:host="values['host']"
v-model:port="values['port']"
v-model:device="values['device']"
v-model:baudrate="values['baudrate']"
v-model:comset="values['comset']"
component-id="device"
:defaultId="modbus.ID ? Number(modbus.ID) : undefined"
:defaultComset="modbus.Comset"
:defaultBaudrate="modbus.Baudrate"
:defaultPort="modbus.Port"
:capabilities="modbusCapabilities"
/>
<PropertyEntry
v-for="param in normalParams"
:id="`${deviceType}Param${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
:currency="currency"
/>
<PropertyCollapsible>
<template v-if="advancedParams.length || modbus" #advanced>
<ModbusAdvanced
v-if="modbus"
v-model:delay="values['delay']"
v-model:timeout="values['timeout']"
component-id="device"
/>
<PropertyEntry
v-for="param in advancedParams"
:id="`${deviceType}Param${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
:currency="currency"
/>
</template>
<template v-if="$slots['collapsible-more']" #more>
<slot name="collapsible-more" :values="values"></slot>
</template>
</PropertyCollapsible>
</div>
</div>
</div>
<DeviceModalActions
v-if="showActions"
:is-deletable="isDeletable"
:test-state="test"
:is-saving="saving"
:is-succeeded="succeeded"
:is-new="isNew"
:sponsor-token-required="sponsorTokenRequired"
:currency="currency"
@save="handleSave"
@remove="handleRemove"
@test="testManually"
>
<template #before-test>
<AdminPasswordPrompt
v-if="adminPasswordRequired"
v-model:password="adminPasswordValue"
:invalid="adminPasswordInvalid"
/>
</template>
<template #after-test>
<slot name="after-test" :values="values"></slot>
</template>
</DeviceModalActions>
</template>
</form>
<slot name="post-content" :values="values"></slot>
</GenericModal>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import GenericModal from "../../Helper/GenericModal.vue";
import DeviceInfoButton from "./DeviceInfoButton.vue";
import { closeModal } from "@/configModal";
import ErrorMessage from "../../Helper/ErrorMessage.vue";
import PropertyEntry from "../PropertyEntry.vue";
import PropertyCollapsible from "../PropertyCollapsible.vue";
import Modbus from "./Modbus.vue";
import ModbusAdvanced from "./ModbusAdvanced.vue";
import DeviceModalActions from "./Actions.vue";
import Markdown from "../Markdown.vue";
import SponsorTokenRequired from "./SponsorTokenRequired.vue";
import TemplateSelector, { type TemplateGroup } from "./TemplateSelector.vue";
import YamlEntry from "./YamlEntry.vue";
import AuthCodeDisplay from "../AuthCodeDisplay.vue";
import AuthConnectButton from "../AuthConnectButton.vue";
import { initialTestState, performTest } from "../utils/test";
import { reportValidityInModal } from "../utils/reportValidityInModal";
import { initialAuthState, prepareAuthLogin } from "../utils/authProvider";
import AdminPasswordPrompt from "@/components/Auth/AdminPasswordPrompt.vue";
import sleep from "@/utils/sleep";
import { ConfigType } from "@/types/evcc";
import type { DeviceType, Timeout } from "@/types/evcc";
import { CURRENCY } from "@/types/evcc";
import {
handleError,
type DeviceValues,
type Template,
type TemplateParam,
type Product,
type ModbusParam,
type ModbusCapability,
type ApiData,
applyDefaultsFromTemplate,
createDeviceUtils,
fetchServiceValues,
ADMIN_PASSWORD_REQUIRED,
} from "./index";
import deepEqual from "@/utils/deepEqual";
const CUSTOM_FIELDS = ["modbus"];
export default defineComponent({
name: "DeviceModalBase",
components: {
GenericModal,
DeviceInfoButton,
ErrorMessage,
PropertyEntry,
PropertyCollapsible,
Modbus,
ModbusAdvanced,
DeviceModalActions,
Markdown,
SponsorTokenRequired,
TemplateSelector,
YamlEntry,
AuthCodeDisplay,
AuthConnectButton,
AdminPasswordPrompt,
},
props: {
deviceType: { type: String as PropType<DeviceType>, required: true },
id: Number as PropType<number | undefined>,
name: String,
isSponsor: Boolean,
// Computed/derived props that must be provided by parent
modalTitle: { type: String, required: true },
initialValues: { type: Object as PropType<DeviceValues>, required: true },
customFields: { type: Array as PropType<string[]>, default: () => CUSTOM_FIELDS },
// Optional: whether to show main content (for multi-step modals like MeterModal)
showMainContent: { type: Boolean, default: true },
// Optional: usage parameter for loadProducts (e.g., meter type: "pv", "battery", "aux", "ext")
usage: String,
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
// Optional: custom product name computation
getProductName: Function as PropType<
(values: DeviceValues, templateName: string | null) => string
>,
// Optional: custom API data transformation
transformApiData: Function as PropType<(data: ApiData, values: DeviceValues) => ApiData>,
// Optional: custom template parameter filtering
filterTemplateParams: Function as PropType<(params: TemplateParam[]) => TemplateParam[]>,
// Optional: custom defaults application
applyCustomDefaults: Function as PropType<
(template: Template | null, values: DeviceValues) => void
>,
// Optional: array of field names to preserve when template changes
preserveOnTemplateChange: Array as PropType<string[]>,
// Optional: determine if YAML input should be shown
isYamlInputType: Function as PropType<(type: ConfigType) => boolean>,
// Optional: determine if a config type is deprecated
isTypeDeprecated: Function as PropType<(type: ConfigType) => boolean>,
// Optional: provide template options from parent (to avoid circular dependency)
provideTemplateOptions: Function as PropType<(products: Product[]) => TemplateGroup[]>,
// Optional: handle template change (receives selected value and values, allows setting values.yaml)
onTemplateChange: Function as PropType<(value: string, values: DeviceValues) => void>,
// Optional: default template to select when opening modal for new devices
defaultTemplate: String,
// Optional: callback after configuration is loaded (receives values)
onConfigurationLoaded: Function as PropType<(values: DeviceValues) => void>,
// Optional: external template selection control (for parent to reset template)
externalTemplate: String as PropType<string | null>,
// Optional: hide template fields, e.g. because ocpp step was not completed
hideTemplateFields: { type: Boolean, default: false },
// Optional: keep modal open after a remove (singletons want to re-enter create mode)
keepOpenOnRemove: { type: Boolean, default: false },
// Optional: hide the bottom-right delete button (e.g. when delete is offered inline)
hideDelete: { type: Boolean, default: false },
// Optional: hide the info button in the header (e.g. for singleton devices like hems)
hideInfo: { type: Boolean, default: false },
},
emits: [
"added",
"updated",
"removed",
"open",
"close",
"template-changed",
"update:externalTemplate",
"reset",
],
data() {
return {
isModalVisible: false,
products: [] as Product[],
templateName: null as string | null,
template: null as Template | null,
saving: false,
auth: initialAuthState(),
succeeded: false,
loadingTemplate: false,
values: { ...this.initialValues } as DeviceValues,
test: initialTestState(),
serviceValues: {} as Record<string, string[]>,
serviceValuesTimer: null as Timeout | null,
adminPasswordValue: "",
adminPasswordRequired: false,
adminPasswordInvalid: false,
};
},
computed: {
device() {
return createDeviceUtils(this.deviceType);
},
modalSize(): string | undefined {
return this.showYamlInput ? "xl" : undefined;
},
computedTemplateOptions() {
if (this.provideTemplateOptions) {
return this.provideTemplateOptions(this.products);
}
return [];
},
templateParams() {
const params = this.template?.Params || [];
const filtered = params.filter(
(p) =>
!this.customFields.includes(p.Name) &&
(p.Usages ? p.Usages.includes(this.deviceType as any) : true)
);
// Allow parent to customize parameter filtering (passes all params for full control)
if (this.filterTemplateParams) {
return this.filterTemplateParams(params);
}
return filtered;
},
authParams() {
const { params = [] } = this.template?.Auth ?? {};
return this.templateParams.filter((p) => params.includes(p.Name));
},
authNormalParams() {
return this.authParams.filter((p: TemplateParam) => !p.Advanced && !p.Deprecated);
},
authAdvancedParams() {
return this.authParams.filter((p: TemplateParam) => p.Advanced || p.Deprecated);
},
normalParams() {
return this.templateParams.filter((p) => !p.Advanced && !p.Deprecated);
},
advancedParams() {
return this.templateParams.filter((p) => p.Advanced || p.Deprecated);
},
visibleParams() {
return this.authRequired ? this.authParams : this.templateParams;
},
modbus(): ModbusParam | undefined {
const params = this.template?.Params || [];
return (params as ModbusParam[]).find((p) => p.Name === "modbus");
},
modbusCapabilities() {
return (this.modbus?.Choice || []) as ModbusCapability[];
},
modbusDefaults() {
return {
id: this.modbus?.ID,
comset: this.modbus?.Comset,
baudrate: this.modbus?.Baudrate,
port: this.modbus?.Port,
};
},
description() {
return this.template?.Requirements?.Description;
},
productName(): string {
if (this.getProductName) {
return this.getProductName(this.values, this.templateName);
}
return this.values.deviceProduct || this.templateName || "";
},
sponsorTokenRequired() {
const requirements = this.template?.Requirements as any;
return requirements?.EVCC?.includes("sponsorship") && !this.isSponsor;
},
apiData(): ApiData {
let data: ApiData = {
...this.modbusDefaults,
...this.values,
};
if (this.values.type === ConfigType.Template && this.templateName) {
data["template"] = this.templateName;
} else {
// Remove template field if not using template type or if no template selected
delete data["template"];
}
if (this.showYamlInput) {
// Icon is extracted from yaml on GET for UI purpose only. Don't write it back.
delete data["icon"];
}
// Remove modbus field if current template doesn't have modbus parameter
if (!this.modbus) {
delete data["modbus"];
}
// Allow parent to transform API data
if (this.transformApiData) {
data = this.transformApiData(data, this.values);
}
return data;
},
isNew() {
return this.id === undefined;
},
isDeletable() {
return !this.isNew && !this.hideDelete;
},
showActions() {
// explicitly hide template fields (ocpp step 1)
if (this.hideTemplateFields) {
return false;
}
// yaml input type
if (this.showYamlInput) {
return true;
}
// template selected and no auth prerequisit
if (this.templateName && !this.authRequired) {
return true;
}
return false;
},
showYamlInput() {
return this.isYamlInputTypeByValue(this.values.type);
},
showTemplateSelector() {
return this.computedTemplateOptions.length > 0;
},
showDeprecatedWarning() {
return this.isTypeDeprecated && this.isTypeDeprecated(this.values.type);
},
authRequired() {
return this.template?.Auth && !this.auth.ok;
},
authValuesMissing() {
const authParamNames: string[] = this.template?.Auth?.params ?? [];
return (
authParamNames.length > 0 &&
this.templateParams
.filter((p: TemplateParam) => authParamNames.includes(p.Name) && p.Required)
.some((p: TemplateParam) => !this.values[p.Name])
);
},
authValues() {
const params = this.template?.Auth?.params ?? [];
return params.reduce(
(acc, param) => {
acc[param] = this.values[param];
return acc;
},
{} as Record<string, any>
);
},
},
watch: {
isModalVisible(visible) {
if (visible) {
this.templateName =
this.isNew && this.defaultTemplate ? this.defaultTemplate : null;
this.reset();
this.test = initialTestState();
this.succeeded = false;
this.loadProducts();
if (this.id !== undefined) {
this.loadConfiguration();
} else {
// For new devices, apply defaults immediately (e.g., default icons based on meter type)
this.applyDefaults();
}
}
},
id(newVal, oldVal) {
if (!this.isModalVisible) return;
// id arrived after open (async lookup) → load existing device
if (newVal !== undefined && oldVal === undefined) {
this.loadConfiguration();
return;
}
// id removed while modal stays open → reset to create mode
if (newVal === undefined && oldVal !== undefined) {
this.reset();
this.templateName = null;
this.succeeded = false;
}
},
templateName(newValue, oldValue) {
// Sync back to parent if using externalTemplate
if (this.externalTemplate !== undefined && newValue !== this.externalTemplate) {
this.$emit("update:externalTemplate", newValue);
}
// Reset values when template changes (except on initial load or when switching to YAML input)
// YAML input types set values.type and values.yaml in handleTemplateChange callback
if (oldValue != null) {
if (this.preserveOnTemplateChange) {
const preserved: Record<string, any> = {};
this.preserveOnTemplateChange.forEach((field) => {
preserved[field] = this.values[field];
});
this.reset();
this.preserveOnTemplateChange.forEach((field) => {
this.values[field] = preserved[field];
});
} else {
this.reset();
}
}
const isYamlInput = this.isYamlInputTypeByValue(newValue as ConfigType);
if (isYamlInput) {
this.template = null;
} else {
this.loadTemplate();
}
this.updateServiceValues();
},
usage() {
// Reload products when usage changes (e.g., meter type selection)
this.loadProducts();
// Apply defaults when usage changes (e.g., set default icon for meter type)
this.applyDefaults();
},
externalTemplate(newValue) {
// Allow parent to control template selection
if (newValue !== this.templateName) {
this.templateName = newValue;
}
},
showMainContent(visible) {
// When main content becomes visible (e.g., meter type selected in MeterModal),
// apply defaults like icon based on type
if (visible) {
this.applyDefaults();
}
},
values: {
handler() {
// a prior test result no longer matches the edited config:
// revert "Save anyway" back to "Validate & save"
if (this.test.isError || this.test.isSuccess) {
this.test = initialTestState();
}
this.adminPasswordRequired = false;
this.adminPasswordInvalid = false;
this.updateServiceValues();
},
deep: true,
},
adminPasswordValue() {
this.adminPasswordInvalid = false;
},
authValues: {
handler() {
if (this.authRequired) {
this.resetAuthStatus();
}
},
deep: true,
},
authRequired() {
// update on auth state change
this.updateServiceValues();
},
serviceValues: {
handler(newValue, oldValue) {
// Apply defaults only for specific params whose service values changed
Object.keys(newValue).forEach((paramName) => {
if (!deepEqual(newValue[paramName], oldValue[paramName])) {
this.applyServiceDefault(paramName);
}
});
},
deep: true,
},
},
methods: {
reset() {
this.values = { ...this.initialValues } as DeviceValues;
this.test = initialTestState();
this.resetAuthStatus();
this.$emit("reset");
},
async loadConfiguration() {
try {
const device = await this.device.load(this.id!);
this.values = device.config;
// convert structure to flat list
// TODO: adjust GET response to match POST/PUT formats
this.values.type = device.type;
this.values.deviceProduct = device.deviceProduct;
if (device.deviceTitle !== undefined) {
this.values.deviceTitle = device.deviceTitle;
}
if (device.deviceIcon !== undefined) {
this.values.deviceIcon = device.deviceIcon;
}
this.applyDefaults();
this.templateName = this.values.template;
// Allow parent to handle post-load logic
if (this.onConfigurationLoaded) {
this.onConfigurationLoaded(this.values);
}
this.checkAuthStatus();
} catch (e) {
console.error(e);
}
},
applyDefaults() {
applyDefaultsFromTemplate(this.template, this.values);
// Allow parent to apply custom defaults
if (this.applyCustomDefaults) {
this.applyCustomDefaults(this.template, this.values);
}
},
async loadProducts() {
if (!this.isModalVisible) {
return;
}
try {
this.products = await this.device.loadProducts(this.$i18n?.locale, this.usage);
} catch (e) {
console.error(e);
}
},
async loadTemplate() {
this.template = null;
if (!this.templateName || this.showYamlInput) return;
this.loadingTemplate = true;
try {
this.template = await this.device.loadTemplate(
this.templateName,
this.$i18n?.locale
);
this.applyDefaults();
this.checkAuthStatus();
} catch (e) {
console.error(e);
}
this.loadingTemplate = false;
},
resetAuthStatus() {
this.auth = initialAuthState();
},
async checkAuthStatus() {
this.resetAuthStatus();
// no auth required
if (!this.template?.Auth) return;
// trigger browser validation
if (this.$refs["form"]) {
if (!reportValidityInModal(this.$refs["form"] as HTMLFormElement)) {
return;
}
}
// validate data
if (this.authValuesMissing) return;
const { type } = this.template.Auth;
// include the template name so the backend can resolve masked fields
const values = { ...this.authValues, template: this.templateName };
this.auth.loading = true;
const result = await this.device.checkAuth(type, values, this.id);
this.auth.loading = false;
if (result.success) {
// login already exists
this.auth.error = null;
this.auth.ok = true;
} else if (result.authId) {
await this.prepareAuthLogin(result.authId);
} else {
// something else failed
this.auth.error = result.error ?? "unknown error";
}
},
async prepareAuthLogin(authId: string) {
await prepareAuthLogin(this.auth, authId);
},
async create(force = false) {
if (this.test.isUnknown && !force) {
const success = await performTest(
this.test,
this.testDevice,
this.$refs["form"] as HTMLFormElement
);
if (!success) {
return;
}
}
// persist selected template product
if (this.template && this.$refs["templateSelect"]) {
this.values.deviceProduct = (this.$refs["templateSelect"] as any).getProductName();
}
this.saving = true;
try {
const res = await this.device.create(this.apiData, force, this.adminPasswordValue);
this.applyAdminPasswordState(res.status);
if (res.status === ADMIN_PASSWORD_REQUIRED) {
this.saving = false;
return;
}
this.saving = false;
this.succeeded = true;
await sleep(500);
this.$emit("added", res.data.name);
await closeModal({ action: "added", name: res.data.name });
} catch (e) {
this.saving = false;
handleError(e, "create failed");
}
},
async testManually() {
await performTest(this.test, this.testDevice, this.$refs["form"] as HTMLFormElement);
},
async testDevice() {
const res = await this.device.test(this.id, this.apiData, this.adminPasswordValue);
this.applyAdminPasswordState(res.status);
return res;
},
// reveal the admin password field when required, flag it invalid if a password was already sent
applyAdminPasswordState(status: number) {
this.adminPasswordRequired = status === ADMIN_PASSWORD_REQUIRED;
this.adminPasswordInvalid =
status === ADMIN_PASSWORD_REQUIRED && !!this.adminPasswordValue;
},
async update(force = false) {
if (this.test.isUnknown && !force) {
const success = await performTest(
this.test,
this.testDevice,
this.$refs["form"] as HTMLFormElement
);
if (!success) {
return;
}
}
this.saving = true;
try {
const res = await this.device.update(
this.id!,
this.apiData,
force,
this.adminPasswordValue
);
this.applyAdminPasswordState(res.status);
if (res.status === ADMIN_PASSWORD_REQUIRED) {
this.saving = false;
return;
}
this.saving = false;
this.succeeded = true;
await sleep(500);
this.$emit("updated");
await closeModal({ action: "updated" });
} catch (e) {
this.saving = false;
console.error("update failed", e);
handleError(e, "update failed");
}
},
async remove() {
try {
await this.device.remove(this.id!);
this.$emit("removed");
if (this.keepOpenOnRemove) {
return;
}
await closeModal({ action: "removed" });
} catch (e) {
handleError(e, "remove failed");
}
},
handleOpen() {
this.isModalVisible = true;
this.$emit("open");
this.adminPasswordRequired = false;
this.adminPasswordInvalid = false;
},
handleClose() {
this.$emit("close");
this.isModalVisible = false;
},
handleTemplateChange(value: string | null) {
// ensure this triggers after tempateName watcher
this.$nextTick(() => {
if (this.onTemplateChange && value !== null) {
this.onTemplateChange(value, this.values);
}
});
},
handleSave(force: boolean) {
if (this.isNew) {
this.create(force);
} else {
this.update(force);
}
},
handleRemove() {
this.remove();
},
handleVisibilityChange() {
this.checkAuthStatus();
},
isYamlInputTypeByValue(value: ConfigType): boolean {
if (this.isYamlInputType) {
return this.isYamlInputType(value);
}
return value === ConfigType.Custom;
},
async updateServiceValues() {
if (this.serviceValuesTimer) {
clearTimeout(this.serviceValuesTimer);
}
this.serviceValuesTimer = setTimeout(async () => {
// Fetch only visible params to prevent premature auth instance creation
this.serviceValues = await fetchServiceValues(this.visibleParams, {
...this.modbusDefaults,
...this.values,
});
}, 500);
},
applyServiceDefault(paramName: string) {
// Auto-apply single service value when field is empty and required
const values = this.serviceValues[paramName];
const param = this.templateParams.find((p) => p.Name === paramName);
// Only auto-apply if exactly one value is returned, field is empty, and field is required
if (values?.length === 1 && !this.values[paramName] && param?.Required) {
this.values[paramName] = values[0];
}
},
},
});
</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>