UI Config: add aux meters, improve reference handling (#19939)
Co-authored-by: andig <cpuidle@gmx.de>
This commit is contained in:
parent
7234be85a7
commit
2ea6c705d9
18 changed files with 571 additions and 234 deletions
|
|
@ -9,149 +9,167 @@
|
|||
>
|
||||
<div v-if="!meterType">
|
||||
<NewDeviceButton
|
||||
title="Add solar meter"
|
||||
v-for="t in typeChoices"
|
||||
:key="t"
|
||||
:title="$t(`config.meter.option.${t}`)"
|
||||
class="mb-4 addButton"
|
||||
@click="selectType('pv')"
|
||||
/>
|
||||
<NewDeviceButton
|
||||
title="Add battery meter"
|
||||
class="addButton"
|
||||
@click="selectType('battery')"
|
||||
@click="selectType(t)"
|
||||
/>
|
||||
</div>
|
||||
<form v-else ref="form" class="container mx-0 px-0">
|
||||
<FormRow
|
||||
v-if="hasDeviceTitle"
|
||||
id="meterParamDeviceTitle"
|
||||
label="Title"
|
||||
help="Will be displayed in the user interface"
|
||||
>
|
||||
<PropertyField
|
||||
<p v-if="['aux', 'ext'].includes(meterType)" class="mt-0 mb-4">
|
||||
{{ $t(`config.${meterType}.description`) }}
|
||||
</p>
|
||||
<div v-if="meterType === 'ext'" class="alert alert-warning mb-4" role="alert">
|
||||
<strong>Work in Progress:</strong> This feature is not yet available.
|
||||
</div>
|
||||
<div v-else>
|
||||
<FormRow
|
||||
v-if="hasDeviceTitle"
|
||||
id="meterParamDeviceTitle"
|
||||
v-model.trim="values.deviceTitle"
|
||||
type="String"
|
||||
size="w-100"
|
||||
class="me-2"
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="meterTemplate" :label="$t('config.meter.template')">
|
||||
<select
|
||||
v-if="isNew"
|
||||
id="meterTemplate"
|
||||
ref="templateSelect"
|
||||
v-model="templateName"
|
||||
class="form-select w-100"
|
||||
@change="templateChanged"
|
||||
label="Title"
|
||||
help="Will be displayed in the user interface"
|
||||
>
|
||||
<option
|
||||
v-for="option in genericOptions"
|
||||
:key="option.name"
|
||||
:value="option.template"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
<option v-if="genericOptions.length" disabled>──────────</option>
|
||||
<option
|
||||
v-for="option in templateOptions"
|
||||
:key="option.name"
|
||||
:value="option.template"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
:value="productName"
|
||||
disabled
|
||||
class="form-control w-100"
|
||||
/>
|
||||
</FormRow>
|
||||
<p v-if="loadingTemplate">Loading ...</p>
|
||||
<Markdown v-if="description" :markdown="description" class="my-4" />
|
||||
<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"
|
||||
:defaultId="modbus.ID"
|
||||
:defaultComset="modbus.Comset"
|
||||
:defaultBaudrate="modbus.Baudrate"
|
||||
:defaultPort="modbus.Port"
|
||||
:capabilities="modbusCapabilities"
|
||||
/>
|
||||
<PropertyEntry
|
||||
v-for="param in normalParams"
|
||||
:id="`meterParam${param.Name}`"
|
||||
:key="param.Name"
|
||||
v-bind="param"
|
||||
v-model="values[param.Name]"
|
||||
/>
|
||||
|
||||
<PropertyCollapsible>
|
||||
<template v-if="advancedParams.length" #advanced>
|
||||
<PropertyEntry
|
||||
v-for="param in advancedParams"
|
||||
:id="`meterParam${param.Name}`"
|
||||
:key="param.Name"
|
||||
v-bind="param"
|
||||
v-model="values[param.Name]"
|
||||
<PropertyField
|
||||
id="meterParamDeviceTitle"
|
||||
v-model.trim="values.deviceTitle"
|
||||
type="String"
|
||||
size="w-100"
|
||||
class="me-2"
|
||||
required
|
||||
/>
|
||||
</template>
|
||||
</PropertyCollapsible>
|
||||
</FormRow>
|
||||
<FormRow v-if="hasDeviceIcon" id="meterParamDeviceIcon" label="Icon">
|
||||
<PropertyField
|
||||
id="meterParamDeviceIcon"
|
||||
v-model="values.deviceIcon"
|
||||
:choice="iconChoices"
|
||||
property="icon"
|
||||
type="String"
|
||||
class="me-2"
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="meterTemplate" :label="$t('config.meter.template')">
|
||||
<select
|
||||
v-if="isNew"
|
||||
id="meterTemplate"
|
||||
ref="templateSelect"
|
||||
v-model="templateName"
|
||||
class="form-select w-100"
|
||||
@change="templateChanged"
|
||||
>
|
||||
<option
|
||||
v-for="option in genericOptions"
|
||||
:key="option.name"
|
||||
:value="option.template"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
<option v-if="genericOptions.length" disabled>──────────</option>
|
||||
<option
|
||||
v-for="option in templateOptions"
|
||||
:key="option.name"
|
||||
:value="option.template"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
:value="productName"
|
||||
disabled
|
||||
class="form-control w-100"
|
||||
/>
|
||||
</FormRow>
|
||||
<p v-if="loadingTemplate">Loading ...</p>
|
||||
<Markdown v-if="description" :markdown="description" class="my-4" />
|
||||
<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"
|
||||
:defaultId="modbus.ID"
|
||||
:defaultComset="modbus.Comset"
|
||||
:defaultBaudrate="modbus.Baudrate"
|
||||
:defaultPort="modbus.Port"
|
||||
:capabilities="modbusCapabilities"
|
||||
/>
|
||||
<PropertyEntry
|
||||
v-for="param in normalParams"
|
||||
:id="`meterParam${param.Name}`"
|
||||
:key="param.Name"
|
||||
v-bind="param"
|
||||
v-model="values[param.Name]"
|
||||
/>
|
||||
|
||||
<TestResult
|
||||
v-if="templateName"
|
||||
:success="testSuccess"
|
||||
:failed="testFailed"
|
||||
:unknown="testUnknown"
|
||||
:running="testRunning"
|
||||
:result="testResult"
|
||||
:error="testError"
|
||||
@test="testManually"
|
||||
/>
|
||||
<PropertyCollapsible>
|
||||
<template v-if="advancedParams.length" #advanced>
|
||||
<PropertyEntry
|
||||
v-for="param in advancedParams"
|
||||
:id="`meterParam${param.Name}`"
|
||||
:key="param.Name"
|
||||
v-bind="param"
|
||||
v-model="values[param.Name]"
|
||||
/>
|
||||
</template>
|
||||
</PropertyCollapsible>
|
||||
|
||||
<div v-if="templateName" class="my-4 d-flex justify-content-between">
|
||||
<button
|
||||
v-if="isDeletable"
|
||||
type="button"
|
||||
class="btn btn-link text-danger"
|
||||
tabindex="0"
|
||||
@click.prevent="remove"
|
||||
>
|
||||
{{ $t("config.general.delete") }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-link text-muted"
|
||||
data-bs-dismiss="modal"
|
||||
tabindex="0"
|
||||
>
|
||||
{{ $t("config.general.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="testRunning || saving"
|
||||
tabindex="0"
|
||||
@click.prevent="isNew ? create() : update()"
|
||||
>
|
||||
<span
|
||||
v-if="saving"
|
||||
class="spinner-border spinner-border-sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{
|
||||
testUnknown ? $t("config.general.validateSave") : $t("config.general.save")
|
||||
}}
|
||||
</button>
|
||||
<TestResult
|
||||
v-if="templateName"
|
||||
:success="testSuccess"
|
||||
:failed="testFailed"
|
||||
:unknown="testUnknown"
|
||||
:running="testRunning"
|
||||
:result="testResult"
|
||||
:error="testError"
|
||||
@test="testManually"
|
||||
/>
|
||||
|
||||
<div v-if="templateName" class="my-4 d-flex justify-content-between">
|
||||
<button
|
||||
v-if="isDeletable"
|
||||
type="button"
|
||||
class="btn btn-link text-danger"
|
||||
tabindex="0"
|
||||
@click.prevent="remove"
|
||||
>
|
||||
{{ $t("config.general.delete") }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-link text-muted"
|
||||
data-bs-dismiss="modal"
|
||||
tabindex="0"
|
||||
>
|
||||
{{ $t("config.general.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="testRunning || saving"
|
||||
tabindex="0"
|
||||
@click.prevent="isNew ? create() : update()"
|
||||
>
|
||||
<span
|
||||
v-if="saving"
|
||||
class="spinner-border spinner-border-sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{
|
||||
testUnknown
|
||||
? $t("config.general.validateSave")
|
||||
: $t("config.general.save")
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</GenericModal>
|
||||
|
|
@ -170,6 +188,7 @@ import GenericModal from "../Helper/GenericModal.vue";
|
|||
import Markdown from "./Markdown.vue";
|
||||
import PropertyField from "./PropertyField.vue";
|
||||
const initialValues = { type: "template", deviceTitle: "", deviceIcon: "" };
|
||||
import { ICONS } from "../VehicleIcon/VehicleIcon.vue";
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
|
@ -177,6 +196,11 @@ function sleep(ms) {
|
|||
|
||||
const CUSTOM_FIELDS = ["usage", "modbus"];
|
||||
|
||||
const defaultIcons = {
|
||||
aux: "aux",
|
||||
ext: "meter",
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "MeterModal",
|
||||
components: {
|
||||
|
|
@ -195,6 +219,10 @@ export default {
|
|||
id: Number,
|
||||
name: String,
|
||||
type: String,
|
||||
typeChoices: {
|
||||
type: Array,
|
||||
default: () => ["pv", "battery", "aux", "ext"],
|
||||
},
|
||||
fade: String,
|
||||
},
|
||||
emits: ["added", "updated", "removed", "close"],
|
||||
|
|
@ -208,6 +236,7 @@ export default {
|
|||
saving: false,
|
||||
selectedType: null,
|
||||
loadingTemplate: false,
|
||||
iconChoices: ICONS,
|
||||
values: { ...initialValues },
|
||||
};
|
||||
},
|
||||
|
|
@ -226,7 +255,10 @@ export default {
|
|||
return this.type || this.selectedType;
|
||||
},
|
||||
hasDeviceTitle() {
|
||||
return ["pv", "battery"].includes(this.meterType);
|
||||
return ["pv", "battery", "aux", "ext"].includes(this.meterType);
|
||||
},
|
||||
hasDeviceIcon() {
|
||||
return ["aux", "ext"].includes(this.meterType);
|
||||
},
|
||||
templateOptions() {
|
||||
return this.products.filter((p) => p.group !== "generic");
|
||||
|
|
@ -299,8 +331,9 @@ export default {
|
|||
}
|
||||
}
|
||||
},
|
||||
meterType() {
|
||||
meterType(type) {
|
||||
this.loadProducts();
|
||||
this.values.deviceIcon = defaultIcons[type] || "";
|
||||
},
|
||||
templateName() {
|
||||
this.loadTemplate();
|
||||
|
|
@ -314,7 +347,9 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
reset(keepTitle = false) {
|
||||
const keep = keepTitle ? { deviceTitle: this.values.deviceTitle } : {};
|
||||
const keep = keepTitle
|
||||
? { deviceTitle: this.values.deviceTitle, deviceIcon: this.values.deviceIcon }
|
||||
: {};
|
||||
this.values = { ...initialValues, ...keep };
|
||||
this.resetTest();
|
||||
},
|
||||
|
|
|
|||
14
assets/js/components/VehicleIcon/Aux.vue
Normal file
14
assets/js/components/VehicleIcon/Aux.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<svg width="1em" height="1em" viewBox="0 0 32 32">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12,27.933l0,-2c-2.356,-0.822 -4.278,-2.261 -5.767,-4.316c-1.489,-2.055 -2.233,-4.394 -2.233,-7.017c0,-1.667 0.317,-3.228 0.951,-4.683c0.633,-1.455 1.489,-2.721 2.565,-3.8c1.076,-1.078 2.343,-1.933 3.8,-2.566c1.457,-0.633 3.007,-0.95 4.651,-0.951c1.643,-0.001 3.199,0.316 4.666,0.951c1.468,0.634 2.746,1.49 3.834,2.566c1.088,1.077 1.949,2.343 2.584,3.8c0.634,1.457 0.951,3.018 0.949,4.683c-0,2.622 -0.75,4.95 -2.249,6.984c-1.5,2.034 -3.417,3.472 -5.751,4.316l-0,2.033c-0,0.378 -0.128,0.695 -0.384,0.951c-0.256,0.256 -0.572,0.384 -0.949,0.383c-0.377,-0.001 -0.694,-0.129 -0.95,-0.383c-0.256,-0.254 -0.384,-0.571 -0.384,-0.951l0,-1.4c-0.222,0.045 -0.444,0.067 -0.666,0.067l-0.7,-0c-0.223,-0 -0.439,-0.005 -0.651,-0.016c-0.212,-0.011 -0.428,-0.028 -0.649,-0.051l-0,1.4c-0,0.378 -0.128,0.695 -0.384,0.951c-0.256,0.256 -0.573,0.384 -0.95,0.383c-0.377,-0.001 -0.693,-0.129 -0.949,-0.383c-0.256,-0.254 -0.384,-0.571 -0.384,-0.951Zm4,-3.933c2.578,-0 4.778,-0.911 6.6,-2.733c1.822,-1.823 2.733,-4.023 2.733,-6.6c0,-2.578 -0.911,-4.778 -2.733,-6.6c-1.822,-1.823 -4.022,-2.734 -6.6,-2.734c-2.578,0 -4.778,0.911 -6.6,2.734c-1.822,1.822 -2.733,4.022 -2.733,6.6c-0,2.577 0.911,4.777 2.733,6.6c1.822,1.822 4.022,2.733 6.6,2.733Zm-4,-12c-0.376,0.002 -0.692,-0.126 -0.949,-0.383c-0.257,-0.257 -0.385,-0.573 -0.384,-0.95c0.001,-0.377 0.129,-0.694 0.384,-0.95c0.255,-0.256 0.571,-0.384 0.949,-0.384l8,0c0.378,0 0.694,0.128 0.949,0.384c0.255,0.256 0.383,0.573 0.384,0.95c0.001,0.377 -0.126,0.693 -0.382,0.949c-0.256,0.256 -0.573,0.384 -0.951,0.384l-8,-0Zm6.833,5.5l-1.833,-0.833l1.833,-0.834l0.834,-1.833l0.833,1.833l1.833,0.834l-1.833,0.833l-0.833,1.833l-0.834,-1.833Zm-6.433,1.767l-2.733,-1.267l2.733,-1.267l1.267,-2.733l1.266,2.733l2.734,1.267l-2.734,1.267l-1.266,2.733l-1.267,-2.733Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Aux",
|
||||
};
|
||||
</script>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<svg width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9 21.95v-2.5q-2.65-.925-4.325-3.237T3 10.95q0-1.875.713-3.512t1.924-2.85t2.85-1.925t3.488-.713t3.5.713t2.875 1.925t1.938 2.85T21 10.95q0 2.95-1.687 5.238T15 19.425v2.525h-2V19.9q-.25.05-.5.05h-.525q-.25 0-.488-.012T11 19.9v2.05zM12 18q2.9 0 4.95-2.05T19 11t-2.05-4.95T12 4T7.05 6.05T5 11t2.05 4.95T12 18M8 9h8V7H8zm3.25 8l3-3L13 12.75l1.25-1.25l-1.5-1.5l-3 3L11 14.25L9.75 15.5zm.75-6"
|
||||
d="M9 20.95v-1.5q-2.65-.925-4.325-3.237T3 10.95q0-1.875.713-3.512t1.924-2.85t2.85-1.925t3.488-.713t3.5.713t2.875 1.925t1.938 2.85T21 10.95q0 2.95-1.687 5.238T15 19.425v1.525q0 .425-.288.713T14 21.95t-.712-.287T13 20.95V19.9q-.25.05-.5.05h-.525q-.25 0-.488-.012T11 19.9v1.05q0 .425-.288.713T10 21.95t-.712-.287T9 20.95M12 18q2.9 0 4.95-2.05T19 11t-2.05-4.95T12 4T7.05 6.05T5 11t2.05 4.95T12 18M9 9h6q.425 0 .713-.288T16 8t-.288-.712T15 7H9q-.425 0-.712.288T8 8t.288.713T9 9m2 5.25l-.5.5q-.325.325-.325.75t.325.75t.75.325t.75-.325l1.55-1.55q.3-.3.3-.7t-.3-.7l-.55-.55l.5-.5q.325-.325.325-.75t-.325-.75t-.75-.325t-.75.325l-1.55 1.55q-.3.3-.3.7t.3.7zM12 11"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import VehicleIcon from "./VehicleIcon.vue";
|
|||
|
||||
const icons = [
|
||||
"car",
|
||||
"aux",
|
||||
"bike",
|
||||
"bus",
|
||||
"moped",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import "@h2d2/shopicons/es/regular/car3";
|
|||
import MultiIcon from "../MultiIcon";
|
||||
|
||||
import airpurifier from "./Airpurifier.vue";
|
||||
import aux from "./Aux.vue";
|
||||
import battery from "./Battery.vue";
|
||||
import bike from "./Bike.vue";
|
||||
import bulb from "./Bulb.vue";
|
||||
|
|
@ -47,6 +48,7 @@ import waterheater from "./WaterHeater.vue";
|
|||
|
||||
const icons = {
|
||||
airpurifier,
|
||||
aux,
|
||||
battery,
|
||||
bike,
|
||||
bulb,
|
||||
|
|
@ -86,6 +88,8 @@ const icons = {
|
|||
waterheater,
|
||||
};
|
||||
|
||||
export const ICONS = Object.keys(icons);
|
||||
|
||||
export default {
|
||||
name: "VehicleIcon",
|
||||
components: { MultiIcon },
|
||||
|
|
|
|||
|
|
@ -17,10 +17,8 @@
|
|||
</p>
|
||||
<p class="mb-1"><strong>Missing features</strong></p>
|
||||
<ul>
|
||||
<li>aux meters</li>
|
||||
<li>custom/plugin meters and vehicles</li>
|
||||
<li>migration for vehicles, chargers, meters, loadpoints</li>
|
||||
<li>remove mixed mode (evcc.yaml + db) for meters and vehicles</li>
|
||||
<li>migration for loadpoints</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Migration and repair.</strong> Run <code>evcc migrate</code> to copy
|
||||
|
|
@ -178,7 +176,32 @@
|
|||
</DeviceCard>
|
||||
<NewDeviceButton
|
||||
:title="$t('config.main.addPvBattery')"
|
||||
@click="newMeter"
|
||||
@click="addSolarBatteryMeter"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<h2 class="my-4 mt-5">{{ $t("config.section.additionalMeter") }} 🧪</h2>
|
||||
<ul class="p-0 config-list">
|
||||
<DeviceCard
|
||||
v-for="meter in auxMeters"
|
||||
:key="meter.name"
|
||||
:title="meter.deviceTitle || meter.config?.template || 'Aux meter'"
|
||||
:name="meter.name"
|
||||
:editable="!!meter.id"
|
||||
:error="deviceError('meter', meter.name)"
|
||||
data-testid="aux"
|
||||
@edit="editMeter(meter.id, 'aux')"
|
||||
>
|
||||
<template #icon>
|
||||
<VehicleIcon :name="meter.deviceIcon || 'aux'" />
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('meter', meter.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<NewDeviceButton
|
||||
:title="$t('config.main.addAdditional')"
|
||||
@click="newAdditionalMeter"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
|
|
@ -320,6 +343,7 @@
|
|||
:id="selectedMeterId"
|
||||
:name="selectedMeterName"
|
||||
:type="selectedMeterType"
|
||||
:typeChoices="selectedMeterTypeChoices"
|
||||
:fade="loadpointSubModalOpen ? 'right' : ''"
|
||||
@added="addMeter"
|
||||
@updated="meterChanged"
|
||||
|
|
@ -441,6 +465,7 @@ export default {
|
|||
selectedVehicleId: undefined,
|
||||
selectedMeterId: undefined,
|
||||
selectedMeterType: undefined,
|
||||
selectedMeterTypeChoices: [],
|
||||
selectedChargerId: undefined,
|
||||
selectedLoadpointId: undefined,
|
||||
loadpointSubModalOpen: false,
|
||||
|
|
@ -470,6 +495,14 @@ export default {
|
|||
const names = this.site?.battery;
|
||||
return this.getMetersByNames(names);
|
||||
},
|
||||
auxMeters() {
|
||||
const names = this.site?.aux;
|
||||
return this.getMetersByNames(names);
|
||||
},
|
||||
extMeters() {
|
||||
const names = this.site?.ext;
|
||||
return this.getMetersByNames(names);
|
||||
},
|
||||
selectedMeterName() {
|
||||
return this.getMeterById(this.selectedMeterId)?.name;
|
||||
},
|
||||
|
|
@ -675,6 +708,18 @@ export default {
|
|||
this.selectedMeterType = type;
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
},
|
||||
addSolarBatteryMeter() {
|
||||
this.selectedMeterId = undefined;
|
||||
this.selectedMeterType = undefined;
|
||||
this.selectedMeterTypeChoices = ["pv", "battery"];
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
},
|
||||
newAdditionalMeter() {
|
||||
this.selectedMeterId = undefined;
|
||||
this.selectedMeterType = undefined;
|
||||
this.selectedMeterTypeChoices = ["aux", "ext"];
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
},
|
||||
editCharger(id) {
|
||||
this.selectedChargerId = id;
|
||||
this.$nextTick(() => this.chargerModal().show());
|
||||
|
|
@ -746,18 +791,14 @@ export default {
|
|||
this.saveSite(type);
|
||||
}
|
||||
},
|
||||
removeMeter(type, name) {
|
||||
removeMeter(type) {
|
||||
if (type === "charge") {
|
||||
// update loadpoint
|
||||
this.$refs.loadpointModal?.setMeter(undefined);
|
||||
} else if (type === "grid") {
|
||||
// update site grid
|
||||
this.site.grid = "";
|
||||
this.saveSite(type);
|
||||
} else {
|
||||
// update site pv, battery, aux
|
||||
this.site[type] = this.site[type].filter((i) => i !== name);
|
||||
this.saveSite(type);
|
||||
// update site grid, pv, battery, aux, ext
|
||||
this.loadSite();
|
||||
this.loadDirty();
|
||||
}
|
||||
},
|
||||
addCharger(name) {
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ func runRoot(cmd *cobra.Command, args []string) {
|
|||
auth.Disable()
|
||||
}
|
||||
|
||||
httpd.RegisterSystemHandler(valueChan, cache, auth, func() {
|
||||
httpd.RegisterSystemHandler(site, valueChan, cache, auth, func() {
|
||||
log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
|
||||
err = errors.New("restart required") // https://gokrazy.org/development/process-interface/
|
||||
once.Do(func() { close(stopC) }) // signal loop to end
|
||||
|
|
|
|||
16
cmd/setup.go
16
cmd/setup.go
|
|
@ -263,14 +263,14 @@ func configureMeters(static []config.Named, names ...string) error {
|
|||
|
||||
instance, err := meter.NewFromConfig(ctx, cc.Type, cc.Other)
|
||||
if err != nil {
|
||||
return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
|
||||
err = &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
|
||||
}
|
||||
|
||||
if err := config.Meters().Add(config.NewConfigurableDevice(&conf, instance)); err != nil {
|
||||
return &DeviceError{cc.Name, err}
|
||||
if e := config.Meters().Add(config.NewConfigurableDevice(&conf, instance)); e != nil && err == nil {
|
||||
err = &DeviceError{cc.Name, e}
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -327,14 +327,14 @@ func configureChargers(static []config.Named, names ...string) error {
|
|||
|
||||
instance, err := charger.NewFromConfig(ctx, cc.Type, cc.Other)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
|
||||
err = &DeviceError{cc.Name, fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)}
|
||||
}
|
||||
|
||||
if err := config.Chargers().Add(config.NewConfigurableDevice(&conf, instance)); err != nil {
|
||||
return &DeviceError{cc.Name, err}
|
||||
if e := config.Chargers().Add(config.NewConfigurableDevice(&conf, instance)); e != nil && err == nil {
|
||||
err = &DeviceError{cc.Name, e}
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ type API interface {
|
|||
SetPVMeterRefs([]string)
|
||||
GetBatteryMeterRefs() []string
|
||||
SetBatteryMeterRefs([]string)
|
||||
GetAuxMeterRefs() []string
|
||||
SetAuxMeterRefs([]string)
|
||||
GetExtMeterRefs() []string
|
||||
SetExtMeterRefs([]string)
|
||||
|
||||
// circuits
|
||||
GetCircuit() api.Circuit
|
||||
|
|
|
|||
|
|
@ -116,6 +116,22 @@ func (site *Site) SetAuxMeterRefs(ref []string) {
|
|||
settings.SetString(keys.AuxMeters, strings.Join(filterConfigurable(ref), ","))
|
||||
}
|
||||
|
||||
// GetExtMeterRefs returns the ExtMeterRef
|
||||
func (site *Site) GetExtMeterRefs() []string {
|
||||
site.RLock()
|
||||
defer site.RUnlock()
|
||||
return site.Meters.ExtMetersRef
|
||||
}
|
||||
|
||||
// SetExtMeterRefs sets the ExtMeterRef
|
||||
func (site *Site) SetExtMeterRefs(ref []string) {
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
|
||||
site.Meters.ExtMetersRef = ref
|
||||
settings.SetString(keys.ExtMeters, strings.Join(filterConfigurable(ref), ","))
|
||||
}
|
||||
|
||||
// Loadpoints returns the loadpoints as api interfaces
|
||||
func (site *Site) Loadpoints() []loadpoint.API {
|
||||
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API { return lp })
|
||||
|
|
|
|||
21
i18n/de.toml
21
i18n/de.toml
|
|
@ -23,6 +23,11 @@ never = "nur mit genug PV-Überschuss."
|
|||
|
||||
[config]
|
||||
|
||||
[config.aux]
|
||||
description = "Gerät, das seinen Verbrauch basierend auf verfügbarem Überschuss (z. B. smarter Heizstab) selbstständig anpasst. evcc erwartet, dass dieses Gerät selbstständig seine Leistungsaufnahme reduziert, wenn es benötigt wird."
|
||||
titleAdd = "Intelligenten Verbraucher hinzufügen"
|
||||
titleEdit = "Intelligenten Verbraucher bearbeiten"
|
||||
|
||||
[config.battery]
|
||||
titleAdd = "Batterie hinzufügen"
|
||||
titleEdit = "Batterie bearbeiten"
|
||||
|
|
@ -105,6 +110,11 @@ C = "C (lädt)"
|
|||
description = "Grundkonfiguration für die Kommunikation mit anderen EEBus-Geräten."
|
||||
title = "EEBus"
|
||||
|
||||
[config.ext]
|
||||
description = "Kann für Lastmanagement oder Statistikzwecke verwendet werden."
|
||||
titleAdd = "Zusätzlichen Zähler hinzufügen"
|
||||
titleEdit = "Zusätzlichen Zähler bearbeiten"
|
||||
|
||||
[config.form]
|
||||
deprecated = "veraltet"
|
||||
example = "Beispiel"
|
||||
|
|
@ -252,6 +262,12 @@ template = "Hersteller"
|
|||
titleChoice = "Was möchtest du hinzufügen?"
|
||||
validateSave = "Überprüfen & speichern"
|
||||
|
||||
[config.meter.option]
|
||||
aux = "Intelligenten Verbraucher hinzufügen"
|
||||
battery = "Hausbatterie hinzufügen"
|
||||
ext = "Zusätzlichen Zähler hinzufügen"
|
||||
pv = "PV-Anlage hinzufügen"
|
||||
|
||||
[config.modbusproxy]
|
||||
description = "Erlaubt mehreren Clients den Zugriff auf ein einzelnes Modbus-Gerät."
|
||||
title = "Modbus-Proxy"
|
||||
|
|
@ -304,10 +320,11 @@ B = "B (verbunden)"
|
|||
C = "C (lädt)"
|
||||
|
||||
[config.pv]
|
||||
titleAdd = "Zähler (PV) hinzufügen"
|
||||
titleEdit = "Zähler (PV) bearbeiten"
|
||||
titleAdd = "PV-Anlage hinzufügen"
|
||||
titleEdit = "PV-Anlage bearbeiten"
|
||||
|
||||
[config.section]
|
||||
additionalMeter = "Zusätzliche Zähler"
|
||||
general = "Allgemein"
|
||||
grid = "Netzanschluss"
|
||||
integrations = "Integrationen"
|
||||
|
|
|
|||
18
i18n/en.toml
18
i18n/en.toml
|
|
@ -23,6 +23,11 @@ never = "only with enough surplus."
|
|||
|
||||
[config]
|
||||
|
||||
[config.aux]
|
||||
description = "Device that adjusts its consumption based on available surplus (like smart water heaters). evcc expects that this devices reduces its power consumption if needed."
|
||||
titleAdd = "Add Self-Regulating Consumer"
|
||||
titleEdit = "Edit Self-Regulating Consumer"
|
||||
|
||||
[config.battery]
|
||||
titleAdd = "Add Battery"
|
||||
titleEdit = "Edit Battery"
|
||||
|
|
@ -105,6 +110,11 @@ C = "C (charging)"
|
|||
description = "Configuration that enables evcc to communicate with other EEBus devices."
|
||||
title = "EEBus"
|
||||
|
||||
[config.ext]
|
||||
description = "Can be used for load management or statistics purposes."
|
||||
titleAdd = "Add External Meter"
|
||||
titleEdit = "Edit External Meter"
|
||||
|
||||
[config.form]
|
||||
deprecated = "deprecated"
|
||||
example = "Example"
|
||||
|
|
@ -227,6 +237,7 @@ vehicleLabel = "Default vehicle"
|
|||
vehiclesTitle = "Vehicles"
|
||||
|
||||
[config.main]
|
||||
addAdditional = "Add additional meter"
|
||||
addGrid = "Add grid meter"
|
||||
addLoadpoint = "Add charge point"
|
||||
addPvBattery = "Add solar or battery"
|
||||
|
|
@ -252,6 +263,12 @@ template = "Manufacturer"
|
|||
titleChoice = "What Do You Want To Add?"
|
||||
validateSave = "Validate & save"
|
||||
|
||||
[config.meter.option]
|
||||
aux = "Add self-regulating consumer"
|
||||
battery = "Add battery meter"
|
||||
ext = "Add external meter"
|
||||
pv = "Add solar meter"
|
||||
|
||||
[config.modbusproxy]
|
||||
description = "Allow multiple clients to access a single Modbus device."
|
||||
title = "Modbus Proxy"
|
||||
|
|
@ -307,6 +324,7 @@ titleAdd = "Add Solar Meter"
|
|||
titleEdit = "Edit Solar Meter"
|
||||
|
||||
[config.section]
|
||||
additionalMeter = "Additional meters"
|
||||
general = "General"
|
||||
grid = "Grid"
|
||||
integrations = "Integrations"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
eapi "github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/api/globalconfig"
|
||||
"github.com/evcc-io/evcc/core"
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/server/assets"
|
||||
|
|
@ -123,19 +124,6 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
|
|||
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
|
||||
// config ui (secured)
|
||||
configApi := api.PathPrefix("/config").Subrouter()
|
||||
|
||||
// TODO clarify location of site config
|
||||
configRoutes := map[string]route{
|
||||
"site": {"GET", "/site", siteHandler(site)},
|
||||
"updatesite": {"PUT", "/site", updateSiteHandler(site)},
|
||||
}
|
||||
|
||||
for _, r := range configRoutes {
|
||||
configApi.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
|
||||
// vehicle api
|
||||
vehicles := map[string]route{
|
||||
"minsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/minsoc/{value:[0-9]+}", minSocHandler(site)},
|
||||
|
|
@ -193,7 +181,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
|
|||
}
|
||||
|
||||
// RegisterSystemHandler provides system level handlers
|
||||
func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.ParamCache, auth auth.Auth, shutdown func()) {
|
||||
func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Param, cache *util.ParamCache, auth auth.Auth, shutdown func()) {
|
||||
router := s.Server.Handler.(*mux.Router)
|
||||
|
||||
// api
|
||||
|
|
@ -204,6 +192,17 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
|
|||
handlers.AllowedHeaders([]string{"Content-Type"}),
|
||||
))
|
||||
|
||||
if site == nil {
|
||||
// If site is nil, create a new empty site. Settings will be loaded during this process and
|
||||
// site meter references and title can be updated using APIs.
|
||||
var err error
|
||||
site, err = core.NewSiteFromConfig(nil)
|
||||
if err != nil {
|
||||
// should not happen
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
{ // /api
|
||||
routes := map[string]route{
|
||||
"state": {"GET", "/state", stateHandler(cache)},
|
||||
|
|
@ -243,7 +242,7 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
|
|||
"dirty": {"GET", "/dirty", getHandler(ConfigDirty)},
|
||||
"newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler},
|
||||
"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
|
||||
"deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
|
||||
"deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)},
|
||||
"testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler},
|
||||
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
|
||||
"interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval)},
|
||||
|
|
@ -281,6 +280,14 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
|
|||
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
|
||||
// site
|
||||
for _, r := range map[string]route{
|
||||
"site": {"GET", "/site", siteHandler(site)},
|
||||
"updatesite": {"PUT", "/site", updateSiteHandler(site)},
|
||||
} {
|
||||
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
|
||||
// loadpoints
|
||||
for _, r := range map[string]route{
|
||||
"loadpoints": {"GET", "/loadpoints", loadpointsConfigHandler()},
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/charger"
|
||||
"github.com/evcc-io/evcc/core/circuit"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/meter"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
|
|
@ -391,76 +392,110 @@ func deleteDevice[T any](id int, h config.Handler[T]) error {
|
|||
return h.Delete(name)
|
||||
}
|
||||
|
||||
// cleanupSiteMeterRef removes a meter reference from site configuration
|
||||
func cleanupSiteMeterRef(name string, get func() []string, set func([]string)) {
|
||||
var res []string
|
||||
refs := get()
|
||||
for _, ref := range refs {
|
||||
if ref != name {
|
||||
res = append(res, ref)
|
||||
}
|
||||
}
|
||||
if len(refs) != len(res) {
|
||||
set(res)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteDeviceHandler deletes a device from database by class
|
||||
func deleteDeviceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
h := config.Loadpoints()
|
||||
func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h := config.Loadpoints()
|
||||
|
||||
vars := mux.Vars(r)
|
||||
vars := mux.Vars(r)
|
||||
|
||||
class, err := templates.ClassString(vars["class"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch class {
|
||||
case templates.Charger:
|
||||
err = deleteDevice(id, config.Chargers())
|
||||
|
||||
// cleanup references
|
||||
for _, dev := range h.Devices() {
|
||||
lp := dev.Instance()
|
||||
if lp.GetChargerRef() == config.NameForID(id) {
|
||||
lp.SetChargerRef("")
|
||||
}
|
||||
class, err := templates.ClassString(vars["class"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
case templates.Meter:
|
||||
err = deleteDevice(id, config.Meters())
|
||||
|
||||
// cleanup references
|
||||
for _, dev := range h.Devices() {
|
||||
lp := dev.Instance()
|
||||
if lp.GetMeterRef() == config.NameForID(id) {
|
||||
lp.SetMeterRef("")
|
||||
}
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
case templates.Vehicle:
|
||||
err = deleteDevice(id, config.Vehicles())
|
||||
switch class {
|
||||
case templates.Charger:
|
||||
err = deleteDevice(id, config.Chargers())
|
||||
|
||||
// cleanup references
|
||||
for _, dev := range h.Devices() {
|
||||
lp := dev.Instance()
|
||||
if lp.GetDefaultVehicleRef() == config.NameForID(id) {
|
||||
lp.SetDefaultVehicleRef("")
|
||||
// cleanup references
|
||||
for _, dev := range h.Devices() {
|
||||
lp := dev.Instance()
|
||||
if lp.GetChargerRef() == config.NameForID(id) {
|
||||
lp.SetChargerRef("")
|
||||
}
|
||||
}
|
||||
|
||||
case templates.Meter:
|
||||
err = deleteDevice(id, config.Meters())
|
||||
|
||||
// cleanup references
|
||||
name := config.NameForID(id)
|
||||
|
||||
if site.GetGridMeterRef() == name {
|
||||
site.SetGridMeterRef("")
|
||||
}
|
||||
|
||||
for _, fun := range []struct {
|
||||
get func() []string
|
||||
set func([]string)
|
||||
}{
|
||||
{site.GetPVMeterRefs, site.SetPVMeterRefs},
|
||||
{site.GetBatteryMeterRefs, site.SetBatteryMeterRefs},
|
||||
{site.GetAuxMeterRefs, site.SetAuxMeterRefs},
|
||||
{site.GetExtMeterRefs, site.SetExtMeterRefs},
|
||||
} {
|
||||
cleanupSiteMeterRef(name, fun.get, fun.set)
|
||||
}
|
||||
|
||||
for _, dev := range h.Devices() {
|
||||
lp := dev.Instance()
|
||||
if lp.GetMeterRef() == name {
|
||||
lp.SetMeterRef("")
|
||||
}
|
||||
}
|
||||
|
||||
case templates.Vehicle:
|
||||
err = deleteDevice(id, config.Vehicles())
|
||||
|
||||
// cleanup references
|
||||
for _, dev := range h.Devices() {
|
||||
lp := dev.Instance()
|
||||
if lp.GetDefaultVehicleRef() == config.NameForID(id) {
|
||||
lp.SetDefaultVehicleRef("")
|
||||
}
|
||||
}
|
||||
|
||||
case templates.Circuit:
|
||||
err = deleteDevice(id, config.Circuits())
|
||||
}
|
||||
|
||||
case templates.Circuit:
|
||||
err = deleteDevice(id, config.Circuits())
|
||||
setConfigDirty()
|
||||
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
res := struct {
|
||||
ID int `json:"id"`
|
||||
}{
|
||||
ID: id,
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
}
|
||||
|
||||
setConfigDirty()
|
||||
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
res := struct {
|
||||
ID int `json:"id"`
|
||||
}{
|
||||
ID: id,
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
}
|
||||
|
||||
func testConfig[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T]) (T, error) {
|
||||
|
|
|
|||
|
|
@ -15,11 +15,15 @@ func siteHandler(site site.API) http.HandlerFunc {
|
|||
Grid string `json:"grid"`
|
||||
PV []string `json:"pv"`
|
||||
Battery []string `json:"battery"`
|
||||
Aux []string `json:"aux"`
|
||||
Ext []string `json:"ext"`
|
||||
}{
|
||||
Title: site.GetTitle(),
|
||||
Grid: site.GetGridMeterRef(),
|
||||
PV: site.GetPVMeterRefs(),
|
||||
Battery: site.GetBatteryMeterRefs(),
|
||||
Aux: site.GetAuxMeterRefs(),
|
||||
Ext: site.GetExtMeterRefs(),
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
|
|
@ -44,6 +48,8 @@ func updateSiteHandler(site site.API) http.HandlerFunc {
|
|||
Grid *string
|
||||
PV *[]string
|
||||
Battery *[]string
|
||||
Aux *[]string
|
||||
Ext *[]string
|
||||
}
|
||||
|
||||
if err := jsonDecoder(r.Body).Decode(&payload); err != nil {
|
||||
|
|
@ -82,6 +88,24 @@ func updateSiteHandler(site site.API) http.HandlerFunc {
|
|||
setConfigDirty()
|
||||
}
|
||||
|
||||
if payload.Aux != nil {
|
||||
if !validateRefs(w, *payload.Aux) {
|
||||
return
|
||||
}
|
||||
|
||||
site.SetAuxMeterRefs(*payload.Aux)
|
||||
setConfigDirty()
|
||||
}
|
||||
|
||||
if payload.Ext != nil {
|
||||
if !validateRefs(w, *payload.Ext) {
|
||||
return
|
||||
}
|
||||
|
||||
site.SetExtMeterRefs(*payload.Ext)
|
||||
setConfigDirty()
|
||||
}
|
||||
|
||||
status := map[bool]int{false: http.StatusOK, true: http.StatusAccepted}
|
||||
w.WriteHeader(status[ConfigDirty()])
|
||||
}
|
||||
|
|
|
|||
60
tests/config-aux.spec.js
Normal file
60
tests/config-aux.spec.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { start, stop, restart, baseUrl } from "./evcc";
|
||||
import { enableExperimental } from "./utils";
|
||||
|
||||
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
|
||||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await start(CONFIG_GRID_ONLY);
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await stop();
|
||||
});
|
||||
|
||||
test.describe("aux meter", async () => {
|
||||
test("create and remove aux meter", async ({ page }) => {
|
||||
await page.goto("/#/config");
|
||||
await enableExperimental(page);
|
||||
|
||||
await expect(page.getByTestId("aux")).toHaveCount(0);
|
||||
|
||||
// create
|
||||
await page.getByRole("button", { name: "Add additional meter" }).click();
|
||||
|
||||
const meterModal = page.getByTestId("meter-modal");
|
||||
await meterModal.getByRole("button", { name: "Add self-regulating consumer" }).click();
|
||||
await meterModal.getByLabel("Title").fill("Water heater");
|
||||
await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
|
||||
await meterModal.getByLabel("Power (W)").fill("1200");
|
||||
await meterModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
await expect(meterModal).not.toBeVisible();
|
||||
|
||||
// check
|
||||
await expect(page.getByTestId("aux")).toBeVisible(1);
|
||||
await expect(page.getByTestId("aux")).toContainText("Water heater");
|
||||
await expect(page.getByTestId("aux")).toContainText("1.2 kW");
|
||||
|
||||
// restart and check again
|
||||
await restart(CONFIG_GRID_ONLY);
|
||||
await page.reload();
|
||||
|
||||
// recheck
|
||||
await expect(page.getByTestId("aux")).toBeVisible(1);
|
||||
await expect(page.getByTestId("aux")).toContainText("Water heater");
|
||||
await expect(page.getByTestId("aux")).toContainText("1.2 kW");
|
||||
|
||||
// delete
|
||||
await page.goto("/#/config");
|
||||
await page.getByTestId("aux").getByRole("button", { name: "edit" }).click();
|
||||
await meterModal.getByRole("button", { name: "Delete" }).click();
|
||||
await expect(page.getByTestId("aux")).toHaveCount(0);
|
||||
|
||||
// restart and check again
|
||||
await restart(CONFIG_GRID_ONLY);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("aux")).toHaveCount(0);
|
||||
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { start, stop, restart, baseUrl } from "./evcc";
|
||||
import { startSimulator, stopSimulator, simulatorHost } from "./simulator";
|
||||
import { enableExperimental } from "./utils";
|
||||
|
||||
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
|
||||
|
|
@ -66,4 +67,42 @@ test.describe("pv meter", async () => {
|
|||
await page.reload();
|
||||
await expect(page.getByTestId("pv")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("remove broken pv meter", async ({ page }) => {
|
||||
// setup test data for mock openems api
|
||||
await startSimulator();
|
||||
|
||||
await page.goto("/#/config");
|
||||
await enableExperimental(page);
|
||||
|
||||
// create meter
|
||||
await page.getByRole("button", { name: "Add solar or battery" }).click();
|
||||
const meterModal = page.getByTestId("meter-modal");
|
||||
await meterModal.getByRole("button", { name: "Add solar meter" }).click();
|
||||
await meterModal.getByLabel("Title").fill("North Roof");
|
||||
await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm");
|
||||
await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
|
||||
await meterModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
await expect(meterModal).not.toBeVisible();
|
||||
await expect(page.getByTestId("pv")).toBeVisible(1);
|
||||
await expect(page.getByTestId("pv")).toContainText("North Roof");
|
||||
|
||||
// break meter
|
||||
await stopSimulator();
|
||||
await restart(CONFIG_GRID_ONLY);
|
||||
await page.reload();
|
||||
|
||||
// remove meter
|
||||
await expect(page.getByTestId("fatal-error")).toBeVisible();
|
||||
await expect(page.getByTestId("pv")).toBeVisible(1);
|
||||
await page.getByTestId("pv").getByRole("button", { name: "edit" }).click();
|
||||
await meterModal.getByRole("button", { name: "Delete" }).click();
|
||||
await expect(page.getByTestId("pv")).toHaveCount(0);
|
||||
|
||||
// restart and check again
|
||||
await restart(CONFIG_GRID_ONLY);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("pv")).toHaveCount(0);
|
||||
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ let state = {
|
|||
vehicles: [{ soc: 0, range: 0 }],
|
||||
};
|
||||
|
||||
const loggingMiddleware = (req, res, next) => {
|
||||
console.log(`[simulator] ${req.method} ${req.originalUrl}`);
|
||||
next();
|
||||
};
|
||||
|
||||
const stateApiMiddleware = (req, res, next) => {
|
||||
if (req.method === "POST" && req.originalUrl === "/api/state") {
|
||||
console.log("[simulator] POST /api/state");
|
||||
|
|
@ -66,16 +71,33 @@ const teslaloggerMiddleware = (req, res, next) => {
|
|||
}
|
||||
};
|
||||
|
||||
const shellyMiddleware = (req, res, next) => {
|
||||
// simulate a shelly gen1 device api. implement power and energy
|
||||
if (req.method === "GET" && req.originalUrl === "/shelly") {
|
||||
res.end(JSON.stringify({ gen: 2 }));
|
||||
} else if (req.originalUrl === "/rpc/Shelly.GetStatus") {
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
"switch:0": { apower: state.site.pv.power, aenergy: { total: state.site.pv.energy } },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
export default () => ({
|
||||
name: "api",
|
||||
enforce: "pre",
|
||||
configureServer(server) {
|
||||
console.log("[simulator] configured");
|
||||
return () => {
|
||||
server.middlewares.use(loggingMiddleware);
|
||||
server.middlewares.use(bodyParser.json());
|
||||
server.middlewares.use(stateApiMiddleware);
|
||||
server.middlewares.use(openemsMiddleware);
|
||||
server.middlewares.use(teslaloggerMiddleware);
|
||||
server.middlewares.use(shellyMiddleware);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue