diff --git a/cmd/root.go b/cmd/root.go index 54fe77e393..f514ea7d9a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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) } @@ -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) diff --git a/cmd/setup.go b/cmd/setup.go index cfdfca3aba..10bcfb788e 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -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 @@ -987,7 +987,7 @@ 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 } @@ -995,10 +995,10 @@ func configureMessengers(confMessaging *globalconfig.Messaging, confEvents *glob 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") } } @@ -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 { @@ -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 @@ -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() { @@ -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) { diff --git a/core/keys/global.go b/core/keys/global.go index 232ec18b44..0eecc811dd 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -14,6 +14,7 @@ const ( Shm = "shm" Messaging = "messaging" MessagingEvents = "messagingEvents" + PushTokens = "pushTokens" ModbusProxy = "modbusproxy" Ocpp = "ocpp" OcppForwarder = "ocppforwarder" diff --git a/messenger/app.go b/messenger/app.go new file mode 100644 index 0000000000..cfad1ed2d1 --- /dev/null +++ b/messenger/app.go @@ -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 { + 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]) + } + } + } +} diff --git a/messenger/app_test.go b/messenger/app_test.go new file mode 100644 index 0000000000..cba5643d46 --- /dev/null +++ b/messenger/app_test.go @@ -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]") + assert.Equal(t, []string{"ExponentPushToken[b]"}, m.tokens) + + m.Unregister("ExponentPushToken[unknown]") + assert.Equal(t, []string{"ExponentPushToken[b]"}, m.tokens) +} diff --git a/server/http_apppush.go b/server/http_apppush.go new file mode 100644 index 0000000000..6ebb39bddc --- /dev/null +++ b/server/http_apppush.go @@ -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) { + 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) + } +}