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:
parent
66c31118ad
commit
7f772bad73
60 changed files with 1547 additions and 363 deletions
|
|
@ -291,3 +291,8 @@ type Circuit interface {
|
|||
type Redactor interface {
|
||||
Redacted() any
|
||||
}
|
||||
|
||||
// Messenger implements message sending
|
||||
type Messenger interface {
|
||||
Send(title, msg string)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
84
assets/js/components/Config/DeviceCardEditIcon.vue
Normal file
84
assets/js/components/Config/DeviceCardEditIcon.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
35
assets/js/components/Config/DeviceModal/DeviceInfoButton.vue
Normal file
35
assets/js/components/Config/DeviceModal/DeviceInfoButton.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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=');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
134
assets/js/components/Config/Messaging/EventItem.vue
Normal file
134
assets/js/components/Config/Messaging/EventItem.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
172
assets/js/components/Config/Messaging/MessagingModal.vue
Normal file
172
assets/js/components/Config/Messaging/MessagingModal.vue
Normal 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>
|
||||
74
assets/js/components/Config/Messaging/MessengerModal.vue
Normal file
74
assets/js/components/Config/Messaging/MessengerModal.vue
Normal 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>
|
||||
3
assets/js/components/Config/Messaging/utils.ts
Normal file
3
assets/js/components/Config/Messaging/utils.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@
|
|||
property="identifiers"
|
||||
size="w-100"
|
||||
class="me-2"
|
||||
:rows="4"
|
||||
/>
|
||||
</FormRow>
|
||||
</template>
|
||||
|
|
|
|||
7
assets/js/components/Config/defaultYaml/messenger.yaml
Normal file
7
assets/js/components/Config/defaultYaml/messenger.yaml
Normal 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}}"
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
cmd/root.go
11
cmd/root.go
|
|
@ -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}
|
||||
|
|
|
|||
204
cmd/setup.go
204
cmd/setup.go
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const (
|
|||
Hems = "hems"
|
||||
Shm = "shm"
|
||||
Messaging = "messaging"
|
||||
MessagingEvents = "messagingEvents"
|
||||
ModbusProxy = "modbusproxy"
|
||||
Ocpp = "ocpp"
|
||||
Tariffs = "tariffs"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
63
i18n/de.json
63
i18n/de.json
|
|
@ -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"
|
||||
|
|
|
|||
65
i18n/en.json
65
i18n/en.json
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
21
messenger/template.go
Normal 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)
|
||||
}
|
||||
24
messenger/template_test.go
Normal file
24
messenger/template_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
39
templates/definition/messenger/email.yaml
Normal file
39
templates/definition/messenger/email.yaml
Normal 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 "," }}
|
||||
63
templates/definition/messenger/ntfy.yaml
Normal file
63
templates/definition/messenger/ntfy.yaml
Normal 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 }}
|
||||
52
templates/definition/messenger/pushover.yaml
Normal file
52
templates/definition/messenger/pushover.yaml
Normal 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 }}
|
||||
19
templates/definition/messenger/shoutrrr.yaml
Normal file
19
templates/definition/messenger/shoutrrr.yaml
Normal 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 }}
|
||||
39
templates/definition/messenger/telegram.yaml
Normal file
39
templates/definition/messenger/telegram.yaml
Normal 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 }}
|
||||
5
tests/config-messaging.evcc.yaml
Normal file
5
tests/config-messaging.evcc.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
messaging:
|
||||
events:
|
||||
start:
|
||||
title: Charge started
|
||||
msg: Started charging
|
||||
|
|
@ -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(""));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
11
tests/messaging-legacy.sql
Normal file
11
tests/messaging-legacy.sql
Normal 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');
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ const (
|
|||
Tariff
|
||||
Loadpoint
|
||||
Circuit
|
||||
Messenger
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue