}
}
)
+ this.on(
+ OCPP20IncomingRequestCommand.UPDATE_FIRMWARE,
+ (
+ chargingStation: ChargingStation,
+ request: OCPP20UpdateFirmwareRequest,
+ response: OCPP20UpdateFirmwareResponse
+ ) => {
+ if (response.status === UpdateFirmwareStatusEnumType.Accepted) {
+ this.simulateFirmwareUpdateLifecycle(
+ chargingStation,
+ request.requestId,
+ request.firmware.signature
+ ).catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.constructor: UpdateFirmware lifecycle error:`,
+ error
+ )
+ })
+ }
+ }
+ )
+ this.on(
+ OCPP20IncomingRequestCommand.GET_LOG,
+ (
+ chargingStation: ChargingStation,
+ request: OCPP20GetLogRequest,
+ response: OCPP20GetLogResponse
+ ) => {
+ if (response.status === LogStatusEnumType.Accepted) {
+ this.simulateLogUploadLifecycle(chargingStation, request.requestId).catch(
+ (error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.constructor: GetLog lifecycle error:`,
+ error
+ )
+ }
+ )
+ }
+ }
+ )
+ this.on(
+ OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION,
+ (
+ chargingStation: ChargingStation,
+ request: OCPP20CustomerInformationRequest,
+ response: OCPP20CustomerInformationResponse
+ ) => {
+ if (response.status === CustomerInformationStatusEnumType.Accepted && request.report) {
+ this.sendNotifyCustomerInformation(chargingStation, request.requestId).catch(
+ (error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.constructor: CustomerInformation notification error:`,
+ error
+ )
+ }
+ )
+ }
+ }
+ )
+ this.on(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ (
+ chargingStation: ChargingStation,
+ request: OCPP20TriggerMessageRequest,
+ response: OCPP20TriggerMessageResponse
+ ) => {
+ if (response.status !== TriggerMessageStatusEnumType.Accepted) {
+ return
+ }
+ const { evse, requestedMessage } = request
+ const errorHandler = (error: unknown): void => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.constructor: Trigger ${requestedMessage} error:`,
+ error
+ )
+ }
+ switch (requestedMessage) {
+ case MessageTriggerEnumType.BootNotification:
+ chargingStation.ocppRequestService
+ .requestHandler<
+ OCPP20BootNotificationRequest,
+ OCPP20BootNotificationResponse
+ >(chargingStation, OCPP20RequestCommand.BOOT_NOTIFICATION, chargingStation.bootNotificationRequest as OCPP20BootNotificationRequest, { skipBufferingOnError: true, triggerMessage: true })
+ .catch(errorHandler)
+ break
+ case MessageTriggerEnumType.FirmwareStatusNotification:
+ chargingStation.ocppRequestService
+ .requestHandler<
+ OCPP20FirmwareStatusNotificationRequest,
+ OCPP20FirmwareStatusNotificationResponse
+ >(chargingStation, OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION, { status: FirmwareStatusEnumType.Idle }, { skipBufferingOnError: true, triggerMessage: true })
+ .catch(errorHandler)
+ break
+ case MessageTriggerEnumType.Heartbeat:
+ chargingStation.ocppRequestService
+ .requestHandler<
+ OCPP20HeartbeatRequest,
+ OCPP20HeartbeatResponse
+ >(chargingStation, OCPP20RequestCommand.HEARTBEAT, {}, { skipBufferingOnError: true, triggerMessage: true })
+ .catch(errorHandler)
+ break
+ case MessageTriggerEnumType.LogStatusNotification:
+ chargingStation.ocppRequestService
+ .requestHandler<
+ OCPP20LogStatusNotificationRequest,
+ OCPP20LogStatusNotificationResponse
+ >(chargingStation, OCPP20RequestCommand.LOG_STATUS_NOTIFICATION, { status: UploadLogStatusEnumType.Idle }, { skipBufferingOnError: true, triggerMessage: true })
+ .catch(errorHandler)
+ break
+ case MessageTriggerEnumType.MeterValues: {
+ const evseId = evse?.id ?? 0
+ chargingStation.ocppRequestService
+ .requestHandler<OCPP20MeterValuesRequest, OCPP20MeterValuesResponse>(
+ chargingStation,
+ OCPP20RequestCommand.METER_VALUES,
+ {
+ evseId,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.TRIGGER,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ value: 0,
+ },
+ ],
+ timestamp: new Date(),
+ },
+ ],
+ },
+ { skipBufferingOnError: true, triggerMessage: true }
+ )
+ .catch(errorHandler)
+ break
+ }
+ case MessageTriggerEnumType.StatusNotification:
+ this.triggerStatusNotification(chargingStation, evse, errorHandler)
+ break
+ }
+ }
+ )
this.validatePayload = this.validatePayload.bind(this)
}
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: Report request accepted, sending empty NotifyCustomerInformation`
)
- // Fire-and-forget NotifyCustomerInformation with empty data
- setImmediate(() => {
- this.sendNotifyCustomerInformation(chargingStation, commandPayload.requestId).catch(
- (error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: Error sending NotifyCustomerInformation:`,
- error
- )
- }
- )
- })
return {
status: CustomerInformationStatusEnumType.Accepted,
}
`${chargingStation.logPrefix()} ${moduleName}.handleRequestGetLog: Received GetLog request with requestId ${requestId.toString()} for logType '${logType}'`
)
- // Fire-and-forget log upload state machine after response is returned
- setImmediate(() => {
- this.simulateLogUploadLifecycle(chargingStation, requestId).catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetLog: Error during log upload simulation:`,
- error
- )
- })
- })
-
return {
filename: 'simulator-log.txt',
status: LogStatusEnumType.Accepted,
},
}
}
- chargingStation.ocppRequestService
- .requestHandler<
- OCPP20BootNotificationRequest,
- OCPP20BootNotificationResponse
- >(chargingStation, OCPP20RequestCommand.BOOT_NOTIFICATION, chargingStation.bootNotificationRequest as OCPP20BootNotificationRequest, { skipBufferingOnError: true, triggerMessage: true })
- .catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending BootNotification:`,
- error
- )
- })
return { status: TriggerMessageStatusEnumType.Accepted }
case MessageTriggerEnumType.FirmwareStatusNotification:
- chargingStation.ocppRequestService
- .requestHandler<
- OCPP20FirmwareStatusNotificationRequest,
- OCPP20FirmwareStatusNotificationResponse
- >(
- chargingStation,
- OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION,
- {
- status: FirmwareStatusEnumType.Idle,
- },
- { skipBufferingOnError: true, triggerMessage: true }
- )
- .catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending FirmwareStatusNotification:`,
- error
- )
- })
return { status: TriggerMessageStatusEnumType.Accepted }
case MessageTriggerEnumType.Heartbeat:
- chargingStation.ocppRequestService
- .requestHandler<
- OCPP20HeartbeatRequest,
- OCPP20HeartbeatResponse
- >(chargingStation, OCPP20RequestCommand.HEARTBEAT, {}, { skipBufferingOnError: true, triggerMessage: true })
- .catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending Heartbeat:`,
- error
- )
- })
return { status: TriggerMessageStatusEnumType.Accepted }
case MessageTriggerEnumType.LogStatusNotification:
- chargingStation.ocppRequestService
- .requestHandler<
- OCPP20LogStatusNotificationRequest,
- OCPP20LogStatusNotificationResponse
- >(
- chargingStation,
- OCPP20RequestCommand.LOG_STATUS_NOTIFICATION,
- {
- status: UploadLogStatusEnumType.Idle,
- },
- { skipBufferingOnError: true, triggerMessage: true }
- )
- .catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending LogStatusNotification:`,
- error
- )
- })
return { status: TriggerMessageStatusEnumType.Accepted }
- case MessageTriggerEnumType.MeterValues: {
- const evseId = evse?.id ?? 0
- chargingStation.ocppRequestService
- .requestHandler<OCPP20MeterValuesRequest, OCPP20MeterValuesResponse>(
- chargingStation,
- OCPP20RequestCommand.METER_VALUES,
- {
- evseId,
- meterValue: [
- {
- sampledValue: [
- {
- context: OCPP20ReadingContextEnumType.TRIGGER,
- measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
- value: 0,
- },
- ],
- timestamp: new Date(),
- },
- ],
- },
- { skipBufferingOnError: true, triggerMessage: true }
- )
- .catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending MeterValues:`,
- error
- )
- })
+ case MessageTriggerEnumType.MeterValues:
return { status: TriggerMessageStatusEnumType.Accepted }
- }
case MessageTriggerEnumType.StatusNotification:
- if (evse?.id !== undefined && evse.id > 0 && evse.connectorId !== undefined) {
- const evseStatus = chargingStation.evses.get(evse.id)
- const connectorStatus = evseStatus?.connectors.get(evse.connectorId)
- const resolvedStatus =
- connectorStatus?.status != null
- ? (connectorStatus.status as unknown as OCPP20ConnectorStatusEnumType)
- : OCPP20ConnectorStatusEnumType.Available
- chargingStation.ocppRequestService
- .requestHandler<OCPP20StatusNotificationRequest, OCPP20StatusNotificationResponse>(
- chargingStation,
- OCPP20RequestCommand.STATUS_NOTIFICATION,
- {
- connectorId: evse.connectorId,
- connectorStatus: resolvedStatus,
- evseId: evse.id,
- timestamp: new Date(),
- },
- { skipBufferingOnError: true, triggerMessage: true }
- )
- .catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending StatusNotification:`,
- error
- )
- })
- } else {
- if (chargingStation.hasEvses) {
- for (const [evseId, evseStatus] of chargingStation.evses) {
- if (evseId > 0) {
- for (const [connectorId, connectorStatus] of evseStatus.connectors) {
- const resolvedConnectorStatus =
- connectorStatus.status != null
- ? (connectorStatus.status as unknown as OCPP20ConnectorStatusEnumType)
- : OCPP20ConnectorStatusEnumType.Available
- chargingStation.ocppRequestService
- .requestHandler<
- OCPP20StatusNotificationRequest,
- OCPP20StatusNotificationResponse
- >(
- chargingStation,
- OCPP20RequestCommand.STATUS_NOTIFICATION,
- {
- connectorId,
- connectorStatus: resolvedConnectorStatus,
- evseId,
- timestamp: new Date(),
- },
- { skipBufferingOnError: true, triggerMessage: true }
- )
- .catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending StatusNotification:`,
- error
- )
- })
- }
- }
- }
- }
- }
return { status: TriggerMessageStatusEnumType.Accepted }
default:
`${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Received UpdateFirmware request with requestId ${requestId.toString()} for location '${firmware.location}'`
)
- // Fire-and-forget firmware update state machine after response is returned
- setImmediate(() => {
- this.simulateFirmwareUpdateLifecycle(chargingStation, requestId, firmware.signature).catch(
- (error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Error during firmware update simulation:`,
- error
- )
- }
- )
- })
-
return {
status: UpdateFirmwareStatusEnumType.Accepted,
}
return handler as IncomingRequestHandler
}
+ private triggerAllEvseStatusNotifications (
+ chargingStation: ChargingStation,
+ errorHandler: (error: unknown) => void
+ ): void {
+ for (const [evseId, evseStatus] of chargingStation.evses) {
+ if (evseId > 0) {
+ for (const [connectorId, connectorStatus] of evseStatus.connectors) {
+ const resolvedConnectorStatus =
+ connectorStatus.status != null
+ ? (connectorStatus.status as unknown as OCPP20ConnectorStatusEnumType)
+ : OCPP20ConnectorStatusEnumType.Available
+ chargingStation.ocppRequestService
+ .requestHandler<OCPP20StatusNotificationRequest, OCPP20StatusNotificationResponse>(
+ chargingStation,
+ OCPP20RequestCommand.STATUS_NOTIFICATION,
+ {
+ connectorId,
+ connectorStatus: resolvedConnectorStatus,
+ evseId,
+ timestamp: new Date(),
+ },
+ { skipBufferingOnError: true, triggerMessage: true }
+ )
+ .catch(errorHandler)
+ }
+ }
+ }
+ }
+
+ private triggerStatusNotification (
+ chargingStation: ChargingStation,
+ evse: OCPP20TriggerMessageRequest['evse'],
+ errorHandler: (error: unknown) => void
+ ): void {
+ if (evse?.id !== undefined && evse.id > 0 && evse.connectorId !== undefined) {
+ const evseStatus = chargingStation.evses.get(evse.id)
+ const connectorStatus = evseStatus?.connectors.get(evse.connectorId)
+ const resolvedStatus =
+ connectorStatus?.status != null
+ ? (connectorStatus.status as unknown as OCPP20ConnectorStatusEnumType)
+ : OCPP20ConnectorStatusEnumType.Available
+ chargingStation.ocppRequestService
+ .requestHandler<OCPP20StatusNotificationRequest, OCPP20StatusNotificationResponse>(
+ chargingStation,
+ OCPP20RequestCommand.STATUS_NOTIFICATION,
+ {
+ connectorId: evse.connectorId,
+ connectorStatus: resolvedStatus,
+ evseId: evse.id,
+ timestamp: new Date(),
+ },
+ { skipBufferingOnError: true, triggerMessage: true }
+ )
+ .catch(errorHandler)
+ } else if (chargingStation.hasEvses) {
+ this.triggerAllEvseStatusNotifications(chargingStation, errorHandler)
+ }
+ }
+
private validateChargingProfile (
chargingStation: ChargingStation,
chargingProfile: OCPP20ChargingProfileType,
*/
import assert from 'node:assert/strict'
-import { afterEach, beforeEach, describe, it } from 'node:test'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/index.js'
import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
-import { CustomerInformationStatusEnumType, OCPPVersion } from '../../../../src/types/index.js'
+import {
+ CustomerInformationStatusEnumType,
+ type OCPP20CustomerInformationRequest,
+ type OCPP20CustomerInformationResponse,
+ OCPP20IncomingRequestCommand,
+ OCPPVersion,
+} from '../../../../src/types/index.js'
import { Constants } from '../../../../src/utils/index.js'
import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
assert.strictEqual(response.status, CustomerInformationStatusEnumType.Rejected)
})
- // Verify clear request with explicit false report flag
- await it('should respond with Accepted for clear=true and report=false', () => {
- const response = testableService.handleRequestCustomerInformation(station, {
+ await it('should register CUSTOMER_INFORMATION event listener in constructor', () => {
+ const service = new OCPP20IncomingRequestService()
+ assert.strictEqual(service.listenerCount(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION), 1)
+ })
+
+ await it('should call sendNotifyCustomerInformation when CUSTOMER_INFORMATION event emitted with Accepted + report=true', () => {
+ const service = new OCPP20IncomingRequestService()
+ const notifyMock = mock.method(
+ service as unknown as {
+ sendNotifyCustomerInformation: (
+ chargingStation: ChargingStation,
+ requestId: number
+ ) => Promise<void>
+ },
+ 'sendNotifyCustomerInformation',
+ () => Promise.resolve()
+ )
+
+ const request: OCPP20CustomerInformationRequest = {
+ clear: false,
+ report: true,
+ requestId: 20,
+ }
+ const response: OCPP20CustomerInformationResponse = {
+ status: CustomerInformationStatusEnumType.Accepted,
+ }
+
+ service.emit(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, station, request, response)
+
+ assert.strictEqual(notifyMock.mock.callCount(), 1)
+ assert.strictEqual(notifyMock.mock.calls[0].arguments[1], 20)
+ })
+
+ await it('should NOT call sendNotifyCustomerInformation when CUSTOMER_INFORMATION event emitted with Accepted + clear=true only', () => {
+ // CRITICAL: clear=true also returns Accepted — listener must NOT fire notification
+ const service = new OCPP20IncomingRequestService()
+ const notifyMock = mock.method(
+ service as unknown as {
+ sendNotifyCustomerInformation: (
+ chargingStation: ChargingStation,
+ requestId: number
+ ) => Promise<void>
+ },
+ 'sendNotifyCustomerInformation',
+ () => Promise.resolve()
+ )
+
+ const request: OCPP20CustomerInformationRequest = {
clear: true,
report: false,
- requestId: 4,
- })
+ requestId: 21,
+ }
+ const response: OCPP20CustomerInformationResponse = {
+ status: CustomerInformationStatusEnumType.Accepted,
+ }
- assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted)
+ service.emit(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, station, request, response)
+
+ assert.strictEqual(notifyMock.mock.callCount(), 0)
})
- // Verify report request with explicit false clear flag
- await it('should respond with Accepted for clear=false and report=true', () => {
- const response = testableService.handleRequestCustomerInformation(station, {
+ await it('should NOT call sendNotifyCustomerInformation when CUSTOMER_INFORMATION event emitted with Rejected', () => {
+ const service = new OCPP20IncomingRequestService()
+ const notifyMock = mock.method(
+ service as unknown as {
+ sendNotifyCustomerInformation: (
+ chargingStation: ChargingStation,
+ requestId: number
+ ) => Promise<void>
+ },
+ 'sendNotifyCustomerInformation',
+ () => Promise.resolve()
+ )
+
+ const request: OCPP20CustomerInformationRequest = {
+ clear: false,
+ report: false,
+ requestId: 22,
+ }
+ const response: OCPP20CustomerInformationResponse = {
+ status: CustomerInformationStatusEnumType.Rejected,
+ }
+
+ service.emit(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, station, request, response)
+
+ assert.strictEqual(notifyMock.mock.callCount(), 0)
+ })
+
+ await it('should handle sendNotifyCustomerInformation rejection gracefully', async () => {
+ const service = new OCPP20IncomingRequestService()
+ mock.method(
+ service as unknown as {
+ sendNotifyCustomerInformation: (
+ chargingStation: ChargingStation,
+ requestId: number
+ ) => Promise<void>
+ },
+ 'sendNotifyCustomerInformation',
+ () => Promise.reject(new Error('notification error'))
+ )
+
+ const request: OCPP20CustomerInformationRequest = {
clear: false,
report: true,
- requestId: 5,
- })
+ requestId: 99,
+ }
+ const response: OCPP20CustomerInformationResponse = {
+ status: CustomerInformationStatusEnumType.Accepted,
+ }
- assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted)
+ service.emit(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, station, request, response)
+
+ await Promise.resolve()
})
})
*/
import assert from 'node:assert/strict'
-import { afterEach, beforeEach, describe, it } from 'node:test'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/index.js'
LogEnumType,
LogStatusEnumType,
type OCPP20GetLogRequest,
+ type OCPP20GetLogResponse,
+ OCPP20IncomingRequestCommand,
OCPPVersion,
} from '../../../../src/types/index.js'
import { Constants } from '../../../../src/utils/index.js'
assert.strictEqual(response.filename, 'simulator-log.txt')
})
- await it('should pass through requestId correctly across different values', () => {
- const testRequestId = 42
+ await it('should register GET_LOG event listener in constructor', () => {
+ const service = new OCPP20IncomingRequestService()
+ assert.strictEqual(service.listenerCount(OCPP20IncomingRequestCommand.GET_LOG), 1)
+ })
+
+ await it('should call simulateLogUploadLifecycle when GET_LOG event emitted with Accepted response', () => {
+ const service = new OCPP20IncomingRequestService()
+ const simulateMock = mock.method(
+ service as unknown as {
+ simulateLogUploadLifecycle: (
+ chargingStation: ChargingStation,
+ requestId: number
+ ) => Promise<void>
+ },
+ 'simulateLogUploadLifecycle',
+ () => Promise.resolve()
+ )
+
const request: OCPP20GetLogRequest = {
log: {
- remoteLocation: 'ftp://logs.example.com/uploads/',
+ remoteLocation: 'https://csms.example.com/logs',
},
logType: LogEnumType.DiagnosticsLog,
- requestId: testRequestId,
+ requestId: 10,
+ }
+ const response: OCPP20GetLogResponse = {
+ filename: 'simulator-log.txt',
+ status: LogStatusEnumType.Accepted,
}
- const response = testableService.handleRequestGetLog(station, request)
+ service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response)
- assert.strictEqual(response.status, LogStatusEnumType.Accepted)
- assert.strictEqual(typeof response.status, 'string')
- assert.strictEqual(response.filename, 'simulator-log.txt')
+ assert.strictEqual(simulateMock.mock.callCount(), 1)
+ assert.strictEqual(simulateMock.mock.calls[0].arguments[1], 10)
})
- await it('should return Accepted for request with retries and retryInterval', () => {
+ await it('should NOT call simulateLogUploadLifecycle when GET_LOG event emitted with Rejected response', () => {
+ const service = new OCPP20IncomingRequestService()
+ const simulateMock = mock.method(
+ service as unknown as {
+ simulateLogUploadLifecycle: (
+ chargingStation: ChargingStation,
+ requestId: number
+ ) => Promise<void>
+ },
+ 'simulateLogUploadLifecycle',
+ () => Promise.resolve()
+ )
+
const request: OCPP20GetLogRequest = {
log: {
- latestTimestamp: new Date('2025-01-15T23:59:59.000Z'),
- oldestTimestamp: new Date('2025-01-01T00:00:00.000Z'),
- remoteLocation: 'ftp://logs.example.com/uploads/',
+ remoteLocation: 'https://csms.example.com/logs',
},
logType: LogEnumType.DiagnosticsLog,
- requestId: 5,
- retries: 3,
- retryInterval: 60,
+ requestId: 11,
+ }
+ const response: OCPP20GetLogResponse = {
+ status: LogStatusEnumType.Rejected,
}
- const response = testableService.handleRequestGetLog(station, request)
+ service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response)
- assert.strictEqual(response.status, LogStatusEnumType.Accepted)
- assert.strictEqual(response.filename, 'simulator-log.txt')
+ assert.strictEqual(simulateMock.mock.callCount(), 0)
+ })
+
+ await it('should handle simulateLogUploadLifecycle rejection gracefully', async () => {
+ const service = new OCPP20IncomingRequestService()
+ mock.method(
+ service as unknown as {
+ simulateLogUploadLifecycle: (
+ chargingStation: ChargingStation,
+ requestId: number
+ ) => Promise<void>
+ },
+ 'simulateLogUploadLifecycle',
+ () => Promise.reject(new Error('log upload error'))
+ )
+
+ const request: OCPP20GetLogRequest = {
+ log: {
+ remoteLocation: 'https://csms.example.com/logs',
+ },
+ logType: LogEnumType.DiagnosticsLog,
+ requestId: 99,
+ }
+ const response: OCPP20GetLogResponse = {
+ filename: 'simulator-log.txt',
+ status: LogStatusEnumType.Accepted,
+ }
+
+ service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response)
+
+ await Promise.resolve()
})
})
mockStation = ResetTestFixtures.createStandardStation()
})
- // FR: B11.FR.01
- await it('should handle Reset request with Immediate type when no transactions', async () => {
- const resetRequest: OCPP20ResetRequest = {
- type: ResetEnumType.Immediate,
- }
-
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
- mockStation,
- resetRequest
- )
-
- assert.notStrictEqual(response, undefined)
- assert.strictEqual(typeof response, 'object')
- assert.notStrictEqual(response.status, undefined)
- assert.strictEqual(typeof response.status, 'string')
- assert.ok(
- [
- ResetStatusEnumType.Accepted,
- ResetStatusEnumType.Rejected,
- ResetStatusEnumType.Scheduled,
- ].includes(response.status)
- )
- })
-
- await it('should handle Reset request with OnIdle type when no transactions', async () => {
- const resetRequest: OCPP20ResetRequest = {
- type: ResetEnumType.OnIdle,
- }
-
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
- mockStation,
- resetRequest
- )
-
- assert.notStrictEqual(response, undefined)
- assert.notStrictEqual(response.status, undefined)
- assert.ok(
- [
- ResetStatusEnumType.Accepted,
- ResetStatusEnumType.Rejected,
- ResetStatusEnumType.Scheduled,
- ].includes(response.status)
- )
- })
-
// FR: B11.FR.03
await it('should handle EVSE-specific reset request when no transactions', async () => {
const resetRequest: OCPP20ResetRequest = {
assert.ok(response.statusInfo.additionalInfo.includes('EVSE 999'))
})
+ // FR: B11.FR.01
await it('should return proper response structure for immediate reset without transactions', async () => {
const resetRequest: OCPP20ResetRequest = {
type: ResetEnumType.Immediate,
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
import {
MessageTriggerEnumType,
+ OCPP20IncomingRequestCommand,
+ OCPP20RequestCommand,
OCPPVersion,
ReasonCodeEnumType,
RegistrationStatusEnumType,
})
})
- await describe('F06 - Response structure', async () => {
+ await describe('F06 - TRIGGER_MESSAGE event listener', async () => {
+ let incomingRequestServiceForListener: OCPP20IncomingRequestService
let mockStation: MockChargingStation
+ let requestHandlerMock: ReturnType<typeof mock.fn>
beforeEach(() => {
- ;({ mockStation } = createTriggerMessageStation())
+ ;({ mockStation, requestHandlerMock } = createTriggerMessageStation())
+ incomingRequestServiceForListener = new OCPP20IncomingRequestService()
+ })
+
+ await it('should register TRIGGER_MESSAGE event listener in constructor', () => {
+ assert.strictEqual(
+ incomingRequestServiceForListener.listenerCount(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE
+ ),
+ 1
+ )
})
- await it('should return a plain object with a string status field', () => {
+ await it('should NOT fire requestHandler when response status is Rejected', () => {
const request: OCPP20TriggerMessageRequest = {
- requestedMessage: MessageTriggerEnumType.BootNotification,
+ requestedMessage: MessageTriggerEnumType.Heartbeat,
+ }
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.Rejected,
+ }
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+
+ await it('should NOT fire requestHandler when response status is NotImplemented', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.Heartbeat,
+ }
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.NotImplemented,
}
- const response = testableService.handleRequestTriggerMessage(mockStation, request)
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
- assert.notStrictEqual(response, undefined)
- assert.strictEqual(typeof response, 'object')
- assert.strictEqual(typeof response.status, 'string')
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
})
- await it('should not return a Promise from synchronous handler', () => {
+ await it('should fire BootNotification requestHandler on Accepted', () => {
const request: OCPP20TriggerMessageRequest = {
requestedMessage: MessageTriggerEnumType.BootNotification,
}
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.Accepted,
+ }
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ })
+
+ await it('should fire Heartbeat requestHandler on Accepted', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.Heartbeat,
+ }
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.Accepted,
+ }
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ })
+
+ await it('should fire FirmwareStatusNotification requestHandler on Accepted', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.FirmwareStatusNotification,
+ }
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.Accepted,
+ }
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ })
+
+ await it('should fire LogStatusNotification requestHandler on Accepted', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.LogStatusNotification,
+ }
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.Accepted,
+ }
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ })
+
+ await it('should fire MeterValues requestHandler on Accepted', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.Accepted,
+ }
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ })
+
+ await it('should broadcast StatusNotification for all EVSEs on Accepted without specific EVSE', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.StatusNotification,
+ }
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.Accepted,
+ }
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+
+ // 3 EVSEs (1, 2, 3) × 1 connector each = 3 StatusNotification calls
+ const callCount = requestHandlerMock.mock.callCount()
+ assert.strictEqual(callCount, 3)
+ for (const call of requestHandlerMock.mock.calls) {
+ const args = call.arguments as [
+ unknown,
+ string,
+ Record<string, unknown>,
+ Record<string, unknown>
+ ]
+ const [, command, payload, options] = args
+ assert.strictEqual(command, OCPP20RequestCommand.STATUS_NOTIFICATION)
+ assert.notStrictEqual(payload, undefined)
+ assert.ok('evseId' in payload, 'Expected payload to include evseId')
+ assert.ok('connectorId' in payload, 'Expected payload to include connectorId')
+ assert.ok('connectorStatus' in payload, 'Expected payload to include connectorStatus')
+ assert.ok('timestamp' in payload, 'Expected payload to include timestamp')
+ assert.ok((payload.evseId as number) > 0, 'Expected evseId > 0 (EVSE 0 excluded)')
+ assert.strictEqual(options.skipBufferingOnError, true)
+ assert.strictEqual(options.triggerMessage, true)
+ }
+ })
+
+ await it('should fire StatusNotification for specific EVSE and connector via listener', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { connectorId: 1, id: 1 },
+ requestedMessage: MessageTriggerEnumType.StatusNotification,
+ }
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.Accepted,
+ }
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const args = requestHandlerMock.mock.calls[0].arguments as [
+ unknown,
+ string,
+ Record<string, unknown>,
+ Record<string, unknown>
+ ]
+ const [, command, payload, options] = args
+ assert.strictEqual(command, OCPP20RequestCommand.STATUS_NOTIFICATION)
+ assert.strictEqual(payload.evseId, 1)
+ assert.strictEqual(payload.connectorId, 1)
+ assert.ok('connectorStatus' in payload)
+ assert.ok(payload.timestamp instanceof Date)
+ assert.strictEqual(options.skipBufferingOnError, true)
+ assert.strictEqual(options.triggerMessage, true)
+ })
+
+ await it('should handle requestHandler rejection gracefully via errorHandler', async () => {
+ const rejectingMock = mock.fn(async () => Promise.reject(new Error('test error')))
+ const { station: rejectStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 3,
+ evseConfiguration: { evsesCount: 3 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: rejectingMock,
+ },
+ stationInfo: {
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
+
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.Heartbeat,
+ }
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.Accepted,
+ }
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ rejectStation,
+ request,
+ response
+ )
- const result = testableService.handleRequestTriggerMessage(mockStation, request)
+ // Flush microtask queue so .catch(errorHandler) executes
+ await Promise.resolve()
- // A Promise would have a `then` property that is a function
- assert.notStrictEqual(typeof (result as unknown as Promise<unknown>).then, 'function')
+ assert.strictEqual(rejectingMock.mock.callCount(), 1)
})
})
})
*/
import assert from 'node:assert/strict'
-import { afterEach, beforeEach, describe, it } from 'node:test'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/index.js'
import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
import {
+ OCPP20IncomingRequestCommand,
type OCPP20UpdateFirmwareRequest,
+ type OCPP20UpdateFirmwareResponse,
OCPPVersion,
UpdateFirmwareStatusEnumType,
} from '../../../../src/types/index.js'
assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
})
- await it('should return Accepted for request with signature field', () => {
+ await it('should register UPDATE_FIRMWARE event listener in constructor', () => {
+ const service = new OCPP20IncomingRequestService()
+ assert.strictEqual(service.listenerCount(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE), 1)
+ })
+
+ await it('should call simulateFirmwareUpdateLifecycle when UPDATE_FIRMWARE event emitted with Accepted response', () => {
+ const service = new OCPP20IncomingRequestService()
+ const simulateMock = mock.method(
+ service as unknown as {
+ simulateFirmwareUpdateLifecycle: (
+ chargingStation: ChargingStation,
+ requestId: number,
+ signature?: string
+ ) => Promise<void>
+ },
+ 'simulateFirmwareUpdateLifecycle',
+ () => Promise.resolve()
+ )
+
const request: OCPP20UpdateFirmwareRequest = {
firmware: {
- location: 'https://firmware.example.com/signed-update.bin',
+ location: 'https://firmware.example.com/update.bin',
retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'),
- signature: 'dGVzdC1zaWduYXR1cmU=',
- signingCertificate: '-----BEGIN CERTIFICATE-----\nMIIBkTCB...',
+ signature: 'dGVzdA==',
},
- requestId: 2,
+ requestId: 42,
+ }
+ const response: OCPP20UpdateFirmwareResponse = {
+ status: UpdateFirmwareStatusEnumType.Accepted,
}
- const response = testableService.handleRequestUpdateFirmware(station, request)
+ service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response)
- assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
+ assert.strictEqual(simulateMock.mock.callCount(), 1)
+ assert.strictEqual(simulateMock.mock.calls[0].arguments[1], 42)
+ assert.strictEqual(simulateMock.mock.calls[0].arguments[2], 'dGVzdA==')
})
- await it('should return Accepted for request without signature', () => {
- const request: OCPP20UpdateFirmwareRequest = {
- firmware: {
- location: 'https://firmware.example.com/unsigned-update.bin',
- retrieveDateTime: new Date('2025-01-15T12:00:00.000Z'),
+ await it('should NOT call simulateFirmwareUpdateLifecycle when UPDATE_FIRMWARE event emitted with Rejected response', () => {
+ const service = new OCPP20IncomingRequestService()
+ const simulateMock = mock.method(
+ service as unknown as {
+ simulateFirmwareUpdateLifecycle: (
+ chargingStation: ChargingStation,
+ requestId: number,
+ signature?: string
+ ) => Promise<void>
},
- requestId: 3,
- }
-
- const response = testableService.handleRequestUpdateFirmware(station, request)
+ 'simulateFirmwareUpdateLifecycle',
+ () => Promise.resolve()
+ )
- assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
- })
-
- await it('should pass through requestId correctly in the response', () => {
- const testRequestId = 42
const request: OCPP20UpdateFirmwareRequest = {
firmware: {
location: 'https://firmware.example.com/update.bin',
- retrieveDateTime: new Date('2025-01-15T14:00:00.000Z'),
+ retrieveDateTime: new Date(),
},
- requestId: testRequestId,
+ requestId: 43,
+ }
+ const response: OCPP20UpdateFirmwareResponse = {
+ status: UpdateFirmwareStatusEnumType.Rejected,
}
- const response = testableService.handleRequestUpdateFirmware(station, request)
+ service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response)
- assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
- assert.strictEqual(typeof response.status, 'string')
+ assert.strictEqual(simulateMock.mock.callCount(), 0)
})
- await it('should return Accepted for request with retries and retryInterval', () => {
+ await it('should handle simulateFirmwareUpdateLifecycle rejection gracefully', async () => {
+ const service = new OCPP20IncomingRequestService()
+ mock.method(
+ service as unknown as {
+ simulateFirmwareUpdateLifecycle: (
+ chargingStation: ChargingStation,
+ requestId: number,
+ signature?: string
+ ) => Promise<void>
+ },
+ 'simulateFirmwareUpdateLifecycle',
+ () => Promise.reject(new Error('firmware lifecycle error'))
+ )
+
const request: OCPP20UpdateFirmwareRequest = {
firmware: {
- installDateTime: new Date('2025-01-15T16:00:00.000Z'),
- location: 'https://firmware.example.com/update-retry.bin',
- retrieveDateTime: new Date('2025-01-15T15:00:00.000Z'),
+ location: 'https://firmware.example.com/update.bin',
+ retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'),
},
- requestId: 5,
- retries: 3,
- retryInterval: 60,
+ requestId: 99,
+ }
+ const response: OCPP20UpdateFirmwareResponse = {
+ status: UpdateFirmwareStatusEnumType.Accepted,
}
- const response = testableService.handleRequestUpdateFirmware(station, request)
+ service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response)
- assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
+ await Promise.resolve()
})
})
assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.Deauthorized)
})
- await it('should select CablePluggedIn for cable_action context with plugged_in', () => {
- const context: OCPP20TransactionContext = {
- cableState: 'plugged_in',
- source: 'cable_action',
- }
-
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Started,
- context
- )
-
- assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.CablePluggedIn)
- })
-
- await it('should select EVDetected for cable_action context with detected', () => {
- const context: OCPP20TransactionContext = {
- cableState: 'detected',
- source: 'cable_action',
- }
-
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Updated,
- context
- )
-
- assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.EVDetected)
- })
-
- await it('should select EVDeparted for cable_action context with unplugged', () => {
- const context: OCPP20TransactionContext = {
- cableState: 'unplugged',
- source: 'cable_action',
- }
-
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- context
- )
-
- assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.EVDeparted)
- })
-
await it('should select ChargingStateChanged for charging_state context', () => {
const context: OCPP20TransactionContext = {
chargingStateChange: {