Config UI: allow disabling devices (#29455)
Co-authored-by: Michael Geers <michael@geers.tv> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: andig <184815+andig@users.noreply.github.com>
This commit is contained in:
parent
ced9ed514b
commit
4465100a65
43 changed files with 1149 additions and 189 deletions
|
|
@ -311,6 +311,23 @@ a:hover {
|
||||||
--bs-btn-line-height: 2;
|
--bs-btn-line-height: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-pill {
|
||||||
|
--bs-btn-bg: var(--bs-body-bg);
|
||||||
|
--bs-btn-color: var(--evcc-default-text);
|
||||||
|
--bs-btn-border-color: var(--evcc-gray-50);
|
||||||
|
--bs-btn-hover-bg: var(--bs-body-bg);
|
||||||
|
--bs-btn-hover-color: var(--evcc-default-text);
|
||||||
|
--bs-btn-hover-border-color: var(--evcc-default-text);
|
||||||
|
--bs-btn-active-bg: var(--bs-body-bg);
|
||||||
|
--bs-btn-active-color: var(--evcc-default-text);
|
||||||
|
--bs-btn-active-border-color: var(--evcc-default-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 50rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-outline-secondary {
|
.btn-outline-secondary {
|
||||||
--bs-btn-color: var(--evcc-default-text);
|
--bs-btn-color: var(--evcc-default-text);
|
||||||
--bs-btn-border-color: var(--evcc-default-text);
|
--bs-btn-border-color: var(--evcc-default-text);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,21 @@
|
||||||
@edit="$emit('edit')"
|
@edit="$emit('edit')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="$slots.tags" ref="tagsContainer" :style="tagsStyle">
|
<template v-if="disabled">
|
||||||
|
<hr class="mt-3 mb-0 divide" />
|
||||||
|
<div class="disabled-region">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-pill px-3"
|
||||||
|
:aria-label="$t('config.general.enable')"
|
||||||
|
data-testid="device-disabled"
|
||||||
|
@click.stop="$emit('enable')"
|
||||||
|
>
|
||||||
|
{{ $t("config.general.disabled") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="$slots.tags" ref="tagsContainer" :style="tagsStyle">
|
||||||
<hr class="my-3 divide" :class="{ 'border-warning': banner }" />
|
<hr class="my-3 divide" :class="{ 'border-warning': banner }" />
|
||||||
<div
|
<div
|
||||||
v-if="banner"
|
v-if="banner"
|
||||||
|
|
@ -59,9 +73,10 @@ export default {
|
||||||
warning: Boolean,
|
warning: Boolean,
|
||||||
noEditButton: Boolean,
|
noEditButton: Boolean,
|
||||||
badge: Boolean,
|
badge: Boolean,
|
||||||
|
disabled: Boolean,
|
||||||
banner: String,
|
banner: String,
|
||||||
},
|
},
|
||||||
emits: ["edit"],
|
emits: ["edit", "enable"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tagsMinHeight: null,
|
tagsMinHeight: null,
|
||||||
|
|
@ -107,7 +122,8 @@ export default {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.root {
|
.root {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
|
|
@ -129,6 +145,23 @@ export default {
|
||||||
color: var(--evcc-gray) !important;
|
color: var(--evcc-gray) !important;
|
||||||
font-weight: normal !important;
|
font-weight: normal !important;
|
||||||
}
|
}
|
||||||
|
.disabled-region {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 -1.5rem -1rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
min-height: 5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0 0 1rem 1rem;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
transparent 0,
|
||||||
|
transparent 10px,
|
||||||
|
var(--evcc-gray-25) 10px,
|
||||||
|
var(--evcc-gray-25) 20px
|
||||||
|
);
|
||||||
|
}
|
||||||
.icon:empty {
|
.icon:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,25 +12,36 @@
|
||||||
|
|
||||||
<slot name="after-test"></slot>
|
<slot name="after-test"></slot>
|
||||||
|
|
||||||
<div class="mt-4 d-flex justify-content-between">
|
<div class="mt-4 d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<button
|
<div class="d-flex flex-wrap align-items-center gap-1">
|
||||||
v-if="isDeletable"
|
<button
|
||||||
type="button"
|
v-if="isDeletable"
|
||||||
class="btn btn-link text-danger"
|
type="button"
|
||||||
tabindex="0"
|
class="btn btn-link text-danger"
|
||||||
@click.prevent="$emit('remove')"
|
tabindex="0"
|
||||||
>
|
@click.prevent="$emit('remove')"
|
||||||
{{ $t("config.general.delete") }}
|
>
|
||||||
</button>
|
{{ $t("config.general.delete") }}
|
||||||
<button
|
</button>
|
||||||
v-else
|
<button
|
||||||
type="button"
|
v-else
|
||||||
class="btn btn-link text-muted"
|
type="button"
|
||||||
data-bs-dismiss="modal"
|
class="btn btn-link text-muted"
|
||||||
tabindex="0"
|
data-bs-dismiss="modal"
|
||||||
>
|
tabindex="0"
|
||||||
{{ $t("config.general.cancel") }}
|
>
|
||||||
</button>
|
{{ $t("config.general.cancel") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isDeletable && canDisable"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-link text-muted"
|
||||||
|
tabindex="0"
|
||||||
|
@click.prevent="$emit('disable', !isDisabled)"
|
||||||
|
>
|
||||||
|
{{ isDisabled ? $t("config.general.enable") : $t("config.general.disable") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:class="buttonClass"
|
:class="buttonClass"
|
||||||
|
|
@ -65,6 +76,8 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
isDeletable: Boolean as PropType<boolean>,
|
isDeletable: Boolean as PropType<boolean>,
|
||||||
|
isDisabled: Boolean as PropType<boolean>,
|
||||||
|
canDisable: { type: Boolean as PropType<boolean>, default: true },
|
||||||
testState: {
|
testState: {
|
||||||
type: Object as PropType<TestState>,
|
type: Object as PropType<TestState>,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
|
|
@ -75,7 +88,7 @@ export default defineComponent({
|
||||||
sponsorTokenRequired: Boolean as PropType<boolean>,
|
sponsorTokenRequired: Boolean as PropType<boolean>,
|
||||||
currency: String as PropType<CURRENCY>,
|
currency: String as PropType<CURRENCY>,
|
||||||
},
|
},
|
||||||
emits: ["save", "remove", "test"],
|
emits: ["save", "remove", "test", "disable"],
|
||||||
computed: {
|
computed: {
|
||||||
saveButtonLabel(): string {
|
saveButtonLabel(): string {
|
||||||
const { isError, isUnknown, isRunning } = this.testState;
|
const { isError, isUnknown, isRunning } = this.testState;
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,8 @@
|
||||||
<DeviceModalActions
|
<DeviceModalActions
|
||||||
v-if="showActions"
|
v-if="showActions"
|
||||||
:is-deletable="isDeletable"
|
:is-deletable="isDeletable"
|
||||||
|
:is-disabled="isDisabled"
|
||||||
|
:can-disable="canDisable"
|
||||||
:test-state="test"
|
:test-state="test"
|
||||||
:is-saving="saving"
|
:is-saving="saving"
|
||||||
:is-succeeded="succeeded"
|
:is-succeeded="succeeded"
|
||||||
|
|
@ -188,6 +190,7 @@
|
||||||
@save="handleSave"
|
@save="handleSave"
|
||||||
@remove="handleRemove"
|
@remove="handleRemove"
|
||||||
@test="testManually"
|
@test="testManually"
|
||||||
|
@disable="handleDisable"
|
||||||
>
|
>
|
||||||
<template #before-test>
|
<template #before-test>
|
||||||
<AdminPasswordPrompt
|
<AdminPasswordPrompt
|
||||||
|
|
@ -210,7 +213,7 @@
|
||||||
import { defineComponent, type PropType } from "vue";
|
import { defineComponent, type PropType } from "vue";
|
||||||
import GenericModal from "../../Helper/GenericModal.vue";
|
import GenericModal from "../../Helper/GenericModal.vue";
|
||||||
import DeviceInfoButton from "./DeviceInfoButton.vue";
|
import DeviceInfoButton from "./DeviceInfoButton.vue";
|
||||||
import { closeModal } from "@/configModal";
|
import { closeModal, isNestedIn } from "@/configModal";
|
||||||
import ErrorMessage from "../../Helper/ErrorMessage.vue";
|
import ErrorMessage from "../../Helper/ErrorMessage.vue";
|
||||||
import PropertyEntry from "../PropertyEntry.vue";
|
import PropertyEntry from "../PropertyEntry.vue";
|
||||||
import PropertyCollapsible from "../PropertyCollapsible.vue";
|
import PropertyCollapsible from "../PropertyCollapsible.vue";
|
||||||
|
|
@ -323,6 +326,7 @@ export default defineComponent({
|
||||||
"added",
|
"added",
|
||||||
"updated",
|
"updated",
|
||||||
"removed",
|
"removed",
|
||||||
|
"disable",
|
||||||
"open",
|
"open",
|
||||||
"close",
|
"close",
|
||||||
"template-changed",
|
"template-changed",
|
||||||
|
|
@ -457,6 +461,12 @@ export default defineComponent({
|
||||||
isDeletable() {
|
isDeletable() {
|
||||||
return !this.isNew && !this.hideDelete;
|
return !this.isNew && !this.hideDelete;
|
||||||
},
|
},
|
||||||
|
isDisabled() {
|
||||||
|
return Boolean(this.values.deviceDisable);
|
||||||
|
},
|
||||||
|
canDisable(): boolean {
|
||||||
|
return !isNestedIn("loadpoint");
|
||||||
|
},
|
||||||
showActions() {
|
showActions() {
|
||||||
// explicitly hide template fields (ocpp step 1)
|
// explicitly hide template fields (ocpp step 1)
|
||||||
if (this.hideTemplateFields) {
|
if (this.hideTemplateFields) {
|
||||||
|
|
@ -647,6 +657,9 @@ export default defineComponent({
|
||||||
if (device.deviceIcon !== undefined) {
|
if (device.deviceIcon !== undefined) {
|
||||||
this.values.deviceIcon = device.deviceIcon;
|
this.values.deviceIcon = device.deviceIcon;
|
||||||
}
|
}
|
||||||
|
if (device.deviceDisable !== undefined) {
|
||||||
|
this.values.deviceDisable = device.deviceDisable;
|
||||||
|
}
|
||||||
this.applyDefaults();
|
this.applyDefaults();
|
||||||
this.templateName = this.values.template;
|
this.templateName = this.values.template;
|
||||||
|
|
||||||
|
|
@ -828,6 +841,11 @@ export default defineComponent({
|
||||||
handleError(e, "remove failed");
|
handleError(e, "remove failed");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async handleDisable(disable: boolean) {
|
||||||
|
if (this.id === undefined) return;
|
||||||
|
this.$emit("disable", { id: this.id, disable });
|
||||||
|
await closeModal();
|
||||||
|
},
|
||||||
handleOpen() {
|
handleOpen() {
|
||||||
this.isModalVisible = true;
|
this.isModalVisible = true;
|
||||||
this.$emit("open");
|
this.$emit("open");
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export type DeviceValues = {
|
||||||
template: string | null;
|
template: string | null;
|
||||||
deviceTitle?: string;
|
deviceTitle?: string;
|
||||||
deviceIcon?: string;
|
deviceIcon?: string;
|
||||||
|
deviceDisable?: boolean;
|
||||||
usage?: MeterTemplateUsage;
|
usage?: MeterTemplateUsage;
|
||||||
heating?: boolean;
|
heating?: boolean;
|
||||||
integrateddevice?: boolean;
|
integrateddevice?: boolean;
|
||||||
|
|
@ -121,6 +122,46 @@ export function customChargerName(type: ConfigType, isHeating: boolean) {
|
||||||
return `${prefix}${type}`;
|
return `${prefix}${type}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// flattenDeviceConfig converts a GET /config/devices/:class/:id response
|
||||||
|
// into the flat shape expected by POST/PUT/test (id and name are dropped).
|
||||||
|
//
|
||||||
|
// GET (config = device-specfic):
|
||||||
|
// {
|
||||||
|
// id: 26,
|
||||||
|
// name: "db:26",
|
||||||
|
// type: "template",
|
||||||
|
// deviceTitle: "Espresso",
|
||||||
|
// device__: "..",
|
||||||
|
// config: {
|
||||||
|
// template: "tasmota",
|
||||||
|
// host: "192.168.1.2"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// PUT|POST|test (flat):
|
||||||
|
// {
|
||||||
|
// type: "template",
|
||||||
|
// deviceTitle: "Espresso",
|
||||||
|
// device__: "..",
|
||||||
|
// template: "tasmota",
|
||||||
|
// host: "192.168.1.2"
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// TODO: align GET and PUT shapes — always nest device-specific values under
|
||||||
|
// `config` and drop the artificial `device` prefix (deviceTitle → title, ...)
|
||||||
|
// matching db structure. Once the API is symmetric this helper goes away.
|
||||||
|
export function flattenDeviceConfig(dev: any): Record<string, any> {
|
||||||
|
const { id, name, config, ...rest } = dev;
|
||||||
|
const flat = { ...config, ...rest };
|
||||||
|
// title/icon are extracted from yaml on GET for display only; writing them
|
||||||
|
// back is rejected ("cannot mix yaml and other")
|
||||||
|
if (flat.yaml) {
|
||||||
|
delete flat.title;
|
||||||
|
delete flat.icon;
|
||||||
|
}
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadServiceValues(path: string) {
|
export async function loadServiceValues(path: string) {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/config/service/${path}`, {
|
const response = await api.get(`/config/service/${path}`, {
|
||||||
|
|
@ -224,6 +265,14 @@ export function createDeviceUtils(deviceType: DeviceType) {
|
||||||
return api.delete(`config/devices/${deviceType}/${id}`);
|
return api.delete(`config/devices/${deviceType}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// disable flips the disable flag by re-PUTing the existing config.
|
||||||
|
// force=true so a broken device can still be toggled.
|
||||||
|
async function disable(id: number, disable: boolean) {
|
||||||
|
const dev = (await api.get(`config/devices/${deviceType}/${id}`)).data;
|
||||||
|
const body = { ...flattenDeviceConfig(dev), deviceDisable: disable };
|
||||||
|
return api.put(`config/devices/${deviceType}/${id}`, body, { params: { force: true } });
|
||||||
|
}
|
||||||
|
|
||||||
async function load(id: number) {
|
async function load(id: number) {
|
||||||
const response = await api.get(`config/devices/${deviceType}/${id}`);
|
const response = await api.get(`config/devices/${deviceType}/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|
@ -292,6 +341,7 @@ export function createDeviceUtils(deviceType: DeviceType) {
|
||||||
test,
|
test,
|
||||||
update,
|
update,
|
||||||
remove,
|
remove,
|
||||||
|
disable,
|
||||||
load,
|
load,
|
||||||
create,
|
create,
|
||||||
loadProducts,
|
loadProducts,
|
||||||
|
|
|
||||||
|
|
@ -597,23 +597,38 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="values.charger" class="mt-5 mb-4 d-flex justify-content-between">
|
<div
|
||||||
<button
|
v-if="values.charger"
|
||||||
v-if="isDeletable"
|
class="mt-5 mb-4 d-flex flex-wrap justify-content-between align-items-center gap-2"
|
||||||
type="button"
|
>
|
||||||
class="btn btn-link text-danger"
|
<div class="d-flex flex-wrap align-items-center gap-1">
|
||||||
@click.prevent="remove"
|
<button
|
||||||
>
|
v-if="isDeletable"
|
||||||
{{ $t("config.meter.delete") }}
|
type="button"
|
||||||
</button>
|
class="btn btn-link text-danger"
|
||||||
<button
|
@click.prevent="remove"
|
||||||
v-else
|
>
|
||||||
type="button"
|
{{ $t("config.meter.delete") }}
|
||||||
class="btn btn-link text-muted btn-cancel"
|
</button>
|
||||||
data-bs-dismiss="modal"
|
<button
|
||||||
>
|
v-else
|
||||||
{{ $t("config.loadpoint.cancel") }}
|
type="button"
|
||||||
</button>
|
class="btn btn-link text-muted btn-cancel"
|
||||||
|
data-bs-dismiss="modal"
|
||||||
|
>
|
||||||
|
{{ $t("config.loadpoint.cancel") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isDeletable"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-link text-muted"
|
||||||
|
@click.prevent="handleDisable(!isDisabled)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isDisabled ? $t("config.general.enable") : $t("config.general.disable")
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||||
<span
|
<span
|
||||||
v-if="saving"
|
v-if="saving"
|
||||||
|
|
@ -717,7 +732,7 @@ export default {
|
||||||
default: () => false,
|
default: () => false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emits: ["changed", "dismissed"],
|
emits: ["changed", "dismissed", "disable"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isModalVisible: false,
|
isModalVisible: false,
|
||||||
|
|
@ -801,6 +816,9 @@ export default {
|
||||||
isDeletable() {
|
isDeletable() {
|
||||||
return !this.isNew;
|
return !this.isNew;
|
||||||
},
|
},
|
||||||
|
isDisabled() {
|
||||||
|
return Boolean(this.values.disable);
|
||||||
|
},
|
||||||
showPriority() {
|
showPriority() {
|
||||||
return this.isNew ? this.loadpointCount > 0 : this.loadpointCount > 1;
|
return this.isNew ? this.loadpointCount > 0 : this.loadpointCount > 1;
|
||||||
},
|
},
|
||||||
|
|
@ -945,6 +963,11 @@ export default {
|
||||||
alert("delete failed");
|
alert("delete failed");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async handleDisable(disable: boolean) {
|
||||||
|
if (this.id === undefined) return;
|
||||||
|
this.$emit("disable", { id: this.id, disable });
|
||||||
|
await closeModal();
|
||||||
|
},
|
||||||
async create() {
|
async create() {
|
||||||
this.autoCreate = true;
|
this.autoCreate = true;
|
||||||
this.autoCreateInProgress = true;
|
this.autoCreateInProgress = true;
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@
|
||||||
:name="meter.name"
|
:name="meter.name"
|
||||||
:editable="!!meter.id"
|
:editable="!!meter.id"
|
||||||
:error="hasError"
|
:error="hasError"
|
||||||
|
:disabled="!!meter.deviceDisable"
|
||||||
:banner="banner"
|
:banner="banner"
|
||||||
:data-testid="meterType"
|
:data-testid="meterType"
|
||||||
@edit="$emit('edit', meterType, meter.id)"
|
@edit="$emit('edit', meterType, meter.id)"
|
||||||
|
@enable="$emit('enable')"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<VehicleIcon v-if="isVehicleIcon" :name="iconName" />
|
<VehicleIcon v-if="isVehicleIcon" :name="iconName" />
|
||||||
|
|
@ -57,7 +59,7 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emits: ["edit"],
|
emits: ["edit", "enable"],
|
||||||
computed: {
|
computed: {
|
||||||
cardTitle() {
|
cardTitle() {
|
||||||
if (this.title) {
|
if (this.title) {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
@added="(name) => emitChanged('added', name)"
|
@added="(name) => emitChanged('added', name)"
|
||||||
@updated="() => emitChanged('updated')"
|
@updated="() => emitChanged('updated')"
|
||||||
@removed="() => emitChanged('removed')"
|
@removed="() => emitChanged('removed')"
|
||||||
|
@disable="$emit('disable', $event)"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<template #pre-content>
|
<template #pre-content>
|
||||||
|
|
@ -148,7 +149,7 @@ export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
isSponsor: Boolean,
|
isSponsor: Boolean,
|
||||||
},
|
},
|
||||||
emits: ["changed", "close"],
|
emits: ["changed", "disable", "close"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
extMeterUsage: "charge" as MeterTemplateUsage,
|
extMeterUsage: "charge" as MeterTemplateUsage,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@
|
||||||
:name="tariff.name"
|
:name="tariff.name"
|
||||||
:editable="!!tariff.id"
|
:editable="!!tariff.id"
|
||||||
:error="hasError"
|
:error="hasError"
|
||||||
|
:disabled="!!tariff.deviceDisable"
|
||||||
:data-testid="`tariff-${tariffType}`"
|
:data-testid="`tariff-${tariffType}`"
|
||||||
@edit="$emit('edit', tariffType, tariff.id)"
|
@edit="$emit('edit', tariffType, tariff.id)"
|
||||||
|
@enable="$emit('enable')"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<component :is="iconComponent" />
|
<component :is="iconComponent" />
|
||||||
|
|
@ -33,6 +35,7 @@ type ConfigTariff = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
deviceTitle?: string;
|
deviceTitle?: string;
|
||||||
|
deviceDisable?: boolean;
|
||||||
config?: {
|
config?: {
|
||||||
template?: string;
|
template?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -52,7 +55,7 @@ export default {
|
||||||
tags: { type: Object, default: () => ({}) },
|
tags: { type: Object, default: () => ({}) },
|
||||||
currency: { type: String as PropType<CURRENCY> },
|
currency: { type: String as PropType<CURRENCY> },
|
||||||
},
|
},
|
||||||
emits: ["edit"],
|
emits: ["edit", "enable"],
|
||||||
computed: {
|
computed: {
|
||||||
cardTitle(): string {
|
cardTitle(): string {
|
||||||
if (this.title) {
|
if (this.title) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
@added="handleAdded"
|
@added="handleAdded"
|
||||||
@updated="handleUpdated"
|
@updated="handleUpdated"
|
||||||
@removed="handleRemoved"
|
@removed="handleRemoved"
|
||||||
|
@disable="$emit('disable', $event)"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<template #pre-content>
|
<template #pre-content>
|
||||||
|
|
@ -86,7 +87,7 @@ export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
|
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
|
||||||
},
|
},
|
||||||
emits: ["changed", "close"],
|
emits: ["changed", "disable", "close"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
initialValues,
|
initialValues,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
@added="$emit('vehicle-changed', $event)"
|
@added="$emit('vehicle-changed', $event)"
|
||||||
@updated="$emit('vehicle-changed')"
|
@updated="$emit('vehicle-changed')"
|
||||||
@removed="$emit('vehicle-changed')"
|
@removed="$emit('vehicle-changed')"
|
||||||
|
@disable="$emit('disable', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -43,7 +44,7 @@ export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
isSponsor: Boolean,
|
isSponsor: Boolean,
|
||||||
},
|
},
|
||||||
emits: ["vehicle-changed"],
|
emits: ["vehicle-changed", "disable"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
initialValues,
|
initialValues,
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,14 @@
|
||||||
v-for="item in loadpoints"
|
v-for="item in loadpoints"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:title="item.title"
|
:title="item.title"
|
||||||
:visible="item.visible"
|
:visible="item.visible && !item.disabled"
|
||||||
>
|
>
|
||||||
|
{{ item.title }}
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<div class="form-check form-switch">
|
<span v-if="item.disabled" class="badge bg-secondary py-1 px-2">
|
||||||
|
{{ $t("settings.loadpoints.disabledDevice") }}
|
||||||
|
</span>
|
||||||
|
<div v-else class="form-check form-switch">
|
||||||
<input
|
<input
|
||||||
:id="`loadpoint-visible-${item.id}`"
|
:id="`loadpoint-visible-${item.id}`"
|
||||||
v-model="item.visible"
|
v-model="item.visible"
|
||||||
|
|
@ -59,12 +63,12 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
resetDisabled() {
|
resetDisabled() {
|
||||||
const allVisible = this.loadpoints.every((lp) => lp.visible);
|
const allVisible = this.loadpoints.every((lp) => lp.disabled || lp.visible);
|
||||||
const noOrder = this.loadpoints.every((lp) => lp.order === null);
|
const noOrder = this.loadpoints.every((lp) => lp.order === null);
|
||||||
return allVisible && noOrder;
|
return allVisible && noOrder;
|
||||||
},
|
},
|
||||||
visibleCount() {
|
visibleCount() {
|
||||||
return this.loadpoints.filter((lp) => lp.visible).length;
|
return this.loadpoints.filter((lp) => lp.visible && !lp.disabled).length;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ export default defineComponent({
|
||||||
return store.uiLoadpoints.value || [];
|
return store.uiLoadpoints.value || [];
|
||||||
},
|
},
|
||||||
orderedVisibleLoadpoints() {
|
orderedVisibleLoadpoints() {
|
||||||
return this.loadpoints.filter((lp) => lp.visible);
|
return this.loadpoints.filter((lp) => lp.visible && !lp.disabled);
|
||||||
},
|
},
|
||||||
batterySoc() {
|
batterySoc() {
|
||||||
return this.battery?.soc;
|
return this.battery?.soc;
|
||||||
|
|
|
||||||
|
|
@ -314,4 +314,9 @@ export function isTopModal(name: string): boolean {
|
||||||
return topModal()?.name === name;
|
return topModal()?.name === name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNestedIn(name: string): boolean {
|
||||||
|
const idx = configModal.stack.findIndex((m) => m.name === name);
|
||||||
|
return idx >= 0 && idx < configModal.stack.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
export default configModal;
|
export default configModal;
|
||||||
|
|
|
||||||
|
|
@ -443,6 +443,7 @@ export interface Entity {
|
||||||
type: string;
|
type: string;
|
||||||
id: number;
|
id: number;
|
||||||
config: Config;
|
config: Config;
|
||||||
|
deviceDisable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ConfigType {
|
export enum ConfigType {
|
||||||
|
|
@ -484,6 +485,7 @@ export interface LoadpointThreshold {
|
||||||
export interface ConfigLoadpoint {
|
export interface ConfigLoadpoint {
|
||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
disable?: boolean;
|
||||||
charger: string;
|
charger: string;
|
||||||
meter: string;
|
meter: string;
|
||||||
vehicle: string;
|
vehicle: string;
|
||||||
|
|
@ -603,6 +605,8 @@ export interface Loadpoint {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
/** Duration since the vehicle was connected, in seconds. */
|
/** Duration since the vehicle was connected, in seconds. */
|
||||||
connectedDuration: number;
|
connectedDuration: number;
|
||||||
|
/** Loadpoint is disabled via configuration. */
|
||||||
|
disabled?: boolean;
|
||||||
/** Delay before charging stops in solar mode, in seconds. */
|
/** Delay before charging stops in solar mode, in seconds. */
|
||||||
disableDelay: number;
|
disableDelay: number;
|
||||||
/** Grid draw power above which charging stops in solar mode, in W. */
|
/** Grid draw power above which charging stops in solar mode, in W. */
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,10 @@
|
||||||
:name="loadpoint.name"
|
:name="loadpoint.name"
|
||||||
:editable="!!loadpoint.id"
|
:editable="!!loadpoint.id"
|
||||||
:error="hasDeviceError('loadpoint', loadpoint.name)"
|
:error="hasDeviceError('loadpoint', loadpoint.name)"
|
||||||
|
:disabled="!!loadpoint.disable"
|
||||||
data-testid="loadpoint"
|
data-testid="loadpoint"
|
||||||
@edit="openModal('loadpoint', { id: loadpoint.id })"
|
@edit="openModal('loadpoint', { id: loadpoint.id })"
|
||||||
|
@enable="handleDisable('loadpoint', loadpoint.id!, false)"
|
||||||
>
|
>
|
||||||
<template #tags>
|
<template #tags>
|
||||||
<DeviceTags :tags="loadpointTags(loadpoint)" />
|
<DeviceTags :tags="loadpointTags(loadpoint)" />
|
||||||
|
|
@ -65,8 +67,10 @@
|
||||||
:name="vehicle.name"
|
:name="vehicle.name"
|
||||||
:editable="vehicle.id >= 0"
|
:editable="vehicle.id >= 0"
|
||||||
:error="hasDeviceError('vehicle', vehicle.name)"
|
:error="hasDeviceError('vehicle', vehicle.name)"
|
||||||
|
:disabled="!!vehicle.deviceDisable"
|
||||||
data-testid="vehicle"
|
data-testid="vehicle"
|
||||||
@edit="openModal('vehicle', { id: vehicle.id })"
|
@edit="openModal('vehicle', { id: vehicle.id })"
|
||||||
|
@enable="handleDisable('vehicle', vehicle.id, false)"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<VehicleIcon :name="vehicle.config?.icon" />
|
<VehicleIcon :name="vehicle.config?.icon" />
|
||||||
|
|
@ -92,6 +96,7 @@
|
||||||
:has-error="hasDeviceError('meter', meter.name)"
|
:has-error="hasDeviceError('meter', meter.name)"
|
||||||
:tags="deviceTags('meter', meter.name)"
|
:tags="deviceTags('meter', meter.name)"
|
||||||
@edit="(type, id) => openModal('meter', { type, id })"
|
@edit="(type, id) => openModal('meter', { type, id })"
|
||||||
|
@enable="handleDisable('meter', meter.id, false)"
|
||||||
/>
|
/>
|
||||||
<MeterCard
|
<MeterCard
|
||||||
v-for="meter in auxMeters"
|
v-for="meter in auxMeters"
|
||||||
|
|
@ -101,6 +106,7 @@
|
||||||
:has-error="hasDeviceError('meter', meter.name)"
|
:has-error="hasDeviceError('meter', meter.name)"
|
||||||
:tags="deviceTags('meter', meter.name)"
|
:tags="deviceTags('meter', meter.name)"
|
||||||
@edit="(type, id) => openModal('meter', { type, id })"
|
@edit="(type, id) => openModal('meter', { type, id })"
|
||||||
|
@enable="handleDisable('meter', meter.id, false)"
|
||||||
/>
|
/>
|
||||||
<NewDeviceButton
|
<NewDeviceButton
|
||||||
data-testid="add-consumer"
|
data-testid="add-consumer"
|
||||||
|
|
@ -119,6 +125,7 @@
|
||||||
:has-error="hasDeviceError('meter', gridMeter.name)"
|
:has-error="hasDeviceError('meter', gridMeter.name)"
|
||||||
:tags="deviceTags('meter', gridMeter.name)"
|
:tags="deviceTags('meter', gridMeter.name)"
|
||||||
@edit="(type, id) => openModal('meter', { type, id })"
|
@edit="(type, id) => openModal('meter', { type, id })"
|
||||||
|
@enable="handleDisable('meter', gridMeter.id, false)"
|
||||||
/>
|
/>
|
||||||
<NewDeviceButton
|
<NewDeviceButton
|
||||||
v-else
|
v-else
|
||||||
|
|
@ -138,6 +145,7 @@
|
||||||
:tags="deviceTags('meter', meter.name)"
|
:tags="deviceTags('meter', meter.name)"
|
||||||
:banner="meterBanner(meter.name)"
|
:banner="meterBanner(meter.name)"
|
||||||
@edit="(type, id) => openModal('meter', { type, id })"
|
@edit="(type, id) => openModal('meter', { type, id })"
|
||||||
|
@enable="handleDisable('meter', meter.id, false)"
|
||||||
/>
|
/>
|
||||||
<MeterCard
|
<MeterCard
|
||||||
v-for="meter in batteryMeters"
|
v-for="meter in batteryMeters"
|
||||||
|
|
@ -147,6 +155,7 @@
|
||||||
:has-error="hasDeviceError('meter', meter.name)"
|
:has-error="hasDeviceError('meter', meter.name)"
|
||||||
:tags="deviceTags('meter', meter.name)"
|
:tags="deviceTags('meter', meter.name)"
|
||||||
@edit="(type, id) => openModal('meter', { type, id })"
|
@edit="(type, id) => openModal('meter', { type, id })"
|
||||||
|
@enable="handleDisable('meter', meter.id, false)"
|
||||||
/>
|
/>
|
||||||
<NewDeviceButton
|
<NewDeviceButton
|
||||||
:title="$t('config.main.addPvBattery')"
|
:title="$t('config.main.addPvBattery')"
|
||||||
|
|
@ -164,6 +173,7 @@
|
||||||
:has-error="hasDeviceError('meter', meter.name)"
|
:has-error="hasDeviceError('meter', meter.name)"
|
||||||
:tags="deviceTags('meter', meter.name)"
|
:tags="deviceTags('meter', meter.name)"
|
||||||
@edit="(type, id) => openModal('meter', { type, id })"
|
@edit="(type, id) => openModal('meter', { type, id })"
|
||||||
|
@enable="handleDisable('meter', meter.id, false)"
|
||||||
/>
|
/>
|
||||||
<NewDeviceButton
|
<NewDeviceButton
|
||||||
data-testid="add-additional"
|
data-testid="add-additional"
|
||||||
|
|
@ -201,6 +211,7 @@
|
||||||
:tags="deviceTags('tariff', gridTariff.name)"
|
:tags="deviceTags('tariff', gridTariff.name)"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
@edit="openModal('tariff', { type: 'grid', id: gridTariff.id })"
|
@edit="openModal('tariff', { type: 'grid', id: gridTariff.id })"
|
||||||
|
@enable="handleDisable('tariff', gridTariff.id, false)"
|
||||||
/>
|
/>
|
||||||
<TariffCard
|
<TariffCard
|
||||||
v-if="feedInTariff"
|
v-if="feedInTariff"
|
||||||
|
|
@ -210,6 +221,7 @@
|
||||||
:tags="deviceTags('tariff', feedInTariff.name)"
|
:tags="deviceTags('tariff', feedInTariff.name)"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
@edit="openModal('tariff', { type: 'feedIn', id: feedInTariff.id })"
|
@edit="openModal('tariff', { type: 'feedIn', id: feedInTariff.id })"
|
||||||
|
@enable="handleDisable('tariff', feedInTariff.id, false)"
|
||||||
/>
|
/>
|
||||||
<NewDeviceButton
|
<NewDeviceButton
|
||||||
v-if="possibleTariffTypes.length"
|
v-if="possibleTariffTypes.length"
|
||||||
|
|
@ -223,6 +235,7 @@
|
||||||
:has-error="hasDeviceError('tariff', co2Tariff.name)"
|
:has-error="hasDeviceError('tariff', co2Tariff.name)"
|
||||||
:tags="deviceTags('tariff', co2Tariff.name)"
|
:tags="deviceTags('tariff', co2Tariff.name)"
|
||||||
@edit="openModal('tariff', { type: 'co2', id: co2Tariff.id })"
|
@edit="openModal('tariff', { type: 'co2', id: co2Tariff.id })"
|
||||||
|
@enable="handleDisable('tariff', co2Tariff.id, false)"
|
||||||
/>
|
/>
|
||||||
<TariffCard
|
<TariffCard
|
||||||
v-for="tariff in solarTariffs"
|
v-for="tariff in solarTariffs"
|
||||||
|
|
@ -233,6 +246,7 @@
|
||||||
:tags="deviceTags('tariff', tariff.name)"
|
:tags="deviceTags('tariff', tariff.name)"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
@edit="openModal('tariff', { type: 'solar', id: tariff.id })"
|
@edit="openModal('tariff', { type: 'solar', id: tariff.id })"
|
||||||
|
@enable="handleDisable('tariff', tariff.id, false)"
|
||||||
/>
|
/>
|
||||||
<TariffCard
|
<TariffCard
|
||||||
v-if="temperatureTariff"
|
v-if="temperatureTariff"
|
||||||
|
|
@ -252,6 +266,7 @@
|
||||||
:tags="deviceTags('tariff', plannerTariff.name)"
|
:tags="deviceTags('tariff', plannerTariff.name)"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
@edit="openModal('tariff', { type: 'planner', id: plannerTariff.id })"
|
@edit="openModal('tariff', { type: 'planner', id: plannerTariff.id })"
|
||||||
|
@enable="handleDisable('tariff', plannerTariff.id, false)"
|
||||||
/>
|
/>
|
||||||
<NewDeviceButton
|
<NewDeviceButton
|
||||||
v-if="possibleForecastTypes.length"
|
v-if="possibleForecastTypes.length"
|
||||||
|
|
@ -458,9 +473,18 @@
|
||||||
:hasDeviceError="hasDeviceError"
|
:hasDeviceError="hasDeviceError"
|
||||||
@changed="loadpointChanged"
|
@changed="loadpointChanged"
|
||||||
@dismissed="loadpointDismissed"
|
@dismissed="loadpointDismissed"
|
||||||
|
@disable="({ id, disable }) => handleDisable('loadpoint', id, disable)"
|
||||||
|
/>
|
||||||
|
<VehicleModal
|
||||||
|
:is-sponsor="isSponsor"
|
||||||
|
@vehicle-changed="vehicleChanged"
|
||||||
|
@disable="({ id, disable }) => handleDisable('vehicle', id, disable)"
|
||||||
|
/>
|
||||||
|
<MeterModal
|
||||||
|
:is-sponsor="isSponsor"
|
||||||
|
@changed="meterChanged"
|
||||||
|
@disable="({ id, disable }) => handleDisable('meter', id, disable)"
|
||||||
/>
|
/>
|
||||||
<VehicleModal :is-sponsor="isSponsor" @vehicle-changed="vehicleChanged" />
|
|
||||||
<MeterModal :is-sponsor="isSponsor" @changed="meterChanged" />
|
|
||||||
<ChargerModal :is-sponsor="isSponsor" :ocpp="ocpp" @changed="chargerChanged" />
|
<ChargerModal :is-sponsor="isSponsor" :ocpp="ocpp" @changed="chargerChanged" />
|
||||||
<InfluxModal @changed="loadDirty" />
|
<InfluxModal @changed="loadDirty" />
|
||||||
<MqttModal @changed="loadDirty" />
|
<MqttModal @changed="loadDirty" />
|
||||||
|
|
@ -476,7 +500,11 @@
|
||||||
<MessagingModal :messengers="messengers" @changed="loadDirty" />
|
<MessagingModal :messengers="messengers" @changed="loadDirty" />
|
||||||
<MessengerModal @changed="messengerChanged" />
|
<MessengerModal @changed="messengerChanged" />
|
||||||
<TariffsLegacyModal @changed="loadDirty" />
|
<TariffsLegacyModal @changed="loadDirty" />
|
||||||
<TariffModal :currency="currency" @changed="tariffChanged" />
|
<TariffModal
|
||||||
|
:currency="currency"
|
||||||
|
@changed="tariffChanged"
|
||||||
|
@disable="({ id, disable }) => handleDisable('tariff', id, disable)"
|
||||||
|
/>
|
||||||
<TelemetryModal :is-sponsor="isSponsor" :telemetry="telemetry" />
|
<TelemetryModal :is-sponsor="isSponsor" :telemetry="telemetry" />
|
||||||
<OptimizerModal :is-sponsor="isSponsor" />
|
<OptimizerModal :is-sponsor="isSponsor" />
|
||||||
<McpModal />
|
<McpModal />
|
||||||
|
|
@ -537,6 +565,7 @@ import MessengerModal from "@/components/Config/Messaging/MessengerModal.vue";
|
||||||
import MessagingLegacyModal from "@/components/Config/Messaging/MessagingLegacyModal.vue";
|
import MessagingLegacyModal from "@/components/Config/Messaging/MessagingLegacyModal.vue";
|
||||||
import MeterModal from "../components/Config/MeterModal.vue";
|
import MeterModal from "../components/Config/MeterModal.vue";
|
||||||
import MeterCard from "../components/Config/MeterCard.vue";
|
import MeterCard from "../components/Config/MeterCard.vue";
|
||||||
|
import { createDeviceUtils } from "../components/Config/DeviceModal";
|
||||||
import { openModal, type ModalResult } from "@/configModal";
|
import { openModal, type ModalResult } from "@/configModal";
|
||||||
import ModbusProxyIcon from "../components/MaterialIcon/ModbusProxy.vue";
|
import ModbusProxyIcon from "../components/MaterialIcon/ModbusProxy.vue";
|
||||||
import ModbusProxyModal from "../components/Config/ModbusProxyModal.vue";
|
import ModbusProxyModal from "../components/Config/ModbusProxyModal.vue";
|
||||||
|
|
@ -1027,6 +1056,30 @@ export default defineComponent({
|
||||||
isUnconfigured(tags: DeviceTags): boolean {
|
isUnconfigured(tags: DeviceTags): boolean {
|
||||||
return tags["configured"]?.value === false;
|
return tags["configured"]?.value === false;
|
||||||
},
|
},
|
||||||
|
async handleDisable(deviceClass: DeviceType, id: number, disable: boolean) {
|
||||||
|
const promptKey = disable
|
||||||
|
? "config.general.confirmDisable"
|
||||||
|
: "config.general.confirmEnable";
|
||||||
|
if (!window.confirm(this.$t(promptKey))) return;
|
||||||
|
const refresh: Partial<Record<DeviceType, () => void>> = {
|
||||||
|
meter: () => this.meterChanged({ action: "updated" }),
|
||||||
|
tariff: () => this.tariffChanged({ action: "updated" }),
|
||||||
|
vehicle: () => this.vehicleChanged(),
|
||||||
|
loadpoint: () => this.loadpointChanged(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (deviceClass === "loadpoint") {
|
||||||
|
const { data } = await api.get(`config/loadpoints/${id}`);
|
||||||
|
await api.put(`config/loadpoints/${id}`, { ...data, disable });
|
||||||
|
} else {
|
||||||
|
await createDeviceUtils(deviceClass).disable(id, disable);
|
||||||
|
}
|
||||||
|
refresh[deviceClass]?.();
|
||||||
|
await this.loadDirty();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("disable failed", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
handleVisibilityChange() {
|
handleVisibilityChange() {
|
||||||
this.isPageVisible = document.visibilityState === "visible";
|
this.isPageVisible = document.visibilityState === "visible";
|
||||||
if (this.isPageVisible) {
|
if (this.isPageVisible) {
|
||||||
|
|
@ -1259,6 +1312,7 @@ export default defineComponent({
|
||||||
} as Record<DeviceType, any[]>;
|
} as Record<DeviceType, any[]>;
|
||||||
for (const type in devices) {
|
for (const type in devices) {
|
||||||
for (const device of devices[type as DeviceType]) {
|
for (const device of devices[type as DeviceType]) {
|
||||||
|
if (device.deviceDisable) continue;
|
||||||
if (this.isComponentMounted && this.isPageVisible) {
|
if (this.isComponentMounted && this.isPageVisible) {
|
||||||
await this.updateDeviceValue(type as DeviceType, device.name);
|
await this.updateDeviceValue(type as DeviceType, device.name);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ func runDump(cmd *cobra.Command, args []string) {
|
||||||
d.DumpWithHeader(fmt.Sprintf("vehicle: %s", v.GetTitle()), v)
|
d.DumpWithHeader(fmt.Sprintf("vehicle: %s", v.GetTitle()), v)
|
||||||
}
|
}
|
||||||
|
|
||||||
for id, lpI := range site.Loadpoints() {
|
for id, lpI := range site.ActiveLoadpoints() {
|
||||||
lp := lpI.(*core.Loadpoint)
|
lp := lpI.(*core.Loadpoint)
|
||||||
|
|
||||||
d.Header(fmt.Sprintf("loadpoint %d", id+1), "=")
|
d.Header(fmt.Sprintf("loadpoint %d", id+1), "=")
|
||||||
|
|
|
||||||
54
cmd/setup.go
54
cmd/setup.go
|
|
@ -52,7 +52,6 @@ import (
|
||||||
"github.com/gorilla/handlers"
|
"github.com/gorilla/handlers"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/libp2p/zeroconf/v2"
|
"github.com/libp2p/zeroconf/v2"
|
||||||
"github.com/samber/lo"
|
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
vpr "github.com/spf13/viper"
|
vpr "github.com/spf13/viper"
|
||||||
|
|
@ -343,7 +342,7 @@ func configurableInstance[T any](typ string, conf *config.Config, newFromConf ne
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var instance T
|
var instance T
|
||||||
if err == nil {
|
if err == nil && !conf.Disable {
|
||||||
instance, err = newFromConf(ctx, typ, other)
|
instance, err = newFromConf(ctx, typ, other)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = &DeviceError{cc.Name, fmt.Errorf("cannot create %s '%s': %w", typ, cc.Name, err)}
|
err = &DeviceError{cc.Name, fmt.Errorf("cannot create %s '%s': %w", typ, cc.Name, err)}
|
||||||
|
|
@ -1142,15 +1141,20 @@ func configureSolarTariffs(confs []config.Typed, deviceNames []string, target *a
|
||||||
if len(deviceNames) == 1 {
|
if len(deviceNames) == 1 {
|
||||||
return configureTariff(config.Typed{}, deviceNames[0], target)
|
return configureTariff(config.Typed{}, deviceNames[0], target)
|
||||||
}
|
}
|
||||||
tt := make([]api.Tariff, len(deviceNames))
|
var tt []api.Tariff
|
||||||
for i, name := range deviceNames {
|
for _, name := range deviceNames {
|
||||||
dev, err := config.Tariffs().ByName(name)
|
dev, err := config.Tariffs().ByName(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("tariff device %s not found: %w", name, err)
|
return fmt.Errorf("tariff device %s not found: %w", name, err)
|
||||||
}
|
}
|
||||||
tt[i] = dev.Instance()
|
// nil instance marks disabled device
|
||||||
|
if instance := dev.Instance(); instance != nil {
|
||||||
|
tt = append(tt, instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tt) > 0 {
|
||||||
|
*target = tariff.NewCombined(tt)
|
||||||
}
|
}
|
||||||
*target = tariff.NewCombined(tt)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -1205,9 +1209,13 @@ func configureTariffs(conf *globalconfig.Tariffs, names ...string) (*tariff.Tari
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
instance, err := tariffInstance(cc.Name, config.Typed{Type: cc.Type, Other: cc.Other})
|
var instance api.Tariff
|
||||||
if err != nil {
|
if !conf.Disable {
|
||||||
return err
|
var err error
|
||||||
|
instance, err = tariffInstance(cc.Name, config.Typed{Type: cc.Type, Other: cc.Other})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if e := config.Tariffs().Add(config.NewConfigurableDevice(&conf, instance)); e != nil {
|
if e := config.Tariffs().Add(config.NewConfigurableDevice(&conf, instance)); e != nil {
|
||||||
|
|
@ -1329,9 +1337,12 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
|
||||||
errs = append(errs, &ClassError{ClassTariff, err})
|
errs = append(errs, &ClassError{ClassTariff, err})
|
||||||
}
|
}
|
||||||
|
|
||||||
loadpoints := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) *core.Loadpoint {
|
// nil entries mark disabled loadpoints- indexes stay aligned with config order
|
||||||
return dev.Instance().(*core.Loadpoint)
|
var loadpoints []*core.Loadpoint
|
||||||
})
|
for _, dev := range config.Loadpoints().Devices() {
|
||||||
|
inst, _ := dev.Instance().(*core.Loadpoint)
|
||||||
|
loadpoints = append(loadpoints, inst)
|
||||||
|
}
|
||||||
|
|
||||||
site, err := configureSite(conf.Site, loadpoints, tariffs)
|
site, err := configureSite(conf.Site, loadpoints, tariffs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1368,7 +1379,7 @@ CONTINUE:
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.ContainsFunc(loadpoints, func(lp *core.Loadpoint) bool {
|
if slices.ContainsFunc(loadpoints, func(lp *core.Loadpoint) bool {
|
||||||
return lp.GetCircuit() == instance
|
return lp != nil && lp.GetCircuit() == instance
|
||||||
}) {
|
}) {
|
||||||
continue CONTINUE
|
continue CONTINUE
|
||||||
}
|
}
|
||||||
|
|
@ -1446,14 +1457,19 @@ func configureLoadpoints(conf globalconfig.All) error {
|
||||||
return &DeviceError{cc.Name, err}
|
return &DeviceError{cc.Name, err}
|
||||||
}
|
}
|
||||||
|
|
||||||
instance, err := newLoadpoint(idx, cc.Name, static, func(log *util.Logger) coresettings.Settings {
|
var instance loadpoint.API
|
||||||
return coresettings.NewConfigSettingsAdapter(log, &conf)
|
if !conf.Disable {
|
||||||
})
|
lp, e := newLoadpoint(idx, cc.Name, static, func(log *util.Logger) coresettings.Settings {
|
||||||
if err != nil {
|
return coresettings.NewConfigSettingsAdapter(log, &conf)
|
||||||
err = &DeviceError{cc.Name, err}
|
})
|
||||||
|
if e != nil {
|
||||||
|
err = &DeviceError{cc.Name, e}
|
||||||
|
} else {
|
||||||
|
instance = lp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance)
|
dev := config.NewConfigurableDevice(&conf, instance)
|
||||||
if e := config.Loadpoints().Add(dev); e != nil && err == nil {
|
if e := config.Loadpoints().Add(dev); e != nil && err == nil {
|
||||||
err = &DeviceError{cc.Name, e}
|
err = &DeviceError{cc.Name, e}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const (
|
||||||
// loadpoint settings
|
// loadpoint settings
|
||||||
Name = "name" // loadpoint name (config identifier)
|
Name = "name" // loadpoint name (config identifier)
|
||||||
Title = "title" // loadpoint title
|
Title = "title" // loadpoint title
|
||||||
|
Disabled = "disabled" // loadpoint disabled via config
|
||||||
Mode = "mode" // charge mode
|
Mode = "mode" // charge mode
|
||||||
DefaultMode = "defaultMode" // default charge mode
|
DefaultMode = "defaultMode" // default charge mode
|
||||||
Charger = "charger" // charger ref
|
Charger = "charger" // charger ref
|
||||||
|
|
|
||||||
127
core/site.go
127
core/site.go
|
|
@ -150,6 +150,23 @@ func NewSiteFromConfig(other map[string]any) (*Site, error) {
|
||||||
return site, nil
|
return site, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func activeMeters(refs []string) ([]config.Device[api.Meter], error) {
|
||||||
|
var res []config.Device[api.Meter]
|
||||||
|
for _, ref := range refs {
|
||||||
|
dev, err := config.Meters().ByName(ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if dev.Instance() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
res = append(res, dev)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tariff.Tariffs) error {
|
func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tariff.Tariffs) error {
|
||||||
site.loadpoints = loadpoints
|
site.loadpoints = loadpoints
|
||||||
site.tariffs = tariffs
|
site.tariffs = tariffs
|
||||||
|
|
@ -180,7 +197,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
||||||
tariff := site.GetTariff(api.TariffUsagePlanner)
|
tariff := site.GetTariff(api.TariffUsagePlanner)
|
||||||
|
|
||||||
// give loadpoints access to vehicles and database
|
// give loadpoints access to vehicles and database
|
||||||
for _, lp := range loadpoints {
|
for _, lp := range site.activeLoadpoints() {
|
||||||
lp.coordinator = coordinator.NewAdapter(lp, site.coordinator)
|
lp.coordinator = coordinator.NewAdapter(lp, site.coordinator)
|
||||||
lp.planner = planner.New(lp.log, tariff)
|
lp.planner = planner.New(lp.log, tariff)
|
||||||
|
|
||||||
|
|
@ -211,13 +228,17 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
site.gridMeter = dev
|
if dev.Instance() == nil {
|
||||||
|
site.log.WARN.Println("missing grid meter instance")
|
||||||
|
} else {
|
||||||
|
site.gridMeter = dev
|
||||||
|
|
||||||
me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid)
|
me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
site.collectors[site.Meters.GridMeterRef] = me
|
||||||
}
|
}
|
||||||
site.collectors[site.Meters.GridMeterRef] = me
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// multiple pv
|
// multiple pv
|
||||||
|
|
@ -226,6 +247,9 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if dev.Instance() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
site.pvMeters = append(site.pvMeters, dev)
|
site.pvMeters = append(site.pvMeters, dev)
|
||||||
|
|
||||||
// energy collector (for history persistence and forecast scaling)
|
// energy collector (for history persistence and forecast scaling)
|
||||||
|
|
@ -251,13 +275,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
||||||
site.collectors[metrics.Temperature] = tc
|
site.collectors[metrics.Temperature] = tc
|
||||||
|
|
||||||
// multiple batteries
|
// multiple batteries
|
||||||
for _, ref := range site.Meters.BatteryMetersRef {
|
mm, err := activeMeters(site.Meters.BatteryMetersRef)
|
||||||
dev, err := config.Meters().ByName(ref)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
site.batteryMeters = mm
|
||||||
site.batteryMeters = append(site.batteryMeters, dev)
|
for _, dev := range mm {
|
||||||
|
ref := dev.Config().Name
|
||||||
me, err := metrics.NewCollector(metrics.Battery, ref, deviceTitleOrName(dev))
|
me, err := metrics.NewCollector(metrics.Battery, ref, deviceTitleOrName(dev))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -266,13 +290,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
||||||
}
|
}
|
||||||
|
|
||||||
// additional meters used only for monitoring
|
// additional meters used only for monitoring
|
||||||
for _, ref := range site.Meters.ExtMetersRef {
|
mm, err = activeMeters(site.Meters.ExtMetersRef)
|
||||||
dev, err := config.Meters().ByName(ref)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
site.extMeters = mm
|
||||||
site.extMeters = append(site.extMeters, dev)
|
for _, dev := range mm {
|
||||||
|
ref := dev.Config().Name
|
||||||
me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev))
|
me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -281,13 +305,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
||||||
}
|
}
|
||||||
|
|
||||||
// auxiliary meters (consumers)
|
// auxiliary meters (consumers)
|
||||||
for _, ref := range site.Meters.AuxMetersRef {
|
mm, err = activeMeters(site.Meters.AuxMetersRef)
|
||||||
dev, err := config.Meters().ByName(ref)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
site.auxMeters = mm
|
||||||
site.auxMeters = append(site.auxMeters, dev)
|
for _, dev := range mm {
|
||||||
|
ref := dev.Config().Name
|
||||||
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
|
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -296,13 +320,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
||||||
}
|
}
|
||||||
|
|
||||||
// consumer meters
|
// consumer meters
|
||||||
for _, ref := range site.Meters.ConsumerMetersRef {
|
mm, err = activeMeters(site.Meters.ConsumerMetersRef)
|
||||||
dev, err := config.Meters().ByName(ref)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
site.consumerMeters = mm
|
||||||
site.consumerMeters = append(site.consumerMeters, dev)
|
for _, dev := range mm {
|
||||||
|
ref := dev.Config().Name
|
||||||
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
|
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -513,6 +537,10 @@ func (site *Site) DumpConfig() {
|
||||||
site.log.INFO.Printf(" solar: %s", presence[site.GetTariff(api.TariffUsageSolar) != nil])
|
site.log.INFO.Printf(" solar: %s", presence[site.GetTariff(api.TariffUsageSolar) != nil])
|
||||||
|
|
||||||
for i, lp := range site.loadpoints {
|
for i, lp := range site.loadpoints {
|
||||||
|
if lp == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
lp.log.INFO.Printf("loadpoint %d:", i+1)
|
lp.log.INFO.Printf("loadpoint %d:", i+1)
|
||||||
lp.log.INFO.Printf(" mode: %s", lp.GetMode())
|
lp.log.INFO.Printf(" mode: %s", lp.GetMode())
|
||||||
|
|
||||||
|
|
@ -565,7 +593,7 @@ func (site *Site) publishLoadpoint(id int, key string, val any) {
|
||||||
|
|
||||||
// clearPlanLocks clears locked plan goals for all loadpoints
|
// clearPlanLocks clears locked plan goals for all loadpoints
|
||||||
func (site *Site) clearPlanLocks() {
|
func (site *Site) clearPlanLocks() {
|
||||||
for _, lp := range site.Loadpoints() {
|
for _, lp := range site.activeLoadpoints() {
|
||||||
lp.ClearPlanLock()
|
lp.ClearPlanLock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1065,7 +1093,7 @@ func (site *Site) updateLoadpoints(rates api.Rates) float64 {
|
||||||
sum float64
|
sum float64
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, lp := range site.loadpoints {
|
for _, lp := range site.activeLoadpoints() {
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
power := lp.UpdateChargePowerAndCurrents()
|
power := lp.UpdateChargePowerAndCurrents()
|
||||||
site.prioritizer.UpdateChargePowerFlexibility(lp, rates)
|
site.prioritizer.UpdateChargePowerFlexibility(lp, rates)
|
||||||
|
|
@ -1090,7 +1118,7 @@ func (site *Site) reservedPVPower(lp updater) float64 {
|
||||||
prio := lp.EffectivePriority()
|
prio := lp.EffectivePriority()
|
||||||
|
|
||||||
var reserved float64
|
var reserved float64
|
||||||
for _, other := range site.loadpoints {
|
for _, other := range site.activeLoadpoints() {
|
||||||
if other == lp {
|
if other == lp {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -1310,6 +1338,21 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
|
||||||
lpDevices := config.Loadpoints().Devices()
|
lpDevices := config.Loadpoints().Devices()
|
||||||
|
|
||||||
for id, lp := range site.loadpoints {
|
for id, lp := range site.loadpoints {
|
||||||
|
// publish name on the loadpoint's behalf — it doesn't know its own
|
||||||
|
if id < len(lpDevices) {
|
||||||
|
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Name, Val: lpDevices[id].Config().Name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// disabled loadpoint- publish minimal placeholder to keep indexes stable
|
||||||
|
if lp == nil {
|
||||||
|
if id < len(lpDevices) {
|
||||||
|
title, _ := lpDevices[id].Config().Other["title"].(string)
|
||||||
|
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Title, Val: title}
|
||||||
|
}
|
||||||
|
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Disabled, Val: true}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
lpUIChan := make(chan util.Param)
|
lpUIChan := make(chan util.Param)
|
||||||
lpPushChan := make(chan messenger.Event)
|
lpPushChan := make(chan messenger.Event)
|
||||||
|
|
||||||
|
|
@ -1327,11 +1370,6 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
|
||||||
}
|
}
|
||||||
}(id)
|
}(id)
|
||||||
|
|
||||||
// publish name on the loadpoint's behalf — it doesn't know its own
|
|
||||||
if id < len(lpDevices) {
|
|
||||||
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Name, Val: lpDevices[id].Config().Name}
|
|
||||||
}
|
|
||||||
|
|
||||||
lp.Prepare(site, lpUIChan, lpPushChan, site.lpUpdateChan)
|
lp.Prepare(site, lpUIChan, lpPushChan, site.lpUpdateChan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1339,15 +1377,16 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
|
||||||
// loopLoadpoints keeps iterating across loadpoints sending the next to the given channel
|
// loopLoadpoints keeps iterating across loadpoints sending the next to the given channel
|
||||||
func (site *Site) loopLoadpoints(next chan<- updater) {
|
func (site *Site) loopLoadpoints(next chan<- updater) {
|
||||||
var logOnce sync.Once
|
var logOnce sync.Once
|
||||||
|
active := site.activeLoadpoints()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
if len(site.loadpoints) == 0 {
|
if len(active) == 0 {
|
||||||
logOnce.Do(func() {
|
logOnce.Do(func() {
|
||||||
site.log.INFO.Println("no loadpoints configured, running in meter-only mode")
|
site.log.INFO.Println("no loadpoints configured, running in meter-only mode")
|
||||||
})
|
})
|
||||||
next <- nil
|
next <- nil
|
||||||
} else {
|
} else {
|
||||||
for _, lp := range site.loadpoints {
|
for _, lp := range active {
|
||||||
next <- lp
|
next <- lp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package site
|
package site
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"iter"
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/evcc-io/evcc/core/loadpoint"
|
"github.com/evcc-io/evcc/core/loadpoint"
|
||||||
)
|
)
|
||||||
|
|
@ -15,6 +17,7 @@ type API interface {
|
||||||
Publisher
|
Publisher
|
||||||
|
|
||||||
Loadpoints() []loadpoint.API
|
Loadpoints() []loadpoint.API
|
||||||
|
ActiveLoadpoints() iter.Seq2[int, loadpoint.API]
|
||||||
Vehicles() Vehicles
|
Vehicles() Vehicles
|
||||||
Optimize()
|
Optimize()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package core
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -169,9 +170,26 @@ func (site *Site) GetBatteryMaxDischargePower() float64 {
|
||||||
return site.batteryMaxDischargePower
|
return site.batteryMaxDischargePower
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loadpoints returns the loadpoints as api interfaces
|
// Loadpoints returns the loadpoints as api interfaces.
|
||||||
|
// Disabled loadpoints are returned as nil to keep indexes stable.
|
||||||
func (site *Site) Loadpoints() []loadpoint.API {
|
func (site *Site) Loadpoints() []loadpoint.API {
|
||||||
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API { return lp })
|
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API {
|
||||||
|
if lp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return lp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveLoadpoints yields enabled loadpoints with their stable index
|
||||||
|
func (site *Site) ActiveLoadpoints() iter.Seq2[int, loadpoint.API] {
|
||||||
|
return func(yield func(int, loadpoint.API) bool) {
|
||||||
|
for id, lp := range site.loadpoints {
|
||||||
|
if lp != nil && !yield(id, lp) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (site *Site) hasMeters() bool {
|
func (site *Site) hasMeters() bool {
|
||||||
|
|
@ -179,12 +197,17 @@ func (site *Site) hasMeters() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (site *Site) IsConfigured() bool {
|
func (site *Site) IsConfigured() bool {
|
||||||
return len(site.loadpoints) > 0 || site.hasMeters()
|
return slices.ContainsFunc(site.loadpoints, func(lp *Loadpoint) bool { return lp != nil }) || site.hasMeters()
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeLoadpoints returns the non-disabled loadpoints
|
||||||
|
func (site *Site) activeLoadpoints() []*Loadpoint {
|
||||||
|
return lo.Filter(site.loadpoints, func(lp *Loadpoint, _ int) bool { return lp != nil })
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadpointsAsCircuitDevices returns the loadpoints as circuit devices
|
// loadpointsAsCircuitDevices returns the loadpoints as circuit devices
|
||||||
func (site *Site) loadpointsAsCircuitDevices() []api.CircuitLoad {
|
func (site *Site) loadpointsAsCircuitDevices() []api.CircuitLoad {
|
||||||
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) api.CircuitLoad { return lp })
|
return lo.Map(site.activeLoadpoints(), func(lp *Loadpoint, _ int) api.CircuitLoad { return lp })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vehicles returns the site vehicles
|
// Vehicles returns the site vehicles
|
||||||
|
|
|
||||||
22
core/site_api_test.go
Normal file
22
core/site_api_test.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadpointsNilSlots(t *testing.T) {
|
||||||
|
site := &Site{loadpoints: []*Loadpoint{new(Loadpoint), nil, new(Loadpoint)}}
|
||||||
|
|
||||||
|
lps := site.Loadpoints()
|
||||||
|
assert.Len(t, lps, 3, "disabled loadpoints must keep their slot")
|
||||||
|
|
||||||
|
// disabled slot must be untyped nil, not a typed-nil interface
|
||||||
|
assert.True(t, lps[1] == nil)
|
||||||
|
assert.NotNil(t, lps[0])
|
||||||
|
assert.NotNil(t, lps[2])
|
||||||
|
|
||||||
|
assert.Len(t, site.activeLoadpoints(), 2)
|
||||||
|
assert.True(t, site.IsConfigured())
|
||||||
|
}
|
||||||
|
|
@ -208,7 +208,7 @@ func (site *Site) dischargeControlActive(rate api.Rate) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, lp := range site.Loadpoints() {
|
for _, lp := range site.activeLoadpoints() {
|
||||||
smartCostActive := site.smartCostActive(lp, rate)
|
smartCostActive := site.smartCostActive(lp, rate)
|
||||||
if lp.GetStatus() == api.StatusC && (smartCostActive || lp.IsFastChargingActive()) {
|
if lp.GetStatus() == api.StatusC && (smartCostActive || lp.IsFastChargingActive()) {
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,10 @@ func (site *Site) suggestion(key, currentAction string) *types.Suggestion {
|
||||||
// publishSuggestions publishes the loadpoints' suggestions
|
// publishSuggestions publishes the loadpoints' suggestions
|
||||||
func (site *Site) publishSuggestions() {
|
func (site *Site) publishSuggestions() {
|
||||||
for id, lp := range site.loadpoints {
|
for id, lp := range site.loadpoints {
|
||||||
|
if lp == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
var val any
|
var val any
|
||||||
if s := site.suggestion(loadpointKey(id), loadpointCurrentAction(lp)); s != nil {
|
if s := site.suggestion(loadpointKey(id), loadpointCurrentAction(lp)); s != nil {
|
||||||
val = *s
|
val = *s
|
||||||
|
|
@ -512,7 +516,7 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim
|
||||||
// uncontrollable power of loadpoints that cannot be modelled as storage
|
// uncontrollable power of loadpoints that cannot be modelled as storage
|
||||||
var unmodelled float64
|
var unmodelled float64
|
||||||
|
|
||||||
for id, lp := range site.Loadpoints() {
|
for id, lp := range site.ActiveLoadpoints() {
|
||||||
// ignore disconnected loadpoints, including StatusNone
|
// ignore disconnected loadpoints, including StatusNone
|
||||||
if s := lp.GetStatus(); s != api.StatusB && s != api.StatusC {
|
if s := lp.GetStatus(); s != api.StatusB && s != api.StatusC {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ func (s *SEMP) deviceInfoQuery(w http.ResponseWriter, r *http.Request) {
|
||||||
if did == "" {
|
if did == "" {
|
||||||
msg.DeviceInfo = append(msg.DeviceInfo, s.allDeviceInfo()...)
|
msg.DeviceInfo = append(msg.DeviceInfo, s.allDeviceInfo()...)
|
||||||
} else {
|
} else {
|
||||||
for id, lp := range s.site.Loadpoints() {
|
for id, lp := range s.site.ActiveLoadpoints() {
|
||||||
if did != s.deviceID(id) {
|
if did != s.deviceID(id) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -239,7 +239,7 @@ func (s *SEMP) deviceStatusQuery(w http.ResponseWriter, r *http.Request) {
|
||||||
if did == "" {
|
if did == "" {
|
||||||
msg.DeviceStatus = append(msg.DeviceStatus, s.allDeviceStatus()...)
|
msg.DeviceStatus = append(msg.DeviceStatus, s.allDeviceStatus()...)
|
||||||
} else {
|
} else {
|
||||||
for id, lp := range s.site.Loadpoints() {
|
for id, lp := range s.site.ActiveLoadpoints() {
|
||||||
if did != s.deviceID(id) {
|
if did != s.deviceID(id) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +264,7 @@ func (s *SEMP) devicePlanningQuery(w http.ResponseWriter, r *http.Request) {
|
||||||
if did == "" {
|
if did == "" {
|
||||||
msg.PlanningRequest = append(msg.PlanningRequest, s.allPlanningRequest()...)
|
msg.PlanningRequest = append(msg.PlanningRequest, s.allPlanningRequest()...)
|
||||||
} else {
|
} else {
|
||||||
for id, lp := range s.site.Loadpoints() {
|
for id, lp := range s.site.ActiveLoadpoints() {
|
||||||
if did != s.deviceID(id) {
|
if did != s.deviceID(id) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -339,7 +339,7 @@ func (s *SEMP) deviceInfo(id int, lp loadpoint.API) DeviceInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SEMP) allDeviceInfo() (res []DeviceInfo) {
|
func (s *SEMP) allDeviceInfo() (res []DeviceInfo) {
|
||||||
for id, lp := range s.site.Loadpoints() {
|
for id, lp := range s.site.ActiveLoadpoints() {
|
||||||
res = append(res, s.deviceInfo(id, lp))
|
res = append(res, s.deviceInfo(id, lp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,7 +370,7 @@ func (s *SEMP) deviceStatus(id int, lp loadpoint.API) DeviceStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SEMP) allDeviceStatus() (res []DeviceStatus) {
|
func (s *SEMP) allDeviceStatus() (res []DeviceStatus) {
|
||||||
for id, lp := range s.site.Loadpoints() {
|
for id, lp := range s.site.ActiveLoadpoints() {
|
||||||
res = append(res, s.deviceStatus(id, lp))
|
res = append(res, s.deviceStatus(id, lp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -427,7 +427,7 @@ func (s *SEMP) planningRequest(id int, lp loadpoint.API) (res PlanningRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SEMP) allPlanningRequest() (res []PlanningRequest) {
|
func (s *SEMP) allPlanningRequest() (res []PlanningRequest) {
|
||||||
for id, lp := range s.site.Loadpoints() {
|
for id, lp := range s.site.ActiveLoadpoints() {
|
||||||
if pr := s.planningRequest(id, lp); len(pr.Timeframe) > 0 {
|
if pr := s.planningRequest(id, lp); len(pr.Timeframe) > 0 {
|
||||||
res = append(res, pr)
|
res = append(res, pr)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -320,17 +320,22 @@
|
||||||
"change": "Ändern",
|
"change": "Ändern",
|
||||||
"clear": "Löschen",
|
"clear": "Löschen",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
|
"confirmDisable": "Dieses Gerät deaktivieren? Es wird nach dem nächsten Neustart ignoriert.",
|
||||||
|
"confirmEnable": "Dieses Gerät wieder aktivieren?",
|
||||||
"confirmSave": "Es gibt ungespeicherte Änderungen. Jetzt speichern?",
|
"confirmSave": "Es gibt ungespeicherte Änderungen. Jetzt speichern?",
|
||||||
"copied": "Kopiert!",
|
"copied": "Kopiert!",
|
||||||
"copy": "Kopieren",
|
"copy": "Kopieren",
|
||||||
"customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.",
|
"customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.",
|
||||||
"customOption": "Benutzerdefiniertes Gerät",
|
"customOption": "Benutzerdefiniertes Gerät",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
|
"disable": "Deaktivieren",
|
||||||
|
"disabled": "deaktiviert",
|
||||||
"dismiss": "Ausblenden",
|
"dismiss": "Ausblenden",
|
||||||
"docsLink": "Siehe Dokumentation.",
|
"docsLink": "Siehe Dokumentation.",
|
||||||
"dragHandle": "Verschieben",
|
"dragHandle": "Verschieben",
|
||||||
"dragItem": "Verschiebbar: {title}",
|
"dragItem": "Verschiebbar: {title}",
|
||||||
"dragList": "Sortierbare Liste",
|
"dragList": "Sortierbare Liste",
|
||||||
|
"enable": "Aktivieren",
|
||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
"experimental": "Experimentell",
|
"experimental": "Experimentell",
|
||||||
"forceSave": "Trotzdem speichern",
|
"forceSave": "Trotzdem speichern",
|
||||||
|
|
@ -1724,6 +1729,7 @@
|
||||||
"label": "Sprache"
|
"label": "Sprache"
|
||||||
},
|
},
|
||||||
"loadpoints": {
|
"loadpoints": {
|
||||||
|
"disabledDevice": "deaktiviert",
|
||||||
"help": "Reihenfolge und Sichtbarkeit für die Benutzeroberfläche ändern.",
|
"help": "Reihenfolge und Sichtbarkeit für die Benutzeroberfläche ändern.",
|
||||||
"hide": "{title} ausblenden",
|
"hide": "{title} ausblenden",
|
||||||
"label": "Ladepunkte",
|
"label": "Ladepunkte",
|
||||||
|
|
|
||||||
|
|
@ -320,17 +320,22 @@
|
||||||
"change": "Change",
|
"change": "Change",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
"confirmDisable": "Disable this device? It will be ignored after the next restart.",
|
||||||
|
"confirmEnable": "Enable this device again?",
|
||||||
"confirmSave": "There are unsaved changes. Save now?",
|
"confirmSave": "There are unsaved changes. Save now?",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"customHelp": "Create a user-defined device using evcc's plugin system.",
|
"customHelp": "Create a user-defined device using evcc's plugin system.",
|
||||||
"customOption": "User-defined device",
|
"customOption": "User-defined device",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"disable": "Disable",
|
||||||
|
"disabled": "disabled",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"docsLink": "See documentation.",
|
"docsLink": "See documentation.",
|
||||||
"dragHandle": "Drag handle",
|
"dragHandle": "Drag handle",
|
||||||
"dragItem": "Draggable: {title}",
|
"dragItem": "Draggable: {title}",
|
||||||
"dragList": "Reorderable list",
|
"dragList": "Reorderable list",
|
||||||
|
"enable": "Enable",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"experimental": "Experimental",
|
"experimental": "Experimental",
|
||||||
"forceSave": "Save anyway",
|
"forceSave": "Save anyway",
|
||||||
|
|
@ -1724,6 +1729,7 @@
|
||||||
"label": "Language"
|
"label": "Language"
|
||||||
},
|
},
|
||||||
"loadpoints": {
|
"loadpoints": {
|
||||||
|
"disabledDevice": "disabled",
|
||||||
"help": "Change order and visibility for the UI.",
|
"help": "Change order and visibility for the UI.",
|
||||||
"hide": "Hide {title}",
|
"hide": "Hide {title}",
|
||||||
"label": "Charging points",
|
"label": "Charging points",
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
|
||||||
|
|
||||||
// loadpoint api
|
// loadpoint api
|
||||||
// TODO any loadpoint
|
// TODO any loadpoint
|
||||||
for id, lp := range site.Loadpoints() {
|
for id, lp := range site.ActiveLoadpoints() {
|
||||||
api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter()
|
api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter()
|
||||||
|
|
||||||
routes := map[string]route{
|
routes := map[string]route{
|
||||||
|
|
|
||||||
|
|
@ -598,7 +598,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
|
||||||
// cleanup references
|
// cleanup references
|
||||||
for _, dev := range h.Devices() {
|
for _, dev := range h.Devices() {
|
||||||
lp := dev.Instance()
|
lp := dev.Instance()
|
||||||
if lp.GetChargerRef() == config.NameForID(id) {
|
if lp != nil && lp.GetChargerRef() == config.NameForID(id) {
|
||||||
lp.SetChargerRef("")
|
lp.SetChargerRef("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -628,7 +628,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
|
||||||
|
|
||||||
for _, dev := range h.Devices() {
|
for _, dev := range h.Devices() {
|
||||||
lp := dev.Instance()
|
lp := dev.Instance()
|
||||||
if lp.GetMeterRef() == name {
|
if lp != nil && lp.GetMeterRef() == name {
|
||||||
lp.SetMeterRef("")
|
lp.SetMeterRef("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -639,7 +639,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
|
||||||
// cleanup references
|
// cleanup references
|
||||||
for _, dev := range h.Devices() {
|
for _, dev := range h.Devices() {
|
||||||
lp := dev.Instance()
|
lp := dev.Instance()
|
||||||
if lp.GetDefaultVehicleRef() == config.NameForID(id) {
|
if lp != nil && lp.GetDefaultVehicleRef() == config.NameForID(id) {
|
||||||
lp.SetDefaultVehicleRef("")
|
lp.SetDefaultVehicleRef("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -650,7 +650,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
|
||||||
// cleanup references
|
// cleanup references
|
||||||
for _, dev := range h.Devices() {
|
for _, dev := range h.Devices() {
|
||||||
lp := dev.Instance()
|
lp := dev.Instance()
|
||||||
if lp.GetCircuitRef() == config.NameForID(id) {
|
if lp != nil && lp.GetCircuitRef() == config.NameForID(id) {
|
||||||
lp.SetCircuitRef("")
|
lp.SetCircuitRef("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,15 @@ func propsToMap(props config.Properties) (map[string]any, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return lo.PickBy(res, func(k string, v any) bool {
|
return lo.PickBy(res, func(k string, v any) bool {
|
||||||
if k == "Type" || v.(string) == "" {
|
if k == "Type" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
return val != ""
|
||||||
|
case bool:
|
||||||
|
return val
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,19 +106,34 @@ func TestConfigReqUnmarshal(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigReqMarshalToMap(t *testing.T) {
|
func TestConfigReqMarshalToMap(t *testing.T) {
|
||||||
props := config.Properties{
|
res, err := propsToMap(config.Properties{
|
||||||
Type: "type",
|
Type: "type",
|
||||||
Title: "title",
|
Title: "title",
|
||||||
Product: "product",
|
Product: "product",
|
||||||
}
|
})
|
||||||
|
|
||||||
res, err := propsToMap(props)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, map[string]any{
|
assert.Equal(t, map[string]any{
|
||||||
"deviceTitle": "title",
|
"deviceTitle": "title",
|
||||||
"deviceProduct": "product",
|
"deviceProduct": "product",
|
||||||
}, res)
|
}, res)
|
||||||
|
|
||||||
|
// Disable=false is omitted (zero value)
|
||||||
|
res, err = propsToMap(config.Properties{
|
||||||
|
Type: "type",
|
||||||
|
Title: "title",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, res, "deviceDisable")
|
||||||
|
|
||||||
|
// Disable=true is included
|
||||||
|
res, err = propsToMap(config.Properties{
|
||||||
|
Type: "type",
|
||||||
|
Disable: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]any{
|
||||||
|
"deviceDisable": true,
|
||||||
|
}, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
type testStruct struct {
|
type testStruct struct {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import (
|
||||||
"github.com/evcc-io/evcc/util/config"
|
"github.com/evcc-io/evcc/util/config"
|
||||||
"github.com/evcc-io/evcc/util/templates"
|
"github.com/evcc-io/evcc/util/templates"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/samber/lo"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig {
|
func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig {
|
||||||
|
|
@ -50,68 +49,116 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
type loadpointFullConfig struct {
|
type loadpointFullConfig struct {
|
||||||
ID int `json:"id,omitempty"` // db row id
|
ID int `json:"id,omitempty"` // db row id
|
||||||
Name string `json:"name"` // either slice index (yaml) or db:<row id>
|
Name string `json:"name"` // either slice index (yaml) or db:<row id>
|
||||||
|
Disable bool `json:"disable,omitempty"`
|
||||||
|
|
||||||
// static config
|
// static config
|
||||||
loadpoint.StaticConfig
|
loadpoint.StaticConfig
|
||||||
loadpoint.DynamicConfig
|
loadpoint.DynamicConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadpointSplitConfig(r io.Reader) (loadpoint.DynamicConfig, map[string]any, error) {
|
func loadpointSplitConfig(r io.Reader) (loadpoint.DynamicConfig, map[string]any, map[string]any, *bool, error) {
|
||||||
var payload map[string]any
|
var payload map[string]any
|
||||||
|
|
||||||
if err := jsonDecoder(r).Decode(&payload); err != nil {
|
if err := jsonDecoder(r).Decode(&payload); err != nil {
|
||||||
return loadpoint.DynamicConfig{}, nil, err
|
return loadpoint.DynamicConfig{}, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return loadpoint.SplitConfig(payload)
|
// nil if not part of the payload- keeps the persisted flag
|
||||||
|
var disable *bool
|
||||||
|
if v, ok := payload["disable"].(bool); ok {
|
||||||
|
disable = &v
|
||||||
|
}
|
||||||
|
delete(payload, "disable")
|
||||||
|
delete(payload, "id")
|
||||||
|
delete(payload, "name")
|
||||||
|
|
||||||
|
dynamic, static, err := loadpoint.SplitConfig(payload)
|
||||||
|
return dynamic, static, payload, disable, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDeviceDisable persists the disable flag on a loadpoint's subdevice
|
||||||
|
func setDeviceDisable[T any](name string, h config.Handler[T], disable bool) error {
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, err := configurableDevice(name, h)
|
||||||
|
if err != nil {
|
||||||
|
// static device- cannot persist
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
props := dev.Properties()
|
||||||
|
if props.Disable == disable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
props.Disable = disable
|
||||||
|
|
||||||
|
return dev.Update(dev.Config().Other, dev.Instance(), config.WithProperties(props))
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadpointConfig returns a single loadpoint's configuration
|
// loadpointConfig returns a single loadpoint's configuration
|
||||||
func loadpointConfig(dev config.Device[loadpoint.API]) loadpointFullConfig {
|
func loadpointConfig(dev config.Device[loadpoint.API]) (loadpointFullConfig, error) {
|
||||||
var id int
|
var (
|
||||||
|
id int
|
||||||
|
disable bool
|
||||||
|
)
|
||||||
if configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API]); ok {
|
if configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API]); ok {
|
||||||
id = configurable.ID()
|
id = configurable.ID()
|
||||||
|
disable = configurable.Properties().Disable
|
||||||
}
|
}
|
||||||
|
|
||||||
lp := dev.Instance()
|
lp := dev.Instance()
|
||||||
|
|
||||||
// // missing instance due to error, decode config from database
|
// disabled loadpoint has no live instance; decode static config from the database instead
|
||||||
// if lp == nil || reflect.ValueOf(lp).IsNil() {
|
if lp == nil {
|
||||||
// cc := dev.Config()
|
dynamic, staticMap, err := loadpoint.SplitConfig(dev.Config().Other)
|
||||||
|
if err != nil {
|
||||||
|
return loadpointFullConfig{}, err
|
||||||
|
}
|
||||||
|
|
||||||
// dynamic, staticMap, _ := loadpoint.SplitConfig(cc.Other)
|
var static loadpoint.StaticConfig
|
||||||
|
if err := util.DecodeOther(staticMap, &static); err != nil {
|
||||||
|
return loadpointFullConfig{}, err
|
||||||
|
}
|
||||||
|
|
||||||
// var static loadpoint.StaticConfig
|
return loadpointFullConfig{
|
||||||
// _ = util.DecodeOther(staticMap, &static)
|
ID: id,
|
||||||
|
Name: dev.Config().Name,
|
||||||
// res := loadpointFullConfig{
|
Disable: disable,
|
||||||
// ID: id,
|
StaticConfig: static,
|
||||||
// Name: dev.Config().Name,
|
DynamicConfig: dynamic,
|
||||||
// StaticConfig: static,
|
}, nil
|
||||||
// DynamicConfig: dynamic,
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
// return res
|
|
||||||
// }
|
|
||||||
|
|
||||||
res := loadpointFullConfig{
|
res := loadpointFullConfig{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: dev.Config().Name,
|
Name: dev.Config().Name,
|
||||||
|
Disable: disable,
|
||||||
StaticConfig: getLoadpointStaticConfig(lp),
|
StaticConfig: getLoadpointStaticConfig(lp),
|
||||||
DynamicConfig: getLoadpointDynamicConfig(lp),
|
DynamicConfig: getLoadpointDynamicConfig(lp),
|
||||||
}
|
}
|
||||||
|
|
||||||
return res
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadpointsConfigHandler returns a device configurations by class
|
// loadpointsConfigHandler returns a device configurations by class
|
||||||
func loadpointsConfigHandler() http.HandlerFunc {
|
func loadpointsConfigHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
res := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) loadpointFullConfig {
|
devices := config.Loadpoints().Devices()
|
||||||
return loadpointConfig(dev)
|
|
||||||
})
|
res := make([]loadpointFullConfig, 0, len(devices))
|
||||||
|
for _, dev := range devices {
|
||||||
|
c, err := loadpointConfig(dev)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res = append(res, c)
|
||||||
|
}
|
||||||
|
|
||||||
jsonWrite(w, res)
|
jsonWrite(w, res)
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +183,11 @@ func loadpointConfigHandler() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res := loadpointConfig(dev)
|
res, err := loadpointConfig(dev)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
jsonWrite(w, res)
|
jsonWrite(w, res)
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +200,7 @@ func newLoadpointHandler() http.HandlerFunc {
|
||||||
// TODO revert charger, meter etc
|
// TODO revert charger, meter etc
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
dynamic, static, err := loadpointSplitConfig(r.Body)
|
dynamic, static, _, disable, err := loadpointSplitConfig(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
|
|
@ -159,7 +210,7 @@ func newLoadpointHandler() http.HandlerFunc {
|
||||||
name := "lp-" + strconv.Itoa(id+1)
|
name := "lp-" + strconv.Itoa(id+1)
|
||||||
log := util.NewLoggerWithLoadpoint(name, id+1)
|
log := util.NewLoggerWithLoadpoint(name, id+1)
|
||||||
|
|
||||||
conf, err := config.AddConfig(templates.Loadpoint, static)
|
conf, err := config.AddConfig(templates.Loadpoint, static, config.WithProperties(config.Properties{Disable: disable != nil && *disable}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
|
|
@ -220,34 +271,58 @@ func updateLoadpointHandler() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dynamic, static, err := loadpointSplitConfig(r.Body)
|
dynamic, static, payload, disable, err := loadpointSplitConfig(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// static
|
props := configurable.Properties()
|
||||||
|
if disable != nil {
|
||||||
// merge here to maintain dynamic part of the config
|
props.Disable = *disable
|
||||||
other := configurable.Config().Other
|
|
||||||
if err := mergo.Merge(&other, static, mergo.WithOverride); err != nil {
|
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
instance := dev.Instance()
|
instance := dev.Instance()
|
||||||
|
|
||||||
if err := configurable.Update(other, instance); err != nil {
|
// merge static config to maintain the dynamic part; without live instance
|
||||||
|
// merge the full payload since dynamic setters cannot persist it
|
||||||
|
src := static
|
||||||
|
if instance == nil {
|
||||||
|
src = payload
|
||||||
|
}
|
||||||
|
|
||||||
|
other := configurable.Config().Other
|
||||||
|
if err := mergo.Merge(&other, src, mergo.WithOverride); err != nil {
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// dynamic
|
if err := configurable.Update(other, instance, config.WithProperties(props)); err != nil {
|
||||||
if err := dynamic.Apply(instance); err != nil {
|
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// propagate disable to the loadpoint's charger and meter
|
||||||
|
chargerRef, _ := other["charger"].(string)
|
||||||
|
if err := setDeviceDisable(chargerRef, config.Chargers(), props.Disable); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meterRef, _ := other["meter"].(string)
|
||||||
|
if err := setDeviceDisable(meterRef, config.Meters(), props.Disable); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// dynamic; instance is nil for a disabled loadpoint, takes effect on next restart
|
||||||
|
if instance != nil {
|
||||||
|
if err := dynamic.Apply(instance); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setConfigDirty()
|
setConfigDirty()
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
@ -276,6 +351,20 @@ func deleteLoadpointHandler() http.HandlerFunc {
|
||||||
|
|
||||||
instance := lp.Instance()
|
instance := lp.Instance()
|
||||||
|
|
||||||
|
// disabled loadpoint has no live instance; delete it without ref cleanup
|
||||||
|
if instance == nil {
|
||||||
|
if err := deleteDevice(id, h); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonWrite(w, struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
}{ID: id})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if dev, err := configurableDevice(instance.GetChargerRef(), config.Chargers()); err == nil {
|
if dev, err := configurableDevice(instance.GetChargerRef(), config.Chargers()); err == nil {
|
||||||
if err := deleteDevice(dev.ID(), config.Chargers()); err != nil {
|
if err := deleteDevice(dev.ID(), config.Chargers()); err != nil {
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
|
|
||||||
70
server/http_config_loadpoint_handler_test.go
Normal file
70
server/http_config_loadpoint_handler_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/evcc-io/evcc/core/loadpoint"
|
||||||
|
"github.com/evcc-io/evcc/server/db"
|
||||||
|
"github.com/evcc-io/evcc/util/config"
|
||||||
|
"github.com/evcc-io/evcc/util/templates"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLoadpointConfigDisabledNilInstance ensures loadpointConfig does not panic
|
||||||
|
// for a disabled loadpoint whose instance was never created (nil loadpoint.API).
|
||||||
|
func TestLoadpointConfigDisabledNilInstance(t *testing.T) {
|
||||||
|
conf := config.Config{
|
||||||
|
Class: templates.Loadpoint,
|
||||||
|
Properties: config.Properties{Disable: true},
|
||||||
|
Data: map[string]any{
|
||||||
|
"charger": "wallbox",
|
||||||
|
"meter": "lp-meter",
|
||||||
|
"title": "Garage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var instance loadpoint.API // nil: disabled loadpoint has no live instance
|
||||||
|
dev := config.NewConfigurableDevice(&conf, instance)
|
||||||
|
|
||||||
|
var res loadpointFullConfig
|
||||||
|
require.NotPanics(t, func() {
|
||||||
|
var err error
|
||||||
|
res, err = loadpointConfig(dev)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.True(t, res.Disable)
|
||||||
|
assert.Equal(t, "wallbox", res.Charger)
|
||||||
|
assert.Equal(t, "lp-meter", res.Meter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteLoadpointDisabledNilInstance ensures deleteLoadpointHandler does not
|
||||||
|
// dereference a nil instance when deleting a disabled loadpoint.
|
||||||
|
func TestDeleteLoadpointDisabledNilInstance(t *testing.T) {
|
||||||
|
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||||
|
|
||||||
|
conf, err := config.AddConfig(templates.Loadpoint,
|
||||||
|
map[string]any{"charger": "wallbox", "title": "Garage"},
|
||||||
|
config.WithProperties(config.Properties{Disable: true}),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var instance loadpoint.API // nil: disabled loadpoint has no live instance
|
||||||
|
dev := config.NewConfigurableDevice(&conf, instance)
|
||||||
|
require.NoError(t, config.Loadpoints().Add(dev))
|
||||||
|
t.Cleanup(func() { _ = config.Loadpoints().Delete(config.NameForID(conf.ID)) })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"id": strconv.Itoa(conf.ID)})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
require.NotPanics(t, func() {
|
||||||
|
deleteLoadpointHandler()(rec, req)
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
}
|
||||||
|
|
@ -215,7 +215,7 @@ func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64))
|
||||||
val = &f
|
val = &f
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, lp := range site.Loadpoints() {
|
for _, lp := range site.ActiveLoadpoints() {
|
||||||
setLimit(lp, val)
|
setLimit(lp, val)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,10 @@ func (m *Influx) Run(site site.API, in <-chan util.Param) {
|
||||||
tags := make(map[string]string)
|
tags := make(map[string]string)
|
||||||
if param.Loadpoint != nil {
|
if param.Loadpoint != nil {
|
||||||
lp := site.Loadpoints()[*param.Loadpoint]
|
lp := site.Loadpoints()[*param.Loadpoint]
|
||||||
|
if lp == nil {
|
||||||
|
// disabled loadpoint- placeholder params only
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
tags["loadpoint"] = lp.GetTitle()
|
tags["loadpoint"] = lp.GetTitle()
|
||||||
if v := lp.GetVehicle(); v != nil {
|
if v := lp.GetVehicle(); v != nil {
|
||||||
|
|
|
||||||
4
server/mcp/openapi.json
generated
4
server/mcp/openapi.json
generated
|
|
@ -3133,6 +3133,10 @@
|
||||||
"description": "Duration since the vehicle was connected, in seconds.",
|
"description": "Duration since the vehicle was connected, in seconds.",
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"disabled": {
|
||||||
|
"description": "Loadpoint is disabled via configuration.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"disableDelay": {
|
"disableDelay": {
|
||||||
"description": "Delay before charging stops in solar mode, in seconds.",
|
"description": "Delay before charging stops in solar mode, in seconds.",
|
||||||
"type": "number"
|
"type": "number"
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@ func (m *MQTT) Listen(site site.API) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadpoint setters
|
// loadpoint setters
|
||||||
for id, lp := range site.Loadpoints() {
|
for id, lp := range site.ActiveLoadpoints() {
|
||||||
topic := fmt.Sprintf("%s/loadpoints/%d", m.root, id+1)
|
topic := fmt.Sprintf("%s/loadpoints/%d", m.root, id+1)
|
||||||
if err := m.listenLoadpointSetters(topic, site, lp); err != nil {
|
if err := m.listenLoadpointSetters(topic, site, lp); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -221,12 +221,12 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error {
|
||||||
{"gridExportLimit", floatSetter(site.SetGridExportLimit)},
|
{"gridExportLimit", floatSetter(site.SetGridExportLimit)},
|
||||||
{"solarAdjusted", boolSetter(pass(site.SetSolarAdjusted))},
|
{"solarAdjusted", boolSetter(pass(site.SetSolarAdjusted))},
|
||||||
{"smartCostLimit", floatPtrSetter(pass(func(limit *float64) {
|
{"smartCostLimit", floatPtrSetter(pass(func(limit *float64) {
|
||||||
for _, lp := range site.Loadpoints() {
|
for _, lp := range site.ActiveLoadpoints() {
|
||||||
lp.SetSmartCostLimit(limit)
|
lp.SetSmartCostLimit(limit)
|
||||||
}
|
}
|
||||||
}))},
|
}))},
|
||||||
{"smartFeedInPriorityLimit", floatPtrSetter(pass(func(limit *float64) {
|
{"smartFeedInPriorityLimit", floatPtrSetter(pass(func(limit *float64) {
|
||||||
for _, lp := range site.Loadpoints() {
|
for _, lp := range site.ActiveLoadpoints() {
|
||||||
lp.SetSmartFeedInPriorityLimit(limit)
|
lp.SetSmartFeedInPriorityLimit(limit)
|
||||||
}
|
}
|
||||||
}))},
|
}))},
|
||||||
|
|
|
||||||
3
server/openapi.state.yaml
generated
3
server/openapi.state.yaml
generated
|
|
@ -827,6 +827,9 @@ components:
|
||||||
connectedDuration:
|
connectedDuration:
|
||||||
description: Duration since the vehicle was connected, in seconds.
|
description: Duration since the vehicle was connected, in seconds.
|
||||||
type: number
|
type: number
|
||||||
|
disabled:
|
||||||
|
description: Loadpoint is disabled via configuration.
|
||||||
|
type: boolean
|
||||||
disableDelay:
|
disableDelay:
|
||||||
description: Delay before charging stops in solar mode, in seconds.
|
description: Delay before charging stops in solar mode, in seconds.
|
||||||
type: number
|
type: number
|
||||||
|
|
|
||||||
415
tests/config-disable.spec.ts
Normal file
415
tests/config-disable.spec.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
import { test, expect, type Page, type Locator } from "@playwright/test";
|
||||||
|
import { start, stop, restart, baseUrl } from "./evcc";
|
||||||
|
import { startSimulator, stopSimulator, simulatorHost } from "./simulator";
|
||||||
|
import {
|
||||||
|
expectModalVisible,
|
||||||
|
expectModalHidden,
|
||||||
|
addDemoCharger,
|
||||||
|
newLoadpoint,
|
||||||
|
finishLoadpoint,
|
||||||
|
openMoreMenu,
|
||||||
|
editorClear,
|
||||||
|
editorPaste,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
test.use({ baseURL: baseUrl() });
|
||||||
|
test.describe.configure({ mode: "parallel" });
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function expectNoFatal(page: Page) {
|
||||||
|
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoAcceptDialogs(page: Page) {
|
||||||
|
page.on("dialog", (dialog) => dialog.accept());
|
||||||
|
}
|
||||||
|
|
||||||
|
function disabledBadge(card: Locator) {
|
||||||
|
return card.getByRole("button", { name: "Enable" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLoadpoint(page: Page, title: string) {
|
||||||
|
await newLoadpoint(page, title);
|
||||||
|
await addDemoCharger(page);
|
||||||
|
// charger save instant-creates the loadpoint
|
||||||
|
await finishLoadpoint(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLoadpointDisable(page: Page, index: number, action: "Disable" | "Enable") {
|
||||||
|
const target = page.getByTestId("loadpoint").nth(index);
|
||||||
|
const lpModal = page.getByTestId("loadpoint-modal");
|
||||||
|
await target.getByRole("button", { name: "edit" }).click();
|
||||||
|
await expectModalVisible(lpModal);
|
||||||
|
await lpModal.getByRole("button", { name: action }).click();
|
||||||
|
await expectModalHidden(lpModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("disable / enable", async () => {
|
||||||
|
test("loadpoint", async ({ page }) => {
|
||||||
|
autoAcceptDialogs(page);
|
||||||
|
await start();
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
// two loadpoints so disabling one keeps the site valid
|
||||||
|
await createLoadpoint(page, "Carport");
|
||||||
|
// reload between creations to reset modal state
|
||||||
|
await page.reload();
|
||||||
|
await createLoadpoint(page, "Garage");
|
||||||
|
|
||||||
|
const target = page.getByTestId("loadpoint").nth(0);
|
||||||
|
|
||||||
|
await toggleLoadpointDisable(page, 0, "Disable");
|
||||||
|
|
||||||
|
// card shows disabled state
|
||||||
|
await expect(disabledBadge(target)).toBeVisible();
|
||||||
|
|
||||||
|
// restart, no fatal
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expectNoFatal(page);
|
||||||
|
await expect(disabledBadge(target)).toBeVisible();
|
||||||
|
|
||||||
|
// re-enable by clicking the disabled card
|
||||||
|
await disabledBadge(target).click();
|
||||||
|
await expect(disabledBadge(target)).toHaveCount(0);
|
||||||
|
|
||||||
|
// restart, no fatal
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expectNoFatal(page);
|
||||||
|
await expect(disabledBadge(target)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grid meter", async ({ page }) => {
|
||||||
|
autoAcceptDialogs(page);
|
||||||
|
await start();
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
const meterModal = page.getByTestId("meter-modal");
|
||||||
|
|
||||||
|
// add grid meter
|
||||||
|
await page.getByRole("button", { name: "Add grid meter" }).click();
|
||||||
|
await expectModalVisible(meterModal);
|
||||||
|
await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
|
||||||
|
await meterModal.getByLabel("Power").fill("0");
|
||||||
|
await meterModal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expectModalHidden(meterModal);
|
||||||
|
|
||||||
|
const gridCard = page.getByTestId("grid");
|
||||||
|
|
||||||
|
// open modal, disable
|
||||||
|
await gridCard.getByRole("button", { name: "edit" }).click();
|
||||||
|
await expectModalVisible(meterModal);
|
||||||
|
await expect(meterModal.getByRole("button", { name: "Disable" })).toBeVisible();
|
||||||
|
await meterModal.getByRole("button", { name: "Disable" }).click();
|
||||||
|
await expectModalHidden(meterModal);
|
||||||
|
|
||||||
|
// card shows disabled state
|
||||||
|
await expect(disabledBadge(gridCard)).toBeVisible();
|
||||||
|
|
||||||
|
// restart, no fatal
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expectNoFatal(page);
|
||||||
|
await expect(disabledBadge(gridCard)).toBeVisible();
|
||||||
|
|
||||||
|
// re-enable by clicking the disabled card
|
||||||
|
await disabledBadge(gridCard).click();
|
||||||
|
await expect(disabledBadge(gridCard)).toHaveCount(0);
|
||||||
|
|
||||||
|
// restart, no fatal
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expectNoFatal(page);
|
||||||
|
await expect(disabledBadge(gridCard)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pv meter", async ({ page }) => {
|
||||||
|
autoAcceptDialogs(page);
|
||||||
|
await start();
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
const meterModal = page.getByTestId("meter-modal");
|
||||||
|
|
||||||
|
// add pv meter
|
||||||
|
await page.getByRole("button", { name: "Add solar or battery" }).click();
|
||||||
|
await expectModalVisible(meterModal);
|
||||||
|
await meterModal.getByRole("button", { name: "Add solar meter" }).click();
|
||||||
|
await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
|
||||||
|
await meterModal.getByLabel("Title").fill("PV");
|
||||||
|
await meterModal.getByLabel("Power").fill("0");
|
||||||
|
await meterModal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expectModalHidden(meterModal);
|
||||||
|
|
||||||
|
const pvCard = page.getByTestId("pv");
|
||||||
|
|
||||||
|
// open modal, disable
|
||||||
|
await pvCard.getByRole("button", { name: "edit" }).click();
|
||||||
|
await expectModalVisible(meterModal);
|
||||||
|
await expect(meterModal.getByRole("button", { name: "Disable" })).toBeVisible();
|
||||||
|
await meterModal.getByRole("button", { name: "Disable" }).click();
|
||||||
|
await expectModalHidden(meterModal);
|
||||||
|
|
||||||
|
// card shows disabled state
|
||||||
|
await expect(disabledBadge(pvCard)).toBeVisible();
|
||||||
|
|
||||||
|
// restart, no fatal
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expectNoFatal(page);
|
||||||
|
await expect(disabledBadge(pvCard)).toBeVisible();
|
||||||
|
|
||||||
|
// re-enable by clicking the disabled card
|
||||||
|
await disabledBadge(pvCard).click();
|
||||||
|
await expect(disabledBadge(pvCard)).toHaveCount(0);
|
||||||
|
|
||||||
|
// restart, no fatal
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expectNoFatal(page);
|
||||||
|
await expect(disabledBadge(pvCard)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grid tariff", async ({ page }) => {
|
||||||
|
autoAcceptDialogs(page);
|
||||||
|
await start();
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
const tariffModal = page.getByTestId("tariff-modal");
|
||||||
|
const gridCard = page.getByTestId("tariff-grid");
|
||||||
|
|
||||||
|
// add grid tariff
|
||||||
|
await page.getByRole("button", { name: "Add tariff" }).click();
|
||||||
|
await expectModalVisible(tariffModal);
|
||||||
|
await tariffModal.getByRole("button", { name: "Add grid import tariff" }).click();
|
||||||
|
await tariffModal.getByLabel("Provider").selectOption("Fixed Price");
|
||||||
|
await tariffModal.getByLabel("Price").fill("32.1");
|
||||||
|
await tariffModal.getByRole("button", { name: "Validate & save" }).click();
|
||||||
|
await expectModalHidden(tariffModal);
|
||||||
|
|
||||||
|
// open modal, disable
|
||||||
|
await gridCard.getByRole("button", { name: "edit" }).click();
|
||||||
|
await expectModalVisible(tariffModal);
|
||||||
|
await tariffModal.getByRole("button", { name: "Disable" }).click();
|
||||||
|
await expectModalHidden(tariffModal);
|
||||||
|
|
||||||
|
// card shows disabled state
|
||||||
|
await expect(disabledBadge(gridCard)).toBeVisible();
|
||||||
|
|
||||||
|
// restart, tariff not instantiated
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expectNoFatal(page);
|
||||||
|
await expect(disabledBadge(gridCard)).toBeVisible();
|
||||||
|
await expect(gridCard).not.toContainText("32.1");
|
||||||
|
|
||||||
|
// re-enable by clicking the disabled card
|
||||||
|
await disabledBadge(gridCard).click();
|
||||||
|
await expect(disabledBadge(gridCard)).toHaveCount(0);
|
||||||
|
|
||||||
|
// restart, tariff active again
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expectNoFatal(page);
|
||||||
|
await expect(disabledBadge(gridCard)).toHaveCount(0);
|
||||||
|
await expect(gridCard).toContainText(["Price", "32.1 ct"].join(""));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("user-defined vehicle", async ({ page }) => {
|
||||||
|
autoAcceptDialogs(page);
|
||||||
|
await start();
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
// add user-defined vehicle
|
||||||
|
await page.getByTestId("add-vehicle").click();
|
||||||
|
const modal = page.getByTestId("vehicle-modal");
|
||||||
|
await expectModalVisible(modal);
|
||||||
|
await modal.getByLabel("Manufacturer").selectOption("User-defined device");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
const editor = modal.getByTestId("yaml-editor");
|
||||||
|
await editorClear(editor);
|
||||||
|
await editorPaste(
|
||||||
|
editor,
|
||||||
|
page,
|
||||||
|
`title: blue Honda
|
||||||
|
capacity: 12.3
|
||||||
|
soc:
|
||||||
|
source: const
|
||||||
|
value: 42`
|
||||||
|
);
|
||||||
|
await modal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expectModalHidden(modal);
|
||||||
|
|
||||||
|
const vehicleCard = page.getByTestId("vehicle");
|
||||||
|
await expect(vehicleCard).toHaveCount(1);
|
||||||
|
|
||||||
|
// open modal, disable
|
||||||
|
await vehicleCard.getByRole("button", { name: "edit" }).click();
|
||||||
|
await expectModalVisible(modal);
|
||||||
|
await modal.getByRole("button", { name: "Disable" }).click();
|
||||||
|
await expectModalHidden(modal);
|
||||||
|
|
||||||
|
// card shows disabled state
|
||||||
|
await expect(disabledBadge(vehicleCard)).toBeVisible();
|
||||||
|
|
||||||
|
// restart, no fatal
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expectNoFatal(page);
|
||||||
|
await expect(disabledBadge(vehicleCard)).toBeVisible();
|
||||||
|
|
||||||
|
// re-enable by clicking the disabled card
|
||||||
|
await disabledBadge(vehicleCard).click();
|
||||||
|
await expect(disabledBadge(vehicleCard)).toHaveCount(0);
|
||||||
|
|
||||||
|
// restart, yaml config intact
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expectNoFatal(page);
|
||||||
|
await expect(vehicleCard).toContainText("blue Honda");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("disabled loadpoint behavior", async () => {
|
||||||
|
test("broken charger boots after disabling its loadpoint", async ({ page }) => {
|
||||||
|
autoAcceptDialogs(page);
|
||||||
|
await startSimulator();
|
||||||
|
await start();
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
// second loadpoint so the site stays valid
|
||||||
|
await createLoadpoint(page, "Carport");
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// loadpoint with shelly charger against simulator
|
||||||
|
await newLoadpoint(page, "Garage");
|
||||||
|
const lpModal = page.getByTestId("loadpoint-modal");
|
||||||
|
await lpModal.getByRole("button", { name: "Add charger" }).click();
|
||||||
|
const chargerModal = page.getByTestId("charger-modal");
|
||||||
|
await expectModalVisible(chargerModal);
|
||||||
|
await chargerModal.getByLabel("Manufacturer").selectOption("Shelly 1");
|
||||||
|
await chargerModal.getByLabel("IP address or hostname").fill(simulatorHost());
|
||||||
|
await chargerModal.getByRole("button", { name: "Validate & save" }).click();
|
||||||
|
await expectModalHidden(chargerModal);
|
||||||
|
await expectModalVisible(lpModal);
|
||||||
|
// charger save instant-creates the loadpoint
|
||||||
|
await finishLoadpoint(page);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// break charger
|
||||||
|
await stopSimulator();
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId("fatal-error")).toBeVisible();
|
||||||
|
|
||||||
|
// disable loadpoint with broken charger
|
||||||
|
await toggleLoadpointDisable(page, 1, "Disable");
|
||||||
|
await expect(disabledBadge(page.getByTestId("loadpoint").nth(1))).toBeVisible();
|
||||||
|
|
||||||
|
// clean boot, charger no longer instantiated
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expectNoFatal(page);
|
||||||
|
await expect(disabledBadge(page.getByTestId("loadpoint").nth(1))).toBeVisible();
|
||||||
|
|
||||||
|
// re-enable, fatal returns
|
||||||
|
await disabledBadge(page.getByTestId("loadpoint").nth(1)).click();
|
||||||
|
await expect(disabledBadge(page.getByTestId("loadpoint").nth(1))).toHaveCount(0);
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId("fatal-error")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disabling a loadpoint keeps api indexes stable", async ({ page }) => {
|
||||||
|
autoAcceptDialogs(page);
|
||||||
|
await start();
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
await createLoadpoint(page, "Carport");
|
||||||
|
await page.reload();
|
||||||
|
await createLoadpoint(page, "Garage");
|
||||||
|
await page.reload();
|
||||||
|
await createLoadpoint(page, "Süd");
|
||||||
|
|
||||||
|
// disable middle loadpoint
|
||||||
|
await toggleLoadpointDisable(page, 1, "Disable");
|
||||||
|
await restart();
|
||||||
|
|
||||||
|
// main ui hides disabled loadpoint
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByTestId("loadpoint")).toHaveCount(2);
|
||||||
|
await expect(page.getByRole("heading", { name: "Garage" })).not.toBeVisible();
|
||||||
|
await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport");
|
||||||
|
await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Süd");
|
||||||
|
|
||||||
|
// state keeps disabled loadpoint at its position
|
||||||
|
const state = await (await page.request.get("/api/state")).json();
|
||||||
|
expect(state.loadpoints).toHaveLength(3);
|
||||||
|
expect(state.loadpoints[1].disabled).toBe(true);
|
||||||
|
expect(state.loadpoints[1].title).toBe("Garage");
|
||||||
|
|
||||||
|
// api index of third loadpoint does not shift
|
||||||
|
const res = await page.request.post("/api/loadpoints/3/mode/now");
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const updated = await (await page.request.get("/api/state")).json();
|
||||||
|
expect(updated.loadpoints[2].mode).toBe("now");
|
||||||
|
expect(updated.loadpoints[0].mode).not.toBe("now");
|
||||||
|
|
||||||
|
// disabled loadpoint has no api routes
|
||||||
|
const disabledRes = await page.request.post("/api/loadpoints/2/mode/now");
|
||||||
|
expect(disabledRes.status()).toBe(404);
|
||||||
|
|
||||||
|
// ui settings show disabled loadpoint as inactive row
|
||||||
|
const menu = await openMoreMenu(page);
|
||||||
|
await menu.getByRole("button", { name: "User Interface" }).click();
|
||||||
|
const modal = page.getByTestId("global-settings-modal");
|
||||||
|
await expectModalVisible(modal);
|
||||||
|
const garageRow = modal.getByRole("listitem", { name: "Draggable: Garage" });
|
||||||
|
await expect(garageRow).toContainText("disabled");
|
||||||
|
await expect(modal.getByRole("switch", { name: "Hide Garage" })).toHaveCount(0);
|
||||||
|
await expect(modal.getByRole("switch", { name: "Hide Carport" })).toBeEnabled();
|
||||||
|
|
||||||
|
// last-visible guard ignores disabled loadpoint
|
||||||
|
await modal.getByRole("switch", { name: "Hide Carport" }).click();
|
||||||
|
await expect(modal.getByRole("switch", { name: "Hide Süd" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editing a disabled loadpoint", async ({ page }) => {
|
||||||
|
autoAcceptDialogs(page);
|
||||||
|
await start();
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
await createLoadpoint(page, "Carport");
|
||||||
|
await page.reload();
|
||||||
|
await createLoadpoint(page, "Garage");
|
||||||
|
|
||||||
|
await toggleLoadpointDisable(page, 1, "Disable");
|
||||||
|
|
||||||
|
const target = page.getByTestId("loadpoint").nth(1);
|
||||||
|
const lpModal = page.getByTestId("loadpoint-modal");
|
||||||
|
|
||||||
|
// update title while disabled
|
||||||
|
await target.getByRole("button", { name: "edit" }).click();
|
||||||
|
await expectModalVisible(lpModal);
|
||||||
|
await lpModal.getByLabel("Title").fill("Garage 2");
|
||||||
|
await lpModal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expectModalHidden(lpModal);
|
||||||
|
await expect(target).toContainText("Garage 2");
|
||||||
|
|
||||||
|
// survives restart, still disabled
|
||||||
|
await restart();
|
||||||
|
await page.reload();
|
||||||
|
await expect(target).toContainText("Garage 2");
|
||||||
|
await expect(disabledBadge(target)).toBeVisible();
|
||||||
|
|
||||||
|
// re-enable, title applies to live loadpoint
|
||||||
|
await disabledBadge(target).click();
|
||||||
|
await expect(disabledBadge(target)).toHaveCount(0);
|
||||||
|
await restart();
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByRole("heading", { name: "Garage 2" })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -24,6 +24,7 @@ type Config struct {
|
||||||
|
|
||||||
type Properties struct {
|
type Properties struct {
|
||||||
Type string
|
Type string
|
||||||
|
Disable bool `json:"deviceDisable,omitempty" mapstructure:"deviceDisable"`
|
||||||
Title string `json:"deviceTitle,omitempty" mapstructure:"deviceTitle"`
|
Title string `json:"deviceTitle,omitempty" mapstructure:"deviceTitle"`
|
||||||
Icon string `json:"deviceIcon,omitempty" mapstructure:"deviceIcon"`
|
Icon string `json:"deviceIcon,omitempty" mapstructure:"deviceIcon"`
|
||||||
Product string `json:"deviceProduct,omitempty" mapstructure:"deviceProduct"`
|
Product string `json:"deviceProduct,omitempty" mapstructure:"deviceProduct"`
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,10 @@ func Tariffs() Handler[api.Tariff] {
|
||||||
func Instances[T any](devices []Device[T]) []T {
|
func Instances[T any](devices []Device[T]) []T {
|
||||||
res := make([]T, 0, len(devices))
|
res := make([]T, 0, len(devices))
|
||||||
for _, dev := range devices {
|
for _, dev := range devices {
|
||||||
res = append(res, dev.Instance())
|
// skip disabled devices without instance
|
||||||
|
if inst := dev.Instance(); any(inst) != nil {
|
||||||
|
res = append(res, inst)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export default defineConfig({
|
||||||
files: ["assets/**/*.{ts,js,vue}", "tests/**/*.ts"],
|
files: ["assets/**/*.{ts,js,vue}", "tests/**/*.ts"],
|
||||||
rules: {
|
rules: {
|
||||||
"no-param-reassign": "error",
|
"no-param-reassign": "error",
|
||||||
|
"no-unused-vars": ["error", { ignoreRestSiblings: true }],
|
||||||
"vue/require-default-prop": "off",
|
"vue/require-default-prop": "off",
|
||||||
"vue/no-reserved-component-names": "off",
|
"vue/no-reserved-component-names": "off",
|
||||||
"typescript/no-explicit-any": "off",
|
"typescript/no-explicit-any": "off",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue