/**
* Per-station lifecycle state carried on {@link OCPP16IncomingRequestService}.
*
- * 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`;
- * - #1973 (trigger cross-check): `activeDiagnosticsRequestId` (shared with
- * #1971), `activeFirmwareUpdateRequestId`.
+ * OCPP 1.6 core-profile `GetDiagnostics.req` and `UpdateFirmware.req`
+ * payloads do not carry a `requestId` (unlike OCPP 2.0.1 `GetLog` /
+ * `UpdateFirmware`, and unlike the OCPP 1.6 Security-Extension
+ * `SignedUpdateFirmware` variant which the simulator does not
+ * implement), so lifecycle progress is tracked with boolean flags
+ * rather than requestId-presence sentinels.
*/
interface OCPP16StationState {
/**
- * `setTimeout` handle for the deferred `updateFirmwareSimulation`
+ * `setTimeout` handle for a 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).
+ * on stop, supersession, or normal fire.
*/
deferredFirmwareUpdateTimer?: NodeJS.Timeout
+ /**
+ * `true` while a diagnostics upload lifecycle is active (from the
+ * FTP upload branch of `handleRequestGetDiagnostics` entry to its
+ * terminal status emission or exception unwind). Read at two sites:
+ * 1. The `TriggerMessage(DiagnosticsStatusNotification)` case: emits
+ * `Idle` unless the flag is set, so a stale
+ * `stationInfo.diagnosticsStatus` left at `Uploading` after an
+ * exception unwind does not leak to the CSMS, per OCPP 1.6 §4.4 /
+ * §7.24 (Idle when not busy uploading diagnostics).
+ * 2. `handleRequestGetDiagnostics`'s FTP-branch entry guard:
+ * early-returns the empty `GetDiagnostics.conf` payload when a
+ * prior lifecycle is still in flight, preventing concurrent FTP
+ * uploads from racing on the same
+ * `${chargingStationId}_logs.tar.gz` archive filename and
+ * emitting duplicate `DiagnosticsStatusNotification` progress
+ * messages to the CSMS.
+ */
+ diagnosticsUploadInProgress?: boolean
+ /**
+ * `true` while a firmware update lifecycle is active (from
+ * `updateFirmwareSimulation` entry to its terminal status emission
+ * or exception unwind). Read at three sites:
+ * 1. The `TriggerMessage(FirmwareStatusNotification)` case: emits
+ * `Idle` unless the flag is set and `stationInfo.firmwareStatus`
+ * is in the §4.5 / §7.25 pass-through set, so a stale non-terminal
+ * status left by an exception unwind does not leak to the CSMS.
+ * 2. `handleRequestUpdateFirmware`'s re-entry guard: warn-logs and
+ * returns the empty `UpdateFirmware.conf` payload when a
+ * concurrent request arrives (OCPP 1.6 `UpdateFirmware.conf` has
+ * no fields to signal rejection to the CSMS).
+ * 3. `updateFirmwareSimulation`'s entry guard: early-returns before
+ * the try/finally when a prior lifecycle is still in flight,
+ * preventing duplicate `FirmwareStatusNotification` progress
+ * messages if the base-class event dispatcher fans out a second
+ * invocation before the first completes.
+ */
+ firmwareUpdateInProgress?: boolean
}
/**
)
.catch(errorHandler)
break
- case OCPP16MessageTrigger.DiagnosticsStatusNotification:
+ case OCPP16MessageTrigger.DiagnosticsStatusNotification: {
+ // §4.4 + §7.24: Idle when not busy uploading diagnostics.
+ // Cross-check the per-station lifecycle flag so a stale
+ // stationInfo.diagnosticsStatus (e.g. left at 'Uploading'
+ // after an exception unwind of the FTP path) does not
+ // leak to the CSMS.
+ const diagnosticsInProgress =
+ this.stationsState.get(chargingStation)?.diagnosticsUploadInProgress === true
+ const diagnosticsStatus = chargingStation.stationInfo?.diagnosticsStatus
+ const diagnosticsTriggerStatus =
+ diagnosticsInProgress && diagnosticsStatus === OCPP16DiagnosticsStatus.Uploading
+ ? OCPP16DiagnosticsStatus.Uploading
+ : OCPP16DiagnosticsStatus.Idle
chargingStation.ocppRequestService
.requestHandler<
OCPP16DiagnosticsStatusNotificationRequest,
>(
chargingStation,
OCPP16RequestCommand.DIAGNOSTICS_STATUS_NOTIFICATION,
- {
- // §4.4 + §7.24: Idle when not busy uploading diagnostics
- status:
- chargingStation.stationInfo?.diagnosticsStatus ===
- OCPP16DiagnosticsStatus.Uploading
- ? OCPP16DiagnosticsStatus.Uploading
- : OCPP16DiagnosticsStatus.Idle,
- },
- {
- triggerMessage: true,
- }
+ { status: diagnosticsTriggerStatus },
+ { triggerMessage: true }
)
.catch(errorHandler)
break
- case OCPP16MessageTrigger.FirmwareStatusNotification:
+ }
+ case OCPP16MessageTrigger.FirmwareStatusNotification: {
+ // §4.5 + §7.25: Idle when not busy downloading/installing
+ // firmware. Cross-check the per-station lifecycle flag so
+ // a stale stationInfo.firmwareStatus (e.g. left at
+ // 'Downloading' after an exception unwind of
+ // updateFirmwareSimulation) does not leak to the CSMS.
+ const firmwareInProgress =
+ this.stationsState.get(chargingStation)?.firmwareUpdateInProgress === true
+ const firmwareStatus = chargingStation.stationInfo?.firmwareStatus
+ const firmwareTriggerStatus =
+ firmwareInProgress &&
+ (firmwareStatus === OCPP16FirmwareStatus.Downloading ||
+ firmwareStatus === OCPP16FirmwareStatus.Downloaded ||
+ firmwareStatus === OCPP16FirmwareStatus.Installing)
+ ? firmwareStatus
+ : OCPP16FirmwareStatus.Idle
chargingStation.ocppRequestService
.requestHandler<
OCPP16FirmwareStatusNotificationRequest,
>(
chargingStation,
OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION,
- {
- // §4.5 + §7.25: Idle when not busy downloading/installing firmware
- status:
- chargingStation.stationInfo?.firmwareStatus ===
- OCPP16FirmwareStatus.Downloading ||
- chargingStation.stationInfo?.firmwareStatus ===
- OCPP16FirmwareStatus.Downloaded ||
- chargingStation.stationInfo?.firmwareStatus === OCPP16FirmwareStatus.Installing
- ? chargingStation.stationInfo.firmwareStatus
- : OCPP16FirmwareStatus.Idle,
- },
- {
- triggerMessage: true,
- }
+ { status: firmwareTriggerStatus },
+ { triggerMessage: true }
)
.catch(errorHandler)
break
+ }
case OCPP16MessageTrigger.Heartbeat:
chargingStation.ocppRequestService
.requestHandler<OCPP16HeartbeatRequest, OCPP16HeartbeatResponse>(
// 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.
+ // resetStationState; 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
/**
* @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.
+ * that own them (see {@link OCPP16StationState}).
*/
protected override createStationState (): OCPP16StationState {
return {}
* {@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 {
/**
* Cancels the deferred `updateFirmwareSimulation` timer and clears
- * the handle (#1972). No-op when no timer is pending.
+ * the handle. No-op when no timer is pending.
* @param stationState - Per-station state carrying the timer handle.
*/
private cancelDeferredFirmwareUpdate (stationState: OCPP16StationState): void {
const uri = new URL(location)
if (uri.protocol.startsWith('ftp:')) {
let ftpClient: Client | undefined
+ const stationState = this.getOrCreateStationState(chargingStation)
+ if (stationState.diagnosticsUploadInProgress === true) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetDiagnostics: skipped, a diagnostics upload lifecycle is already in flight`
+ )
+ return OCPP16Constants.OCPP_RESPONSE_EMPTY
+ }
+ stationState.diagnosticsUploadInProgress = true
try {
const logConfiguration = Configuration.getConfigurationSection<LogConfiguration>(
ConfigurationSection.log
{ errorResponse }
) ?? errorResponse
)
+ } finally {
+ delete stationState.diagnosticsUploadInProgress
}
} else {
logger.warn(
return OCPP16Constants.OCPP_RESPONSE_EMPTY
}
commandPayload.retrieveDate = convertToDate(commandPayload.retrieveDate) ?? new Date()
- if (
- chargingStation.stationInfo?.firmwareStatus === OCPP16FirmwareStatus.Downloading ||
- chargingStation.stationInfo?.firmwareStatus === OCPP16FirmwareStatus.Downloaded ||
- chargingStation.stationInfo?.firmwareStatus === OCPP16FirmwareStatus.Installing
- ) {
+ if (this.stationsState.get(chargingStation)?.firmwareUpdateInProgress === true) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Cannot simulate firmware update: firmware update is already in progress`
)
)
return
}
- for (const { connectorId, connectorStatus } of chargingStation.iterateConnectors(true)) {
- if (connectorStatus.transactionStarted === false) {
- await sendAndSetConnectorStatus(chargingStation, {
- connectorId,
- status: OCPP16ChargePointStatus.Unavailable,
- } as OCPP16StatusNotificationRequest)
- }
- }
- await chargingStation.ocppRequestService.requestHandler<
- OCPP16FirmwareStatusNotificationRequest,
- OCPP16FirmwareStatusNotificationResponse
- >(chargingStation, OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
- status: OCPP16FirmwareStatus.Downloading,
- })
- if (chargingStation.stationInfo != null) {
- chargingStation.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Downloading
+ const stationState = this.getOrCreateStationState(chargingStation)
+ if (stationState.firmwareUpdateInProgress === true) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.updateFirmwareSimulation: skipped, a firmware update lifecycle is already in flight`
+ )
+ return
}
- if (
- chargingStation.stationInfo?.firmwareUpgrade?.failureStatus ===
- OCPP16FirmwareStatus.DownloadFailed
- ) {
+ stationState.firmwareUpdateInProgress = true
+ try {
+ for (const { connectorId, connectorStatus } of chargingStation.iterateConnectors(true)) {
+ if (connectorStatus.transactionStarted === false) {
+ await sendAndSetConnectorStatus(chargingStation, {
+ connectorId,
+ status: OCPP16ChargePointStatus.Unavailable,
+ } as OCPP16StatusNotificationRequest)
+ }
+ }
+ await chargingStation.ocppRequestService.requestHandler<
+ OCPP16FirmwareStatusNotificationRequest,
+ OCPP16FirmwareStatusNotificationResponse
+ >(chargingStation, OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
+ status: OCPP16FirmwareStatus.Downloading,
+ })
+ if (chargingStation.stationInfo != null) {
+ chargingStation.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Downloading
+ }
+ if (
+ chargingStation.stationInfo?.firmwareUpgrade?.failureStatus ===
+ OCPP16FirmwareStatus.DownloadFailed
+ ) {
+ await sleep(secondsToMilliseconds(randomInt(minDelay, maxDelay + 1)))
+ await chargingStation.ocppRequestService.requestHandler<
+ OCPP16FirmwareStatusNotificationRequest,
+ OCPP16FirmwareStatusNotificationResponse
+ >(chargingStation, OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
+ status: chargingStation.stationInfo.firmwareUpgrade.failureStatus,
+ })
+ chargingStation.stationInfo.firmwareStatus =
+ chargingStation.stationInfo.firmwareUpgrade.failureStatus
+ return
+ }
await sleep(secondsToMilliseconds(randomInt(minDelay, maxDelay + 1)))
await chargingStation.ocppRequestService.requestHandler<
OCPP16FirmwareStatusNotificationRequest,
OCPP16FirmwareStatusNotificationResponse
>(chargingStation, OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
- status: chargingStation.stationInfo.firmwareUpgrade.failureStatus,
+ status: OCPP16FirmwareStatus.Downloaded,
})
- chargingStation.stationInfo.firmwareStatus =
- chargingStation.stationInfo.firmwareUpgrade.failureStatus
- return
- }
- await sleep(secondsToMilliseconds(randomInt(minDelay, maxDelay + 1)))
- await chargingStation.ocppRequestService.requestHandler<
- OCPP16FirmwareStatusNotificationRequest,
- OCPP16FirmwareStatusNotificationResponse
- >(chargingStation, OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
- status: OCPP16FirmwareStatus.Downloaded,
- })
- if (chargingStation.stationInfo != null) {
- chargingStation.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Downloaded
- }
- let wasTransactionsStarted = false
- let transactionsStarted: boolean
- do {
- const runningTransactions = chargingStation.getNumberOfRunningTransactions()
- if (runningTransactions > 0) {
- const waitTime = secondsToMilliseconds(15)
- logger.debug(
- `${chargingStation.logPrefix()} ${moduleName}.updateFirmwareSimulation: ${runningTransactions.toString()} transaction(s) in progress, waiting ${formatDurationMilliSeconds(
- waitTime
- )} before continuing firmware update simulation`
- )
- await sleep(waitTime)
- transactionsStarted = true
- wasTransactionsStarted = true
- } else {
- for (const { connectorId, connectorStatus } of chargingStation.iterateConnectors(true)) {
- if (connectorStatus.status !== OCPP16ChargePointStatus.Unavailable) {
- await sendAndSetConnectorStatus(chargingStation, {
- connectorId,
- status: OCPP16ChargePointStatus.Unavailable,
- } as OCPP16StatusNotificationRequest)
+ if (chargingStation.stationInfo != null) {
+ chargingStation.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Downloaded
+ }
+ let wasTransactionsStarted = false
+ let transactionsStarted: boolean
+ do {
+ const runningTransactions = chargingStation.getNumberOfRunningTransactions()
+ if (runningTransactions > 0) {
+ const waitTime = secondsToMilliseconds(15)
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.updateFirmwareSimulation: ${runningTransactions.toString()} transaction(s) in progress, waiting ${formatDurationMilliSeconds(
+ waitTime
+ )} before continuing firmware update simulation`
+ )
+ await sleep(waitTime)
+ transactionsStarted = true
+ wasTransactionsStarted = true
+ } else {
+ for (const { connectorId, connectorStatus } of chargingStation.iterateConnectors(true)) {
+ if (connectorStatus.status !== OCPP16ChargePointStatus.Unavailable) {
+ await sendAndSetConnectorStatus(chargingStation, {
+ connectorId,
+ status: OCPP16ChargePointStatus.Unavailable,
+ } as OCPP16StatusNotificationRequest)
+ }
}
+ transactionsStarted = false
}
- transactionsStarted = false
+ } while (transactionsStarted)
+ !wasTransactionsStarted &&
+ (await sleep(secondsToMilliseconds(randomInt(minDelay, maxDelay + 1))))
+ if (!checkChargingStationState(chargingStation, chargingStation.logPrefix())) {
+ return
}
- } while (transactionsStarted)
- !wasTransactionsStarted &&
- (await sleep(secondsToMilliseconds(randomInt(minDelay, maxDelay + 1))))
- if (!checkChargingStationState(chargingStation, chargingStation.logPrefix())) {
- return
- }
- await chargingStation.ocppRequestService.requestHandler<
- OCPP16FirmwareStatusNotificationRequest,
- OCPP16FirmwareStatusNotificationResponse
- >(chargingStation, OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
- status: OCPP16FirmwareStatus.Installing,
- })
- if (chargingStation.stationInfo != null) {
- chargingStation.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Installing
- }
- if (
- chargingStation.stationInfo?.firmwareUpgrade?.failureStatus ===
- OCPP16FirmwareStatus.InstallationFailed
- ) {
- await sleep(secondsToMilliseconds(randomInt(minDelay, maxDelay + 1)))
await chargingStation.ocppRequestService.requestHandler<
OCPP16FirmwareStatusNotificationRequest,
OCPP16FirmwareStatusNotificationResponse
>(chargingStation, OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
- status: chargingStation.stationInfo.firmwareUpgrade.failureStatus,
+ status: OCPP16FirmwareStatus.Installing,
})
- chargingStation.stationInfo.firmwareStatus =
- chargingStation.stationInfo.firmwareUpgrade.failureStatus
- return
- }
- await sleep(secondsToMilliseconds(randomInt(minDelay, maxDelay + 1)))
- await chargingStation.ocppRequestService.requestHandler<
- OCPP16FirmwareStatusNotificationRequest,
- OCPP16FirmwareStatusNotificationResponse
- >(chargingStation, OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
- status: OCPP16FirmwareStatus.Installed,
- })
- if (chargingStation.stationInfo != null) {
- chargingStation.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Installed
- }
- if (chargingStation.stationInfo?.firmwareUpgrade?.reset === true) {
+ if (chargingStation.stationInfo != null) {
+ chargingStation.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Installing
+ }
+ if (
+ chargingStation.stationInfo?.firmwareUpgrade?.failureStatus ===
+ OCPP16FirmwareStatus.InstallationFailed
+ ) {
+ await sleep(secondsToMilliseconds(randomInt(minDelay, maxDelay + 1)))
+ await chargingStation.ocppRequestService.requestHandler<
+ OCPP16FirmwareStatusNotificationRequest,
+ OCPP16FirmwareStatusNotificationResponse
+ >(chargingStation, OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
+ status: chargingStation.stationInfo.firmwareUpgrade.failureStatus,
+ })
+ chargingStation.stationInfo.firmwareStatus =
+ chargingStation.stationInfo.firmwareUpgrade.failureStatus
+ return
+ }
await sleep(secondsToMilliseconds(randomInt(minDelay, maxDelay + 1)))
- await chargingStation.reset(OCPP16StopTransactionReason.REBOOT)
+ await chargingStation.ocppRequestService.requestHandler<
+ OCPP16FirmwareStatusNotificationRequest,
+ OCPP16FirmwareStatusNotificationResponse
+ >(chargingStation, OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
+ status: OCPP16FirmwareStatus.Installed,
+ })
+ if (chargingStation.stationInfo != null) {
+ chargingStation.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Installed
+ }
+ if (chargingStation.stationInfo?.firmwareUpgrade?.reset === true) {
+ await sleep(secondsToMilliseconds(randomInt(minDelay, maxDelay + 1)))
+ await chargingStation.reset(OCPP16StopTransactionReason.REBOOT)
+ }
+ } finally {
+ delete stationState.firmwareUpdateInProgress
}
}
}
import { OCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.js'
import {
+ ConfigurationSection,
OCPP16FirmwareStatus,
OCPP16IncomingRequestCommand,
OCPP16StandardParametersKey,
type OCPP16UpdateFirmwareRequest,
type OCPP16UpdateFirmwareResponse,
} from '../../../../src/types/index.js'
-import { Constants, logger } from '../../../../src/utils/index.js'
+import { Configuration, Constants, logger } from '../../../../src/utils/index.js'
import {
flushMicrotasks,
standardCleanup,
assert.notStrictEqual(response, undefined)
assert.strictEqual(Object.keys(response).length, 0)
})
+
+ await it('should clear diagnosticsUploadInProgress in the finally block when the FTP branch returns early on missing log file', async t => {
+ // Arrange: enable FirmwareManagement so the FTP branch is entered,
+ // then force the early return path inside the try block by returning
+ // a log configuration with no file. This exercises the finally clause
+ // through a non-throwing exit path.
+ const { incomingRequestService, station, testableService } = context
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,FirmwareManagement'
+ )
+ t.mock.method(Configuration, 'getConfigurationSection', (section: ConfigurationSection) => {
+ if (section === ConfigurationSection.log) {
+ return { file: undefined }
+ }
+ return {}
+ })
+
+ const request: GetDiagnosticsRequest = {
+ location: 'ftp://localhost/diagnostics',
+ }
+
+ // Act
+ const response = await testableService.handleRequestGetDiagnostics(station, request)
+
+ // Assert
+ assert.strictEqual(Object.keys(response).length, 0)
+ const plumbing = incomingRequestService as unknown as {
+ stationsState: WeakMap<ChargingStation, { diagnosticsUploadInProgress?: boolean }>
+ }
+ assert.strictEqual(
+ plumbing.stationsState.get(station)?.diagnosticsUploadInProgress,
+ undefined
+ )
+ })
+
+ await it('should skip the diagnostics FTP upload when diagnosticsUploadInProgress is already set (concurrent invocation safety)', async t => {
+ // Regression guard: two concurrent GetDiagnostics.req arriving via
+ // the base-class dispatcher's fire-and-forget WebSocket message
+ // handling must not spawn concurrent FTP uploads racing on the same
+ // ${chargingStationId}_logs.tar.gz archive filename or emit
+ // duplicate DiagnosticsStatusNotification progress messages to the
+ // CSMS.
+
+ // Arrange
+ const { incomingRequestService, station, testableService } = context
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,FirmwareManagement'
+ )
+ const plumbing = incomingRequestService as unknown as {
+ getOrCreateStationState: (cs: ChargingStation) => { diagnosticsUploadInProgress?: boolean }
+ stationsState: WeakMap<ChargingStation, { diagnosticsUploadInProgress?: boolean }>
+ }
+ plumbing.getOrCreateStationState(station).diagnosticsUploadInProgress = true
+ const warnSpy = t.mock.method(logger, 'warn')
+
+ // Act
+ const response = await testableService.handleRequestGetDiagnostics(station, {
+ location: 'ftp://localhost/diagnostics',
+ })
+
+ // Assert
+ assert.strictEqual(Object.keys(response).length, 0)
+ assert.strictEqual(plumbing.stationsState.get(station)?.diagnosticsUploadInProgress, true)
+ const warnMessages = warnSpy.mock.calls.map(call => call.arguments[0] as unknown as string)
+ assert.ok(
+ warnMessages.some(m => m.includes('a diagnostics upload lifecycle is already in flight')),
+ `expected an "already in flight" warning, got: ${JSON.stringify(warnMessages)}`
+ )
+ })
})
// §6.4: UpdateFirmware
assert.strictEqual(Object.keys(response).length, 0)
})
- await it('should return empty response when firmware update is already in progress', () => {
+ await it('should return empty response and log the "already in progress" warning when firmwareUpdateInProgress is set', t => {
+ // Arrange
+ const { incomingRequestService, station, testableService } = context
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,FirmwareManagement'
+ )
+ const plumbing = incomingRequestService as unknown as {
+ getOrCreateStationState: (cs: ChargingStation) => { firmwareUpdateInProgress?: boolean }
+ }
+ plumbing.getOrCreateStationState(station).firmwareUpdateInProgress = true
+ const warnSpy = t.mock.method(logger, 'warn')
+
+ // Act
+ const response = testableService.handleRequestUpdateFirmware(station, {
+ location: 'ftp://localhost/firmware.bin',
+ retrieveDate: new Date(),
+ })
+
+ // Assert
+ assert.strictEqual(Object.keys(response).length, 0)
+ const warnMessages = warnSpy.mock.calls.map(call => call.arguments[0] as unknown as string)
+ assert.ok(
+ warnMessages.some(m => m.includes('firmware update is already in progress')),
+ `expected an "already in progress" warning, got: ${JSON.stringify(warnMessages)}`
+ )
+ })
+
+ await it('should NOT log the "already in progress" warning when stationInfo.firmwareStatus is stale but firmwareUpdateInProgress is unset', t => {
+ // Regression guard: a stale stationInfo.firmwareStatus left at
+ // Downloading / Downloaded / Installing by a prior exception unwind
+ // must not suppress a legitimate new UpdateFirmware.req. The
+ // authoritative predicate is firmwareUpdateInProgress on the
+ // per-station state, not stationInfo.
+
// Arrange
const { station, testableService } = context
upsertConfigurationKey(
if (station.stationInfo != null) {
station.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Downloading
}
+ const warnSpy = t.mock.method(logger, 'warn')
// Act
const response = testableService.handleRequestUpdateFirmware(station, {
})
// Assert
- assert.notStrictEqual(response, undefined)
assert.strictEqual(Object.keys(response).length, 0)
+ const warnMessages = warnSpy.mock.calls.map(call => call.arguments[0] as unknown as string)
+ assert.ok(
+ warnMessages.every(m => !m.includes('firmware update is already in progress')),
+ `expected no "already in progress" warning, got: ${JSON.stringify(warnMessages)}`
+ )
})
})
})
})
- // #1972: deferred UpdateFirmware timer lifecycle on OCPP16StationState
+ // Deferred UpdateFirmware timer lifecycle on OCPP16StationState
await describe('UPDATE_FIRMWARE deferred timer lifecycle', async () => {
interface OCPP16StationStateShape {
deferredFirmwareUpdateTimer?: NodeJS.Timeout
/**
* @file Tests for OCPP16IncomingRequestService TriggerMessage handler
* @description Tests for TriggerMessage (§10.1) incoming request handler covering
- * accepted triggers, unimplemented triggers, and feature profile validation
+ * accepted triggers, unimplemented triggers, feature profile validation, and
+ * the per-station lifecycle-flag cross-check that satisfies OCPP 1.6 §4.4 /
+ * §7.24 (DiagnosticsStatusNotification Idle when not busy uploading) and
+ * §4.5 / §7.25 (FirmwareStatusNotification Idle when not busy downloading /
+ * installing).
*/
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 {
+ OCPP16DiagnosticsStatusNotificationRequest,
+ OCPP16FirmwareStatusNotificationRequest,
OCPP16TriggerMessageRequest,
OCPP16TriggerMessageResponse,
} from '../../../../src/types/index.js'
import { OCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.js'
import {
+ OCPP16DiagnosticsStatus,
+ OCPP16FirmwareStatus,
OCPP16IncomingRequestCommand,
OCPP16MessageTrigger,
OCPP16RequestCommand,
OCPP16TriggerMessageStatus,
OCPPVersion,
} from '../../../../src/types/index.js'
-import { Constants } from '../../../../src/utils/index.js'
+import { Constants, logger } from '../../../../src/utils/index.js'
import { flushMicrotasks, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
import { createMockChargingStation } from '../../helpers/StationHelpers.js'
assert.strictEqual(rejectingMock.mock.callCount(), 1)
})
})
+
+ // OCPP 1.6 §4.4 / §7.24 (Diagnostics) and §4.5 / §7.25 (Firmware):
+ // the Charge Point SHALL only send status Idle after receipt of a
+ // TriggerMessage when it is not busy uploading diagnostics /
+ // downloading or installing firmware. These tests exercise the
+ // lifecycle-flag cross-check that collapses stale stationInfo
+ // values to Idle when no lifecycle is actually active.
+ await describe('lifecycle-flag cross-check', async () => {
+ interface OCPP16StationStateShape {
+ diagnosticsUploadInProgress?: boolean
+ firmwareUpdateInProgress?: boolean
+ }
+
+ interface PlumbingAccess {
+ getOrCreateStationState: (chargingStation: ChargingStation) => OCPP16StationStateShape
+ stationsState: WeakMap<ChargingStation, OCPP16StationStateShape>
+ }
+
+ const asPlumbing = (service: OCPP16IncomingRequestService): PlumbingAccess =>
+ service as unknown as PlumbingAccess
+
+ const emitTrigger = (
+ service: OCPP16IncomingRequestService,
+ station: ChargingStation,
+ trigger: OCPP16MessageTrigger
+ ): void => {
+ const request: OCPP16TriggerMessageRequest = { requestedMessage: trigger }
+ const response: OCPP16TriggerMessageResponse = {
+ status: OCPP16TriggerMessageStatus.ACCEPTED,
+ }
+ service.emit(OCPP16IncomingRequestCommand.TRIGGER_MESSAGE, station, request, response)
+ }
+
+ let service: OCPP16IncomingRequestService
+ let plumbing: PlumbingAccess
+ let station: ChargingStation
+ let requestHandlerMock: ReturnType<typeof mock.fn>
+
+ beforeEach(() => {
+ service = new OCPP16IncomingRequestService()
+ plumbing = asPlumbing(service)
+ ;({ requestHandlerMock, station } = createOCPP16ListenerStation('listener-lifecycle-flag'))
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await it('should emit Idle for DiagnosticsStatusNotification trigger when no upload is in progress', async () => {
+ emitTrigger(service, station, OCPP16MessageTrigger.DiagnosticsStatusNotification)
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const args = requestHandlerMock.mock.calls[0].arguments as [
+ unknown,
+ OCPP16RequestCommand,
+ OCPP16DiagnosticsStatusNotificationRequest,
+ ...unknown[]
+ ]
+ assert.strictEqual(args[1], OCPP16RequestCommand.DIAGNOSTICS_STATUS_NOTIFICATION)
+ assert.strictEqual(args[2].status, OCPP16DiagnosticsStatus.Idle)
+ })
+
+ await it('should emit Uploading for DiagnosticsStatusNotification trigger when upload is in progress', async () => {
+ plumbing.getOrCreateStationState(station).diagnosticsUploadInProgress = true
+ if (station.stationInfo != null) {
+ station.stationInfo.diagnosticsStatus = OCPP16DiagnosticsStatus.Uploading
+ }
+
+ emitTrigger(service, station, OCPP16MessageTrigger.DiagnosticsStatusNotification)
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const args = requestHandlerMock.mock.calls[0].arguments as [
+ unknown,
+ OCPP16RequestCommand,
+ OCPP16DiagnosticsStatusNotificationRequest,
+ ...unknown[]
+ ]
+ assert.strictEqual(args[1], OCPP16RequestCommand.DIAGNOSTICS_STATUS_NOTIFICATION)
+ assert.strictEqual(args[2].status, OCPP16DiagnosticsStatus.Uploading)
+ })
+
+ await it('should emit Idle for DiagnosticsStatusNotification when stationInfo is stale but lifecycle is not active', async () => {
+ // Regression guard: stationInfo.diagnosticsStatus is left at
+ // `Uploading` by a prior exception unwind that did not reset it,
+ // but the lifecycle flag correctly reports no active upload.
+ if (station.stationInfo != null) {
+ station.stationInfo.diagnosticsStatus = OCPP16DiagnosticsStatus.Uploading
+ }
+ assert.strictEqual(plumbing.stationsState.has(station), false)
+
+ emitTrigger(service, station, OCPP16MessageTrigger.DiagnosticsStatusNotification)
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const args = requestHandlerMock.mock.calls[0].arguments as [
+ unknown,
+ OCPP16RequestCommand,
+ OCPP16DiagnosticsStatusNotificationRequest,
+ ...unknown[]
+ ]
+ assert.strictEqual(args[2].status, OCPP16DiagnosticsStatus.Idle)
+ })
+
+ await it('should emit Idle for FirmwareStatusNotification trigger when no firmware update is in progress', async () => {
+ emitTrigger(service, station, OCPP16MessageTrigger.FirmwareStatusNotification)
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const args = requestHandlerMock.mock.calls[0].arguments as [
+ unknown,
+ OCPP16RequestCommand,
+ OCPP16FirmwareStatusNotificationRequest,
+ ...unknown[]
+ ]
+ assert.strictEqual(args[1], OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION)
+ assert.strictEqual(args[2].status, OCPP16FirmwareStatus.Idle)
+ })
+
+ const activeFirmwareStatuses: OCPP16FirmwareStatus[] = [
+ OCPP16FirmwareStatus.Downloading,
+ OCPP16FirmwareStatus.Downloaded,
+ OCPP16FirmwareStatus.Installing,
+ ]
+
+ for (const activeStatus of activeFirmwareStatuses) {
+ await it(`should emit ${activeStatus} for FirmwareStatusNotification trigger when lifecycle is active with ${activeStatus}`, async () => {
+ plumbing.getOrCreateStationState(station).firmwareUpdateInProgress = true
+ if (station.stationInfo != null) {
+ station.stationInfo.firmwareStatus = activeStatus
+ }
+
+ emitTrigger(service, station, OCPP16MessageTrigger.FirmwareStatusNotification)
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const args = requestHandlerMock.mock.calls[0].arguments as [
+ unknown,
+ OCPP16RequestCommand,
+ OCPP16FirmwareStatusNotificationRequest,
+ ...unknown[]
+ ]
+ assert.strictEqual(args[2].status, activeStatus)
+ })
+ }
+
+ await it('should emit Idle for FirmwareStatusNotification when stationInfo is stale but lifecycle is not active', async () => {
+ // Regression guard for the firmware-side stale stationInfo scenario.
+ if (station.stationInfo != null) {
+ station.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Downloading
+ }
+ assert.strictEqual(plumbing.stationsState.has(station), false)
+
+ emitTrigger(service, station, OCPP16MessageTrigger.FirmwareStatusNotification)
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const args = requestHandlerMock.mock.calls[0].arguments as [
+ unknown,
+ OCPP16RequestCommand,
+ OCPP16FirmwareStatusNotificationRequest,
+ ...unknown[]
+ ]
+ assert.strictEqual(args[2].status, OCPP16FirmwareStatus.Idle)
+ })
+
+ await it('should emit Idle for FirmwareStatusNotification when lifecycle is active but stationInfo status is terminal (DownloadFailed)', async () => {
+ // §4.5 / §7.25: only Downloading / Downloaded / Installing carry over;
+ // any other value (including terminal failure statuses) collapses to Idle.
+ plumbing.getOrCreateStationState(station).firmwareUpdateInProgress = true
+ if (station.stationInfo != null) {
+ station.stationInfo.firmwareStatus = OCPP16FirmwareStatus.DownloadFailed
+ }
+
+ emitTrigger(service, station, OCPP16MessageTrigger.FirmwareStatusNotification)
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const args = requestHandlerMock.mock.calls[0].arguments as [
+ unknown,
+ OCPP16RequestCommand,
+ OCPP16FirmwareStatusNotificationRequest,
+ ...unknown[]
+ ]
+ assert.strictEqual(args[2].status, OCPP16FirmwareStatus.Idle)
+ })
+
+ await it('should clear firmwareUpdateInProgress in the finally block of updateFirmwareSimulation and then emit Idle on trigger', async () => {
+ // The `try/finally` around updateFirmwareSimulation's body guarantees
+ // firmwareUpdateInProgress is cleared on every exit path (happy,
+ // return, throw). We exercise the throw path by rejecting the first
+ // request emitted from inside the try block.
+ const rejectingHandler = mock.fn(async () =>
+ Promise.reject(new Error('simulated network error'))
+ )
+ const { station: rejectStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 2,
+ ocppRequestService: { requestHandler: rejectingHandler },
+ started: true,
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+
+ const invokeSimulation = (
+ service as unknown as {
+ updateFirmwareSimulation: (
+ chargingStation: ChargingStation,
+ maxDelay?: number,
+ minDelay?: number
+ ) => Promise<void>
+ }
+ ).updateFirmwareSimulation.bind(service)
+
+ await assert.rejects(invokeSimulation(rejectStation))
+
+ assert.strictEqual(
+ plumbing.stationsState.get(rejectStation)?.firmwareUpdateInProgress,
+ undefined
+ )
+
+ // Now that the flag is cleared, TriggerMessage(FirmwareStatusNotification)
+ // should collapse any lingering stationInfo status to Idle.
+ if (rejectStation.stationInfo != null) {
+ rejectStation.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Downloading
+ }
+ const listenerHandlerMock = mock.fn((..._args: unknown[]) => Promise.resolve({}))
+ rejectStation.ocppRequestService.requestHandler =
+ listenerHandlerMock as typeof rejectStation.ocppRequestService.requestHandler
+ emitTrigger(service, rejectStation, OCPP16MessageTrigger.FirmwareStatusNotification)
+ await flushMicrotasks()
+
+ assert.strictEqual(listenerHandlerMock.mock.callCount(), 1)
+ const args = listenerHandlerMock.mock.calls[0].arguments as unknown as [
+ unknown,
+ OCPP16RequestCommand,
+ OCPP16FirmwareStatusNotificationRequest,
+ ...unknown[]
+ ]
+ assert.strictEqual(args[2].status, OCPP16FirmwareStatus.Idle)
+ })
+
+ await it('should skip updateFirmwareSimulation when firmwareUpdateInProgress is already set (concurrent invocation safety)', async t => {
+ // Regression guard: if the base-class event dispatcher fans out a
+ // second UPDATE_FIRMWARE emit while a prior lifecycle is still in
+ // flight, the second updateFirmwareSimulation invocation must
+ // early-return before entering the try/finally — otherwise duplicate
+ // FirmwareStatusNotification progress messages would reach the CSMS.
+
+ // Arrange
+ const requestHandlerSpy = mock.fn((..._args: unknown[]) => Promise.resolve({}))
+ const { station: concurrentStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 2,
+ ocppRequestService: { requestHandler: requestHandlerSpy },
+ started: true,
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ plumbing.getOrCreateStationState(concurrentStation).firmwareUpdateInProgress = true
+ const warnSpy = t.mock.method(logger, 'warn')
+ const invokeSimulation = (
+ service as unknown as {
+ updateFirmwareSimulation: (
+ chargingStation: ChargingStation,
+ maxDelay?: number,
+ minDelay?: number
+ ) => Promise<void>
+ }
+ ).updateFirmwareSimulation.bind(service)
+
+ // Act
+ await invokeSimulation(concurrentStation)
+
+ // Assert
+ assert.strictEqual(requestHandlerSpy.mock.callCount(), 0)
+ assert.strictEqual(
+ plumbing.stationsState.get(concurrentStation)?.firmwareUpdateInProgress,
+ true
+ )
+ const warnMessages = warnSpy.mock.calls.map(call => call.arguments[0] as unknown as string)
+ assert.ok(
+ warnMessages.some(m => m.includes('a firmware update lifecycle is already in flight')),
+ `expected an "already in flight" warning, got: ${JSON.stringify(warnMessages)}`
+ )
+ })
+
+ await it('should isolate lifecycle flags across stations so a trigger on B is unaffected by A', async () => {
+ const { requestHandlerMock: handlerMockB, station: stationB } = createOCPP16ListenerStation(
+ 'listener-lifecycle-flag-b'
+ )
+ plumbing.getOrCreateStationState(station).firmwareUpdateInProgress = true
+ if (station.stationInfo != null) {
+ station.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Downloading
+ }
+ if (stationB.stationInfo != null) {
+ stationB.stationInfo.firmwareStatus = OCPP16FirmwareStatus.Downloading
+ }
+
+ emitTrigger(service, station, OCPP16MessageTrigger.FirmwareStatusNotification)
+ emitTrigger(service, stationB, OCPP16MessageTrigger.FirmwareStatusNotification)
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const argsA = requestHandlerMock.mock.calls[0].arguments as [
+ unknown,
+ OCPP16RequestCommand,
+ OCPP16FirmwareStatusNotificationRequest,
+ ...unknown[]
+ ]
+ assert.strictEqual(argsA[2].status, OCPP16FirmwareStatus.Downloading)
+
+ assert.strictEqual(handlerMockB.mock.callCount(), 1)
+ const argsB = handlerMockB.mock.calls[0].arguments as [
+ unknown,
+ OCPP16RequestCommand,
+ OCPP16FirmwareStatusNotificationRequest,
+ ...unknown[]
+ ]
+ assert.strictEqual(argsB[2].status, OCPP16FirmwareStatus.Idle)
+ })
+ })
})
/**
* @file Tests for OCPPIncomingRequestService per-station state plumbing (#1963)
* @description Unit tests for the shared WeakMap + lazy-init + stop() template
- * in `OCPPIncomingRequestService`, plus the OCPP 1.6 no-op `resetStationState`
- * contract that companion PRs (#1971, #1972, #1973) will replace.
+ * in `OCPPIncomingRequestService` and for the OCPP 1.6 `resetStationState`
+ * contract.
*
* The OCPP 2.0.1 5-statement ordering invariant (abort firmware → abort log →
* cancel retry → resetActiveFirmwareUpdateState → resetActiveLogUploadState)