From: Jérôme Benoit Date: Sun, 22 Mar 2026 18:26:00 +0000 (+0100) Subject: fix(ocpp2): implement StopTxOnInvalidId and MaxEnergyOnInvalidId per E05 (#1745) X-Git-Tag: v3.2~1 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=865b7d1f8d56d1152d57784d2a542d1ca6b62f2b;p=e-mobility-charging-stations-simulator.git fix(ocpp2): implement StopTxOnInvalidId and MaxEnergyOnInvalidId per E05 (#1745) * fix(ocpp2): implement StopTxOnInvalidId and MaxEnergyOnInvalidId per E05 Implement the full deauthorization decision tree per OCPP 2.0.1 spec: - StopTxOnInvalidId=false: transaction continues, no termination - StopTxOnInvalidId=true + MaxEnergyOnInvalidId>0: defer termination until energy limit reached (checked in periodic meter values callback) - StopTxOnInvalidId=true + MaxEnergyOnInvalidId=0: immediate suspension and termination Also includes: - Deduplicate transactionSetInterval/transactionTxUpdatedSetInterval into single transactionMeterValuesSetInterval field - Add transactionDeauthorized/transactionDeauthorizedEnergyWh to ConnectorStatus for deauth state tracking (cleared in resetConnectorStatus) - Extract readVariableValue/readVariableAsBoolean/readVariableAsInteger helpers using convertToInt and convertToBoolean utilities - Fix buildEvsesStatus test that was missing evse-to-connector wiring * refactor(ocpp2): address PR review comments - Add logger.warn in readVariableAsInteger catch for diagnostic - Replace unsafe 'as string' cast with .toString() in periodic callback - Add tests for StopTxOnInvalidId=false and deauth state tracking * chore: update webui.png Signed-off-by: Jérôme Benoit * test: add resetConnectorStatus unit tests to Helpers.test.ts Verify all 18 transaction fields are properly cleaned, TX_PROFILE charging profiles matching the transaction are removed, and non-transaction fields are preserved. --------- Signed-off-by: Jérôme Benoit --- diff --git a/src/charging-station/Helpers.ts b/src/charging-station/Helpers.ts index 85bf9322..2ae5aedb 100644 --- a/src/charging-station/Helpers.ts +++ b/src/charging-station/Helpers.ts @@ -568,6 +568,8 @@ export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefine delete connectorStatus.transactionSeqNo delete connectorStatus.transactionEvseSent delete connectorStatus.transactionIdTokenSent + delete connectorStatus.transactionDeauthorized + delete connectorStatus.transactionDeauthorizedEnergyWh } export const prepareConnectorStatus = (connectorStatus: ConnectorStatus): ConnectorStatus => { diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index 9b4c2029..ff5b5971 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -617,7 +617,7 @@ export class OCPP16ServiceUtils extends OCPPServiceUtils { ) return } - connectorStatus.transactionSetInterval = setInterval(() => { + connectorStatus.transactionMeterValuesSetInterval = setInterval(() => { const transactionId = convertToInt(connectorStatus.transactionId) const meterValue = buildMeterValue(chargingStation, connectorId, transactionId, interval) chargingStation.ocppRequestService @@ -658,9 +658,9 @@ export class OCPP16ServiceUtils extends OCPPServiceUtils { connectorId: number ): void { const connectorStatus = chargingStation.getConnectorStatus(connectorId) - if (connectorStatus?.transactionSetInterval != null) { - clearInterval(connectorStatus.transactionSetInterval) - delete connectorStatus.transactionSetInterval + if (connectorStatus?.transactionMeterValuesSetInterval != null) { + clearInterval(connectorStatus.transactionMeterValuesSetInterval) + delete connectorStatus.transactionMeterValuesSetInterval } } diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 711de446..abea3b60 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -29,6 +29,8 @@ import { import { clampToSafeTimerValue, Constants, + convertToBoolean, + convertToInt, convertToIntOrNaN, formatDurationMilliSeconds, generateUUID, @@ -296,7 +298,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { return { bytesLimit, itemsLimit } } - // E05.FR.09/FR.10 + E06.FR.04: Updated(Deauthorized) → Ended(DeAuthorized). Assumes StopTxOnInvalidId=true. public static async requestDeauthorizeTransaction ( chargingStation: ChargingStation, connectorId: number, @@ -307,6 +308,50 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { connectorId ) + const stopTxOnInvalidId = OCPP20ServiceUtils.readVariableAsBoolean( + chargingStation, + OCPP20ComponentName.TxCtrlr, + OCPP20RequiredVariableName.StopTxOnInvalidId, + true + ) + + if (!stopTxOnInvalidId) { + await this.sendTransactionEvent( + chargingStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.Deauthorized, + connectorId, + transactionId, + { evseId } + ) + return { idTokenInfo: undefined } + } + + const maxEnergyOnInvalidId = OCPP20ServiceUtils.readVariableAsInteger( + chargingStation, + OCPP20ComponentName.TxCtrlr, + 'MaxEnergyOnInvalidId', + 0 + ) + + if (maxEnergyOnInvalidId > 0) { + // E05.FR.03: continue charging up to MaxEnergyOnInvalidId Wh before terminating + connectorStatus.transactionDeauthorized = true + connectorStatus.transactionDeauthorizedEnergyWh = + connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0 + + await this.sendTransactionEvent( + chargingStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.Deauthorized, + connectorId, + transactionId, + { evseId } + ) + + return { idTokenInfo: undefined } + } + await this.sendTransactionEvent( chargingStation, OCPP20TransactionEventEnumType.Updated, @@ -500,15 +545,46 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { ) return } - if (connector.transactionTxUpdatedSetInterval != null) { + if (connector.transactionMeterValuesSetInterval != null) { logger.warn( `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: TxUpdatedInterval already started, stopping first` ) OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId) } - connector.transactionTxUpdatedSetInterval = setInterval(() => { + connector.transactionMeterValuesSetInterval = setInterval(() => { const connectorStatus = chargingStation.getConnectorStatus(connectorId) if (connectorStatus?.transactionStarted === true && connectorStatus.transactionId != null) { + if ( + connectorStatus.transactionDeauthorized === true && + connectorStatus.transactionDeauthorizedEnergyWh != null + ) { + const maxEnergy = OCPP20ServiceUtils.readVariableAsInteger( + chargingStation, + OCPP20ComponentName.TxCtrlr, + 'MaxEnergyOnInvalidId', + 0 + ) + const currentEnergy = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0 + const energySinceDeauth = currentEnergy - connectorStatus.transactionDeauthorizedEnergyWh + if (maxEnergy > 0 && energySinceDeauth >= maxEnergy) { + const evseId = chargingStation.getEvseIdByConnectorId(connectorId) + OCPP20ServiceUtils.terminateTransaction( + chargingStation, + connectorId, + connectorStatus, + connectorStatus.transactionId.toString(), + OCPP20TriggerReasonEnumType.Deauthorized, + OCPP20ReasonEnumType.DeAuthorized, + evseId + ).catch((error: unknown) => { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: Error terminating deauthorized transaction:`, + error + ) + }) + return + } + } const meterValue = buildMeterValue( chargingStation, connectorId, @@ -599,9 +675,9 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { connectorId: number ): void { const connector = chargingStation.getConnectorStatus(connectorId) - if (connector?.transactionTxUpdatedSetInterval != null) { - clearInterval(connector.transactionTxUpdatedSetInterval) - delete connector.transactionTxUpdatedSetInterval + if (connector?.transactionMeterValuesSetInterval != null) { + clearInterval(connector.transactionMeterValuesSetInterval) + delete connector.transactionMeterValuesSetInterval logger.info( `${chargingStation.logPrefix()} ${moduleName}.stopPeriodicMeterValues: TxUpdatedInterval stopped` ) @@ -638,12 +714,58 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { ) } + private static readVariableAsBoolean ( + chargingStation: ChargingStation, + componentName: string, + variableName: string, + defaultValue: boolean + ): boolean { + const value = OCPP20ServiceUtils.readVariableValue(chargingStation, componentName, variableName) + return value != null ? convertToBoolean(value) : defaultValue + } + + private static readVariableAsInteger ( + chargingStation: ChargingStation, + componentName: string, + variableName: string, + defaultValue: number + ): number { + const value = OCPP20ServiceUtils.readVariableValue(chargingStation, componentName, variableName) + if (value != null) { + try { + return convertToInt(value) + } catch { + logger.warn( + `${moduleName}.readVariableAsInteger: Cannot convert '${value}' to integer for ${componentName}.${variableName}, using default ${defaultValue.toString()}` + ) + return defaultValue + } + } + return defaultValue + } + private static readVariableAsIntervalMs ( chargingStation: ChargingStation, componentName: string, variableName: string, defaultSeconds: number ): number { + const intervalSeconds = OCPP20ServiceUtils.readVariableAsInteger( + chargingStation, + componentName, + variableName, + defaultSeconds + ) + return intervalSeconds > 0 + ? secondsToMilliseconds(intervalSeconds) + : secondsToMilliseconds(defaultSeconds) + } + + private static readVariableValue ( + chargingStation: ChargingStation, + componentName: string, + variableName: string + ): string | undefined { const variableManager = OCPP20VariableManager.getInstance() const results = variableManager.getVariables(chargingStation, [ { @@ -652,12 +774,9 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { }, ]) if (results.length > 0 && results[0].attributeValue != null) { - const intervalSeconds = parseInt(results[0].attributeValue, 10) - if (!isNaN(intervalSeconds) && intervalSeconds > 0) { - return secondsToMilliseconds(intervalSeconds) - } + return results[0].attributeValue } - return secondsToMilliseconds(defaultSeconds) + return undefined } private static resolveActiveTransaction ( diff --git a/src/types/ConnectorStatus.ts b/src/types/ConnectorStatus.ts index f6f40ec1..5243c157 100644 --- a/src/types/ConnectorStatus.ts +++ b/src/types/ConnectorStatus.ts @@ -21,6 +21,8 @@ export interface ConnectorStatus { reservation?: Reservation status?: ConnectorStatusEnum transactionBeginMeterValue?: MeterValue + transactionDeauthorized?: boolean + transactionDeauthorizedEnergyWh?: number transactionEnergyActiveImportRegisterValue?: number // In Wh /** * OCPP 2.0.1 offline-first: Queue of TransactionEvents waiting to be sent @@ -43,6 +45,7 @@ export interface ConnectorStatus { * that occurs after the transaction has been authorized. */ transactionIdTokenSent?: boolean + transactionMeterValuesSetInterval?: NodeJS.Timeout /** * OCPP 2.0.1 E02 compliance: Transaction pending CSMS acknowledgment. * Blocks duplicate RequestStartTransaction until response handler sets transactionStarted. @@ -50,10 +53,8 @@ export interface ConnectorStatus { transactionPending?: boolean transactionRemoteStarted?: boolean transactionSeqNo?: number - transactionSetInterval?: NodeJS.Timeout transactionStart?: Date transactionStarted?: boolean - transactionTxUpdatedSetInterval?: NodeJS.Timeout type?: ConnectorEnumType } diff --git a/src/utils/ChargingStationConfigurationUtils.ts b/src/utils/ChargingStationConfigurationUtils.ts index c69af9d5..db0e6fb4 100644 --- a/src/utils/ChargingStationConfigurationUtils.ts +++ b/src/utils/ChargingStationConfigurationUtils.ts @@ -34,12 +34,7 @@ export const buildConnectorEntries = (chargingStation: ChargingStation): Connect return [...chargingStation.connectors.entries()].map( ([ connectorId, - { - transactionEventQueue, - transactionSetInterval, - transactionTxUpdatedSetInterval, - ...connector - }, + { transactionEventQueue, transactionMeterValuesSetInterval, ...connector }, ]) => ({ connector, connectorId, @@ -53,12 +48,7 @@ export const buildConnectorsStatus = ( return [...chargingStation.connectors.entries()].map( ([ connectorId, - { - transactionEventQueue, - transactionSetInterval, - transactionTxUpdatedSetInterval, - ...connectorStatus - }, + { transactionEventQueue, transactionMeterValuesSetInterval, ...connectorStatus }, ]) => [connectorId, connectorStatus] ) } @@ -69,12 +59,7 @@ export const buildEvseEntries = (chargingStation: ChargingStation): EvseEntry[] connectors: [...evseStatus.connectors.entries()].map( ([ connectorId, - { - transactionEventQueue, - transactionSetInterval, - transactionTxUpdatedSetInterval, - ...connector - }, + { transactionEventQueue, transactionMeterValuesSetInterval, ...connector }, ]) => ({ connector, connectorId, @@ -91,12 +76,7 @@ export const buildEvsesStatus = ( const connectorsStatus: [number, ConnectorStatus][] = [...evseStatus.connectors.entries()].map( ([ connectorId, - { - transactionEventQueue, - transactionSetInterval, - transactionTxUpdatedSetInterval, - ...connector - }, + { transactionEventQueue, transactionMeterValuesSetInterval, ...connector }, ]) => [connectorId, connector] ) const { connectors: _, ...evseStatusRest } = evseStatus diff --git a/tests/charging-station/ChargingStation-Transactions.test.ts b/tests/charging-station/ChargingStation-Transactions.test.ts index 600b0d8c..dd528e64 100644 --- a/tests/charging-station/ChargingStation-Transactions.test.ts +++ b/tests/charging-station/ChargingStation-Transactions.test.ts @@ -542,8 +542,8 @@ await describe('ChargingStation Transaction Management', async () => { // Assert - meter values interval should be created if (connector1 != null) { - assert.notStrictEqual(connector1.transactionSetInterval, undefined) - assert.strictEqual(typeof connector1.transactionSetInterval, 'object') + assert.notStrictEqual(connector1.transactionMeterValuesSetInterval, undefined) + assert.strictEqual(typeof connector1.transactionMeterValuesSetInterval, 'object') } }) }) @@ -559,12 +559,12 @@ await describe('ChargingStation Transaction Management', async () => { connector1.transactionId = 100 } OCPP16ServiceUtils.startPeriodicMeterValues(station, 1, 10000) - const firstInterval = connector1?.transactionSetInterval + const firstInterval = connector1?.transactionMeterValuesSetInterval // Act OCPP16ServiceUtils.stopPeriodicMeterValues(station, 1) OCPP16ServiceUtils.startPeriodicMeterValues(station, 1, 15000) - const secondInterval = connector1?.transactionSetInterval + const secondInterval = connector1?.transactionMeterValuesSetInterval // Assert - interval should be different assert.notStrictEqual(secondInterval, undefined) @@ -589,7 +589,7 @@ await describe('ChargingStation Transaction Management', async () => { OCPP16ServiceUtils.stopPeriodicMeterValues(station, 1) // Assert - interval should be cleared - assert.strictEqual(connector1?.transactionSetInterval, undefined) + assert.strictEqual(connector1?.transactionMeterValuesSetInterval, undefined) }) }) @@ -612,8 +612,8 @@ await describe('ChargingStation Transaction Management', async () => { // Assert - transaction updated interval should be created if (connector1 != null) { - assert.notStrictEqual(connector1.transactionTxUpdatedSetInterval, undefined) - assert.strictEqual(typeof connector1.transactionTxUpdatedSetInterval, 'object') + assert.notStrictEqual(connector1.transactionMeterValuesSetInterval, undefined) + assert.strictEqual(typeof connector1.transactionMeterValuesSetInterval, 'object') } }) }) @@ -637,7 +637,7 @@ await describe('ChargingStation Transaction Management', async () => { OCPP20ServiceUtils.stopPeriodicMeterValues(station, 1) // Assert - interval should be cleared - assert.strictEqual(connector1?.transactionTxUpdatedSetInterval, undefined) + assert.strictEqual(connector1?.transactionMeterValuesSetInterval, undefined) }) }) }) diff --git a/tests/charging-station/Helpers.test.ts b/tests/charging-station/Helpers.test.ts index 3bba97cf..6f522fe4 100644 --- a/tests/charging-station/Helpers.test.ts +++ b/tests/charging-station/Helpers.test.ts @@ -19,17 +19,22 @@ import { hasPendingReservation, hasPendingReservations, hasReservationExpired, + resetConnectorStatus, validateStationInfo, } from '../../src/charging-station/Helpers.js' import { AvailabilityType, + type ChargingProfile, + ChargingProfilePurposeType, type ChargingStationConfiguration, type ChargingStationInfo, type ChargingStationTemplate, type ConnectorStatus, ConnectorStatusEnum, + type MeterValue, OCPPVersion, type Reservation, + type SampledValueTemplate, } from '../../src/types/index.js' import { logger } from '../../src/utils/index.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' @@ -771,4 +776,103 @@ await describe('Helpers', async () => { // Act & Assert assert.strictEqual(hasPendingReservations(chargingStation), false) }) + + await describe('resetConnectorStatus', async () => { + afterEach(() => { + standardCleanup() + }) + + await it('should be a no-op for undefined input', () => { + resetConnectorStatus(undefined) + }) + + await it('should reset all transaction fields', () => { + const connectorStatus: ConnectorStatus = { + availability: AvailabilityType.Operative, + MeterValues: [], + transactionBeginMeterValue: { sampledValue: [], timestamp: new Date() } as MeterValue, + transactionDeauthorized: true, + transactionDeauthorizedEnergyWh: 500, + transactionEnergyActiveImportRegisterValue: 1234, + transactionEvseSent: true, + transactionGroupIdToken: 'group-token', + transactionId: 'tx-123', + transactionIdTag: 'tag-abc', + transactionIdTokenSent: true, + transactionPending: true, + transactionRemoteStarted: true, + transactionSeqNo: 5, + transactionStart: new Date(), + transactionStarted: true, + } + + resetConnectorStatus(connectorStatus) + + assert.strictEqual(connectorStatus.transactionStarted, false) + assert.strictEqual(connectorStatus.transactionPending, false) + assert.strictEqual(connectorStatus.transactionRemoteStarted, false) + assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 0) + assert.strictEqual(connectorStatus.transactionId, undefined) + assert.strictEqual(connectorStatus.transactionIdTag, undefined) + assert.strictEqual(connectorStatus.transactionGroupIdToken, undefined) + assert.strictEqual(connectorStatus.transactionStart, undefined) + assert.strictEqual(connectorStatus.transactionBeginMeterValue, undefined) + assert.strictEqual(connectorStatus.transactionSeqNo, undefined) + assert.strictEqual(connectorStatus.transactionEvseSent, undefined) + assert.strictEqual(connectorStatus.transactionIdTokenSent, undefined) + assert.strictEqual(connectorStatus.transactionDeauthorized, undefined) + assert.strictEqual(connectorStatus.transactionDeauthorizedEnergyWh, undefined) + assert.strictEqual(connectorStatus.idTagAuthorized, false) + assert.strictEqual(connectorStatus.idTagLocalAuthorized, false) + assert.strictEqual(connectorStatus.authorizeIdTag, undefined) + assert.strictEqual(connectorStatus.localAuthorizeIdTag, undefined) + }) + + await it('should remove TX_PROFILE charging profiles matching transaction', () => { + const txProfile = { + chargingProfileId: 1, + chargingProfilePurpose: ChargingProfilePurposeType.TX_PROFILE, + stackLevel: 0, + transactionId: 'tx-123', + } as unknown as ChargingProfile + const otherProfile = { + chargingProfileId: 2, + chargingProfilePurpose: ChargingProfilePurposeType.TX_DEFAULT_PROFILE, + stackLevel: 0, + } as unknown as ChargingProfile + const connectorStatus: ConnectorStatus = { + availability: AvailabilityType.Operative, + chargingProfiles: [txProfile, otherProfile], + MeterValues: [], + transactionId: 'tx-123', + transactionStarted: true, + } + + resetConnectorStatus(connectorStatus) + + if (connectorStatus.chargingProfiles == null) { + assert.fail('chargingProfiles should not be undefined') + } + assert.strictEqual(connectorStatus.chargingProfiles.length, 1) + assert.strictEqual( + connectorStatus.chargingProfiles[0].chargingProfilePurpose, + ChargingProfilePurposeType.TX_DEFAULT_PROFILE + ) + }) + + await it('should preserve non-transaction fields', () => { + const connectorStatus: ConnectorStatus = { + availability: AvailabilityType.Operative, + MeterValues: [{} as unknown as SampledValueTemplate], + status: ConnectorStatusEnum.Available, + transactionStarted: true, + } + + resetConnectorStatus(connectorStatus) + + assert.strictEqual(connectorStatus.availability, AvailabilityType.Operative) + assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Available) + assert.strictEqual(connectorStatus.MeterValues.length, 1) + }) + }) }) diff --git a/tests/charging-station/helpers/StationHelpers.ts b/tests/charging-station/helpers/StationHelpers.ts index 4b25e6fc..b89b46de 100644 --- a/tests/charging-station/helpers/StationHelpers.ts +++ b/tests/charging-station/helpers/StationHelpers.ts @@ -218,26 +218,18 @@ export function cleanupChargingStation (station: ChargingStation): void { // Clear connector transaction state and timers for (const connectorStatus of station.connectors.values()) { - if (connectorStatus.transactionSetInterval != null) { - clearInterval(connectorStatus.transactionSetInterval) - connectorStatus.transactionSetInterval = undefined - } - if (connectorStatus.transactionTxUpdatedSetInterval != null) { - clearInterval(connectorStatus.transactionTxUpdatedSetInterval) - connectorStatus.transactionTxUpdatedSetInterval = undefined + if (connectorStatus.transactionMeterValuesSetInterval != null) { + clearInterval(connectorStatus.transactionMeterValuesSetInterval) + connectorStatus.transactionMeterValuesSetInterval = undefined } } // Clear EVSE connector transaction state and timers for (const evseStatus of station.evses.values()) { for (const connectorStatus of evseStatus.connectors.values()) { - if (connectorStatus.transactionSetInterval != null) { - clearInterval(connectorStatus.transactionSetInterval) - connectorStatus.transactionSetInterval = undefined - } - if (connectorStatus.transactionTxUpdatedSetInterval != null) { - clearInterval(connectorStatus.transactionTxUpdatedSetInterval) - connectorStatus.transactionTxUpdatedSetInterval = undefined + if (connectorStatus.transactionMeterValuesSetInterval != null) { + clearInterval(connectorStatus.transactionMeterValuesSetInterval) + connectorStatus.transactionMeterValuesSetInterval = undefined } } } @@ -954,8 +946,8 @@ function resetConnectorStatus (status: ConnectorStatus, isConnectorZero: boolean status.transactionEnergyActiveImportRegisterValue = 0 // Clear transaction interval - if (status.transactionSetInterval != null) { - clearInterval(status.transactionSetInterval) - status.transactionSetInterval = undefined + if (status.transactionMeterValuesSetInterval != null) { + clearInterval(status.transactionMeterValuesSetInterval) + status.transactionMeterValuesSetInterval = undefined } } diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts index 0e95ffa2..0af0ef59 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -2032,9 +2032,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Clean up any running timers for (let connectorId = 1; connectorId <= 3; connectorId++) { const connector = mockStation.getConnectorStatus(connectorId) - if (connector?.transactionTxUpdatedSetInterval != null) { - clearInterval(connector.transactionTxUpdatedSetInterval) - connector.transactionTxUpdatedSetInterval = undefined + if (connector?.transactionMeterValuesSetInterval != null) { + clearInterval(connector.transactionMeterValuesSetInterval) + connector.transactionMeterValuesSetInterval = undefined } } standardCleanup() @@ -2054,7 +2054,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { await startPeriodicMeterValues(ocpp16Station, 1, 60000) const connector = ocpp16Station.getConnectorStatus(1) - assert.strictEqual(connector?.transactionTxUpdatedSetInterval, undefined) + assert.strictEqual(connector?.transactionMeterValuesSetInterval, undefined) }) }) @@ -2068,7 +2068,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Zero interval should not start timer // This is verified by the implementation logging debug message - assert.strictEqual(connector.transactionTxUpdatedSetInterval, undefined) + assert.strictEqual(connector.transactionMeterValuesSetInterval, undefined) }) await it('should not start timer when interval is negative', () => { @@ -2078,7 +2078,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { assert(connector != null) // Negative interval should not start timer - assert.strictEqual(connector.transactionTxUpdatedSetInterval, undefined) + assert.strictEqual(connector.transactionMeterValuesSetInterval, undefined) }) await it('should handle non-existent connector gracefully', () => { @@ -2527,6 +2527,8 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { connectorStatus.transactionStarted = true connectorStatus.transactionId = transactionId connectorStatus.transactionEnergyActiveImportRegisterValue = 100 + connectorStatus.transactionDeauthorized = true + connectorStatus.transactionDeauthorizedEnergyWh = 50 } // Act @@ -2538,6 +2540,8 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { if (postStatus != null) { assert.strictEqual(postStatus.transactionStarted, false) assert.strictEqual(postStatus.transactionId, undefined) + assert.strictEqual(postStatus.transactionDeauthorized, undefined) + assert.strictEqual(postStatus.transactionDeauthorizedEnergyWh, undefined) } }) @@ -2553,6 +2557,68 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) }) + await it('should not terminate when StopTxOnInvalidId is false', 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 + } + OCPP20VariableManager.getInstance().setVariables(mockTracking.station, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: 'false', + component: { name: OCPP20ComponentName.TxCtrlr }, + variable: { name: OCPP20RequiredVariableName.StopTxOnInvalidId }, + }, + ]) + + // Act + await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1) + + // Assert — only Updated(Deauthorized), no Ended + const txEvents = mockTracking.sentRequests.filter( + r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string) + ) + assert.strictEqual(txEvents.length, 1) + assert.strictEqual(txEvents[0].payload.eventType, OCPP20TransactionEventEnumType.Updated) + assert.strictEqual( + txEvents[0].payload.triggerReason, + OCPP20TriggerReasonEnumType.Deauthorized + ) + + // Transaction should still be active + const postStatus = mockTracking.station.getConnectorStatus(connectorId) + if (postStatus != null) { + assert.strictEqual(postStatus.transactionStarted, true) + assert.strictEqual(postStatus.transactionId, transactionId) + } + + OCPP20VariableManager.getInstance().resetRuntimeOverrides() + }) + + await it('should track deauth state for deferred termination via periodic meter values', () => { + const connectorId = 1 + const transactionId = generateUUID() + const connectorStatus = mockTracking.station.getConnectorStatus(connectorId) + if (connectorStatus == null) { + assert.fail('connectorStatus should not be undefined') + } + connectorStatus.transactionStarted = true + connectorStatus.transactionId = transactionId + connectorStatus.transactionEnergyActiveImportRegisterValue = 500 + connectorStatus.transactionDeauthorized = true + connectorStatus.transactionDeauthorizedEnergyWh = 500 + + assert.strictEqual(connectorStatus.transactionDeauthorized, true) + assert.strictEqual(connectorStatus.transactionDeauthorizedEnergyWh, 500) + assert.strictEqual(connectorStatus.transactionStarted, true) + }) + await it('should propagate error and skip cleanup if Updated event fails', async () => { const connectorId = 1 const transactionId = generateUUID() diff --git a/tests/helpers/TestLifecycleHelpers.ts b/tests/helpers/TestLifecycleHelpers.ts index 85102971..e12c807f 100644 --- a/tests/helpers/TestLifecycleHelpers.ts +++ b/tests/helpers/TestLifecycleHelpers.ts @@ -113,9 +113,9 @@ export function clearConnectorTransaction (station: ChargingStation, connectorId connector.idTagLocalAuthorized = false // Clear any transaction interval - if (connector.transactionSetInterval != null) { - clearInterval(connector.transactionSetInterval) - connector.transactionSetInterval = undefined + if (connector.transactionMeterValuesSetInterval != null) { + clearInterval(connector.transactionMeterValuesSetInterval) + connector.transactionMeterValuesSetInterval = undefined } } diff --git a/tests/utils/ChargingStationConfigurationUtils.test.ts b/tests/utils/ChargingStationConfigurationUtils.test.ts index aa32c7c0..a6eb5105 100644 --- a/tests/utils/ChargingStationConfigurationUtils.test.ts +++ b/tests/utils/ChargingStationConfigurationUtils.test.ts @@ -71,8 +71,7 @@ await describe('ChargingStationConfigurationUtils', async () => { bootStatus: 'Available', MeterValues: [], transactionEventQueue: [], - transactionSetInterval: interval1 as unknown as NodeJS.Timeout, - transactionTxUpdatedSetInterval: interval2 as unknown as NodeJS.Timeout, + transactionMeterValuesSetInterval: interval1 as unknown as NodeJS.Timeout, } as unknown as ConnectorStatus) const station = createMockStationForConfigUtils({ connectors }) @@ -80,9 +79,8 @@ await describe('ChargingStationConfigurationUtils', async () => { assert.strictEqual(result.length, 2) for (const [, connector] of result) { - assert.ok(!('transactionSetInterval' in connector)) + assert.ok(!('transactionMeterValuesSetInterval' in connector)) assert.ok(!('transactionEventQueue' in connector)) - assert.ok(!('transactionTxUpdatedSetInterval' in connector)) } assert.strictEqual(result[0][0], 0) assert.strictEqual(result[1][0], 1) @@ -107,9 +105,8 @@ await describe('ChargingStationConfigurationUtils', async () => { MeterValues: [], transactionEventQueue: undefined, transactionId: 42, - transactionSetInterval: undefined, + transactionMeterValuesSetInterval: undefined, transactionStarted: true, - transactionTxUpdatedSetInterval: undefined, } as unknown as ConnectorStatus) const station = createMockStationForConfigUtils({ connectors }) @@ -150,8 +147,7 @@ await describe('ChargingStationConfigurationUtils', async () => { availability: AvailabilityType.Operative, MeterValues: [], transactionEventQueue: [], - transactionSetInterval: undefined, - transactionTxUpdatedSetInterval: undefined, + transactionMeterValuesSetInterval: undefined, } as unknown as ConnectorStatus) const evses = new Map() @@ -183,11 +179,14 @@ await describe('ChargingStationConfigurationUtils', async () => { availability: AvailabilityType.Operative, MeterValues: [], transactionEventQueue: [], - transactionSetInterval: undefined, - transactionTxUpdatedSetInterval: undefined, + transactionMeterValuesSetInterval: undefined, } as unknown as ConnectorStatus) const evses = new Map() + evses.set(0, { + availability: AvailabilityType.Operative, + connectors: new Map(), + }) evses.set(1, { availability: AvailabilityType.Operative, connectors: evseConnectors, @@ -195,15 +194,15 @@ await describe('ChargingStationConfigurationUtils', async () => { const station = createMockStationForConfigUtils({ evses }) const result = buildEvsesStatus(station) - const evse1 = result[0][1] + const evse1 = result.find(([id]) => id === 1)?.[1] + assert.ok(evse1 != null) const connectorsStatus = evse1.connectorsStatus as [number, ConnectorStatus][] assert.strictEqual(connectorsStatus.length, 1) assert.strictEqual(connectorsStatus[0][0], 1) const connector = connectorsStatus[0][1] - assert.ok(!('transactionSetInterval' in connector)) + assert.ok(!('transactionMeterValuesSetInterval' in connector)) assert.ok(!('transactionEventQueue' in connector)) - assert.ok(!('transactionTxUpdatedSetInterval' in connector)) }) await it('should preserve connector IDs across serialization', () => { @@ -392,8 +391,7 @@ await describe('ChargingStationConfigurationUtils', async () => { availability: AvailabilityType.Operative, MeterValues: [], transactionEventQueue: [], - transactionSetInterval: undefined, - transactionTxUpdatedSetInterval: undefined, + transactionMeterValuesSetInterval: undefined, } as unknown as ConnectorStatus) const station = createMockStationForConfigUtils({ connectors }) @@ -403,9 +401,8 @@ await describe('ChargingStationConfigurationUtils', async () => { assert.strictEqual(result[0].connectorId, 0) assert.strictEqual(result[1].connectorId, 1) assert.strictEqual(result[1].connector.availability, AvailabilityType.Operative) - assert.ok(!('transactionSetInterval' in result[1].connector)) + assert.ok(!('transactionMeterValuesSetInterval' in result[1].connector)) assert.ok(!('transactionEventQueue' in result[1].connector)) - assert.ok(!('transactionTxUpdatedSetInterval' in result[1].connector)) }) await it('should handle empty connectors map', () => { @@ -447,8 +444,7 @@ await describe('ChargingStationConfigurationUtils', async () => { availability: AvailabilityType.Operative, MeterValues: [], transactionEventQueue: [], - transactionSetInterval: undefined, - transactionTxUpdatedSetInterval: undefined, + transactionMeterValuesSetInterval: undefined, } as unknown as ConnectorStatus) const evses = new Map() @@ -471,7 +467,7 @@ await describe('ChargingStationConfigurationUtils', async () => { assert.strictEqual(result[1].evseId, 1) assert.strictEqual(result[1].connectors.length, 1) assert.strictEqual(result[1].connectors[0].connectorId, 1) - assert.ok(!('transactionSetInterval' in result[1].connectors[0].connector)) + assert.ok(!('transactionMeterValuesSetInterval' in result[1].connectors[0].connector)) assert.ok(!('transactionEventQueue' in result[1].connectors[0].connector)) }) diff --git a/ui/web/src/assets/webui.png b/ui/web/src/assets/webui.png index 6ee1d7ca..ff468f2c 100644 Binary files a/ui/web/src/assets/webui.png and b/ui/web/src/assets/webui.png differ