diff --git a/assets/js/components/Config/Remote/RemoteModal.vue b/assets/js/components/Config/Remote/RemoteModal.vue
index c52560217..a25e4e129 100644
--- a/assets/js/components/Config/Remote/RemoteModal.vue
+++ b/assets/js/components/Config/Remote/RemoteModal.vue
@@ -35,6 +35,11 @@
{{ $t("config.remote.disconnected") }}
+
diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts
index 5fbd3743c..82b2826fb 100644
--- a/assets/js/types/evcc.ts
+++ b/assets/js/types/evcc.ts
@@ -963,6 +963,8 @@ export type RemoteStatus = {
loginBlocked: boolean;
/** Last remote activity per client, keyed by username. RFC3339 timestamps. */
lastSeen?: Record;
+ /** Last connection error. */
+ error?: string;
};
export type RemoteClient = {
diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json
index f99e838ec..c0312fa0e 100644
--- a/server/mcp/openapi.json
+++ b/server/mcp/openapi.json
@@ -4127,6 +4127,10 @@
"additionalProperties": {
"type": "string"
}
+ },
+ "error": {
+ "description": "Last connection error.",
+ "type": "string"
}
},
"required": [
diff --git a/server/openapi.state.yaml b/server/openapi.state.yaml
index 500ae5955..fac0068aa 100644
--- a/server/openapi.state.yaml
+++ b/server/openapi.state.yaml
@@ -1586,6 +1586,9 @@ components:
type: object
additionalProperties:
type: string
+ error:
+ description: Last connection error.
+ type: string
required:
- connected
- loginBlocked
diff --git a/server/remote/remote.go b/server/remote/remote.go
index b95a272d8..dc0b0c2f8 100644
--- a/server/remote/remote.go
+++ b/server/remote/remote.go
@@ -1,6 +1,7 @@
package remote
import (
+ "errors"
"fmt"
"net/http"
"sync"
@@ -34,6 +35,7 @@ type Remote struct {
publisher chan<- util.Param
lastSeen map[string]time.Time // persisted: username → last activity
connected map[string]int // in-memory: active connection count per user
+ lastError error // last connect/registration error, published to UI
}
// New creates a new Remote manager, loads persisted settings, and connects if enabled.
@@ -93,8 +95,10 @@ func (r *Remote) Enabled() bool {
}
func (r *Remote) connect() {
- if !sponsor.IsAuthorizedForApi() {
- r.log.WARN.Println("remote access requires a sponsor token")
+ if sponsor.Token == "" {
+ msg := "remote access requires a sponsor token"
+ r.log.WARN.Println(msg)
+ r.setError(errors.New(msg))
return
}
@@ -105,10 +109,13 @@ func (r *Remote) connect() {
if token == "" {
if err := r.register(); err != nil {
r.log.ERROR.Printf("registration failed: %v", err)
+ r.setError(fmt.Errorf("registration failed: %w", err))
return
}
}
+ r.setError(nil)
+
r.log.INFO.Printf("remote access via %s", r.settings.URL)
tunnel := NewTunnel(r.settings.TunnelURL, r.settings.Token, r.httpHandler, r.Authenticate, r.TrackActivity, r.log, r.publish)
@@ -129,6 +136,16 @@ func (r *Remote) disconnect() {
r.tunnel.Close()
r.tunnel = nil
}
+ r.lastError = nil
+}
+
+// setError records a connect error and publishes it to the UI.
+func (r *Remote) setError(err error) {
+ r.mu.Lock()
+ r.lastError = err
+ r.mu.Unlock()
+
+ r.publish()
}
type registerRequest struct {
@@ -192,6 +209,16 @@ func (r *Remote) ConfigStatus() globalconfig.ConfigStatus {
connected := r.tunnel != nil && r.tunnel.IsConnected()
loginBlocked := r.tunnel != nil && r.tunnel.LoginBlocked()
+ lastErr := r.lastError
+ if lastErr == nil && r.tunnel != nil {
+ lastErr = r.tunnel.Error()
+ }
+
+ var errMsg string
+ if lastErr != nil {
+ errMsg = lastErr.Error()
+ }
+
return globalconfig.ConfigStatus{
Config: struct {
Enabled bool `json:"enabled"`
@@ -203,11 +230,13 @@ func (r *Remote) ConfigStatus() globalconfig.ConfigStatus {
URL string `json:"url,omitempty"`
LoginBlocked bool `json:"loginBlocked"`
LastSeen map[string]time.Time `json:"lastSeen,omitempty"`
+ Error string `json:"error,omitempty"`
}{
Connected: connected,
URL: r.settings.URL,
LoginBlocked: loginBlocked,
LastSeen: r.lastSeen,
+ Error: errMsg,
},
}
}
diff --git a/server/remote/tunnel.go b/server/remote/tunnel.go
index c452db84e..b9d65cbcd 100644
--- a/server/remote/tunnel.go
+++ b/server/remote/tunnel.go
@@ -35,6 +35,7 @@ type Tunnel struct {
mu sync.Mutex
session *yamux.Session
+ lastErr error
}
// NewTunnel creates a new tunnel client.
@@ -66,6 +67,7 @@ func (t *Tunnel) run() {
ok, err := t.connect(ctx)
if err != nil && !errors.Is(err, context.Canceled) {
t.log.ERROR.Printf("tunnel: %v", err)
+ t.setError(err)
}
// rejected credentials will not self-heal; a new token requires a restart
@@ -139,6 +141,9 @@ func (t *Tunnel) connect(ctx context.Context) (bool, error) {
func (t *Tunnel) changeState(session *yamux.Session, err error) {
t.mu.Lock()
t.session = session
+ if session != nil {
+ t.lastErr = nil
+ }
t.mu.Unlock()
if t.onStateChange != nil {
@@ -156,6 +161,23 @@ func (t *Tunnel) changeState(session *yamux.Session, err error) {
}
}
+func (t *Tunnel) setError(err error) {
+ t.mu.Lock()
+ t.lastErr = err
+ t.mu.Unlock()
+
+ if t.onStateChange != nil {
+ t.onStateChange()
+ }
+}
+
+// Error returns the last connection error, if any.
+func (t *Tunnel) Error() error {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.lastErr
+}
+
// IsConnected returns whether the tunnel is currently connected.
func (t *Tunnel) IsConnected() bool {
t.mu.Lock()