]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ui): add OCPP 2.0.x command support to Web UI (#1734)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 18 Mar 2026 13:34:49 +0000 (14:34 +0100)
committerGitHub <noreply@github.com>
Wed, 18 Mar 2026 13:34:49 +0000 (14:34 +0100)
* 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>
27 files changed:
.github/copilot-instructions.md
.github/workflows/ci.yml
pnpm-lock.yaml
src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/types/ChargingStationWorker.ts
src/types/index.ts
src/utils/ChargingStationConfigurationUtils.ts
src/utils/MessageChannelUtils.ts
src/utils/index.ts
tests/ocpp-server/README.md
tests/utils/ChargingStationConfigurationUtils.test.ts
ui/web/.gitignore
ui/web/README.md
ui/web/package.json
ui/web/src/components/actions/AddChargingStations.vue
ui/web/src/components/actions/StartTransaction.vue
ui/web/src/components/charging-stations/CSConnector.vue
ui/web/src/components/charging-stations/CSData.vue
ui/web/src/composables/UIClient.ts
ui/web/src/types/ChargingStationType.ts
ui/web/src/types/JsonType.ts
ui/web/src/types/UIProtocol.ts
ui/web/src/types/index.ts
ui/web/src/views/ChargingStationsView.vue
ui/web/tests/unit/UIClient.test.ts [new file with mode: 0644]

index 714947021659900a726286dd56c7c0ea8e7e8901..67503c74949d74601b8ba9e2cf28f2703c1eb573 100644 (file)
@@ -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).
index 94016cfea9e451a0b36d57094f96d9b09340ad50..37971c13b33afdadffb8ceed0f824ff66772b78d 100644 (file)
@@ -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
index 0596d55fd8835332e10669ebd32989d5896fcef2..34a887ae41b221ba5a5061b576fa92f15445fe75 100644 (file)
@@ -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
index ba414170d30f9e106b74406945ebdccd1be12d00..8b818c5849cac67459d164038de9693d3c12c54c 100644 (file)
@@ -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<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 {
index 00c7ee1e5b0925a0ba4ec81aab001e2d27af5571..0d479461d2f090abaa67814c822e038c7afa794f 100644 (file)
@@ -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 (
index 5732bfe1ccf1a4054f338000920ae59905f25c92..731a5edf09de7abac897a3e3688517a6230e4724 100644 (file)
@@ -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<GenericResponse> {
+  ): Promise<OCPP20TransactionEventResponse> {
     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()}`
+    )
   }
 
   /**
index a0fff8758eb72af2c723b05b8b5a2d4a3342d717..4a4806b789da50c82fd955563c04dc6873578410 100644 (file)
@@ -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<EvseStatus, 'connectors'> & {
-  connectors?: ConnectorStatus[]
+export interface ConnectorEntry {
+  connector: ConnectorStatus
+  connectorId: number
+}
+
+export interface EvseEntry {
+  availability: AvailabilityType
+  connectors: ConnectorEntry[]
+  evseId: number
 }
index f684b1771e050164c30aef4efca643272ef3aca5..ed5470e7e364973baf8bdc2a8e5a956633919cf1 100644 (file)
@@ -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,
index 19a62cdbd347de27b65d3a825dfca5131e23b619..4c7af2991565061b280633676716f68ffed73cbb 100644 (file)
@@ -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
   })
 }
index f40795e7e0b8aed4c779879fca9fe5eaab027ee2..9335c6448ed58619f81add9ed844cf15e25014ac 100644 (file)
@@ -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),
+      },
     }),
   }
 }
index a36d9451ea7047984373b19f1fe16b86701eb423..f172e191e1414c30bde43b3a0e4fba5edb70558f 100644 (file)
@@ -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'
index 868edfdf186a47e0a4f71be7afe28790c8f9f93b..85ae9581618f02e1b88345a9c346ebdfaf859ab2 100644 (file)
@@ -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
index 961d20fda53e9e7ed979ee820f4d1479fd9a92cd..0ea606448a185fc7d5c08094d40381cbe48b5d12 100644 (file)
@@ -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<string, unknown>
@@ -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<number, ConnectorStatus>()
       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<string, unknown>
       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<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 () => {
@@ -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<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
+      )
+    })
+  })
 })
index 447b9ddd9fe333e5efd092c4b81f62a3fc45471e..b8846175549502cf983ae9b634a497041e88021b 100644 (file)
@@ -41,3 +41,6 @@ pnpm-debug.log*
 
 # pnpm
 package-lock.json
+
+# TypeScript incremental compilation information
+*.tsbuildinfo
index d234ce4ee03a8f5926930fc89e122a7929a8e8ec..eb544a2cd8c24e156a90c4e5ba68997b4236e240 100644 (file)
@@ -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                    |
index 7a99186aa6af47b56817334c7bd6f909d988094e..6454551ddeb60cbb18d2120c57a00cb4735fb072 100644 (file)
@@ -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"
   }
 }
index 5551b917c1f5fb69ddcaf5451918669a71debde7..7b8e43fdd9af08849dcc0ac9345a2ccc7e366c31 100644 (file)
@@ -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()
+  })
+}
 </script>
 
 <style>
index a93909736c653e7926bb56a38c9b820895d990fb..c16401d206ea1b3549310364bdad99eeb17a80e5 100644 (file)
@@ -3,7 +3,12 @@
     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
@@ -94,10 +50,62 @@ const props = defineProps<{
   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>
index 8503d8f3b3de0c154a8f60286965c185e0f86a6d..d4f274ce69fabdf245fb677573ba0a344df67530 100644 (file)
@@ -1,7 +1,7 @@
 <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 ?? 'Ø' }}
@@ -14,7 +14,7 @@
     </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 } : {}),
+              },
             })
           }
         "
@@ -49,7 +53,7 @@
 <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'
@@ -60,7 +64,9 @@ const props = defineProps<{
   chargingStationId: string
   connector: ConnectorStatus
   connectorId: number
+  evseId?: number
   hashId: string
+  ocppVersion?: OCPPVersion
 }>()
 
 const $emit = defineEmits(['need-refresh'])
@@ -75,7 +81,10 @@ const stopTransaction = (): void => {
     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')
     })
index 3310c36a0dd9c6e33d25161bdadc1266a199f133..171dd62cd48ebccfd9e1aa1577e089a7d1202d08 100644 (file)
         </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>
@@ -131,29 +133,47 @@ import ToggleButton from '@/components/buttons/ToggleButton.vue'
 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)
index fbb97b97bb8a07972a864fd6fd8ecb1af58df08b..fa2c802f966d48e306deeac54fd71867c6b04823 100644 (file)
@@ -4,6 +4,10 @@ import {
   ApplicationProtocol,
   AuthenticationType,
   type ChargingStationOptions,
+  OCPP20IdTokenEnumType,
+  OCPP20TransactionEventEnumType,
+  type OCPP20TransactionEventRequest,
+  OCPPVersion,
   ProcedureName,
   type ProtocolResponse,
   type RequestPayload,
@@ -43,6 +47,10 @@ export class UIClient {
     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,
@@ -138,13 +146,30 @@ export class UIClient {
 
   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,
     })
   }
 
@@ -170,11 +195,26 @@ export class UIClient {
 
   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,
     })
   }
 
@@ -305,4 +345,14 @@ export class UIClient {
       }
     })
   }
+
+  private async transactionEvent (
+    hashId: string,
+    payload: OCPP20TransactionEventRequest
+  ): Promise<ResponsePayload> {
+    return this.sendRequest(ProcedureName.TRANSACTION_EVENT, {
+      hashIds: [hashId],
+      ...payload,
+    })
+  }
 }
index 357d177d56f78745f798cc3de62a438a942f47b4..70809450408c80e2443a28ad543feb9485819c29 100644 (file)
@@ -87,6 +87,23 @@ export enum OCPP16RequestCommand {
   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',
 }
@@ -104,6 +121,16 @@ export enum Voltage {
   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
@@ -123,16 +150,11 @@ export type BootNotificationResponse = OCPP16BootNotificationResponse
 
 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
@@ -226,6 +248,11 @@ export interface ConfigurationKey extends OCPPConfigurationKey {
   visible?: boolean
 }
 
+export interface ConnectorEntry extends JsonObject {
+  connector: ConnectorStatus
+  connectorId: number
+}
+
 export interface ConnectorStatus extends JsonObject {
   authorizeIdTag?: string
   availability: AvailabilityType
@@ -236,15 +263,38 @@ export interface ConnectorStatus extends JsonObject {
   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 = {
index 0cc5ae269da647da7a8df860a58b36e1cfa4ed07..e0968de6b6f3dd18f6d5c9668881983190689163 100644 (file)
@@ -1,3 +1,5 @@
-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
index f27b6e25379ab729e5b85a41b62a2bdeef407ccf..4539529ebecd3201364c8f7ed76aef5c611e16df 100644 (file)
@@ -15,10 +15,17 @@ export enum ProcedureName {
   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',
@@ -28,6 +35,7 @@ export enum ProcedureName {
   STOP_CHARGING_STATION = 'stopChargingStation',
   STOP_SIMULATOR = 'stopSimulator',
   STOP_TRANSACTION = 'stopTransaction',
+  TRANSACTION_EVENT = 'transactionEvent',
 }
 
 export enum Protocol {
index faa55133711f180e73ad8d4191eac2cf53a646a3..2eb33c9063e3119b5f7c41a7438f719cfc7d1c90 100644 (file)
@@ -1,10 +1,20 @@
 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,
index b0c7687e826e591fd8a0a88a497d18308a92eb02..c530223d139e38e10d815a8e880fc5c263522557 100644 (file)
@@ -67,7 +67,7 @@
         :class="simulatorButtonClass"
         :off="() => stopSimulator()"
         :on="() => startSimulator()"
-        :status="simulatorState?.started"
+        :status="simulatorStarted"
       >
         {{ simulatorButtonMessage }}
       </ToggleButton>
@@ -140,11 +140,13 @@ import {
 
 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})` : ''
     }`
@@ -183,9 +185,12 @@ const clearToggleButtons = (): void => {
 
 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()
diff --git a/ui/web/tests/unit/UIClient.test.ts b/ui/web/tests/unit/UIClient.test.ts
new file mode 100644 (file)
index 0000000..2f61ab8
--- /dev/null
@@ -0,0 +1,257 @@
+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,
+        })
+      })
+    })
+  })
+})