- name: pnpm lint
if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '22.x' }}
run: pnpm lint
+ - name: pnpm typecheck
+ if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '22.x' }}
+ run: pnpm typecheck
- name: pnpm build
run: pnpm build
- name: pnpm test
> **Note**: OCPP 2.0.x implementation is **partial** and under active development.
-#### A. Provisioning
+#### B. Provisioning
- :white_check_mark: BootNotification
- :white_check_mark: GetBaseReport
+- :white_check_mark: GetVariables
- :white_check_mark: NotifyReport
+- :white_check_mark: SetVariables
-#### B. Authorization
+#### C. Authorization
- :white_check_mark: ClearCache
-#### C. Availability
+#### D. LocalAuthorizationListManagement
-- :white_check_mark: Heartbeat
-- :white_check_mark: StatusNotification
+- :x: GetLocalListVersion
+- :x: SendLocalList
#### E. Transactions
#### F. RemoteControl
- :white_check_mark: Reset
+- :white_check_mark: TriggerMessage
+- :white_check_mark: UnlockConnector
-#### G. Monitoring
+#### G. Availability
-- :white_check_mark: GetVariables
-- :white_check_mark: SetVariables
+- :white_check_mark: Heartbeat
+- :white_check_mark: StatusNotification
-#### H. FirmwareManagement
+#### L. FirmwareManagement
- :x: UpdateFirmware
- :x: FirmwareStatusNotification
-#### I. ISO15118CertificateManagement
+#### M. ISO 15118 CertificateManagement
- :white_check_mark: CertificateSigned
- :white_check_mark: DeleteCertificate
+- :white_check_mark: Get15118EVCertificate
+- :white_check_mark: GetCertificateStatus
- :white_check_mark: GetInstalledCertificateIds
- :white_check_mark: InstallCertificate
- :white_check_mark: SignCertificate
> - **Mock CSR generation**: The `SignCertificate` command generates a mock Certificate Signing Request (CSR) for simulation purposes. In production, this should be replaced with actual cryptographic CSR generation.
> - **OCSP stub**: Online Certificate Status Protocol (OCSP) validation is stubbed and returns `Failed` status. Full OCSP integration requires external OCSP responder configuration.
-#### J. LocalAuthorizationListManagement
-
-- :x: GetLocalListVersion
-- :x: SendLocalList
-
-#### K. DataTransfer
+#### P. DataTransfer
- :x: DataTransfer
### Version 2.0.x
-> **Note**: OCPP 2.0.x variables management is not yet implemented.
+- :white_check_mark: GetVariables
+- :white_check_mark: SetVariables
## UI Protocol
"build:entities": "tsc -p tsconfig-mikro-orm.json",
"clean:dist": "pnpm exec rimraf dist",
"clean:node_modules": "pnpm exec rimraf node_modules",
+ "typecheck": "tsc --noEmit --skipLibCheck",
"lint": "cross-env TIMING=1 eslint --cache src tests scripts ./*.js ./*.ts",
"lint:fix": "cross-env TIMING=1 eslint --cache --fix src tests scripts ./*.js ./*.ts",
"format": "prettier --cache --write .; eslint --cache --fix src tests scripts ./*.js ./*.ts",
--- /dev/null
+export declare const JSRuntime: {
+ browser: 'browser'
+ bun: 'bun'
+ deno: 'deno'
+ node: 'node'
+ workerd: 'workerd'
+}
+export declare const runtime: string
}
if (interval > 0) {
connectorStatus.transactionSetInterval = setInterval(() => {
- const meterValue = buildMeterValue(
- this,
- connectorId,
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- connectorStatus.transactionId!,
- interval
- )
+ const transactionId = convertToInt(connectorStatus.transactionId)
+ const meterValue = buildMeterValue(this, connectorId, transactionId, interval)
this.ocppRequestService
.requestHandler<MeterValuesRequest, MeterValuesResponse>(
this,
{
connectorId,
meterValue: [meterValue],
- transactionId: connectorStatus.transactionId,
- }
+ transactionId,
+ } as MeterValuesRequest
)
.catch((error: unknown) => {
logger.error(
connectorId: number,
reason?: StopTransactionReason
): Promise<StopTransactionResponse> {
- const transactionId = this.getConnectorStatus(connectorId)?.transactionId
+ const rawTransactionId = this.getConnectorStatus(connectorId)?.transactionId
+ const transactionId = rawTransactionId != null ? convertToInt(rawTransactionId) : undefined
if (
this.stationInfo?.beginEndMeterValues === true &&
this.stationInfo.ocppStrictCompliance === true &&
const transactionEndMeterValue = buildTransactionEndMeterValue(
this,
connectorId,
- this.getEnergyActiveImportRegisterByTransactionId(transactionId)
+ this.getEnergyActiveImportRegisterByTransactionId(rawTransactionId)
)
await this.ocppRequestService.requestHandler<MeterValuesRequest, MeterValuesResponse>(
this,
connectorId,
meterValue: [transactionEndMeterValue],
transactionId,
- }
+ } as MeterValuesRequest
)
}
return await this.ocppRequestService.requestHandler<
Partial<StopTransactionRequest>,
StopTransactionResponse
>(this, RequestCommand.STOP_TRANSACTION, {
- meterStop: this.getEnergyActiveImportRegisterByTransactionId(transactionId, true),
+ meterStop: this.getEnergyActiveImportRegisterByTransactionId(rawTransactionId, true),
transactionId,
...(reason != null && { reason }),
})
ChargingProfileKindType,
ChargingProfilePurposeType,
ChargingRateUnitType,
+ type ChargingSchedule,
type ChargingSchedulePeriod,
type ChargingStationConfiguration,
type ChargingStationInfo,
ConnectorStatusEnum,
CurrentType,
type EvseTemplate,
- type OCPP16BootNotificationRequest,
- type OCPP20BootNotificationRequest,
OCPPVersion,
RecurrencyKindType,
type Reservation,
chargingProfile.chargingProfilePurpose !== ChargingProfilePurposeType.TX_PROFILE
)
.map(chargingProfile => {
- chargingProfile.chargingSchedule.startSchedule = convertToDate(
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
- chargingProfile.chargingSchedule.startSchedule
- )
+ const chargingSchedule = getSingleChargingSchedule(chargingProfile)
+ if (chargingSchedule != null) {
+ chargingSchedule.startSchedule = convertToDate(chargingSchedule.startSchedule)
+ }
chargingProfile.validFrom = convertToDate(chargingProfile.validFrom)
chargingProfile.validTo = convertToDate(chargingProfile.validTo)
return chargingProfile
...(stationInfo.meterType != null && {
meterType: stationInfo.meterType,
}),
- } satisfies OCPP16BootNotificationRequest
+ } satisfies BootNotificationRequest
case OCPPVersion.VERSION_20:
case OCPPVersion.VERSION_201:
return {
}),
},
reason: bootReason,
- } satisfies OCPP20BootNotificationRequest
+ } satisfies BootNotificationRequest
}
}
chargingStation.stationInfo!.maximumPower!
if (limit > chargingStationMaximumPower) {
logger.error(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- `${chargingStation.logPrefix()} ${moduleName}.getChargingStationChargingProfilesLimit: Charging profile id ${chargingProfilesLimit.chargingProfile.chargingProfileId.toString()} limit ${limit.toString()} is greater than charging station maximum ${chargingStationMaximumPower.toString()}: %j`,
+ `${chargingStation.logPrefix()} ${moduleName}.getChargingStationChargingProfilesLimit: Charging profile id ${getChargingProfileId(chargingProfilesLimit.chargingProfile)} limit ${limit.toString()} is greater than charging station maximum ${chargingStationMaximumPower.toString()}: %j`,
chargingProfilesLimit
)
return chargingStationMaximumPower
chargingStation.stationInfo!.maximumPower! / chargingStation.powerDivider!
if (limit > connectorMaximumPower) {
logger.error(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- `${chargingStation.logPrefix()} ${moduleName}.getConnectorChargingProfilesLimit: Charging profile id ${chargingProfilesLimit.chargingProfile.chargingProfileId.toString()} limit ${limit.toString()} is greater than connector ${connectorId.toString()} maximum ${connectorMaximumPower.toString()}: %j`,
+ `${chargingStation.logPrefix()} ${moduleName}.getConnectorChargingProfilesLimit: Charging profile id ${getChargingProfileId(chargingProfilesLimit.chargingProfile)} limit ${limit.toString()} is greater than connector ${connectorId.toString()} maximum ${connectorMaximumPower.toString()}: %j`,
chargingProfilesLimit
)
return connectorMaximumPower
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
const errorMsg = `Unknown ${chargingStation.stationInfo?.currentOutType} currentOutType in charging station information, cannot build charging profiles limit`
const { chargingProfile, limit } = chargingProfilesLimit
+ const chargingSchedule = getSingleChargingSchedule(
+ chargingProfile,
+ chargingStation.logPrefix(),
+ 'buildChargingProfilesLimit'
+ )
+ if (chargingSchedule == null) {
+ return limit
+ }
switch (chargingStation.stationInfo?.currentOutType) {
case CurrentType.AC:
- return chargingProfile.chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT
+ return chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT
? limit
: ACElectricUtils.powerTotal(
chargingStation.getNumberOfPhases(),
limit
)
case CurrentType.DC:
- return chargingProfile.chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT
+ return chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT
? limit
: DCElectricUtils.power(chargingStation.getVoltageOut(), limit)
default:
limit: number
}
+const getChargingProfileId = (chargingProfile: ChargingProfile): string => {
+ const id = chargingProfile.chargingProfileId ?? chargingProfile.id
+ return typeof id === 'number' ? id.toString() : 'unknown'
+}
+
+const getSingleChargingSchedule = (
+ chargingProfile: ChargingProfile,
+ logPrefix?: string,
+ methodName?: string
+): ChargingSchedule | undefined => {
+ if (!Array.isArray(chargingProfile.chargingSchedule)) {
+ return chargingProfile.chargingSchedule
+ }
+ if (logPrefix != null && methodName != null) {
+ logger.debug(
+ `${logPrefix} ${moduleName}.${methodName}: Charging profile id ${getChargingProfileId(chargingProfile)} has an OCPP 2.0 chargingSchedule array and is skipped`
+ )
+ }
+}
+
/**
* Get the charging profiles limit for a connector
* Charging profiles shall already be sorted by priorities
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
let previousActiveChargingProfile: ChargingProfile | undefined
for (const chargingProfile of chargingProfiles) {
- const chargingSchedule = chargingProfile.chargingSchedule
+ const chargingProfileId = getChargingProfileId(chargingProfile)
+ const chargingSchedule = getSingleChargingSchedule(
+ chargingProfile,
+ chargingStation.logPrefix(),
+ 'getChargingProfilesLimit'
+ )
+ if (chargingSchedule == null) {
+ continue
+ }
if (chargingSchedule.startSchedule == null) {
logger.debug(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfile.chargingProfileId.toString()} has no startSchedule defined. Trying to set it to the connector current transaction start date`
+ `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} has no startSchedule defined. Trying to set it to the connector current transaction start date`
)
// OCPP specifies that if startSchedule is not defined, it should be relative to start of the connector transaction
chargingSchedule.startSchedule = connectorStatus?.transactionStart
}
if (!isDate(chargingSchedule.startSchedule)) {
logger.warn(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfile.chargingProfileId.toString()} startSchedule property is not a Date instance. Trying to convert it to a Date instance`
+ `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} startSchedule property is not a Date instance. Trying to convert it to a Date instance`
)
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-non-null-assertion
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
chargingSchedule.startSchedule = convertToDate(chargingSchedule.startSchedule)!
}
if (chargingSchedule.duration == null) {
logger.debug(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfile.chargingProfileId.toString()} has no duration defined and will be set to the maximum time allowed`
+ `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} has no duration defined and will be set to the maximum time allowed`
)
// OCPP specifies that if duration is not defined, it should be infinite
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+
chargingSchedule.duration = differenceInSeconds(maxTime, chargingSchedule.startSchedule)
}
if (
// Check if the charging profile is active
if (
isWithinInterval(currentDate, {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
end: addSeconds(chargingSchedule.startSchedule, chargingSchedule.duration),
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+
start: chargingSchedule.startSchedule,
})
) {
): number => a.startPeriod - b.startPeriod
if (
!isArraySorted<ChargingSchedulePeriod>(
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
chargingSchedule.chargingSchedulePeriod,
chargingSchedulePeriodCompareFn
)
) {
logger.warn(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfile.chargingProfileId.toString()} schedule periods are not sorted by start period`
+ `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} schedule periods are not sorted by start period`
)
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
+
chargingSchedule.chargingSchedulePeriod.sort(chargingSchedulePeriodCompareFn)
}
// Check if the first schedule period startPeriod property is equal to 0
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+
if (chargingSchedule.chargingSchedulePeriod[0].startPeriod !== 0) {
logger.error(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
- `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfile.chargingProfileId.toString()} first schedule period start period ${chargingSchedule.chargingSchedulePeriod[0].startPeriod.toString()} is not equal to 0`
+ `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} first schedule period start period ${chargingSchedule.chargingSchedulePeriod[0].startPeriod.toString()} is not equal to 0`
)
continue
}
// Handle only one schedule period
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+
if (chargingSchedule.chargingSchedulePeriod.length === 1) {
const chargingProfilesLimit: ChargingProfilesLimit = {
chargingProfile,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+
limit: chargingSchedule.chargingSchedulePeriod[0].limit,
}
logger.debug(debugLogMsg, chargingProfilesLimit)
for (const [
index,
chargingSchedulePeriod,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
] of chargingSchedule.chargingSchedulePeriod.entries()) {
// Find the right schedule period
if (
isAfter(
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
addSeconds(chargingSchedule.startSchedule, chargingSchedulePeriod.startPeriod),
currentDate
)
// Found the schedule period: previous is the correct one
const chargingProfilesLimit: ChargingProfilesLimit = {
chargingProfile: previousActiveChargingProfile ?? chargingProfile,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+
limit: previousChargingSchedulePeriod?.limit ?? chargingSchedulePeriod.limit,
}
logger.debug(debugLogMsg, chargingProfilesLimit)
}
// Handle the last schedule period within the charging profile duration
if (
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
index === chargingSchedule.chargingSchedulePeriod.length - 1 ||
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(index < chargingSchedule.chargingSchedulePeriod.length - 1 &&
differenceInSeconds(
addSeconds(
chargingSchedule.startSchedule,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-plus-operands
+
chargingSchedule.chargingSchedulePeriod[index + 1].startPeriod
),
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+
chargingSchedule.startSchedule
) > chargingSchedule.duration)
) {
const chargingProfilesLimit: ChargingProfilesLimit = {
chargingProfile,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+
limit: chargingSchedulePeriod.limit,
}
logger.debug(debugLogMsg, chargingProfilesLimit)
return chargingProfilesLimit
}
// Keep a reference to previous charging schedule period
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+
previousChargingSchedulePeriod = chargingSchedulePeriod
}
}
currentDate: Date | number | string,
logPrefix: string
): boolean => {
+ const chargingProfileId = getChargingProfileId(chargingProfile)
+ const chargingSchedule = getSingleChargingSchedule(
+ chargingProfile,
+ logPrefix,
+ 'prepareChargingProfileKind'
+ )
+ if (chargingSchedule == null) {
+ return false
+ }
switch (chargingProfile.chargingProfileKind) {
case ChargingProfileKindType.RECURRING:
if (!canProceedRecurringChargingProfile(chargingProfile, logPrefix)) {
prepareRecurringChargingProfile(chargingProfile, currentDate, logPrefix)
break
case ChargingProfileKindType.RELATIVE:
- if (chargingProfile.chargingSchedule.startSchedule != null) {
+ if (chargingSchedule.startSchedule != null) {
logger.warn(
- `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfile.chargingProfileId.toString()} has a startSchedule property defined. It will be ignored or used if the connector has a transaction started`
+ `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfileId} has a startSchedule property defined. It will be ignored or used if the connector has a transaction started`
)
- delete chargingProfile.chargingSchedule.startSchedule
+ delete chargingSchedule.startSchedule
}
if (connectorStatus?.transactionStarted === true) {
- chargingProfile.chargingSchedule.startSchedule = connectorStatus.transactionStart
+ chargingSchedule.startSchedule = connectorStatus.transactionStart
}
// FIXME: handle relative charging profile duration
break
currentDate: Date | number | string,
logPrefix: string
): boolean => {
+ const chargingProfileId = getChargingProfileId(chargingProfile)
+ const chargingSchedule = getSingleChargingSchedule(
+ chargingProfile,
+ logPrefix,
+ 'canProceedChargingProfile'
+ )
+ if (chargingSchedule == null) {
+ return false
+ }
if (
(isValidDate(chargingProfile.validFrom) && isBefore(currentDate, chargingProfile.validFrom)) ||
(isValidDate(chargingProfile.validTo) && isAfter(currentDate, chargingProfile.validTo))
) {
logger.debug(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId.toString()} is not valid for the current date ${
+ `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} is not valid for the current date ${
isDate(currentDate) ? currentDate.toISOString() : currentDate.toString()
}`
)
return false
}
- if (
- chargingProfile.chargingSchedule.startSchedule == null ||
- chargingProfile.chargingSchedule.duration == null
- ) {
+ if (chargingSchedule.startSchedule == null || chargingSchedule.duration == null) {
logger.error(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId.toString()} has no startSchedule or duration defined`
+ `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has no startSchedule or duration defined`
)
return false
}
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
- if (!isValidDate(chargingProfile.chargingSchedule.startSchedule)) {
+
+ if (!isValidDate(chargingSchedule.startSchedule)) {
logger.error(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId.toString()} has an invalid startSchedule date defined`
+ `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has an invalid startSchedule date defined`
)
return false
}
- if (!Number.isSafeInteger(chargingProfile.chargingSchedule.duration)) {
+ if (!Number.isSafeInteger(chargingSchedule.duration)) {
logger.error(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId.toString()} has non integer duration defined`
+ `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has non integer duration defined`
)
return false
}
chargingProfile: ChargingProfile,
logPrefix: string
): boolean => {
+ const chargingProfileId = getChargingProfileId(chargingProfile)
+ const chargingSchedule = getSingleChargingSchedule(
+ chargingProfile,
+ logPrefix,
+ 'canProceedRecurringChargingProfile'
+ )
+ if (chargingSchedule == null) {
+ return false
+ }
if (
chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING &&
chargingProfile.recurrencyKind == null
) {
logger.error(
- `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfile.chargingProfileId.toString()} has no recurrencyKind defined`
+ `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfileId} has no recurrencyKind defined`
)
return false
}
if (
chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING &&
- chargingProfile.chargingSchedule.startSchedule == null
+ chargingSchedule.startSchedule == null
) {
logger.error(
- `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfile.chargingProfileId.toString()} has no startSchedule defined`
+ `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfileId} has no startSchedule defined`
)
return false
}
currentDate: Date | number | string,
logPrefix: string
): boolean => {
- const chargingSchedule = chargingProfile.chargingSchedule
+ const chargingProfileId = getChargingProfileId(chargingProfile)
+ const chargingSchedule = getSingleChargingSchedule(
+ chargingProfile,
+ logPrefix,
+ 'prepareRecurringChargingProfile'
+ )
+ if (chargingSchedule == null) {
+ return false
+ }
let recurringIntervalTranslated = false
let recurringInterval: Interval | undefined
switch (chargingProfile.recurrencyKind) {
case RecurrencyKindType.DAILY:
recurringInterval = {
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-non-null-assertion
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
end: addDays(chargingSchedule.startSchedule!, 1),
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-non-null-assertion
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
start: chargingSchedule.startSchedule!,
}
checkRecurringChargingProfileDuration(chargingProfile, recurringInterval, logPrefix)
!isWithinInterval(currentDate, recurringInterval) &&
isBefore(recurringInterval.end, currentDate)
) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
chargingSchedule.startSchedule = addDays(
recurringInterval.start,
differenceInDays(currentDate, recurringInterval.start)
)
recurringInterval = {
end: addDays(chargingSchedule.startSchedule, 1),
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+
start: chargingSchedule.startSchedule,
}
recurringIntervalTranslated = true
break
case RecurrencyKindType.WEEKLY:
recurringInterval = {
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-non-null-assertion
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
end: addWeeks(chargingSchedule.startSchedule!, 1),
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-non-null-assertion
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
start: chargingSchedule.startSchedule!,
}
checkRecurringChargingProfileDuration(chargingProfile, recurringInterval, logPrefix)
!isWithinInterval(currentDate, recurringInterval) &&
isBefore(recurringInterval.end, currentDate)
) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
chargingSchedule.startSchedule = addWeeks(
recurringInterval.start,
differenceInWeeks(currentDate, recurringInterval.start)
)
recurringInterval = {
end: addWeeks(chargingSchedule.startSchedule, 1),
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+
start: chargingSchedule.startSchedule,
}
recurringIntervalTranslated = true
break
default:
logger.error(
- // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
- `${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${chargingProfile.recurrencyKind} charging profile id ${chargingProfile.chargingProfileId.toString()} is not supported`
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ `${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${chargingProfile.recurrencyKind} charging profile id ${chargingProfileId} is not supported`
)
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
`${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
chargingProfile.recurrencyKind
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- } charging profile id ${chargingProfile.chargingProfileId.toString()} recurrency time interval [${toDate(
+ } charging profile id ${chargingProfileId} recurrency time interval [${toDate(
recurringInterval?.start as Date
).toISOString()}, ${toDate(
recurringInterval?.end as Date
interval: Interval,
logPrefix: string
): void => {
- if (chargingProfile.chargingSchedule.duration == null) {
+ const chargingProfileId = getChargingProfileId(chargingProfile)
+ const chargingSchedule = getSingleChargingSchedule(
+ chargingProfile,
+ logPrefix,
+ 'checkRecurringChargingProfileDuration'
+ )
+ if (chargingSchedule == null) {
+ return
+ }
+ if (chargingSchedule.duration == null) {
logger.warn(
`${logPrefix} ${moduleName}.checkRecurringChargingProfileDuration: Recurring ${
chargingProfile.chargingProfileKind
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- } charging profile id ${chargingProfile.chargingProfileId.toString()} duration is not defined, set it to the recurrency time interval duration ${differenceInSeconds(
+ } charging profile id ${chargingProfileId} duration is not defined, set it to the recurrency time interval duration ${differenceInSeconds(
interval.end,
interval.start
).toString()}`
)
- chargingProfile.chargingSchedule.duration = differenceInSeconds(interval.end, interval.start)
- } else if (
- chargingProfile.chargingSchedule.duration > differenceInSeconds(interval.end, interval.start)
- ) {
+ chargingSchedule.duration = differenceInSeconds(interval.end, interval.start)
+ } else if (chargingSchedule.duration > differenceInSeconds(interval.end, interval.start)) {
logger.warn(
`${logPrefix} ${moduleName}.checkRecurringChargingProfileDuration: Recurring ${
chargingProfile.chargingProfileKind
- // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
- } charging profile id ${chargingProfile.chargingProfileId.toString()} duration ${chargingProfile.chargingSchedule.duration.toString()} is greater than the recurrency time interval duration ${differenceInSeconds(
+ } charging profile id ${chargingProfileId} duration ${chargingSchedule.duration.toString()} is greater than the recurrency time interval duration ${differenceInSeconds(
interval.end,
interval.start
).toString()}`
)
- chargingProfile.chargingSchedule.duration = differenceInSeconds(interval.end, interval.start)
+ chargingSchedule.duration = differenceInSeconds(interval.end, interval.start)
}
}
this.chargingStation,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
connectorId!,
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- transactionId!,
+ convertToInt(transactionId),
configuredMeterValueSampleInterval != null
? secondsToMilliseconds(convertToInt(configuredMeterValueSampleInterval.value))
: Constants.DEFAULT_METER_VALUES_INTERVAL
type JsonObject,
type JsonType,
OCPP16ChargePointStatus,
+ type OCPP16MeterValue,
OCPP16RequestCommand,
type OCPP16StartTransactionRequest,
OCPPVersion,
...(chargingStation.stationInfo?.transactionDataMeterValues === true && {
transactionData: OCPP16ServiceUtils.buildTransactionDataMeterValues(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- chargingStation.getConnectorStatus(connectorId!)!.transactionBeginMeterValue!,
+ chargingStation.getConnectorStatus(connectorId!)!
+ .transactionBeginMeterValue! as OCPP16MeterValue,
OCPP16ServiceUtils.buildTransactionEndMeterValue(
chargingStation,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
connectorId!,
energyActiveImportRegister
- )
+ ) as OCPP16MeterValue
),
}),
...commandParams,
type OCPP16BootNotificationResponse,
OCPP16ChargePointStatus,
OCPP16IncomingRequestCommand,
+ type OCPP16MeterValue,
type OCPP16MeterValuesRequest,
type OCPP16MeterValuesResponse,
OCPP16RequestCommand,
chargingStation,
transactionConnectorId,
requestPayload.meterStop
- ),
+ ) as OCPP16MeterValue,
],
transactionId: requestPayload.transactionId,
}))
OCPP16MeterValueContext,
OCPP16MeterValueUnit,
OCPP16RequestCommand,
+ type OCPP16SampledValue,
OCPP16StandardParametersKey,
OCPP16StopTransactionReason,
type OCPP16SupportedFeatureProfiles,
chargingStation,
connectorId
)
- const unitDivider =
- sampledValueTemplate?.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
- meterValue.sampledValue.push(
- OCPP16ServiceUtils.buildSampledValue(
- chargingStation.stationInfo?.ocppVersion,
- sampledValueTemplate,
- roundTo((meterStart ?? 0) / unitDivider, 4),
- OCPP16MeterValueContext.TRANSACTION_BEGIN
+ if (sampledValueTemplate != null) {
+ const unitDivider =
+ sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
+ meterValue.sampledValue.push(
+ OCPP16ServiceUtils.buildSampledValue(
+ chargingStation.stationInfo?.ocppVersion,
+ sampledValueTemplate,
+ roundTo((meterStart ?? 0) / unitDivider, 4),
+ OCPP16MeterValueContext.TRANSACTION_BEGIN
+ ) as OCPP16SampledValue
)
- )
+ }
return meterValue
}
private static readonly PEM_END_MARKER = '-----END CERTIFICATE-----'
/**
- * Computes hash data for a PEM certificate per RFC 6960 Section 4.1.1 CertID semantics
+ * Computes hash data for a PEM certificate per RFC 6960 §4.1.1 CertID semantics.
*
* Per RFC 6960, the CertID identifies a certificate by:
* - issuerNameHash: Hash of the issuer's DN (from the subject certificate)
* - issuerKeyHash: Hash of the issuer's public key (from the issuer certificate)
* - serialNumber: The certificate's serial number
+ * @remarks
+ * **RFC 6960 §4.1.1 deviation**: Per RFC 6960, `issuerNameHash` must be the hash of the
+ * DER-encoded issuer distinguished name. This implementation hashes the string DN
+ * representation from `X509Certificate.issuer` as a simulation approximation. Full RFC 6960
+ * compliance would require ASN.1/DER encoding of the issuer name, which is outside the scope
+ * of this simulator. See also: mock CSR generation in the SignCertificate handler.
* @param pemData - PEM-encoded certificate data
* @param hashAlgorithm - Hash algorithm to use (default: SHA256)
* @param issuerCertPem - Optional PEM-encoded issuer certificate for issuerKeyHash computation.
const firstCertPem = this.extractFirstCertificate(pemData)
const x509 = new X509Certificate(firstCertPem)
- // RFC 6960 4.1.1: issuerNameHash is the hash of the issuer's DN from the subject certificate
- // Node.js X509Certificate.issuer provides the string representation of the issuer DN
+ // RFC 6960 §4.1.1 deviation: spec requires hash of DER-encoded issuer distinguished name.
+ // Using string DN from X509Certificate.issuer as simulation approximation
+ // (ASN.1/DER encoding of the issuer name is out of scope for this simulator).
const issuerNameHash = createHash(algorithmName).update(x509.issuer).digest('hex')
- // RFC 6960 4.1.1: issuerKeyHash is the hash of the issuer certificate's public key
+ // RFC 6960 §4.1.1: issuerKeyHash is the hash of the issuer certificate's public key
// Determine which public key to use for issuerKeyHash
let issuerPublicKeyDer: Buffer
/**
* Computes fallback hash data when X509Certificate parsing fails.
- * Uses the raw PEM content to derive hash values.
+ * Uses the raw PEM content to derive deterministic but non-RFC-compliant hash values.
+ * @remarks
+ * This fallback produces stable, unique identifiers for certificate matching purposes only.
+ * The hash values do not conform to RFC 6960 §4.1.1 CertID semantics since the raw DER
+ * content cannot be structurally parsed without X509Certificate support.
* @param pemData - PEM-encoded certificate data
* @param hashAlgorithm - Hash algorithm enum type for the response
* @param algorithmName - Node.js crypto hash algorithm name
hashAlgorithm: HashAlgorithmEnumType,
algorithmName: string
): CertificateHashDataType {
- // Extract the base64 content between PEM markers
const base64Content = pemData
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s/g, '')
- // Compute hashes from the certificate content
const contentBuffer = Buffer.from(base64Content, 'base64')
+
+ // Use first 64 bytes as issuer name proxy: in DER-encoded X.509, the issuer DN
+ // typically resides within this range, providing a stable hash for matching.
+ const issuerNameSliceEnd = Math.min(64, contentBuffer.length)
const issuerNameHash = createHash(algorithmName)
- .update(contentBuffer.subarray(0, Math.min(64, contentBuffer.length)))
+ .update(contentBuffer.subarray(0, issuerNameSliceEnd))
.digest('hex')
const issuerKeyHash = createHash(algorithmName).update(contentBuffer).digest('hex')
- // Generate a serial number from the content hash
- const serialNumber = createHash('sha256')
- .update(pemData)
- .digest('hex')
- .substring(0, 16)
- .toUpperCase()
+ const serialNumber = this.generateFallbackSerialNumber(pemData)
return {
hashAlgorithm,
// { from: OCPP20ConnectorStatusEnumType.Faulted, to: OCPP20ConnectorStatusEnumType.Faulted }
])
+ /**
+ * Default timeout in milliseconds for async OCPP 2.0 handler operations
+ * (e.g., certificate file I/O). Prevents handlers from hanging indefinitely.
+ */
+ static readonly HANDLER_TIMEOUT_MS = 30_000
+
static readonly TriggerReasonMapping: readonly TriggerReasonMap[] = Object.freeze([
// Priority 1: Remote Commands (highest priority)
{
InstallCertificateStatusEnumType,
InstallCertificateUseEnumType,
type JsonType,
+ MessageTriggerEnumType,
+ type OCPP20BootNotificationRequest,
+ type OCPP20BootNotificationResponse,
type OCPP20CertificateSignedRequest,
type OCPP20CertificateSignedResponse,
type OCPP20ClearCacheResponse,
type OCPP20GetInstalledCertificateIdsResponse,
type OCPP20GetVariablesRequest,
type OCPP20GetVariablesResponse,
+ type OCPP20HeartbeatRequest,
+ type OCPP20HeartbeatResponse,
OCPP20IncomingRequestCommand,
type OCPP20InstallCertificateRequest,
type OCPP20InstallCertificateResponse,
type OCPP20ResetResponse,
type OCPP20SetVariablesRequest,
type OCPP20SetVariablesResponse,
+ type OCPP20StatusNotificationRequest,
+ type OCPP20StatusNotificationResponse,
+ type OCPP20TriggerMessageRequest,
+ type OCPP20TriggerMessageResponse,
+ type OCPP20UnlockConnectorRequest,
+ type OCPP20UnlockConnectorResponse,
OCPPVersion,
ReasonCodeEnumType,
+ RegistrationStatusEnumType,
ReportBaseEnumType,
type ReportDataType,
RequestStartStopStatusEnumType,
ResetStatusEnumType,
SetVariableStatusEnumType,
StopTransactionReason,
+ TriggerMessageStatusEnumType,
+ UnlockStatusEnumType,
} from '../../../types/index.js'
import {
OCPP20ChargingProfileKindEnumType,
OCPP20ChargingRateUnitEnumType,
OCPP20ReasonEnumType,
} from '../../../types/ocpp/2.0/Transaction.js'
-import { StandardParametersKey } from '../../../types/ocpp/Configuration.js'
import {
Constants,
- convertToIntOrNaN,
generateUUID,
isAsyncFunction,
logger,
validateUUID,
} from '../../../utils/index.js'
-import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
import {
getIdTagsFile,
hasPendingReservation,
const moduleName = 'OCPP20IncomingRequestService'
-/**
- * OCPP 2.0+ Incoming Request Service - handles and processes all incoming requests
- * from the Central System (CSMS) to the Charging Station using OCPP 2.0+ protocol.
- *
- * This service class is responsible for:
- * - **Request Reception**: Receiving and routing OCPP 2.0+ incoming requests from CSMS
- * - **Payload Validation**: Validating incoming request payloads against OCPP 2.0+ JSON schemas
- * - **Request Processing**: Executing business logic for each OCPP 2.0+ request type
- * - **Response Generation**: Creating and sending appropriate responses back to CSMS
- * - **Enhanced Features**: Supporting advanced OCPP 2.0+ features like variable management
- *
- * Supported OCPP 2.0+ Incoming Request Types:
- * - **Transaction Management**: RequestStartTransaction, RequestStopTransaction
- * - **Configuration Management**: SetVariables, GetVariables, GetBaseReport
- * - **Security Operations**: CertificatesSigned, SecurityEventNotification
- * - **Charging Management**: SetChargingProfile, ClearChargingProfile, GetChargingProfiles
- * - **Diagnostics**: TriggerMessage, GetLog, UpdateFirmware
- * - **Display Management**: SetDisplayMessage, ClearDisplayMessage
- * - **Customer Management**: ClearCache, SendLocalList
- *
- * Key OCPP 2.0+ Enhancements:
- * - **Variable Model**: Advanced configuration through standardized variable system
- * - **Enhanced Security**: Improved authentication and authorization mechanisms
- * - **Rich Messaging**: Support for display messages and customer information
- * - **Advanced Monitoring**: Comprehensive logging and diagnostic capabilities
- * - **Flexible Charging**: Enhanced charging profile management and scheduling
- *
- * Architecture Pattern:
- * This class extends OCPPIncomingRequestService and implements OCPP 2.0+-specific
- * request handling logic. It integrates with the OCPP20VariableManager for advanced
- * configuration management and maintains backward compatibility concepts while
- * providing next-generation OCPP features.
- *
- * Validation Workflow:
- * 1. Incoming request received and parsed
- * 2. Payload validated against OCPP 2.0+ JSON schema
- * 3. Request routed to appropriate handler method
- * 4. Business logic executed with variable model integration
- * 5. Response payload validated and sent back to CSMS
- * @see {@link validatePayload} Request payload validation method
- * @see {@link handleRequestStartTransaction} Example OCPP 2.0+ request handler
- * @see {@link OCPP20VariableManager} Variable management integration
- */
-
export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
protected payloadValidatorFunctions: Map<OCPP20IncomingRequestCommand, ValidateFunction<JsonType>>
this.incomingRequestHandlers = new Map<OCPP20IncomingRequestCommand, IncomingRequestHandler>([
[
OCPP20IncomingRequestCommand.CERTIFICATE_SIGNED,
- this.handleRequestCertificateSigned.bind(this) as unknown as IncomingRequestHandler,
+ this.toHandler(this.handleRequestCertificateSigned.bind(this)),
],
[
OCPP20IncomingRequestCommand.CLEAR_CACHE,
- this.handleRequestClearCache.bind(this) as unknown as IncomingRequestHandler,
+ this.toHandler(this.handleRequestClearCache.bind(this)),
],
[
OCPP20IncomingRequestCommand.DELETE_CERTIFICATE,
- this.handleRequestDeleteCertificate.bind(this) as unknown as IncomingRequestHandler,
+ this.toHandler(this.handleRequestDeleteCertificate.bind(this)),
],
[
OCPP20IncomingRequestCommand.GET_BASE_REPORT,
- this.handleRequestGetBaseReport.bind(this) as unknown as IncomingRequestHandler,
+ this.toHandler(this.handleRequestGetBaseReport.bind(this)),
],
-
[
OCPP20IncomingRequestCommand.GET_INSTALLED_CERTIFICATE_IDS,
- this.handleRequestGetInstalledCertificateIds.bind(
- this
- ) as unknown as IncomingRequestHandler,
+ this.toHandler(this.handleRequestGetInstalledCertificateIds.bind(this)),
],
[
OCPP20IncomingRequestCommand.GET_VARIABLES,
- this.handleRequestGetVariables.bind(this) as unknown as IncomingRequestHandler,
+ this.toHandler(this.handleRequestGetVariables.bind(this)),
],
[
OCPP20IncomingRequestCommand.INSTALL_CERTIFICATE,
- this.handleRequestInstallCertificate.bind(this) as unknown as IncomingRequestHandler,
+ this.toHandler(this.handleRequestInstallCertificate.bind(this)),
],
[
OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
- this.handleRequestStartTransaction.bind(this) as unknown as IncomingRequestHandler,
+ this.toHandler(this.handleRequestStartTransaction.bind(this)),
],
[
OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
- this.handleRequestStopTransaction.bind(this) as unknown as IncomingRequestHandler,
+ this.toHandler(this.handleRequestStopTransaction.bind(this)),
],
+ [OCPP20IncomingRequestCommand.RESET, this.toHandler(this.handleRequestReset.bind(this))],
[
- OCPP20IncomingRequestCommand.RESET,
- this.handleRequestReset.bind(this) as unknown as IncomingRequestHandler,
+ OCPP20IncomingRequestCommand.SET_VARIABLES,
+ this.toHandler(this.handleRequestSetVariables.bind(this)),
],
[
- OCPP20IncomingRequestCommand.SET_VARIABLES,
- this.handleRequestSetVariables.bind(this) as unknown as IncomingRequestHandler,
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ this.toHandler(this.handleRequestTriggerMessage.bind(this)),
+ ],
+ [
+ OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
+ this.toHandler(this.handleRequestUnlockConnector.bind(this)),
],
])
this.payloadValidatorFunctions = OCPP20ServiceUtils.createPayloadValidatorMap(
const variableManager = OCPP20VariableManager.getInstance()
- // Enforce ItemsPerMessage and BytesPerMessage limits if configured
- let enforceItemsLimit = 0
- let enforceBytesLimit = 0
- try {
- const itemsCfg = getConfigurationKey(
- chargingStation,
- OCPP20RequiredVariableName.ItemsPerMessage as unknown as StandardParametersKey
- )?.value
- const bytesCfg = getConfigurationKey(
- chargingStation,
- OCPP20RequiredVariableName.BytesPerMessage as unknown as StandardParametersKey
- )?.value
- if (itemsCfg && /^\d+$/.test(itemsCfg)) {
- enforceItemsLimit = convertToIntOrNaN(itemsCfg)
- }
- if (bytesCfg && /^\d+$/.test(bytesCfg)) {
- enforceBytesLimit = convertToIntOrNaN(bytesCfg)
- }
- } catch {
- /* ignore */
- }
+ const { bytesLimit: enforceBytesLimit, itemsLimit: enforceItemsLimit } =
+ OCPP20ServiceUtils.readMessageLimits(chargingStation)
const variableData = commandPayload.getVariableData
const preEnforcement = OCPP20ServiceUtils.enforceMessageLimits(
setVariableResult: [],
}
- // Enforce ItemsPerMessageSetVariables and BytesPerMessageSetVariables limits if configured
- let enforceItemsLimit = 0
- let enforceBytesLimit = 0
- try {
- const itemsCfg = getConfigurationKey(
- chargingStation,
- OCPP20RequiredVariableName.ItemsPerMessage as unknown as StandardParametersKey
- )?.value
- const bytesCfg = getConfigurationKey(
- chargingStation,
- OCPP20RequiredVariableName.BytesPerMessage as unknown as StandardParametersKey
- )?.value
- if (itemsCfg && /^\d+$/.test(itemsCfg)) {
- enforceItemsLimit = convertToIntOrNaN(itemsCfg)
- }
- if (bytesCfg && /^\d+$/.test(bytesCfg)) {
- enforceBytesLimit = convertToIntOrNaN(bytesCfg)
- }
- } catch {
- /* ignore */
- }
+ const { bytesLimit: enforceBytesLimit, itemsLimit: enforceItemsLimit } =
+ OCPP20ServiceUtils.readMessageLimits(chargingStation)
const variableManager = OCPP20VariableManager.getInstance()
- // Items per message enforcement
const variableData = commandPayload.setVariableData
const preEnforcement = OCPP20ServiceUtils.enforceMessageLimits(
chargingStation,
* @param chargingStation - The charging station instance
* @returns Promise resolving to ClearCacheResponse
*/
- protected override async handleRequestClearCache (
+ protected async handleRequestClearCache (
chargingStation: ChargingStation
): Promise<OCPP20ClearCacheResponse> {
try {
chargingStation: ChargingStation,
reportBase: ReportBaseEnumType
): ReportDataType[] {
- // Validate reportBase parameter
if (!Object.values(ReportBaseEnumType).includes(reportBase)) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.buildReportData: Invalid reportBase '${reportBase}'`
switch (reportBase) {
case ReportBaseEnumType.ConfigurationInventory:
- // Include OCPP configuration keys
if (chargingStation.ocppConfiguration?.configurationKey) {
for (const configKey of chargingStation.ocppConfiguration.configurationKey) {
reportData.push({
break
case ReportBaseEnumType.FullInventory:
- // 1. Charging Station information
if (chargingStation.stationInfo) {
const stationInfo = chargingStation.stationInfo
if (stationInfo.chargePointModel) {
}
}
- // 2. OCPP configuration
if (chargingStation.ocppConfiguration?.configurationKey) {
for (const configKey of chargingStation.ocppConfiguration.configurationKey) {
const variableAttributes = []
}
}
- // 3. Registered OCPP 2.0.1 variables
try {
const variableManager = OCPP20VariableManager.getInstance()
- // Build getVariableData array from VARIABLE_REGISTRY metadata
const getVariableData: OCPP20GetVariablesRequest['getVariableData'] = []
for (const variableMetadata of Object.values(VARIABLE_REGISTRY)) {
- // Include instance-scoped metadata; the OCPP Variable type supports instance under variable
const variableDescriptor: { instance?: string; name: string } = {
name: variableMetadata.variable,
}
if (variableMetadata.instance) {
variableDescriptor.instance = variableMetadata.instance
}
- // Always request Actual first
getVariableData.push({
attributeType: AttributeEnumType.Actual,
component: { name: variableMetadata.component },
variable: variableDescriptor,
})
- // Request MinSet/MaxSet only if supported by metadata
if (variableMetadata.supportedAttributes.includes(AttributeEnumType.MinSet)) {
getVariableData.push({
attributeType: AttributeEnumType.MinSet,
}
}
const getResults = variableManager.getVariables(chargingStation, getVariableData)
- // Group results by component+variable preserving attribute ordering Actual, MinSet, MaxSet
const grouped = new Map<
string,
{
}
}
}
- // Normalize attribute ordering
for (const entry of grouped.values()) {
entry.attributes.sort((a, b) => {
const order = [
)
}
- // 4. EVSE and connector information
if (chargingStation.hasEvses) {
for (const [evseId, evse] of chargingStation.evses) {
reportData.push({
}
}
} else {
- // Fallback to connectors if no EVSE structure
for (const [connectorId, connector] of chargingStation.connectors) {
if (connectorId > 0) {
reportData.push({
})
}
} else {
- // Fallback to connectors if no EVSE structure
for (const [connectorId, connector] of chargingStation.connectors) {
if (connectorId > 0) {
reportData.push({
return {
status: GenericStatus.Rejected,
statusInfo: {
+ additionalInfo: 'Certificate manager is not available on this charging station',
reasonCode: ReasonCodeEnumType.InternalError,
},
}
}
- // Validate certificate chain format
if (!chargingStation.certificateManager.validateCertificateFormat(certificateChain)) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestCertificateSigned: Invalid PEM format for certificate chain`
return {
status: GenericStatus.Rejected,
statusInfo: {
+ additionalInfo: 'Certificate PEM format is invalid or malformed',
reasonCode: ReasonCodeEnumType.InvalidCertificate,
},
}
}
- // Store certificate chain
try {
const result = chargingStation.certificateManager.storeCertificate(
chargingStation.stationInfo?.hashId ?? '',
certificateChain
)
- // Handle both Promise and synchronous returns, and both boolean and object results
const storeResult = result instanceof Promise ? await result : result
- // Handle both boolean (test mock) and object (real implementation) results
const success = typeof storeResult === 'boolean' ? storeResult : storeResult.success
if (!success) {
return {
status: GenericStatus.Rejected,
statusInfo: {
+ additionalInfo: 'Certificate storage rejected the certificate chain as invalid',
reasonCode: ReasonCodeEnumType.InvalidCertificate,
},
}
}
- // For ChargingStationCertificate, trigger websocket reconnect to use the new certificate
const effectiveCertificateType =
certificateType ?? CertificateSigningUseEnumType.ChargingStationCertificate
if (effectiveCertificateType === CertificateSigningUseEnumType.ChargingStationCertificate) {
return {
status: GenericStatus.Rejected,
statusInfo: {
+ additionalInfo: 'Failed to store certificate chain due to a storage error',
reasonCode: ReasonCodeEnumType.OutOfStorage,
},
}
return {
status: DeleteCertificateStatusEnumType.Failed,
statusInfo: {
+ additionalInfo: 'Certificate manager is not available on this charging station',
reasonCode: ReasonCodeEnumType.InternalError,
},
}
certificateHashData
)
- // Handle both Promise and synchronous returns
const deleteResult = result instanceof Promise ? await result : result
- // Check the status field for the result
if (deleteResult.status === DeleteCertificateStatusEnumType.NotFound) {
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestDeleteCertificate: Certificate not found`
}
}
- // Failed status
return {
status: DeleteCertificateStatusEnumType.Failed,
statusInfo: {
+ additionalInfo: 'Certificate deletion operation returned a failed status',
reasonCode: ReasonCodeEnumType.InternalError,
},
}
return {
status: DeleteCertificateStatusEnumType.Failed,
statusInfo: {
+ additionalInfo: 'Certificate deletion failed due to an unexpected error',
reasonCode: ReasonCodeEnumType.InternalError,
},
}
}
}
- // Cache report data for subsequent NotifyReport requests to avoid recomputation
const cached = this.reportDataCache.get(commandPayload.requestId)
const reportData = cached ?? this.buildReportData(chargingStation, commandPayload.reportBase)
if (!cached && reportData.length > 0) {
return {
status: GetInstalledCertificateStatusEnumType.NotFound,
statusInfo: {
+ additionalInfo: 'Certificate manager is not available on this charging station',
reasonCode: ReasonCodeEnumType.InternalError,
},
}
return {
status: GetInstalledCertificateStatusEnumType.NotFound,
statusInfo: {
+ additionalInfo: 'Failed to retrieve installed certificates due to an unexpected error',
reasonCode: ReasonCodeEnumType.InternalError,
},
}
return {
status: InstallCertificateStatusEnumType.Failed,
statusInfo: {
+ additionalInfo: 'Certificate manager is not available on this charging station',
reasonCode: ReasonCodeEnumType.InternalError,
},
}
return {
status: InstallCertificateStatusEnumType.Rejected,
statusInfo: {
+ additionalInfo: 'Certificate PEM format is invalid or malformed',
reasonCode: ReasonCodeEnumType.InvalidCertificate,
},
}
}
try {
- const methodResult = chargingStation.certificateManager.storeCertificate(
+ const rawResult = chargingStation.certificateManager.storeCertificate(
chargingStation.stationInfo?.hashId ?? '',
certificateType,
certificate
)
- const storeResult: StoreCertificateResult =
- methodResult instanceof Promise ? await methodResult : methodResult
+ const resultPromise: Promise<StoreCertificateResult> =
+ rawResult instanceof Promise
+ ? withTimeout(rawResult, OCPP20Constants.HANDLER_TIMEOUT_MS, 'storeCertificate')
+ : Promise.resolve(rawResult)
+ const storeResult: StoreCertificateResult = await resultPromise
if (!storeResult.success) {
logger.warn(
return {
status: InstallCertificateStatusEnumType.Rejected,
statusInfo: {
+ additionalInfo: 'Certificate storage rejected the certificate as invalid',
reasonCode: ReasonCodeEnumType.InvalidCertificate,
},
}
return {
status: InstallCertificateStatusEnumType.Failed,
statusInfo: {
+ additionalInfo: 'Failed to store certificate due to a storage error',
reasonCode: ReasonCodeEnumType.OutOfStorage,
},
}
const { evseId, type } = commandPayload
+ const variableManager = OCPP20VariableManager.getInstance()
+ const allowResetResults = variableManager.getVariables(chargingStation, [
+ {
+ component: { name: OCPP20ComponentName.EVSE },
+ variable: { name: 'AllowReset' },
+ },
+ ])
+ if (allowResetResults.length > 0 && allowResetResults[0].attributeValue === 'false') {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: AllowReset is false, rejecting reset request`
+ )
+ return {
+ status: ResetStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'AllowReset variable is set to false',
+ reasonCode: ReasonCodeEnumType.NotEnabled,
+ },
+ }
+ }
+
+ if (this.hasFirmwareUpdateInProgress(chargingStation)) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Firmware update in progress, rejecting reset request`
+ )
+ return {
+ status: ResetStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Firmware update is in progress',
+ reasonCode: ReasonCodeEnumType.FwUpdateInProgress,
+ },
+ }
+ }
+
if (evseId !== undefined && evseId > 0) {
- // Check if the charging station supports EVSE-specific reset
if (!chargingStation.hasEvses) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Charging station does not support EVSE-specific reset`
}
}
- // Check if the EVSE exists
const evseExists = chargingStation.evses.has(evseId)
if (!evseExists) {
logger.warn(
}
}
- // Check for active transactions
const hasActiveTransactions = chargingStation.getNumberOfRunningTransactions() > 0
- // Check for EVSE-specific active transactions if evseId is provided
let evseHasActiveTransactions = false
if (evseId !== undefined && evseId > 0) {
const evse = chargingStation.getEvseStatus(evseId)
try {
if (type === ResetEnumType.Immediate) {
- if (evseId !== undefined) {
- // EVSE-specific immediate reset
+ if (evseId !== undefined && evseId > 0) {
if (evseHasActiveTransactions) {
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate EVSE reset with active transaction, will terminate transaction and reset EVSE ${evseId.toString()}`
)
- // Implement EVSE-specific transaction termination
await this.terminateEvseTransactions(
chargingStation,
evseId,
status: ResetStatusEnumType.Accepted,
}
} else {
- // Reset EVSE immediately
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate EVSE reset without active transactions for EVSE ${evseId.toString()}`
)
}
}
} else {
- // Charging station immediate reset
if (hasActiveTransactions) {
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate reset with active transactions, will terminate transactions and reset`
)
- // Implement proper transaction termination with TransactionEventRequest
await this.terminateAllTransactions(
chargingStation,
OCPP20ReasonEnumType.ImmediateReset
`${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate reset without active transactions`
)
- // Send StatusNotification(Unavailable) for all connectors
this.sendAllConnectorsStatusNotifications(
chargingStation,
OCPP20ConnectorStatusEnumType.Unavailable
}
}
} else {
- // OnIdle reset
- if (evseId !== undefined) {
- // EVSE-specific OnIdle reset
+ if (evseId !== undefined && evseId > 0) {
const evse = chargingStation.getEvseStatus(evseId)
if (evse != null && !this.isEvseIdle(chargingStation, evse)) {
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle EVSE reset scheduled for EVSE ${evseId.toString()}, waiting for idle state`
)
- // Monitor EVSE for idle state and schedule reset when idle
this.scheduleEvseResetOnIdle(chargingStation, evseId)
return {
status: ResetStatusEnumType.Scheduled,
}
} else {
- // EVSE is idle, reset immediately
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle EVSE reset - EVSE ${evseId.toString()} is idle, resetting immediately`
)
}
}
} else {
- // Charging station OnIdle reset
if (!this.isChargingStationIdle(chargingStation)) {
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle reset scheduled, waiting for idle state`
status: ResetStatusEnumType.Scheduled,
}
} else {
- // Charging station is idle, reset immediately
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle reset - charging station is idle, resetting immediately`
)
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction request received on EVSE ${evseId?.toString() ?? 'undefined'} with idToken ${idToken.idToken} and remoteStartId ${remoteStartId.toString()}`
)
- // Validate that EVSE ID is provided
if (evseId == null) {
const errorMsg = 'EVSE ID is required for RequestStartTransaction'
logger.warn(
)
}
- // Get the first connector for this EVSE
const evse = chargingStation.getEvseStatus(evseId)
if (evse == null) {
const errorMsg = `EVSE ${evseId.toString()} does not exist on charging station`
)
}
- // Check if connector is available for a new transaction
if (connectorStatus.transactionStarted === true) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} already has an active transaction`
}
}
- // Authorize idToken
let isAuthorized = false
try {
isAuthorized = this.isIdTokenAuthorized(chargingStation, idToken)
}
}
- // Authorize groupIdToken if provided
if (groupIdToken != null) {
let isGroupAuthorized = false
try {
}
}
- // Validate charging profile if provided
if (chargingProfile != null) {
// OCPP 2.0.1 §2.10: RequestStartTransaction requires chargingProfilePurpose = TxProfile
if (
const transactionId = generateUUID()
try {
- // Set connector transaction state
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Setting transaction state for connector ${connectorId.toString()}, transaction ID: ${transactionId}`
)
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Transaction state set successfully for connector ${connectorId.toString()}`
)
- // Update connector status to Occupied
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Updating connector ${connectorId.toString()} status to Occupied`
)
evseId
)
- // Store charging profile if provided
if (chargingProfile != null) {
connectorStatus.chargingProfiles ??= []
connectorStatus.chargingProfiles.push(chargingProfile)
}
}
+ private handleRequestTriggerMessage (
+ chargingStation: ChargingStation,
+ commandPayload: OCPP20TriggerMessageRequest
+ ): OCPP20TriggerMessageResponse {
+ try {
+ const { evse, requestedMessage } = commandPayload
+
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: TriggerMessage received for '${requestedMessage}'${evse?.id !== undefined ? ` on EVSE ${evse.id.toString()}` : ''}`
+ )
+
+ if (evse?.id !== undefined && evse.id > 0) {
+ if (!chargingStation.hasEvses) {
+ return {
+ status: TriggerMessageStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Charging station does not support EVSEs',
+ reasonCode: ReasonCodeEnumType.UnsupportedRequest,
+ },
+ }
+ }
+ if (!chargingStation.evses.has(evse.id)) {
+ return {
+ status: TriggerMessageStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: `EVSE ${evse.id.toString()} does not exist`,
+ reasonCode: ReasonCodeEnumType.UnknownEvse,
+ },
+ }
+ }
+ }
+
+ switch (requestedMessage) {
+ case MessageTriggerEnumType.BootNotification:
+ // F06.FR.17: Reject BootNotification trigger if last boot was already Accepted
+ if (
+ chargingStation.bootNotificationResponse?.status === RegistrationStatusEnumType.ACCEPTED
+ ) {
+ return {
+ status: TriggerMessageStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'BootNotification already accepted (F06.FR.17)',
+ reasonCode: ReasonCodeEnumType.NotEnabled,
+ },
+ }
+ }
+ 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.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.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:
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Unsupported message trigger '${requestedMessage}'`
+ )
+ return {
+ status: TriggerMessageStatusEnumType.NotImplemented,
+ statusInfo: {
+ additionalInfo: `Message trigger '${requestedMessage}' is not implemented`,
+ reasonCode: ReasonCodeEnumType.UnsupportedRequest,
+ },
+ }
+ }
+ } catch (error) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error handling trigger message request:`,
+ error
+ )
+
+ return {
+ status: TriggerMessageStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Internal error occurred while processing trigger message request',
+ reasonCode: ReasonCodeEnumType.InternalError,
+ },
+ }
+ }
+ }
+
+ private async handleRequestUnlockConnector (
+ chargingStation: ChargingStation,
+ commandPayload: OCPP20UnlockConnectorRequest
+ ): Promise<OCPP20UnlockConnectorResponse> {
+ try {
+ const { connectorId, evseId } = commandPayload
+
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestUnlockConnector: UnlockConnector received for EVSE ${evseId.toString()} connector ${connectorId.toString()}`
+ )
+
+ if (!chargingStation.hasEvses) {
+ return {
+ status: UnlockStatusEnumType.UnknownConnector,
+ statusInfo: {
+ additionalInfo: 'Charging station does not support EVSEs',
+ reasonCode: ReasonCodeEnumType.UnsupportedRequest,
+ },
+ }
+ }
+
+ if (!chargingStation.evses.has(evseId)) {
+ return {
+ status: UnlockStatusEnumType.UnknownConnector,
+ statusInfo: {
+ additionalInfo: `EVSE ${evseId.toString()} does not exist`,
+ reasonCode: ReasonCodeEnumType.UnknownEvse,
+ },
+ }
+ }
+
+ const evseStatus = chargingStation.getEvseStatus(evseId)
+ if (evseStatus?.connectors.has(connectorId) !== true) {
+ return {
+ status: UnlockStatusEnumType.UnknownConnector,
+ statusInfo: {
+ additionalInfo: `Connector ${connectorId.toString()} does not exist on EVSE ${evseId.toString()}`,
+ reasonCode: ReasonCodeEnumType.UnknownConnectorId,
+ },
+ }
+ }
+
+ // F05.FR.02: Check for ongoing authorized transaction on the specified connector
+ const targetConnector = evseStatus.connectors.get(connectorId)
+ if (targetConnector?.transactionId != null) {
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestUnlockConnector: Ongoing authorized transaction on connector ${connectorId.toString()} of EVSE ${evseId.toString()}`
+ )
+ return {
+ status: UnlockStatusEnumType.OngoingAuthorizedTransaction,
+ statusInfo: {
+ additionalInfo: `Connector ${connectorId.toString()} on EVSE ${evseId.toString()} has an ongoing authorized transaction`,
+ reasonCode: ReasonCodeEnumType.TxInProgress,
+ },
+ }
+ }
+
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestUnlockConnector: Unlocking connector ${connectorId.toString()} on EVSE ${evseId.toString()}`
+ )
+
+ await sendAndSetConnectorStatus(
+ chargingStation,
+ connectorId,
+ ConnectorStatusEnum.Available,
+ evseId
+ )
+
+ return { status: UnlockStatusEnumType.Unlocked }
+ } catch (error) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestUnlockConnector: Error handling unlock connector request:`,
+ error
+ )
+
+ return {
+ status: UnlockStatusEnumType.UnlockFailed,
+ statusInfo: {
+ additionalInfo: 'Internal error occurred while processing unlock connector request',
+ reasonCode: ReasonCodeEnumType.InternalError,
+ },
+ }
+ }
+ }
+
/**
* Checks if a specific EVSE has any active transactions.
* @param evse - The EVSE to check
)
try {
- // Check if local authorization is disabled and remote authorization is also disabled
const localAuthListEnabled = chargingStation.getLocalAuthListEnabled()
const remoteAuthorizationEnabled = chargingStation.stationInfo?.remoteAuthorization ?? true
return true
}
- // 1. Check local authorization list first (if enabled)
if (localAuthListEnabled) {
const isLocalAuthorized = this.isIdTokenLocalAuthorized(chargingStation, idToken.idToken)
if (isLocalAuthorized) {
)
}
- // 2. For OCPP 2.0, if we can't authorize locally and remote auth is enabled,
- // we should validate through TransactionEvent mechanism or return false
- // In OCPP 2.0, there's no explicit remote authorize - it's handled during transaction events
+ // In OCPP 2.0, remote authorization happens during TransactionEvent processing
if (remoteAuthorizationEnabled) {
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Remote authorization enabled but no explicit remote auth mechanism in OCPP 2.0 - deferring to transaction event validation`
)
- // In OCPP 2.0, remote authorization happens during TransactionEvent processing
- // For now, we'll allow the transaction to proceed and let the CSMS validate during TransactionEvent
return true
}
- // 3. If we reach here, authorization failed
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${idToken.idToken} authorization failed - not found in local list and remote auth not configured`
)
`${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Error during authorization validation for ${idToken.idToken}:`,
error
)
- // Fail securely - deny access on authorization errors
return false
}
}
evseId: number,
hasActiveTransactions: boolean
): void {
- // Send status notification for unavailable EVSE
this.sendEvseStatusNotifications(
chargingStation,
evseId,
OCPP20ConnectorStatusEnumType.Unavailable
)
- // Schedule the actual EVSE reset
setImmediate(() => {
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.scheduleEvseReset: Executing EVSE ${evseId.toString()} reset${hasActiveTransactions ? ' after transaction termination' : ''}`
)
- // Reset EVSE - this would typically involve resetting the EVSE hardware/software
- // For now, we'll restore connectors to available status after a short delay
setTimeout(() => {
const evse = chargingStation.getEvseStatus(evseId)
if (evse) {
response: OCPP20GetBaseReportResponse
): Promise<void> {
const { reportBase, requestId } = request
- // Use cached report data if available (computed during GetBaseReport handling)
const cached = this.reportDataCache.get(requestId)
const reportData = cached ?? this.buildReportData(chargingStation, reportBase)
- // Fragment report data if needed (OCPP2 spec recommends max 100 items per message)
const maxItemsPerMessage = 100
const chunks = []
for (let i = 0; i < reportData.length; i += maxItemsPerMessage) {
chunks.push(reportData.slice(i, i + maxItemsPerMessage))
}
- // Ensure we always send at least one message
if (chunks.length === 0) {
chunks.push(undefined) // undefined means reportData will be omitted from the request
}
- // Send fragmented NotifyReport messages
for (let seqNo = 0; seqNo < chunks.length; seqNo++) {
const isLastChunk = seqNo === chunks.length - 1
const chunk = chunks[seqNo]
requestId,
seqNo,
tbc: !isLastChunk,
- // Only include reportData if chunk is defined and not empty
...(chunk !== undefined && chunk.length > 0 && { reportData: chunk }),
}
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`${chargingStation.logPrefix()} ${moduleName}.sendNotifyReportRequest: Completed NotifyReport for requestId ${requestId} with ${reportData.length} total items in ${chunks.length} message(s)`
)
- // Clear cache for requestId after successful completion
this.reportDataCache.delete(requestId)
}
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.terminateAllTransactions: Terminating transaction ${connector.transactionId.toString()} on connector ${connectorId.toString()}`
)
- // Use the proper OCPP 2.0 transaction termination method
terminationPromises.push(
OCPP20ServiceUtils.requestStopTransaction(chargingStation, connectorId, evseId).catch(
(error: unknown) => {
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.terminateEvseTransactions: Terminating transaction ${connector.transactionId.toString()} on connector ${connectorId.toString()}`
)
- // Use the proper OCPP 2.0 transaction termination method
terminationPromises.push(
OCPP20ServiceUtils.requestStopTransaction(chargingStation, connectorId, evseId).catch(
(error: unknown) => {
}
}
+ private toHandler (
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ handler: (chargingStation: ChargingStation, commandPayload: any) => JsonType | Promise<JsonType>
+ ): IncomingRequestHandler {
+ return handler as IncomingRequestHandler
+ }
+
private validateChargingProfile (
chargingStation: ChargingStation,
chargingProfile: OCPP20ChargingProfileType,
`${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: Validating charging profile ${chargingProfile.id.toString()} for EVSE ${evseId.toString()}`
)
- // Validate stack level range (OCPP 2.0 spec: 0-9)
if (chargingProfile.stackLevel < 0 || chargingProfile.stackLevel > 9) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: Invalid stack level ${chargingProfile.stackLevel.toString()}, must be 0-9`
return false
}
- // Validate charging profile ID is positive
if (chargingProfile.id <= 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: Invalid charging profile ID ${chargingProfile.id.toString()}, must be positive`
return false
}
- // Validate EVSE compatibility
if (!chargingStation.hasEvses && evseId > 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: EVSE ${evseId.toString()} not supported by this charging station`
return false
}
- // Validate charging schedules array is not empty
if (chargingProfile.chargingSchedule.length === 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: Charging profile must contain at least one charging schedule`
return false
}
- // Time constraints validation
const now = new Date()
if (chargingProfile.validFrom && chargingProfile.validTo) {
if (chargingProfile.validFrom >= chargingProfile.validTo) {
return false
}
- // Validate recurrency kind compatibility with profile kind
if (
chargingProfile.recurrencyKind &&
chargingProfile.chargingProfileKind !== OCPP20ChargingProfileKindEnumType.Recurring
return false
}
- // Validate each charging schedule
for (const [scheduleIndex, schedule] of chargingProfile.chargingSchedule.entries()) {
if (
!this.validateChargingSchedule(
}
}
- // Profile purpose specific validations
if (!this.validateChargingProfilePurpose(chargingStation, chargingProfile, evseId)) {
return false
}
switch (chargingProfile.chargingProfilePurpose) {
case OCPP20ChargingProfilePurposeEnumType.ChargingStationExternalConstraints:
- // ChargingStationExternalConstraints must apply to EVSE 0 (entire station)
if (evseId !== 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingProfilePurpose: ChargingStationExternalConstraints must apply to EVSE 0, got EVSE ${evseId.toString()}`
break
case OCPP20ChargingProfilePurposeEnumType.ChargingStationMaxProfile:
- // ChargingStationMaxProfile must apply to EVSE 0 (entire station)
if (evseId !== 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingProfilePurpose: ChargingStationMaxProfile must apply to EVSE 0, got EVSE ${evseId.toString()}`
break
case OCPP20ChargingProfilePurposeEnumType.TxDefaultProfile:
- // TxDefaultProfile can apply to EVSE 0 or specific EVSE
- // No additional constraints beyond general EVSE validation
break
case OCPP20ChargingProfilePurposeEnumType.TxProfile:
- // TxProfile must apply to a specific EVSE (not 0)
if (evseId === 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingProfilePurpose: TxProfile cannot apply to EVSE 0, must target specific EVSE`
return false
}
- // TxProfile should have a transactionId when used with active transaction
if (!chargingProfile.transactionId) {
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingProfilePurpose: TxProfile without transactionId - may be for future use`
`${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Validating schedule ${scheduleIndex.toString()} (ID: ${schedule.id.toString()}) in profile ${chargingProfile.id.toString()}`
)
- // Validate schedule ID is positive
if (schedule.id <= 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Invalid schedule ID ${schedule.id.toString()}, must be positive`
return false
}
- // Validate charging schedule periods array is not empty
if (schedule.chargingSchedulePeriod.length === 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Schedule must contain at least one charging schedule period`
return false
}
- // Validate charging rate unit is valid (type system ensures it exists)
if (!Object.values(OCPP20ChargingRateUnitEnumType).includes(schedule.chargingRateUnit)) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Invalid charging rate unit: ${schedule.chargingRateUnit}`
return false
}
- // Validate duration constraints
if (schedule.duration !== undefined && schedule.duration <= 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Schedule duration must be positive if specified`
return false
}
- // Validate minimum charging rate if specified
if (schedule.minChargingRate !== undefined && schedule.minChargingRate < 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Minimum charging rate cannot be negative`
return false
}
- // Validate start schedule time constraints
if (
schedule.startSchedule &&
chargingProfile.validFrom &&
return false
}
- // Validate charging schedule periods
let previousStartPeriod = -1
for (const [periodIndex, period] of schedule.chargingSchedulePeriod.entries()) {
- // Validate start period is non-negative and increasing
if (period.startPeriod < 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Period ${periodIndex.toString()} start time cannot be negative`
}
previousStartPeriod = period.startPeriod
- // Validate charging limit is positive
if (period.limit <= 0) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Period ${periodIndex.toString()} charging limit must be positive`
return false
}
- // Validate minimum charging rate constraint
if (schedule.minChargingRate !== undefined && period.limit < schedule.minChargingRate) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Period ${periodIndex.toString()} limit cannot be below minimum charging rate`
return false
}
- // Validate number of phases constraints
if (period.numberPhases !== undefined) {
if (period.numberPhases < 1 || period.numberPhases > 3) {
logger.warn(
return false
}
- // If phaseToUse is specified, validate it's within the number of phases
if (
period.phaseToUse !== undefined &&
(period.phaseToUse < 1 || period.phaseToUse > period.numberPhases)
return false
}
}
+
+/**
+ * OCPP 2.0+ Incoming Request Service - handles and processes all incoming requests
+ * from the Central System (CSMS) to the Charging Station using OCPP 2.0+ protocol.
+ *
+ * This service class is responsible for:
+ * - **Request Reception**: Receiving and routing OCPP 2.0+ incoming requests from CSMS
+ * - **Payload Validation**: Validating incoming request payloads against OCPP 2.0+ JSON schemas
+ * - **Request Processing**: Executing business logic for each OCPP 2.0+ request type
+ * - **Response Generation**: Creating and sending appropriate responses back to CSMS
+ * - **Enhanced Features**: Supporting advanced OCPP 2.0+ features like variable management
+ *
+ * Supported OCPP 2.0+ Incoming Request Types:
+ * - **Transaction Management**: RequestStartTransaction, RequestStopTransaction
+ * - **Configuration Management**: SetVariables, GetVariables, GetBaseReport
+ * - **Security Operations**: CertificatesSigned, SecurityEventNotification
+ * - **Charging Management**: SetChargingProfile, ClearChargingProfile, GetChargingProfiles
+ * - **Diagnostics**: TriggerMessage, GetLog, UpdateFirmware
+ * - **Display Management**: SetDisplayMessage, ClearDisplayMessage
+ * - **Customer Management**: ClearCache, SendLocalList
+ *
+ * Key OCPP 2.0+ Enhancements:
+ * - **Variable Model**: Advanced configuration through standardized variable system
+ * - **Enhanced Security**: Improved authentication and authorization mechanisms
+ * - **Rich Messaging**: Support for display messages and customer information
+ * - **Advanced Monitoring**: Comprehensive logging and diagnostic capabilities
+ * - **Flexible Charging**: Enhanced charging profile management and scheduling
+ *
+ * Architecture Pattern:
+ * This class extends OCPPIncomingRequestService and implements OCPP 2.0+-specific
+ * request handling logic. It integrates with the OCPP20VariableManager for advanced
+ * configuration management and maintains backward compatibility concepts while
+ * providing next-generation OCPP features.
+ *
+ * Validation Workflow:
+ * 1. Incoming request received and parsed
+ * 2. Payload validated against OCPP 2.0+ JSON schema
+ * 3. Request routed to appropriate handler method
+ * 4. Business logic executed with variable model integration
+ * 5. Response payload validated and sent back to CSMS
+ * @see {@link validatePayload} Request payload validation method
+ * @see {@link handleRequestStartTransaction} Example OCPP 2.0+ request handler
+ * @see {@link OCPP20VariableManager} Variable management integration
+ */
+
+/**
+ * Races a promise against a timeout, clearing the timer on settlement to avoid leaks.
+ * @param promise - The promise to race against the timeout
+ * @param ms - Timeout duration in milliseconds
+ * @param label - Descriptive label for the timeout error message
+ * @returns The resolved value of the original promise, or rejects with a timeout error
+ */
+function withTimeout<T> (promise: Promise<T>, ms: number, label: string): Promise<T> {
+ let timer: ReturnType<typeof setTimeout>
+ return Promise.race([
+ promise.finally(() => {
+ clearTimeout(timer)
+ }),
+ new Promise<never>((_resolve, reject) => {
+ timer = setTimeout(() => {
+ reject(new Error(`${label} timed out after ${ms.toString()}ms`))
+ }, ms)
+ }),
+ ])
+}
throw new OCPPError(ErrorType.INTERNAL_ERROR, errorMsg, OCPP20RequestCommand.SIGN_CERTIFICATE)
}
- // Build request payload
const requestPayload: OCPP20SignCertificateRequest = {
csr,
}
- // Add certificate type if specified
if (certificateType != null) {
requestPayload.certificateType = certificateType
}
OCPP20OptionalVariableName,
OCPP20RequestCommand,
type OCPP20StatusNotificationResponse,
+ type OCPP20TransactionEventResponse,
OCPPVersion,
RegistrationStatusEnumType,
type ResponseHandler,
OCPP20RequestCommand.STATUS_NOTIFICATION,
this.handleResponseStatusNotification.bind(this) as ResponseHandler,
],
+ [
+ OCPP20RequestCommand.TRANSACTION_EVENT,
+ this.handleResponseTransactionEvent.bind(this) as ResponseHandler,
+ ],
])
this.payloadValidatorFunctions = OCPP20ServiceUtils.createPayloadValidatorMap(
OCPP20ServiceUtils.createResponsePayloadConfigs(),
)
}
+ // TODO: currently log-only — future work should act on idTokenInfo.status (Invalid/Blocked → stop transaction)
+ // and chargingPriority (update charging profile priority) per OCPP 2.0.1 spec
+ private handleResponseTransactionEvent (
+ chargingStation: ChargingStation,
+ payload: OCPP20TransactionEventResponse
+ ): void {
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: TransactionEvent response received`
+ )
+ if (payload.totalCost != null) {
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Total cost: ${payload.totalCost.toString()}`
+ )
+ }
+ if (payload.chargingPriority != null) {
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Charging priority: ${payload.chargingPriority.toString()}`
+ )
+ }
+ if (payload.idTokenInfo != null) {
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken info status: ${payload.idTokenInfo.status}`
+ )
+ }
+ if (payload.updatedPersonalMessage != null) {
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Updated personal message format: ${payload.updatedPersonalMessage.format}, content: ${payload.updatedPersonalMessage.content}`
+ )
+ }
+ }
+
/**
* Validates incoming OCPP 2.0 response payload against JSON schema
* @param chargingStation - The charging station instance receiving the response
type OCPP20TransactionEventResponse,
OCPP20TriggerReasonEnumType,
OCPPVersion,
+ type UUIDv4,
} from '../../../types/index.js'
+import { OCPP20RequiredVariableName } from '../../../types/index.js'
import {
OCPP20MeasurandEnumType,
type OCPP20MeterValue,
type OCPP20TransactionEventOptions,
type OCPP20TransactionType,
} from '../../../types/ocpp/2.0/Transaction.js'
-import { logger, validateIdentifierString } from '../../../utils/index.js'
+import { convertToIntOrNaN, logger, validateIdentifierString } from '../../../utils/index.js'
+import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
import { OCPPServiceUtils, sendAndSetConnectorStatus } from '../OCPPServiceUtils.js'
import { OCPP20Constants } from './OCPP20Constants.js'
transactionId: string,
options?: OCPP20TransactionEventOptions
): OCPP20TransactionEventRequest
- // Implementation with union type + type guard
public static buildTransactionEvent (
chargingStation: ChargingStation,
eventType: OCPP20TransactionEventEnumType,
transactionId: string,
options: OCPP20TransactionEventOptions = {}
): OCPP20TransactionEventRequest {
- // Type guard: distinguish between context object and direct trigger reason
const isContext = typeof triggerReasonOrContext === 'object'
const triggerReason = isContext
? this.selectTriggerReason(eventType, triggerReasonOrContext)
throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
}
- // Get or validate EVSE ID
const evseId = options.evseId ?? chargingStation.getEvseIdByConnectorId(connectorId)
if (evseId == null) {
const errorMsg = `Cannot find EVSE ID for connector ${connectorId.toString()}`
throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
}
- // Get connector status and manage sequence number
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
if (connectorStatus == null) {
const errorMsg = `Cannot find connector status for connector ${connectorId.toString()}`
throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
}
- // Per-EVSE sequence number management (OCPP 2.0.1 Section 1.3.2.1)
- // Initialize sequence number to 0 for new transactions, or increment for existing
+ // Per-EVSE sequence number management (OCPP 2.0.1 §1.3.2.1)
if (connectorStatus.transactionSeqNo == null) {
- // First TransactionEvent for this EVSE/connector - start at 0
connectorStatus.transactionSeqNo = 0
} else {
- // Increment for subsequent TransactionEvents
connectorStatus.transactionSeqNo = connectorStatus.transactionSeqNo + 1
}
connectorStatus.transactionEvseSent = true
}
- // Build transaction info object
const transactionInfo: OCPP20TransactionType = {
- transactionId,
+ transactionId: transactionId as UUIDv4,
}
- // Add optional transaction info fields
if (options.chargingState !== undefined) {
transactionInfo.chargingState = options.chargingState
}
transactionInfo.remoteStartId = options.remoteStartId
}
- // Build the complete TransactionEvent request
const transactionEventRequest: OCPP20TransactionEventRequest = {
eventType,
seqNo: connectorStatus.transactionSeqNo,
OCPP20IncomingRequestCommand,
{ schemaPath: string }
][] => [
+ [
+ OCPP20IncomingRequestCommand.CERTIFICATE_SIGNED,
+ OCPP20ServiceUtils.PayloadValidatorConfig('CertificateSignedRequest.json'),
+ ],
[
OCPP20IncomingRequestCommand.CLEAR_CACHE,
OCPP20ServiceUtils.PayloadValidatorConfig('ClearCacheRequest.json'),
],
+ [
+ OCPP20IncomingRequestCommand.DELETE_CERTIFICATE,
+ OCPP20ServiceUtils.PayloadValidatorConfig('DeleteCertificateRequest.json'),
+ ],
[
OCPP20IncomingRequestCommand.GET_BASE_REPORT,
OCPP20ServiceUtils.PayloadValidatorConfig('GetBaseReportRequest.json'),
],
+ [
+ OCPP20IncomingRequestCommand.GET_INSTALLED_CERTIFICATE_IDS,
+ OCPP20ServiceUtils.PayloadValidatorConfig('GetInstalledCertificateIdsRequest.json'),
+ ],
[
OCPP20IncomingRequestCommand.GET_VARIABLES,
OCPP20ServiceUtils.PayloadValidatorConfig('GetVariablesRequest.json'),
],
+ [
+ OCPP20IncomingRequestCommand.INSTALL_CERTIFICATE,
+ OCPP20ServiceUtils.PayloadValidatorConfig('InstallCertificateRequest.json'),
+ ],
[
OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
OCPP20ServiceUtils.PayloadValidatorConfig('RequestStartTransactionRequest.json'),
OCPP20IncomingRequestCommand.SET_VARIABLES,
OCPP20ServiceUtils.PayloadValidatorConfig('SetVariablesRequest.json'),
],
+ [
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ OCPP20ServiceUtils.PayloadValidatorConfig('TriggerMessageRequest.json'),
+ ],
+ [
+ OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
+ OCPP20ServiceUtils.PayloadValidatorConfig('UnlockConnectorRequest.json'),
+ ],
]
/**
OCPP20IncomingRequestCommand,
{ schemaPath: string }
][] => [
+ [
+ OCPP20IncomingRequestCommand.CERTIFICATE_SIGNED,
+ OCPP20ServiceUtils.PayloadValidatorConfig('CertificateSignedResponse.json'),
+ ],
[
OCPP20IncomingRequestCommand.CLEAR_CACHE,
OCPP20ServiceUtils.PayloadValidatorConfig('ClearCacheResponse.json'),
],
+ [
+ OCPP20IncomingRequestCommand.DELETE_CERTIFICATE,
+ OCPP20ServiceUtils.PayloadValidatorConfig('DeleteCertificateResponse.json'),
+ ],
[
OCPP20IncomingRequestCommand.GET_BASE_REPORT,
OCPP20ServiceUtils.PayloadValidatorConfig('GetBaseReportResponse.json'),
],
+ [
+ OCPP20IncomingRequestCommand.GET_INSTALLED_CERTIFICATE_IDS,
+ OCPP20ServiceUtils.PayloadValidatorConfig('GetInstalledCertificateIdsResponse.json'),
+ ],
+ [
+ OCPP20IncomingRequestCommand.GET_VARIABLES,
+ OCPP20ServiceUtils.PayloadValidatorConfig('GetVariablesResponse.json'),
+ ],
+ [
+ OCPP20IncomingRequestCommand.INSTALL_CERTIFICATE,
+ OCPP20ServiceUtils.PayloadValidatorConfig('InstallCertificateResponse.json'),
+ ],
[
OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
OCPP20ServiceUtils.PayloadValidatorConfig('RequestStartTransactionResponse.json'),
OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
OCPP20ServiceUtils.PayloadValidatorConfig('RequestStopTransactionResponse.json'),
],
+ [
+ OCPP20IncomingRequestCommand.RESET,
+ OCPP20ServiceUtils.PayloadValidatorConfig('ResetResponse.json'),
+ ],
+ [
+ OCPP20IncomingRequestCommand.SET_VARIABLES,
+ OCPP20ServiceUtils.PayloadValidatorConfig('SetVariablesResponse.json'),
+ ],
+ [
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ OCPP20ServiceUtils.PayloadValidatorConfig('TriggerMessageResponse.json'),
+ ],
+ [
+ OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
+ OCPP20ServiceUtils.PayloadValidatorConfig('UnlockConnectorResponse.json'),
+ ],
]
/**
)
}
+ /**
+ * Read ItemsPerMessage and BytesPerMessage configuration limits
+ * Extracts configuration-reading logic shared between handleRequestGetVariables
+ * and handleRequestSetVariables to eliminate DRY violations.
+ * @param chargingStation - The charging station instance
+ * @returns Object with itemsLimit and bytesLimit (both fallback to 0 if not configured or invalid)
+ */
+ public static readMessageLimits (chargingStation: ChargingStation): {
+ bytesLimit: number
+ itemsLimit: number
+ } {
+ let itemsLimit = 0
+ let bytesLimit = 0
+ try {
+ const itemsCfg = getConfigurationKey(
+ chargingStation,
+ OCPP20RequiredVariableName.ItemsPerMessage
+ )?.value
+ const bytesCfg = getConfigurationKey(
+ chargingStation,
+ OCPP20RequiredVariableName.BytesPerMessage
+ )?.value
+ if (itemsCfg && /^\d+$/.test(itemsCfg)) {
+ itemsLimit = convertToIntOrNaN(itemsCfg)
+ }
+ if (bytesCfg && /^\d+$/.test(bytesCfg)) {
+ bytesLimit = convertToIntOrNaN(bytesCfg)
+ }
+ } catch (error) {
+ logger.debug(
+ `${chargingStation.logPrefix()} readMessageLimits: error reading message limits:`,
+ error
+ )
+ }
+ return { bytesLimit, itemsLimit }
+ }
+
public static async requestStopTransaction (
chargingStation: ChargingStation,
connectorId: number,
): Promise<GenericResponse> {
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
if (connectorStatus?.transactionStarted && connectorStatus.transactionId != null) {
- // OCPP 2.0 validation: transactionId should be a valid UUID format
let transactionId: string
if (typeof connectorStatus.transactionId === 'string') {
transactionId = connectorStatus.transactionId
connectorStatus.transactionSeqNo = (connectorStatus.transactionSeqNo ?? 0) + 1
- // FR: F03.FR.09 - Build final meter values for TransactionEvent(Ended)
+ // F03.FR.04: Build final meter values for TransactionEvent(Ended)
const finalMeterValues: OCPP20MeterValue[] = []
const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
if (energyValue >= 0) {
timestamp: new Date(),
transactionInfo: {
stoppedReason: OCPP20ReasonEnumType.Remote,
- transactionId,
+ transactionId: transactionId as UUIDv4,
},
triggerReason: OCPP20TriggerReasonEnumType.RemoteStop,
}
- // FR: F03.FR.09 - Include final meter values in TransactionEvent(Ended)
+ // F03.FR.04: Include final meter values in TransactionEvent(Ended)
if (finalMeterValues.length > 0) {
transactionEventRequest.meterValue = finalMeterValues
}
return { idTokenInfo: undefined }
}
- // Send the request to CSMS
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Sending TransactionEvent for trigger ${triggerReason}`
)
SetVariableStatusEnumType,
type VariableType,
} from '../../../types/index.js'
-import { StandardParametersKey } from '../../../types/ocpp/Configuration.js'
import { Constants, convertToIntOrNaN, logger } from '../../../utils/index.js'
import { type ChargingStation } from '../../ChargingStation.js'
import {
export class OCPP20VariableManager {
private static instance: null | OCPP20VariableManager = null
+ readonly #validComponentNames = new Set<string>(
+ Object.keys(VARIABLE_REGISTRY).map(k => k.split('::')[0])
+ )
+
private readonly invalidVariables = new Set<string>() // composite key (lower case)
private readonly maxSetOverrides = new Map<string, string>() // composite key (lower case)
private readonly minSetOverrides = new Map<string, string>() // composite key (lower case)
}
// Instance-scoped persistent variables are also auto-created when defaultValue is defined
const configurationKeyName = computeConfigurationKeyName(variableMetadata)
- const configurationKey = getConfigurationKey(
- chargingStation,
- configurationKeyName as unknown as StandardParametersKey
- )
+ const configurationKey = getConfigurationKey(chargingStation, configurationKeyName)
const variableKey = buildCaseInsensitiveCompositeKey(
variableMetadata.component,
variableMetadata.instance,
}
const defaultValue = variableMetadata.defaultValue
if (defaultValue != null) {
- addConfigurationKey(
- chargingStation,
- configurationKeyName as unknown as StandardParametersKey,
- defaultValue,
- undefined,
- { overwrite: false }
- )
+ addConfigurationKey(chargingStation, configurationKeyName, defaultValue, undefined, {
+ overwrite: false,
+ })
logger.info(
`${chargingStation.logPrefix()} Added missing configuration key for variable '${configurationKeyName}' with default '${defaultValue}'`
)
} else {
- // Mark invalid
this.invalidVariables.add(variableKey)
logger.error(
`${chargingStation.logPrefix()} Missing configuration key mapping and no default for variable '${configurationKeyName}'`
)
}
- // Handle MinSet / MaxSet attribute retrieval
if (resolvedAttributeType === AttributeEnumType.MinSet) {
if (variableMetadata.min === undefined && this.minSetOverrides.get(variableKey) == null) {
return this.rejectGet(
let valueSize: string | undefined
let reportingValueSize: string | undefined
if (!this.invalidVariables.has(valueSizeKey)) {
- valueSize = getConfigurationKey(
- chargingStation,
- OCPP20RequiredVariableName.ValueSize as unknown as StandardParametersKey
- )?.value
+ valueSize = getConfigurationKey(chargingStation, OCPP20RequiredVariableName.ValueSize)?.value
}
if (!this.invalidVariables.has(reportingValueSizeKey)) {
reportingValueSize = getConfigurationKey(
chargingStation,
- OCPP20RequiredVariableName.ReportingValueSize as unknown as StandardParametersKey
+ OCPP20RequiredVariableName.ReportingValueSize
)?.value
}
// Apply ValueSize first then ReportingValueSize
}
private isComponentValid (_chargingStation: ChargingStation, component: ComponentType): boolean {
- const supported = new Set<string>([
- OCPP20ComponentName.AuthCtrlr as string,
- OCPP20ComponentName.ChargingStation as string,
- OCPP20ComponentName.ClockCtrlr as string,
- OCPP20ComponentName.DeviceDataCtrlr as string,
- OCPP20ComponentName.OCPPCommCtrlr as string,
- OCPP20ComponentName.SampledDataCtrlr as string,
- OCPP20ComponentName.SecurityCtrlr as string,
- OCPP20ComponentName.TxCtrlr as string,
- ])
- return supported.has(component.name)
+ return this.#validComponentNames.has(component.name)
}
private isVariableSupported (component: ComponentType, variable: VariableType): boolean {
variableMetadata.mutability !== MutabilityEnumType.WriteOnly
) {
const configurationKeyName = computeConfigurationKeyName(variableMetadata)
- let cfg = getConfigurationKey(
- chargingStation,
- configurationKeyName as unknown as StandardParametersKey
- )
+ let cfg = getConfigurationKey(chargingStation, configurationKeyName)
if (cfg == null) {
addConfigurationKey(
chargingStation,
- configurationKeyName as unknown as StandardParametersKey,
+ configurationKeyName,
value, // Use the resolved default value
undefined,
{
overwrite: false,
}
)
- cfg = getConfigurationKey(
- chargingStation,
- configurationKeyName as unknown as StandardParametersKey
- )
+ cfg = getConfigurationKey(chargingStation, configurationKeyName)
}
if (cfg?.value) {
resolvedAttributeType === AttributeEnumType.MinSet ||
resolvedAttributeType === AttributeEnumType.MaxSet
) {
- // Only meaningful for integer data type
if (variableMetadata.dataType !== DataEnumType.integer) {
return this.rejectSet(
variable,
}
}
- // Actual attribute setting logic
if (variableMetadata.mutability === MutabilityEnumType.ReadOnly) {
return this.rejectSet(
variable,
if (!this.invalidVariables.has(configurationValueSizeKey)) {
configurationValueSizeRaw = getConfigurationKey(
chargingStation,
- OCPP20RequiredVariableName.ConfigurationValueSize as unknown as StandardParametersKey
+ OCPP20RequiredVariableName.ConfigurationValueSize
)?.value
}
if (!this.invalidVariables.has(valueSizeKey)) {
valueSizeRaw = getConfigurationKey(
chargingStation,
- OCPP20RequiredVariableName.ValueSize as unknown as StandardParametersKey
+ OCPP20RequiredVariableName.ValueSize
)?.value
}
const cfgLimit = convertToIntOrNaN(configurationValueSizeRaw ?? '')
let rebootRequired = false
const configurationKeyName = computeConfigurationKeyName(variableMetadata)
- const previousValue = getConfigurationKey(
- chargingStation,
- configurationKeyName as unknown as StandardParametersKey
- )?.value
+ const previousValue = getConfigurationKey(chargingStation, configurationKeyName)?.value
if (
variableMetadata.persistence === PersistenceEnumType.Persistent &&
variableMetadata.mutability !== MutabilityEnumType.WriteOnly
) {
- let configKey = getConfigurationKey(
- chargingStation,
- configurationKeyName as unknown as StandardParametersKey
- )
+ const configKey = getConfigurationKey(chargingStation, configurationKeyName)
if (configKey == null) {
- addConfigurationKey(
- chargingStation,
- configurationKeyName as unknown as StandardParametersKey,
- attributeValue,
- undefined,
- {
- overwrite: false,
- }
- )
- configKey = getConfigurationKey(
- chargingStation,
- configurationKeyName as unknown as StandardParametersKey
- )
+ addConfigurationKey(chargingStation, configurationKeyName, attributeValue, undefined, {
+ overwrite: false,
+ })
} else if (configKey.value !== attributeValue) {
- setConfigurationKeyValue(
- chargingStation,
- configurationKeyName as unknown as StandardParametersKey,
- attributeValue
- )
+ setConfigurationKeyValue(chargingStation, configurationKeyName, attributeValue)
}
rebootRequired =
(variableMetadata.rebootRequired === true ||
- getConfigurationKey(
- chargingStation,
- configurationKeyName as unknown as StandardParametersKey
- )?.reboot === true) &&
+ getConfigurationKey(chargingStation, configurationKeyName)?.reboot === true) &&
previousValue !== attributeValue
}
// Heartbeat & WS ping interval dynamic restarts
// Create typed wrapper for the mock
const sendMessageMock: SendMessageMock = {
fn: mockFn as unknown as SendMessageFn,
- mock: mockFn.mock as SendMessageMock['mock'],
+ mock: mockFn.mock as unknown as SendMessageMock['mock'],
}
return {
OCPP20ResetResponse,
OCPP20SetVariablesRequest,
OCPP20SetVariablesResponse,
+ OCPP20TriggerMessageRequest,
+ OCPP20TriggerMessageResponse,
+ OCPP20UnlockConnectorRequest,
+ OCPP20UnlockConnectorResponse,
ReportBaseEnumType,
- type ReportDataType,
+ ReportDataType,
} from '../../../../types/index.js'
import type { ChargingStation } from '../../../index.js'
import type { OCPP20IncomingRequestService } from '../OCPP20IncomingRequestService.js'
chargingStation: ChargingStation,
commandPayload: OCPP20RequestStopTransactionRequest
) => Promise<OCPP20RequestStopTransactionResponse>
+
+ handleRequestTriggerMessage: (
+ chargingStation: ChargingStation,
+ commandPayload: OCPP20TriggerMessageRequest
+ ) => OCPP20TriggerMessageResponse
+
+ handleRequestUnlockConnector: (
+ chargingStation: ChargingStation,
+ commandPayload: OCPP20UnlockConnectorRequest
+ ) => Promise<OCPP20UnlockConnectorResponse>
}
/**
handleRequestSetVariables: serviceImpl.handleRequestSetVariables.bind(service),
handleRequestStartTransaction: serviceImpl.handleRequestStartTransaction.bind(service),
handleRequestStopTransaction: serviceImpl.handleRequestStopTransaction.bind(service),
+ handleRequestTriggerMessage: serviceImpl.handleRequestTriggerMessage.bind(service),
+ handleRequestUnlockConnector: serviceImpl.handleRequestUnlockConnector.bind(service),
}
}
MeterValuePhase,
MeterValueUnit,
type OCPP16ChargePointStatus,
+ type OCPP16MeterValue,
type OCPP16SampledValue,
type OCPP16StatusNotificationRequest,
type OCPP20ConnectorStatusEnumType,
connectorId: number,
idTag: string
): Promise<boolean> => {
+ const stationOcppVersion = chargingStation.stationInfo?.ocppVersion
// OCPP 2.0+ always uses unified auth system
// OCPP 1.6 can optionally use unified or legacy system
const shouldUseUnified =
- chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_20 ||
- chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_201
+ stationOcppVersion === OCPPVersion.VERSION_20 || stationOcppVersion === OCPPVersion.VERSION_201
if (shouldUseUnified) {
try {
connectorId,
context: AuthContext.TRANSACTION_START,
identifier: {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- ocppVersion: chargingStation.stationInfo.ocppVersion!,
+ ocppVersion: stationOcppVersion,
type: IdentifierType.ID_TAG,
value: idTag,
},
}
}
-const addMainVoltageToMeterValue = (
+const addMainVoltageToMeterValue = <TSampledValue extends OCPP16SampledValue | OCPP20SampledValue>(
chargingStation: ChargingStation,
- meterValue: MeterValue,
- voltageData: { template: SampledValueTemplate; value: number }
+ meterValue: { sampledValue: TSampledValue[] },
+ voltageData: { template: SampledValueTemplate; value: number },
+ buildVersionedSampledValue: (
+ sampledValueTemplate: SampledValueTemplate,
+ value: number,
+ context?: MeterValueContext,
+ phase?: MeterValuePhase
+ ) => TSampledValue
): void => {
+ const stationInfo = chargingStation.stationInfo
+ if (stationInfo == null) {
+ return
+ }
if (
chargingStation.getNumberOfPhases() !== 3 ||
- (chargingStation.getNumberOfPhases() === 3 &&
- chargingStation.stationInfo?.mainVoltageMeterValues === true)
+ (chargingStation.getNumberOfPhases() === 3 && stationInfo.mainVoltageMeterValues === true)
) {
meterValue.sampledValue.push(
- buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
- voltageData.template,
- voltageData.value
- )
+ buildVersionedSampledValue(voltageData.template, voltageData.value)
)
}
}
-const addPhaseVoltageToMeterValue = (
+const addPhaseVoltageToMeterValue = <TSampledValue extends OCPP16SampledValue | OCPP20SampledValue>(
chargingStation: ChargingStation,
connectorId: number,
- meterValue: MeterValue,
+ meterValue: { sampledValue: TSampledValue[] },
mainVoltageData: { template: SampledValueTemplate; value: number },
- phase: number
+ phase: number,
+ buildVersionedSampledValue: (
+ sampledValueTemplate: SampledValueTemplate,
+ value: number,
+ context?: MeterValueContext,
+ phase?: MeterValuePhase
+ ) => TSampledValue
): void => {
+ const stationInfo = chargingStation.stationInfo
+ if (stationInfo == null) {
+ return
+ }
const phaseLineToNeutralValue = `L${phase.toString()}-N` as MeterValuePhase
const voltagePhaseLineToNeutralSampledValueTemplate = getSampledValueTemplate(
chargingStation,
)
}
meterValue.sampledValue.push(
- buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
+ buildVersionedSampledValue(
voltagePhaseLineToNeutralSampledValueTemplate ?? mainVoltageData.template,
voltagePhaseLineToNeutralMeasurandValue ?? mainVoltageData.value,
undefined,
)
}
-const addLineToLineVoltageToMeterValue = (
- chargingStation: ChargingStation,
- connectorId: number,
- meterValue: MeterValue,
- mainVoltageData: { template: SampledValueTemplate; value: number },
- phase: number
-): void => {
- if (chargingStation.stationInfo.phaseLineToLineVoltageMeterValues === true) {
- const phaseLineToLineValue = `L${phase.toString()}-L${
- (phase + 1) % chargingStation.getNumberOfPhases() !== 0
- ? ((phase + 1) % chargingStation.getNumberOfPhases()).toString()
- : chargingStation.getNumberOfPhases().toString()
- }` as MeterValuePhase
- const voltagePhaseLineToLineValueRounded = roundTo(
- Math.sqrt(chargingStation.getNumberOfPhases()) * chargingStation.getVoltageOut(),
- 2
- )
- const voltagePhaseLineToLineSampledValueTemplate = getSampledValueTemplate(
- chargingStation,
- connectorId,
- MeterValueMeasurand.VOLTAGE,
- phaseLineToLineValue
+const addLineToLineVoltageToMeterValue = <
+ TSampledValue extends OCPP16SampledValue | OCPP20SampledValue
+>(
+ chargingStation: ChargingStation,
+ connectorId: number,
+ meterValue: { sampledValue: TSampledValue[] },
+ mainVoltageData: { template: SampledValueTemplate; value: number },
+ phase: number,
+ buildVersionedSampledValue: (
+ sampledValueTemplate: SampledValueTemplate,
+ value: number,
+ context?: MeterValueContext,
+ phase?: MeterValuePhase
+ ) => TSampledValue
+ ): void => {
+ const stationInfo = chargingStation.stationInfo
+ if (stationInfo?.phaseLineToLineVoltageMeterValues !== true) {
+ return
+ }
+ const phaseLineToLineValue = `L${phase.toString()}-L${
+ (phase + 1) % chargingStation.getNumberOfPhases() !== 0
+ ? ((phase + 1) % chargingStation.getNumberOfPhases()).toString()
+ : chargingStation.getNumberOfPhases().toString()
+ }` as MeterValuePhase
+ const voltagePhaseLineToLineValueRounded = roundTo(
+ Math.sqrt(chargingStation.getNumberOfPhases()) * chargingStation.getVoltageOut(),
+ 2
+ )
+ const voltagePhaseLineToLineSampledValueTemplate = getSampledValueTemplate(
+ chargingStation,
+ connectorId,
+ MeterValueMeasurand.VOLTAGE,
+ phaseLineToLineValue
+ )
+ let voltagePhaseLineToLineMeasurandValue: number | undefined
+ if (voltagePhaseLineToLineSampledValueTemplate != null) {
+ const voltagePhaseLineToLineSampledValueTemplateValue = isNotEmptyString(
+ voltagePhaseLineToLineSampledValueTemplate.value
)
- let voltagePhaseLineToLineMeasurandValue: number | undefined
- if (voltagePhaseLineToLineSampledValueTemplate != null) {
- const voltagePhaseLineToLineSampledValueTemplateValue = isNotEmptyString(
- voltagePhaseLineToLineSampledValueTemplate.value
- )
- ? Number.parseInt(voltagePhaseLineToLineSampledValueTemplate.value)
- : voltagePhaseLineToLineValueRounded
- const fluctuationPhaseLineToLinePercent =
- voltagePhaseLineToLineSampledValueTemplate.fluctuationPercent ??
- Constants.DEFAULT_FLUCTUATION_PERCENT
- voltagePhaseLineToLineMeasurandValue = getRandomFloatFluctuatedRounded(
- voltagePhaseLineToLineSampledValueTemplateValue,
- fluctuationPhaseLineToLinePercent
- )
- }
- meterValue.sampledValue.push(
- buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
- voltagePhaseLineToLineSampledValueTemplate ?? mainVoltageData.template,
- voltagePhaseLineToLineMeasurandValue ?? voltagePhaseLineToLineValueRounded,
- undefined,
- phaseLineToLineValue
- )
+ ? Number.parseInt(voltagePhaseLineToLineSampledValueTemplate.value)
+ : voltagePhaseLineToLineValueRounded
+ const fluctuationPhaseLineToLinePercent =
+ voltagePhaseLineToLineSampledValueTemplate.fluctuationPercent ??
+ Constants.DEFAULT_FLUCTUATION_PERCENT
+ voltagePhaseLineToLineMeasurandValue = getRandomFloatFluctuatedRounded(
+ voltagePhaseLineToLineSampledValueTemplateValue,
+ fluctuationPhaseLineToLinePercent
)
}
+ meterValue.sampledValue.push(
+ buildVersionedSampledValue(
+ voltagePhaseLineToLineSampledValueTemplate ?? mainVoltageData.template,
+ voltagePhaseLineToLineMeasurandValue ?? voltagePhaseLineToLineValueRounded,
+ undefined,
+ phaseLineToLineValue
+ )
+ )
}
const buildEnergyMeasurandValue = (
debug = false
): MeterValue => {
const connector = chargingStation.getConnectorStatus(connectorId)
- let meterValue: MeterValue
switch (chargingStation.stationInfo?.ocppVersion) {
case OCPPVersion.VERSION_16: {
- meterValue = {
+ const meterValue: OCPP16MeterValue = {
sampledValue: [],
timestamp: new Date(),
}
+ const buildVersionedSampledValue = (
+ sampledValueTemplate: SampledValueTemplate,
+ value: number,
+ context?: MeterValueContext,
+ phase?: MeterValuePhase
+ ): OCPP16SampledValue => {
+ return buildSampledValueForOCPP16(sampledValueTemplate, value, context, phase)
+ }
// SoC measurand
const socMeasurand = buildSocMeasurandValue(chargingStation, connectorId)
if (socMeasurand != null) {
- const socSampledValue = buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
+ const socSampledValue = buildVersionedSampledValue(
socMeasurand.template,
socMeasurand.value
)
// Voltage measurand
const voltageMeasurand = buildVoltageMeasurandValue(chargingStation, connectorId)
if (voltageMeasurand != null) {
- addMainVoltageToMeterValue(chargingStation, meterValue, voltageMeasurand)
+ addMainVoltageToMeterValue(
+ chargingStation,
+ meterValue,
+ voltageMeasurand,
+ buildVersionedSampledValue
+ )
for (
let phase = 1;
chargingStation.getNumberOfPhases() === 3 && phase <= chargingStation.getNumberOfPhases();
connectorId,
meterValue,
voltageMeasurand,
- phase
+ phase,
+ buildVersionedSampledValue
)
addLineToLineVoltageToMeterValue(
chargingStation,
connectorId,
meterValue,
voltageMeasurand,
- phase
+ phase,
+ buildVersionedSampledValue
)
}
}
const connectorMinimumPower = Math.round(powerMeasurand.template.minimumValue ?? 0)
meterValue.sampledValue.push(
- buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
- powerMeasurand.template,
- powerMeasurand.values.allPhases
- )
+ buildVersionedSampledValue(powerMeasurand.template, powerMeasurand.values.allPhases)
)
const sampledValuesIndex = meterValue.sampledValue.length - 1
validatePowerMeasurandValue(
const phasePowerValue =
powerMeasurand.values[`L${phase.toString()}` as keyof MeasurandValues]
meterValue.sampledValue.push(
- buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
- phaseTemplate,
- phasePowerValue,
- undefined,
- phaseValue
- )
+ buildVersionedSampledValue(phaseTemplate, phasePowerValue, undefined, phaseValue)
)
const sampledValuesPerPhaseIndex = meterValue.sampledValue.length - 1
validatePowerMeasurandValue(
const connectorMinimumAmperage = currentMeasurand.template.minimumValue ?? 0
meterValue.sampledValue.push(
- buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
- currentMeasurand.template,
- currentMeasurand.values.allPhases
- )
+ buildVersionedSampledValue(currentMeasurand.template, currentMeasurand.values.allPhases)
)
const sampledValuesIndex = meterValue.sampledValue.length - 1
validateCurrentMeasurandValue(
) {
const phaseValue = `L${phase.toString()}` as MeterValuePhase
meterValue.sampledValue.push(
- buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
+ buildVersionedSampledValue(
currentMeasurand.perPhaseTemplates[
phaseValue as keyof MeasurandPerPhaseSampledValueTemplates
] ?? currentMeasurand.template,
updateConnectorEnergyValues(connector, energyMeasurand.value)
const unitDivider =
energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
- const energySampledValue = buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
+ const energySampledValue = buildVersionedSampledValue(
energyMeasurand.template,
roundTo(
chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId) /
sampledValue: [],
timestamp: new Date(),
}
+ const buildVersionedSampledValue = (
+ sampledValueTemplate: SampledValueTemplate,
+ value: number,
+ context?: MeterValueContext,
+ phase?: MeterValuePhase
+ ): OCPP20SampledValue => {
+ return buildSampledValueForOCPP20(sampledValueTemplate, value, context, phase)
+ }
// SoC measurand
const socMeasurand = buildSocMeasurandValue(chargingStation, connectorId)
if (socMeasurand != null) {
- const socSampledValue = buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
+ const socSampledValue = buildVersionedSampledValue(
socMeasurand.template,
socMeasurand.value
)
// Voltage measurand
const voltageMeasurand = buildVoltageMeasurandValue(chargingStation, connectorId)
if (voltageMeasurand != null) {
- addMainVoltageToMeterValue(chargingStation, meterValue, voltageMeasurand)
+ addMainVoltageToMeterValue(
+ chargingStation,
+ meterValue,
+ voltageMeasurand,
+ buildVersionedSampledValue
+ )
for (
let phase = 1;
chargingStation.getNumberOfPhases() === 3 && phase <= chargingStation.getNumberOfPhases();
connectorId,
meterValue,
voltageMeasurand,
- phase
+ phase,
+ buildVersionedSampledValue
)
addLineToLineVoltageToMeterValue(
chargingStation,
connectorId,
meterValue,
voltageMeasurand,
- phase
+ phase,
+ buildVersionedSampledValue
)
}
}
updateConnectorEnergyValues(connector, energyMeasurand.value)
const unitDivider =
energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
- const energySampledValue = buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
+ const energySampledValue = buildVersionedSampledValue(
energyMeasurand.template,
roundTo(
chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId) /
// Power.Active.Import measurand
const powerMeasurand = buildPowerMeasurandValue(chargingStation, connectorId)
if (powerMeasurand?.values.allPhases != null) {
- const powerSampledValue = buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
+ const powerSampledValue = buildVersionedSampledValue(
powerMeasurand.template,
powerMeasurand.values.allPhases
)
// Current.Import measurand
const currentMeasurand = buildCurrentMeasurandValue(chargingStation, connectorId)
if (currentMeasurand?.values.allPhases != null) {
- const currentSampledValue = buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
+ const currentSampledValue = buildVersionedSampledValue(
currentMeasurand.template,
currentMeasurand.values.allPhases
)
connectorId: number,
meterStop: number | undefined
): MeterValue => {
- let meterValue: MeterValue
- let sampledValueTemplate: SampledValueTemplate | undefined
- let unitDivider: number
+ const sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId)
+ if (sampledValueTemplate == null) {
+ throw new BaseError(
+ `Missing MeterValues for default measurand '${MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER}' in template on connector id ${connectorId.toString()}`
+ )
+ }
+ const unitDivider = sampledValueTemplate.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
switch (chargingStation.stationInfo?.ocppVersion) {
- case OCPPVersion.VERSION_16:
+ case OCPPVersion.VERSION_16: {
+ const meterValue: OCPP16MeterValue = {
+ sampledValue: [],
+ timestamp: new Date(),
+ }
+ meterValue.sampledValue.push(
+ buildSampledValueForOCPP16(
+ sampledValueTemplate,
+ roundTo((meterStop ?? 0) / unitDivider, 4),
+ MeterValueContext.TRANSACTION_END
+ )
+ )
+ return meterValue
+ }
case OCPPVersion.VERSION_20:
- case OCPPVersion.VERSION_201:
- meterValue = {
+ case OCPPVersion.VERSION_201: {
+ const meterValue: OCPP20MeterValue = {
sampledValue: [],
timestamp: new Date(),
}
- // Energy.Active.Import.Register measurand (default)
- sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId)
- unitDivider = sampledValueTemplate?.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
meterValue.sampledValue.push(
- buildSampledValue(
- chargingStation.stationInfo.ocppVersion,
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- sampledValueTemplate!,
+ buildSampledValueForOCPP20(
+ sampledValueTemplate,
roundTo((meterStop ?? 0) / unitDivider, 4),
MeterValueContext.TRANSACTION_END
)
)
return meterValue
+ }
default:
throw new OCPPError(
ErrorType.INTERNAL_ERROR,
}
}
+/**
+ * Builds an OCPP 1.6 sampled value from a template and measurement data.
+ * @param sampledValueTemplate - The sampled value template to use.
+ * @param value - The measured value.
+ * @param context - The reading context.
+ * @param phase - The phase of the measurement.
+ * @returns The built OCPP 1.6 sampled value.
+ */
+function buildSampledValueForOCPP16 (
+ sampledValueTemplate: SampledValueTemplate,
+ value: number,
+ context?: MeterValueContext,
+ phase?: MeterValuePhase
+): OCPP16SampledValue {
+ return buildSampledValue(
+ OCPPVersion.VERSION_16,
+ sampledValueTemplate,
+ value,
+ context,
+ phase
+ ) as OCPP16SampledValue
+}
+
+/**
+ * Builds an OCPP 2.0 sampled value from a template and measurement data.
+ * @param sampledValueTemplate - The sampled value template to use.
+ * @param value - The measured value.
+ * @param context - The reading context.
+ * @param phase - The phase of the measurement.
+ * @returns The built OCPP 2.0 sampled value.
+ */
+function buildSampledValueForOCPP20 (
+ sampledValueTemplate: SampledValueTemplate,
+ value: number,
+ context?: MeterValueContext,
+ phase?: MeterValuePhase
+): OCPP20SampledValue {
+ return buildSampledValue(
+ OCPPVersion.VERSION_20,
+ sampledValueTemplate,
+ value,
+ context,
+ phase
+ ) as OCPP20SampledValue
+}
+
const getMeasurandDefaultContext = (measurandType: MeterValueMeasurand): MeterValueContext => {
return MeterValueContext.SAMPLE_PERIODIC
}
await this.orm?.em.upsert({
...performanceStatistics,
statisticsData: Array.from(performanceStatistics.statisticsData, ([name, value]) => ({
- name,
...value,
+ measurementTimeSeries:
+ value.measurementTimeSeries != null ? [...value.measurementTimeSeries] : undefined,
+ name,
})),
} satisfies PerformanceRecord)
} catch (error) {
InstallCertificateStatusEnumType,
InstallCertificateUseEnumType,
Iso15118EVCertificateStatusEnumType,
+ MessageTriggerEnumType,
OCPP20ComponentName,
OCPP20UnitEnumType,
type OCSPRequestDataType,
ReportBaseEnumType,
ResetEnumType,
ResetStatusEnumType,
+ TriggerMessageStatusEnumType,
+ UnlockStatusEnumType,
} from './ocpp/2.0/Common.js'
export {
OCPP20LocationEnumType,
type OCPP20SetVariablesRequest,
type OCPP20SignCertificateRequest,
type OCPP20StatusNotificationRequest,
+ type OCPP20TriggerMessageRequest,
+ type OCPP20UnlockConnectorRequest,
} from './ocpp/2.0/Requests.js'
export type {
OCPP20BootNotificationResponse,
OCPP20SetVariablesResponse,
OCPP20SignCertificateResponse,
OCPP20StatusNotificationResponse,
+ OCPP20TriggerMessageResponse,
+ OCPP20UnlockConnectorResponse,
} from './ocpp/2.0/Responses.js'
export {
type ComponentType,
ChargingProfileKindType,
ChargingProfilePurposeType,
ChargingRateUnitType,
+ type ChargingSchedule,
type ChargingSchedulePeriod,
RecurrencyKindType,
} from './ocpp/ChargingProfile.js'
import type { OCPP16StandardParametersKey, OCPP16VendorParametersKey } from './Configuration.js'
import type { OCPP16DiagnosticsStatus } from './DiagnosticsStatus.js'
-export const enum OCPP16AvailabilityType {
+export enum OCPP16AvailabilityType {
Inoperative = 'Inoperative',
Operative = 'Operative',
}
-export const enum OCPP16FirmwareStatus {
+export enum OCPP16FirmwareStatus {
Downloaded = 'Downloaded',
DownloadFailed = 'DownloadFailed',
Downloading = 'Downloading',
Installing = 'Installing',
}
-export const enum OCPP16IncomingRequestCommand {
+export enum OCPP16IncomingRequestCommand {
CANCEL_RESERVATION = 'CancelReservation',
CHANGE_AVAILABILITY = 'ChangeAvailability',
CHANGE_CONFIGURATION = 'ChangeConfiguration',
StatusNotification = 'StatusNotification',
}
-export const enum OCPP16RequestCommand {
+export enum OCPP16RequestCommand {
AUTHORIZE = 'Authorize',
BOOT_NOTIFICATION = 'BootNotification',
DATA_TRANSFER = 'DataTransfer',
import type { OCPPConfigurationKey } from '../Configuration.js'
import type { OCPP16ChargingSchedule } from './ChargingProfile.js'
-export const enum OCPP16AvailabilityStatus {
+export enum OCPP16AvailabilityStatus {
ACCEPTED = 'Accepted',
REJECTED = 'Rejected',
SCHEDULED = 'Scheduled',
}
-export const enum OCPP16ChargingProfileStatus {
+export enum OCPP16ChargingProfileStatus {
ACCEPTED = 'Accepted',
NOT_SUPPORTED = 'NotSupported',
REJECTED = 'Rejected',
UNKNOWN = 'Unknown',
}
-export const enum OCPP16ConfigurationStatus {
+export enum OCPP16ConfigurationStatus {
ACCEPTED = 'Accepted',
NOT_SUPPORTED = 'NotSupported',
REBOOT_REQUIRED = 'RebootRequired',
REJECTED = 'Rejected',
}
-export const enum OCPP16DataTransferStatus {
+export enum OCPP16DataTransferStatus {
ACCEPTED = 'Accepted',
REJECTED = 'Rejected',
UNKNOWN_MESSAGE_ID = 'UnknownMessageId',
REJECTED = 'Rejected',
}
-export const enum OCPP16UnlockStatus {
+export enum OCPP16UnlockStatus {
NOT_SUPPORTED = 'NotSupported',
UNLOCK_FAILED = 'UnlockFailed',
UNLOCKED = 'Unlocked',
Failed = 'Failed',
}
+export enum MessageTriggerEnumType {
+ BootNotification = 'BootNotification',
+ FirmwareStatusNotification = 'FirmwareStatusNotification',
+ Heartbeat = 'Heartbeat',
+ LogStatusNotification = 'LogStatusNotification',
+ MeterValues = 'MeterValues',
+ PublishFirmwareStatusNotification = 'PublishFirmwareStatusNotification',
+ SignChargingStationCertificate = 'SignChargingStationCertificate',
+ SignCombinedCertificate = 'SignCombinedCertificate',
+ SignV2GCertificate = 'SignV2GCertificate',
+ StatusNotification = 'StatusNotification',
+ TransactionEvent = 'TransactionEvent',
+}
+
export enum OCPP20ComponentName {
// Physical and Logical Components
AccessBarrier = 'AccessBarrier',
Scheduled = 'Scheduled',
}
+export enum TriggerMessageStatusEnumType {
+ Accepted = 'Accepted',
+ NotImplemented = 'NotImplemented',
+ Rejected = 'Rejected',
+}
+
+export enum UnlockStatusEnumType {
+ OngoingAuthorizedTransaction = 'OngoingAuthorizedTransaction',
+ UnknownConnector = 'UnknownConnector',
+ Unlocked = 'Unlocked',
+ UnlockFailed = 'UnlockFailed',
+}
+
export interface CertificateHashDataChainType extends JsonObject {
certificateHashData: CertificateHashDataType
certificateType: GetCertificateIdUseEnumType
CustomDataType,
GetCertificateIdUseEnumType,
InstallCertificateUseEnumType,
+ MessageTriggerEnumType,
OCSPRequestDataType,
ReportBaseEnumType,
ResetEnumType,
import type {
OCPP20ChargingProfileType,
OCPP20ConnectorStatusEnumType,
+ OCPP20EVSEType,
OCPP20IdTokenType,
} from './Transaction.js'
import type {
ReportDataType,
} from './Variables.js'
-export const enum OCPP20IncomingRequestCommand {
+export enum OCPP20IncomingRequestCommand {
CERTIFICATE_SIGNED = 'CertificateSigned',
CLEAR_CACHE = 'ClearCache',
DELETE_CERTIFICATE = 'DeleteCertificate',
UNLOCK_CONNECTOR = 'UnlockConnector',
}
-export const enum OCPP20RequestCommand {
+export enum OCPP20RequestCommand {
BOOT_NOTIFICATION = 'BootNotification',
GET_15118_EV_CERTIFICATE = 'Get15118EVCertificate',
GET_CERTIFICATE_STATUS = 'GetCertificateStatus',
evseId: number
timestamp: Date
}
+
+export interface OCPP20TriggerMessageRequest extends JsonObject {
+ customData?: CustomDataType
+ evse?: OCPP20EVSEType
+ requestedMessage: MessageTriggerEnumType
+}
+
+export interface OCPP20UnlockConnectorRequest extends JsonObject {
+ connectorId: number
+ customData?: CustomDataType
+ evseId: number
+}
Iso15118EVCertificateStatusEnumType,
ResetStatusEnumType,
StatusInfoType,
+ TriggerMessageStatusEnumType,
+ UnlockStatusEnumType,
} from './Common.js'
import type { RequestStartStopStatusEnumType } from './Transaction.js'
import type { OCPP20GetVariableResultType, OCPP20SetVariableResultType } from './Variables.js'
}
export type OCPP20StatusNotificationResponse = EmptyObject
+
+export type { OCPP20TransactionEventResponse } from './Transaction.js'
+
+export interface OCPP20TriggerMessageResponse extends JsonObject {
+ customData?: CustomDataType
+ status: TriggerMessageStatusEnumType
+ statusInfo?: StatusInfoType
+}
+
+export interface OCPP20UnlockConnectorResponse extends JsonObject {
+ customData?: CustomDataType
+ status: UnlockStatusEnumType
+ statusInfo?: StatusInfoType
+}
import type { UUIDv4 } from '../../UUID.js'
import type { CustomDataType } from './Common.js'
import type { OCPP20MeterValue } from './MeterValues.js'
+import type { OCPP20IncomingRequestCommand } from './Requests.js'
export enum CostKindEnumType {
CarbonDioxideEmission = 'CarbonDioxideEmission',
-export type { OCPP20CommonDataModelType, OCPP20CustomDataType } from './Common.js'
export type { OCPP20MeterValue } from './MeterValues.js'
-export type { OCPP20RequestsType } from './Requests.js'
-export type { OCPP20ResponsesType } from './Responses.js'
export type {
OCPP20ChargingStateEnumType,
OCPP20EVSEType,
OCPP20TransactionEventOptions,
OCPP20TransactionEventRequest,
} from './Transaction.js'
-export type {
- OCPP20GetVariablesStatusEnumType,
- OCPP20VariableAttributeType,
- OCPP20VariableType,
-} from './Variables.js'
OCPP16ChargingProfileKindType,
OCPP16ChargingProfilePurposeType,
OCPP16ChargingRateUnitType,
+ type OCPP16ChargingSchedule,
type OCPP16ChargingSchedulePeriod,
OCPP16RecurrencyKindType,
} from './1.6/ChargingProfile.js'
type OCPP20ChargingProfileType,
OCPP20ChargingRateUnitEnumType,
type OCPP20ChargingSchedulePeriodType,
+ type OCPP20ChargingScheduleType,
OCPP20RecurrencyKindEnumType,
} from './2.0/Transaction.js'
export type ChargingProfile = OCPP16ChargingProfile | OCPP20ChargingProfileType
+export type ChargingSchedule = OCPP16ChargingSchedule | OCPP20ChargingScheduleType
+
export type ChargingSchedulePeriod = OCPP16ChargingSchedulePeriod | OCPP20ChargingSchedulePeriodType
export const ChargingProfilePurposeType = {
import type { JsonObject } from '../JsonType.js'
-export const enum GenericStatus {
+export enum GenericStatus {
Accepted = 'Accepted',
Rejected = 'Rejected',
}
import type { OCPPError } from '../../exception/index.js'
import type { JsonType } from '../JsonType.js'
import type { OCPP16MeterValuesRequest } from './1.6/MeterValues.js'
+import type { OCPP20MeterValuesRequest } from './2.0/MeterValues.js'
import type { MessageType } from './MessageType.js'
import { OCPP16DiagnosticsStatus } from './1.6/DiagnosticsStatus.js'
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type MessageTrigger = OCPP16MessageTrigger
-export type MeterValuesRequest = OCPP16MeterValuesRequest
+export type MeterValuesRequest = OCPP16MeterValuesRequest | OCPP20MeterValuesRequest
export type ResponseCallback = (payload: JsonType, requestPayload: JsonType) => void
import type { ChargingStation } from '../../charging-station/index.js'
import type { JsonType } from '../JsonType.js'
import type { OCPP16MeterValuesResponse } from './1.6/MeterValues.js'
+import type { OCPP20MeterValuesResponse } from './2.0/MeterValues.js'
import type { OCPP20BootNotificationResponse, OCPP20ClearCacheResponse } from './2.0/Responses.js'
import type { ErrorType } from './ErrorType.js'
import type { MessageType } from './MessageType.js'
export type HeartbeatResponse = OCPP16HeartbeatResponse
-export type MeterValuesResponse = OCPP16MeterValuesResponse
+// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
+export type MeterValuesResponse = OCPP16MeterValuesResponse | OCPP20MeterValuesResponse
export type Response = [MessageType.CALL_RESULT_MESSAGE, string, JsonType]
expect(station.inPendingState()).toBe(true)
// Act - transition from PENDING to ACCEPTED
- station.bootNotificationResponse.status = RegistrationStatusEnumType.ACCEPTED
- station.bootNotificationResponse.currentTime = new Date()
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ station.bootNotificationResponse!.status = RegistrationStatusEnumType.ACCEPTED
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ station.bootNotificationResponse!.currentTime = new Date()
// Assert
expect(station.inAcceptedState()).toBe(true)
expect(station.inPendingState()).toBe(true)
// Act - transition from PENDING to REJECTED
- station.bootNotificationResponse.status = RegistrationStatusEnumType.REJECTED
- station.bootNotificationResponse.currentTime = new Date()
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ station.bootNotificationResponse!.status = RegistrationStatusEnumType.REJECTED
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ station.bootNotificationResponse!.currentTime = new Date()
// Assert
expect(station.inRejectedState()).toBe(true)
station.start()
// Assert initial state
- expect(station.stopping).toBe(false)
+ expect((station as unknown as { stopping: boolean }).stopping).toBe(false)
// Act
await station.stop()
// Assert - after stop() completes, stopping should be false
- expect(station.stopping).toBe(false)
+ expect((station as unknown as { stopping: boolean }).stopping).toBe(false)
expect(station.started).toBe(false)
})
import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
-import { RegistrationStatusEnumType } from '../../src/types/index.js'
+import { OCPP16RequestCommand, RegistrationStatusEnumType } from '../../src/types/index.js'
import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
import { TEST_HEARTBEAT_INTERVAL_MS } from './ChargingStationTestConstants.js'
import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
let station: ChargingStation
beforeEach(() => {
- station = undefined
+ station = undefined as unknown as ChargingStation
})
afterEach(() => {
// Arrange
const result = createMockChargingStation({ connectorsCount: 1 })
station = result.station
+ const stationWithRetryCount = station as unknown as { wsConnectionRetryCount: number }
// Assert - Initial retry count should be 0
- expect(station.wsConnectionRetryCount).toBe(0)
+ expect(stationWithRetryCount.wsConnectionRetryCount).toBe(0)
// Act - Increment retry count manually (simulating reconnection attempt)
- station.wsConnectionRetryCount = 1
+ stationWithRetryCount.wsConnectionRetryCount = 1
// Assert - Count should be incremented
- expect(station.wsConnectionRetryCount).toBe(1)
+ expect(stationWithRetryCount.wsConnectionRetryCount).toBe(1)
})
await it('should support exponential backoff configuration', () => {
// Arrange
const result = createMockChargingStation({ connectorsCount: 1 })
station = result.station
- station.wsConnectionRetryCount = 5 // Simulate some retries
+ const stationWithRetryCount = station as unknown as { wsConnectionRetryCount: number }
+ stationWithRetryCount.wsConnectionRetryCount = 5
// Act - Reset retry count (as would happen on successful reconnection)
- station.wsConnectionRetryCount = 0
+ stationWithRetryCount.wsConnectionRetryCount = 0
// Assert
- expect(station.wsConnectionRetryCount).toBe(0)
+ expect(stationWithRetryCount.wsConnectionRetryCount).toBe(0)
})
// -------------------------------------------------------------------------
// Add a request with specific message ID to simulate duplicate
const messageId = 'duplicate-uuid-123'
- station.requests.set(messageId, ['callback', 'errorCallback', 'TestCommand'])
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ const responseCallback = (): void => {}
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ const errorCallback = (): void => {}
+ station.requests.set(messageId, [
+ responseCallback,
+ errorCallback,
+ OCPP16RequestCommand.HEARTBEAT,
+ {},
+ ])
// Assert - Request with duplicate ID exists
expect(station.requests.has(messageId)).toBe(true)
station = result.station
// Add some pending requests
- station.requests.set('req-1', ['callback1', 'errorCallback1', 'Command1'])
- station.requests.set('req-2', ['callback2', 'errorCallback2', 'Command2'])
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ const callback1 = (): void => {}
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ const errorCallback1 = (): void => {}
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ const callback2 = (): void => {}
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ const errorCallback2 = (): void => {}
+ station.requests.set('req-1', [
+ callback1,
+ errorCallback1,
+ OCPP16RequestCommand.BOOT_NOTIFICATION,
+ {},
+ ])
+ station.requests.set('req-2', [callback2, errorCallback2, OCPP16RequestCommand.HEARTBEAT, {}])
// Act - Cleanup station
cleanupChargingStation(station)
let station: ChargingStation
beforeEach(() => {
- station = undefined
+ station = undefined as unknown as ChargingStation
})
afterEach(() => {
station.bufferMessage(testMessage)
// Assert - Message should be queued but not sent
- expect(station.messageQueue.length).toBe(1)
- expect(station.messageQueue[0]).toBe(testMessage)
+ const stationWithQueue = station as unknown as { messageQueue: string[] }
+ expect(stationWithQueue.messageQueue.length).toBe(1)
+ expect(stationWithQueue.messageQueue[0]).toBe(testMessage)
expect(mocks.webSocket.sentMessages.length).toBe(0)
})
// Note: Due to async nature, the message may be sent or buffered depending on timing
// This test verifies the message is queued at minimum
- expect(station.messageQueue.length).toBeGreaterThanOrEqual(0)
+ const stationWithQueue = station as unknown as { messageQueue: string[] }
+ expect(stationWithQueue.messageQueue.length).toBeGreaterThanOrEqual(0)
})
await it('should flush messages in FIFO order when connection restored', () => {
station.bufferMessage(msg3)
// Assert - All messages should be buffered
- expect(station.messageQueue.length).toBe(3)
- expect(station.messageQueue[0]).toBe(msg1)
- expect(station.messageQueue[1]).toBe(msg2)
- expect(station.messageQueue[2]).toBe(msg3)
+ const stationWithQueue = station as unknown as { messageQueue: string[] }
+ expect(stationWithQueue.messageQueue.length).toBe(3)
+ expect(stationWithQueue.messageQueue[0]).toBe(msg1)
+ expect(stationWithQueue.messageQueue[1]).toBe(msg2)
+ expect(stationWithQueue.messageQueue[2]).toBe(msg3)
expect(mocks.webSocket.sentMessages.length).toBe(0)
})
}
// Assert - Verify FIFO order
- expect(station.messageQueue.length).toBe(5)
+ const stationWithQueue = station as unknown as { messageQueue: string[] }
+ expect(stationWithQueue.messageQueue.length).toBe(5)
for (let i = 0; i < messages.length; i++) {
- expect(station.messageQueue[i]).toBe(messages[i])
+ expect(stationWithQueue.messageQueue[i]).toBe(messages[i])
}
})
}
// Assert - All messages should be buffered
- expect(station.messageQueue.length).toBe(messageCount)
+ const stationWithQueue = station as unknown as { messageQueue: string[] }
+ expect(stationWithQueue.messageQueue.length).toBe(messageCount)
expect(mocks.webSocket.sentMessages.length).toBe(0)
// Verify first and last message are in correct positions
- expect(station.messageQueue[0]).toContain('msg-0')
- expect(station.messageQueue[messageCount - 1]).toContain(
+ expect(stationWithQueue.messageQueue[0]).toContain('msg-0')
+ expect(stationWithQueue.messageQueue[messageCount - 1]).toContain(
`msg-${(messageCount - 1).toString()}`
)
})
const initialSentCount = mocks.webSocket.sentMessages.length
// Assert - Message should remain buffered
- expect(station.messageQueue.length).toBe(1)
+ const stationWithQueue = station as unknown as { messageQueue: string[] }
+ expect(stationWithQueue.messageQueue.length).toBe(1)
expect(mocks.webSocket.sentMessages.length).toBe(initialSentCount)
})
// Act - Buffer message
station.bufferMessage(testMessage)
- const bufferedCount = station.messageQueue.length
+ const stationWithQueue = station as unknown as { messageQueue: string[] }
+ const bufferedCount = stationWithQueue.messageQueue.length
// Assert - Message is buffered
expect(bufferedCount).toBe(1)
// Now simulate successful send by manually removing (simulating what sendMessageBuffer does)
- if (station.messageQueue.length > 0) {
- station.messageQueue.shift()
+ if (stationWithQueue.messageQueue.length > 0) {
+ stationWithQueue.messageQueue.shift()
}
// Assert - Buffer should be cleared
- expect(station.messageQueue.length).toBe(0)
+ expect(stationWithQueue.messageQueue.length).toBe(0)
})
await it('should handle rapid buffer/reconnect cycles without message loss', () => {
}
// Assert - All messages from all cycles should be buffered in order
- expect(station.messageQueue.length).toBe(totalExpectedMessages)
- expect(station.messageQueue[0]).toContain('cycle-0-msg-0')
- expect(station.messageQueue[totalExpectedMessages - 1]).toContain(
+ const stationWithQueue = station as unknown as { messageQueue: string[] }
+ expect(stationWithQueue.messageQueue.length).toBe(totalExpectedMessages)
+ expect(stationWithQueue.messageQueue[0]).toContain('cycle-0-msg-0')
+ expect(stationWithQueue.messageQueue[totalExpectedMessages - 1]).toContain(
`cycle-${(cycleCount - 1).toString()}-msg-${(messagesPerCycle - 1).toString()}`
)
})
import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import { OCPPVersion } from '../../src/types/index.js'
import { standardCleanup, withMockTimers } from '../helpers/TestLifecycleHelpers.js'
import { TEST_HEARTBEAT_INTERVAL_MS, TEST_ID_TAG } from './ChargingStationTestConstants.js'
import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
await it('should create transaction updated interval when startTxUpdatedInterval() is called for OCPP 2.0', async t => {
await withMockTimers(t, ['setInterval'], () => {
// Arrange
- const result = createMockChargingStation({ connectorsCount: 2, ocppVersion: '2.0' })
+ const result = createMockChargingStation({
+ connectorsCount: 2,
+ ocppVersion: OCPPVersion.VERSION_20,
+ })
station = result.station
const connector1 = station.getConnectorStatus(1)
if (connector1 != null) {
await it('should clear transaction updated interval when stopTxUpdatedInterval() is called', async t => {
await withMockTimers(t, ['setInterval'], () => {
// Arrange
- const result = createMockChargingStation({ connectorsCount: 2, ocppVersion: '2.0' })
+ const result = createMockChargingStation({
+ connectorsCount: 2,
+ ocppVersion: OCPPVersion.VERSION_20,
+ })
station = result.station
const connector1 = station.getConnectorStatus(1)
if (connector1 != null) {
// Buffer messages while disconnected
station.bufferMessage('["2","uuid-2","StatusNotification",{}]')
- expect(station.messageQueue.length).toBe(1)
+ const stationWithQueue = station as unknown as { messageQueue: string[] }
+ expect(stationWithQueue.messageQueue.length).toBe(1)
cleanupChargingStation(station)
station = undefined
// For validation edge cases, we need to manually create invalid states
// since the factory is designed to create valid configurations
const { station: stationNoInfo } = createMockChargingStation({
- TEST_CHARGING_STATION_BASE_NAME,
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
})
stationNoInfo.stationInfo = undefined
// Arrange
// For validation edge cases, manually create empty stationInfo
const { station: stationEmptyInfo } = createMockChargingStation({
- TEST_CHARGING_STATION_BASE_NAME,
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
})
stationEmptyInfo.stationInfo = {} as ChargingStationInfo
await it('should throw when chargingStationId is undefined', () => {
// Arrange
const { station: stationMissingId } = createMockChargingStation({
- stationInfo: { chargingStationId: undefined, TEST_CHARGING_STATION_BASE_NAME },
- TEST_CHARGING_STATION_BASE_NAME,
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ stationInfo: { baseName: TEST_CHARGING_STATION_BASE_NAME, chargingStationId: undefined },
})
// Act & Assert
await it('should throw when chargingStationId is empty string', () => {
// Arrange
const { station: stationEmptyId } = createMockChargingStation({
- stationInfo: { chargingStationId: '', TEST_CHARGING_STATION_BASE_NAME },
- TEST_CHARGING_STATION_BASE_NAME,
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ stationInfo: { baseName: TEST_CHARGING_STATION_BASE_NAME, chargingStationId: '' },
})
// Act & Assert
await it('should throw when hashId is undefined', () => {
// Arrange
const { station: stationMissingHash } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: undefined,
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should throw when hashId is empty string', () => {
// Arrange
const { station: stationEmptyHash } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: '',
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should throw when templateIndex is undefined', () => {
// Arrange
const { station: stationMissingTemplate } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: getHashId(1, chargingStationTemplate),
templateIndex: undefined,
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should throw when templateIndex is zero', () => {
// Arrange
const { station: stationInvalidTemplate } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: getHashId(1, chargingStationTemplate),
templateIndex: 0,
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should throw when templateName is undefined', () => {
// Arrange
const { station: stationMissingName } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: getHashId(1, chargingStationTemplate),
templateIndex: 1,
templateName: undefined,
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should throw when templateName is empty string', () => {
// Arrange
const { station: stationEmptyName } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: getHashId(1, chargingStationTemplate),
templateIndex: 1,
templateName: '',
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should throw when maximumPower is undefined', () => {
// Arrange
const { station: stationMissingPower } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: getHashId(1, chargingStationTemplate),
maximumPower: undefined,
templateIndex: 1,
templateName: 'test-template.json',
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should throw when maximumPower is zero', () => {
// Arrange
const { station: stationInvalidPower } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: getHashId(1, chargingStationTemplate),
maximumPower: 0,
templateIndex: 1,
templateName: 'test-template.json',
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should throw when maximumAmperage is undefined', () => {
// Arrange
const { station: stationMissingAmperage } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: getHashId(1, chargingStationTemplate),
maximumAmperage: undefined,
maximumPower: 12000,
templateIndex: 1,
templateName: 'test-template.json',
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should throw when maximumAmperage is zero', () => {
// Arrange
const { station: stationInvalidAmperage } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: getHashId(1, chargingStationTemplate),
maximumAmperage: 0,
maximumPower: 12000,
templateIndex: 1,
templateName: 'test-template.json',
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should pass validation with complete valid configuration', () => {
// Arrange
const { station: validStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: getHashId(1, chargingStationTemplate),
maximumAmperage: 16,
maximumPower: 12000,
templateIndex: 1,
templateName: 'test-template.json',
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should throw for OCPP 2.0 without EVSE configuration', () => {
// Arrange
const { station: stationOcpp20 } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 0, // Ensure no EVSEs are created
evseConfiguration: { evsesCount: 0 },
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: getHashId(1, chargingStationTemplate),
maximumAmperage: 16,
ocppVersion: OCPPVersion.VERSION_20,
templateIndex: 1,
templateName: 'test-template.json',
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should throw for OCPP 2.0.1 without EVSE configuration', () => {
// Arrange
const { station: stationOcpp201 } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 0, // Ensure no EVSEs are created
evseConfiguration: { evsesCount: 0 },
stationInfo: {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
chargingStationId: getChargingStationId(1, chargingStationTemplate),
hashId: getHashId(1, chargingStationTemplate),
maximumAmperage: 16,
ocppVersion: OCPPVersion.VERSION_201,
templateIndex: 1,
templateName: 'test-template.json',
- TEST_CHARGING_STATION_BASE_NAME,
},
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
// Arrange
const warnMock = t.mock.method(logger, 'warn')
const { station: stationNotStarted } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
started: false,
starting: false,
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act
// Arrange
const warnMock = t.mock.method(logger, 'warn')
const { station: stationStarting } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
started: false,
starting: true,
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act
// Arrange
const warnMock = t.mock.method(logger, 'warn')
const { station: stationStarted } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
started: true,
starting: false,
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act
await it('should return Available when no bootStatus is defined', () => {
// Arrange
const { station: chargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 2,
- TEST_CHARGING_STATION_BASE_NAME,
})
const connectorStatus = {} as ConnectorStatus
await it('should return bootStatus from template when defined', () => {
// Arrange
const { station: chargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 2,
- TEST_CHARGING_STATION_BASE_NAME,
})
const connectorStatus = {
bootStatus: ConnectorStatusEnum.Unavailable,
await it('should return Unavailable when charging station is inoperative', () => {
// Arrange
const { station: chargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorDefaults: { availability: AvailabilityType.Inoperative },
connectorsCount: 2,
- TEST_CHARGING_STATION_BASE_NAME,
})
const connectorStatus = {
bootStatus: ConnectorStatusEnum.Available,
await it('should return Unavailable when connector is inoperative', () => {
// Arrange
const { station: chargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorDefaults: { availability: AvailabilityType.Inoperative },
connectorsCount: 2,
- TEST_CHARGING_STATION_BASE_NAME,
})
const connectorStatus = {
availability: AvailabilityType.Inoperative,
await it('should restore previous status when transaction is in progress', () => {
// Arrange
const { station: chargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 2,
- TEST_CHARGING_STATION_BASE_NAME,
})
const connectorStatus = {
bootStatus: ConnectorStatusEnum.Available,
await it('should use bootStatus over previous status when no transaction', () => {
// Arrange
const { station: chargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 2,
- TEST_CHARGING_STATION_BASE_NAME,
})
const connectorStatus = {
bootStatus: ConnectorStatusEnum.Available,
await it('should return false when no reservations exist (connector mode)', () => {
const { station: chargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 2,
- TEST_CHARGING_STATION_BASE_NAME,
})
expect(hasPendingReservations(chargingStation)).toBe(false)
})
await it('should return true when pending reservation exists (connector mode)', () => {
// Arrange
const { station: chargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 2,
- TEST_CHARGING_STATION_BASE_NAME,
})
const connectorStatus = chargingStation.connectors.get(1)
if (connectorStatus != null) {
await it('should return false when no reservations exist (EVSE mode)', () => {
// Arrange
const { station: chargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 2,
stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
- TEST_CHARGING_STATION_BASE_NAME,
})
// Act & Assert
await it('should return true when pending reservation exists (EVSE mode)', () => {
// Arrange
const { station: chargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 2,
stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
- TEST_CHARGING_STATION_BASE_NAME,
})
const firstEvse = chargingStation.evses.get(1)
const firstConnector = firstEvse?.connectors.values().next().value
await it('should return false when only expired reservations exist (EVSE mode)', () => {
// Arrange
const { station: chargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 2,
stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
- TEST_CHARGING_STATION_BASE_NAME,
})
const firstEvse = chargingStation.evses.get(1)
const firstConnector = firstEvse?.connectors.values().next().value
ChargingStationTemplate,
ConnectorStatus,
EvseStatus,
+ Reservation,
StopTransactionReason,
} from '../../../src/types/index.js'
}
/**
- * Extended ChargingStation interface for test mocking.
- * Combines all test-specific properties to avoid multiple interface definitions.
- * - Timer properties: for cleanup (wsPingSetInterval, flushMessageBufferSetInterval)
- * - Reset methods: for Reset command tests (getNumberOfRunningTransactions, reset)
+ * Mock type combining ChargingStation with optional test-specific properties.
*/
-export interface MockChargingStation extends ChargingStation {
- /** Private message buffer flush interval timer (accessed for cleanup) */
- flushMessageBufferSetInterval?: NodeJS.Timeout
- /** Mock method for getting number of running transactions (Reset tests) */
+export type MockChargingStation = ChargingStation & {
getNumberOfRunningTransactions?: () => number
- /** Mock method for reset operation (Reset tests) */
reset?: () => Promise<void>
- /** Private WebSocket ping interval timer (accessed for cleanup) */
- wsPingSetInterval?: NodeJS.Timeout
}
/**
* Provides typed access to mock handlers without eslint-disable comments
*/
export interface MockOCPPRequestService {
- requestHandler: () => Promise<unknown>
- sendError: () => Promise<unknown>
- sendResponse: () => Promise<unknown>
+ requestHandler: (...args: unknown[]) => Promise<unknown>
+ sendError: (...args: unknown[]) => Promise<unknown>
+ sendResponse: (...args: unknown[]) => Promise<unknown>
}
/**
station.heartbeatSetInterval = undefined
}
- // Stop WebSocket ping timer (private, accessed for cleanup via MockChargingStation)
- const stationInternal = station as MockChargingStation
- if (stationInternal.wsPingSetInterval != null) {
- clearInterval(stationInternal.wsPingSetInterval)
- stationInternal.wsPingSetInterval = undefined
+ // Stop WebSocket ping timer (private, accessed for cleanup via typed cast)
+ const stationWithWsTimer = station as unknown as { wsPingSetInterval?: NodeJS.Timeout }
+ if (stationWithWsTimer.wsPingSetInterval != null) {
+ clearInterval(stationWithWsTimer.wsPingSetInterval)
+ stationWithWsTimer.wsPingSetInterval = undefined
}
- // Stop message buffer flush timer (private, accessed for cleanup via MockChargingStation)
- if (stationInternal.flushMessageBufferSetInterval != null) {
- clearInterval(stationInternal.flushMessageBufferSetInterval)
- stationInternal.flushMessageBufferSetInterval = undefined
+ // Stop message buffer flush timer (private, accessed for cleanup via typed cast)
+ const stationWithFlushTimer = station as unknown as {
+ flushMessageBufferSetInterval?: NodeJS.Timeout
+ }
+ if (stationWithFlushTimer.flushMessageBufferSetInterval != null) {
+ clearInterval(stationWithFlushTimer.flushMessageBufferSetInterval)
+ stationWithFlushTimer.flushMessageBufferSetInterval = undefined
}
// Close WebSocket connection
}
const connectorStatus = this.getConnectorStatus(reservation.connectorId as number)
if (connectorStatus != null) {
- connectorStatus.reservation = reservation
+ connectorStatus.reservation = reservation as unknown as Reservation
}
},
automaticTransactionGenerator: undefined,
currentTime: new Date(),
interval: heartbeatInterval,
status: bootNotificationStatus,
- },
+ } as
+ | undefined
+ | {
+ currentTime: Date
+ interval: number
+ status: RegistrationStatusEnumType
+ },
bufferMessage (message: string): void {
this.messageQueue.push(message)
idTagsCache: mockIdTagsCache as unknown,
inAcceptedState (): boolean {
- return this.bootNotificationResponse.status === RegistrationStatusEnumType.ACCEPTED
+ return this.bootNotificationResponse?.status === RegistrationStatusEnumType.ACCEPTED
},
// Core properties
index,
inPendingState (): boolean {
- return this.bootNotificationResponse.status === RegistrationStatusEnumType.PENDING
+ return this.bootNotificationResponse?.status === RegistrationStatusEnumType.PENDING
},
inRejectedState (): boolean {
- return this.bootNotificationResponse.status === RegistrationStatusEnumType.REJECTED
+ return this.bootNotificationResponse?.status === RegistrationStatusEnumType.REJECTED
},
inUnknownState (): boolean {
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return this.bootNotificationResponse?.status == null
},
// Simulate async stop behavior (immediate resolution for tests)
await Promise.resolve()
this.closeWSConnection()
- delete this.bootNotificationResponse
+ this.bootNotificationResponse = undefined
this.started = false
this.stopping = false
}
const TEST_CERT_TYPE = InstallCertificateUseEnumType.CSMSRootCertificate
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- kept for future assertions
-const _EXPECTED_HASH_DATA: CertificateHashDataType = {
+const _EXPECTED_HASH_DATA = {
hashAlgorithm: HashAlgorithmEnumType.SHA256,
issuerKeyHash: expect.stringMatching(/^[a-fA-F0-9]+$/),
issuerNameHash: expect.stringMatching(/^[a-fA-F0-9]+$/),
import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type { ChargingStationWithCertificateManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20CertificateManager.js'
import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
VALID_CERTIFICATE_CHAIN,
VALID_PEM_CERTIFICATE,
} from './OCPP20CertificateTestData.js'
-import { createMockCertificateManager } from './OCPP20TestUtils.js'
+import {
+ createMockCertificateManager,
+ createStationWithCertificateManager,
+} from './OCPP20TestUtils.js'
await describe('I04 - CertificateSigned', async () => {
let station: ChargingStation
+ let stationWithCertManager: ChargingStationWithCertificateManager
let incomingRequestService: OCPP20IncomingRequestService
let testableService: ReturnType<typeof createTestableIncomingRequestService>
websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
})
station = mockStation
- station.certificateManager = createMockCertificateManager()
+ stationWithCertManager = createStationWithCertificateManager(
+ station,
+ createMockCertificateManager()
+ )
station.closeWSConnection = mock.fn()
incomingRequestService = new OCPP20IncomingRequestService()
testableService = createTestableIncomingRequestService(incomingRequestService)
})
await describe('Valid Certificate Chain Installation', async () => {
await it('should accept valid certificate chain', async () => {
- station.certificateManager = createMockCertificateManager({
+ stationWithCertManager.certificateManager = createMockCertificateManager({
storeCertificateResult: true,
})
})
await it('should accept single certificate (no chain)', async () => {
- station.certificateManager = createMockCertificateManager({
+ stationWithCertManager.certificateManager = createMockCertificateManager({
storeCertificateResult: true,
})
const mockCertManager = createMockCertificateManager({
storeCertificateResult: true,
})
- station.certificateManager = mockCertManager
+ stationWithCertManager.certificateManager = mockCertManager
const mockCloseWSConnection = mock.fn()
station.closeWSConnection = mockCloseWSConnection
const mockCertManager = createMockCertificateManager({
storeCertificateResult: true,
})
- station.certificateManager = mockCertManager
+ stationWithCertManager.certificateManager = mockCertManager
const mockCloseWSConnection = mock.fn()
station.closeWSConnection = mockCloseWSConnection
websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
})
- // Ensure certificateManager is undefined (not present)
- delete stationWithoutCertManager.certificateManager
+ // certificateManager is not set on this station (not present by default)
const request: OCPP20CertificateSignedRequest = {
certificateChain: VALID_PEM_CERTIFICATE,
await describe('Storage Failure Handling', async () => {
await it('should return Rejected status when storage fails', async () => {
- station.certificateManager = createMockCertificateManager({
+ stationWithCertManager.certificateManager = createMockCertificateManager({
storeCertificateResult: false,
})
})
await it('should return Rejected status when storage throws error', async () => {
- station.certificateManager = createMockCertificateManager({
+ stationWithCertManager.certificateManager = createMockCertificateManager({
storeCertificateError: new Error('Storage full'),
})
await describe('Response Structure Validation', async () => {
await it('should return response matching CertificateSignedResponse schema', async () => {
- station.certificateManager = createMockCertificateManager({
+ stationWithCertManager.certificateManager = createMockCertificateManager({
storeCertificateResult: true,
})
await describe('Valid Certificate Deletion', async () => {
await it('should accept deletion of existing certificate', async () => {
stationWithCertManager.certificateManager = createMockCertificateManager({
- deleteCertificateResult: { status: 'Accepted' },
+ deleteCertificateResult: { status: DeleteCertificateStatusEnumType.Accepted },
})
const request: OCPP20DeleteCertificateRequest = {
await it('should accept deletion with SHA384 hash algorithm', async () => {
stationWithCertManager.certificateManager = createMockCertificateManager({
- deleteCertificateResult: { status: 'Accepted' },
+ deleteCertificateResult: { status: DeleteCertificateStatusEnumType.Accepted },
})
const request: OCPP20DeleteCertificateRequest = {
await it('should accept deletion with SHA512 hash algorithm', async () => {
stationWithCertManager.certificateManager = createMockCertificateManager({
- deleteCertificateResult: { status: 'Accepted' },
+ deleteCertificateResult: { status: DeleteCertificateStatusEnumType.Accepted },
})
const request: OCPP20DeleteCertificateRequest = {
await describe('Certificate Not Found', async () => {
await it('should return NotFound for non-existent certificate', async () => {
stationWithCertManager.certificateManager = createMockCertificateManager({
- deleteCertificateResult: { status: 'NotFound' },
+ deleteCertificateResult: { status: DeleteCertificateStatusEnumType.NotFound },
})
const request: OCPP20DeleteCertificateRequest = {
await describe('Response Structure Validation', async () => {
await it('should return response matching DeleteCertificateResponse schema', async () => {
stationWithCertManager.certificateManager = createMockCertificateManager({
- deleteCertificateResult: { status: 'Accepted' },
+ deleteCertificateResult: { status: DeleteCertificateStatusEnumType.Accepted },
})
const request: OCPP20DeleteCertificateRequest = {
} from './OCPP20TestUtils.js'
await describe('B06 - Get Variables', async () => {
- let station: ReturnType<typeof createMockChargingStation>
+ let station: ReturnType<typeof createMockChargingStation>['station']
let incomingRequestService: OCPP20IncomingRequestService
beforeEach(() => {
stationWithCertManager.certificateManager = createMockCertificateManager({
storeCertificateResult: false,
})
- mockStation.stationInfo.validateCertificateExpiry = true
+ ;(mockStation.stationInfo as Record<string, unknown>).validateCertificateExpiry = true
const request: OCPP20InstallCertificateRequest = {
certificate: EXPIRED_PEM_CERTIFICATE,
expect(response.statusInfo).toBeDefined()
expect(response.statusInfo?.reasonCode).toBeDefined()
- delete mockStation.stationInfo.validateCertificateExpiry
+ delete (mockStation.stationInfo as Record<string, unknown>).validateCertificateExpiry
})
})
*/
import { expect } from '@std/expect'
+import assert from 'node:assert'
import { afterEach, beforeEach, describe, it } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
})
await it('should not modify connector status before authorization', () => {
+ assert(mockStation != null)
// Given: Connector in initial state
// Then: Connector status should remain unchanged before processing
const connectorStatus = mockStation.getConnectorStatus(1)
await describe('G03.FR.03.005 - Remote start on occupied connector', async () => {
await it('should detect existing transaction on connector', () => {
+ assert(mockStation != null)
// Given: Connector with active transaction
mockStation.getConnectorStatus = (): ConnectorStatus => ({
availability: OperationalStatusEnumType.Operative,
})
await it('should preserve existing transaction details', () => {
+ assert(mockStation != null)
// Given: Existing transaction details
const existingTransactionId = 'existing-tx-456'
const existingTokenTag = 'EXISTING_TOKEN_002'
})
await it('should support OCPP 2.0.1 version', () => {
+ assert(mockStation != null)
// Given: Station with OCPP 2.0.1
expect(mockStation.stationInfo?.ocppVersion).toBe(OCPPVersion.VERSION_201)
})
})
await it('should have valid charging station configuration', () => {
+ assert(mockStation != null)
// Then: Charging station should have required configuration
expect(mockStation).toBeDefined()
expect(mockStation.evses).toBeDefined()
import type { OCPP20RequestStartTransactionRequest } from '../../../../src/types/index.js'
import type {
OCPP20ChargingProfileType,
- OCPP20ChargingRateUnitType,
+ OCPP20ChargingRateUnitEnumType,
} from '../../../../src/types/ocpp/2.0/Transaction.js'
import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
chargingProfilePurpose: OCPP20ChargingProfilePurposeEnumType.TxProfile,
chargingSchedule: [
{
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- chargingRateUnit: 'A' as OCPP20ChargingRateUnitType,
+ chargingRateUnit: 'A' as OCPP20ChargingRateUnitEnumType,
chargingSchedulePeriod: [
{
limit: 30,
chargingProfilePurpose: OCPP20ChargingProfilePurposeEnumType.TxDefaultProfile,
chargingSchedule: [
{
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- chargingRateUnit: 'A' as OCPP20ChargingRateUnitType,
+ chargingRateUnit: 'A' as OCPP20ChargingRateUnitEnumType,
chargingSchedulePeriod: [
{
limit: 25,
chargingProfilePurpose: OCPP20ChargingProfilePurposeEnumType.TxProfile,
chargingSchedule: [
{
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- chargingRateUnit: 'A' as OCPP20ChargingRateUnitType,
+ chargingRateUnit: 'A' as OCPP20ChargingRateUnitEnumType,
chargingSchedulePeriod: [
{
limit: 32,
import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { VARIABLE_REGISTRY } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableRegistry.js'
import {
FirmwareStatus,
+ OCPP20ComponentName,
ReasonCodeEnumType,
ResetEnumType,
ResetStatusEnumType,
await describe('Firmware Update Blocking', async () => {
// FR: B12.FR.04.01 - Station NOT idle during firmware operations
- await it('should return Scheduled when firmware is Downloading', async () => {
+ await it('should return Rejected/FwUpdateInProgress when firmware is Downloading', async () => {
const station = createTestStation()
- // Firmware status: Downloading
- Object.assign(station.stationInfo, {
+ // Firmware check runs before OnIdle idle-state logic — always returns Rejected
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ Object.assign(station.stationInfo!, {
firmwareStatus: FirmwareStatus.Downloading,
})
)
expect(response).toBeDefined()
- expect(response.status).toBe(ResetStatusEnumType.Scheduled)
+ expect(response.status).toBe(ResetStatusEnumType.Rejected)
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.FwUpdateInProgress)
})
- await it('should return Scheduled when firmware is Downloaded', async () => {
+ await it('should return Rejected/FwUpdateInProgress when firmware is Downloaded', async () => {
const station = createTestStation()
- // Firmware status: Downloaded
- Object.assign(station.stationInfo, {
+ // Firmware check runs before OnIdle idle-state logic — always returns Rejected
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ Object.assign(station.stationInfo!, {
firmwareStatus: FirmwareStatus.Downloaded,
})
)
expect(response).toBeDefined()
- expect(response.status).toBe(ResetStatusEnumType.Scheduled)
+ expect(response.status).toBe(ResetStatusEnumType.Rejected)
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.FwUpdateInProgress)
})
- await it('should return Scheduled when firmware is Installing', async () => {
+ await it('should return Rejected/FwUpdateInProgress when firmware is Installing', async () => {
const station = createTestStation()
- // Firmware status: Installing
- Object.assign(station.stationInfo, {
+ // Firmware check runs before OnIdle idle-state logic — always returns Rejected
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ Object.assign(station.stationInfo!, {
firmwareStatus: FirmwareStatus.Installing,
})
)
expect(response).toBeDefined()
- expect(response.status).toBe(ResetStatusEnumType.Scheduled)
+ expect(response.status).toBe(ResetStatusEnumType.Rejected)
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.FwUpdateInProgress)
})
await it('should return Accepted when firmware is Installed (complete)', async () => {
const station = createTestStation()
// Firmware status: Installed (complete)
- Object.assign(station.stationInfo, {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ Object.assign(station.stationInfo!, {
firmwareStatus: FirmwareStatus.Installed,
})
await it('should return Accepted when firmware status is Idle', async () => {
const station = createTestStation()
// Firmware status: Idle
- Object.assign(station.stationInfo, {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ Object.assign(station.stationInfo!, {
firmwareStatus: FirmwareStatus.Idle,
})
// No transactions
station.getNumberOfRunningTransactions = () => 0
// No firmware update
- Object.assign(station.stationInfo, {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ Object.assign(station.stationInfo!, {
firmwareStatus: FirmwareStatus.Idle,
})
// No reservations (default)
const station = createTestStation()
// Transaction active
station.getNumberOfRunningTransactions = () => 1
- // Firmware update in progress
- Object.assign(station.stationInfo, {
- firmwareStatus: FirmwareStatus.Downloading,
- })
// Non-expired reservation
const futureExpiryDate = new Date(Date.now() + TEST_ONE_HOUR_MS)
const mockReservation: Partial<Reservation> = {
})
})
})
+
+ await describe('AllowReset variable checks', async () => {
+ const ALLOW_RESET_KEY = `${OCPP20ComponentName.EVSE as string}::AllowReset`
+ let savedDefaultValue: string | undefined
+
+ beforeEach(() => {
+ savedDefaultValue = VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue
+ })
+
+ afterEach(() => {
+ VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = savedDefaultValue
+ })
+
+ await it('should reject with NotEnabled when AllowReset is false', async () => {
+ const station = ResetTestFixtures.createStandardStation()
+ VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = 'false'
+ const request: OCPP20ResetRequest = { type: ResetEnumType.Immediate }
+ const response = await testableService.handleRequestReset(station, request)
+ expect(response.status).toBe(ResetStatusEnumType.Rejected)
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.NotEnabled)
+ })
+
+ await it('should proceed normally when AllowReset is true', async () => {
+ const station = ResetTestFixtures.createStandardStation()
+ VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = 'true'
+ const request: OCPP20ResetRequest = { type: ResetEnumType.Immediate }
+ const response = await testableService.handleRequestReset(station, request)
+ expect(response.status).toBe(ResetStatusEnumType.Accepted)
+ })
+
+ await it('should proceed normally when AllowReset defaultValue is undefined', async () => {
+ const station = ResetTestFixtures.createStandardStation()
+ VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = undefined
+ const request: OCPP20ResetRequest = { type: ResetEnumType.Immediate }
+ const response = await testableService.handleRequestReset(station, request)
+ expect(response.status).toBe(ResetStatusEnumType.Accepted)
+ })
+ })
})
--- /dev/null
+/**
+ * @file Tests for OCPP20IncomingRequestService TriggerMessage
+ * @description Unit tests for OCPP 2.0 TriggerMessage command handling (F06)
+ */
+
+import { expect } from '@std/expect'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+
+import type {
+ OCPP20TriggerMessageRequest,
+ OCPP20TriggerMessageResponse,
+} from '../../../../src/types/index.js'
+import type { MockChargingStation } from '../../ChargingStationTestUtils.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 {
+ MessageTriggerEnumType,
+ OCPPVersion,
+ ReasonCodeEnumType,
+ RegistrationStatusEnumType,
+ TriggerMessageStatusEnumType,
+} 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'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+/**
+ * Create a mock station suitable for TriggerMessage tests.
+ * Uses a mock requestHandler to avoid network calls from fire-and-forget paths.
+ * @returns The mock station and its request handler spy
+ */
+function createTriggerMessageStation (): {
+ mockStation: MockChargingStation
+ requestHandlerMock: ReturnType<typeof mock.fn>
+} {
+ const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 3,
+ evseConfiguration: { evsesCount: 3 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: requestHandlerMock,
+ },
+ stationInfo: {
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
+ const mockStation = station as MockChargingStation
+ return { mockStation, requestHandlerMock }
+}
+
+await describe('F06 - TriggerMessage', async () => {
+ let incomingRequestService: OCPP20IncomingRequestService
+ let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+ beforeEach(() => {
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout'] })
+ incomingRequestService = new OCPP20IncomingRequestService()
+ testableService = createTestableIncomingRequestService(incomingRequestService)
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await describe('F06 - Accepted triggers (happy path)', async () => {
+ let mockStation: MockChargingStation
+
+ beforeEach(() => {
+ ;({ mockStation } = createTriggerMessageStation())
+ })
+
+ await it('should return Accepted for BootNotification trigger when boot is Pending', () => {
+ if (mockStation.bootNotificationResponse != null) {
+ mockStation.bootNotificationResponse.status = RegistrationStatusEnumType.PENDING
+ }
+
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.BootNotification,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+ expect(response.statusInfo).toBeUndefined()
+ })
+
+ await it('should return Accepted for Heartbeat trigger', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.Heartbeat,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+ expect(response.statusInfo).toBeUndefined()
+ })
+
+ await it('should return Accepted for StatusNotification trigger without EVSE', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.StatusNotification,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+ expect(response.statusInfo).toBeUndefined()
+ })
+
+ await it('should return Accepted for StatusNotification trigger with valid EVSE and connector', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { connectorId: 1, id: 1 },
+ requestedMessage: MessageTriggerEnumType.StatusNotification,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+ expect(response.statusInfo).toBeUndefined()
+ })
+
+ await it('should not validate EVSE when evse.id is 0', () => {
+ // evse.id === 0 means whole-station scope; EVSE validation is skipped
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 0 },
+ requestedMessage: MessageTriggerEnumType.Heartbeat,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+ })
+ })
+
+ await describe('F06 - NotImplemented triggers', async () => {
+ let mockStation: MockChargingStation
+
+ beforeEach(() => {
+ ;({ mockStation } = createTriggerMessageStation())
+ })
+
+ await it('should return NotImplemented for MeterValues trigger', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.NotImplemented)
+ expect(response.statusInfo).toBeDefined()
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+ expect(response.statusInfo?.additionalInfo).toContain('MeterValues')
+ })
+
+ await it('should return NotImplemented for TransactionEvent trigger', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.TransactionEvent,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.NotImplemented)
+ expect(response.statusInfo).toBeDefined()
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+ })
+
+ await it('should return NotImplemented for LogStatusNotification trigger', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.LogStatusNotification,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.NotImplemented)
+ expect(response.statusInfo).toBeDefined()
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+ })
+
+ await it('should return NotImplemented for FirmwareStatusNotification trigger', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.FirmwareStatusNotification,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.NotImplemented)
+ expect(response.statusInfo).toBeDefined()
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+ })
+ })
+
+ await describe('F06 - EVSE validation', async () => {
+ let mockStation: MockChargingStation
+
+ beforeEach(() => {
+ ;({ mockStation } = createTriggerMessageStation())
+ })
+
+ await it('should return Rejected with UnsupportedRequest when station has no EVSEs and EVSE id > 0 specified', () => {
+ Object.defineProperty(mockStation, 'hasEvses', {
+ configurable: true,
+ value: false,
+ writable: true,
+ })
+
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.BootNotification,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.Rejected)
+ expect(response.statusInfo).toBeDefined()
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+ expect(response.statusInfo?.additionalInfo).toContain('does not support EVSEs')
+ })
+
+ await it('should return Rejected with UnknownEvse for non-existent EVSE id', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 999 },
+ requestedMessage: MessageTriggerEnumType.BootNotification,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.Rejected)
+ expect(response.statusInfo).toBeDefined()
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnknownEvse)
+ expect(response.statusInfo?.additionalInfo).toContain('999')
+ })
+
+ await it('should accept trigger when evse is undefined', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.Heartbeat,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+ })
+ })
+
+ await describe('F06.FR.17 - BootNotification already accepted', async () => {
+ let mockStation: MockChargingStation
+
+ beforeEach(() => {
+ ;({ mockStation } = createTriggerMessageStation())
+ })
+
+ await it('should return Rejected when boot was already Accepted (F06.FR.17)', () => {
+ if (mockStation.bootNotificationResponse != null) {
+ mockStation.bootNotificationResponse.status = RegistrationStatusEnumType.ACCEPTED
+ }
+
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.BootNotification,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.Rejected)
+ expect(response.statusInfo).toBeDefined()
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.NotEnabled)
+ expect(response.statusInfo?.additionalInfo).toContain('F06.FR.17')
+ })
+
+ await it('should return Accepted for BootNotification when boot was Rejected', () => {
+ if (mockStation.bootNotificationResponse != null) {
+ mockStation.bootNotificationResponse.status = RegistrationStatusEnumType.REJECTED
+ }
+
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.BootNotification,
+ }
+
+ const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ request
+ )
+
+ expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+ })
+ })
+
+ await describe('F06 - Response structure', async () => {
+ let mockStation: MockChargingStation
+
+ beforeEach(() => {
+ ;({ mockStation } = createTriggerMessageStation())
+ })
+
+ await it('should return a plain object with a string status field', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.BootNotification,
+ }
+
+ const response = testableService.handleRequestTriggerMessage(mockStation, request)
+
+ expect(response).toBeDefined()
+ expect(typeof response).toBe('object')
+ expect(typeof response.status).toBe('string')
+ })
+
+ await it('handler is synchronous — result is not a Promise', () => {
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.BootNotification,
+ }
+
+ const result = testableService.handleRequestTriggerMessage(mockStation, request)
+
+ // A Promise would have a `then` property that is a function
+ expect(typeof (result as unknown as Promise<unknown>).then).not.toBe('function')
+ })
+ })
+})
--- /dev/null
+/**
+ * @file Tests for OCPP20IncomingRequestService UnlockConnector
+ * @description Unit tests for OCPP 2.0 UnlockConnector command handling (F05)
+ */
+
+import { expect } from '@std/expect'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+
+import type {
+ OCPP20UnlockConnectorRequest,
+ OCPP20UnlockConnectorResponse,
+} from '../../../../src/types/index.js'
+import type { MockChargingStation } from '../../ChargingStationTestUtils.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 {
+ OCPPVersion,
+ ReasonCodeEnumType,
+ UnlockStatusEnumType,
+} 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'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+/**
+ * Create a mock station suitable for UnlockConnector tests.
+ * Provides 3 EVSEs each with 1 connector.
+ * Mocks requestHandler to allow sendAndSetConnectorStatus to succeed
+ * (sendAndSetConnectorStatus calls requestHandler internally for StatusNotification).
+ * @returns The mock station and its request handler spy
+ */
+function createUnlockConnectorStation (): {
+ mockStation: MockChargingStation
+ requestHandlerMock: ReturnType<typeof mock.fn>
+} {
+ const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 3,
+ evseConfiguration: { evsesCount: 3 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: requestHandlerMock,
+ },
+ stationInfo: {
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
+ return { mockStation: station as MockChargingStation, requestHandlerMock }
+}
+
+await describe('F05 - UnlockConnector', async () => {
+ let incomingRequestService: OCPP20IncomingRequestService
+ let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+ beforeEach(() => {
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout'] })
+ incomingRequestService = new OCPP20IncomingRequestService()
+ testableService = createTestableIncomingRequestService(incomingRequestService)
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await describe('F05 - No-EVSE station errors', async () => {
+ await it('should return UnknownConnector + UnsupportedRequest when station has no EVSEs', async () => {
+ const { mockStation } = createUnlockConnectorStation()
+ Object.defineProperty(mockStation, 'hasEvses', {
+ configurable: true,
+ value: false,
+ writable: true,
+ })
+
+ const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+ const response: OCPP20UnlockConnectorResponse =
+ await testableService.handleRequestUnlockConnector(mockStation, request)
+
+ expect(response.status).toBe(UnlockStatusEnumType.UnknownConnector)
+ expect(response.statusInfo).toBeDefined()
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+ expect(response.statusInfo?.additionalInfo).toContain('does not support EVSEs')
+ })
+ })
+
+ await describe('F05 - Unknown EVSE errors', async () => {
+ await it('should return UnknownConnector + UnknownEvse for non-existent EVSE id', async () => {
+ const { mockStation } = createUnlockConnectorStation()
+
+ const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 999 }
+ const response: OCPP20UnlockConnectorResponse =
+ await testableService.handleRequestUnlockConnector(mockStation, request)
+
+ expect(response.status).toBe(UnlockStatusEnumType.UnknownConnector)
+ expect(response.statusInfo).toBeDefined()
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnknownEvse)
+ expect(response.statusInfo?.additionalInfo).toContain('999')
+ })
+ })
+
+ await describe('F05 - Unknown connector errors', async () => {
+ await it('should return UnknownConnector + UnknownConnectorId for non-existent connector on EVSE', async () => {
+ // With evsesCount:3 connectorsCount:3, EVSE 1 has connector 1 only
+ const { mockStation } = createUnlockConnectorStation()
+
+ const request: OCPP20UnlockConnectorRequest = { connectorId: 99, evseId: 1 }
+ const response: OCPP20UnlockConnectorResponse =
+ await testableService.handleRequestUnlockConnector(mockStation, request)
+
+ expect(response.status).toBe(UnlockStatusEnumType.UnknownConnector)
+ expect(response.statusInfo).toBeDefined()
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnknownConnectorId)
+ expect(response.statusInfo?.additionalInfo).toContain('99')
+ expect(response.statusInfo?.additionalInfo).toContain('1')
+ })
+ })
+
+ await describe('F05 - Ongoing transaction errors (F05.FR.02)', async () => {
+ await it('should return OngoingAuthorizedTransaction when specified connector has active transaction', async () => {
+ const { mockStation } = createUnlockConnectorStation()
+
+ const evseStatus = mockStation.evses.get(1)
+ const connector = evseStatus?.connectors.get(1)
+ if (connector != null) {
+ connector.transactionId = 'tx-001'
+ }
+
+ const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+ const response: OCPP20UnlockConnectorResponse =
+ await testableService.handleRequestUnlockConnector(mockStation, request)
+
+ expect(response.status).toBe(UnlockStatusEnumType.OngoingAuthorizedTransaction)
+ expect(response.statusInfo).toBeDefined()
+ expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.TxInProgress)
+ expect(response.statusInfo?.additionalInfo).toContain('1')
+ })
+
+ await it('should return Unlocked when a different connector on the same EVSE has a transaction (F05.FR.02)', async () => {
+ const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 2,
+ evseConfiguration: { evsesCount: 1 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: requestHandlerMock,
+ },
+ stationInfo: {
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
+ const multiConnectorStation = station as MockChargingStation
+
+ const evseStatus = multiConnectorStation.evses.get(1)
+ const connector2 = evseStatus?.connectors.get(2)
+ if (connector2 != null) {
+ connector2.transactionId = 'tx-other'
+ }
+
+ const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+ const response: OCPP20UnlockConnectorResponse =
+ await testableService.handleRequestUnlockConnector(multiConnectorStation, request)
+
+ expect(response.status).toBe(UnlockStatusEnumType.Unlocked)
+ })
+ })
+
+ await describe('F05 - Happy path (unlock succeeds)', async () => {
+ await it('should return Unlocked when EVSE and connector exist and no active transaction', async () => {
+ const { mockStation } = createUnlockConnectorStation()
+
+ const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+ const response: OCPP20UnlockConnectorResponse =
+ await testableService.handleRequestUnlockConnector(mockStation, request)
+
+ expect(response.status).toBe(UnlockStatusEnumType.Unlocked)
+ expect(response.statusInfo).toBeUndefined()
+ })
+
+ await it('should call requestHandler (StatusNotification) to set connector status Available after unlock', async () => {
+ const { mockStation, requestHandlerMock } = createUnlockConnectorStation()
+
+ const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+ await testableService.handleRequestUnlockConnector(mockStation, request)
+
+ // sendAndSetConnectorStatus calls requestHandler internally for StatusNotification
+ expect(requestHandlerMock.mock.calls.length).toBeGreaterThan(0)
+ })
+
+ await it('handler is async — result is a Promise', async () => {
+ const { mockStation } = createUnlockConnectorStation()
+
+ const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+
+ const result = testableService.handleRequestUnlockConnector(mockStation, request)
+
+ expect(typeof (result as unknown as Promise<unknown>).then).toBe('function')
+ await result
+ })
+ })
+
+ await describe('F05 - Response structure', async () => {
+ await it('should return a plain object with a string status field', async () => {
+ const { mockStation } = createUnlockConnectorStation()
+
+ const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+ const response = await testableService.handleRequestUnlockConnector(mockStation, request)
+
+ expect(response).toBeDefined()
+ expect(typeof response).toBe('object')
+ expect(typeof response.status).toBe('string')
+ })
+
+ await it('should not include statusInfo on successful unlock', async () => {
+ const { mockStation } = createUnlockConnectorStation()
+
+ const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+ const response = await testableService.handleRequestUnlockConnector(mockStation, request)
+
+ expect(response.status).toBe(UnlockStatusEnumType.Unlocked)
+ expect(response.statusInfo).toBeUndefined()
+ })
+ })
+})
--- /dev/null
+import { expect } from '@std/expect'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type {
+ OCPP20DeleteCertificateRequest,
+ OCPP20GetInstalledCertificateIdsRequest,
+ OCPP20InstallCertificateRequest,
+} from '../../../../src/types/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 { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js'
+import {
+ GetCertificateIdUseEnumType,
+ HashAlgorithmEnumType,
+ InstallCertificateStatusEnumType,
+ InstallCertificateUseEnumType,
+ 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'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+/** @returns A mock station configured for certificate integration tests */
+function createIntegrationStation (): ChargingStation {
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 3,
+ evseConfiguration: { evsesCount: 3 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: async () => Promise.resolve({}),
+ },
+ stationInfo: {
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
+ return station
+}
+
+await describe('OCPP 2.0 Integration — Certificate install and delete lifecycle', async () => {
+ let station: ChargingStation
+ let incomingRequestService: OCPP20IncomingRequestService
+ let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+ beforeEach(() => {
+ station = createIntegrationStation()
+ incomingRequestService = new OCPP20IncomingRequestService()
+ testableService = createTestableIncomingRequestService(incomingRequestService)
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ OCPPAuthServiceFactory.clearAllInstances()
+ })
+
+ await it('should install a certificate and then list it via GetInstalledCertificateIds', async () => {
+ const fakePem = [
+ '-----BEGIN CERTIFICATE-----',
+ 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a',
+ '-----END CERTIFICATE-----',
+ ].join('\n')
+
+ const installRequest: OCPP20InstallCertificateRequest = {
+ certificate: fakePem,
+ certificateType: InstallCertificateUseEnumType.V2GRootCertificate,
+ }
+
+ const installResponse = await testableService.handleRequestInstallCertificate(
+ station,
+ installRequest
+ )
+
+ expect(installResponse.status).toBeDefined()
+ expect(Object.values(InstallCertificateStatusEnumType)).toContain(installResponse.status)
+ })
+
+ await it('should respond to GetInstalledCertificateIds without throwing', async () => {
+ const getRequest: OCPP20GetInstalledCertificateIdsRequest = {
+ certificateType: [GetCertificateIdUseEnumType.V2GRootCertificate],
+ }
+
+ const getResponse = await testableService.handleRequestGetInstalledCertificateIds(
+ station,
+ getRequest
+ )
+
+ expect(getResponse).toBeDefined()
+ expect(getResponse.status).toBeDefined()
+ expect(
+ getResponse.certificateHashDataChain === undefined ||
+ Array.isArray(getResponse.certificateHashDataChain)
+ ).toBe(true)
+ })
+
+ await it('should handle DeleteCertificate request without throwing even for unknown cert hash', async () => {
+ const deleteRequest: OCPP20DeleteCertificateRequest = {
+ certificateHashData: {
+ hashAlgorithm: HashAlgorithmEnumType.SHA256,
+ issuerKeyHash: 'abc123',
+ issuerNameHash: 'def456',
+ serialNumber: '01',
+ },
+ }
+
+ const deleteResponse = await testableService.handleRequestDeleteCertificate(
+ station,
+ deleteRequest
+ )
+
+ expect(deleteResponse.status).toBeDefined()
+ })
+})
--- /dev/null
+import { expect } from '@std/expect'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type {
+ OCPP20GetVariablesRequest,
+ OCPP20GetVariablesResponse,
+ OCPP20SetVariablesRequest,
+ OCPP20SetVariablesResponse,
+} from '../../../../src/types/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 { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js'
+import {
+ AttributeEnumType,
+ GetVariableStatusEnumType,
+ OCPP20ComponentName,
+ OCPPVersion,
+ SetVariableStatusEnumType,
+} 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'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+import { resetLimits } from './OCPP20TestUtils.js'
+
+/** @returns A mock station configured for integration tests */
+function createIntegrationStation (): ChargingStation {
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 3,
+ evseConfiguration: { evsesCount: 3 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: async () => Promise.resolve({}),
+ },
+ stationInfo: {
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
+ return station
+}
+
+await describe('OCPP 2.0 Integration — SetVariables → GetVariables consistency', async () => {
+ let station: ChargingStation
+ let incomingRequestService: OCPP20IncomingRequestService
+ let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+ beforeEach(() => {
+ station = createIntegrationStation()
+ incomingRequestService = new OCPP20IncomingRequestService()
+ testableService = createTestableIncomingRequestService(incomingRequestService)
+ resetLimits(station)
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ OCPPAuthServiceFactory.clearAllInstances()
+ })
+
+ await it('should read back the same value that was written via SetVariables→GetVariables', () => {
+ const setRequest: OCPP20SetVariablesRequest = {
+ setVariableData: [
+ {
+ attributeValue: '60',
+ component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+ variable: { name: 'HeartbeatInterval' },
+ },
+ ],
+ }
+ const getRequest: OCPP20GetVariablesRequest = {
+ getVariableData: [
+ {
+ attributeType: AttributeEnumType.Actual,
+ component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+ variable: { name: 'HeartbeatInterval' },
+ },
+ ],
+ }
+
+ const setResponse: OCPP20SetVariablesResponse = testableService.handleRequestSetVariables(
+ station,
+ setRequest
+ )
+
+ expect(setResponse.setVariableResult).toHaveLength(1)
+ const setResult = setResponse.setVariableResult[0]
+ expect(setResult.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+
+ const getResponse: OCPP20GetVariablesResponse = testableService.handleRequestGetVariables(
+ station,
+ getRequest
+ )
+
+ expect(getResponse.getVariableResult).toHaveLength(1)
+ const getResult = getResponse.getVariableResult[0]
+ expect(getResult.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+ expect(getResult.attributeValue).toBe('60')
+ })
+
+ await it('should return UnknownVariable for GetVariables on an unknown variable name', () => {
+ const getRequest: OCPP20GetVariablesRequest = {
+ getVariableData: [
+ {
+ component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+ variable: { name: 'ThisVariableDoesNotExistInRegistry' },
+ },
+ ],
+ }
+
+ const getResponse = testableService.handleRequestGetVariables(station, getRequest)
+
+ expect(getResponse.getVariableResult).toHaveLength(1)
+ const result = getResponse.getVariableResult[0]
+ expect(
+ result.attributeStatus === GetVariableStatusEnumType.UnknownVariable ||
+ result.attributeStatus === GetVariableStatusEnumType.UnknownComponent
+ ).toBe(true)
+ })
+
+ await it('should handle multiple variables in a single SetVariables→GetVariables round trip', () => {
+ const setRequest: OCPP20SetVariablesRequest = {
+ setVariableData: [
+ {
+ attributeValue: '30',
+ component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+ variable: { name: 'HeartbeatInterval' },
+ },
+ {
+ attributeValue: '20',
+ component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+ variable: { name: 'WebSocketPingInterval' },
+ },
+ ],
+ }
+
+ const setResponse = testableService.handleRequestSetVariables(station, setRequest)
+ expect(setResponse.setVariableResult).toHaveLength(2)
+
+ const getRequest: OCPP20GetVariablesRequest = {
+ getVariableData: [
+ {
+ component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+ variable: { name: 'HeartbeatInterval' },
+ },
+ {
+ component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+ variable: { name: 'WebSocketPingInterval' },
+ },
+ ],
+ }
+ const getResponse = testableService.handleRequestGetVariables(station, getRequest)
+
+ expect(getResponse.getVariableResult).toHaveLength(2)
+ for (const result of getResponse.getVariableResult) {
+ expect(result.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+ }
+ })
+
+ await it('should reject SetVariables on an unknown component and confirm GetVariables returns UnknownComponent', () => {
+ const unknownComponent = { name: 'NonExistentComponent' as OCPP20ComponentName }
+ const variableName = 'SomeVariable'
+
+ // Attempt to set a variable on a component that does not exist in the registry
+ const setRequest: OCPP20SetVariablesRequest = {
+ setVariableData: [
+ {
+ attributeValue: '999',
+ component: unknownComponent,
+ variable: { name: variableName },
+ },
+ ],
+ }
+ const setResponse = testableService.handleRequestSetVariables(station, setRequest)
+
+ expect(setResponse.setVariableResult).toHaveLength(1)
+ const setResult = setResponse.setVariableResult[0]
+ expect(
+ setResult.attributeStatus === SetVariableStatusEnumType.UnknownComponent ||
+ setResult.attributeStatus === SetVariableStatusEnumType.UnknownVariable ||
+ setResult.attributeStatus === SetVariableStatusEnumType.Rejected
+ ).toBe(true)
+
+ // Confirm GetVariables also rejects lookup on the same unknown component
+ const getRequest: OCPP20GetVariablesRequest = {
+ getVariableData: [
+ {
+ component: unknownComponent,
+ variable: { name: variableName },
+ },
+ ],
+ }
+ const getResponse = testableService.handleRequestGetVariables(station, getRequest)
+
+ expect(getResponse.getVariableResult).toHaveLength(1)
+ const getResult = getResponse.getVariableResult[0]
+ expect(
+ getResult.attributeStatus === GetVariableStatusEnumType.UnknownComponent ||
+ getResult.attributeStatus === GetVariableStatusEnumType.UnknownVariable
+ ).toBe(true)
+ })
+})
await describe('OCPP20 ISO15118 Request Service', async () => {
await describe('M02 - Get15118EVCertificate Request', async () => {
- let station: ReturnType<typeof createMockChargingStation>
+ let station: ReturnType<typeof createMockChargingStation>['station']
beforeEach(() => {
const { station: newStation } = createMockChargingStation({
*/
import { expect } from '@std/expect'
+import assert from 'node:assert'
import { afterEach, beforeEach, describe, it } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/index.js'
) as OCPP20NotifyReportRequest
expect(payload).toBeDefined()
- expect(payload.reportData[0].variableAttribute[0].type).toBe(attributeType)
- expect(payload.reportData[0].variableAttribute[0].value).toBe(
- `Test Value ${index.toString()}`
- )
+ assert(payload.reportData != null)
+ const firstReport = payload.reportData[0]
+ assert(firstReport.variableAttribute != null)
+ expect(firstReport.variableAttribute[0].type).toBe(attributeType)
+ expect(firstReport.variableAttribute[0].value).toBe(`Test Value ${index.toString()}`)
})
})
) as OCPP20NotifyReportRequest
expect(payload).toBeDefined()
- expect(payload.reportData[0].variableCharacteristics.dataType).toBe(testCase.dataType)
- expect(payload.reportData[0].variableAttribute[0].value).toBe(testCase.value)
+ assert(payload.reportData != null)
+ const firstReport = payload.reportData[0]
+ assert(firstReport.variableCharacteristics != null)
+ assert(firstReport.variableAttribute != null)
+ expect(firstReport.variableCharacteristics.dataType).toBe(testCase.dataType)
+ expect(firstReport.variableAttribute[0].value).toBe(testCase.value)
})
})
) as OCPP20NotifyReportRequest
expect(payload).toBeDefined()
- expect(payload.reportData[0].variableAttribute).toHaveLength(1)
- expect(payload.reportData[0].variableAttribute[0].type).toBe(AttributeEnumType.Actual)
+ assert(payload.reportData != null)
+ const firstReport = payload.reportData[0]
+ assert(firstReport.variableAttribute != null)
+ expect(firstReport.variableAttribute).toHaveLength(1)
+ expect(firstReport.variableAttribute[0].type).toBe(AttributeEnumType.Actual)
})
await it('should preserve all payload properties correctly', () => {
type OCPP20SignCertificateRequest,
type OCPP20SignCertificateResponse,
OCPPVersion,
+ ReasonCodeEnumType,
} from '../../../../src/types/index.js'
import { Constants } from '../../../../src/utils/index.js'
import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
station = createdStation
// Set up configuration with OrganizationName
station.ocppConfiguration = {
- configurationKey: [{ key: 'SecurityCtrlr.OrganizationName', value: MOCK_ORGANIZATION_NAME }],
+ configurationKey: [
+ { key: 'SecurityCtrlr.OrganizationName', readonly: false, value: MOCK_ORGANIZATION_NAME },
+ ],
}
})
sendMessageResponse: {
status: GenericStatus.Rejected,
statusInfo: {
- reasonCode: 'InvalidCSR',
+ reasonCode: ReasonCodeEnumType.InvalidCSR,
},
},
})
stationWithoutCertManager.ocppConfiguration = {
configurationKey: [
- { key: 'SecurityCtrlr.OrganizationName', value: MOCK_ORGANIZATION_NAME },
+ { key: 'SecurityCtrlr.OrganizationName', readonly: false, value: MOCK_ORGANIZATION_NAME },
],
}
- delete stationWithoutCertManager.certificateManager
-
const { sendMessageMock, service } =
createTestableRequestService<OCPP20SignCertificateResponse>({
sendMessageResponse: {
--- /dev/null
+/**
+ * @file Tests for OCPP20ResponseService TransactionEvent response handling
+ * @description Unit tests for OCPP 2.0 TransactionEvent response processing (E01-E04)
+ *
+ * Covers:
+ * - E01-E04 TransactionEventResponse handler branch coverage
+ * - Empty response (no optional fields) — baseline
+ * - totalCost logging branch
+ * - chargingPriority logging branch
+ * - idTokenInfo.Accepted logging branch
+ * - idTokenInfo.Invalid logging branch
+ * - updatedPersonalMessage logging branch
+ * - All fields together
+ */
+
+import { expect } from '@std/expect'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+
+import type { MockChargingStation } from '../../ChargingStationTestUtils.js'
+
+import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
+import { OCPP20RequestCommand, OCPPVersion } from '../../../../src/types/index.js'
+import {
+ OCPP20AuthorizationStatusEnumType,
+ type OCPP20MessageContentType,
+ OCPP20MessageFormatEnumType,
+ type OCPP20TransactionEventResponse,
+} from '../../../../src/types/ocpp/2.0/Transaction.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+/**
+ * Create a mock station suitable for TransactionEvent response tests.
+ * Uses ocppStrictCompliance: false to bypass AJV validation so the
+ * handler logic can be tested in isolation.
+ * @returns A mock station configured for TransactionEvent tests
+ */
+function createTransactionEventStation (): MockChargingStation {
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ stationInfo: {
+ // Bypass AJV schema validation — tests focus on handler logic
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
+ return station as MockChargingStation
+}
+
+await describe('E01-E04 - TransactionEventResponse handler', async () => {
+ let responseService: OCPP20ResponseService
+ let mockStation: MockChargingStation
+
+ beforeEach(() => {
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout'] })
+ responseService = new OCPP20ResponseService()
+ mockStation = createTransactionEventStation()
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ /**
+ * Helper to dispatch a TransactionEventResponse through the public responseHandler.
+ * The station is in Accepted state by default (RegistrationStatusEnumType.ACCEPTED).
+ * @param payload - The TransactionEventResponse payload to dispatch
+ * @returns Resolves when the response handler completes
+ */
+ async function dispatch (payload: OCPP20TransactionEventResponse): Promise<void> {
+ await responseService.responseHandler(
+ mockStation,
+ OCPP20RequestCommand.TRANSACTION_EVENT,
+ payload as unknown as Parameters<typeof responseService.responseHandler>[2],
+ {} as Parameters<typeof responseService.responseHandler>[3]
+ )
+ }
+
+ await it('should handle empty TransactionEvent response without throwing', async () => {
+ const payload: OCPP20TransactionEventResponse = {}
+ await expect(dispatch(payload)).resolves.toBeUndefined()
+ })
+
+ await it('should handle totalCost field without throwing', async () => {
+ const payload: OCPP20TransactionEventResponse = { totalCost: 12.5 }
+ await expect(dispatch(payload)).resolves.toBeUndefined()
+ })
+
+ await it('should handle chargingPriority field without throwing', async () => {
+ const payload: OCPP20TransactionEventResponse = { chargingPriority: 1 }
+ await expect(dispatch(payload)).resolves.toBeUndefined()
+ })
+
+ await it('should handle idTokenInfo with Accepted status without throwing', async () => {
+ const payload: OCPP20TransactionEventResponse = {
+ idTokenInfo: {
+ status: OCPP20AuthorizationStatusEnumType.Accepted,
+ },
+ }
+ await expect(dispatch(payload)).resolves.toBeUndefined()
+ })
+
+ await it('should handle idTokenInfo with Invalid status without throwing', async () => {
+ const payload: OCPP20TransactionEventResponse = {
+ idTokenInfo: {
+ status: OCPP20AuthorizationStatusEnumType.Invalid,
+ },
+ }
+ await expect(dispatch(payload)).resolves.toBeUndefined()
+ })
+
+ await it('should handle updatedPersonalMessage field without throwing', async () => {
+ const message: OCPP20MessageContentType = {
+ content: 'Thank you for charging!',
+ format: OCPP20MessageFormatEnumType.UTF8,
+ }
+ const payload: OCPP20TransactionEventResponse = { updatedPersonalMessage: message }
+ await expect(dispatch(payload)).resolves.toBeUndefined()
+ })
+
+ await it('should handle all optional fields present simultaneously without throwing', async () => {
+ const message: OCPP20MessageContentType = {
+ content: '<b>Session complete</b>',
+ format: OCPP20MessageFormatEnumType.HTML,
+ }
+ const payload: OCPP20TransactionEventResponse = {
+ chargingPriority: 2,
+ idTokenInfo: {
+ chargingPriority: 3,
+ status: OCPP20AuthorizationStatusEnumType.Accepted,
+ },
+ totalCost: 9.99,
+ updatedPersonalMessage: message,
+ }
+ await expect(dispatch(payload)).resolves.toBeUndefined()
+ })
+})
--- /dev/null
+/**
+ * @file Tests for OCPP 2.0 JSON schema validation (negative tests)
+ * @description Verifies that OCPP 2.0.1 JSON schemas correctly reject invalid payloads
+ * when compiled with AJV. Tests the schemas directly (not through service plumbing),
+ * which ensures correctness regardless of path resolution in tsx/dist modes.
+ *
+ * This approach also validates the AJV configuration (strict:false required because
+ * many OCPP 2.0 schemas use additionalItems without array items, which AJV 8 strict
+ * mode rejects at compile time).
+ */
+
+import { expect } from '@std/expect'
+import _Ajv, { type ValidateFunction } from 'ajv'
+import _ajvFormats from 'ajv-formats'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, it } from 'node:test'
+import { fileURLToPath } from 'node:url'
+
+const AjvConstructor = _Ajv.default
+const ajvFormats = _ajvFormats.default
+
+/** Absolute path to OCPP 2.0 JSON schemas, resolved relative to this test file. */
+const SCHEMA_DIR = join(
+ fileURLToPath(new URL('.', import.meta.url)),
+ '../../../../src/assets/json-schemas/ocpp/2.0'
+)
+
+/**
+ * Load a schema from the OCPP 2.0 schema directory and return parsed JSON.
+ * @param filename - Schema filename (e.g. 'ResetRequest.json')
+ * @returns Parsed JSON schema object
+ */
+function loadSchema (filename: string): Record<string, unknown> {
+ return JSON.parse(readFileSync(join(SCHEMA_DIR, filename), 'utf8')) as Record<string, unknown>
+}
+
+/**
+ * Create an AJV validator for the given schema file.
+ * strict:false is required because OCPP 2.0 schemas use additionalItems without
+ * array items (a draft-07 pattern), which AJV 8 strict mode rejects at compile time.
+ * @param schemaFile - Schema filename (e.g. 'ResetRequest.json')
+ * @returns Compiled AJV validate function
+ */
+function makeValidator (schemaFile: string): ValidateFunction {
+ const ajv = new AjvConstructor({ keywords: ['javaType'], multipleOfPrecision: 2, strict: false })
+ ajvFormats(ajv)
+ return ajv.compile(loadSchema(schemaFile))
+}
+
+await describe('OCPP 2.0 schema validation — negative tests', async () => {
+ await it('AJV compiles ResetRequest schema without error (strict:false required)', () => {
+ // Verifies the AJV configuration works for schemas using additionalItems pattern
+ expect(() => makeValidator('ResetRequest.json')).not.toThrow()
+ })
+
+ await it('AJV compiles GetVariablesRequest schema without error (uses additionalItems)', () => {
+ // GetVariablesRequest uses additionalItems:false — would fail in strict mode
+ expect(() => makeValidator('GetVariablesRequest.json')).not.toThrow()
+ })
+
+ await it('Reset: missing required "type" field → validation fails', () => {
+ const validate = makeValidator('ResetRequest.json')
+ expect(validate({})).toBe(false)
+ expect(validate.errors).toBeDefined()
+ // AJV reports missingProperty for required field violations
+ const hasMissingType = validate.errors?.some(
+ e =>
+ e.keyword === 'required' &&
+ (e.params as { missingProperty?: string }).missingProperty === 'type'
+ )
+ expect(hasMissingType).toBe(true)
+ })
+
+ await it('Reset: invalid "type" enum value → validation fails', () => {
+ const validate = makeValidator('ResetRequest.json')
+ // Valid values are Immediate and OnIdle only; HardReset is OCPP 1.6
+ expect(validate({ type: 'HardReset' })).toBe(false)
+ expect(validate.errors).toBeDefined()
+ const hasEnumError = validate.errors?.some(e => e.keyword === 'enum')
+ expect(hasEnumError).toBe(true)
+ })
+
+ await it('GetVariables: empty getVariableData array (minItems:1) → validation fails', () => {
+ const validate = makeValidator('GetVariablesRequest.json')
+ expect(validate({ getVariableData: [] })).toBe(false)
+ expect(validate.errors).toBeDefined()
+ const hasMinItemsError = validate.errors?.some(e => e.keyword === 'minItems')
+ expect(hasMinItemsError).toBe(true)
+ })
+
+ await it('GetVariables: missing required getVariableData → validation fails', () => {
+ const validate = makeValidator('GetVariablesRequest.json')
+ expect(validate({})).toBe(false)
+ expect(validate.errors).toBeDefined()
+ const hasMissingProp = validate.errors?.some(
+ e =>
+ e.keyword === 'required' &&
+ (e.params as { missingProperty?: string }).missingProperty === 'getVariableData'
+ )
+ expect(hasMissingProp).toBe(true)
+ })
+
+ await it('SetVariables: missing required setVariableData → validation fails', () => {
+ const validate = makeValidator('SetVariablesRequest.json')
+ expect(validate({})).toBe(false)
+ expect(validate.errors).toBeDefined()
+ const hasMissingProp = validate.errors?.some(
+ e =>
+ e.keyword === 'required' &&
+ (e.params as { missingProperty?: string }).missingProperty === 'setVariableData'
+ )
+ expect(hasMissingProp).toBe(true)
+ })
+
+ await it('TriggerMessage: invalid requestedMessage enum value → validation fails', () => {
+ const validate = makeValidator('TriggerMessageRequest.json')
+ expect(validate({ requestedMessage: 'INVALID_MESSAGE_TYPE_XYZ' })).toBe(false)
+ expect(validate.errors).toBeDefined()
+ const hasEnumError = validate.errors?.some(e => e.keyword === 'enum')
+ expect(hasEnumError).toBe(true)
+ })
+
+ await it('TriggerMessage: missing required requestedMessage → validation fails', () => {
+ const validate = makeValidator('TriggerMessageRequest.json')
+ expect(validate({})).toBe(false)
+ expect(validate.errors).toBeDefined()
+ const hasMissingProp = validate.errors?.some(
+ e =>
+ e.keyword === 'required' &&
+ (e.params as { missingProperty?: string }).missingProperty === 'requestedMessage'
+ )
+ expect(hasMissingProp).toBe(true)
+ })
+
+ await it('UnlockConnector: missing required "evseId" → validation fails', () => {
+ const validate = makeValidator('UnlockConnectorRequest.json')
+ expect(validate({ connectorId: 1 })).toBe(false)
+ expect(validate.errors).toBeDefined()
+ const hasMissingProp = validate.errors?.some(
+ e =>
+ e.keyword === 'required' &&
+ (e.params as { missingProperty?: string }).missingProperty === 'evseId'
+ )
+ expect(hasMissingProp).toBe(true)
+ })
+
+ await it('UnlockConnector: missing required "connectorId" → validation fails', () => {
+ const validate = makeValidator('UnlockConnectorRequest.json')
+ expect(validate({ evseId: 1 })).toBe(false)
+ expect(validate.errors).toBeDefined()
+ const hasMissingProp = validate.errors?.some(
+ e =>
+ e.keyword === 'required' &&
+ (e.params as { missingProperty?: string }).missingProperty === 'connectorId'
+ )
+ expect(hasMissingProp).toBe(true)
+ })
+
+ await it('RequestStartTransaction: missing required "idToken" → validation fails', () => {
+ const validate = makeValidator('RequestStartTransactionRequest.json')
+ // remoteStartId is also required; provide it but omit idToken
+ expect(validate({ remoteStartId: 1 })).toBe(false)
+ expect(validate.errors).toBeDefined()
+ const hasMissingProp = validate.errors?.some(
+ e =>
+ e.keyword === 'required' &&
+ (e.params as { missingProperty?: string }).missingProperty === 'idToken'
+ )
+ expect(hasMissingProp).toBe(true)
+ })
+
+ await it('CertificateSigned: missing required certificateChain → validation fails', () => {
+ const validate = makeValidator('CertificateSignedRequest.json')
+ expect(validate({})).toBe(false)
+ expect(validate.errors).toBeDefined()
+ const hasMissingProp = validate.errors?.some(
+ e =>
+ e.keyword === 'required' &&
+ (e.params as { missingProperty?: string }).missingProperty === 'certificateChain'
+ )
+ expect(hasMissingProp).toBe(true)
+ })
+
+ await it('Reset: valid payload passes validation', () => {
+ const validate = makeValidator('ResetRequest.json')
+ expect(validate({ type: 'Immediate' })).toBe(true)
+ expect(validate({ type: 'OnIdle' })).toBe(true)
+ expect(validate({ evseId: 1, type: 'OnIdle' })).toBe(true)
+ })
+
+ await it('TriggerMessage: valid payload passes validation', () => {
+ const validate = makeValidator('TriggerMessageRequest.json')
+ expect(validate({ requestedMessage: 'Heartbeat' })).toBe(true)
+ expect(validate({ requestedMessage: 'BootNotification' })).toBe(true)
+ })
+})
*/
import { expect } from '@std/expect'
+import assert from 'node:assert'
import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
OCPP20TriggerReasonEnumType,
OCPPVersion,
} from '../../../../src/types/index.js'
+import { OCPP20IncomingRequestCommand } from '../../../../src/types/ocpp/2.0/Requests.js'
import {
OCPP20ChargingStateEnumType,
OCPP20IdTokenEnumType,
await describe('selectTriggerReason', async () => {
await it('should select RemoteStart for remote_command context with RequestStartTransaction', () => {
const context: OCPP20TransactionContext = {
- command: 'RequestStartTransaction',
+ command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
source: 'remote_command',
}
await it('should select RemoteStop for remote_command context with RequestStopTransaction', () => {
const context: OCPP20TransactionContext = {
- command: 'RequestStopTransaction',
+ command: OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
source: 'remote_command',
}
await it('should select UnlockCommand for remote_command context with UnlockConnector', () => {
const context: OCPP20TransactionContext = {
- command: 'UnlockConnector',
+ command: OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
source: 'remote_command',
}
await it('should select ResetCommand for remote_command context with Reset', () => {
const context: OCPP20TransactionContext = {
- command: 'Reset',
+ command: OCPP20IncomingRequestCommand.RESET,
source: 'remote_command',
}
await it('should select Trigger for remote_command context with TriggerMessage', () => {
const context: OCPP20TransactionContext = {
- command: 'TriggerMessage',
+ command: OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
source: 'remote_command',
}
// Test context with multiple applicable triggers - priority should be respected
const context: OCPP20TransactionContext = {
cableState: 'plugged_in', // Even lower priority
- command: 'RequestStartTransaction',
+ command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
isDeauthorized: true, // Lower priority but should be overridden
source: 'remote_command', // High priority
}
const connectorId = 1
const transactionId = generateUUID()
const context: OCPP20TransactionContext = {
- command: 'RequestStartTransaction',
+ command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
source: 'remote_command',
}
expect(response.idTokenInfo).toBeUndefined()
const connector = mockStation.getConnectorStatus(connectorId)
- expect(connector?.transactionEventQueue).toBeDefined()
+ assert(connector != null)
+ assert(connector.transactionEventQueue != null)
expect(connector.transactionEventQueue.length).toBe(1)
expect(connector.transactionEventQueue[0].seqNo).toBe(0)
})
const connector = mockStation.getConnectorStatus(connectorId)
expect(connector?.transactionEventQueue?.length).toBe(3)
+ assert(connector != null)
+ assert(connector.transactionEventQueue != null)
expect(connector.transactionEventQueue[0].seqNo).toBe(0)
expect(connector.transactionEventQueue[1].seqNo).toBe(1)
const connector = mockStation.getConnectorStatus(connectorId)
expect(connector?.transactionEventQueue?.length).toBe(2)
+ assert(connector != null)
+ assert(connector.transactionEventQueue != null)
expect(connector.transactionEventQueue[0].seqNo).toBe(1)
expect(connector.transactionEventQueue[1].seqNo).toBe(2)
})
const connector = mockStation.getConnectorStatus(connectorId)
expect(connector?.transactionEventQueue?.[0]?.timestamp).toBeInstanceOf(Date)
+ assert(connector != null)
+ assert(connector.transactionEventQueue != null)
expect(connector.transactionEventQueue[0].timestamp.getTime()).toBeGreaterThanOrEqual(
beforeQueue.getTime()
)
const connector = mockStation.getConnectorStatus(connectorId)
expect(connector?.transactionEventQueue?.length).toBe(1)
+ assert(connector != null)
+ assert(connector.transactionEventQueue != null)
setOnline(true)
await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockStation, connectorId)
await it('should handle null queue gracefully', async () => {
const connectorId = 1
const connector = mockStation.getConnectorStatus(connectorId)
+ assert(connector != null)
connector.transactionEventQueue = undefined
await expect(
expect(connector1?.transactionEventQueue?.length).toBe(2)
expect(connector2?.transactionEventQueue?.length).toBe(1)
+ assert(connector1 != null)
+ assert(connector1.transactionEventQueue != null)
+ assert(connector2 != null)
+ assert(connector2.transactionEventQueue != null)
expect(connector1.transactionEventQueue[0].request.transactionInfo.transactionId).toBe(
transactionId1
await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockStation, 1)
expect(sentRequests.length).toBe(1)
- expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId1)
+ expect(
+ (sentRequests[0].payload.transactionInfo as Record<string, unknown>).transactionId
+ ).toBe(transactionId1)
const connector2 = mockStation.getConnectorStatus(2)
expect(connector2?.transactionEventQueue?.length).toBe(1)
await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockStation, 2)
expect(sentRequests.length).toBe(2)
- expect(sentRequests[1].payload.transactionInfo.transactionId).toBe(transactionId2)
+ expect(
+ (sentRequests[1].payload.transactionInfo as Record<string, unknown>).transactionId
+ ).toBe(transactionId2)
})
})
// Simulate startTxUpdatedInterval with zero interval
const connector = mockStation.getConnectorStatus(connectorId)
expect(connector).toBeDefined()
+ assert(connector != null)
// Zero interval should not start timer
// This is verified by the implementation logging debug message
const connectorId = 1
const connector = mockStation.getConnectorStatus(connectorId)
expect(connector).toBeDefined()
+ assert(connector != null)
// Negative interval should not start timer
expect(connector.transactionTxUpdatedSetInterval).toBeUndefined()
// Verify EVSE info is present
expect(sentRequests[0].payload.evse).toBeDefined()
- expect(sentRequests[0].payload.evse.id).toBe(connectorId)
+ expect((sentRequests[0].payload.evse as Record<string, unknown>).id).toBe(connectorId)
})
await it('should include transactionInfo with correct transactionId', async () => {
// Verify transactionInfo contains the transaction ID
expect(sentRequests[0].payload.transactionInfo).toBeDefined()
- expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId)
+ expect(
+ (sentRequests[0].payload.transactionInfo as Record<string, unknown>).transactionId
+ ).toBe(transactionId)
})
})
--- /dev/null
+import { expect } from '@std/expect'
+import { describe, it } from 'node:test'
+
+import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+
+interface MockLogger {
+ debug: (...args: unknown[]) => void
+ debugCalls: unknown[][]
+}
+
+interface RejectedResult {
+ info: string
+ original: TestItem
+ reasonCode: string
+}
+
+interface TestItem {
+ attributeValue?: string
+ component: { name: string }
+ variable: { name: string }
+}
+
+/**
+ * @param name - Variable name for the test item
+ * @param value - Optional attribute value
+ * @returns A test item with the given variable name and optional value
+ */
+function makeItem (name: string, value?: string): TestItem {
+ return {
+ component: { name: 'TestComponent' },
+ variable: { name },
+ ...(value !== undefined ? { attributeValue: value } : {}),
+ }
+}
+
+/** @returns A mock logger that captures debug calls */
+function makeMockLogger (): MockLogger {
+ const debugCalls: unknown[][] = []
+ return {
+ debug (...args: unknown[]) {
+ debugCalls.push(args)
+ },
+ debugCalls,
+ }
+}
+
+/** @returns A mock station with a logPrefix method */
+function makeMockStation () {
+ return { logPrefix: () => '[TestStation]' }
+}
+
+/** @returns A builder function that creates rejected result objects */
+function makeRejectedBuilder () {
+ return (item: TestItem, reason: { info: string; reasonCode: string }): RejectedResult => ({
+ info: reason.info,
+ original: item,
+ reasonCode: reason.reasonCode,
+ })
+}
+
+await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => {
+ await describe('no limits configured (both 0)', async () => {
+ await it('should return rejected:false and empty results when both limits are 0', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const items = [makeItem('HeartbeatInterval', '30')]
+
+ const result = OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ items,
+ 0,
+ 0,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(result.rejected).toBe(false)
+ expect(result.results).toStrictEqual([])
+ })
+
+ await it('should return rejected:false for empty data array with both limits 0', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+
+ const result = OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ [],
+ 0,
+ 0,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(result.rejected).toBe(false)
+ expect(result.results).toStrictEqual([])
+ })
+ })
+
+ await describe('itemsLimit enforcement', async () => {
+ await it('should return rejected:false when data length is under the items limit', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const items = [makeItem('A'), makeItem('B'), makeItem('C')]
+
+ const result = OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ items,
+ 5,
+ 0,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(result.rejected).toBe(false)
+ expect(result.results).toStrictEqual([])
+ })
+
+ await it('should return rejected:false when data length equals the items limit', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const items = [makeItem('A')]
+
+ const result = OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ items,
+ 1,
+ 0,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(result.rejected).toBe(false)
+ expect(result.results).toStrictEqual([])
+ })
+
+ await it('should reject all items with TooManyElements when items limit is exceeded', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const items = [makeItem('A'), makeItem('B'), makeItem('C')]
+
+ const result = OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ items,
+ 2,
+ 0,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(result.rejected).toBe(true)
+ expect(result.results).toHaveLength(3)
+ for (const r of result.results as RejectedResult[]) {
+ expect(r.reasonCode).toBe('TooManyElements')
+ expect(r.info).toContain('ItemsPerMessage limit 2')
+ }
+ })
+
+ await it('should reject exactly one-over-limit case with TooManyElements', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const items = [makeItem('A'), makeItem('B')]
+
+ const result = OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ items,
+ 1,
+ 0,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(result.rejected).toBe(true)
+ expect(result.results).toHaveLength(2)
+ for (const r of result.results as RejectedResult[]) {
+ expect(r.reasonCode).toBe('TooManyElements')
+ }
+ })
+
+ await it('should log a debug message when items limit is exceeded', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const items = [makeItem('A'), makeItem('B'), makeItem('C')]
+
+ OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'TestModule',
+ 'testContext',
+ items,
+ 2,
+ 0,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(logger.debugCalls).toHaveLength(1)
+ expect(String(logger.debugCalls[0][0])).toContain('ItemsPerMessage limit')
+ })
+ })
+
+ await describe('bytesLimit enforcement', async () => {
+ await it('should return rejected:false when data size is under the bytes limit', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const items = [makeItem('HeartbeatInterval', '30')]
+
+ const result = OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ items,
+ 0,
+ 999_999,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(result.rejected).toBe(false)
+ expect(result.results).toStrictEqual([])
+ })
+
+ await it('should reject all items with TooLargeElement when bytes limit is exceeded', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const items = [makeItem('SomeVariable', 'someValue')]
+
+ const result = OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ items,
+ 0,
+ 1,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(result.rejected).toBe(true)
+ expect(result.results).toHaveLength(1)
+ const r = (result.results as RejectedResult[])[0]
+ expect(r.reasonCode).toBe('TooLargeElement')
+ expect(r.info).toContain('BytesPerMessage limit 1')
+ })
+
+ await it('should reject all items with TooLargeElement for multiple items over bytes limit', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const items = [makeItem('A', 'val'), makeItem('B', 'val')]
+
+ const result = OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ items,
+ 0,
+ 1,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(result.rejected).toBe(true)
+ expect(result.results).toHaveLength(2)
+ for (const r of result.results as RejectedResult[]) {
+ expect(r.reasonCode).toBe('TooLargeElement')
+ }
+ })
+
+ await it('should log a debug message when bytes limit is exceeded', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const items = [makeItem('SomeVariable', 'someValue')]
+
+ OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'TestModule',
+ 'testContext',
+ items,
+ 0,
+ 1,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(logger.debugCalls).toHaveLength(1)
+ expect(String(logger.debugCalls[0][0])).toContain('BytesPerMessage limit')
+ })
+ })
+
+ await describe('items limit takes precedence over bytes limit', async () => {
+ await it('should apply items limit check before bytes limit check', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const items = [makeItem('A'), makeItem('B'), makeItem('C')]
+
+ const result = OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ items,
+ 2,
+ 1,
+ makeRejectedBuilder(),
+ logger
+ )
+
+ expect(result.rejected).toBe(true)
+ for (const r of result.results as RejectedResult[]) {
+ expect(r.reasonCode).toBe('TooManyElements')
+ }
+ })
+ })
+
+ await describe('buildRejected callback', async () => {
+ await it('should pass original item to buildRejected callback', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const item = makeItem('HeartbeatInterval', 'abc')
+ const capturedItems: TestItem[] = []
+
+ OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ [item],
+ 0,
+ 1,
+ (i: TestItem, _reason) => {
+ capturedItems.push(i)
+ return { rejected: true }
+ },
+ logger
+ )
+
+ expect(capturedItems).toHaveLength(1)
+ expect(capturedItems[0]).toBe(item)
+ })
+
+ await it('should pass reason with info and reasonCode to buildRejected callback', () => {
+ const station = makeMockStation()
+ const logger = makeMockLogger()
+ const item = makeItem('WebSocketPingInterval', 'xyz')
+ const capturedReasons: { info: string; reasonCode: string }[] = []
+
+ OCPP20ServiceUtils.enforceMessageLimits(
+ station,
+ 'OCPP20ServiceUtils',
+ 'enforceMessageLimits',
+ [item],
+ 0,
+ 1,
+ (_i: TestItem, reason) => {
+ capturedReasons.push(reason)
+ return { rejected: true }
+ },
+ logger
+ )
+
+ expect(capturedReasons).toHaveLength(1)
+ expect(capturedReasons[0].reasonCode).toBe('TooLargeElement')
+ expect(typeof capturedReasons[0].info).toBe('string')
+ expect(capturedReasons[0].info.length).toBeGreaterThan(0)
+ })
+ })
+})
import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
import {
ConnectorStatusEnum,
+ DeleteCertificateStatusEnumType,
HashAlgorithmEnumType,
OCPP20RequiredVariableName,
OCPPVersion,
} from '../../../../src/types/index.js'
+import { OCPP20IncomingRequestCommand } from '../../../../src/types/ocpp/2.0/Requests.js'
import { OCPP20IdTokenEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js'
import { Constants } from '../../../../src/utils/index.js'
import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
const sentRequests: CapturedOCPPRequest[] = []
let isOnline = true
- const requestHandlerMock = mock.fn(
- async (_station: ChargingStation, command: string, payload: Record<string, unknown>) => {
- sentRequests.push({ command, payload })
- return Promise.resolve({} as EmptyObject)
- }
- )
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ sentRequests.push({
+ command: args[1] as string,
+ payload: args[2] as Record<string, unknown>,
+ })
+ return Promise.resolve({} as EmptyObject)
+ })
const { station } = createMockChargingStation({
baseName: TEST_CHARGING_STATION_BASE_NAME,
* @returns An OCPP20TransactionContext for remote start.
*/
remoteStart: (): OCPP20TransactionContext => ({
- command: 'RequestStartTransaction',
+ command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
source: 'remote_command',
}),
* @returns An OCPP20TransactionContext for remote stop.
*/
remoteStop: (): OCPP20TransactionContext => ({
- command: 'RequestStopTransaction',
+ command: OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
source: 'remote_command',
}),
* @returns An OCPP20TransactionContext for reset.
*/
reset: (): OCPP20TransactionContext => ({
- command: 'Reset',
+ command: OCPP20IncomingRequestCommand.RESET,
source: 'remote_command',
}),
* @returns An OCPP20TransactionContext for trigger message.
*/
triggerMessage: (): OCPP20TransactionContext => ({
- command: 'TriggerMessage',
+ command: OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
source: 'remote_command',
}),
* @returns An OCPP20TransactionContext for unlock connector.
*/
unlockConnector: (): OCPP20TransactionContext => ({
- command: 'UnlockConnector',
+ command: OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
source: 'remote_command',
}),
} as const
export interface MockCertificateManagerOptions {
/** Error to throw when deleteCertificate is called */
deleteCertificateError?: Error
- /** Result to return from deleteCertificate (default: { status: 'Accepted' }) */
- deleteCertificateResult?: { status: 'Accepted' | 'Failed' | 'NotFound' }
+ /** Result to return from deleteCertificate (default: { status: DeleteCertificateStatusEnumType.Accepted }) */
+ deleteCertificateResult?: { status: DeleteCertificateStatusEnumType }
/** Error to throw when getInstalledCertificates is called */
getInstalledCertificatesError?: Error
/** Result to return from getInstalledCertificates (default: []) */
- getInstalledCertificatesResult?: unknown[]
+ getInstalledCertificatesResult?: CertificateHashDataChainType[]
/** Error to throw when storeCertificate is called */
storeCertificateError?: Error
/** Result to return from storeCertificate (default: { success: true }) */
if (options.deleteCertificateError != null) {
throw options.deleteCertificateError
}
- return options.deleteCertificateResult ?? { status: 'Accepted' }
+ return options.deleteCertificateResult ?? { status: DeleteCertificateStatusEnumType.Accepted }
}),
getInstalledCertificates: mock.fn(() => {
if (options.getInstalledCertificatesError != null) {
expect(result[2].attributeStatusInfo).toBeUndefined()
})
- await it('should reject EVSE component as unsupported', () => {
+ await it('should reject unknown variable on EVSE component', () => {
const request: OCPP20GetVariableDataType[] = [
{
component: {
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(1)
- expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.UnknownComponent)
+ expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.UnknownVariable)
expect(result[0].attributeType).toBe(AttributeEnumType.Actual)
expect(result[0].attributeValue).toBeUndefined()
expect(result[0].component.name).toBe(OCPP20ComponentName.EVSE)
import { afterEach, beforeEach, describe, it } from 'node:test'
import type { ChargingStation } from '../../../../../src/charging-station/ChargingStation.js'
-import type { OCPP16AuthorizeResponse } from '../../../../../src/types/ocpp/1.6/Responses.js'
+import type { OCPP16AuthorizeResponse } from '../../../../../src/types/ocpp/1.6/Transaction.js'
import { OCPP16AuthAdapter } from '../../../../../src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.js'
import {
ProcedureName.LIST_CHARGING_STATIONS,
{},
])
- server.sendResponse(response)
+ if (response != null) {
+ server.sendResponse(response)
+ }
expect(server.hasResponseHandler(TEST_UUID)).toBe(false)
})