Config UI: make messengers configurable (#26946)

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

28
messenger/config.go Normal file
View file

@ -0,0 +1,28 @@
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"
)
var registry = reg.New[api.Messenger]("messenger")
// NewFromConfig creates messenger from configuration
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
}
v, err := factory(ctx, other)
if err != nil {
err = fmt.Errorf("cannot create messenger type '%s': %w", util.TypeWithTemplateName(typ, other), err)
}
return v, err
}

149
messenger/hub.go Normal file
View file

@ -0,0 +1,149 @@
package messenger
import (
"fmt"
"reflect"
"strings"
"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"
)
// Event is a notification event
type Event struct {
Loadpoint *int // optional loadpoint id
Event string
}
type Vehicles interface {
// ByName returns a single vehicle adapter by name
ByName(string) (vehicle.API, error)
}
// Hub subscribes to event notifications and sends them to client devices
type Hub struct {
definitions globalconfig.MessagingEvents
sender []api.Messenger
cache *util.ParamCache
vehicles Vehicles
}
// NewHub creates push hub with definitions and receiver
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)
}
if _, err := template.New("out").Funcs(sprig.FuncMap()).Parse(v.Msg); err != nil {
return nil, fmt.Errorf("invalid event message: %s (%w)", k, err)
}
}
h := &Hub{
definitions: cc,
cache: cache,
vehicles: vv,
}
return h, nil
}
// Add adds a sender to the list of senders
func (h *Hub) Add(sender api.Messenger) {
h.sender = append(h.sender, sender)
}
// apply applies the event template to the content to produce the actual message
func (h *Hub) apply(ev Event, tmpl string) (string, error) {
attr := make(map[string]any)
// loadpoint id
if ev.Loadpoint != nil {
attr["loadpoint"] = *ev.Loadpoint + 1
}
// get all values from cache
for _, p := range h.cache.All() {
if p.Loadpoint == nil || ev.Loadpoint == p.Loadpoint {
val := p.Val
// resolve pointers (https://github.com/evcc-io/evcc/issues/24688)
if rv := reflect.ValueOf(p.Val); rv.Kind() == reflect.Pointer && !rv.IsNil() {
val = rv.Elem().Interface()
}
attr[p.Key] = val
}
}
// add missing attributes
if name, ok := attr["vehicleName"].(string); ok {
if v, err := h.vehicles.ByName(name); err == nil {
attr["vehicleLimitSoc"] = v.GetLimitSoc()
attr["vehicleMinSoc"] = v.GetMinSoc()
attr["vehiclePlanTime"], attr["vehiclePlanSoc"] = v.GetPlanSoc()
instance := v.Instance()
attr["vehicleTitle"] = instance.GetTitle()
attr["vehicleIcon"] = instance.Icon()
attr["vehicleCapacity"] = instance.Capacity()
}
}
return util.ReplaceFormatted(tmpl, attr)
}
// Run is the Hub's main publishing loop
func (h *Hub) Run(events <-chan Event, valueChan chan<- util.Param) {
log := util.NewLogger("push")
for ev := range events {
if len(h.sender) == 0 {
continue
}
definition, ok := h.definitions[ev.Event]
if !ok {
continue
}
// let cache catch up, refs https://github.com/evcc-io/evcc/pull/445
flushC := util.Flusher()
valueChan <- util.Param{Val: flushC}
<-flushC
title, err := h.apply(ev, definition.Title)
if err != nil {
log.ERROR.Printf("invalid title template for %s: %v", ev.Event, err)
continue
}
msg, err := h.apply(ev, definition.Msg)
if err != nil {
log.ERROR.Printf("invalid message template for %s: %v", ev.Event, err)
continue
}
if strings.TrimSpace(msg) == "" {
continue
}
for _, sender := range h.sender {
go sender.Send(title, msg)
}
}
}

91
messenger/messenger.go Normal file
View file

@ -0,0 +1,91 @@
package messenger
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/plugin"
"github.com/evcc-io/evcc/util"
)
func init() {
registry.AddCtx(api.Custom, NewConfigurableFromConfig)
}
// NewConfigurableFromConfig creates Messenger from config
func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.Messenger, error) {
var cc struct {
Send plugin.Config
Encoding string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
send, err := cc.Send.StringSetter(ctx, "send")
if err != nil {
return nil, err
}
return NewConfigurable(send, cc.Encoding)
}
// NewConfigurable creates a new Messenger
func NewConfigurable(send func(string) error, encoding string) (*Push, error) {
m := &Push{
log: util.NewLogger("push"),
send: send,
encoding: strings.ToLower(encoding),
}
return m, nil
}
// Push is a configurable Messenger implementation
type Push struct {
log *util.Logger
send func(string) error
encoding string
}
func (m *Push) csv(separator rune, title, msg string) string {
var b bytes.Buffer
ww := csv.NewWriter(&b)
ww.Comma = separator
_ = ww.Write([]string{title, msg})
ww.Flush()
return b.String()
}
// Send implements the Messenger interface
func (m *Push) Send(title, msg string) {
var res string
switch m.encoding {
case "json":
b, _ := json.Marshal(struct {
Title string `json:"title,omitempty"`
Msg string `json:"msg"`
}{
Title: title,
Msg: msg,
})
res = string(b)
case "csv":
res = m.csv(',', title, msg)
case "tsv":
res = m.csv('\t', title, msg)
case "title":
res = title
default:
res = msg
}
if err := m.send(res); err != nil {
m.log.ERROR.Printf("send: %v", err)
}
}

97
messenger/ntfy.go Normal file
View file

@ -0,0 +1,97 @@
package messenger
import (
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
)
func init() {
registry.Add("ntfy", NewNtfyFromConfig)
}
// Ntfy implements the ntfy messaging aggregator
type Ntfy struct {
log *util.Logger
uri string
priority string
tags string
}
// NewNtfyFromConfig creates new Ntfy messenger
func NewNtfyFromConfig(other map[string]any) (api.Messenger, error) {
var cc struct {
URI string
Priority string
Tags string
AuthToken string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.URI == "" {
return nil, errors.New("missing uri")
}
u, err := url.Parse(cc.URI)
if err != nil {
return nil, err
}
log := util.NewLogger("ntfy")
if cc.AuthToken != "" {
bearer := "Bearer " + cc.AuthToken
encoded := base64.RawStdEncoding.EncodeToString([]byte(bearer))
q := u.Query()
if q.Has("auth") {
return nil, fmt.Errorf("uri already contains auth parameter")
}
q.Set("auth", encoded)
u.RawQuery = q.Encode()
cc.URI = u.String()
log = log.Redact(cc.AuthToken, bearer, encoded)
}
if token, ok := strings.CutPrefix(u.String(), "https://ntfy.sh/"); ok {
log = log.Redact(token)
}
m := &Ntfy{
log: log,
uri: cc.URI,
priority: cc.Priority,
tags: cc.Tags,
}
return m, nil
}
// Send sends to all receivers
func (m *Ntfy) Send(title, msg string) {
req, err := request.New("POST", m.uri, strings.NewReader(msg), map[string]string{
"Priority": m.priority,
"Title": title,
"Tags": m.tags,
})
if err != nil {
m.log.ERROR.Printf("ntfy: %v", err)
}
if _, err := http.DefaultClient.Do(req); err != nil {
m.log.ERROR.Printf("ntfy: %v", err)
}
}

67
messenger/pushover.go Normal file
View file

@ -0,0 +1,67 @@
package messenger
import (
"errors"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/gregdel/pushover"
)
func init() {
registry.Add("pushover", NewPushOverFromConfig)
}
// PushOver implements the pushover messenger
type PushOver struct {
log *util.Logger
app *pushover.Pushover
device string
recipients []string
}
// NewPushOverFromConfig creates new pushover messenger
func NewPushOverFromConfig(other map[string]any) (api.Messenger, error) {
var cc struct {
App string
Recipients []string
Devices []string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.App == "" {
return nil, errors.New("missing app name")
}
m := &PushOver{
log: util.NewLogger("pushover").Redact(cc.App),
app: pushover.New(cc.App),
device: strings.Join(cc.Devices, ","),
recipients: cc.Recipients,
}
m.log.Redact(cc.Recipients...)
return m, nil
}
// Send sends to all receivers
func (m *PushOver) Send(title, msg string) {
message := pushover.NewMessageWithTitle(msg, title)
message.DeviceName = m.device
for _, id := range m.recipients {
go func(id string) {
m.log.DEBUG.Printf("sending to %s", id)
recipient := pushover.NewRecipient(id)
if _, err := m.app.SendMessage(message, recipient); err != nil {
m.log.ERROR.Print(err)
}
}(id)
}
}

55
messenger/shoutrrr.go Normal file
View file

@ -0,0 +1,55 @@
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("shout", NewShoutrrrFromConfig)
}
// Shoutrrr implements the shoutrrr messaging aggregator
type Shoutrrr struct {
log *util.Logger
app *router.ServiceRouter
}
// NewShoutrrrFromConfig creates new Shoutrrr messenger
func NewShoutrrrFromConfig(other map[string]any) (api.Messenger, error) {
var cc struct {
URI string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
app, err := shoutrrr.CreateSender(cc.URI)
if err != nil {
return nil, err
}
m := &Shoutrrr{
log: util.NewLogger("shoutrrr"),
app: app,
}
return m, nil
}
// Send sends to all receivers
func (m *Shoutrrr) Send(title, msg string) {
params := &types.Params{
"title": title,
}
for _, err := range m.app.Send(msg, params) {
if err != nil {
m.log.ERROR.Println("send:", err)
}
}
}

95
messenger/telegram.go Normal file
View file

@ -0,0 +1,95 @@
package messenger
import (
"context"
"errors"
"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"
)
func init() {
registry.AddCtx("telegram", NewTelegramFromConfig)
}
// Telegram implements the Telegram messenger
type Telegram struct {
log *util.Logger
sync.Mutex
bot *bot.Bot
chats map[int64]struct{}
}
// NewTelegramFromConfig creates new pushover messenger
func NewTelegramFromConfig(ctx context.Context, other map[string]any) (api.Messenger, error) {
var cc struct {
Token string
Chats []int64
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
log := util.NewLogger("telegram").Redact(cc.Token)
m := &Telegram{
log: log,
chats: make(map[int64]struct{}),
}
bot, err := bot.New(cc.Token, bot.WithDefaultHandler(m.handler), bot.WithErrorsHandler(func(err error) {
log.ERROR.Println(err)
}), bot.WithDebugHandler(func(format string, args ...any) {
log.TRACE.Printf(format, args...)
}))
if err != nil {
return nil, errors.New("invalid bot token")
}
m.bot = bot
go bot.Start(ctx)
for _, chat := range cc.Chats {
log.Redact(strconv.FormatInt(chat, 10))
m.chats[chat] = struct{}{}
}
return m, nil
}
// handler captures ids of all chats that bot participates in
func (m *Telegram) handler(ctx context.Context, b *bot.Bot, update *models.Update) {
if update.Message == nil {
return
}
m.Lock()
defer m.Unlock()
if _, ok := m.chats[update.Message.Chat.ID]; !ok {
m.log.INFO.Printf("new chat id: %d", update.Message.Chat.ID)
}
}
// Send sends to all receivers
func (m *Telegram) Send(title, msg string) {
m.Lock()
defer m.Unlock()
for chat := range m.chats {
m.log.DEBUG.Printf("sending to %d", chat)
if _, err := m.bot.SendMessage(context.Background(), &bot.SendMessageParams{
ChatID: chat,
Text: msg,
}); err != nil {
m.log.ERROR.Println("send:", err)
}
}
}

21
messenger/template.go Normal file
View file

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

View file

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