Config UI: make messengers configurable (#26946)

Co-authored-by: andig <cpuidle@gmail.com>
Co-authored-by: andig <andi@evcc.io>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Maschga 2026-02-17 12:26:03 +01:00 • committed by GitHub
parent 66c31118ad
commit 7f772bad73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 1547 additions and 363 deletions

View file

@ -291,3 +291,8 @@ type Circuit interface {
type Redactor interface {
Redacted() any
}
// Messenger implements message sending
type Messenger interface {
Send(title, msg string)
}

View file

@ -34,34 +34,35 @@ const (
)
type All struct {
Network Network
Ocpp ocpp.Config
Log string
SponsorToken string
Plant string // telemetry plant id
Telemetry bool
Mcp bool
Metrics bool
Profile bool
Levels map[string]string
Interval time.Duration
Database DB
Mqtt Mqtt
ModbusProxy []ModbusProxy
Javascript []Javascript
Go []Go
Influx Influx
EEBus eebus.Config
HEMS Hems
SHM shm.Config
Messaging Messaging
Meters []config.Named
Chargers []config.Named
Vehicles []config.Named
Tariffs Tariffs
Site map[string]any
Loadpoints []config.Named
Circuits []config.Named
Network Network
Ocpp ocpp.Config
Log string
SponsorToken string
Plant string // telemetry plant id
Telemetry bool
Mcp bool
Metrics bool
Profile bool
Levels map[string]string
Interval time.Duration
Database DB
Mqtt Mqtt
ModbusProxy []ModbusProxy
Javascript []Javascript
Go []Go
Influx Influx
EEBus eebus.Config
HEMS Hems
SHM shm.Config
Messaging Messaging
MessagingEvents MessagingEvents
Meters []config.Named
Chargers []config.Named
Vehicles []config.Named
Tariffs Tariffs
Site map[string]any
Loadpoints []config.Named
Circuits []config.Named
}
type Javascript struct {
@ -146,13 +147,17 @@ type DB struct {
}
type Messaging struct {
Events map[string]MessagingEventTemplate
Events MessagingEvents
Services []config.Typed
}
type MessagingEvents = map[string]MessagingEventTemplate
// MessagingEventTemplate is the push message configuration for an event
type MessagingEventTemplate struct {
Title, Msg string
Title string `json:"title"`
Msg string `json:"msg"`
Disabled bool `json:"disabled"`
}
func (c Messaging) IsConfigured() bool {

View file

@ -773,3 +773,26 @@ input::-webkit-date-and-time-value {
.tabular {
font-variant-numeric: tabular-nums;
}
/* Collapsible wrapper pattern with choreographed animation */
.collapsible-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows var(--evcc-transition-fast) ease 0.2s;
}
.collapsible-wrapper.open {
grid-template-rows: 1fr;
transition: grid-template-rows var(--evcc-transition-fast) ease;
}
.collapsible-content {
overflow: hidden;
opacity: 0;
transition: opacity 0.2s ease;
}
.collapsible-wrapper.open .collapsible-content {
opacity: 1;
transition: opacity var(--evcc-transition-fast) ease 0.2s;
}

View file

@ -1,6 +1,6 @@
<template>
<div class="strategy-wrapper" :class="{ open: show }">
<div class="strategy-content">
<div class="collapsible-wrapper" :class="{ open: show }">
<div class="collapsible-content pb-3">
<div v-if="disabled" class="row mb-4">
<div class="small text-muted">
<strong class="text-primary">{{ $t("general.note") }}</strong>
@ -142,33 +142,3 @@ export default defineComponent({
},
});
</script>
<style scoped>
.strategy-wrapper {
display: grid;
grid-template-rows: 0fr;
margin-bottom: 0;
transition:
grid-template-rows 0.3s ease 0.2s,
margin-bottom 0.3s ease 0.2s;
}
.strategy-wrapper.open {
grid-template-rows: 1fr;
margin-bottom: 1rem;
transition:
grid-template-rows 0.3s ease,
margin-bottom 0.3s ease;
}
.strategy-content {
overflow: hidden;
opacity: 0;
transition: opacity 0.2s ease;
}
.open .strategy-content {
opacity: 1;
transition: opacity 0.3s ease 0.2s;
}
</style>

View file

@ -19,20 +19,12 @@
:title="name"
>{{ title }}</strong
>
<button
ref="tooltip"
type="button"
class="btn btn-sm btn-outline-secondary position-relative border-0 p-2 edit-button"
:class="{ 'opacity-25': !editable, invisible: noEditButton }"
data-bs-toggle="tooltip"
data-bs-html="true"
:title="tooltipTitle"
:aria-label="editable ? $t('config.main.edit') : null"
:disabled="!editable || noEditButton"
@click="edit"
>
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
</button>
<DeviceCardEditIcon
:editable="editable"
:noEditButton="noEditButton"
:badge="badge"
@edit="$emit('edit')"
/>
</div>
<div v-if="$slots.tags">
<hr class="my-3 divide" />
@ -42,12 +34,11 @@
</template>
<script>
import "@h2d2/shopicons/es/regular/adjust";
import "@h2d2/shopicons/es/regular/invoice";
import Tooltip from "bootstrap/js/dist/tooltip";
import DeviceCardEditIcon from "./DeviceCardEditIcon.vue";
export default {
name: "DeviceCard",
components: { DeviceCardEditIcon },
props: {
name: String,
title: String,
@ -56,49 +47,9 @@ export default {
unconfigured: Boolean,
warning: Boolean,
noEditButton: Boolean,
badge: Boolean,
},
emits: ["edit"],
data() {
return {
tooltip: null,
};
},
computed: {
tooltipTitle() {
if (!this.name) {
return "";
}
let title = `${this.$t("config.main.name")}: <span class='font-monospace'>${this.name}</span>`;
if (!this.editable) {
title += `<div class="mt-1">${this.$t("config.general.fromYamlHint")}</div>`;
}
return `<div class="text-start">${title}</div>`;
},
},
watch: {
tooltipTitle() {
this.initTooltip();
},
},
mounted() {
this.initTooltip();
},
methods: {
edit() {
if (this.editable) {
this.tooltip?.hide();
this.$emit("edit");
}
},
initTooltip() {
this.$nextTick(() => {
this.tooltip?.dispose();
if (this.$refs.tooltip) {
this.tooltip = new Tooltip(this.$refs.tooltip);
}
});
},
},
};
</script>
@ -129,15 +80,8 @@ export default {
.icon:empty {
display: none;
}
.edit-button {
/* transparent button, right align icon */
margin-right: -0.5rem;
}
.divide {
margin-left: -1.5rem;
margin-right: -1.5rem;
}
button:disabled {
pointer-events: auto;
}
</style>

View file

@ -0,0 +1,84 @@
<template>
<button
ref="tooltip"
type="button"
class="btn btn-sm btn-outline-secondary position-relative border-0 p-2 edit-button"
:class="{ 'opacity-25': !editable, invisible: noEditButton }"
data-bs-toggle="tooltip"
data-bs-html="true"
:title="tooltipTitle"
:aria-label="editable ? $t('config.main.edit') : null"
:disabled="!editable || noEditButton"
@click="edit"
>
<span
v-if="badge"
class="position-absolute top-0 start-100 translate-middle p-2 rounded-circle bg-warning"
>
<span class="visually-hidden">new</span>
</span>
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
</button>
</template>
<script>
import "@h2d2/shopicons/es/regular/adjust";
import Tooltip from "bootstrap/js/dist/tooltip";
export default {
name: "DeviceCardEditIcon",
props: {
editable: Boolean,
noEditButton: Boolean,
badge: Boolean,
},
emits: ["edit"],
data() {
return {
tooltip: null,
};
},
computed: {
tooltipTitle() {
if (!this.editable) {
return `<div class="text-start mt-1">${this.$t("config.general.fromYamlHint")}</div>`;
}
return "";
},
},
watch: {
tooltipTitle() {
this.initTooltip();
},
},
mounted() {
this.initTooltip();
},
methods: {
edit() {
if (this.editable) {
this.tooltip?.hide();
this.$emit("edit");
}
},
initTooltip() {
this.$nextTick(() => {
this.tooltip?.dispose();
if (this.$refs.tooltip && this.tooltipTitle) {
this.tooltip = new Tooltip(this.$refs.tooltip);
}
});
},
},
};
</script>
<style scoped>
.edit-button {
/* transparent button, right align icon */
margin-right: -0.5rem;
}
button:disabled {
pointer-events: auto;
}
</style>

View file

@ -7,7 +7,7 @@
@test="$emit('test')"
/>
<div class="my-4 d-flex justify-content-between">
<div class="mt-4 d-flex justify-content-between">
<button
v-if="isDeletable"
type="button"

View file

@ -0,0 +1,35 @@
<template>
<div class="dropdown dropdown-center">
<button
type="button"
class="btn btn-link evcc-default-text p-0"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<shopicon-regular-info></shopicon-regular-info>
</button>
<ul class="dropdown-menu">
<li>
<span class="dropdown-item-text font-monospace"> db:{{ id }} </span>
</li>
</ul>
</div>
</template>
<script>
import { defineComponent } from "vue";
import "@h2d2/shopicons/es/regular/info";
export default defineComponent({
name: "DeviceInfoButton",
props: {
id: { type: Number, required: true },
},
});
</script>
<style scoped>
.dropdown-menu {
min-width: auto;
}
</style>

View file

@ -10,6 +10,9 @@
@close="handleClose"
@visibilitychange="handleVisibilityChange"
>
<template #header-actions>
<DeviceInfoButton v-if="id" :id="id" />
</template>
<form ref="form" class="container mx-0 px-0">
<slot name="pre-content" :values="values"></slot>
@ -167,6 +170,7 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import GenericModal from "../../Helper/GenericModal.vue";
import DeviceInfoButton from "./DeviceInfoButton.vue";
import { closeModal } from "@/configModal";
import ErrorMessage from "../../Helper/ErrorMessage.vue";
import PropertyEntry from "../PropertyEntry.vue";
@ -205,6 +209,7 @@ export default defineComponent({
name: "DeviceModalBase",
components: {
GenericModal,
DeviceInfoButton,
ErrorMessage,
PropertyEntry,
PropertyCollapsible,

View file

@ -19,7 +19,7 @@
{{ error }}
</p>
<form ref="form" class="container mx-0 px-0" @submit.prevent="save">
<slot :values="values"></slot>
<slot :values="values" :changes="!nothingChanged" :save="save"></slot>
<div
v-if="!noButtons"
@ -54,6 +54,7 @@
</div>
<button
v-if="!disableSave"
type="submit"
class="btn btn-primary order-1 order-sm-2 flex-grow-1 flex-sm-grow-0 px-4"
:disabled="saving || nothingChanged"
@ -77,6 +78,7 @@ import api from "@/api";
import { docsPrefix } from "@/i18n";
import store from "@/store";
import deepClone from "@/utils/deepClone";
import { closeModal } from "@/configModal";
export default {
name: "JsonModal",
@ -89,6 +91,7 @@ export default {
endpoint: String,
disableCancel: Boolean,
disableRemove: Boolean,
disableSave: Boolean,
noButtons: Boolean,
transformReadValues: Function,
transformWriteValues: Function,
@ -147,7 +150,7 @@ export default {
}
this.values = deepClone(this.serverValues);
},
async save() {
async save(shouldClose = true) {
this.saving = true;
this.error = "";
try {
@ -160,7 +163,11 @@ export default {
});
if (res.status === 200 || res.status === 202) {
this.$emit("changed");
this.$refs.modal.close();
if (shouldClose) {
await closeModal();
} else {
await this.load();
}
}
if (res.status === 400) {
this.error = res.data.error;

View file

@ -13,7 +13,9 @@ export default {
},
computed: {
compiledMarkdown() {
return snarkdown(this.markdown);
const html = snarkdown(this.markdown);
// open all links in new window
return html.replace(/<a href=/g, '<a target="_blank" rel="noopener noreferrer" href=');
},
},
};

View file

@ -0,0 +1,134 @@
<template>
<div
:data-testid="`event-${type}`"
role="group"
:aria-label="type"
class="collapsible-wrapper mb-2"
:class="{ open: !disabled }"
>
<div class="form-check form-switch mb-4">
<input
:id="formId('switch')"
:checked="!disabled"
class="form-check-input form-check-input"
type="checkbox"
role="switch"
tabindex="0"
@change="updateDisabled(($event.target as HTMLInputElement).checked)"
/>
<label :for="formId('switch')" class="form-check-label fw-bold">
{{ $t(`config.messaging.event.${type}.title`) }}
</label>
</div>
<div class="collapsible-content">
<div class="row mb-3">
<div class="col-md-2 col-form-label">
<label :for="formId('title')">{{ $t("config.messaging.eventTitle") }}</label>
</div>
<div class="col-md-10">
<PropertyField
:id="formId('title')"
:model-value="title"
type="String"
:disabled="disabled"
required
@update:model-value="updateTitle"
/>
</div>
</div>
<div class="row mb-5">
<div class="col-md-2 col-form-label">
<label :for="formId('message')">{{
$t("config.messaging.eventMessage")
}}</label>
</div>
<div class="col-md-10">
<PropertyField
:id="formId('message')"
:model-value="message"
type="String"
property="eventMessage"
:disabled="disabled"
:rows="3"
required
@change="updateMessage($event.target.value)"
/>
<div class="text-end small text-gray mt-1">
<a
:href="`${docsPrefix()}/docs/reference/configuration/messaging#msg`"
target="_blank"
class="text-gray"
>
{{ $t("config.messaging.seePlaceholders") }}
</a>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import type { PropType } from "vue";
import { MESSAGING_EVENTS } from "@/types/evcc";
import PropertyField from "../PropertyField.vue";
import { docsPrefix } from "@/i18n";
const EVENT_PARAMS: Record<MESSAGING_EVENTS, Record<string, string>> = {
asleep: { vehicleName: "{{ if .vehicleTitle }}{{ .vehicleTitle }} {{ end }}" },
connect: { pvPower: "${pvPower:%.1fk}" },
disconnect: { connectedDuration: "${connectedDuration}" },
soc: { vehicleSoc: "${vehicleSoc:%.0f}" },
start: { mode: "${mode}" },
stop: { chargedEnergy: "${chargedEnergy:%.1fk}", chargeDuration: "${chargeDuration}" },
planoverrun: { vehicleTitle: "{{ if .vehicleTitle }} {{ .vehicleTitle }} {{end}}" },
guest: {},
};
export default {
name: "EventItem",
components: { PropertyField },
props: {
type: { type: String as PropType<MESSAGING_EVENTS>, required: true },
disabled: { type: Boolean, required: true },
title: { type: String, required: true },
message: { type: String, required: true },
},
emits: ["update:disabled", "update:title", "update:message"],
mounted() {
if (!this.title) {
this.updateTitle(this.$t(`config.messaging.event.${this.type}.titleDefault`));
}
if (!this.message || this.message === "") {
const p = EVENT_PARAMS[this.type] || {};
this.updateMessage(this.$t(`config.messaging.event.${this.type}.messageDefault`, p));
}
},
methods: {
docsPrefix,
formId(name: string) {
return `messaging-event-${this.type}-${name}`;
},
updateDisabled(newValue: boolean) {
this.$emit("update:disabled", !newValue);
},
updateTitle(newValue: string) {
this.$emit("update:title", newValue);
},
updateMessage(newValue: string) {
this.$emit("update:message", newValue);
},
},
};
</script>
<style scoped>
.collapsible-wrapper {
grid-template-rows: auto 0fr;
}
.collapsible-wrapper.open {
grid-template-rows: auto 1fr;
}
</style>

View file

@ -0,0 +1,34 @@
<template>
<YamlModal
id="messagingLegacyModal"
name="messaginglegacy"
:title="`${$t('config.messaging.title')} (${$t('config.general.legacy')})`"
:description="$t('config.messaging.description')"
docs="/docs/reference/configuration/messaging"
:defaultYaml="defaultYaml"
endpoint="/config/messaging"
removeKey="messaging"
data-testid="messaging-legacy-modal"
@changed="$emit('changed')"
>
<template #afterDescription>
<div class="alert alert-warning my-4" role="alert">
{{ $t("config.messaging.legacyWarning") }}
</div>
</template>
</YamlModal>
</template>
<script>
import YamlModal from "../YamlModal.vue";
import defaultYaml from "../defaultYaml/messaging.yaml?raw";
export default {
name: "MessagingLegacyModal",
components: { YamlModal },
emits: ["changed"],
data() {
return { defaultYaml: defaultYaml.trim() };
},
};
</script>

View file

@ -0,0 +1,172 @@
<template>
<JsonModal
id="messagingModal"
name="messaging"
:title="$t('config.messaging.title')"
:description="$t('config.messaging.description')"
docs="/docs/reference/configuration/messaging"
endpoint="/config/messagingEvents"
state-key="messagingEvents"
data-testid="messaging-modal"
size="xl"
:transform-read-values="transformReadValues"
:disable-save="!activeEventsTab"
:disable-cancel="!activeEventsTab"
disable-remove
@changed="$emit('changed')"
>
<template
#default="{
values,
changes,
save,
}: {
values: MessagingEvents;
changes: boolean;
save: (shouldClose?: boolean) => Promise<void>;
}"
>
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a
class="nav-link tabular"
:class="{ active: activeEventsTab }"
href="#"
@click.prevent="switchToTab(true, changes, save)"
>
{{ $t("config.messaging.events") }} ({{ eventCount(values) }})
</a>
</li>
<li class="nav-item">
<a
class="nav-link tabular"
:class="{ active: !activeEventsTab }"
href="#"
@click.prevent="switchToTab(false, changes, save)"
>
{{ $t("config.messaging.messengers") }} ({{ messengers.length }})
</a>
</li>
</ul>
<div v-if="activeEventsTab" class="my-5">
<div class="mb-5">
<EventItem
v-for="event in visibleEvents(values)"
:key="event"
v-model:disabled="values[event].disabled"
v-model:title="values[event].title"
v-model:message="values[event].msg"
:type="event"
/>
</div>
</div>
<div v-else>
<div v-for="(m, index) in messengers" :key="index" class="my-4">
<div
class="d-flex align-items-center justify-content-between py-2 px-4 border rounded"
:data-testid="`messenger-box-${index}`"
>
<div class="flex-grow-1">
<small class="text-muted">#{{ index + 1 }}</small>
<span class="fw-semibold mx-3">{{ messengerType(m) }}</span>
</div>
<DeviceCardEditIcon
:editable="true"
:no-edit-button="false"
@edit="openMessenger(m.id)"
/>
</div>
</div>
<button
type="button"
class="d-flex btn btn-sm btn-outline-secondary border-0 align-items-center gap-2 evcc-gray"
tabindex="0"
@click="openMessenger()"
>
<shopicon-regular-plus size="s" class="flex-shrink-0"></shopicon-regular-plus>
{{ $t("config.messaging.addMessenger") }}
</button>
</div>
</template>
</JsonModal>
</template>
<script lang="ts">
import { MESSAGING_EVENTS, type ConfigMessenger, type MessagingEvents } from "@/types/evcc";
import "@h2d2/shopicons/es/regular/plus";
import JsonModal from "../JsonModal.vue";
import EventItem from "./EventItem.vue";
import { type PropType } from "vue";
import DeviceCardEditIcon from "../DeviceCardEditIcon.vue";
import { capitalize } from "./utils";
import { openModal } from "@/configModal";
export default {
name: "MessagingModal",
components: {
JsonModal,
EventItem,
DeviceCardEditIcon,
},
props: {
messengers: { type: Array as PropType<ConfigMessenger[]>, required: true },
},
emits: ["changed"],
data() {
return {
activeEventsTab: true,
};
},
computed: {
events() {
return Object.values(MESSAGING_EVENTS);
},
},
methods: {
visibleEvents(values: MessagingEvents) {
return this.events.filter((e) => values[e]);
},
eventCount(events: MessagingEvents) {
return Object.values(events).filter((e) => !e.disabled).length;
},
transformReadValues(values: MessagingEvents) {
const v = values ?? {};
this.events.forEach((event) => {
const e = v[event];
v[event] = {
disabled: e?.disabled ?? true,
title: e?.title ?? "",
msg: e?.msg ?? "",
};
});
return v;
},
async openMessenger(id?: number) {
await openModal("messenger", { id });
},
messengerType(m: ConfigMessenger) {
const type =
m.type === "custom" ? this.$t("config.messenger.custom") : m.config.template;
return capitalize(type ?? "");
},
async switchToTab(
isEventsTab: boolean,
changes: boolean,
save: (shouldClose?: boolean) => Promise<void>
) {
if (this.activeEventsTab === isEventsTab) return;
if (this.activeEventsTab && !isEventsTab && changes) {
if (!window.confirm(this.$t("config.general.confirmSave"))) {
return;
}
await save(false);
}
this.activeEventsTab = isEventsTab;
},
},
};
</script>

View file

@ -0,0 +1,74 @@
<template>
<DeviceModalBase
:id="id"
name="messenger"
device-type="messenger"
:modal-title="$t(`config.messenger.${isNew ? 'titleAdd' : 'titleEdit'}`)"
:provide-template-options="provideTemplateOptions"
:initial-values="initialValues"
:on-template-change="handleTemplateChange"
@added="$emit('changed', $event)"
@updated="$emit('changed')"
@removed="$emit('changed')"
></DeviceModalBase>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import DeviceModalBase from "../DeviceModal/DeviceModalBase.vue";
import type { DeviceValues, Product } from "../DeviceModal";
import { type TemplateGroup, customTemplateOption } from "../DeviceModal/TemplateSelector.vue";
import { ConfigType } from "@/types/evcc";
import defaultMessengerYaml from "../defaultYaml/messenger.yaml?raw";
import { getModal } from "@/configModal";
const initialValues = {
type: ConfigType.Template,
template: null,
};
export default defineComponent({
name: "MessengerModal",
components: {
DeviceModalBase,
},
emits: ["changed"],
data() {
return {
initialValues,
};
},
computed: {
id(): number | undefined {
return getModal("messenger")?.id;
},
isNew(): boolean {
return this.id === undefined;
},
},
methods: {
provideTemplateOptions(products: Product[]): TemplateGroup[] {
return [
{
label: "generic",
options: [
...products.filter((p: Product) => p.group === "generic"),
customTemplateOption(this.$t("config.messenger.custom")),
],
},
{
label: "primary",
options: [...products.filter((p: Product) => p.group !== "generic")],
},
];
},
handleTemplateChange(e: Event, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value;
if (value === ConfigType.Custom) {
values.type = ConfigType.Custom;
values.yaml = defaultMessengerYaml;
}
},
},
});
</script>

View file

@ -0,0 +1,3 @@
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}

View file

@ -1,26 +0,0 @@
<template>
<YamlModal
name="messaging"
:title="$t('config.messaging.title')"
:description="$t('config.messaging.description')"
docs="/docs/reference/configuration/messaging"
:defaultYaml="defaultYaml"
endpoint="/config/messaging"
removeKey="messaging"
@changed="$emit('changed')"
/>
</template>
<script>
import YamlModal from "./YamlModal.vue";
import defaultYaml from "./defaultYaml/messaging.yaml?raw";
export default {
name: "MessagingModal",
components: { YamlModal },
emits: ["changed"],
data() {
return { defaultYaml: defaultYaml.trim() };
},
};
</script>

View file

@ -13,6 +13,7 @@
:class="selectMode ? 'btn-check' : 'd-none'"
:name="property"
:value="key"
:disabled="disabled"
@click="toggleSelectMode"
/>
<label
@ -42,11 +43,24 @@
{ value: false, name: $t('config.options.boolean.no') },
{ value: true, name: $t('config.options.boolean.yes') },
]"
:disabled="disabled"
/>
<select v-else-if="select" :id="id" v-model="value" class="form-select" :class="inputClasses">
<option v-if="!required" value="">---</option>
<select
v-else-if="select"
:id="id"
v-model="value"
class="form-select"
:class="inputClasses"
:disabled="disabled"
>
<option v-if="!required" value="" :disabled="disabled">---</option>
<template v-for="({ key, name }, idx) in selectOptions">
<option v-if="key !== null && name !== null" :key="key" :value="key">
<option
v-if="key !== null && name !== null"
:key="key"
:value="key"
:disabled="disabled"
>
{{ name }}
</option>
<option v-else :key="idx" disabled>─────</option>
@ -61,7 +75,8 @@
:type="inputType"
:placeholder="placeholder"
:required="required"
rows="4"
:rows="rows || 4"
:disabled="disabled"
/>
<div v-else class="d-flex" :class="sizeClass">
<div class="position-relative flex-grow-1">
@ -81,12 +96,14 @@
unitValue ? 'border-top-right-radius: 0; border-bottom-right-radius: 0' : null
"
:autocomplete="masked || datalistId ? 'off' : null"
:disabled="disabled"
/>
<button
v-if="showClearButton"
type="button"
class="form-control-clear"
:aria-label="$t('config.general.clear')"
:disabled="disabled"
@click="value = ''"
></button>
<datalist v-if="showDatalist" :id="datalistId">
@ -128,11 +145,13 @@ export default {
scale: Number,
required: Boolean,
invalid: Boolean,
disabled: Boolean,
pattern: { type: Object, default: () => ({}) },
choice: { type: Array, default: () => [] },
modelValue: [String, Number, Boolean, Object],
label: String,
serviceValues: { type: Array, default: () => [] },
rows: { type: Number },
},
emits: ["update:modelValue"],
data: () => {
@ -221,7 +240,9 @@ export default {
},
textarea() {
return (
this.array || ["accessToken", "refreshToken", "identifiers"].includes(this.property)
this.rows ||
this.array ||
["accessToken", "refreshToken", "identifiers"].includes(this.property)
);
},
boolean() {

View file

@ -30,9 +30,9 @@
<div v-if="error" class="text-danger" :class="{ 'opacity-25': isRunning }">
{{ error }}
</div>
<hr v-if="result" class="divider" />
<div v-if="result" :class="{ 'opacity-25': isRunning }">
<DeviceTags :tags="result" class="success-values" />
<hr v-if="hasResult" class="divider" />
<div v-if="hasResult" :class="{ 'opacity-25': isRunning }">
<DeviceTags :tags="result as Record<string, any>" class="success-values" />
</div>
</div>
</template>
@ -60,6 +60,11 @@ export default defineComponent({
showTokenRequired: false,
};
},
computed: {
hasResult() {
return this.result && Object.keys(this.result).length > 0;
},
},
watch: {
sponsorTokenRequired() {
this.showTokenRequired = false;

View file

@ -140,6 +140,7 @@
property="identifiers"
size="w-100"
class="me-2"
:rows="4"
/>
</FormRow>
</template>

View file

@ -0,0 +1,7 @@
encoding: json
send:
# Plugin Typ
source: script
# Plugin-spezifische Konfiguration.
# {{.send}} enthält die JSON Nachricht
cmd: /usr/local/bin/evcc_message "{{.send}}"

View file

@ -13,17 +13,20 @@
>
<div class="modal-dialog modal-dialog-centered" :class="sizeClass" role="document">
<div class="modal-content">
<div class="modal-header">
<div class="modal-header d-flex justify-content-between align-items-center">
<h5 class="modal-title">
{{ title }}
</h5>
<button
v-if="!uncloseable"
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
<div class="d-flex align-items-center gap-1">
<slot name="header-actions"></slot>
<button
v-if="!uncloseable"
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
</div>
<div ref="modalBody" class="modal-body">
<slot />

View file

@ -84,7 +84,8 @@ export interface State {
sponsor?: Sponsor;
eebus?: Eebus;
modbusproxy?: ModbusProxy[];
messaging?: any;
messaging?: ConfigStatus<unknown, unknown>;
messagingEvents?: MessagingEvents;
interval?: number;
circuits?: Record<string, Circuit>;
siteTitle?: string;
@ -162,6 +163,7 @@ export enum ConfigType {
}
export type ConfigVehicle = Entity;
export type ConfigMessenger = Entity;
// Configuration-specific types for device setup/configuration contexts
export interface ConfigCharger extends Omit<Entity, "type"> {
@ -466,6 +468,25 @@ export type ModbusProxy = {
settings: ModbusProxySettings;
};
export type MessagingEvents = Record<MESSAGING_EVENTS, MessagingEvent>;
export enum MESSAGING_EVENTS {
START = "start",
STOP = "stop",
CONNECT = "connect",
DISCONNECT = "disconnect",
SOC = "soc",
GUEST = "guest",
ASLEEP = "asleep",
PLANOVERRUN = "planoverrun",
}
export interface MessagingEvent {
title: string;
msg: string;
disabled: boolean;
}
export interface ModbusProxySettings {
uri?: string;
rtu?: boolean;
@ -555,7 +576,7 @@ export interface SelectOption<T> {
disabled?: boolean;
}
export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint";
export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint" | "messenger";
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext";
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";

View file

@ -165,7 +165,6 @@
</div>
<h2 class="my-4 mt-5">{{ $t("config.section.integrations") }}</h2>
<div class="p-0 config-list">
<AuthProvidersCard
:providers="authProviders"
@ -187,11 +186,12 @@
</DeviceCard>
<DeviceCard
:title="$t('config.messaging.title')"
editable
:editable="messagingYamlSource !== 'file'"
:error="hasClassError('messenger')"
:unconfigured="isUnconfigured(messagingTags)"
:badge="messagingYamlSource === 'db'"
data-testid="messaging"
@edit="openModal('messaging')"
@edit="openMessagingModal"
>
<template #icon><NotificationIcon /></template>
<template #tags>
@ -337,23 +337,21 @@
<MqttModal @changed="loadDirty" />
<NetworkModal @changed="loadDirty" />
<ControlModal @changed="loadDirty" />
<HemsModal :yamlSource="hems?.yamlSource" @changed="yamlChanged" />
<HemsModal :yamlSource="hems?.yamlSource" @changed="loadDirty" />
<ShmModal @changed="loadDirty" />
<MessagingModal @changed="yamlChanged" />
<TariffsModal @changed="yamlChanged" />
<MessagingLegacyModal @changed="loadDirty" />
<MessagingModal :messengers="messengers" @changed="loadDirty" />
<MessengerModal @changed="messengerChanged" />
<TariffsModal @changed="loadDirty" />
<TelemetryModal :sponsor="sponsor" :telemetry="telemetry" />
<ExperimentalModal :experimental="experimental" />
<TitleModal @changed="loadDirty" />
<ModbusProxyModal :is-sponsor="isSponsor" @changed="loadDirty" />
<CircuitsModal
:gridMeter="gridMeter"
:extMeters="extMeters"
@changed="yamlChanged"
/>
<CircuitsModal :gridMeter="gridMeter" :extMeters="extMeters" @changed="loadDirty" />
<EebusModal
:status="eebus?.status"
:yamlSource="eebus?.yamlSource"
@changed="yamlChanged"
@changed="loadDirty"
/>
<OcppModal :ocpp="ocpp" />
<BackupRestoreModal v-bind="backupRestoreProps" />
@ -392,7 +390,7 @@ import InfluxIcon from "../components/MaterialIcon/Influx.vue";
import InfluxModal from "../components/Config/InfluxModal.vue";
import LoadpointModal from "../components/Config/LoadpointModal.vue";
import LoadpointIcon from "../components/MaterialIcon/Loadpoint.vue";
import MessagingModal from "../components/Config/MessagingModal.vue";
import MessagingModal from "../components/Config/Messaging/MessagingModal.vue";
import MeterModal from "../components/Config/MeterModal.vue";
import MeterCard from "../components/Config/MeterCard.vue";
import { openModal, type ModalResult } from "@/configModal";
@ -426,6 +424,7 @@ import type {
SiteConfig,
DeviceType,
Notification,
ConfigMessenger,
} from "@/types/evcc";
type DeviceValuesMap = Record<DeviceType, Record<string, any>>;
@ -440,6 +439,8 @@ import WelcomeBanner from "../components/Config/WelcomeBanner.vue";
import AuthSuccessBanner from "../components/Config/AuthSuccessBanner.vue";
import PasswordModal from "../components/Auth/PasswordModal.vue";
import AuthProvidersCard from "../components/Config/AuthProvidersCard.vue";
import MessengerModal from "@/components/Config/Messaging/MessengerModal.vue";
import MessagingLegacyModal from "@/components/Config/Messaging/MessagingLegacyModal.vue";
export default defineComponent({
name: "Config",
@ -463,7 +464,9 @@ export default defineComponent({
ShmIcon,
InfluxIcon,
InfluxModal,
MessagingLegacyModal,
MessagingModal,
MessengerModal,
MeterModal,
MeterCard,
LoadpointModal,
@ -494,6 +497,7 @@ export default defineComponent({
},
data() {
return {
messengers: [] as ConfigMessenger[],
vehicles: [] as ConfigVehicle[],
meters: [] as ConfigMeter[],
loadpoints: [] as ConfigLoadpoint[],
@ -522,6 +526,9 @@ export default defineComponent({
return { title: this.$t("config.main.title") };
},
computed: {
messagingConfigured() {
return store.state.messaging;
},
callbackCompleted() {
return this.$route.query["callbackCompleted"] as string | undefined;
},
@ -659,7 +666,17 @@ export default defineComponent({
return { configured: { value: false } };
},
messagingTags(): DeviceTags {
return { configured: { value: store.state?.messaging || false } };
if (this.messagingUiConfigured) {
const events = store.state?.messagingEvents || [];
const enabledEvents = Object.values(events).filter((e) => !e.disabled).length;
return {
events: { value: enabledEvents },
messengers: { value: this.messengers.length },
};
}
return { configured: { value: this.messagingYamlConfigured } };
},
backupRestoreProps() {
return {
@ -672,6 +689,18 @@ export default defineComponent({
(a, b) => sortedNames.indexOf(a.name) - sortedNames.indexOf(b.name)
);
},
messagingYamlSource() {
return store.state.messaging?.yamlSource;
},
messagingYamlConfigured() {
return this.messagingYamlSource === "file" || this.messagingYamlSource === "db";
},
messagingUiConfigured() {
return (
this.messengers.length > 0 ||
Object.keys(store.state.messagingEvents ?? {}).length > 0
);
},
},
watch: {
offline() {
@ -706,6 +735,7 @@ export default defineComponent({
}
},
async loadAll() {
await this.loadMessengers();
await this.loadVehicles();
await this.loadMeters();
await this.loadSite();
@ -726,6 +756,9 @@ export default defineComponent({
const response = await api.get(`/config/${path}`, { validateStatus });
return response.status === 200 ? response.data : undefined;
},
async loadMessengers() {
this.messengers = (await this.loadConfig("devices/messenger")) || [];
},
async loadVehicles() {
this.vehicles = (await this.loadConfig("devices/vehicle")) || [];
},
@ -801,8 +834,6 @@ export default defineComponent({
if (result.action === "removed") {
await this.loadSite();
}
// Reload meters and update UI
await this.loadMeters();
await this.loadDirty();
this.updateValues();
@ -820,10 +851,15 @@ export default defineComponent({
this.loadVehicles();
this.loadDirty();
},
siteChanged() {
openMessagingModal() {
const modalName = this.messagingYamlSource === "db" ? "messaginglegacy" : "messaging";
openModal(modalName);
},
async messengerChanged() {
this.loadMessengers();
this.loadDirty();
},
yamlChanged() {
siteChanged() {
this.loadDirty();
},
async saveSite(key: keyof SiteConfig) {
@ -860,6 +896,7 @@ export default defineComponent({
if (!this.offline) {
const devices = {
meter: this.meters,
messenger: this.messengers,
vehicle: this.vehicles,
charger: this.chargers,
} as Record<DeviceType, any[]>;

View file

@ -8,10 +8,10 @@ import (
)
func init() {
registry.AddCtx("template", NewChargerFromTemplateConfig)
registry.AddCtx("template", NewFromTemplateConfig)
}
func NewChargerFromTemplateConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
func NewFromTemplateConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
instance, err := templates.RenderInstance(templates.Charger, other)
if err != nil {
return nil, err

View file

@ -17,7 +17,7 @@ import (
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
hemsapi "github.com/evcc-io/evcc/hems/hems"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/server"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/eebus"
@ -353,9 +353,9 @@ func runRoot(cmd *cobra.Command, args []string) {
}
// setup messaging
var pushChan chan push.Event
var pushChan chan messenger.Event
if err == nil {
pushChan, err = configureMessengers(&conf.Messaging, site.Vehicles(), valueChan, cache)
pushChan, err = configureMessengers(&conf.Messaging, &conf.MessagingEvents, site.Vehicles(), valueChan, cache)
err = wrapErrorWithClass(ClassMessenger, err)
}
@ -368,7 +368,10 @@ func runRoot(cmd *cobra.Command, args []string) {
valueChan <- util.Param{Key: keys.Shm, Val: conf.SHM}
valueChan <- util.Param{Key: keys.Influx, Val: conf.Influx}
valueChan <- util.Param{Key: keys.Interval, Val: conf.Interval}
valueChan <- util.Param{Key: keys.Messaging, Val: conf.Messaging.IsConfigured()}
valueChan <- util.Param{Key: keys.Messaging, Val: globalconfig.ConfigStatus{
YamlSource: yamlSource.messaging,
}}
valueChan <- util.Param{Key: keys.MessagingEvents, Val: conf.MessagingEvents}
valueChan <- util.Param{Key: keys.ModbusProxy, Val: conf.ModbusProxy}
valueChan <- util.Param{Key: keys.Mqtt, Val: conf.Mqtt}
valueChan <- util.Param{Key: keys.Network, Val: conf.Network}

View file

@ -27,11 +27,11 @@ import (
"github.com/evcc-io/evcc/hems"
hemsapi "github.com/evcc-io/evcc/hems/hems"
"github.com/evcc-io/evcc/hems/shm"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/plugin/golang"
"github.com/evcc-io/evcc/plugin/javascript"
"github.com/evcc-io/evcc/plugin/mqtt"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/server"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
@ -82,9 +82,10 @@ var conf = globalconfig.All{
}
var yamlSource struct {
sponsor globalconfig.YamlSource
hems globalconfig.YamlSource
eebus globalconfig.YamlSource
sponsor globalconfig.YamlSource
hems globalconfig.YamlSource
eebus globalconfig.YamlSource
messaging globalconfig.YamlSource
}
var nameRE = regexp.MustCompile(`^[a-zA-Z0-9_.:-]+$`)
@ -231,6 +232,47 @@ NEXT:
return nil
}
type newFromConfFunc[T any] func(context.Context, string, map[string]any) (T, error)
func staticInstance[T any](typ string, cc config.Named, newFromConf newFromConfFunc[T], h config.Handler[T]) error {
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
instance, err := newFromConf(ctx, cc.Type, cc.Other)
if err != nil {
return &DeviceError{cc.Name, fmt.Errorf("cannot create %s '%s': %w", typ, cc.Name, err)}
}
if err := h.Add(config.NewStaticDevice(cc, instance)); err != nil {
return &DeviceError{cc.Name, err}
}
return nil
}
func configurableInstance[T any](typ string, conf *config.Config, newFromConf newFromConfFunc[T], h config.Handler[T]) error {
cc := conf.Named()
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
props, err := customDevice(cc.Other)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom %s '%s': %w", typ, cc.Name, err)}
}
var instance T
if err == nil {
instance, err = newFromConf(ctx, cc.Type, props)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot create %s '%s': %w", typ, cc.Name, err)}
}
}
if e := h.Add(config.NewConfigurableDevice(conf, instance)); e != nil && err == nil {
err = &DeviceError{cc.Name, e}
}
return err
}
func configureMeters(static []config.Named, names ...string) error {
var eg errgroup.Group
@ -249,18 +291,7 @@ func configureMeters(static []config.Named, names ...string) error {
}
eg.Go(func() error {
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
instance, err := meter.NewFromConfig(ctx, cc.Type, cc.Other)
if err != nil {
return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
}
if err := config.Meters().Add(config.NewStaticDevice(cc, instance)); err != nil {
return &DeviceError{cc.Name, err}
}
return nil
return staticInstance("meter", cc, meter.NewFromConfig, config.Meters())
})
}
@ -279,26 +310,7 @@ func configureMeters(static []config.Named, names ...string) error {
return nil
}
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
props, err := customDevice(cc.Other)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom meter '%s': %w", cc.Name, err)}
}
var instance api.Meter
if err == nil {
instance, err = meter.NewFromConfig(ctx, cc.Type, props)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
}
}
if e := config.Meters().Add(config.NewConfigurableDevice(&conf, instance)); e != nil && err == nil {
err = &DeviceError{cc.Name, e}
}
return err
return configurableInstance("meter", &conf, meter.NewFromConfig, config.Meters())
})
}
@ -323,18 +335,7 @@ func configureChargers(static []config.Named, names ...string) error {
}
eg.Go(func() error {
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
instance, err := charger.NewFromConfig(ctx, cc.Type, cc.Other)
if err != nil {
return &DeviceError{cc.Name, fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)}
}
if err := config.Chargers().Add(config.NewStaticDevice(cc, instance)); err != nil {
return &DeviceError{cc.Name, err}
}
return nil
return staticInstance("charger", cc, charger.NewFromConfig, config.Chargers())
})
}
@ -353,26 +354,7 @@ func configureChargers(static []config.Named, names ...string) error {
return nil
}
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
props, err := customDevice(cc.Other)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom charger '%s': %w", cc.Name, err)}
}
var instance api.Charger
if err == nil {
instance, err = charger.NewFromConfig(ctx, cc.Type, props)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)}
}
}
if e := config.Chargers().Add(config.NewConfigurableDevice(&conf, instance)); e != nil && err == nil {
err = &DeviceError{cc.Name, e}
}
return err
return configurableInstance("charger", &conf, charger.NewFromConfig, config.Chargers())
})
}
@ -812,34 +794,86 @@ func configureEEBus(conf *eebus.Config) error {
return nil
}
// setup messaging
func configureMessengers(conf *globalconfig.Messaging, vehicles push.Vehicles, valueChan chan<- util.Param, cache *util.ParamCache) (chan push.Event, error) {
// migrate settings
func configureMessengers(confMessaging *globalconfig.Messaging, confEvents *globalconfig.MessagingEvents, vehicles messenger.Vehicles, valueChan chan<- util.Param, cache *util.ParamCache) (chan messenger.Event, error) {
// yaml config from file
if len(confMessaging.Events) != 0 || len(confMessaging.Services) != 0 {
yamlSource.messaging = globalconfig.YamlSourceFile
}
// yaml config from db (deprecated)
if settings.Exists(keys.Messaging) {
*conf = globalconfig.Messaging{}
if err := settings.Yaml(keys.Messaging, new(map[string]any), &conf); err != nil {
if yamlSource.messaging == globalconfig.YamlSourceFile {
// just warn, no error to not break previous behavior
log.WARN.Println("messaging configured via UI yaml; evcc.yaml config will be ignored")
}
*confMessaging = globalconfig.Messaging{}
if err := settings.Yaml(keys.Messaging, new(map[string]any), &confMessaging); err != nil {
return nil, err
}
yamlSource.messaging = globalconfig.YamlSourceDb
}
if settings.Exists(keys.MessagingEvents) {
*confEvents = globalconfig.MessagingEvents{}
if err := settings.Json(keys.MessagingEvents, &confEvents); err != nil {
return nil, err
}
if yamlSource.messaging != globalconfig.YamlSourceNone && confEvents != nil {
return nil, errors.New("yaml and device config exists for messaging; remove yaml config")
}
}
messageChan := make(chan push.Event, 1)
messageChan := make(chan messenger.Event, 1)
var eg errgroup.Group
for i, cc := range confMessaging.Services {
// add name for meter/charger parity
cc := config.Named{
Name: fmt.Sprintf("push-%d", i+1),
Type: cc.Type,
Other: cc.Other,
}
eg.Go(func() error {
return staticInstance("messenger", cc, messenger.NewFromConfig, config.Messengers())
})
}
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Messenger)
if err != nil {
return messageChan, err
}
for _, conf := range configurable {
eg.Go(func() error {
return configurableInstance("messenger", &conf, messenger.NewFromConfig, config.Messengers())
})
}
if err := eg.Wait(); err != nil {
return messageChan, &ClassError{ClassMessenger, err}
}
var events globalconfig.MessagingEvents
if len(*confEvents) > 0 {
events = conf.MessagingEvents
} else {
events = confMessaging.Events
}
messageHub, err := messenger.NewHub(events, vehicles, cache)
messageHub, err := push.NewHub(conf.Events, vehicles, cache)
if err != nil {
return messageChan, fmt.Errorf("failed configuring push services: %w", err)
}
for _, conf := range conf.Services {
props, err := customDevice(conf.Other)
if err != nil {
return nil, fmt.Errorf("cannot decode push service '%s': %w", conf.Type, err)
for _, dev := range config.Messengers().Devices() {
if inst := dev.Instance(); inst != nil {
messageHub.Add(inst)
}
impl, err := push.NewFromConfig(context.TODO(), conf.Type, props)
if err != nil {
return messageChan, fmt.Errorf("failed configuring push service %s: %w", conf.Type, err)
}
messageHub.Add(impl)
}
go messageHub.Run(messageChan, valueChan)

View file

@ -13,6 +13,7 @@ const (
Hems = "hems"
Shm = "shm"
Messaging = "messaging"
MessagingEvents = "messagingEvents"
ModbusProxy = "modbusproxy"
Ocpp = "ocpp"
Tariffs = "tariffs"

View file

@ -24,7 +24,7 @@ import (
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/core/wrapper"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/modbus"
@ -79,9 +79,9 @@ type Loadpoint struct {
clock clock.Clock // mockable time
bus evbus.Bus // event bus
site site.API
pushChan chan<- push.Event // notifications
uiChan chan<- util.Param // client push messages
lpChan chan<- *Loadpoint // update requests
pushChan chan<- messenger.Event // notifications
uiChan chan<- util.Param // client push messages
lpChan chan<- *Loadpoint // update requests
log *util.Logger
rwMutex int64 // count reentrant RWMutex
@ -444,7 +444,7 @@ func (lp *Loadpoint) configureChargerType(charger api.Charger) {
// pushEvent sends push messages to clients
func (lp *Loadpoint) pushEvent(event string) {
lp.pushChan <- push.Event{Event: event}
lp.pushChan <- messenger.Event{Event: event}
}
// publish sends values to UI and databases
@ -617,7 +617,7 @@ func (lp *Loadpoint) defaultMode() {
}
// Prepare loadpoint configuration by adding missing helper elements
func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan chan<- push.Event, lpChan chan<- *Loadpoint) {
func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan chan<- messenger.Event, lpChan chan<- *Loadpoint) {
lp.site = site
lp.uiChan = uiChan
lp.pushChan = pushChan

View file

@ -10,7 +10,7 @@ import (
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
@ -35,11 +35,11 @@ func (n *Null) ChargeDuration() (time.Duration, error) {
return 0, nil
}
func createChannels(t *testing.T) (chan util.Param, chan push.Event, chan *Loadpoint) {
func createChannels(t *testing.T) (chan util.Param, chan messenger.Event, chan *Loadpoint) {
t.Helper()
uiChan := make(chan util.Param)
pushChan := make(chan push.Event)
pushChan := make(chan messenger.Event)
lpChan := make(chan *Loadpoint)
log := false
@ -65,7 +65,7 @@ func createChannels(t *testing.T) (chan util.Param, chan push.Event, chan *Loadp
return uiChan, pushChan, lpChan
}
func attachChannels(lp *Loadpoint, uiChan chan util.Param, pushChan chan push.Event, lpChan chan *Loadpoint) {
func attachChannels(lp *Loadpoint, uiChan chan util.Param, pushChan chan messenger.Event, lpChan chan *Loadpoint) {
lp.uiChan = uiChan
lp.pushChan = pushChan
lp.lpChan = lpChan

View file

@ -27,7 +27,7 @@ import (
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/tariff"
@ -1033,7 +1033,7 @@ func (site *Site) prepare() {
}
// Prepare attaches communication channels to site and loadpoints
func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- push.Event) {
func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger.Event) {
// https://github.com/evcc-io/evcc/issues/11191 prevent deadlock
// https://github.com/evcc-io/evcc/pull/11675 maintain message order
@ -1056,7 +1056,7 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- push.Even
for id, lp := range site.loadpoints {
lpUIChan := make(chan util.Param)
lpPushChan := make(chan push.Event)
lpPushChan := make(chan messenger.Event)
// pipe messages through go func to add id
go func(id int) {

View file

@ -128,12 +128,14 @@
"dimmed": "Gedimmt",
"enabled": "Ladebereit",
"energy": "Energie",
"events": "Ereignisse",
"feedinPrice": "Einspeisevergütung",
"gridPrice": "Netzpreis",
"heaterTempLimit": "Heizungslimit",
"hemsActiveLimit": "Aktives Limit",
"hemsType": "Kommunikation",
"identifier": "RFID-Kennung",
"messengers": "Dienste",
"no": "Nein",
"odometer": "Kilometerstand",
"org": "Organisation",
@ -214,6 +216,7 @@
"cancel": "Abbrechen",
"clear": "Löschen",
"close": "Schließen",
"confirmSave": "Es gibt ungespeicherte Änderungen. Jetzt speichern?",
"copied": "Kopiert!",
"copy": "Kopieren",
"customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.",
@ -229,6 +232,7 @@
"fromYamlHint": "Hinweis: Konfiguriert über evcc.yaml. Entferne den Eintrag aus der Datei, um die Bearbeitung hier zu aktivieren.",
"hideAdvancedSettings": "Erweiterte Einstellungen ausblenden",
"invalidFileSelected": "Ungültige Datei ausgewählt",
"legacy": "veraltet",
"noFileSelected": "Keine Datei ausgewählt.",
"off": "aus",
"on": "an",
@ -410,9 +414,66 @@
"welcomeBannerTitle": "Los geht's mit der Konfiguration!"
},
"messaging": {
"addMessenger": "Dienst hinzufügen",
"description": "Benachrichtigungen über Ladevorgänge und andere Ereignisse erhalten.",
"event": {
"asleep": {
"messageDefault": "Ladefreigabe, Fahrzeug {vehicleName} lädt nicht.",
"title": "Wenn Fahrzeug nicht lädt",
"titleDefault": "Fahrzeug schläft"
},
"connect": {
"messageDefault": "Auto verbunden bei {pvPower}kW PV",
"title": "Wenn ein Auto verbunden wird",
"titleDefault": "Auto verbunden"
},
"disconnect": {
"messageDefault": "Auto getrennt nach {connectedDuration}",
"title": "Wenn ein Auto getrennt wird",
"titleDefault": "Auto getrennt"
},
"guest": {
"messageDefault": "Unbekanntes Fahrzeug, Gast verbunden?",
"title": "Wenn ein unbekanntes Auto verbunden wird",
"titleDefault": "Unbekanntes Fahrzeug"
},
"planoverrun": {
"messageDefault": "{vehicleTitle}: Plan wird überschritten.",
"title": "Wenn das geplante Laden überschritten wird",
"titleDefault": "Plan überschritten"
},
"soc": {
"messageDefault": "Batterie geladen auf {vehicleSoc}%",
"title": "Ladestandsaktualisierung",
"titleDefault": "Ladestand aktualisiert"
},
"start": {
"messageDefault": "Laden im {mode}-Modus gestartet.",
"title": "Wenn das Laden startet",
"titleDefault": "Ladevorgang gestartet"
},
"stop": {
"messageDefault": "Laden beendet: {chargedEnergy}kWh in {chargeDuration}.",
"title": "Wenn das Laden stoppt",
"titleDefault": "Ladevorgang beendet"
}
},
"eventMessage": "Nachricht",
"eventTitle": "Titel",
"events": "Ereignisse",
"legacyWarning": "Neue Benachrichtigungskonfiguration verfügbar! Entferne und speichere deine Konfiguration hier, um den neuen geführten Prozess zu verwenden.",
"messengers": "Dienste",
"seePlaceholders": "siehe Platzhalter",
"title": "Benachrichtigungen"
},
"messenger": {
"custom": "Benutzerdefinierter Dienst",
"generic": "Generischer Dienst",
"primary": "Spezifischer Dienst",
"template": "Dienst",
"titleAdd": "Dienst hinzufügen",
"titleEdit": "Dienst bearbeiten"
},
"meter": {
"cancel": "Abbrechen",
"delete": "Löschen",
@ -649,7 +710,7 @@
"validation": {
"failed": "fehlgeschlagen",
"label": "Status",
"running": "Prüfe …",
"running": "prüfe …",
"success": "erfolgreich",
"unknown": "unbekannt",
"validate": "prüfen"

View file

@ -128,12 +128,14 @@
"dimmed": "Dimmed",
"enabled": "Enabled",
"energy": "Energy",
"events": "Events",
"feedinPrice": "Feed-in price",
"gridPrice": "Grid price",
"heaterTempLimit": "Heater limit",
"hemsActiveLimit": "Active limit",
"hemsType": "Communication",
"identifier": "RFID-Identifier",
"messengers": "Services",
"no": "no",
"odometer": "Odometer",
"org": "Organization",
@ -214,6 +216,7 @@
"cancel": "Cancel",
"clear": "Clear",
"close": "Close",
"confirmSave": "There are unsaved changes. Save now?",
"copied": "Copied!",
"copy": "Copy",
"customHelp": "Create a user-defined device using evcc's plugin system.",
@ -229,6 +232,7 @@
"fromYamlHint": "Note: Configured via evcc.yaml. Remove the entry from the file to enable editing here.",
"hideAdvancedSettings": "Hide advanced settings",
"invalidFileSelected": "Invalid file selected",
"legacy": "legacy",
"noFileSelected": "No file selected.",
"off": "off",
"on": "on",
@ -410,9 +414,66 @@
"welcomeBannerTitle": "Let's configure your system!"
},
"messaging": {
"description": "Receive messages about your charging sessions.",
"addMessenger": "Add service",
"description": "Receive notifications about your charging sessions.",
"event": {
"asleep": {
"messageDefault": "Charge release, vehicle {vehicleName} not charging.",
"title": "When waiting for vehicle",
"titleDefault": "Vehicle asleep"
},
"connect": {
"messageDefault": "Car connected at {pvPower}kW PV",
"title": "When a car connects",
"titleDefault": "Car connected"
},
"disconnect": {
"messageDefault": "Car disconnected after {connectedDuration}",
"title": "When a car disconnects",
"titleDefault": "Car disconnected"
},
"guest": {
"messageDefault": "Unknown vehicle, guest connected?",
"title": "When an unknown car connects",
"titleDefault": "Unknown vehicle"
},
"planoverrun": {
"messageDefault": "{vehicleTitle}: Plan will overrun.",
"title": "When plan charging is going to overrun",
"titleDefault": "Plan overrun"
},
"soc": {
"messageDefault": "Battery charged to {vehicleSoc}%",
"title": "Charge level update",
"titleDefault": "Charge level updated"
},
"start": {
"messageDefault": "Started charging in {mode} mode.",
"title": "When charging starts",
"titleDefault": "Charge started"
},
"stop": {
"messageDefault": "Finished charging {chargedEnergy}kWh in {chargeDuration}.",
"title": "When charging stops",
"titleDefault": "Charge finished"
}
},
"eventMessage": "Message",
"eventTitle": "Title",
"events": "Events",
"legacyWarning": "New notification configuration available! Remove and save your configuration here to use the new guided process.",
"messengers": "Services",
"seePlaceholders": "see placeholders",
"title": "Notifications"
},
"messenger": {
"custom": "User-defined service",
"generic": "Generic service",
"primary": "Specific service",
"template": "Service",
"titleAdd": "Add Service",
"titleEdit": "Edit Service"
},
"meter": {
"cancel": "Cancel",
"delete": "Delete",
@ -649,7 +710,7 @@
"validation": {
"failed": "failed",
"label": "Status",
"running": "Validating…",
"running": "validating…",
"success": "successful",
"unknown": "unknown",
"validate": "validate"

View file

@ -1,23 +1,19 @@
package push
package messenger
import (
"context"
"fmt"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
reg "github.com/evcc-io/evcc/util/registry"
)
// Messenger implements message sending
type Messenger interface {
Send(title, msg string)
}
var registry = reg.New[Messenger]("messenger")
var registry = reg.New[api.Messenger]("messenger")
// NewFromConfig creates messenger from configuration
func NewFromConfig(ctx context.Context, typ string, other map[string]any) (Messenger, error) {
func NewFromConfig(ctx context.Context, typ string, other map[string]any) (api.Messenger, error) {
factory, err := registry.Get(strings.ToLower(typ))
if err != nil {
return nil, err

View file

@ -1,4 +1,4 @@
package push
package messenger
import (
"fmt"
@ -7,6 +7,7 @@ import (
"text/template"
"github.com/Masterminds/sprig/v3"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/util"
@ -25,16 +26,25 @@ type Vehicles interface {
// Hub subscribes to event notifications and sends them to client devices
type Hub struct {
definitions map[string]globalconfig.MessagingEventTemplate
sender []Messenger
definitions globalconfig.MessagingEvents
sender []api.Messenger
cache *util.ParamCache
vehicles Vehicles
}
// NewHub creates push hub with definitions and receiver
func NewHub(cc map[string]globalconfig.MessagingEventTemplate, vv Vehicles, cache *util.ParamCache) (*Hub, error) {
// instantiate all event templates
func NewHub(cc globalconfig.MessagingEvents, vv Vehicles, cache *util.ParamCache) (*Hub, error) {
// keep only enabled events
filtered := make(globalconfig.MessagingEvents, len(cc))
for k, v := range cc {
if !v.Disabled {
filtered[k] = v
}
}
// instantiate all event templates
for k, v := range filtered {
if _, err := template.New("out").Funcs(sprig.FuncMap()).Parse(v.Title); err != nil {
return nil, fmt.Errorf("invalid event title: %s (%w)", k, err)
}
@ -53,7 +63,7 @@ func NewHub(cc map[string]globalconfig.MessagingEventTemplate, vv Vehicles, cach
}
// Add adds a sender to the list of senders
func (h *Hub) Add(sender Messenger) {
func (h *Hub) Add(sender api.Messenger) {
h.sender = append(h.sender, sender)
}

View file

@ -1,4 +1,4 @@
package push
package messenger
import (
"bytes"
@ -17,7 +17,7 @@ func init() {
}
// NewConfigurableFromConfig creates Messenger from config
func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (Messenger, error) {
func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.Messenger, error) {
var cc struct {
Send plugin.Config
Encoding string

View file

@ -1,4 +1,4 @@
package push
package messenger
import (
"encoding/base64"
@ -8,6 +8,7 @@ import (
"net/url"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
)
@ -25,7 +26,7 @@ type Ntfy struct {
}
// NewNtfyFromConfig creates new Ntfy messenger
func NewNtfyFromConfig(other map[string]any) (Messenger, error) {
func NewNtfyFromConfig(other map[string]any) (api.Messenger, error) {
var cc struct {
URI string
Priority string

View file

@ -1,9 +1,10 @@
package push
package messenger
import (
"errors"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/gregdel/pushover"
)
@ -21,7 +22,7 @@ type PushOver struct {
}
// NewPushOverFromConfig creates new pushover messenger
func NewPushOverFromConfig(other map[string]any) (Messenger, error) {
func NewPushOverFromConfig(other map[string]any) (api.Messenger, error) {
var cc struct {
App string
Recipients []string

View file

@ -1,14 +1,14 @@
package push
package messenger
import (
"github.com/containrrr/shoutrrr"
"github.com/containrrr/shoutrrr/pkg/router"
"github.com/containrrr/shoutrrr/pkg/types"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
)
func init() {
registry.Add("email", NewShoutrrrFromConfig)
registry.Add("shout", NewShoutrrrFromConfig)
}
@ -19,7 +19,7 @@ type Shoutrrr struct {
}
// NewShoutrrrFromConfig creates new Shoutrrr messenger
func NewShoutrrrFromConfig(other map[string]any) (Messenger, error) {
func NewShoutrrrFromConfig(other map[string]any) (api.Messenger, error) {
var cc struct {
URI string
}

View file

@ -1,4 +1,4 @@
package push
package messenger
import (
"context"
@ -6,6 +6,7 @@ import (
"strconv"
"sync"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
@ -24,7 +25,7 @@ type Telegram struct {
}
// NewTelegramFromConfig creates new pushover messenger
func NewTelegramFromConfig(ctx context.Context, other map[string]any) (Messenger, error) {
func NewTelegramFromConfig(ctx context.Context, other map[string]any) (api.Messenger, error) {
var cc struct {
Token string
Chats []int64

21
messenger/template.go Normal file
View file

@ -0,0 +1,21 @@
package messenger
import (
"context"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util/templates"
)
func init() {
registry.AddCtx("template", NewFromTemplateConfig)
}
func NewFromTemplateConfig(ctx context.Context, other map[string]any) (api.Messenger, error) {
instance, err := templates.RenderInstance(templates.Messenger, other)
if err != nil {
return nil, err
}
return NewFromConfig(ctx, instance.Type, instance.Other)
}

View file

@ -0,0 +1,24 @@
package messenger
import (
"testing"
"github.com/evcc-io/evcc/util/templates"
"github.com/evcc-io/evcc/util/test"
)
var acceptable = []string{
// api.ErrMissingCredentials.Error(),
// api.ErrMissingToken.Error(),
}
func TestTemplates(t *testing.T) {
templates.TestClass(t, templates.Messenger, func(t *testing.T, values map[string]any) {
t.Helper()
if _, err := NewFromConfig(t.Context(), "template", values); err != nil && !test.Acceptable(err, acceptable) {
t.Log(values)
t.Error(err)
}
})
}

View file

@ -8,10 +8,10 @@ import (
)
func init() {
registry.AddCtx("template", NewMeterFromTemplateConfig)
registry.AddCtx("template", NewFromTemplateConfig)
}
func NewMeterFromTemplateConfig(ctx context.Context, other map[string]any) (api.Meter, error) {
func NewFromTemplateConfig(ctx context.Context, other map[string]any) (api.Meter, error) {
instance, err := templates.RenderInstance(templates.Meter, other)
if err != nil {
return nil, err

View file

@ -311,12 +311,13 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
// json handlers
for key, fun := range map[string]func() any{
keys.Network: func() any { return new(globalconfig.Network) }, // has default
keys.Mqtt: func() any { return new(globalconfig.Mqtt) }, // has default
keys.ModbusProxy: func() any { return new([]globalconfig.ModbusProxy) }, // slice
keys.Shm: func() any { return new(shm.Config) },
keys.Influx: func() any { return new(globalconfig.Influx) },
keys.EEBus: func() any { return new(eebus.Config) },
keys.Network: func() any { return new(globalconfig.Network) }, // has default
keys.Mqtt: func() any { return new(globalconfig.Mqtt) }, // has default
keys.ModbusProxy: func() any { return new([]globalconfig.ModbusProxy) }, // slice
keys.Shm: func() any { return new(shm.Config) },
keys.Influx: func() any { return new(globalconfig.Influx) },
keys.EEBus: func() any { return new(eebus.Config) },
keys.MessagingEvents: func() any { return new(globalconfig.MessagingEvents) },
} {
routes["update"+key] = route{Method: "POST", Pattern: "/" + key, HandlerFunc: settingsSetJsonHandler(key, pub, fun)}
routes["delete"+key] = route{Method: "DELETE", Pattern: "/" + key, HandlerFunc: settingsDeleteJsonHandler(key, pub, fun())}

View file

@ -14,6 +14,7 @@ import (
"github.com/evcc-io/evcc/charger"
"github.com/evcc-io/evcc/core/circuit"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
@ -71,6 +72,9 @@ func devicesConfigHandler(w http.ResponseWriter, r *http.Request) {
case templates.Circuit:
res, err = devicesConfig(class, config.Circuits(), hidePrivate)
case templates.Messenger:
res, err = devicesConfig(class, config.Messengers(), hidePrivate)
}
if err != nil {
@ -191,6 +195,9 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
case templates.Circuit:
res, err = deviceConfig(class, id, config.Circuits(), hidePrivate)
case templates.Messenger:
res, err = deviceConfig(class, id, config.Messengers(), hidePrivate)
}
if err != nil {
@ -247,6 +254,9 @@ func deviceStatusHandler(w http.ResponseWriter, r *http.Request) {
case templates.Circuit:
instance, err = deviceStatus(name, config.Circuits())
case templates.Messenger:
instance, err = deviceStatus(name, config.Messengers())
}
if err != nil {
@ -306,6 +316,9 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
conf, err = newDevice(ctx, class, req, func(ctx context.Context, _ string, other map[string]any) (api.Circuit, error) {
return circuit.NewFromConfig(ctx, util.NewLogger("circuit"), other)
}, config.Circuits(), force)
case templates.Messenger:
conf, err = newDevice(ctx, class, req, messenger.NewFromConfig, config.Messengers(), force)
}
if err != nil {
@ -387,6 +400,9 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
err = updateDevice(ctx, id, class, req, func(ctx context.Context, _ string, other map[string]any) (api.Circuit, error) {
return circuit.NewFromConfig(ctx, util.NewLogger("circuit"), other)
}, config.Circuits(), force)
case templates.Messenger:
err = updateDevice(ctx, id, class, req, messenger.NewFromConfig, config.Messengers(), force)
}
setConfigDirty()
@ -533,6 +549,9 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
lp.SetCircuitRef("")
}
}
case templates.Messenger:
err = deleteDevice(id, config.Messengers())
}
setConfigDirty()
@ -603,6 +622,9 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) {
case templates.Circuit:
err = api.ErrNotAvailable
case templates.Messenger:
instance, err = testConfig(ctx, id, class, req, messenger.NewFromConfig, config.Messengers())
}
if err != nil {

View file

@ -8,10 +8,10 @@ import (
)
func init() {
registry.AddCtx("template", NewTariffFromTemplateConfig)
registry.AddCtx("template", NewFromTemplateConfig)
}
func NewTariffFromTemplateConfig(ctx context.Context, other map[string]any) (api.Tariff, error) {
func NewFromTemplateConfig(ctx context.Context, other map[string]any) (api.Tariff, error) {
instance, err := templates.RenderInstance(templates.Tariff, other)
if err != nil {
return nil, err

View file

@ -2,5 +2,5 @@ package definition
import "embed"
//go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml tariff/*.yaml
//go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml tariff/*.yaml messenger/*.yaml
var YamlTemplates embed.FS

View file

@ -0,0 +1,39 @@
template: email
products:
- description:
en: Email
de: E-Mail
params:
- name: host
required: true
example: smtp.example.com
- name: port
required: true
example: 465
default: 465
- name: user
required: true
- name: password
required: true
- name: from
required: true
example: john.doe@example.com
description:
de: Von
en: From
help:
de: E-Mail-Adresse des Absenders.
en: Sender's email address.
- name: to
required: true
type: list
example: recipient@example.com
description:
de: An
en: To
help:
de: E-Mail-Adressen der Empfänger. One entry per line.
en: Email addresses of recipients. Ein Eintrag pro Zeile.
render: |
type: shout
uri: smtp://{{ .user }}:{{ .password }}@{{ .host }}:{{ .port }}/?fromAddress={{ .from }}&to={{ .to | join "," }}

View file

@ -0,0 +1,63 @@
template: ntfy
products:
- brand: Ntfy
params:
- name: host
required: true
example: ntfy.sh
default: ntfy.sh
- name: topics
required: true
type: list
example: evcc_alert
description:
de: Themen
en: Topics
help:
de: Ein Eintrag pro Zeile.
en: One entry per line.
- name: authtoken
mask: true
example: tk_7eevizlsiwf9yi4uxsrs83r4352o0
description:
de: Access Token
en: Access token
help:
de: Wird für den sicheren Zugriff auf den privaten ntfy-Server verwendet.
en: Used for secure access to the private ntfy server.
- name: priority
required: true
type: choice
choice: ["max", "high", "default", "low", "min"]
default: default
description:
de: Priorität
en: Priority
help:
de: >
Nachrichten haben eine Prioritätsstufe, die bestimmt, wie dringend dein Telefon dich benachrichtigt.
Unter Android kannst du Benachrichtigungstöne und Vibrationsmuster basierend auf diesen Prioritätsstufen anpassen.
Weitere Details unter [docs.ntfy.sh](https://docs.ntfy.sh/publish#message-priority).
en: >
Messages have a priority level that determines how urgently your phone notifies you.
On Android, you can customize notification sounds and vibration patterns based on these priority levels.
For more details, see [docs.ntfy.sh](https://docs.ntfy.sh/publish#message-priority).
- name: tags
type: list
example: electric_plug
description:
de: Tags & Emojis
en: Tags & emojis
help:
de: >
Nachrichten können mit Emojis oder Zeichenketten markiert werden, die im Titel oder in der Nachricht angezeigt werden.
Weitere Details unter [docs.ntfy.sh](https://docs.ntfy.sh/publish#tags-emojis). Ein Eintrag pro Zeile.
en: >
Messages can be tagged with emojis or strings, which will be shown in the title or message.
For more details, see [docs.ntfy.sh](https://docs.ntfy.sh/publish#tags-emojis). One entry per line.
render: |
type: ntfy
uri: https://{{ .host }}/{{ .topics | join "," }}
priority: {{ .priority }}
tags: {{ .tags | join "," }}
authtoken: {{ .authtoken }}

View file

@ -0,0 +1,52 @@
template: pushover
products:
- brand: Pushover
params:
- name: app
required: true
mask: true
example: azGDORePK8gMaC0QOYAMyEEuzJnyUi
description:
de: API Token
en: API token
help:
de: API-Token der Anwendung. Erhältlich unter [pushover.net](https://pushover.net/apps/build) nach dem Erstellen einer Applikation.
en: API token of the application. Get it from [pushover.net](https://pushover.net/apps/build) after creating an application.
- name: recipients
required: true
example: uQiRzpo4DXghDmr9QzzfQu27cmVRsG
type: list
description:
de: Empfänger
en: Recipients
help:
de: >
Benutzerkennungen und Gruppenkennungen können angegeben werden. Ein Eintrag pro Zeile.
Gruppen müssen zuerst unter [pushover.net](https://pushover.net/groups/build) erstellt werden.
en: >
User keys and group identifiers can be specified. One entry per line.
Groups must first be created at [pushover.net](https://pushover.net/groups/build).
- name: devices
example: droid2
type: list
description:
de: Gerätenamen
en: Device names
help:
de: Beschränken der Benachrichtigungen auf bestimmte Geräte. Leer lassen, um an alle zu senden. Ein Eintrag pro Zeile.
en: Restrict notifications to specific devices. Leave blank to send to all. One entry per line.
render: |
type: pushover
app: {{ .app }}
{{- if .recipients }}
recipients:
{{- range .recipients }}
- {{ . }}
{{- end }}
{{- end }}
{{- if .devices }}
devices:
{{- range .devices }}
- {{ . }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,19 @@
template: shout
products:
- brand: Shoutrrr
requirements:
description:
en: Shoutrrr sends messages to various services like Slack, Teams, Matrix, Mattermost. See [containrrr.dev/shoutrrr](https://containrrr.dev/shoutrrr/latest/services/overview/) for supported services.
de: Shoutrrr sendet Nachrichten an verschiedene Dienste wie Slack, Teams, Matrix, Mattermost. Übersicht unter [containrrr.dev/shoutrrr](https://containrrr.dev/shoutrrr/latest/services/overview/).
group: generic
params:
- name: uri
required: true
example: gotify://gotify.example.com:443/secr3t/?priority=1
private: true
help:
en: See [containrrr.dev/shoutrrr](https://containrrr.dev/shoutrrr/latest/services/overview/) for possible formats.
de: Übersicht unter [containrrr.dev/shoutrrr](https://containrrr.dev/shoutrrr/latest/services/overview/) für mögliche Formate.
render: |
type: shout
uri: {{ .uri }}

View file

@ -0,0 +1,39 @@
template: telegram
products:
- brand: Telegram
requirements:
evcc: ["skiptest"]
params:
- name: token
required: true
mask: true
example: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
description:
de: Token
en: Token
help:
de: Auch Bot-ID genannt.
en: Also called Bot ID.
- name: chats
required: true
type: list
example: -210987654
description:
de: Chat-IDs
en: Chat IDs
help:
de: >
Chat-Identifikatoren und Gruppen-Identifikatoren können angegeben werden. Letztere haben ein Minuszeichen. Ein Eintrag pro Zeile.
Tipp: In Telegram im Browser anmelden und die Chats öffnen, um die Kennungen in der URL zu sehen.
en: >
Chat identifiers and group identifiers can be specified. The latter have a minus sign. One entry per line.
Tip: Log in to Telegram in your browser and open the chats to see the identifiers in the URL.
render: |
type: telegram
token: {{ .token }}
{{- if .chats }}
chats:
{{- range .chats }}
- {{ . }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,5 @@
messaging:
events:
start:
title: Charge started
msg: Started charging

View file

@ -1,6 +1,9 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalHidden, expectModalVisible } from "./utils";
import { start, stop, restart, baseUrl } from "./evcc";
import { expectModalHidden, expectModalVisible, editorClear, editorPaste } from "./utils";
const CONFIG_WITH_MESSAGING = "config-messaging.evcc.yaml";
const CONFIG_MESSAGING_LEGACY = "messaging-legacy.sql";
test.use({ baseURL: baseUrl() });
@ -8,30 +11,169 @@ test.afterEach(async () => {
await stop();
});
const SELECT_ALL = "ControlOrMeta+KeyA";
test.describe("messaging", async () => {
test("save a comment", async ({ page }) => {
test("messaging not configured", async ({ page }) => {
await start();
await page.goto("/#/config");
await page.getByTestId("messaging").getByRole("button", { name: "edit" }).click();
const modal = await page.getByTestId("messaging-modal");
await expectModalVisible(modal);
await modal.locator(".monaco-editor .view-line").nth(0).click();
for (let i = 0; i < 4; i++) {
await page.keyboard.press(SELECT_ALL, { delay: 10 });
await page.keyboard.press("Backspace", { delay: 10 });
}
await page.keyboard.type("# hello world");
await page.getByRole("button", { name: "Save" }).click();
await expectModalHidden(modal);
const card = page.getByTestId("messaging");
await expect(card).toBeVisible();
await expect(card).toContainText(["Configured", "no"].join(""));
});
test("messaging from yaml ui (legacy)", async ({ page }) => {
await start(undefined, CONFIG_MESSAGING_LEGACY);
await page.goto("/#/config");
await page.getByTestId("messaging").getByRole("button", { name: "edit" }).click();
const card = page.getByTestId("messaging");
await expect(card).toBeVisible();
await expect(card).toContainText(["Configured", "yes"].join(""));
await card.getByRole("button", { name: "edit" }).click();
const modal = page.getByTestId("messaging-legacy-modal");
await expectModalVisible(modal);
await expect(modal).toContainText("# hello world");
// check for new configuration notice
await expect(modal.getByRole("alert")).toContainText(
"New notification configuration available"
);
const validYaml = `events:
start:
title: Charge started
msg: Started charging`;
// default content
const editor = modal.getByTestId("yaml-editor");
await expect(editor).toContainText(validYaml);
// clear and enter invalid yaml
await editorClear(editor);
await editorPaste(editor, page, "foo: bar");
await page.getByRole("button", { name: "Save" }).click();
await expect(modal.getByTestId("error")).toContainText("invalid keys: foo");
// clear and enter valid yaml
await editorClear(editor);
await editorPaste(editor, page, validYaml);
await page.getByRole("button", { name: "Save" }).click();
await expect(modal.getByTestId("error")).not.toBeVisible();
// modal closes
await expectModalHidden(modal);
await expect(card).toBeVisible();
await expect(card).toContainText(["Configured", "yes"].join(""));
});
test("messaging from evcc.yaml", async ({ page }) => {
await start(CONFIG_WITH_MESSAGING);
await page.goto("/#/config");
const card = page.getByTestId("messaging");
await expect(card).toBeVisible();
await expect(card).toContainText(["Configured", "yes"].join(""));
});
test("messaging events via ui", async ({ page }) => {
await start();
await page.goto("/#/config");
const card = page.getByTestId("messaging");
await expect(card).toBeVisible();
await expect(card).toContainText(["Configured", "no"].join(""));
await card.getByRole("button", { name: "edit" }).click();
const modal = page.getByTestId("messaging-modal");
await expectModalVisible(modal);
// validate start event
const eventStart = modal.getByTestId("event-start");
const switchInput = eventStart.getByRole("switch");
const titleInput = eventStart.getByLabel("Title");
const messageInput = eventStart.getByLabel("Message");
await expect(switchInput).not.toBeChecked();
await expect(titleInput).toBeDisabled();
await expect(titleInput).toHaveValue("Charge started");
await expect(messageInput).toBeDisabled();
await expect(messageInput).toHaveValue("Started charging in ${mode} mode.");
await switchInput.check();
await expect(titleInput).toBeEnabled();
await expect(messageInput).toBeEnabled();
await titleInput.fill("event-start-title");
await messageInput.fill("event-start-message");
// validate connection
await modal.getByRole("button", { name: "Save", exact: true }).click();
await expectModalHidden(modal);
await expect(card).toContainText(["Events", "1", "Services", "0"].join(""));
// restart button appears
const restartButton = page
.getByTestId("bottom-banner")
.getByRole("button", { name: "Restart" });
await expect(restartButton).toBeVisible();
await restart();
await page.reload();
await card.getByRole("button", { name: "edit" }).click();
// validate start event
await expect(switchInput).toBeChecked();
await expect(titleInput).toBeEnabled();
await expect(messageInput).toBeEnabled();
await expect(titleInput).toHaveValue("event-start-title");
await expect(messageInput).toHaveValue("event-start-message");
});
test("create, verify, delete custom service via ui", async ({ page }) => {
await start();
await page.goto("/#/config");
const card = page.getByTestId("messaging");
await expect(card).toBeVisible();
await expect(card).toContainText(["Configured", "no"].join(""));
await card.getByRole("button", { name: "edit" }).click();
const modal = page.getByTestId("messaging-modal");
await expectModalVisible(modal);
// confirm save unsaved changes
page.once("dialog", (dialog) => dialog.accept());
await modal.getByRole("link", { name: "Services (0)" }).click();
await modal.getByRole("button", { name: "Add service" }).click();
const messengerModal = page.getByTestId("messenger-modal");
await expectModalHidden(modal);
await expectModalVisible(messengerModal);
await messengerModal.getByLabel("Service").selectOption("User-defined service");
await messengerModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(messengerModal);
await expectModalVisible(modal);
const messengerBox = modal.getByTestId("messenger-box-0");
await expect(messengerBox).toHaveText(["#1", "User-defined service"].join(""));
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await expect(card).toContainText(["Events", "0", "Services", "1"].join(""));
await card.getByRole("button", { name: "edit" }).click();
await expectModalVisible(modal);
await messengerBox.getByRole("button", { name: "edit" }).click();
await expectModalHidden(modal);
await expectModalVisible(messengerModal);
await messengerModal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(messengerModal);
await expectModalVisible(modal);
await expect(messengerBox).not.toBeVisible();
await page.keyboard.press("Escape");
await expectModalHidden(modal);
await expect(card).toContainText(["Events", "0", "Services", "0"].join(""));
});
});

View file

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS `settings` (
`key` text
, `value` text
, PRIMARY KEY(`key`)
);
-- legacy messenger configuration stored in database
INSERT INTO settings("key", value) VALUES('messaging', 'events:
start:
title: Charge started
msg: Started charging');

View file

@ -13,6 +13,7 @@ var instance struct {
chargers *handler[api.Charger]
vehicles *handler[api.Vehicle]
circuits *handler[api.Circuit]
messengers *handler[api.Messenger]
loadpoints *handler[loadpoint.API]
}
@ -25,6 +26,7 @@ func Reset() {
instance.chargers = &handler[api.Charger]{topic: "charger"}
instance.vehicles = &handler[api.Vehicle]{topic: "vehicle"}
instance.circuits = &handler[api.Circuit]{topic: "circuit"}
instance.messengers = &handler[api.Messenger]{topic: "messenger"}
instance.loadpoints = &handler[loadpoint.API]{topic: "loadpoint"}
}
@ -48,6 +50,10 @@ func Vehicles() Handler[api.Vehicle] {
return instance.vehicles
}
func Messengers() Handler[api.Messenger] {
return instance.messengers
}
func Circuits() Handler[api.Circuit] {
return instance.circuits
}

View file

@ -11,4 +11,5 @@ const (
Tariff
Loadpoint
Circuit
Messenger
)

View file

@ -7,11 +7,11 @@ import (
"strings"
)
const _ClassName = "chargermetervehicletariffloadpointcircuit"
const _ClassName = "chargermetervehicletariffloadpointcircuitmessenger"
var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41}
var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41, 50}
const _ClassLowerName = "chargermetervehicletariffloadpointcircuit"
const _ClassLowerName = "chargermetervehicletariffloadpointcircuitmessenger"
func (i Class) String() string {
i -= 1
@ -31,9 +31,10 @@ func _ClassNoOp() {
_ = x[Tariff-(4)]
_ = x[Loadpoint-(5)]
_ = x[Circuit-(6)]
_ = x[Messenger-(7)]
}
var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit}
var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit, Messenger}
var _ClassNameToValueMap = map[string]Class{
_ClassName[0:7]: Charger,
@ -48,6 +49,8 @@ var _ClassNameToValueMap = map[string]Class{
_ClassLowerName[25:34]: Loadpoint,
_ClassName[34:41]: Circuit,
_ClassLowerName[34:41]: Circuit,
_ClassName[41:50]: Messenger,
_ClassLowerName[41:50]: Messenger,
}
var _ClassNames = []string{
@ -57,6 +60,7 @@ var _ClassNames = []string{
_ClassName[19:25],
_ClassName[25:34],
_ClassName[34:41],
_ClassName[41:50],
}
// ClassString retrieves an enum value from the enum constants string name.

View file

@ -33,7 +33,7 @@ func init() {
baseTmpl = template.Must(FuncMap(template.New("base")).ParseFS(includeFS, "includes/*.tpl"))
for _, class := range []Class{Charger, Meter, Vehicle, Tariff} {
for _, class := range []Class{Charger, Meter, Vehicle, Tariff, Messenger} {
load(class)
}
}

View file

@ -8,10 +8,10 @@ import (
)
func init() {
registry.AddCtx("template", NewVehicleFromTemplateConfig)
registry.AddCtx("template", NewFromTemplateConfig)
}
func NewVehicleFromTemplateConfig(ctx context.Context, other map[string]any) (api.Vehicle, error) {
func NewFromTemplateConfig(ctx context.Context, other map[string]any) (api.Vehicle, error) {
instance, err := templates.RenderInstance(templates.Vehicle, other)
if err != nil {
return nil, err