- Fix periodic TransactionEvent(Updated) to include meter values via buildMeterValue
- Replace non-UUID temp transactionId with generateUUID in OCPP20AuthAdapter
- Refactor state ownership: response handler is sole authority for transactionStarted,
StatusNotification(Occupied), and TxUpdatedInterval start
- Add transactionPending flag to prevent duplicate RequestStartTransaction race conditions
- Add requestDeauthorizeTransaction per E05.FR.09/FR.10/E06.FR.04: sends
Updated(Deauthorized, SuspendedEVSE) then Ended(Deauthorized, DeAuthorized)
- Fix rejection check to cover all non-Accepted idTokenInfo statuses
- Extract buildFinalMeterValues helper to eliminate DRY violation
- Skip Occupied/TxUpdatedInterval setup when idToken is rejected in same response
- Remove cleanup from Ended response handler (owned by caller)
- Add tests for getTxUpdatedInterval, requestDeauthorizeTransaction, Updated-failure path
MeterValueMeasurand,
type MeterValuesRequest,
type MeterValuesResponse,
+ type OCPP20MeterValue,
OCPPVersion,
type OutgoingRequest,
PowerUnits,
connector.transactionTxUpdatedSetInterval = setInterval(() => {
const connectorStatus = this.getConnectorStatus(connectorId)
if (connectorStatus?.transactionStarted === true && connectorStatus.transactionId != null) {
+ const meterValue = buildMeterValue(this, connectorId, 0, interval) as OCPP20MeterValue
OCPP20ServiceUtils.sendTransactionEvent(
this,
OCPP20TransactionEventEnumType.Updated,
OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
- connectorStatus.transactionId as string
+ connectorStatus.transactionId as string,
+ { meterValue: [meterValue] }
).catch((error: unknown) => {
logger.error(
`${this.logPrefix()} Error sending periodic TransactionEvent at TxUpdatedInterval:`,
)
}
resetAuthorizeConnectorStatus(connectorStatus)
+ connectorStatus.transactionPending = false
connectorStatus.transactionRemoteStarted = false
connectorStatus.transactionStarted = false
delete connectorStatus.transactionStart
error
)
})
- const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation)
- chargingStation.startTxUpdatedInterval(connectorId, txUpdatedInterval)
}
}
}
)
}
- if (connectorStatus.transactionStarted === true) {
+ if (
+ connectorStatus.transactionStarted === true ||
+ connectorStatus.transactionPending === true
+ ) {
logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} already has an active transaction`
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} already has an active or pending transaction`
)
return {
status: RequestStartStopStatusEnumType.Rejected,
statusInfo: {
- additionalInfo: `Connector ${connectorId.toString()} already has an active transaction`,
+ additionalInfo: `Connector ${connectorId.toString()} already has an active or pending transaction`,
reasonCode: ReasonCodeEnumType.TxInProgress,
},
transactionId: generateUUID(),
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Setting transaction state for connector ${connectorId.toString()}, transaction ID: ${transactionId}`
)
- connectorStatus.transactionStarted = true
+ connectorStatus.transactionPending = true
connectorStatus.transactionId = transactionId
connectorStatus.transactionIdTag = idToken.idToken
connectorStatus.transactionGroupIdToken = groupIdToken?.idToken
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Transaction state set successfully for connector ${connectorId.toString()}`
)
- logger.debug(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Updating connector ${connectorId.toString()} status to Occupied`
- )
- await sendAndSetConnectorStatus(
- chargingStation,
- connectorId,
- ConnectorStatusEnum.Occupied,
- evseId
- )
-
if (chargingProfile != null) {
connectorStatus.chargingProfiles ??= []
connectorStatus.chargingProfiles.push(chargingProfile)
presentedGroupIdToken?: OCPP20IdTokenType
): boolean {
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
- if (connectorStatus?.transactionStarted !== true) {
+ if (
+ connectorStatus?.transactionStarted !== true &&
+ connectorStatus?.transactionPending !== true
+ ) {
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.isAuthorizedToStopTransaction: No active transaction on connector ${connectorId.toString()}`
)
import type { OCPP20IncomingRequestCommand } from '../../../types/index.js'
-import {
- addConfigurationKey,
- type ChargingStation,
- resetConnectorStatus,
-} from '../../../charging-station/index.js'
+import { addConfigurationKey, type ChargingStation } from '../../../charging-station/index.js'
import {
ChargingStationEvents,
ConnectorStatusEnum,
switch (requestPayload.eventType) {
case OCPP20TransactionEventEnumType.Ended:
+ // Cleanup (stopTxUpdatedInterval, resetConnectorStatus, StatusNotification) is owned by
+ // the caller that sends TransactionEvent(Ended) — see requestStopTransaction in OCPP20ServiceUtils.
if (connectorId != null) {
- if (
- !chargingStation.isChargingStationAvailable() ||
- !chargingStation.isConnectorAvailable(connectorId)
- ) {
- sendAndSetConnectorStatus(
- chargingStation,
- connectorId,
- ConnectorStatusEnum.Unavailable
- ).catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error sending StatusNotification(Unavailable):`,
- error
- )
- })
- } else {
- sendAndSetConnectorStatus(
- chargingStation,
- connectorId,
- ConnectorStatusEnum.Available
- ).catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error sending StatusNotification(Available):`,
- error
- )
- })
- }
- chargingStation.stopTxUpdatedInterval(connectorId)
- chargingStation.stopMeterValues(connectorId)
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} ENDED on connector ${connectorId.toString()}`
)
}
- resetConnectorStatus(connectorStatus)
break
case OCPP20TransactionEventEnumType.Started:
- if (connectorStatus != null && connectorStatus.transactionStarted !== true) {
+ if (connectorStatus != null) {
connectorStatus.transactionStarted = true
- connectorStatus.transactionId = requestPayload.transactionInfo.transactionId
- connectorStatus.transactionIdTag = requestPayload.idToken?.idToken
- connectorStatus.transactionStart = new Date()
- connectorStatus.transactionEnergyActiveImportRegisterValue = 0
- if (connectorId != null) {
+ connectorStatus.transactionPending = false
+ connectorStatus.transactionId ??= requestPayload.transactionInfo.transactionId
+ connectorStatus.transactionIdTag ??= requestPayload.idToken?.idToken
+ connectorStatus.transactionStart ??= new Date()
+ connectorStatus.transactionEnergyActiveImportRegisterValue ??= 0
+ const isIdTokenAccepted =
+ payload.idTokenInfo == null ||
+ payload.idTokenInfo.status === OCPP20AuthorizationStatusEnumType.Accepted
+ if (connectorId != null && isIdTokenAccepted) {
sendAndSetConnectorStatus(
chargingStation,
connectorId,
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken info status: ${payload.idTokenInfo.status}`
)
- // D01/D05: Stop transaction when idToken authorization is rejected by CSMS
- const rejectedStatuses = new Set<OCPP20AuthorizationStatusEnumType>([
- OCPP20AuthorizationStatusEnumType.Blocked,
- OCPP20AuthorizationStatusEnumType.Expired,
- OCPP20AuthorizationStatusEnumType.Invalid,
- OCPP20AuthorizationStatusEnumType.NoCredit,
- ])
- if (rejectedStatuses.has(payload.idTokenInfo.status)) {
+ // E05.FR.09/FR.10 + E06.FR.04: Deauthorize transaction when idToken is not accepted by CSMS
+ if (payload.idTokenInfo.status !== OCPP20AuthorizationStatusEnumType.Accepted) {
logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken authorization rejected with status '${payload.idTokenInfo.status}', stopping active transaction per OCPP 2.0.1 spec (D01/D05)`
+ `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken authorization rejected with status '${payload.idTokenInfo.status}', de-authorizing transaction per E05.FR.09/E05.FR.10/E06.FR.04`
)
- // Find the specific connector for this transaction
- const connectorId = chargingStation.getConnectorIdByTransactionId(
+ const txConnectorId = chargingStation.getConnectorIdByTransactionId(
requestPayload.transactionInfo.transactionId
)
- const evseId = chargingStation.getEvseIdByTransactionId(
+ const txEvseId = chargingStation.getEvseIdByTransactionId(
requestPayload.transactionInfo.transactionId
)
- if (connectorId != null && evseId != null) {
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Stopping transaction ${requestPayload.transactionInfo.transactionId} on EVSE ${evseId.toString()}, connector ${connectorId.toString()} due to rejected idToken`
- )
- OCPP20ServiceUtils.requestStopTransaction(chargingStation, connectorId, evseId).catch(
- (error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error stopping transaction ${requestPayload.transactionInfo.transactionId} on connector ${connectorId.toString()}:`,
- error
- )
- }
- )
+ if (txConnectorId != null && txEvseId != null) {
+ OCPP20ServiceUtils.requestDeauthorizeTransaction(
+ chargingStation,
+ txConnectorId,
+ txEvseId
+ ).catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error de-authorizing transaction ${requestPayload.transactionInfo.transactionId} on connector ${txConnectorId.toString()}:`,
+ error
+ )
+ })
} else {
logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Could not find connector for transaction ${requestPayload.transactionInfo.transactionId}, cannot stop transaction`
+ `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Could not find connector for transaction ${requestPayload.transactionInfo.transactionId}, cannot de-authorize`
)
}
}
import { type ChargingStation, resetConnectorStatus } from '../../../charging-station/index.js'
import { OCPPError } from '../../../exception/index.js'
import {
+ type ConnectorStatus,
ConnectorStatusEnum,
ErrorType,
type JsonObject,
OCPP20ReadingContextEnumType,
} from '../../../types/ocpp/2.0/MeterValues.js'
import {
+ OCPP20ChargingStateEnumType,
type OCPP20EVSEType,
OCPP20ReasonEnumType,
type OCPP20TransactionEventOptions,
return { bytesLimit, itemsLimit }
}
+ // E05.FR.09/FR.10 + E06.FR.04: Deauthorization flow when CSMS rejects idToken.
+ // Assumes StopTxOnInvalidId=true (simulator default). Sends Updated(Deauthorized, SuspendedEVSE)
+ // then Ended(Deauthorized, DeAuthorized) then cleanup.
+ public static async requestDeauthorizeTransaction (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ evseId?: number
+ ): Promise<OCPP20TransactionEventResponse> {
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ if (
+ (connectorStatus?.transactionStarted === true ||
+ connectorStatus?.transactionPending === true) &&
+ connectorStatus.transactionId != null
+ ) {
+ const transactionId =
+ typeof connectorStatus.transactionId === 'string'
+ ? connectorStatus.transactionId
+ : connectorStatus.transactionId.toString()
+
+ await this.sendTransactionEvent(
+ chargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.Deauthorized,
+ connectorId,
+ transactionId,
+ {
+ chargingState: OCPP20ChargingStateEnumType.SuspendedEVSE,
+ evseId,
+ }
+ )
+
+ const finalMeterValues = this.buildFinalMeterValues(connectorStatus)
+
+ const response = await this.sendTransactionEvent(
+ chargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.Deauthorized,
+ connectorId,
+ transactionId,
+ {
+ evseId,
+ meterValue: finalMeterValues.length > 0 ? finalMeterValues : undefined,
+ stoppedReason: OCPP20ReasonEnumType.DeAuthorized,
+ }
+ )
+
+ chargingStation.stopTxUpdatedInterval(connectorId)
+ resetConnectorStatus(connectorStatus)
+ await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available)
+
+ return response
+ }
+ throw new OCPPError(
+ ErrorType.PROPERTY_CONSTRAINT_VIOLATION,
+ `No active transaction on connector ${connectorId.toString()}`
+ )
+ }
+
public static async requestStopTransaction (
chargingStation: ChargingStation,
connectorId: number,
evseId?: number
): Promise<OCPP20TransactionEventResponse> {
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
- if (connectorStatus?.transactionStarted && connectorStatus.transactionId != null) {
+ if (
+ (connectorStatus?.transactionStarted === true ||
+ connectorStatus?.transactionPending === true) &&
+ connectorStatus.transactionId != null
+ ) {
let transactionId: string
if (typeof connectorStatus.transactionId === 'string') {
transactionId = connectorStatus.transactionId
}
// F03.FR.04: Build final meter values for TransactionEvent(Ended)
- const finalMeterValues: OCPP20MeterValue[] = []
- const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
- if (energyValue >= 0) {
- finalMeterValues.push({
- sampledValue: [
- {
- context: OCPP20ReadingContextEnumType.TRANSACTION_END,
- measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
- value: energyValue,
- },
- ],
- timestamp: new Date(),
- })
- }
+ const finalMeterValues = this.buildFinalMeterValues(connectorStatus)
const response = await this.sendTransactionEvent(
chargingStation,
throw error
}
}
+
+ private static buildFinalMeterValues (connectorStatus: ConnectorStatus): OCPP20MeterValue[] {
+ const finalMeterValues: OCPP20MeterValue[] = []
+ const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+ if (energyValue >= 0) {
+ finalMeterValues.push({
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ value: energyValue,
+ },
+ ],
+ timestamp: new Date(),
+ })
+ }
+ return finalMeterValues
+ }
}
export function buildTransactionEvent (
chargingStation: ChargingStation,
OCPP20TriggerReasonEnumType,
} from '../../../../types/ocpp/2.0/Transaction.js'
import { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js'
-import { logger, truncateId } from '../../../../utils/index.js'
+import { generateUUID, logger, truncateId } from '../../../../utils/index.js'
import {
AuthContext,
AuthenticationMethod,
// OCPP 2.0: Authorization through TransactionEvent
// According to OCPP 2.0.1 spec section G03 - Authorization
- const tempTransactionId =
- transactionId != null ? transactionId.toString() : `auth-${Date.now().toString()}`
+ const tempTransactionId = transactionId != null ? transactionId.toString() : generateUUID()
// Get EVSE ID from connector
const evseId = connectorId // In OCPP 2.0, connector maps to EVSE
* that occurs after the transaction has been authorized.
*/
transactionIdTokenSent?: boolean
+ /**
+ * OCPP 2.0.1: Transaction is pending CSMS acknowledgment via TransactionEvent response.
+ * Set by RequestStartTransaction handler to block duplicate starts before the response
+ * handler finalizes the transaction state with transactionStarted = true.
+ */
+ transactionPending?: boolean
transactionRemoteStarted?: boolean
transactionSeqNo?: number
transactionSetInterval?: NodeJS.Timeout
assert.fail('Expected connectorStatus to be defined')
}
assert.strictEqual(connectorStatus.transactionGroupIdToken, GROUP_ID_TOKEN)
- assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionPending, true)
const isAuthorized = testableService.isAuthorizedToStopTransaction(
mockStation,
}
assert.strictEqual(connectorStatus.remoteStartId, 42)
assert.strictEqual(connectorStatus.transactionIdTag, 'REMOTE_TOKEN_456')
- assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionPending, true)
assert.strictEqual(connectorStatus.transactionId, response.transactionId)
OCPPAuthServiceFactory.clearAllInstances()
await it('should not stop transaction when idTokenInfo status is Accepted', () => {
// Arrange
- const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
- Promise.resolve({ status: 'Accepted' })
+ const mockDeauthTransaction = mock.method(
+ OCPP20ServiceUtils,
+ 'requestDeauthorizeTransaction',
+ () => Promise.resolve({ status: 'Accepted' })
)
const payload: OCPP20TransactionEventResponse = {
idTokenInfo: {
testable.handleResponseTransactionEvent(station, payload, requestPayload)
// Assert
- assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
+ assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0)
})
await it('should stop only the specific transaction when idTokenInfo status is Invalid', () => {
// Arrange
- const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
- Promise.resolve({ status: 'Accepted' })
+ const mockDeauthTransaction = mock.method(
+ OCPP20ServiceUtils,
+ 'requestDeauthorizeTransaction',
+ () => Promise.resolve({ status: 'Accepted' })
)
const payload: OCPP20TransactionEventResponse = {
idTokenInfo: {
testable.handleResponseTransactionEvent(station, payload, requestPayload)
// Assert — only the specific connector (1) on EVSE (1) is stopped
- assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
- assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], station)
- assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[1], 1)
- assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[2], 1)
+ assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1)
+ assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[0], station)
+ assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[1], 1)
+ assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[2], 1)
})
await it('should stop only the specific transaction when idTokenInfo status is Blocked', () => {
// Arrange
- const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
- Promise.resolve({ status: 'Accepted' })
+ const mockDeauthTransaction = mock.method(
+ OCPP20ServiceUtils,
+ 'requestDeauthorizeTransaction',
+ () => Promise.resolve({ status: 'Accepted' })
)
const payload: OCPP20TransactionEventResponse = {
idTokenInfo: {
testable.handleResponseTransactionEvent(station, payload, requestPayload)
// Assert
- assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
- assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], station)
+ assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1)
+ assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[0], station)
})
await it('should not stop transaction when only chargingPriority is present', () => {
// Arrange
- const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
- Promise.resolve({ status: 'Accepted' })
+ const mockDeauthTransaction = mock.method(
+ OCPP20ServiceUtils,
+ 'requestDeauthorizeTransaction',
+ () => Promise.resolve({ status: 'Accepted' })
)
const payload: OCPP20TransactionEventResponse = {
chargingPriority: 5,
testable.handleResponseTransactionEvent(station, payload, requestPayload)
// Assert
- assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
+ assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0)
})
await it('should handle empty response without stopping transaction', () => {
// Arrange
- const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
- Promise.resolve({ status: 'Accepted' })
+ const mockDeauthTransaction = mock.method(
+ OCPP20ServiceUtils,
+ 'requestDeauthorizeTransaction',
+ () => Promise.resolve({ status: 'Accepted' })
)
const payload: OCPP20TransactionEventResponse = {}
const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID)
testable.handleResponseTransactionEvent(station, payload, requestPayload)
// Assert
- assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
+ assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0)
})
await it('should stop only the specific transaction when idTokenInfo status is Expired', () => {
// Arrange
- const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
- Promise.resolve({ status: 'Accepted' })
+ const mockDeauthTransaction = mock.method(
+ OCPP20ServiceUtils,
+ 'requestDeauthorizeTransaction',
+ () => Promise.resolve({ status: 'Accepted' })
)
const payload: OCPP20TransactionEventResponse = {
idTokenInfo: {
testable.handleResponseTransactionEvent(station, payload, requestPayload)
// Assert
- assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
+ assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1)
})
await it('should stop only the specific transaction when idTokenInfo status is NoCredit', () => {
// Arrange
- const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
- Promise.resolve({ status: 'Accepted' })
+ const mockDeauthTransaction = mock.method(
+ OCPP20ServiceUtils,
+ 'requestDeauthorizeTransaction',
+ () => Promise.resolve({ status: 'Accepted' })
)
const payload: OCPP20TransactionEventResponse = {
idTokenInfo: {
testable.handleResponseTransactionEvent(station, payload, requestPayload)
// Assert
- assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
+ assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1)
})
await it('should not stop transaction when response has totalCost and updatedPersonalMessage', () => {
// Arrange
- const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
- Promise.resolve({ status: 'Accepted' })
+ const mockDeauthTransaction = mock.method(
+ OCPP20ServiceUtils,
+ 'requestDeauthorizeTransaction',
+ () => Promise.resolve({ status: 'Accepted' })
)
const payload: OCPP20TransactionEventResponse = {
totalCost: 12.5,
testable.handleResponseTransactionEvent(station, payload, requestPayload)
// Assert
- assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
+ assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0)
})
await it('should stop only the targeted transaction on multi-EVSE station', () => {
connector2.transactionId = txn2
}
- const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
- Promise.resolve({ status: 'Accepted' })
+ const mockDeauthTransaction = mock.method(
+ OCPP20ServiceUtils,
+ 'requestDeauthorizeTransaction',
+ () => Promise.resolve({ status: 'Accepted' })
)
const payload: OCPP20TransactionEventResponse = {
idTokenInfo: {
)
// Assert — only 1 stop call targeting connector 1, EVSE 2 untouched
- assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
- assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], multiStation)
- assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[1], 1)
+ assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1)
+ assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[0], multiStation)
+ assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[1], 1)
})
})
buildTransactionEvent,
OCPP20ServiceUtils,
} from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
+import { OCPPError } from '../../../../src/exception/OCPPError.js'
+import {
+ AttributeEnumType,
+ OCPP20ComponentName,
+ OCPP20RequiredVariableName,
+} from '../../../../src/types/index.js'
import {
ConnectorStatusEnum,
OCPP20TransactionEventEnumType,
OCPP20TriggerReasonEnumType,
OCPPVersion,
} from '../../../../src/types/index.js'
+import {
+ OCPP20MeasurandEnumType,
+ OCPP20ReadingContextEnumType,
+} from '../../../../src/types/ocpp/2.0/MeterValues.js'
import {
OCPP20ChargingStateEnumType,
OCPP20IdTokenEnumType,
})
})
})
+
+ await describe('getTxUpdatedInterval', async () => {
+ let station: ChargingStation
+
+ beforeEach(() => {
+ const mockTracking = createMockStationWithRequestTracking()
+ station = mockTracking.station
+ resetLimits(station)
+ })
+
+ afterEach(() => {
+ OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+ standardCleanup()
+ })
+
+ await it('should return default interval when TxUpdatedInterval is not configured', () => {
+ const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+ assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000)
+ })
+
+ await it('should return configured interval in milliseconds', () => {
+ OCPP20VariableManager.getInstance().setVariables(station, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '60',
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+ },
+ ])
+
+ const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+ assert.strictEqual(interval, 60000)
+ })
+
+ await it('should return default for non-numeric value', () => {
+ OCPP20VariableManager.getInstance().setVariables(station, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: 'abc',
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+ },
+ ])
+
+ const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+ assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000)
+ })
+
+ await it('should return default for zero value', () => {
+ OCPP20VariableManager.getInstance().setVariables(station, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '0',
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+ },
+ ])
+
+ const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+ assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000)
+ })
+
+ await it('should return default for negative value', () => {
+ OCPP20VariableManager.getInstance().setVariables(station, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '-10',
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+ },
+ ])
+
+ const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+ assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000)
+ })
+ })
+
+ await describe('requestDeauthorizeTransaction', async () => {
+ let mockTracking: MockStationWithTracking
+
+ beforeEach(() => {
+ mockTracking = createMockStationWithRequestTracking()
+ resetConnectorTransactionState(mockTracking.station)
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await it('should send Updated(Deauthorized, SuspendedEVSE) then Ended(Deauthorized, DeAuthorized)', async () => {
+ // Arrange
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.notStrictEqual(connectorStatus, undefined)
+ if (connectorStatus != null) {
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ }
+
+ // Act
+ await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
+
+ // Assert
+ const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent')
+ assert.strictEqual(txEvents.length, 2)
+
+ const updatedEvent = txEvents[0].payload
+ assert.strictEqual(updatedEvent.eventType, OCPP20TransactionEventEnumType.Updated)
+ assert.strictEqual(updatedEvent.triggerReason, OCPP20TriggerReasonEnumType.Deauthorized)
+ assert.strictEqual(updatedEvent.chargingState, OCPP20ChargingStateEnumType.SuspendedEVSE)
+
+ const endedEvent = txEvents[1].payload
+ assert.strictEqual(endedEvent.eventType, OCPP20TransactionEventEnumType.Ended)
+ assert.strictEqual(endedEvent.triggerReason, OCPP20TriggerReasonEnumType.Deauthorized)
+ assert.strictEqual(endedEvent.stoppedReason, OCPP20ReasonEnumType.DeAuthorized)
+ })
+
+ await it('should include final meter values in Ended event', async () => {
+ // Arrange
+ const connectorId = 2
+ const transactionId = generateUUID()
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.notStrictEqual(connectorStatus, undefined)
+ if (connectorStatus != null) {
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 1500
+ }
+
+ // Act
+ await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 2)
+
+ // Assert
+ const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent')
+ assert.strictEqual(txEvents.length, 2)
+
+ const endedPayload = txEvents[1].payload
+ assert.notStrictEqual(endedPayload.meterValue, undefined)
+ const meterValues = endedPayload.meterValue as {
+ sampledValue: { context: string; measurand: string; value: number }[]
+ timestamp: Date
+ }[]
+ assert.strictEqual(meterValues.length, 1)
+ assert.strictEqual(meterValues[0].sampledValue.length, 1)
+ assert.strictEqual(
+ meterValues[0].sampledValue[0].measurand,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.strictEqual(meterValues[0].sampledValue[0].value, 1500)
+ assert.strictEqual(
+ meterValues[0].sampledValue[0].context,
+ OCPP20ReadingContextEnumType.TRANSACTION_END
+ )
+ })
+
+ await it('should reset connector status after deauthorization', async () => {
+ // Arrange
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.notStrictEqual(connectorStatus, undefined)
+ if (connectorStatus != null) {
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 100
+ }
+
+ // Act
+ await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
+
+ // Assert
+ const postStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.notStrictEqual(postStatus, undefined)
+ if (postStatus != null) {
+ assert.strictEqual(postStatus.transactionStarted, false)
+ assert.strictEqual(postStatus.transactionId, undefined)
+ }
+ })
+
+ await it('should throw if no active transaction', async () => {
+ const connectorId = 1
+
+ await assert.rejects(
+ OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1),
+ (error: unknown) => {
+ assert.ok(error instanceof OCPPError)
+ return true
+ }
+ )
+ })
+
+ await it('should propagate error and skip cleanup if Updated event fails', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ if (connectorStatus != null) {
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ }
+
+ const originalSend = OCPP20ServiceUtils.sendTransactionEvent.bind(OCPP20ServiceUtils)
+ const sendMock = mock.method(OCPP20ServiceUtils, 'sendTransactionEvent', () => {
+ sendMock.mock.restore()
+ OCPP20ServiceUtils.sendTransactionEvent = originalSend
+ return Promise.reject(new Error('Network failure'))
+ })
+
+ await assert.rejects(
+ OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1),
+ (error: unknown) => {
+ assert.ok(error instanceof Error)
+ assert.strictEqual(error.message, 'Network failure')
+ return true
+ }
+ )
+
+ const postStatus = mockTracking.station.getConnectorStatus(connectorId)
+ if (postStatus != null) {
+ assert.strictEqual(postStatus.transactionStarted, true)
+ assert.strictEqual(postStatus.transactionId, transactionId)
+ }
+ })
+ })
})