## Quality gates
-- Documented build/lint/type checks pass (where applicable).
+- Documented build/type checks/lint pass (where applicable).
- Documented tests pass (where applicable).
- Documentation updated to reflect changes when necessary.
- Logs use appropriate levels (error, warn, info, debug).
cache: poetry
- name: Install Dependencies
run: poetry install --no-root
- - name: Lint
- if: ${{ matrix.os == 'ubuntu-latest' && matrix.python == '3.13' }}
- run: poetry run task lint
- name: Typecheck
if: ${{ matrix.os == 'ubuntu-latest' && matrix.python == '3.13' }}
run: poetry run task typecheck
+ - name: Lint
+ if: ${{ matrix.os == 'ubuntu-latest' && matrix.python == '3.13' }}
+ run: poetry run task lint
- name: Test
if: ${{ !(github.repository == 'sap/e-mobility-charging-stations-simulator' && matrix.os == 'ubuntu-latest' && matrix.python == '3.13') }}
run: poetry run task test
# - name: pnpm audit
# if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
# run: pnpm audit --prod
- - name: pnpm lint
- if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
- run: pnpm lint
- name: pnpm typecheck
if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
run: pnpm typecheck
+ - name: pnpm lint
+ if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
+ run: pnpm lint
- name: pnpm build
run: pnpm build
- name: pnpm test
# - name: pnpm audit
# if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
# run: pnpm audit --prod
+ - name: pnpm typecheck
+ if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
+ run: pnpm typecheck
- name: pnpm lint
if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
run: pnpm lint
vitest:
specifier: ^4.1.0
version: 4.1.0(@types/node@24.12.0)(jsdom@29.0.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
+ vue-tsc:
+ specifier: ^2.2.0
+ version: 2.2.12(typescript@5.9.3)
packages:
'@vitest/utils@4.1.0':
resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==}
+ '@volar/language-core@2.4.15':
+ resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
+
+ '@volar/source-map@2.4.15':
+ resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
+
+ '@volar/typescript@2.4.15':
+ resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
+
'@vue-macros/common@3.1.2':
resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==}
engines: {node: '>=20.19.0'}
'@vue/compiler-ssr@3.5.30':
resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
+ '@vue/compiler-vue2@2.7.16':
+ resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
+
'@vue/devtools-api@8.1.0':
resolution: {integrity: sha512-O44X57jjkLKbLEc4OgL/6fEPOOanRJU8kYpCE8qfKlV96RQZcdzrcLI5mxMuVRUeXhHKIHGhCpHacyCk0HyO4w==}
'@vue/devtools-shared@8.1.0':
resolution: {integrity: sha512-h8uCb4Qs8UT8VdTT5yjY6tOJ//qH7EpxToixR0xqejR55t5OdISIg7AJ7eBkhBs8iu1qG5gY3QQNN1DF1EelAA==}
+ '@vue/language-core@2.2.12':
+ resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
'@vue/reactivity@3.5.30':
resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
+ alien-signals@1.0.13:
+ resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
+
ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
+ de-indent@1.0.2:
+ resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
+
debounce-fn@4.0.0:
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
engines: {node: '>=10'}
hdr-histogram-percentiles-obj@3.0.0:
resolution: {integrity: sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==}
+ he@1.2.0:
+ resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
+ hasBin: true
+
hidden-markov-model-tf@4.0.0:
resolution: {integrity: sha512-q8VeBNCyQ5CNsUlbt4T5JXc+pUeKqq7LEGjs4HiH+thgZ2fuyJ9pf/V66ZFx9jZobXkwxVuQRWKZa3TwOFW+zw==}
peerDependencies:
peerDependencies:
vue: ^3.0
+ vue-tsc@2.2.12:
+ resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
+ hasBin: true
+ peerDependencies:
+ typescript: '>=5.0.0'
+
vue@3.5.30:
resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
peerDependencies:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
+ '@volar/language-core@2.4.15':
+ dependencies:
+ '@volar/source-map': 2.4.15
+
+ '@volar/source-map@2.4.15': {}
+
+ '@volar/typescript@2.4.15':
+ dependencies:
+ '@volar/language-core': 2.4.15
+ path-browserify: 1.0.1
+ vscode-uri: 3.1.0
+
'@vue-macros/common@3.1.2(vue@3.5.30(typescript@5.9.3))':
dependencies:
'@vue/compiler-sfc': 3.5.30
'@vue/compiler-dom': 3.5.30
'@vue/shared': 3.5.30
+ '@vue/compiler-vue2@2.7.16':
+ dependencies:
+ de-indent: 1.0.2
+ he: 1.2.0
+
'@vue/devtools-api@8.1.0':
dependencies:
'@vue/devtools-kit': 8.1.0
'@vue/devtools-shared@8.1.0': {}
+ '@vue/language-core@2.2.12(typescript@5.9.3)':
+ dependencies:
+ '@volar/language-core': 2.4.15
+ '@vue/compiler-dom': 3.5.30
+ '@vue/compiler-vue2': 2.7.16
+ '@vue/shared': 3.5.30
+ alien-signals: 1.0.13
+ minimatch: 10.2.4
+ muggle-string: 0.4.1
+ path-browserify: 1.0.1
+ optionalDependencies:
+ typescript: 5.9.3
+
'@vue/reactivity@3.5.30':
dependencies:
'@vue/shared': 3.5.30
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
+ alien-signals@1.0.13: {}
+
ansi-align@3.0.1:
dependencies:
string-width: 4.2.3
date-fns@4.1.0: {}
+ de-indent@1.0.2: {}
+
debounce-fn@4.0.0:
dependencies:
mimic-fn: 3.1.0
hdr-histogram-percentiles-obj@3.0.0: {}
+ he@1.2.0: {}
+
hidden-markov-model-tf@4.0.0(@tensorflow/tfjs-core@3.21.0):
dependencies:
'@tensorflow/tfjs-core': 3.21.0
dependencies:
vue: 3.5.30(typescript@5.9.3)
+ vue-tsc@2.2.12(typescript@5.9.3):
+ dependencies:
+ '@volar/typescript': 2.4.15
+ '@vue/language-core': 2.2.12(typescript@5.9.3)
+ typescript: 5.9.3
+
vue@3.5.30(typescript@5.9.3):
dependencies:
'@vue/compiler-dom': 3.5.30
type DiagnosticsStatusNotificationRequest,
type DiagnosticsStatusNotificationResponse,
type EmptyObject,
+ ErrorType,
type FirmwareStatusNotificationRequest,
type FirmwareStatusNotificationResponse,
GenericStatus,
type OCPP20SecurityEventNotificationResponse,
type OCPP20SignCertificateRequest,
type OCPP20SignCertificateResponse,
+ OCPP20TransactionEventEnumType,
type OCPP20TransactionEventRequest,
type OCPP20TransactionEventResponse,
+ OCPP20TriggerReasonEnumType,
OCPPVersion,
RegistrationStatusEnumType,
RequestCommand,
import {
Constants,
convertToInt,
+ generateUUID,
getErrorMessage,
isAsyncFunction,
isEmpty,
logger,
} from '../../utils/index.js'
import { getConfigurationKey } from '../ConfigurationKeyUtils.js'
-import { buildMeterValue } from '../ocpp/index.js'
+import { buildMeterValue, OCPP20ServiceUtils } from '../ocpp/index.js'
import { WorkerBroadcastChannel } from './WorkerBroadcastChannel.js'
const moduleName = 'ChargingStationWorkerBroadcastChannel'
private async handleTransactionEvent (
requestPayload?: BroadcastChannelRequestPayload
): Promise<OCPP20TransactionEventResponse> {
- return await this.chargingStation.ocppRequestService.requestHandler<
- OCPP20TransactionEventRequest,
- OCPP20TransactionEventResponse
- >(
+ const payload = requestPayload as OCPP20TransactionEventRequest
+
+ switch (payload.eventType) {
+ case OCPP20TransactionEventEnumType.Ended:
+ return this.handleUIStopTransaction(payload)
+ case OCPP20TransactionEventEnumType.Started:
+ return this.handleUIStartTransaction(payload)
+ default:
+ return await this.chargingStation.ocppRequestService.requestHandler<
+ OCPP20TransactionEventRequest,
+ OCPP20TransactionEventResponse
+ >(this.chargingStation, RequestCommand.TRANSACTION_EVENT, payload, this.requestParams)
+ }
+ }
+
+ private async handleUIStartTransaction (
+ payload: OCPP20TransactionEventRequest
+ ): Promise<OCPP20TransactionEventResponse> {
+ const connectorId = payload.evse?.connectorId ?? payload.evse?.id ?? 1
+ const transactionId = generateUUID()
+
+ const connectorStatus = this.chargingStation.getConnectorStatus(connectorId)
+ if (connectorStatus != null) {
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionIdTag = payload.idToken?.idToken
+ connectorStatus.transactionStart = new Date()
+ }
+
+ const response = await OCPP20ServiceUtils.sendTransactionEvent(
this.chargingStation,
- RequestCommand.TRANSACTION_EVENT,
- requestPayload as OCPP20TransactionEventRequest,
- this.requestParams
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
)
+
+ const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(this.chargingStation)
+ this.chargingStation.startTxUpdatedInterval(connectorId, txUpdatedInterval)
+
+ return response
+ }
+
+ private async handleUIStopTransaction (
+ payload: OCPP20TransactionEventRequest
+ ): Promise<OCPP20TransactionEventResponse> {
+ const transactionId = (payload as unknown as { transactionId?: string }).transactionId
+ if (transactionId == null) {
+ throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, 'Missing transactionId for stop')
+ }
+
+ const connectorId = this.chargingStation.getConnectorIdByTransactionId(transactionId)
+ if (connectorId == null) {
+ throw new OCPPError(
+ ErrorType.PROPERTY_CONSTRAINT_VIOLATION,
+ `No connector found for transaction ${transactionId}`
+ )
+ }
+
+ return await OCPP20ServiceUtils.requestStopTransaction(this.chargingStation, connectorId)
}
private messageErrorHandler (messageEvent: MessageEvent): void {
import type { ValidateFunction } from 'ajv'
-import { secondsToMilliseconds } from 'date-fns'
-
import type { ChargingStation } from '../../../charging-station/index.js'
import type {
OCPP20ChargingProfileType,
OCPP20ReasonEnumType,
} from '../../../types/ocpp/2.0/Transaction.js'
import {
- Constants,
convertToDate,
generateUUID,
logger,
}
private getTxUpdatedInterval (chargingStation: ChargingStation): number {
- const variableManager = OCPP20VariableManager.getInstance()
- const results = variableManager.getVariables(chargingStation, [
- {
- component: { name: OCPP20ComponentName.SampledDataCtrlr },
- variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
- },
- ])
- if (results.length > 0 && results[0].attributeValue != null) {
- const intervalSeconds = parseInt(results[0].attributeValue, 10)
- if (!isNaN(intervalSeconds) && intervalSeconds > 0) {
- return secondsToMilliseconds(intervalSeconds)
- }
- }
- return secondsToMilliseconds(Constants.DEFAULT_TX_UPDATED_INTERVAL)
+ return OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation)
}
private handleConnectorChangeAvailability (
/* eslint-disable @typescript-eslint/unified-signatures */
+import { secondsToMilliseconds } from 'date-fns'
+
import { type ChargingStation, resetConnectorStatus } from '../../../charging-station/index.js'
import { OCPPError } from '../../../exception/index.js'
import {
ConnectorStatusEnum,
ErrorType,
- type GenericResponse,
+ OCPP20ComponentName,
OCPP20IncomingRequestCommand,
OCPP20RequestCommand,
OCPP20TransactionEventEnumType,
type OCPP20TransactionEventOptions,
type OCPP20TransactionType,
} from '../../../types/ocpp/2.0/Transaction.js'
-import { convertToIntOrNaN, logger, validateIdentifierString } from '../../../utils/index.js'
+import {
+ Constants,
+ convertToIntOrNaN,
+ logger,
+ validateIdentifierString,
+} from '../../../utils/index.js'
import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
import { OCPPServiceUtils, sendAndSetConnectorStatus } from '../OCPPServiceUtils.js'
import { OCPP20Constants } from './OCPP20Constants.js'
+import { OCPP20VariableManager } from './OCPP20VariableManager.js'
const moduleName = 'OCPP20ServiceUtils'
return currentResults
}
+ /**
+ * Gets the TxUpdatedInterval configuration value for periodic TransactionEvent(Updated) messages.
+ * Reads the SampledDataCtrlr.TxUpdatedInterval variable and falls back to
+ * Constants.DEFAULT_TX_UPDATED_INTERVAL if not configured.
+ * @param chargingStation - The charging station instance
+ * @returns The interval in milliseconds
+ */
+ public static getTxUpdatedInterval (chargingStation: ChargingStation): number {
+ const variableManager = OCPP20VariableManager.getInstance()
+ const results = variableManager.getVariables(chargingStation, [
+ {
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+ },
+ ])
+ if (results.length > 0 && results[0].attributeValue != null) {
+ const intervalSeconds = parseInt(results[0].attributeValue, 10)
+ if (!isNaN(intervalSeconds) && intervalSeconds > 0) {
+ return secondsToMilliseconds(intervalSeconds)
+ }
+ }
+ return secondsToMilliseconds(Constants.DEFAULT_TX_UPDATED_INTERVAL)
+ }
+
/**
* Read ItemsPerMessage and BytesPerMessage configuration limits
* Extracts configuration-reading logic shared between handleRequestGetVariables
chargingStation: ChargingStation,
connectorId: number,
evseId?: number
- ): Promise<GenericResponse> {
+ ): Promise<OCPP20TransactionEventResponse> {
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
if (connectorStatus?.transactionStarted && connectorStatus.transactionId != null) {
let transactionId: string
} else {
transactionId = connectorStatus.transactionId.toString()
logger.warn(
- `${chargingStation.logPrefix()} OCPP20ServiceUtils.remoteStopTransaction: Non-string transaction ID ${transactionId} converted to string for OCPP 2.0`
- )
- }
-
- if (!validateIdentifierString(transactionId, 36)) {
- logger.error(
- `${chargingStation.logPrefix()} OCPP20ServiceUtils.remoteStopTransaction: Invalid transaction ID format (must be non-empty string ≤36 characters): ${transactionId}`
+ `${chargingStation.logPrefix()} ${moduleName}.requestStopTransaction: Non-string transaction ID ${transactionId} converted to string for OCPP 2.0`
)
- return OCPP20Constants.OCPP_RESPONSE_REJECTED
}
- evseId = evseId ?? chargingStation.getEvseIdByConnectorId(connectorId)
- if (evseId == null) {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Cannot find connector status for connector ${connectorId.toString()}: `
- )
- return OCPP20Constants.OCPP_RESPONSE_REJECTED
- }
-
- connectorStatus.transactionSeqNo = (connectorStatus.transactionSeqNo ?? 0) + 1
-
// F03.FR.04: Build final meter values for TransactionEvent(Ended)
const finalMeterValues: OCPP20MeterValue[] = []
const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
})
}
- const transactionEventRequest: OCPP20TransactionEventRequest = {
- eventType: OCPP20TransactionEventEnumType.Ended,
- evse: {
- id: evseId,
- },
- seqNo: connectorStatus.transactionSeqNo,
- timestamp: new Date(),
- transactionInfo: {
+ const response = await this.sendTransactionEvent(
+ chargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.RemoteStop,
+ connectorId,
+ transactionId,
+ {
+ evseId,
+ meterValue: finalMeterValues.length > 0 ? finalMeterValues : undefined,
stoppedReason: OCPP20ReasonEnumType.Remote,
- transactionId: transactionId as UUIDv4,
- },
- triggerReason: OCPP20TriggerReasonEnumType.RemoteStop,
- }
-
- // F03.FR.04: Include final meter values in TransactionEvent(Ended)
- if (finalMeterValues.length > 0) {
- transactionEventRequest.meterValue = finalMeterValues
- }
-
- await chargingStation.ocppRequestService.requestHandler<
- OCPP20TransactionEventRequest,
- OCPP20TransactionEventRequest
- >(chargingStation, OCPP20RequestCommand.TRANSACTION_EVENT, transactionEventRequest)
+ }
+ )
chargingStation.stopTxUpdatedInterval(connectorId)
resetConnectorStatus(connectorStatus)
await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available)
- return OCPP20Constants.OCPP_RESPONSE_ACCEPTED
+ return response
}
- return OCPP20Constants.OCPP_RESPONSE_REJECTED
+ throw new OCPPError(
+ ErrorType.PROPERTY_CONSTRAINT_VIOLATION,
+ `No active transaction on connector ${connectorId.toString()}`
+ )
}
/**
import type { WebSocket } from 'ws'
import type { WorkerData } from '../worker/index.js'
-import type { ChargingStationAutomaticTransactionGeneratorConfiguration } from './AutomaticTransactionGenerator.js'
+import type {
+ AutomaticTransactionGeneratorConfiguration,
+ Status,
+} from './AutomaticTransactionGenerator.js'
import type { ChargingStationInfo } from './ChargingStationInfo.js'
import type { ChargingStationOcppConfiguration } from './ChargingStationOcppConfiguration.js'
import type { ConnectorStatus } from './ConnectorStatus.js'
-import type { EvseStatus } from './Evse.js'
import type { JsonObject } from './JsonType.js'
+import type { AvailabilityType } from './ocpp/Requests.js'
import type { BootNotificationResponse } from './ocpp/Responses.js'
import type { Statistics } from './Statistics.js'
import type { UUIDv4 } from './UUID.js'
performanceStatistics = 'performanceStatistics',
}
+export interface ATGConfiguration {
+ automaticTransactionGenerator?: AutomaticTransactionGeneratorConfiguration
+ automaticTransactionGeneratorStatuses?: ATGEntry[]
+}
+
+export interface ATGEntry {
+ connectorId: number
+ status: Status
+}
+
export interface ChargingStationData extends WorkerData {
- automaticTransactionGenerator?: ChargingStationAutomaticTransactionGeneratorConfiguration
+ automaticTransactionGenerator?: ATGConfiguration
bootNotificationResponse?: BootNotificationResponse
- connectors: ConnectorStatus[]
- evses: EvseStatusWorkerType[]
+ connectors: ConnectorEntry[]
+ evses: EvseEntry[]
ocppConfiguration: ChargingStationOcppConfiguration
started: boolean
stationInfo: ChargingStationInfo
| ChargingStationEvents
| ChargingStationMessageEvents
-export type EvseStatusWorkerType = Omit<EvseStatus, 'connectors'> & {
- connectors?: ConnectorStatus[]
+export interface ConnectorEntry {
+ connector: ConnectorStatus
+ connectorId: number
+}
+
+export interface EvseEntry {
+ availability: AvailabilityType
+ connectors: ConnectorEntry[]
+ evseId: number
}
type WsOptions,
} from './ChargingStationTemplate.js'
export {
+ type ATGConfiguration,
+ type ATGEntry,
type ChargingStationData,
type ChargingStationOptions,
type ChargingStationWorkerData,
type ChargingStationWorkerMessage,
type ChargingStationWorkerMessageData,
ChargingStationWorkerMessageEvents,
- type EvseStatusWorkerType,
+ type ConnectorEntry,
+ type EvseEntry,
} from './ChargingStationWorker.js'
export {
ApplicationProtocolVersion,
import type { ChargingStation } from '../charging-station/index.js'
import type {
+ ATGEntry,
ChargingStationAutomaticTransactionGeneratorConfiguration,
+ ConnectorEntry,
ConnectorStatus,
+ EvseEntry,
EvseStatusConfiguration,
- EvseStatusWorkerType,
} from '../types/index.js'
+export const buildATGEntries = (chargingStation: ChargingStation): ATGEntry[] => {
+ if (chargingStation.automaticTransactionGenerator?.connectorsStatus == null) {
+ return []
+ }
+ return [...chargingStation.automaticTransactionGenerator.connectorsStatus.entries()].map(
+ ([connectorId, status]) => ({ connectorId, status })
+ )
+}
+
export const buildChargingStationAutomaticTransactionGeneratorConfiguration = (
chargingStation: ChargingStation
): ChargingStationAutomaticTransactionGeneratorConfiguration => {
}
}
+export const buildConnectorEntries = (chargingStation: ChargingStation): ConnectorEntry[] => {
+ return [...chargingStation.connectors.entries()].map(
+ ([
+ connectorId,
+ {
+ transactionEventQueue,
+ transactionSetInterval,
+ transactionTxUpdatedSetInterval,
+ ...connector
+ },
+ ]) => ({
+ connector,
+ connectorId,
+ })
+ )
+}
+
export const buildConnectorsStatus = (chargingStation: ChargingStation): ConnectorStatus[] => {
return [...chargingStation.connectors.values()].map(
({
)
}
-export enum OutputFormat {
- configuration = 'configuration',
- worker = 'worker',
+export const buildEvseEntries = (chargingStation: ChargingStation): EvseEntry[] => {
+ return [...chargingStation.evses.entries()].map(([evseId, evseStatus]) => ({
+ availability: evseStatus.availability,
+ connectors: [...evseStatus.connectors.entries()].map(
+ ([
+ connectorId,
+ {
+ transactionEventQueue,
+ transactionSetInterval,
+ transactionTxUpdatedSetInterval,
+ ...connector
+ },
+ ]) => ({
+ connector,
+ connectorId,
+ })
+ ),
+ evseId,
+ }))
}
-export const buildEvsesStatus = (
- chargingStation: ChargingStation,
- outputFormat: OutputFormat = OutputFormat.configuration
-): (EvseStatusConfiguration | EvseStatusWorkerType)[] => {
+export const buildEvsesStatus = (chargingStation: ChargingStation): EvseStatusConfiguration[] => {
return [...chargingStation.evses.values()].map(evseStatus => {
const connectorsStatus = [...evseStatus.connectors.values()].map(
({
...connectorStatus
}) => connectorStatus
)
- switch (outputFormat) {
- case OutputFormat.configuration: {
- const status: EvseStatusConfiguration = {
- ...evseStatus,
- connectorsStatus,
- }
- delete (status as EvseStatusWorkerType).connectors
- return status
- }
- case OutputFormat.worker:
- return {
- ...evseStatus,
- connectors: connectorsStatus,
- }
- default:
- throw new RangeError(`Unknown output format: ${outputFormat as string}`)
+ const status: EvseStatusConfiguration = {
+ ...evseStatus,
+ connectorsStatus,
}
+ delete (status as { connectors?: unknown }).connectors
+ return status
})
}
type TimestampedData,
} from '../types/index.js'
import {
- buildChargingStationAutomaticTransactionGeneratorConfiguration,
- buildConnectorsStatus,
- buildEvsesStatus,
- OutputFormat,
+ buildATGEntries,
+ buildConnectorEntries,
+ buildEvseEntries,
} from './ChargingStationConfigurationUtils.js'
const buildChargingStationWorkerMessage = (
const buildChargingStationDataPayload = (chargingStation: ChargingStation): ChargingStationData => {
return {
bootNotificationResponse: chargingStation.bootNotificationResponse,
- connectors: buildConnectorsStatus(chargingStation),
- evses: buildEvsesStatus(chargingStation, OutputFormat.worker),
+ connectors: buildConnectorEntries(chargingStation),
+ evses: buildEvseEntries(chargingStation),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
ocppConfiguration: chargingStation.ocppConfiguration!,
started: chargingStation.started,
timestamp: Date.now(),
wsState: chargingStation.wsConnection?.readyState,
...(chargingStation.automaticTransactionGenerator != null && {
- automaticTransactionGenerator:
- buildChargingStationAutomaticTransactionGeneratorConfiguration(chargingStation),
+ automaticTransactionGenerator: {
+ automaticTransactionGenerator:
+ chargingStation.getAutomaticTransactionGeneratorConfiguration(),
+ automaticTransactionGeneratorStatuses: buildATGEntries(chargingStation),
+ },
}),
}
}
export { AsyncLock, AsyncLockType } from './AsyncLock.js'
export {
+ buildATGEntries,
buildChargingStationAutomaticTransactionGeneratorConfiguration,
+ buildConnectorEntries,
buildConnectorsStatus,
+ buildEvseEntries,
buildEvsesStatus,
- OutputFormat,
} from './ChargingStationConfigurationUtils.js'
export { Configuration } from './Configuration.js'
export { Constants } from './Constants.js'
poetry run task format
```
-### Code linting
+### Type checking
```shell
-poetry run task lint
+poetry run task typecheck
```
-### Type checking
+### Code linting
```shell
-poetry run task typecheck
+poetry run task lint
```
### Testing
/**
* @file Tests for ChargingStationConfigurationUtils
* @description Unit tests for charging station configuration utility functions including
- * buildConnectorsStatus, buildEvsesStatus, buildChargingStationAutomaticTransactionGeneratorConfiguration,
- * and the OutputFormat enum.
+ * config persistence (buildConnectorsStatus, buildEvsesStatus,
+ * buildChargingStationAutomaticTransactionGeneratorConfiguration) and
+ * UI serialization (buildATGEntries, buildConnectorEntries, buildEvseEntries).
*/
import assert from 'node:assert/strict'
import { afterEach, describe, it } from 'node:test'
import { AvailabilityType } from '../../src/types/index.js'
import {
+ buildATGEntries,
buildChargingStationAutomaticTransactionGeneratorConfiguration,
+ buildConnectorEntries,
buildConnectorsStatus,
+ buildEvseEntries,
buildEvsesStatus,
- OutputFormat,
} from '../../src/utils/ChargingStationConfigurationUtils.js'
import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
standardCleanup()
})
+ // ── Config persistence builders ────────────────────────────────────────
+
await describe('buildConnectorsStatus', async () => {
await it('should strip internal transaction fields from connectors', () => {
const noop = (): void => {
})
const station = createMockStationForConfigUtils({ evses })
- const result = buildEvsesStatus(station, OutputFormat.configuration)
+ const result = buildEvsesStatus(station)
assert.strictEqual(result.length, 2)
const evse1 = result[1] as Record<string, unknown>
assert.ok(!('connectors' in evse1))
})
- await it('should strip internal fields from evse connectors in configuration format', () => {
+ await it('should strip internal fields from evse connectors', () => {
const evseConnectors = new Map<number, ConnectorStatus>()
evseConnectors.set(1, {
availability: AvailabilityType.Operative,
})
const station = createMockStationForConfigUtils({ evses })
- const result = buildEvsesStatus(station, OutputFormat.configuration)
+ const result = buildEvsesStatus(station)
const evse1 = result[0] as Record<string, unknown>
const connectorsStatus = evse1.connectorsStatus as ConnectorStatus[]
assert.ok(!('transactionTxUpdatedSetInterval' in connectorsStatus[0]))
})
- await it('should return worker format with connectors array', () => {
- const evseConnectors = new Map<number, ConnectorStatus>()
- evseConnectors.set(1, {
- availability: AvailabilityType.Operative,
- MeterValues: [],
- transactionEventQueue: undefined,
- transactionSetInterval: undefined,
- transactionTxUpdatedSetInterval: undefined,
- } as unknown as ConnectorStatus)
-
- const evses = new Map<number, EvseStatus>()
- evses.set(0, {
- availability: AvailabilityType.Operative,
- connectors: new Map<number, ConnectorStatus>(),
- })
- evses.set(1, {
- availability: AvailabilityType.Operative,
- connectors: evseConnectors,
- })
-
- const station = createMockStationForConfigUtils({ evses })
- const result = buildEvsesStatus(station, OutputFormat.worker)
-
- assert.strictEqual(result.length, 2)
- const evse1 = result[1] as Record<string, unknown>
- assert.ok('connectors' in evse1)
- assert.ok(Array.isArray(evse1.connectors))
- })
-
- await it('should default to configuration format when no format specified', () => {
- const evses = new Map<number, EvseStatus>()
- evses.set(1, {
- availability: AvailabilityType.Operative,
- connectors: new Map<number, ConnectorStatus>(),
- })
-
- const station = createMockStationForConfigUtils({ evses })
- const result = buildEvsesStatus(station)
-
- assert.strictEqual(result.length, 1)
- const evse = result[0] as Record<string, unknown>
- assert.ok('connectorsStatus' in evse)
- assert.ok(!('connectors' in evse))
- })
-
await it('should handle empty evses map', () => {
const station = createMockStationForConfigUtils({ evses: new Map() })
- const result = buildEvsesStatus(station, OutputFormat.configuration)
+ const result = buildEvsesStatus(station)
assert.strictEqual(result.length, 0)
})
-
- await it('should throw RangeError for unknown output format', () => {
- const evses = new Map<number, EvseStatus>()
- evses.set(1, {
- availability: AvailabilityType.Operative,
- connectors: new Map<number, ConnectorStatus>(),
- })
- const station = createMockStationForConfigUtils({ evses })
-
- assert.throws(() => {
- buildEvsesStatus(station, 'unknown' as OutputFormat)
- }, RangeError)
- })
})
await describe('buildChargingStationAutomaticTransactionGeneratorConfiguration', async () => {
assert.strictEqual(result.automaticTransactionGeneratorStatuses, undefined)
})
})
+
+ // ── UI serialization Entry builders ────────────────────────────────────
+
+ await describe('buildATGEntries', async () => {
+ await it('should return entries with connectorId and status', () => {
+ const connectorsStatus = new Map<number, unknown>()
+ connectorsStatus.set(1, { start: true })
+ connectorsStatus.set(3, { start: false })
+
+ const station = createMockStationForConfigUtils({
+ automaticTransactionGenerator: { connectorsStatus },
+ })
+ const result = buildATGEntries(station)
+
+ assert.strictEqual(result.length, 2)
+ assert.strictEqual(result[0].connectorId, 1)
+ assert.deepStrictEqual(result[0].status, { start: true })
+ assert.strictEqual(result[1].connectorId, 3)
+ assert.deepStrictEqual(result[1].status, { start: false })
+ })
+
+ await it('should preserve non-sequential connector IDs', () => {
+ const connectorsStatus = new Map<number, unknown>()
+ connectorsStatus.set(2, { start: true })
+ connectorsStatus.set(7, { start: false })
+
+ const station = createMockStationForConfigUtils({
+ automaticTransactionGenerator: { connectorsStatus },
+ })
+ const result = buildATGEntries(station)
+
+ assert.strictEqual(result.length, 2)
+ assert.strictEqual(result[0].connectorId, 2)
+ assert.strictEqual(result[1].connectorId, 7)
+ })
+
+ await it('should return empty array when no ATG instance', () => {
+ const station = createMockStationForConfigUtils({
+ automaticTransactionGenerator: undefined,
+ })
+ const result = buildATGEntries(station)
+ assert.strictEqual(result.length, 0)
+ })
+
+ await it('should return empty array when connectorsStatus is undefined', () => {
+ const station = createMockStationForConfigUtils({
+ automaticTransactionGenerator: { connectorsStatus: undefined },
+ })
+ const result = buildATGEntries(station)
+ assert.strictEqual(result.length, 0)
+ })
+ })
+
+ await describe('buildConnectorEntries', async () => {
+ await it('should return entries with connectorId and stripped connector', () => {
+ const connectors = new Map<number, ConnectorStatus>()
+ connectors.set(0, {
+ availability: AvailabilityType.Operative,
+ MeterValues: [],
+ } as ConnectorStatus)
+ connectors.set(1, {
+ availability: AvailabilityType.Operative,
+ MeterValues: [],
+ transactionEventQueue: [],
+ transactionSetInterval: undefined,
+ transactionTxUpdatedSetInterval: undefined,
+ } as unknown as ConnectorStatus)
+
+ const station = createMockStationForConfigUtils({ connectors })
+ const result = buildConnectorEntries(station)
+
+ assert.strictEqual(result.length, 2)
+ 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(!('transactionEventQueue' in result[1].connector))
+ assert.ok(!('transactionTxUpdatedSetInterval' in result[1].connector))
+ })
+
+ await it('should handle empty connectors map', () => {
+ const station = createMockStationForConfigUtils({ connectors: new Map() })
+ const result = buildConnectorEntries(station)
+ assert.strictEqual(result.length, 0)
+ })
+
+ await it('should preserve non-sequential connector IDs', () => {
+ const connectors = new Map<number, ConnectorStatus>()
+ connectors.set(0, {
+ availability: AvailabilityType.Operative,
+ MeterValues: [],
+ } as ConnectorStatus)
+ connectors.set(3, {
+ availability: AvailabilityType.Operative,
+ MeterValues: [],
+ } as ConnectorStatus)
+ connectors.set(7, {
+ availability: AvailabilityType.Inoperative,
+ MeterValues: [],
+ } as ConnectorStatus)
+
+ const station = createMockStationForConfigUtils({ connectors })
+ const result = buildConnectorEntries(station)
+
+ assert.strictEqual(result.length, 3)
+ assert.strictEqual(result[0].connectorId, 0)
+ assert.strictEqual(result[1].connectorId, 3)
+ assert.strictEqual(result[2].connectorId, 7)
+ assert.strictEqual(result[2].connector.availability, AvailabilityType.Inoperative)
+ })
+ })
+
+ await describe('buildEvseEntries', async () => {
+ await it('should return entries with evseId, availability, and connector entries', () => {
+ const evseConnectors = new Map<number, ConnectorStatus>()
+ evseConnectors.set(1, {
+ availability: AvailabilityType.Operative,
+ MeterValues: [],
+ transactionEventQueue: [],
+ transactionSetInterval: undefined,
+ transactionTxUpdatedSetInterval: undefined,
+ } as unknown as ConnectorStatus)
+
+ const evses = new Map<number, EvseStatus>()
+ evses.set(0, {
+ availability: AvailabilityType.Operative,
+ connectors: new Map<number, ConnectorStatus>(),
+ })
+ evses.set(1, {
+ availability: AvailabilityType.Operative,
+ connectors: evseConnectors,
+ })
+
+ const station = createMockStationForConfigUtils({ evses })
+ const result = buildEvseEntries(station)
+
+ assert.strictEqual(result.length, 2)
+ assert.strictEqual(result[0].evseId, 0)
+ assert.strictEqual(result[0].availability, AvailabilityType.Operative)
+ assert.strictEqual(result[0].connectors.length, 0)
+ 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(!('transactionEventQueue' in result[1].connectors[0].connector))
+ })
+
+ await it('should handle empty evses map', () => {
+ const station = createMockStationForConfigUtils({ evses: new Map() })
+ const result = buildEvseEntries(station)
+ assert.strictEqual(result.length, 0)
+ })
+
+ await it('should preserve non-sequential evseId and connectorId', () => {
+ const evse2Connectors = new Map<number, ConnectorStatus>()
+ evse2Connectors.set(2, {
+ availability: AvailabilityType.Operative,
+ MeterValues: [],
+ } as ConnectorStatus)
+ evse2Connectors.set(5, {
+ availability: AvailabilityType.Inoperative,
+ MeterValues: [],
+ } as ConnectorStatus)
+
+ const evses = new Map<number, EvseStatus>()
+ evses.set(0, {
+ availability: AvailabilityType.Operative,
+ connectors: new Map<number, ConnectorStatus>(),
+ })
+ evses.set(3, {
+ availability: AvailabilityType.Operative,
+ connectors: evse2Connectors,
+ })
+
+ const station = createMockStationForConfigUtils({ evses })
+ const result = buildEvseEntries(station)
+
+ assert.strictEqual(result.length, 2)
+ assert.strictEqual(result[0].evseId, 0)
+ assert.strictEqual(result[1].evseId, 3)
+ assert.strictEqual(result[1].connectors.length, 2)
+ assert.strictEqual(result[1].connectors[0].connectorId, 2)
+ assert.strictEqual(result[1].connectors[1].connectorId, 5)
+ assert.strictEqual(
+ result[1].connectors[1].connector.availability,
+ AvailabilityType.Inoperative
+ )
+ })
+ })
})
# pnpm
package-lock.json
+
+# TypeScript incremental compilation information
+*.tsbuildinfo
| `pnpm build` | Build the production bundle to `dist/` |
| `pnpm preview` | Build and preview the production bundle locally |
| `pnpm start` | Build and serve via Node.js HTTP server (port 3030) |
+| `pnpm typecheck` | Run vue-tsc type checking |
| `pnpm lint` | Run ESLint |
| `pnpm lint:fix` | Run ESLint with auto-fix |
| `pnpm format` | Run Prettier and ESLint auto-fix |
"lint:fix": "cross-env TIMING=1 eslint --cache --fix .",
"format": "prettier --cache --write .; eslint --cache --fix .",
"test": "vitest",
- "test:coverage": "vitest run --coverage"
+ "test:coverage": "vitest run --coverage",
+ "typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"finalhandler": "^2.1.1",
"rimraf": "^6.1.3",
"typescript": "~5.9.3",
"vite": "^7.3.1",
- "vitest": "^4.1.0"
+ "vitest": "^4.1.0",
+ "vue-tsc": "^2.2.0"
}
}
template: '',
})
-watch(getCurrentInstance()!.appContext.config.globalProperties!.$templates, () => {
- state.value.renderTemplates = randomUUID()
-})
+const templates = getCurrentInstance()?.appContext.config.globalProperties.$templates
+if (templates != null) {
+ watch(templates, () => {
+ state.value.renderTemplates = randomUUID()
+ })
+}
</script>
<style>
Start Transaction
</h1>
<h2>{{ chargingStationId }}</h2>
- <h3>Connector {{ connectorId }}</h3>
+ <h3 v-if="evseId != null">
+ EVSE {{ evseId }} / Connector {{ connectorId }}
+ </h3>
+ <h3 v-else>
+ Connector {{ connectorId }}
+ </h3>
<p>
RFID tag:
<input
type="text"
>
</p>
- <p>
+ <p v-if="!isOCPP20x">
Authorize RFID tag:
<input
v-model="state.authorizeIdTag"
- false-value="false"
- true-value="true"
type="checkbox"
>
</p>
<br>
<Button
id="action-button"
- @click="
- () => {
- state.authorizeIdTag = convertToBoolean(state.authorizeIdTag)
- if (state.authorizeIdTag) {
- if (state.idTag == null || state.idTag.trim().length === 0) {
- $toast.error('Please provide an RFID tag to authorize')
- return
- }
- $uiClient
- ?.authorize(hashId, state.idTag)
- .then(() => {
- $uiClient
- ?.startTransaction(hashId, convertToInt(connectorId), state.idTag)
- .then(() => {
- $toast.success('Transaction successfully started')
- })
- .catch((error: Error) => {
- $toast.error('Error at starting transaction')
- console.error('Error at starting transaction:', error)
- })
- .finally(() => {
- resetToggleButtonState(
- `${props.hashId}-${props.connectorId}-start-transaction`,
- true
- )
- $router.push({ name: 'charging-stations' })
- })
- })
- .catch((error: Error) => {
- $toast.error('Error at authorizing RFID tag')
- console.error('Error at authorizing RFID tag:', error)
- resetToggleButtonState(`${props.hashId}-${props.connectorId}-start-transaction`, true)
- $router.push({ name: 'charging-stations' })
- })
- } else {
- $uiClient
- ?.startTransaction(hashId, convertToInt(connectorId), state.idTag)
- .then(() => {
- $toast.success('Transaction successfully started')
- })
- .catch((error: Error) => {
- $toast.error('Error at starting transaction')
- console.error('Error at starting transaction:', error)
- })
- .finally(() => {
- resetToggleButtonState(`${props.hashId}-${props.connectorId}-start-transaction`, true)
- $router.push({ name: 'charging-stations' })
- })
- }
- }
- "
+ @click="handleStartTransaction"
>
Start Transaction
</Button>
</template>
<script setup lang="ts">
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useToast } from 'vue-toast-notification'
import Button from '@/components/buttons/Button.vue'
-import { convertToBoolean, convertToInt, resetToggleButtonState } from '@/composables'
+import { convertToInt, resetToggleButtonState, UIClient, useUIClient } from '@/composables'
+import { type OCPPVersion } from '@/types'
const props = defineProps<{
chargingStationId: string
hashId: string
}>()
+const $toast = useToast()
+const $router = useRouter()
+const $route = useRoute()
+
+const evseId = computed(() =>
+ $route.query.evseId != null ? Number($route.query.evseId) : undefined
+)
+const ocppVersion = computed(() => $route.query.ocppVersion as OCPPVersion | undefined)
+const isOCPP20x = computed(() => UIClient.isOCPP20x(ocppVersion.value))
+
const state = ref<{ authorizeIdTag: boolean; idTag: string }>({
authorizeIdTag: false,
idTag: '',
})
+
+const uiClient = useUIClient()
+
+const toggleButtonId = computed(
+ () => `${props.hashId}-${evseId.value ?? 0}-${props.connectorId}-start-transaction`
+)
+
+const handleStartTransaction = async (): Promise<void> => {
+ const idTag = state.value.idTag.trim().length > 0 ? state.value.idTag.trim() : undefined
+
+ if (!isOCPP20x.value && state.value.authorizeIdTag) {
+ if (idTag == null) {
+ $toast.error('Please provide an RFID tag to authorize')
+ return
+ }
+ try {
+ await uiClient.authorize(props.hashId, idTag)
+ } catch (error) {
+ $toast.error('Error at authorizing RFID tag')
+ console.error('Error at authorizing RFID tag:', error)
+ resetToggleButtonState(toggleButtonId.value, true)
+ $router.push({ name: 'charging-stations' })
+ return
+ }
+ }
+
+ try {
+ await uiClient.startTransaction(props.hashId, {
+ connectorId: convertToInt(props.connectorId),
+ evseId: evseId.value,
+ idTag,
+ ocppVersion: ocppVersion.value,
+ })
+ $toast.success('Transaction successfully started')
+ } catch (error) {
+ $toast.error('Error at starting transaction')
+ console.error('Error at starting transaction:', error)
+ } finally {
+ resetToggleButtonState(toggleButtonId.value, true)
+ $router.push({ name: 'charging-stations' })
+ }
+}
</script>
<style>
<template>
<tr class="connectors-table__row">
<td class="connectors-table__column">
- {{ connectorId }}
+ {{ evseId != null ? `${evseId}/${connectorId}` : connectorId }}
</td>
<td class="connectors-table__column">
{{ connector.status ?? 'Ø' }}
</td>
<td class="connectors-table__column">
<ToggleButton
- :id="`${hashId}-${connectorId}-start-transaction`"
+ :id="`${hashId}-${evseId ?? 0}-${connectorId}-start-transaction`"
:off="
() => {
$router.push({ name: 'charging-stations' })
$router.push({
name: 'start-transaction',
params: { hashId, chargingStationId, connectorId },
+ query: {
+ ...(evseId != null ? { evseId: String(evseId) } : {}),
+ ...(ocppVersion != null ? { ocppVersion } : {}),
+ },
})
}
"
<script setup lang="ts">
import { useToast } from 'vue-toast-notification'
-import type { ConnectorStatus, Status } from '@/types'
+import type { ConnectorStatus, OCPPVersion, Status } from '@/types'
import Button from '@/components/buttons/Button.vue'
import ToggleButton from '@/components/buttons/ToggleButton.vue'
chargingStationId: string
connector: ConnectorStatus
connectorId: number
+ evseId?: number
hashId: string
+ ocppVersion?: OCPPVersion
}>()
const $emit = defineEmits(['need-refresh'])
return
}
uiClient
- .stopTransaction(props.hashId, props.connector.transactionId)
+ .stopTransaction(props.hashId, {
+ ocppVersion: props.ocppVersion,
+ transactionId: props.connector.transactionId,
+ })
.then(() => {
return $toast.success('Transaction successfully stopped')
})
</thead>
<tbody id="connectors-table__body">
<CSConnector
- v-for="(connector, index) in getConnectorStatuses()"
- :key="index + 1"
- :atg-status="getATGStatus(index + 1)"
+ v-for="entry in getConnectorEntries()"
+ :key="entry.evseId != null ? `${entry.evseId}-${entry.connectorId}` : entry.connectorId"
+ :atg-status="getATGStatus(entry.connectorId)"
:charging-station-id="chargingStation.stationInfo.chargingStationId"
- :connector="connector"
- :connector-id="index + 1"
+ :connector="entry.connector"
+ :connector-id="entry.connectorId"
+ :evse-id="entry.evseId"
:hash-id="chargingStation.stationInfo.hashId"
+ :ocpp-version="chargingStation.stationInfo.ocppVersion"
@need-refresh="$emit('need-refresh')"
/>
</tbody>
import CSConnector from '@/components/charging-stations/CSConnector.vue'
import { deleteFromLocalStorage, getLocalStorage, useUIClient } from '@/composables'
+interface ConnectorTableEntry {
+ connector: ConnectorStatus
+ connectorId: number
+ evseId?: number
+}
+
const props = defineProps<{
chargingStation: ChargingStationData
}>()
const $emit = defineEmits(['need-refresh'])
-const getConnectorStatuses = (): ConnectorStatus[] => {
+const getConnectorEntries = (): ConnectorTableEntry[] => {
if (Array.isArray(props.chargingStation.evses) && props.chargingStation.evses.length > 0) {
- const connectorStatuses: ConnectorStatus[] = []
- for (const [evseId, evseStatus] of props.chargingStation.evses.entries()) {
- if (evseId > 0 && Array.isArray(evseStatus.connectors) && evseStatus.connectors.length > 0) {
- for (const connectorStatus of evseStatus.connectors) {
- connectorStatuses.push(connectorStatus)
+ const entries: ConnectorTableEntry[] = []
+ for (const evse of props.chargingStation.evses) {
+ if (evse.evseId > 0) {
+ for (const entry of evse.connectors) {
+ if (entry.connectorId > 0) {
+ entries.push({
+ connector: entry.connector,
+ connectorId: entry.connectorId,
+ evseId: evse.evseId,
+ })
+ }
}
}
}
- return connectorStatuses
+ return entries
}
- return props.chargingStation.connectors?.slice(1)
+ return (props.chargingStation.connectors ?? [])
+ .filter(c => c.connectorId > 0)
+ .map(entry => ({
+ connector: entry.connector,
+ connectorId: entry.connectorId,
+ }))
}
const getATGStatus = (connectorId: number): Status | undefined => {
- return props.chargingStation.automaticTransactionGenerator
- ?.automaticTransactionGeneratorStatuses?.[connectorId - 1]
+ return props.chargingStation.automaticTransactionGenerator?.automaticTransactionGeneratorStatuses?.find(
+ entry => entry.connectorId === connectorId
+ )?.status
}
const getSupervisionUrl = (): string => {
const supervisionUrl = new URL(props.chargingStation.supervisionUrl)
ApplicationProtocol,
AuthenticationType,
type ChargingStationOptions,
+ OCPP20IdTokenEnumType,
+ OCPP20TransactionEventEnumType,
+ type OCPP20TransactionEventRequest,
+ OCPPVersion,
ProcedureName,
type ProtocolResponse,
type RequestPayload,
return UIClient.instance
}
+ public static isOCPP20x (version: OCPPVersion | undefined): boolean {
+ return version === OCPPVersion.VERSION_20 || version === OCPPVersion.VERSION_201
+ }
+
public async addChargingStations (
template: string,
numberOfStations: number,
public async startTransaction (
hashId: string,
- connectorId: number,
- idTag: string | undefined
+ options: {
+ connectorId: number
+ evseId?: number
+ idTag?: string
+ ocppVersion?: OCPPVersion
+ }
): Promise<ResponsePayload> {
+ if (UIClient.isOCPP20x(options.ocppVersion)) {
+ return this.transactionEvent(hashId, {
+ eventType: OCPP20TransactionEventEnumType.STARTED,
+ evse:
+ options.evseId != null
+ ? { connectorId: options.connectorId, id: options.evseId }
+ : undefined,
+ idToken:
+ options.idTag != null
+ ? { idToken: options.idTag, type: OCPP20IdTokenEnumType.ISO14443 }
+ : undefined,
+ })
+ }
return this.sendRequest(ProcedureName.START_TRANSACTION, {
- connectorId,
+ connectorId: options.connectorId,
hashIds: [hashId],
- idTag,
+ idTag: options.idTag,
})
}
public async stopTransaction (
hashId: string,
- transactionId: number | undefined
+ options: {
+ ocppVersion?: OCPPVersion
+ transactionId: number | string | undefined
+ }
): Promise<ResponsePayload> {
+ if (UIClient.isOCPP20x(options.ocppVersion)) {
+ return this.transactionEvent(hashId, {
+ eventType: OCPP20TransactionEventEnumType.ENDED,
+ transactionId: options.transactionId?.toString(),
+ })
+ }
+ if (typeof options.transactionId === 'string') {
+ return {
+ errorMessage: 'OCPP 1.6 requires numeric transactionId',
+ status: ResponseStatus.FAILURE,
+ }
+ }
return this.sendRequest(ProcedureName.STOP_TRANSACTION, {
hashIds: [hashId],
- transactionId,
+ transactionId: options.transactionId,
})
}
}
})
}
+
+ private async transactionEvent (
+ hashId: string,
+ payload: OCPP20TransactionEventRequest
+ ): Promise<ResponsePayload> {
+ return this.sendRequest(ProcedureName.TRANSACTION_EVENT, {
+ hashIds: [hashId],
+ ...payload,
+ })
+ }
}
STOP_TRANSACTION = 'StopTransaction',
}
+export enum OCPP20IdTokenEnumType {
+ CENTRAL = 'Central',
+ EMAID = 'eMAID',
+ ISO14443 = 'ISO14443',
+ ISO15693 = 'ISO15693',
+ KEY_CODE = 'KeyCode',
+ LOCAL = 'Local',
+ MAC_ADDRESS = 'MacAddress',
+ NO_AUTHORIZATION = 'NoAuthorization',
+}
+
+export enum OCPP20TransactionEventEnumType {
+ ENDED = 'Ended',
+ STARTED = 'Started',
+ UPDATED = 'Updated',
+}
+
export enum OCPPProtocol {
JSON = 'json',
}
VOLTAGE_800 = 800,
}
+export interface ATGConfiguration extends JsonObject {
+ automaticTransactionGenerator?: AutomaticTransactionGeneratorConfiguration
+ automaticTransactionGeneratorStatuses?: ATGEntry[]
+}
+
+export interface ATGEntry extends JsonObject {
+ connectorId: number
+ status: Status
+}
+
export interface AutomaticTransactionGeneratorConfiguration extends JsonObject {
enable: boolean
idTagDistribution?: IdTagDistribution
export type ChargePointStatus = OCPP16ChargePointStatus
-export interface ChargingStationAutomaticTransactionGeneratorConfiguration extends JsonObject {
- automaticTransactionGenerator?: AutomaticTransactionGeneratorConfiguration
- automaticTransactionGeneratorStatuses?: Status[]
-}
-
export interface ChargingStationData extends JsonObject {
- automaticTransactionGenerator?: ChargingStationAutomaticTransactionGeneratorConfiguration
+ automaticTransactionGenerator?: ATGConfiguration
bootNotificationResponse?: BootNotificationResponse
- connectors: ConnectorStatus[]
- evses: EvseStatus[]
+ connectors?: ConnectorEntry[]
+ evses?: EvseEntry[]
ocppConfiguration: ChargingStationOcppConfiguration
started: boolean
stationInfo: ChargingStationInfo
visible?: boolean
}
+export interface ConnectorEntry extends JsonObject {
+ connector: ConnectorStatus
+ connectorId: number
+}
+
export interface ConnectorStatus extends JsonObject {
authorizeIdTag?: string
availability: AvailabilityType
localAuthorizeIdTag?: string
status?: ChargePointStatus
transactionEnergyActiveImportRegisterValue?: number // In Wh
- transactionId?: number
+ /**
+ * Transaction ID.
+ * For OCPP 1.6: numeric ID
+ * For OCPP 2.0.x: UUID string
+ */
+ transactionId?: number | string
transactionIdTag?: string
transactionRemoteStarted?: boolean
transactionStarted?: boolean
}
-export interface EvseStatus extends JsonObject {
+export interface EvseEntry extends JsonObject {
availability: AvailabilityType
- connectors?: ConnectorStatus[]
+ connectors: ConnectorEntry[]
+ evseId: number
+}
+
+export interface OCPP20EVSEType extends JsonObject {
+ connectorId?: number
+ id: number
+}
+
+export interface OCPP20IdTokenType extends JsonObject {
+ idToken: string
+ type: OCPP20IdTokenEnumType
+}
+
+export interface OCPP20TransactionEventRequest extends JsonObject {
+ eventType: OCPP20TransactionEventEnumType
+ evse?: OCPP20EVSEType
+ idToken?: OCPP20IdTokenType
+ transactionId?: string
}
export const FirmwareStatus = {
-export type JsonObject = { [key in string]?: JsonType }
+export type JsonObject = {
+ [key in string]?: (JsonObject | JsonPrimitive)[] | JsonObject | JsonPrimitive
+}
export type JsonType = JsonObject | JsonPrimitive | JsonType[]
type JsonPrimitive = boolean | Date | null | number | string
AUTHORIZE = 'authorize',
CLOSE_CONNECTION = 'closeConnection',
DELETE_CHARGING_STATIONS = 'deleteChargingStations',
+ GET_15118_EV_CERTIFICATE = 'get15118EVCertificate',
+ GET_CERTIFICATE_STATUS = 'getCertificateStatus',
LIST_CHARGING_STATIONS = 'listChargingStations',
LIST_TEMPLATES = 'listTemplates',
+ LOG_STATUS_NOTIFICATION = 'logStatusNotification',
+ NOTIFY_CUSTOMER_INFORMATION = 'notifyCustomerInformation',
+ NOTIFY_REPORT = 'notifyReport',
OPEN_CONNECTION = 'openConnection',
+ SECURITY_EVENT_NOTIFICATION = 'securityEventNotification',
SET_SUPERVISION_URL = 'setSupervisionUrl',
+ SIGN_CERTIFICATE = 'signCertificate',
SIMULATOR_STATE = 'simulatorState',
START_AUTOMATIC_TRANSACTION_GENERATOR = 'startAutomaticTransactionGenerator',
START_CHARGING_STATION = 'startChargingStation',
STOP_CHARGING_STATION = 'stopChargingStation',
STOP_SIMULATOR = 'stopSimulator',
STOP_TRANSACTION = 'stopTransaction',
+ TRANSACTION_EVENT = 'transactionEvent',
}
export enum Protocol {
export type {
+ ATGConfiguration,
+ ATGEntry,
ChargingStationData,
ChargingStationInfo,
ChargingStationOptions,
+ ConnectorEntry,
ConnectorStatus,
+ EvseEntry,
+ OCPP20TransactionEventRequest,
Status,
} from './ChargingStationType'
+export {
+ OCPP20IdTokenEnumType,
+ OCPP20TransactionEventEnumType,
+ OCPPVersion,
+} from './ChargingStationType'
export type { ConfigurationData, UIServerConfigurationSection } from './ConfigurationType'
export {
ApplicationProtocol,
:class="simulatorButtonClass"
:off="() => stopSimulator()"
:on="() => startSimulator()"
- :status="simulatorState?.started"
+ :status="simulatorStarted"
>
{{ simulatorButtonMessage }}
</ToggleButton>
const simulatorState = ref<SimulatorState | undefined>(undefined)
-const simulatorButtonClass = computed<string>(() =>
+const simulatorStarted = computed((): boolean | undefined => simulatorState.value?.started)
+
+const simulatorButtonClass = computed((): string =>
simulatorState.value?.started === true ? 'simulator-stop-button' : 'simulator-start-button'
)
-const simulatorButtonMessage = computed<string>(
- () =>
+const simulatorButtonMessage = computed(
+ (): string =>
`${simulatorState.value?.started === true ? 'Stop' : 'Start'} Simulator${
simulatorState.value?.version != null ? ` (${simulatorState.value.version})` : ''
}`
const app = getCurrentInstance()
-watch(app!.appContext.config.globalProperties!.$chargingStations, () => {
- state.value.renderChargingStations = randomUUID()
-})
+const chargingStationsRef = app?.appContext.config.globalProperties.$chargingStations
+if (chargingStationsRef != null) {
+ watch(chargingStationsRef, () => {
+ state.value.renderChargingStations = randomUUID()
+ })
+}
watch(simulatorState, () => {
state.value.renderSimulator = randomUUID()
--- /dev/null
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { UIClient } from '@/composables/UIClient'
+import {
+ OCPP20TransactionEventEnumType,
+ OCPPVersion,
+ Protocol,
+ ProtocolVersion,
+ ResponseStatus,
+} from '@/types'
+
+vi.mock('vue-toast-notification', () => ({
+ useToast: () => ({
+ error: vi.fn(),
+ info: vi.fn(),
+ success: vi.fn(),
+ }),
+}))
+
+class MockWebSocket {
+ addEventListener = vi.fn()
+ close = vi.fn()
+ onclose: (() => void) | null = null
+ onerror: ((event: Event) => void) | null = null
+ onmessage: ((event: MessageEvent) => void) | null = null
+
+ onopen: (() => void) | null = null
+
+ readyState = WebSocket.OPEN
+ removeEventListener = vi.fn()
+ send = vi.fn()
+ constructor () {
+ setTimeout(() => {
+ this.onopen?.()
+ }, 0)
+ }
+}
+
+const mockConfig = {
+ host: 'localhost',
+ port: 8080,
+ protocol: Protocol.UI,
+ version: ProtocolVersion['0.0.1'],
+}
+
+describe('UIClient', () => {
+ describe('isOCPP20x', () => {
+ it('should return true for VERSION_20', () => {
+ expect(UIClient.isOCPP20x(OCPPVersion.VERSION_20)).toBe(true)
+ })
+
+ it('should return true for VERSION_201', () => {
+ expect(UIClient.isOCPP20x(OCPPVersion.VERSION_201)).toBe(true)
+ })
+
+ it('should return false for VERSION_16', () => {
+ expect(UIClient.isOCPP20x(OCPPVersion.VERSION_16)).toBe(false)
+ })
+
+ it('should return false for undefined', () => {
+ expect(UIClient.isOCPP20x(undefined)).toBe(false)
+ })
+ })
+
+ describe('version-aware transaction methods', () => {
+ let client: UIClient
+ let sendRequestSpy: ReturnType<typeof vi.spyOn>
+
+ beforeEach(() => {
+ // @ts-expect-error - accessing private static property for testing
+ UIClient.instance = null
+ vi.stubGlobal('WebSocket', MockWebSocket)
+ client = UIClient.getInstance(mockConfig)
+ // @ts-expect-error - accessing private method for testing
+ sendRequestSpy = vi.spyOn(client, 'sendRequest').mockResolvedValue({
+ status: ResponseStatus.SUCCESS,
+ })
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ vi.unstubAllGlobals()
+ // @ts-expect-error - accessing private static property for testing
+ UIClient.instance = null
+ })
+
+ describe('startTransaction', () => {
+ it('should send START_TRANSACTION for OCPP 1.6', async () => {
+ await client.startTransaction('hash123', {
+ connectorId: 1,
+ idTag: 'idTag123',
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('startTransaction', {
+ connectorId: 1,
+ hashIds: ['hash123'],
+ idTag: 'idTag123',
+ })
+ })
+
+ it('should send TRANSACTION_EVENT with evse object for OCPP 2.0.x', async () => {
+ await client.startTransaction('hash123', {
+ connectorId: 2,
+ evseId: 1,
+ idTag: 'idTag123',
+ ocppVersion: OCPPVersion.VERSION_20,
+ })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+ eventType: OCPP20TransactionEventEnumType.STARTED,
+ evse: { connectorId: 2, id: 1 },
+ hashIds: ['hash123'],
+ idToken: { idToken: 'idTag123', type: 'ISO14443' },
+ })
+ })
+
+ it('should default to OCPP 1.6 when version is undefined', async () => {
+ await client.startTransaction('hash123', { connectorId: 1, idTag: 'idTag123' })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('startTransaction', {
+ connectorId: 1,
+ hashIds: ['hash123'],
+ idTag: 'idTag123',
+ })
+ })
+
+ it('should send undefined evse when evseId is not provided for OCPP 2.0.x', async () => {
+ await client.startTransaction('hash123', {
+ connectorId: 1,
+ idTag: 'idTag123',
+ ocppVersion: OCPPVersion.VERSION_20,
+ })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+ eventType: OCPP20TransactionEventEnumType.STARTED,
+ evse: undefined,
+ hashIds: ['hash123'],
+ idToken: { idToken: 'idTag123', type: 'ISO14443' },
+ })
+ })
+
+ it('should send undefined idToken when idTag is not provided for OCPP 2.0.x', async () => {
+ await client.startTransaction('hash123', {
+ connectorId: 1,
+ evseId: 1,
+ ocppVersion: OCPPVersion.VERSION_20,
+ })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+ eventType: OCPP20TransactionEventEnumType.STARTED,
+ evse: { connectorId: 1, id: 1 },
+ hashIds: ['hash123'],
+ idToken: undefined,
+ })
+ })
+
+ it('should send undefined evse and idToken when both absent for OCPP 2.0.x', async () => {
+ await client.startTransaction('hash123', {
+ connectorId: 1,
+ ocppVersion: OCPPVersion.VERSION_20,
+ })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+ eventType: OCPP20TransactionEventEnumType.STARTED,
+ evse: undefined,
+ hashIds: ['hash123'],
+ idToken: undefined,
+ })
+ })
+ })
+
+ describe('stopTransaction', () => {
+ it('should send STOP_TRANSACTION for OCPP 1.6', async () => {
+ await client.stopTransaction('hash123', {
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionId: 12345,
+ })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('stopTransaction', {
+ hashIds: ['hash123'],
+ transactionId: 12345,
+ })
+ })
+
+ it('should send TRANSACTION_EVENT with Ended for OCPP 2.0.x', async () => {
+ await client.stopTransaction('hash123', {
+ ocppVersion: OCPPVersion.VERSION_20,
+ transactionId: 'tx-uuid-123',
+ })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+ eventType: OCPP20TransactionEventEnumType.ENDED,
+ hashIds: ['hash123'],
+ transactionId: 'tx-uuid-123',
+ })
+ })
+
+ it('should default to OCPP 1.6 when version is undefined', async () => {
+ await client.stopTransaction('hash123', { transactionId: 12345 })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('stopTransaction', {
+ hashIds: ['hash123'],
+ transactionId: 12345,
+ })
+ })
+
+ it('should send undefined transactionId for OCPP 2.0.x when not provided', async () => {
+ await client.stopTransaction('hash123', {
+ ocppVersion: OCPPVersion.VERSION_20,
+ transactionId: undefined,
+ })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+ eventType: OCPP20TransactionEventEnumType.ENDED,
+ hashIds: ['hash123'],
+ transactionId: undefined,
+ })
+ })
+
+ it('should convert numeric transactionId to string for OCPP 2.0.x', async () => {
+ await client.stopTransaction('hash123', {
+ ocppVersion: OCPPVersion.VERSION_20,
+ transactionId: 12345,
+ })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+ eventType: OCPP20TransactionEventEnumType.ENDED,
+ hashIds: ['hash123'],
+ transactionId: '12345',
+ })
+ })
+
+ it('should return failure for string transactionId with OCPP 1.6', async () => {
+ const result = await client.stopTransaction('hash123', {
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionId: 'string-id',
+ })
+
+ expect(result.status).toBe(ResponseStatus.FAILURE)
+ expect(sendRequestSpy).not.toHaveBeenCalled()
+ })
+
+ it('should send undefined transactionId for OCPP 1.6 when not provided', async () => {
+ await client.stopTransaction('hash123', {
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionId: undefined,
+ })
+
+ expect(sendRequestSpy).toHaveBeenCalledWith('stopTransaction', {
+ hashIds: ['hash123'],
+ transactionId: undefined,
+ })
+ })
+ })
+ })
+})