diff --git a/.changeset/ocpp-socket-read-wait.md b/.changeset/ocpp-socket-read-wait.md new file mode 100644 index 00000000..760c4c0d --- /dev/null +++ b/.changeset/ocpp-socket-read-wait.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +An idle OCPP charger no longer loses its connection about once a minute. The OCPP library closed a charger socket that had sent nothing for 60 s, the same as a 60 s heartbeat, so a charger that only sends heartbeats could be dropped as each one arrived, reconnect, and briefly show Home use as "≈". A charger socket may now stay silent for five heartbeat intervals (150 s with the default 30 s heartbeat). FTW's own watchdog still decides when a quiet charger is offline. diff --git a/go/internal/ocpp/read_wait_test.go b/go/internal/ocpp/read_wait_test.go new file mode 100644 index 00000000..7898d7c9 --- /dev/null +++ b/go/internal/ocpp/read_wait_test.go @@ -0,0 +1,50 @@ +package ocpp + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/gorilla/websocket" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// An idle charger that sends only heartbeats must keep its socket. The +// library's own limit was 60 s, the same as a 60 s heartbeat, so the socket +// was closed as the heartbeat arrived. The limit now follows the heartbeat +// interval: with a 1 s heartbeat a silent charger is closed after about 5 s, +// not after 60 s. +func TestSilentChargerSocketFollowsHeartbeat(t *testing.T) { + port := freePort(t) + cfg := &Config{Enabled: true, Bind: "127.0.0.1", Port: port, HeartbeatIntervalS: 1, ApprovedIDs: []string{"garage"}} + srv, err := Start(context.Background(), cfg, telemetry.NewStore()) + if err != nil { + t.Fatalf("start: %v", err) + } + t.Cleanup(srv.Stop) + waitForListener(t, port) + + dialer := websocket.Dialer{Subprotocols: []string{"ocpp1.6"}, HandshakeTimeout: 2 * time.Second} + conn, _, err := dialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d%sgarage", port, srv.Path()), nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + start := time.Now() + _ = conn.SetReadDeadline(start.Add(12 * time.Second)) + for { + // Core may probe the charger; a silent charger never answers. + if _, _, err := conn.ReadMessage(); err != nil { + break + } + } + if got := time.Since(start); got < 4*time.Second || got > 8*time.Second { + t.Fatalf("silent charger socket closed after %v, want about 5 s (five 1 s heartbeats)", got.Round(100*time.Millisecond)) + } + if got := socketReadWait(30); got != 150*time.Second { + t.Fatalf("default 30 s heartbeat gives %v, want 150 s", got) + } +} diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go index 992b3b1e..4b032971 100644 --- a/go/internal/ocpp/server.go +++ b/go/internal/ocpp/server.go @@ -207,6 +207,14 @@ func newListener(cfg *Config, auth *authorizer, sockets *socketSessions) (ws.WsS } else { srv = ws.NewServer() } + // The library closes a socket that has sent nothing for its PingWait, + // 60 s by default. An idle charger that sends only heartbeats, at our + // interval or its own 60 s, lands on that edge and is dropped. The + // telemetry watchdog decides when a quiet charger is offline; this timer + // only has to outlast a few missed heartbeats. + timeouts := ws.NewServerTimeoutConfig() + timeouts.PingWait = socketReadWait(cfg.HeartbeatIntervalS) + srv.SetTimeoutConfig(timeouts) if auth.requiresCredential() { srv.SetBasicAuthHandler(auth.basicAuth) } @@ -214,6 +222,12 @@ func newListener(cfg *Config, auth *authorizer, sockets *socketSessions) (ws.WsS return &guardedServer{Server: srv, check: auth.checkClient, sessions: sockets}, nil } +// socketReadWait is how long a charger socket may stay silent: five +// heartbeat intervals. +func socketReadWait(heartbeatS int) time.Duration { + return 5 * time.Duration(heartbeatS) * time.Second +} + // guardedServer keeps our connection check installed. // // ocppj.Server.Start unconditionally calls SetCheckClientHandler with its own