Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,9 @@ func runRoot(cmd *cobra.Command, args []string) {

// setup messaging
var pushChan chan messenger.Event
var appPush *messenger.AppPush
if err == nil {
pushChan, err = configureMessengers(&conf.Messaging, &conf.MessagingEvents, site.Vehicles(), valueChan, cache)
pushChan, appPush, err = configureMessengers(&conf.Messaging, &conf.MessagingEvents, site.Vehicles(), valueChan, cache)
err = wrapErrorWithClass(ClassMessenger, err)
}

Expand Down Expand Up @@ -493,6 +494,11 @@ func runRoot(cmd *cobra.Command, args []string) {
once.Do(func() { close(stopC) }) // signal loop to end
}, viper.ConfigFileUsed(), remoteAccess)

// companion app push token registration
if appPush != nil {
httpd.RegisterAppPushHandlers(appPush)
}

// show and check version, reduce api load during development
if util.Version != util.DevVersion {
go updater.Run(log, httpd, valueChan)
Expand Down
20 changes: 12 additions & 8 deletions cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,7 @@ func configureEEBus(conf *eebus.Config) error {
return nil
}

func configureMessengers(confMessaging *globalconfig.Messaging, confEvents *globalconfig.MessagingEvents, vehicles messenger.Vehicles, valueChan chan<- util.Param, cache *util.ParamCache) (chan messenger.Event, error) {
func configureMessengers(confMessaging *globalconfig.Messaging, confEvents *globalconfig.MessagingEvents, vehicles messenger.Vehicles, valueChan chan<- util.Param, cache *util.ParamCache) (chan messenger.Event, *messenger.AppPush, error) {
// yaml config from file
if len(confMessaging.Events) != 0 || len(confMessaging.Services) != 0 {
yamlSource.messaging = globalconfig.YamlSourceFile
Expand All @@ -987,18 +987,18 @@ func configureMessengers(confMessaging *globalconfig.Messaging, confEvents *glob
}
*confMessaging = globalconfig.Messaging{}
if err := settings.Yaml(keys.Messaging, new(map[string]any), &confMessaging); err != nil {
return nil, err
return nil, 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
return nil, nil, err
}
if yamlSource.messaging != globalconfig.YamlSourceNone && confEvents != nil {
return nil, errors.New("yaml and device config exists for messaging; remove yaml config")
return nil, nil, errors.New("yaml and device config exists for messaging; remove yaml config")
}
}

Expand All @@ -1022,7 +1022,7 @@ func configureMessengers(confMessaging *globalconfig.Messaging, confEvents *glob
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Messenger)
if err != nil {
return messageChan, err
return messageChan, nil, err
}

for _, conf := range configurable {
Expand All @@ -1032,7 +1032,7 @@ func configureMessengers(confMessaging *globalconfig.Messaging, confEvents *glob
}

if err := eg.Wait(); err != nil {
return messageChan, &ClassError{ClassMessenger, err}
return messageChan, nil, &ClassError{ClassMessenger, err}
}

var events globalconfig.MessagingEvents
Expand All @@ -1046,7 +1046,7 @@ func configureMessengers(confMessaging *globalconfig.Messaging, confEvents *glob
messageHub, err := messenger.NewHub(events, vehicles, cache)

if err != nil {
return messageChan, fmt.Errorf("failed configuring push services: %w", err)
return messageChan, nil, fmt.Errorf("failed configuring push services: %w", err)
}

for _, dev := range config.Messengers().Devices() {
Expand All @@ -1055,9 +1055,13 @@ func configureMessengers(confMessaging *globalconfig.Messaging, confEvents *glob
}
}

// companion app push devices are an implicit messenger
appPush := messenger.NewAppPushFromSettings()
messageHub.Add(appPush)

go messageHub.Run(messageChan, valueChan)

return messageChan, nil
return messageChan, appPush, nil
}

func tariffInstance(name string, conf config.Typed) (api.Tariff, error) {
Expand Down
1 change: 1 addition & 0 deletions core/keys/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const (
Shm = "shm"
Messaging = "messaging"
MessagingEvents = "messagingEvents"
PushTokens = "pushTokens"
ModbusProxy = "modbusproxy"
Ocpp = "ocpp"
OcppForwarder = "ocppforwarder"
Expand Down
141 changes: 141 additions & 0 deletions messenger/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package messenger

import (
"net/http"
"slices"
"strings"
"sync"

"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
)

const (
expoPushURI = "https://exp.host/--/api/v2/push/send"

// Expo push tokens have the form ExponentPushToken[xxxxxxxx]
tokenPrefix = "ExponentPushToken["
tokenSuffix = "]"
maxTokenLen = 128
maxTokens = 20
)

// AppPush sends messages to registered companion app devices via the Expo push
// service. The app registers its device token through the /api/push/token endpoint.
type AppPush struct {
mu sync.Mutex
log *util.Logger
tokens []string
}

// NewAppPushFromSettings creates an AppPush messenger with tokens restored from settings
func NewAppPushFromSettings() *AppPush {
m := &AppPush{log: util.NewLogger("apppush")}
_ = settings.Json(keys.PushTokens, &m.tokens)
return m
}

// ValidPushToken checks the Expo push token format
func ValidPushToken(token string) bool {
return len(token) <= maxTokenLen &&
strings.HasPrefix(token, tokenPrefix) &&
strings.HasSuffix(token, tokenSuffix)
}

// Register adds a device token
func (m *AppPush) Register(token string) {
if !ValidPushToken(token) {
return
}

m.mu.Lock()
defer m.mu.Unlock()

if slices.Contains(m.tokens, token) {
return
}

// drop oldest when full
if len(m.tokens) >= maxTokens {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
m.tokens = m.tokens[len(m.tokens)-maxTokens+1:]
}

m.tokens = append(m.tokens, token)
m.persist()
}

// Unregister removes a device token
func (m *AppPush) Unregister(token string) {
m.mu.Lock()
defer m.mu.Unlock()

if i := slices.Index(m.tokens, token); i >= 0 {
m.tokens = slices.Delete(m.tokens, i, i+1)
m.persist()
}
}

// persist must be called with mu held
func (m *AppPush) persist() {
if err := settings.SetJson(keys.PushTokens, m.tokens); err != nil {
m.log.ERROR.Println(err)
}
}

type expoPushMessage struct {
To string `json:"to"`
Title string `json:"title,omitempty"`
Body string `json:"body"`
}

type expoPushResponse struct {
Data []struct {
Status string `json:"status"`
Message string `json:"message"`
Details struct {
Error string `json:"error"`
} `json:"details"`
} `json:"data"`
}

// Send implements the api.Messenger interface
func (m *AppPush) Send(title, msg string) {
m.mu.Lock()
tokens := slices.Clone(m.tokens)
m.mu.Unlock()

if len(tokens) == 0 {
return
}

messages := make([]expoPushMessage, 0, len(tokens))
for _, to := range tokens {
messages = append(messages, expoPushMessage{To: to, Title: title, Body: msg})
}

req, err := request.New(http.MethodPost, expoPushURI, request.MarshalJSON(messages), request.JSONEncoding)
if err != nil {
m.log.ERROR.Println(err)
return
}

var res expoPushResponse
if err := request.NewHelper(m.log).DoJSON(req, &res); err != nil {
m.log.ERROR.Println(err)
return
}

// responses are order-aligned with the request
for i, r := range res.Data {
if r.Status != "ok" && i < len(tokens) {
m.log.WARN.Printf("push failed: %s %s", r.Message, r.Details.Error)

// prune devices that are no longer registered
if r.Details.Error == "DeviceNotRegistered" {
m.Unregister(tokens[i])
}
}
}
}
51 changes: 51 additions & 0 deletions messenger/app_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package messenger

import (
"strings"
"testing"

"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
)

// token of exactly the given total length
func tokenOfLen(l int) string {
return tokenPrefix + strings.Repeat("x", l-len(tokenPrefix)-len(tokenSuffix)) + tokenSuffix
}

func TestValidPushToken(t *testing.T) {
tc := []struct {
name string
token string
valid bool
}{
{"typical", "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", true},
{"empty", "", false},
{"garbage", "foo", false},
{"unterminated", "ExponentPushToken[unterminated", false},
{"max length", tokenOfLen(maxTokenLen), true},
{"too long", tokenOfLen(maxTokenLen + 1), false},
}

for _, tc := range tc {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.valid, ValidPushToken(tc.token))
})
}
}

func TestAppPushRegister(t *testing.T) {
m := &AppPush{log: util.NewLogger("test")}

m.Register("ExponentPushToken[a]")
m.Register("ExponentPushToken[a]") // duplicate
m.Register("ExponentPushToken[b]")
m.Register("invalid")
assert.Equal(t, []string{"ExponentPushToken[a]", "ExponentPushToken[b]"}, m.tokens)

m.Unregister("ExponentPushToken[a]")
Comment on lines +37 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for max token capacity and pruning behavior in AppPush.Register

The test currently covers register/unregister and duplicates but not the maxTokens capacity behavior. Please add a test that:

  • initializes m.tokens with maxTokens valid entries,
  • registers one additional valid token,
  • verifies the slice length remains maxTokens and the oldest token was removed.
    This will validate the pruning logic and help prevent regressions in capacity handling.

Suggested implementation:

func TestAppPushRegister(t *testing.T) {
	m := &AppPush{log: util.NewLogger("test")}

	m.Register("ExponentPushToken[a]")
	m.Register("ExponentPushToken[a]") // duplicate
	m.Register("ExponentPushToken[b]")
	m.Register("invalid")
	assert.Equal(t, []string{"ExponentPushToken[a]", "ExponentPushToken[b]"}, m.tokens)

	m.Unregister("ExponentPushToken[a]")
	assert.Equal(t, []string{"ExponentPushToken[b]"}, m.tokens)

	m.Unregister("ExponentPushToken[unknown]")
	assert.Equal(t, []string{"ExponentPushToken[b]"}, m.tokens)
}

func TestAppPushRegisterMaxTokensPrunesOldest(t *testing.T) {
	m := &AppPush{log: util.NewLogger("test")}

	// Fill up to maxTokens with valid, distinct tokens
	for i := 0; i < maxTokens; i++ {
		token := fmt.Sprintf("ExponentPushToken[%d]", i)
		m.Register(token)
	}

	// Sanity check: we should be at capacity
	assert.Len(t, m.tokens, maxTokens)
	assert.Equal(t, "ExponentPushToken[0]", m.tokens[0])

	// Register one more valid token; this should prune the oldest
	newToken := "ExponentPushToken[new]"
	m.Register(newToken)

	// Capacity must remain maxTokens
	assert.Len(t, m.tokens, maxTokens)

	// Oldest token should have been removed
	assert.NotContains(t, m.tokens, "ExponentPushToken[0]")

	// New token should be present
	assert.Contains(t, m.tokens, newToken)
}

If fmt is not already imported in messenger/app_test.go, add it to the import list:

<<<<<<< SEARCH
import (
"testing"

"github.com/stretchr/testify/assert"
"example.com/project/util"

)

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"example.com/project/util"

)

REPLACE

Also ensure that maxTokens is accessible in this test file (exported or in the same package). If it is not, expose it or add a helper in the test file that mirrors the production value.

assert.Equal(t, []string{"ExponentPushToken[b]"}, m.tokens)

m.Unregister("ExponentPushToken[unknown]")
assert.Equal(t, []string{"ExponentPushToken[b]"}, m.tokens)
}
50 changes: 50 additions & 0 deletions server/http_apppush.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package server

import (
"encoding/json"
"errors"
"net/http"

"github.com/evcc-io/evcc/messenger"
"github.com/gorilla/handlers"
)

// RegisterAppPushHandlers adds the companion app push token endpoints
func (s *HTTPd) RegisterAppPushHandlers(m *messenger.AppPush) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
api := s.Router().PathPrefix("/api").Subrouter()
api.Use(jsonHandler)
api.Use(handlers.CompressHandler)
api.Use(handlers.CORS(
handlers.AllowedHeaders([]string{"Content-Type"}),
))

routes := map[string]route{
"registerpushtoken": {"POST", "/push/token", pushTokenHandler(m.Register)},
"unregisterpushtoken": {"DELETE", "/push/token", pushTokenHandler(m.Unregister)},
}

for _, r := range routes {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
}

func pushTokenHandler(fun func(string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
}

if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}

if !messenger.ValidPushToken(req.Token) {
jsonError(w, http.StatusBadRequest, errors.New("invalid push token"))
return
}

fun(req.Token)
jsonWrite(w, true)
}
}
Loading