private stopping: boolean
private templateFileHash: string
private templateFileWatcher?: FSWatcher
+ private wsConnectionClosedByRequest: boolean
private wsConnectionRetryCount: number
private wsPingSetInterval?: NodeJS.Timeout
this.starting = false
this.stopping = false
this.wsConnection = null
+ this.wsConnectionClosedByRequest = false
this.wsConnectionRetryCount = 0
this.index = index
this.templateFile = templateFile
this.setIntervalFlushMessageBuffer()
}
- /** Closes the WebSocket connection to the central server. */
- public closeWSConnection (): void {
+ /**
+ * Closes the WebSocket connection to the central server.
+ * @param options - Close options
+ * @param options.byRequest - Whether the close is requested (the UI disconnect
+ * action); a requested close is terminal (onClose does not reconnect), any
+ * other close (e.g. certificate rotation) reconnects like a server-initiated drop
+ */
+ public closeWSConnection ({ byRequest = false }: { byRequest?: boolean } = {}): void {
if (this.isWebSocketConnectionOpened()) {
+ if (byRequest) {
+ this.wsConnectionClosedByRequest = true
+ }
this.wsConnection?.close()
}
}
private onClose (code: WebSocketCloseEventStatusCode, reason: Buffer): void {
this.emitChargingStationEvent(ChargingStationEvents.disconnected)
this.emitChargingStationEvent(ChargingStationEvents.updated)
+ // Capture and clear the requested-close marker before deciding whether to
+ // reconnect.
+ const closedByRequest = this.wsConnectionClosedByRequest
+ this.wsConnectionClosedByRequest = false
switch (code) {
// Normal close
case WebSocketCloseEventStatusCode.CLOSE_NO_STATUS:
code
)}' and reason '${reason.toString()}'`
)
- this.wsConnectionRetryCount = 0
break
// Abnormal close
default:
code
)}' and reason '${reason.toString()}'`
)
- this.started &&
- this.reconnect()
- .then(() => {
- this.emitChargingStationEvent(ChargingStationEvents.updated)
- return undefined
- })
- .catch((error: unknown) =>
- logger.error(
- `${this.logPrefix()} ${moduleName}.onClose: Error while reconnecting:`,
- error
- )
- )
break
}
+ // Reconnect on any close we did not request while still started: a
+ // server-initiated drop, or an internal close wanting a fresh connection
+ // (e.g. certificate rotation), clean or abnormal. Only a requested close
+ // stays terminal.
+ if (this.started && !closedByRequest) {
+ this.reconnect()
+ .then(() => {
+ this.emitChargingStationEvent(ChargingStationEvents.updated)
+ return undefined
+ })
+ .catch((error: unknown) =>
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.onClose: Error while reconnecting:`,
+ error
+ )
+ )
+ } else {
+ this.wsConnectionRetryCount = 0
+ }
}
private onError (error: WSError): void {
[
BroadcastChannelProcedureName.CLOSE_CONNECTION,
() => {
- this.chargingStation.closeWSConnection()
+ // Requested disconnect (UI action): terminal, must not auto-reconnect.
+ this.chargingStation.closeWSConnection({ byRequest: true })
},
],
[BroadcastChannelProcedureName.DATA_TRANSFER, this.passthrough(RequestCommand.DATA_TRANSFER)],
--- /dev/null
+/**
+ * @file Tests the reconnect decision made when a charging station's WebSocket closes.
+ * @description The station reconnects after any close it did not itself request
+ * (a server-initiated drop, clean or abnormal) while still started, and stays
+ * disconnected after a requested close.
+ */
+import assert from 'node:assert/strict'
+import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, it } from 'node:test'
+import { WebSocket } from 'ws'
+
+import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import { WebSocketCloseEventStatusCode } from '../../src/types/index.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+
+// onClose and reconnect are private; the tests drive onClose directly with a
+// spied reconnect to observe the reconnect decision without opening a socket.
+interface StationInternals {
+ onClose: (code: WebSocketCloseEventStatusCode, reason: Buffer) => void
+ reconnect: () => Promise<void>
+ started: boolean
+ wsConnection: unknown
+}
+
+const tmpRoots: string[] = []
+
+// Build a started station whose reconnect() is replaced by a counter.
+const makeStation = (): { reconnectCount: () => number; station: ChargingStation } => {
+ const root = mkdtempSync(join(tmpdir(), 'cs-reconnect-'))
+ tmpRoots.push(root)
+ mkdirSync(join(root, 'station-templates'), { recursive: true })
+ const templateFile = join(root, 'station-templates', 'virtual-simple.station-template.json')
+ copyFileSync(
+ join(process.cwd(), 'src/assets/station-templates/virtual-simple.station-template.json'),
+ templateFile
+ )
+ const station = new ChargingStation(1, templateFile, {
+ autoStart: false,
+ supervisionUrls: 'ws://localhost:9999/',
+ })
+ let reconnects = 0
+ const internals = station as unknown as StationInternals
+ internals.reconnect = () => {
+ reconnects++
+ return Promise.resolve()
+ }
+ internals.started = true
+ return { reconnectCount: () => reconnects, station }
+}
+
+await describe('ChargingStation reconnect decision on WebSocket close', async () => {
+ afterEach(() => {
+ standardCleanup()
+ for (const root of tmpRoots.splice(0)) {
+ rmSync(root, { force: true, recursive: true })
+ }
+ })
+
+ await it('should reconnect after a server-initiated normal close while started', () => {
+ const { reconnectCount, station } = makeStation()
+
+ ;(station as unknown as StationInternals).onClose(
+ WebSocketCloseEventStatusCode.CLOSE_NORMAL,
+ Buffer.from('')
+ )
+
+ assert.strictEqual(reconnectCount(), 1)
+ })
+
+ await it('should stay disconnected after a requested close', () => {
+ const { reconnectCount, station } = makeStation()
+ const internals = station as unknown as StationInternals
+ // An open socket lets closeWSConnection record the request before onClose runs.
+ internals.wsConnection = { close: () => undefined, readyState: WebSocket.OPEN }
+
+ station.closeWSConnection({ byRequest: true })
+ internals.onClose(WebSocketCloseEventStatusCode.CLOSE_NORMAL, Buffer.from(''))
+
+ assert.strictEqual(reconnectCount(), 0)
+ })
+})
assert.strictEqual(mocks.webSocket.readyState, 3) // CLOSED
})
- await it('should not reconnect on normal WebSocket close', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- station.started = true
-
- // Act - Simulate normal close (code 1000 = normal closure)
- mocks.webSocket.simulateClose(1000, 'Normal closure')
-
- // Assert - WebSocket should be closed
- assert.strictEqual(mocks.webSocket.readyState, 3) // CLOSED
- })
-
await it('should track connection retry count', () => {
// Arrange
const result = createMockChargingStation({ connectorsCount: 1 })
assert.strictEqual(sentRequests[0].command, RequestCommand.GET_15118_EV_CERTIFICATE)
})
+ await it('should dispatch CLOSE_CONNECTION as a requested (terminal) close', async t => {
+ const { station } = createMockStationWithRequestTracking()
+
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+ const closeSpy = t.mock.method(station, 'closeWSConnection', () => undefined)
+
+ await testable.commandHandler(BroadcastChannelProcedureName.CLOSE_CONNECTION, {})
+
+ // The UI disconnect must close with byRequest so onClose treats it as
+ // terminal and does not auto-reconnect.
+ assert.strictEqual(closeSpy.mock.calls.length, 1)
+ assert.deepStrictEqual(closeSpy.mock.calls[0].arguments, [{ byRequest: true }])
+ })
+
await it('should dispatch GET_CERTIFICATE_STATUS through commandHandler', async () => {
const { sentRequests, station } = createMockStationWithRequestTracking()