type UnlockConnectorResponse,
} from '../../../types/index.js'
import {
+ clampToSafeTimerValue,
Configuration,
+ Constants,
convertToDate,
convertToInt,
convertToIntOrNaN,
/**
* Per-station lifecycle state carried on {@link OCPP16IncomingRequestService}.
*
- * As of #1963 this interface is intentionally empty. Companion PRs will
- * populate this interface with their own fields:
+ * Populated incrementally as companion PRs land. Fields currently
+ * present:
+ * - `deferredFirmwareUpdateTimer` (#1972).
+ *
+ * Companion PRs still pending will add their own fields:
* - #1971 (GetDiagnostics supersession): `activeDiagnosticsAbortController`,
* `activeDiagnosticsRequestId`;
- * - #1972 (deferred firmware `setTimeout` cancel): firmware timer handle;
* - #1973 (trigger cross-check): `activeDiagnosticsRequestId` (shared with
* #1971), `activeFirmwareUpdateRequestId`.
*/
-// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- populated by companion PRs #1971 / #1972 / #1973
-interface OCPP16StationState {}
+interface OCPP16StationState {
+ /**
+ * `setTimeout` handle for the deferred `updateFirmwareSimulation`
+ * scheduled by the `UPDATE_FIRMWARE` event listener when the incoming
+ * request carries a future `retrieveDate`. Released by
+ * {@link OCPP16IncomingRequestService.cancelDeferredFirmwareUpdate}
+ * on stop, supersession, or normal fire (#1972).
+ */
+ deferredFirmwareUpdateTimer?: NodeJS.Timeout
+}
/**
* OCPP 1.6 Incoming Request Service - handles and processes all incoming requests
)
})
} else {
- // Unref: fire-and-forget deferred firmware update must not
- // block node.js exit.
- setTimeout(() => {
+ // Fire-and-forget deferred update: setTimeout(...).unref() keeps
+ // the pending timer from blocking node.js exit. The handle is
+ // stored on OCPP16StationState so stop() cancels it via
+ // resetStationState (#1972); the schedule site supersedes any
+ // prior pending timer via the same helper, and the callback
+ // clears the handle before the awaited simulation so
+ // resetStationState never targets a fired timer.
+ const stationState = this.getOrCreateStationState(chargingStation)
+ this.cancelDeferredFirmwareUpdate(stationState)
+ // Clamp via canonical helper: a retrieveDate beyond
+ // Constants.MAX_SETINTERVAL_DELAY_MS would otherwise be silently
+ // reduced to 1 ms by Node and fire immediately.
+ const delayMs = retrieveDate.getTime() - now
+ const scheduleDelayMs = clampToSafeTimerValue(delayMs)
+ if (scheduleDelayMs !== delayMs) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.constructor: UpdateFirmware retrieveDate delay ${delayMs.toString()} ms exceeds ${Constants.MAX_SETINTERVAL_DELAY_MS.toString()} ms, clamping to ${scheduleDelayMs.toString()} ms`
+ )
+ }
+ stationState.deferredFirmwareUpdateTimer = setTimeout(() => {
+ delete stationState.deferredFirmwareUpdateTimer
this.updateFirmwareSimulation(chargingStation).catch((error: unknown) => {
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.constructor: UpdateFirmware simulation error:`,
error
)
})
- }, retrieveDate.getTime() - now).unref()
+ }, scheduleDelayMs).unref()
}
}
)
}
/**
- * @returns Fresh empty state — companion PRs (#1971 / #1972 / #1973)
- * populate fields via declaration merging on {@link OCPP16StationState}.
+ * @returns Fresh state with all optional {@link OCPP16StationState}
+ * fields unset. Fields are lazily assigned by the request handlers
+ * that own them (see {@link OCPP16StationState}); companion PRs
+ * (#1971 / #1973) will populate their own fields on first use.
*/
protected override createStationState (): OCPP16StationState {
return {}
}
/**
- * Companion PRs (#1971 / #1972 / #1973) will populate this method with
- * release logic paired with the {@link OCPP16StationState} fields they
- * add. They MUST follow the abort-before-clear ordering documented on
- * {@link OCPP20IncomingRequestService.resetStationState}: abort in-flight
- * signals BEFORE clearing controller references, cancel timers BEFORE
- * the base template deletes the entry.
- * @param _stationState - Per-station state (currently empty; unused).
+ * Release resources held by the per-station state. Called by the
+ * inherited `stop()` template BEFORE the base deletes the WeakMap
+ * entry. Follow the abort-before-clear / cancel-before-delete
+ * ordering documented on
+ * {@link OCPP20IncomingRequestService.resetStationState}: abort
+ * in-flight signals BEFORE clearing controller references, cancel
+ * timers BEFORE the base template deletes the entry.
+ *
+ * Companion PRs (#1971 GetDiagnostics supersession, #1973 trigger
+ * cross-check) will extend this method with their own field releases.
+ * @param stationState - Per-station state to release.
*/
- protected override resetStationState (_stationState: OCPP16StationState): void {
- /* no-op: OCPP 1.6 carries no state fields yet (see companion PRs) */
+ protected override resetStationState (stationState: OCPP16StationState): void {
+ this.cancelDeferredFirmwareUpdate(stationState)
+ }
+
+ /**
+ * Cancels the deferred `updateFirmwareSimulation` timer and clears
+ * the handle (#1972). No-op when no timer is pending.
+ * @param stationState - Per-station state carrying the timer handle.
+ */
+ private cancelDeferredFirmwareUpdate (stationState: OCPP16StationState): void {
+ if (stationState.deferredFirmwareUpdateTimer != null) {
+ clearTimeout(stationState.deferredFirmwareUpdateTimer)
+ delete stationState.deferredFirmwareUpdateTimer
+ }
}
private composeCompositeSchedule (
import assert from 'node:assert/strict'
import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
import type { GetDiagnosticsRequest } from '../../../../src/types/index.js'
import { OCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.js'
type OCPP16UpdateFirmwareRequest,
type OCPP16UpdateFirmwareResponse,
} from '../../../../src/types/index.js'
+import { Constants, logger } from '../../../../src/utils/index.js'
import {
flushMicrotasks,
standardCleanup,
// Assert: test passes if no unhandled rejection was thrown
})
})
+
+ // #1972: deferred UpdateFirmware timer lifecycle on OCPP16StationState
+ await describe('UPDATE_FIRMWARE deferred timer lifecycle', async () => {
+ interface OCPP16StationStateShape {
+ deferredFirmwareUpdateTimer?: NodeJS.Timeout
+ }
+
+ interface PlumbingAccess {
+ stationsState: WeakMap<ChargingStation, OCPP16StationStateShape>
+ }
+
+ const asPlumbing = (service: OCPP16IncomingRequestService): PlumbingAccess =>
+ service as unknown as PlumbingAccess
+
+ let listenerService: OCPP16IncomingRequestService
+ let updateFirmwareMock: ReturnType<typeof mock.fn>
+
+ beforeEach(() => {
+ listenerService = new OCPP16IncomingRequestService()
+ updateFirmwareMock = mock.method(
+ listenerService as unknown as {
+ updateFirmwareSimulation: (chargingStation: unknown) => Promise<void>
+ },
+ 'updateFirmwareSimulation',
+ mock.fn(async () => Promise.resolve())
+ )
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await it('should store the deferred timer handle on OCPP16StationState when retrieveDate is in the future', async t => {
+ // Arrange
+ const { station } = createOCPP16ListenerStation('listener-timer-store')
+ const request: OCPP16UpdateFirmwareRequest = {
+ location: 'ftp://localhost/firmware.bin',
+ retrieveDate: new Date(Date.now() + 60_000),
+ }
+ const response: OCPP16UpdateFirmwareResponse = {}
+
+ // Act & Assert
+ await withMockTimers(t, ['setTimeout'], () => {
+ listenerService.emit(
+ OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+ station,
+ request,
+ response
+ )
+
+ const state = asPlumbing(listenerService).stationsState.get(station)
+ assert.notStrictEqual(state, undefined)
+ assert.notStrictEqual(state?.deferredFirmwareUpdateTimer, undefined)
+ })
+ })
+
+ await it('should cancel the deferred timer on stop() and never fire updateFirmwareSimulation', async t => {
+ // Arrange
+ const { station } = createOCPP16ListenerStation('listener-timer-stop')
+ const request: OCPP16UpdateFirmwareRequest = {
+ location: 'ftp://localhost/firmware.bin',
+ retrieveDate: new Date(Date.now() + 60_000),
+ }
+ const response: OCPP16UpdateFirmwareResponse = {}
+
+ // Act & Assert
+ await withMockTimers(t, ['setTimeout'], async () => {
+ listenerService.emit(
+ OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+ station,
+ request,
+ response
+ )
+
+ const plumbing = asPlumbing(listenerService)
+ assert.strictEqual(plumbing.stationsState.has(station), true)
+
+ listenerService.stop(station)
+ assert.strictEqual(plumbing.stationsState.has(station), false)
+
+ t.mock.timers.tick(120_000)
+ await flushMicrotasks()
+
+ assert.strictEqual(updateFirmwareMock.mock.callCount(), 0)
+ })
+ })
+
+ await it('should self-clear the deferred timer handle when the callback fires on schedule', async t => {
+ // Arrange
+ const { station } = createOCPP16ListenerStation('listener-timer-self-clear')
+ const request: OCPP16UpdateFirmwareRequest = {
+ location: 'ftp://localhost/firmware.bin',
+ retrieveDate: new Date(Date.now() + 100),
+ }
+ const response: OCPP16UpdateFirmwareResponse = {}
+
+ // Act & Assert
+ await withMockTimers(t, ['setTimeout'], async () => {
+ listenerService.emit(
+ OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+ station,
+ request,
+ response
+ )
+
+ const plumbing = asPlumbing(listenerService)
+ assert.notStrictEqual(
+ plumbing.stationsState.get(station)?.deferredFirmwareUpdateTimer,
+ undefined
+ )
+
+ t.mock.timers.tick(200)
+ await flushMicrotasks()
+
+ assert.strictEqual(updateFirmwareMock.mock.callCount(), 1)
+ assert.strictEqual(
+ plumbing.stationsState.get(station)?.deferredFirmwareUpdateTimer,
+ undefined
+ )
+ })
+ })
+
+ await it('should cancel the previous deferred timer when re-scheduled before it fires', async t => {
+ // Arrange
+ const { station } = createOCPP16ListenerStation('listener-timer-reschedule')
+ const firstRequest: OCPP16UpdateFirmwareRequest = {
+ location: 'ftp://localhost/firmware.bin',
+ retrieveDate: new Date(Date.now() + 60_000),
+ }
+ const secondRequest: OCPP16UpdateFirmwareRequest = {
+ location: 'ftp://localhost/firmware.bin',
+ retrieveDate: new Date(Date.now() + 30_000),
+ }
+ const response: OCPP16UpdateFirmwareResponse = {}
+
+ // Act & Assert
+ await withMockTimers(t, ['setTimeout'], async () => {
+ listenerService.emit(
+ OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+ station,
+ firstRequest,
+ response
+ )
+ listenerService.emit(
+ OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+ station,
+ secondRequest,
+ response
+ )
+
+ t.mock.timers.tick(31_000)
+ await flushMicrotasks()
+ assert.strictEqual(updateFirmwareMock.mock.callCount(), 1)
+
+ t.mock.timers.tick(60_000)
+ await flushMicrotasks()
+ assert.strictEqual(updateFirmwareMock.mock.callCount(), 1)
+ })
+ })
+
+ await it('should isolate deferred timers between stations - stop(A) must not cancel B', async t => {
+ // Arrange
+ const { station: stationA } = createOCPP16ListenerStation('listener-timer-iso-a')
+ const { station: stationB } = createOCPP16ListenerStation('listener-timer-iso-b')
+ const request: OCPP16UpdateFirmwareRequest = {
+ location: 'ftp://localhost/firmware.bin',
+ retrieveDate: new Date(Date.now() + 60_000),
+ }
+ const response: OCPP16UpdateFirmwareResponse = {}
+
+ // Act & Assert
+ await withMockTimers(t, ['setTimeout'], async () => {
+ listenerService.emit(
+ OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+ stationA,
+ request,
+ response
+ )
+ listenerService.emit(
+ OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+ stationB,
+ request,
+ response
+ )
+
+ const plumbing = asPlumbing(listenerService)
+ assert.strictEqual(plumbing.stationsState.has(stationA), true)
+ assert.strictEqual(plumbing.stationsState.has(stationB), true)
+
+ listenerService.stop(stationA)
+ assert.strictEqual(plumbing.stationsState.has(stationA), false)
+ assert.strictEqual(plumbing.stationsState.has(stationB), true)
+ assert.notStrictEqual(
+ plumbing.stationsState.get(stationB)?.deferredFirmwareUpdateTimer,
+ undefined
+ )
+
+ t.mock.timers.tick(70_000)
+ await flushMicrotasks()
+
+ assert.strictEqual(updateFirmwareMock.mock.callCount(), 1)
+ })
+ })
+
+ await it('should self-clear the deferred timer handle even when updateFirmwareSimulation rejects', async t => {
+ // Arrange
+ const { station } = createOCPP16ListenerStation('listener-timer-defer-reject')
+ const rejectingMock = mock.method(
+ listenerService as unknown as {
+ updateFirmwareSimulation: (chargingStation: unknown) => Promise<void>
+ },
+ 'updateFirmwareSimulation',
+ mock.fn(async () => Promise.reject(new Error('deferred firmware simulation error')))
+ )
+ const request: OCPP16UpdateFirmwareRequest = {
+ location: 'ftp://localhost/firmware.bin',
+ retrieveDate: new Date(Date.now() + 100),
+ }
+ const response: OCPP16UpdateFirmwareResponse = {}
+
+ // Act & Assert
+ await withMockTimers(t, ['setTimeout'], async () => {
+ listenerService.emit(
+ OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+ station,
+ request,
+ response
+ )
+
+ t.mock.timers.tick(200)
+ await flushMicrotasks()
+ await flushMicrotasks()
+
+ assert.strictEqual(rejectingMock.mock.callCount(), 1)
+ assert.strictEqual(
+ asPlumbing(listenerService).stationsState.get(station)?.deferredFirmwareUpdateTimer,
+ undefined
+ )
+ })
+ })
+
+ await it('should clamp retrieveDate delay to Node setTimeout 32-bit maximum and warn', async t => {
+ // Arrange
+ const { station } = createOCPP16ListenerStation('listener-timer-clamp')
+ const warnSpy = t.mock.method(logger, 'warn')
+ // MAX_SETINTERVAL_DELAY_MS + 1000 ms drift buffer: Date.now() is not
+ // mocked, so a 1 s cushion avoids flake if the listener re-samples
+ // Date.now() a few ms later and delayMs drops to exactly MAX.
+ const request: OCPP16UpdateFirmwareRequest = {
+ location: 'ftp://localhost/firmware.bin',
+ retrieveDate: new Date(Date.now() + Constants.MAX_SETINTERVAL_DELAY_MS + 1000),
+ }
+ const response: OCPP16UpdateFirmwareResponse = {}
+
+ // Act & Assert
+ await withMockTimers(t, ['setTimeout'], () => {
+ listenerService.emit(
+ OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+ station,
+ request,
+ response
+ )
+
+ assert.strictEqual(warnSpy.mock.callCount(), 1)
+ const warnMessage = warnSpy.mock.calls[0].arguments[0] as unknown as string
+ assert.match(
+ warnMessage,
+ new RegExp(
+ `exceeds ${Constants.MAX_SETINTERVAL_DELAY_MS.toString()} ms, clamping to ${Constants.MAX_SETINTERVAL_DELAY_MS.toString()} ms`
+ )
+ )
+ assert.notStrictEqual(
+ asPlumbing(listenerService).stationsState.get(station)?.deferredFirmwareUpdateTimer,
+ undefined
+ )
+ })
+ })
+ })
})