From 4aeb171dbb5cd2f9122452c1e45759e843d6ddb1 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 18 Mar 2026 14:34:49 +0100 Subject: [PATCH] feat(ui): add OCPP 2.0.x command support to Web UI (#1734) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(ui): add OCPP 2.0.x types and sync ProcedureName enum - Add OCPP 2.0.x-specific procedures to ProcedureName enum: TRANSACTION_EVENT, GET_15118_EV_CERTIFICATE, GET_CERTIFICATE_STATUS, LOG_STATUS_NOTIFICATION, NOTIFY_CUSTOMER_INFORMATION, NOTIFY_REPORT, SECURITY_EVENT_NOTIFICATION, SIGN_CERTIFICATE - Add OCPP 2.0.x type definitions: - OCPP20IdTokenEnumType (8 values) - OCPP20TransactionEventEnumType (Ended, Started, Updated) - OCPP20IdTokenType interface - OCPP20TransactionEventRequest interface - Update ConnectorStatus.transactionId to support both number (OCPP 1.6) and string/UUID (OCPP 2.0.x) Wave 1 complete. * feat(ui): add UIClient transaction methods with OCPP version support - Add transactionEvent() method for OCPP 2.0.x TransactionEvent requests - Add isOCPP20x() static helper for version detection - Add startTransactionForVersion() helper that routes to appropriate method based on OCPP version (1.6 vs 2.0.x) - Add stopTransactionForVersion() helper for version-aware stop - Add comprehensive unit tests (16 tests passing) Wave 2 complete. * feat(ui): add version-aware StartTransaction form with OCPP 2.0.x support - Modify StartTransaction.vue to detect OCPP version from station info - Show connector ID for OCPP 1.6, EVSE ID input for OCPP 2.0.x - Hide Authorize checkbox for OCPP 2.0.x stations (v-if) - Use startTransactionForVersion() helper for version-aware API calls - Add loading state while fetching station info - Show appropriate form fields based on OCPP version - All 16 tests passing Wave 3 complete. * fix(webui): use enums instead of string literals for OCPP 2.0.x types - Replace 'ISO14443' string with OCPP20IdTokenEnumType.ISO14443 - Fix test mocks to use Protocol.UI and ResponseStatus.SUCCESS enums - Export OCPP20IdTokenEnumType from types index * style(webui): fix import ordering in UIClient.ts * [autofix.ci] apply automated fixes * chore: remove tsbuildinfo and add to gitignore * fix(webui): address PR review comments - Add ResponseStatus import and use enum instead of string - Use UIClient.isOCPP20x() helper instead of manual comparison - Remove redundant showAuthorize computed, use !isOCPP20x directly - Fix authorizeIdTag checkbox binding (remove true-value/false-value) - Initialize evseId from props.connectorId - Separate error handling for authorize vs startTransaction - Add validation for transactionId type in stopTransactionForVersion * [autofix.ci] apply automated fixes * chore: move tsbuildinfo gitignore to ui/web subdirectory - Remove *.tsbuildinfo from root .gitignore - Add *.tsbuildinfo to ui/web/.gitignore with proper comment * ci(webui): add TypeScript type checking to CI - Add vue-tsc dev dependency to ui/web - Add typecheck script to package.json - Add typecheck step to build-dashboard job in CI * fix(webui): fix vue-tsc typecheck and improve Vue.js best practices - Break recursive JsonObject/JsonType chain causing TS2589 in vue-tsc - Fix CSConnector to use stopTransactionForVersion with ocppVersion prop - Replace getCurrentInstance() anti-pattern with useToast()/useRouter() - Make isOCPP20x a computed instead of manually-synced ref - Deduplicate handleStartTransaction (remove ~30 lines of duplication) - Add null guards for watch() on potentially undefined global refs * [autofix.ci] apply automated fixes * refactor(webui): align namespace with simulator and improve API design - Merge startTransactionForVersion/stopTransactionForVersion into startTransaction/stopTransaction with optional ocppVersion param - Make transactionEvent private (implementation detail, not public API) - Revert UITransactionEventPayload to OCPP20TransactionEventRequest to match backend naming convention - Pass ocppVersion via route param instead of re-fetching all stations - Remove convertToBoolean no-op and loading state - Flip negated v-if condition for SonarCloud compliance - Factor test setup, remove duplicate coverage, add missing test case - Net result: -90 lines, cleaner API surface * [autofix.ci] apply automated fixes * refactor(webui): move tests from __tests__ to tests/unit for consistency Align test file location with existing project convention (tests/unit/) instead of Jest-style __tests__ directory. * feat(webui): integrate OCPP 2.0 EVSE 3-tier model across web UI - Add OCPP20EVSEType matching spec EVSEType {id, connectorId?} - Replace flat evseId with proper evse object in TransactionEventRequest - CSData: preserve EVSE→Connector mapping instead of flattening - CSConnector: display 'evseId/connectorId' for OCPP 2.0 stations - Route: pass evseId and ocppVersion as query params (not sentinels) - StartTransaction: read EVSE context from query, display EVSE/Connector - UIClient.startTransaction: accept evseId, build spec-compliant evse object - Tests: exhaustive decision tree coverage (18 tests, all branches) * [autofix.ci] apply automated fixes * refactor(webui): improve API elegance and fix cross-version concerns - Refactor startTransaction/stopTransaction to use named options object instead of positional params for better readability and grouping - Fix ToggleButton ID collision: include evseId for multi-EVSE uniqueness - Normalize idTag validation as cross-version concern (empty→undefined) - Rename getConnectorStatuses→getConnectorEntries to match semantics - Extract toggleButtonId as computed to avoid string duplication * feat: entry-based serialization for EVSE/Connector/ATG data Introduce ConnectorEntry, EvseEntry, and ATGStatusEntry types at the UI serialization boundary to preserve Map keys (evseId, connectorId) that were previously lost during Map-to-Array conversion. Backend: - Add buildConnectorEntries/buildEvseEntries using .entries() - Keep buildConnectorsStatus/buildEvsesStatus unchanged for config persistence - Remove EvseStatusWorkerType and OutputFormat enum - ATG statuses serialized with connectorId at the UI boundary only - ConnectorStatus and EvseStatus types remain pure (no identity fields) Frontend: - CSData uses explicit IDs from Entry types (no more index-based mapping) - ATG status lookup by connectorId instead of array index - Filter connector 0 in both OCPP 1.6 and 2.0 paths - Both connectors and evses optional in ChargingStationData * [autofix.ci] apply automated fixes * fix(webui): use data presence instead of protocol version for EVSE display EVSE/Connector display depends on whether the station has EVSEs (data), not on the OCPP version (protocol). A station could use EVSEs regardless of OCPP version. * refactor: extract buildATGStatusEntries for consistent Entry builder pattern Align ATG status serialization with ConnectorEntry/EvseEntry pattern: dedicated builder function instead of inline transformation. * refactor: harmonize Entry naming pattern across codebase Rename ATGStatusEntry/buildATGStatusEntries to ATGEntry/buildATGEntries to match ConnectorEntry/EvseEntry naming convention. * refactor: define ATGConfiguration type and harmonize across workspaces Extract inline ATG type into named ATGConfiguration interface, matching the ATG*/Connector*/Evse* Entry pattern in both backend and frontend. * test: add missing Entry builder tests and non-sequential ID coverage - Add buildATGEntries tests (entries with IDs, non-sequential, no ATG, no status) - Add non-sequential connector ID test for buildConnectorEntries (keys 0,3,7) - Add non-sequential evseId/connectorId test for buildEvseEntries (3/2,5) - These tests verify the core invariant of the Entry pattern: Map keys are preserved regardless of their sequence * refactor: organize tests by concern and replace throw with graceful failure - Group backend tests: config persistence builders then UI Entry builders - Replace throw Error in stopTransaction with ResponsePayload FAILURE to avoid unhandled rejections in UI components * docs: harmonize quality gate ordering (typecheck before lint) across codebase Align CI workflows, READMEs, and copilot-instructions to consistently run typecheck before lint in all three sub-projects. * refactor(ocpp2): centralize TransactionEvent payload building - requestStopTransaction delegates to sendTransactionEvent instead of building the OCPP payload inline, eliminating duplication - All TransactionEvent paths now converge through sendTransactionEvent → buildTransactionEvent as the single payload construction point - handleTransactionEvent (UI) dispatches Started/Ended to proper flows with connector state initialization and lifecycle management - Remove unused imports (secondsToMilliseconds, Constants) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .github/copilot-instructions.md | 2 +- .github/workflows/ci.yml | 15 +- pnpm-lock.yaml | 81 ++++++ .../ChargingStationWorkerBroadcastChannel.ts | 71 ++++- .../ocpp/2.0/OCPP20IncomingRequestService.ts | 18 +- .../ocpp/2.0/OCPP20ServiceUtils.ts | 97 ++++--- src/types/ChargingStationWorker.ts | 34 ++- src/types/index.ts | 5 +- .../ChargingStationConfigurationUtils.ts | 78 +++-- src/utils/MessageChannelUtils.ts | 18 +- src/utils/index.ts | 4 +- tests/ocpp-server/README.md | 8 +- .../ChargingStationConfigurationUtils.test.ts | 266 +++++++++++++----- ui/web/.gitignore | 3 + ui/web/README.md | 1 + ui/web/package.json | 6 +- .../actions/AddChargingStations.vue | 9 +- .../components/actions/StartTransaction.vue | 122 ++++---- .../charging-stations/CSConnector.vue | 17 +- .../components/charging-stations/CSData.vue | 50 +++- ui/web/src/composables/UIClient.ts | 62 +++- ui/web/src/types/ChargingStationType.ts | 72 ++++- ui/web/src/types/JsonType.ts | 4 +- ui/web/src/types/UIProtocol.ts | 8 + ui/web/src/types/index.ts | 10 + ui/web/src/views/ChargingStationsView.vue | 19 +- ui/web/tests/unit/UIClient.test.ts | 257 +++++++++++++++++ 27 files changed, 1044 insertions(+), 293 deletions(-) create mode 100644 ui/web/tests/unit/UIClient.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 71494702..67503c74 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -101,7 +101,7 @@ Documentation serves as an operational specification, not narrative prose. ## 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). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94016cfe..37971c13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,12 +42,12 @@ jobs: 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 @@ -86,12 +86,12 @@ jobs: # - 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 @@ -141,6 +141,9 @@ jobs: # - 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0596d55f..34a887ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: 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: @@ -1472,6 +1475,15 @@ 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'} @@ -1509,6 +1521,9 @@ packages: '@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==} @@ -1518,6 +1533,14 @@ packages: '@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==} @@ -1616,6 +1639,9 @@ packages: 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==} @@ -2432,6 +2458,9 @@ packages: 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'} @@ -3304,6 +3333,10 @@ packages: 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: @@ -5724,6 +5757,12 @@ packages: 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: @@ -7332,6 +7371,18 @@ snapshots: 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 @@ -7401,6 +7452,11 @@ snapshots: '@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 @@ -7414,6 +7470,19 @@ snapshots: '@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 @@ -7508,6 +7577,8 @@ snapshots: 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 @@ -8529,6 +8600,8 @@ snapshots: date-fns@4.1.0: {} + de-indent@1.0.2: {} + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -9593,6 +9666,8 @@ snapshots: 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 @@ -12183,6 +12258,12 @@ snapshots: 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 diff --git a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts index ba414170..8b818c58 100644 --- a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts +++ b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts @@ -19,6 +19,7 @@ import { type DiagnosticsStatusNotificationRequest, type DiagnosticsStatusNotificationResponse, type EmptyObject, + ErrorType, type FirmwareStatusNotificationRequest, type FirmwareStatusNotificationResponse, GenericStatus, @@ -44,8 +45,10 @@ import { type OCPP20SecurityEventNotificationResponse, type OCPP20SignCertificateRequest, type OCPP20SignCertificateResponse, + OCPP20TransactionEventEnumType, type OCPP20TransactionEventRequest, type OCPP20TransactionEventResponse, + OCPP20TriggerReasonEnumType, OCPPVersion, RegistrationStatusEnumType, RequestCommand, @@ -62,13 +65,14 @@ import { 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' @@ -619,15 +623,66 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne private async handleTransactionEvent ( requestPayload?: BroadcastChannelRequestPayload ): Promise { - 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 { + 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 { + 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 { diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index 00c7ee1e..0d479461 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -2,8 +2,6 @@ import type { ValidateFunction } from 'ajv' -import { secondsToMilliseconds } from 'date-fns' - import type { ChargingStation } from '../../../charging-station/index.js' import type { OCPP20ChargingProfileType, @@ -128,7 +126,6 @@ import { OCPP20ReasonEnumType, } from '../../../types/ocpp/2.0/Transaction.js' import { - Constants, convertToDate, generateUUID, logger, @@ -1119,20 +1116,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } 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 ( diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 5732bfe1..731a5edf 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -1,11 +1,13 @@ /* 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, @@ -28,10 +30,16 @@ import { 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' @@ -386,6 +394,30 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { 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 @@ -427,7 +459,7 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { chargingStation: ChargingStation, connectorId: number, evseId?: number - ): Promise { + ): Promise { const connectorStatus = chargingStation.getConnectorStatus(connectorId) if (connectorStatus?.transactionStarted && connectorStatus.transactionId != null) { let transactionId: string @@ -436,27 +468,10 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { } 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 @@ -473,37 +488,29 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { }) } - 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()}` + ) } /** diff --git a/src/types/ChargingStationWorker.ts b/src/types/ChargingStationWorker.ts index a0fff875..4a4806b7 100644 --- a/src/types/ChargingStationWorker.ts +++ b/src/types/ChargingStationWorker.ts @@ -1,12 +1,15 @@ 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' @@ -17,11 +20,21 @@ enum ChargingStationMessageEvents { 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 @@ -67,6 +80,13 @@ export type ChargingStationWorkerMessageEvents = | ChargingStationEvents | ChargingStationMessageEvents -export type EvseStatusWorkerType = Omit & { - connectors?: ConnectorStatus[] +export interface ConnectorEntry { + connector: ConnectorStatus + connectorId: number +} + +export interface EvseEntry { + availability: AvailabilityType + connectors: ConnectorEntry[] + evseId: number } diff --git a/src/types/index.ts b/src/types/index.ts index f684b177..ed5470e7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -24,13 +24,16 @@ export { 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, diff --git a/src/utils/ChargingStationConfigurationUtils.ts b/src/utils/ChargingStationConfigurationUtils.ts index 19a62cdb..4c7af299 100644 --- a/src/utils/ChargingStationConfigurationUtils.ts +++ b/src/utils/ChargingStationConfigurationUtils.ts @@ -1,11 +1,22 @@ 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 => { @@ -19,6 +30,23 @@ export const buildChargingStationAutomaticTransactionGeneratorConfiguration = ( } } +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( ({ @@ -30,15 +58,28 @@ export const buildConnectorsStatus = (chargingStation: ChargingStation): Connect ) } -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( ({ @@ -48,22 +89,11 @@ export const buildEvsesStatus = ( ...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 }) } diff --git a/src/utils/MessageChannelUtils.ts b/src/utils/MessageChannelUtils.ts index f40795e7..9335c644 100644 --- a/src/utils/MessageChannelUtils.ts +++ b/src/utils/MessageChannelUtils.ts @@ -10,10 +10,9 @@ import { type TimestampedData, } from '../types/index.js' import { - buildChargingStationAutomaticTransactionGeneratorConfiguration, - buildConnectorsStatus, - buildEvsesStatus, - OutputFormat, + buildATGEntries, + buildConnectorEntries, + buildEvseEntries, } from './ChargingStationConfigurationUtils.js' const buildChargingStationWorkerMessage = ( @@ -98,8 +97,8 @@ export const buildPerformanceStatisticsMessage = ( 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, @@ -109,8 +108,11 @@ const buildChargingStationDataPayload = (chargingStation: ChargingStation): Char timestamp: Date.now(), wsState: chargingStation.wsConnection?.readyState, ...(chargingStation.automaticTransactionGenerator != null && { - automaticTransactionGenerator: - buildChargingStationAutomaticTransactionGeneratorConfiguration(chargingStation), + automaticTransactionGenerator: { + automaticTransactionGenerator: + chargingStation.getAutomaticTransactionGeneratorConfiguration(), + automaticTransactionGeneratorStatuses: buildATGEntries(chargingStation), + }, }), } } diff --git a/src/utils/index.ts b/src/utils/index.ts index a36d9451..f172e191 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,9 +1,11 @@ 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' diff --git a/tests/ocpp-server/README.md b/tests/ocpp-server/README.md index 868edfdf..85ae9581 100644 --- a/tests/ocpp-server/README.md +++ b/tests/ocpp-server/README.md @@ -160,16 +160,16 @@ poetry run python server.py --command GetBaseReport --period 5 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 diff --git a/tests/utils/ChargingStationConfigurationUtils.test.ts b/tests/utils/ChargingStationConfigurationUtils.test.ts index 961d20fd..0ea60644 100644 --- a/tests/utils/ChargingStationConfigurationUtils.test.ts +++ b/tests/utils/ChargingStationConfigurationUtils.test.ts @@ -1,8 +1,9 @@ /** * @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' @@ -12,10 +13,12 @@ import type { ConnectorStatus, EvseStatus } from '../../src/types/index.js' 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' @@ -48,6 +51,8 @@ await describe('ChargingStationConfigurationUtils', async () => { standardCleanup() }) + // ── Config persistence builders ──────────────────────────────────────── + await describe('buildConnectorsStatus', async () => { await it('should strip internal transaction fields from connectors', () => { const noop = (): void => { @@ -137,7 +142,7 @@ await describe('ChargingStationConfigurationUtils', async () => { }) 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 @@ -145,7 +150,7 @@ await describe('ChargingStationConfigurationUtils', async () => { 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() evseConnectors.set(1, { availability: AvailabilityType.Operative, @@ -162,7 +167,7 @@ await describe('ChargingStationConfigurationUtils', async () => { }) const station = createMockStationForConfigUtils({ evses }) - const result = buildEvsesStatus(station, OutputFormat.configuration) + const result = buildEvsesStatus(station) const evse1 = result[0] as Record const connectorsStatus = evse1.connectorsStatus as ConnectorStatus[] @@ -172,69 +177,11 @@ await describe('ChargingStationConfigurationUtils', async () => { assert.ok(!('transactionTxUpdatedSetInterval' in connectorsStatus[0])) }) - await it('should return worker format with connectors array', () => { - const evseConnectors = new Map() - evseConnectors.set(1, { - availability: AvailabilityType.Operative, - MeterValues: [], - transactionEventQueue: undefined, - transactionSetInterval: undefined, - transactionTxUpdatedSetInterval: 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, - }) - - const station = createMockStationForConfigUtils({ evses }) - const result = buildEvsesStatus(station, OutputFormat.worker) - - assert.strictEqual(result.length, 2) - const evse1 = result[1] as Record - 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() - evses.set(1, { - availability: AvailabilityType.Operative, - connectors: new Map(), - }) - - const station = createMockStationForConfigUtils({ evses }) - const result = buildEvsesStatus(station) - - assert.strictEqual(result.length, 1) - const evse = result[0] as Record - 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() - evses.set(1, { - availability: AvailabilityType.Operative, - connectors: new Map(), - }) - const station = createMockStationForConfigUtils({ evses }) - - assert.throws(() => { - buildEvsesStatus(station, 'unknown' as OutputFormat) - }, RangeError) - }) }) await describe('buildChargingStationAutomaticTransactionGeneratorConfiguration', async () => { @@ -291,4 +238,193 @@ await describe('ChargingStationConfigurationUtils', 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() + 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() + 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() + 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() + 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() + evseConnectors.set(1, { + availability: AvailabilityType.Operative, + MeterValues: [], + transactionEventQueue: [], + transactionSetInterval: undefined, + transactionTxUpdatedSetInterval: 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, + }) + + 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() + evse2Connectors.set(2, { + availability: AvailabilityType.Operative, + MeterValues: [], + } as ConnectorStatus) + evse2Connectors.set(5, { + availability: AvailabilityType.Inoperative, + MeterValues: [], + } as ConnectorStatus) + + const evses = new Map() + evses.set(0, { + availability: AvailabilityType.Operative, + connectors: new Map(), + }) + 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 + ) + }) + }) }) diff --git a/ui/web/.gitignore b/ui/web/.gitignore index 447b9ddd..b8846175 100644 --- a/ui/web/.gitignore +++ b/ui/web/.gitignore @@ -41,3 +41,6 @@ pnpm-debug.log* # pnpm package-lock.json + +# TypeScript incremental compilation information +*.tsbuildinfo diff --git a/ui/web/README.md b/ui/web/README.md index d234ce4e..eb544a2c 100644 --- a/ui/web/README.md +++ b/ui/web/README.md @@ -182,6 +182,7 @@ This builds the image and runs the container, exposing the Web UI on port 3030. | `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 | diff --git a/ui/web/package.json b/ui/web/package.json index 7a99186a..6454551d 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -24,7 +24,8 @@ "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", @@ -48,6 +49,7 @@ "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" } } diff --git a/ui/web/src/components/actions/AddChargingStations.vue b/ui/web/src/components/actions/AddChargingStations.vue index 5551b917..7b8e43fd 100644 --- a/ui/web/src/components/actions/AddChargingStations.vue +++ b/ui/web/src/components/actions/AddChargingStations.vue @@ -138,9 +138,12 @@ const state = ref<{ 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() + }) +}