]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ui): add CLI client and shared UI common library (#1789)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 15 Apr 2026 16:59:22 +0000 (18:59 +0200)
committerGitHub <noreply@github.com>
Wed, 15 Apr 2026 16:59:22 +0000 (18:59 +0200)
* feat(ui): scaffold ui/common and ui/cli workspaces

- Add ui/common workspace with shared protocol types, SRPC WebSocket client,
  UUID utilities, config types, and Zod validation schemas (21 tests, 0 errors)
- Add ui/cli workspace scaffolding with all configuration files
- Register both workspaces in pnpm-workspace.yaml
- Update .prettierignore, release-please config and manifest
- Add build-common (library, single env) and build-cli (3x3 matrix) CI jobs
- Add formatting steps for new workspaces in autofix.yml
- Add sonar-project.properties for both workspaces

* feat(cli): implement CLI core infrastructure

- Commander.js entry point with 10 subcommand groups (simulator, station,
  template, connection, connector, atg, transaction, ocpp, performance,
  supervision)
- Config loading with lilconfig + Zod validation + merge precedence
  (defaults < config file < CLI flags)
- Output formatters: JSON (--json flag) and table (human-readable)
- WS client lifecycle: executeCommand() connects, sends, receives,
  disconnects; registerSignalHandlers() for SIGINT/SIGTERM
- Typed error classes: ConnectionError, AuthenticationError,
  TimeoutError, ServerError
- esbuild bundle with version injection and shebang
- 23 unit tests passing, zero lint warnings

* feat(cli): implement all 35 UI protocol command groups

All 35 ProcedureName procedures exposed as CLI subcommands:
- simulator: state, start, stop (3)
- station: list, start, stop, add, delete (5)
- template: list (1)
- connection: open, close (2)
- connector: lock, unlock (2)
- atg: start, stop (2)
- transaction: start, stop (2)
- ocpp: authorize, boot-notification, data-transfer, heartbeat,
  meter-values, status-notification, firmware-status-notification,
  diagnostics-status-notification, security-event-notification,
  sign-certificate, notify-report, notify-customer-information,
  log-status-notification, get-15118-ev-certificate,
  get-certificate-status, transaction-event (16)
- performance: stats (1)
- supervision: set-url (1)

Shared runAction() helper DRYs up all command action handlers.

* test(cli): add integration tests

- 8 integration tests covering --help, --version, subcommand help,
  connection error handling, JSON mode, and missing required options
- Separate test:integration script targeting tests/integration/
- Unit tests narrowed to tests/*.test.ts (no integration overlap)

* docs(cli): add README for ui/cli and ui/common

- ui/cli/README.md: installation, configuration, all command groups
  with examples, global options, exit codes, environment variables,
  available scripts
- ui/common/README.md: exported API reference (types, WebSocketClient,
  config validation, UUID utilities)

* [autofix.ci] apply automated fixes

* fix(cli): improve error handling for config and connection failures

- Catch config file ENOENT in loader.ts and throw clean error message
- Move loadConfig inside try/catch in runAction to prevent stack traces
- Use event.error in WebSocketClient onerror for better error propagation
- Separate connect errors from request errors in lifecycle.ts
- Include cause message in ConnectionError for descriptive output

* fix(cli): address PR review feedback

- Fix onerror stale after connect: replace with persistent error handler
  that fails all pending sendRequest promises immediately on WS error
- Fix dead code: call registerSignalHandlers() in cli.ts for SIGINT/SIGTERM
- Fix JSON error output: write to stdout (not stderr) in --json mode to
  match documented contract and enable scripting with 2>/dev/null
- Fix process.exit() in action.ts: use process.exitCode for proper async
  cleanup and testability
- Fix Map iteration: use snapshot+clear pattern in clearHandlers/failAllPending
- Fix empty array edge case in table formatter: check .length === 0
- Fix README: merge exit codes 1+2 into single row (Commander uses 1)
- Fix CI: add needs: build-common to build-cli job

* fix(cli): address second round of PR review feedback

- Fix ServerFailureError: WebSocketClient.handleMessage now rejects
  with a typed Error (carrying the ResponsePayload) instead of a raw
  object, preventing [object Object] in CLI output
- Fix table formatter: FAILURE responses now display hashIdsFailed/
  hashIdsSucceeded tables instead of early-returning with minimal info
- Fix auth schema: Zod refinement requires username+password when
  authentication enabled with protocol-basic-auth
- Fix AuthenticationConfig.type: use AuthenticationType enum instead
  of plain string for compile-time safety
- Fix signal handlers: use process.exitCode instead of process.exit()
  so finally blocks in executeCommand can run cleanup

* fix(cli): restore process.exit in signal handlers, fix type mismatch, remove dead code

- Signal handlers: restore process.exit() — setting only process.exitCode
  keeps the process alive since registering SIGINT listener removes Node's
  default termination behavior
- BroadcastChannelResponsePayload: align field name with server wire format
  (hashId: string | undefined, not id: string)
- Remove unused DEFAULT_TIMEOUT_MS export from defaults.ts

* refactor(ui-common): extract WS timeout to shared constant

Move UI_WEBSOCKET_REQUEST_TIMEOUT_MS from a private constant in
WebSocketClient.ts to ui/common/src/constants.ts as a single source
of truth, exported via barrel for consumers.

* refactor(cli): merge duplicate ui-common and commander imports

Consolidate split type/value imports from the same module into single
import statements using inline type syntax (`import { type X, Y }`).
Resolves SonarCloud 'imported multiple times' warning.

* refactor(cli): use Number.parseInt instead of parseInt

Resolves SonarCloud 'Prefer Number.parseInt over parseInt' warning.

* refactor(cli): remove unnecessary Command alias from commander import

Use import { Command } from 'commander' directly instead of aliasing
to Cmd. Single import serves both type and value usage.

* chore: reorder ui workspaces consistently (common, cli, web)

Apply dependency-first ordering across all config files:
pnpm-workspace.yaml, .prettierignore, release-please config/manifest,
ci.yml job order, and autofix.yml step order.

* refactor(ui-common): widen validateUUID to accept unknown

Align with ui/web pattern — move typeof string check into validateUUID
itself, removing redundant guard at call sites. Add tests for
non-string inputs (number, null, undefined, object, boolean).

* fix(cli): use Vercel CLI pattern for graceful signal shutdown

Replace AbortController with module-level activeClient/activeSpinner
refs + cleanupInProgress guard (Vercel CLI pattern). Signal handler
stops spinner, disconnects WS, then process.exit(130/143). Simpler,
battle-tested, correct for batch request-response CLI.

* chore: reorder linked-versions components (common, cli, web)

* style(ci): add blank line between all job definitions in ci.yml

* refactor(cli): rename parseIntList to parseCommaSeparatedInts

* fix(cli): validate connector IDs input and wrap config search errors

- parseCommaSeparatedInts now rejects NaN values with a clear error
  message instead of silently sending garbage to the server
- lilconfig search() path now wrapped in try/catch like the explicit
  config path, giving consistent error messages for malformed configs

* feat(cli): standalone build + XDG-only config + install script

- Bundle all dependencies into single 504KB dist/cli.js (no node_modules
  needed at runtime). Only ws native addons (bufferutil, utf-8-validate)
  are external — ws falls back to pure JS automatically.
- Replace lilconfig with direct XDG config file reading:
  ${XDG_CONFIG_HOME:-~/.config}/evse-cli/config.json
- Remove lilconfig dependency
- Add install.sh: builds CLI, copies to ~/.local/bin/evse-cli, creates
  default XDG config, warns if ~/.local/bin not in PATH
- Isolate ALL config tests from host env via XDG_CONFIG_HOME in
  beforeEach/afterEach + add XDG auto-discovery happy path test
- Guard against JSON array in config file
- Update README: standalone install instructions + XDG config location

* [autofix.ci] apply automated fixes

* fix(cli): address review feedback round 3

WebSocketClient:
- Validate responsePayload shape before casting (guard against [uuid, null])
- Reject connect() Promise if socket closes before onopen fires
- Add tests for both edge cases

CLI:
- Validate ws:/wss: URL scheme in parseServerUrl
- Output ServerFailureError.payload via formatter (show hashIdsFailed details)
- Extract shared parseInteger() validator — reject NaN with clear error
- Remove dead error types (AuthenticationError, ServerError, TimeoutError)
- Chain build in test:integration script
- Remove unreachable FAILURE branch in outputTable

Schema:
- Require password.length > 0 in auth refinement (reject empty string)

* ci: remove redundant lint:fix from autofix workflow

pnpm format already runs eslint --cache --fix, making the separate
pnpm lint:fix step redundant in all three ui workspaces.

* refactor(cli): remove unnecessary comment in outputTable

* fix(cli): don't override template defaults in station add, reject array config

- station add: only include autoStart, persistentConfiguration,
  ocppStrictCompliance, deleteConfiguration in payload when explicitly
  passed — lets server use template defaults otherwise
- config loader: reject uiServer array with clear error instead of
  silently spreading array keys into object

* refactor(cli): remove unused terminal-link dep and dead exports

- Remove terminal-link from dependencies (never imported)
- Remove unused exports: printSuccess, printWarning, printInfo (human.ts),
  outputTableList (table.ts)
- Remove corresponding test for printSuccess

* [autofix.ci] apply automated fixes

* fix(ui-common): reject malformed payloads, replace ReadyState with enum

- handleMessage: reject pending handler immediately when server sends
  response with matching UUID but missing status field, instead of
  silently dropping and waiting for 60s timeout
- Replace ReadyState type alias with WebSocketReadyState const enum
- Remove redundant ReadyState type (duplicated enum semantics)

* fix(cli): add connection timeout, remove dead defaultUIServerConfig

- Wrap client.connect() with Promise.race timeout to prevent infinite
  hang when server accepts TCP but never completes WS handshake
- Remove unused defaultUIServerConfig export from defaults.ts

* fix(cli): clear connection timeout timer on success

* refactor(ui-common): expose url getter, remove duplicate ConfigurationType

- WebSocketClient: make buildUrl() a public url getter so consumers
  use the canonical URL instead of reconstructing it
- lifecycle.ts: use client.url instead of building URL independently
- Remove ConfigurationType.ts: UIServerConfigurationSection is now
  a type alias for the Zod-inferred UIServerConfig (single source)

* fix(cli): display failure status instead of misleading Success

displayGenericPayload now checks payload.status before printing — a
failure response without hashIds shows the red status line instead of
a green checkmark.

* fix: align ui/web ResponsePayload with server, reject non-object config

- ui/web ResponsePayload: replace incorrect hashIds with
  hashIdsFailed/hashIdsSucceeded/responsesFailed matching server schema
- cli config loader: reject non-object uiServer values with clear error
  instead of silently falling back to defaults

* fix: reject non-object config files, merge duplicate import

- Config loader: throw on primitive JSON config values (42, "hello")
  instead of silently falling back to defaults
- Merge duplicate UIProtocol.ts import in ui/common/src/client/types.ts

* fix: suppress dangling connect rejection, remove dead Zod defaults

- Attach .catch() to connect() promise to prevent unhandled rejection
  when the timeout wins Promise.race and disconnect triggers onclose
- Remove .default() calls from Zod schema — the CLI loader always
  provides all fields via its canonical defaults map, making Zod
  defaults dead code paths

* fix(cli): preserve error cause in config loader for debuggability

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
65 files changed:
.github/release-please/config.json
.github/release-please/manifest.json
.github/workflows/autofix.yml
.github/workflows/ci.yml
.prettierignore
pnpm-lock.yaml
pnpm-workspace.yaml
ui/cli/.editorconfig [new file with mode: 0644]
ui/cli/.lintstagedrc.js [new file with mode: 0644]
ui/cli/.npmrc [new file with mode: 0644]
ui/cli/.prettierignore [new file with mode: 0644]
ui/cli/.prettierrc.json [new file with mode: 0644]
ui/cli/README.md [new file with mode: 0644]
ui/cli/install.sh [new file with mode: 0755]
ui/cli/package.json [new file with mode: 0644]
ui/cli/scripts/bundle.js [new file with mode: 0644]
ui/cli/sonar-project.properties [new file with mode: 0644]
ui/cli/src/cli.ts [new file with mode: 0644]
ui/cli/src/client/errors.ts [new file with mode: 0644]
ui/cli/src/client/lifecycle.ts [new file with mode: 0644]
ui/cli/src/commands/action.ts [new file with mode: 0644]
ui/cli/src/commands/atg.ts [new file with mode: 0644]
ui/cli/src/commands/connection.ts [new file with mode: 0644]
ui/cli/src/commands/connector.ts [new file with mode: 0644]
ui/cli/src/commands/ocpp.ts [new file with mode: 0644]
ui/cli/src/commands/performance.ts [new file with mode: 0644]
ui/cli/src/commands/simulator.ts [new file with mode: 0644]
ui/cli/src/commands/station.ts [new file with mode: 0644]
ui/cli/src/commands/supervision.ts [new file with mode: 0644]
ui/cli/src/commands/template.ts [new file with mode: 0644]
ui/cli/src/commands/transaction.ts [new file with mode: 0644]
ui/cli/src/config/defaults.ts [new file with mode: 0644]
ui/cli/src/config/loader.ts [new file with mode: 0644]
ui/cli/src/output/formatter.ts [new file with mode: 0644]
ui/cli/src/output/human.ts [new file with mode: 0644]
ui/cli/src/output/json.ts [new file with mode: 0644]
ui/cli/src/output/table.ts [new file with mode: 0644]
ui/cli/src/types.ts [new file with mode: 0644]
ui/cli/tests/config.test.ts [new file with mode: 0644]
ui/cli/tests/integration/cli.test.ts [new file with mode: 0644]
ui/cli/tests/lifecycle.test.ts [new file with mode: 0644]
ui/cli/tests/output.test.ts [new file with mode: 0644]
ui/cli/tsconfig.json [new file with mode: 0644]
ui/common/.editorconfig [new file with mode: 0644]
ui/common/.lintstagedrc.js [new file with mode: 0644]
ui/common/.npmrc [new file with mode: 0644]
ui/common/.prettierignore [new file with mode: 0644]
ui/common/.prettierrc.json [new file with mode: 0644]
ui/common/README.md [new file with mode: 0644]
ui/common/package.json [new file with mode: 0644]
ui/common/sonar-project.properties [new file with mode: 0644]
ui/common/src/client/WebSocketClient.ts [new file with mode: 0644]
ui/common/src/client/types.ts [new file with mode: 0644]
ui/common/src/config/schema.ts [new file with mode: 0644]
ui/common/src/constants.ts [new file with mode: 0644]
ui/common/src/index.ts [new file with mode: 0644]
ui/common/src/types/JsonType.ts [new file with mode: 0644]
ui/common/src/types/UIProtocol.ts [new file with mode: 0644]
ui/common/src/types/UUID.ts [new file with mode: 0644]
ui/common/src/utils/UUID.ts [new file with mode: 0644]
ui/common/tests/UUID.test.ts [new file with mode: 0644]
ui/common/tests/WebSocketClient.test.ts [new file with mode: 0644]
ui/common/tests/config.test.ts [new file with mode: 0644]
ui/common/tsconfig.json [new file with mode: 0644]
ui/web/src/types/UIProtocol.ts

index 73df0e2902df6e4b0f1cc1339ad8cffaf71edd28..fccd447d71a6af44e89507237c1d847f1e6d7452 100644 (file)
@@ -7,10 +7,18 @@
   "include-v-in-tag": true,
   "packages": {
     ".": {
-      "exclude-paths": ["ui/web", "tests/ocpp-server"],
+      "exclude-paths": ["ui/common", "ui/cli", "ui/web", "tests/ocpp-server"],
       "component": "simulator",
       "extra-files": ["sonar-project.properties"]
     },
+    "ui/common": {
+      "component": "ui-common",
+      "extra-files": ["sonar-project.properties"]
+    },
+    "ui/cli": {
+      "component": "cli",
+      "extra-files": ["sonar-project.properties"]
+    },
     "ui/web": {
       "component": "webui",
       "extra-files": ["sonar-project.properties"]
@@ -24,7 +32,7 @@
     {
       "type": "linked-versions",
       "groupName": "simulator-ui-ocpp-server",
-      "components": ["simulator", "webui", "ocpp-server"]
+      "components": ["simulator", "ui-common", "cli", "webui", "ocpp-server"]
     }
   ],
   "changelog-sections": [
     { "type": "refactor", "section": "✨ Polish", "hidden": false },
     { "type": "test", "section": "🧪 Tests", "hidden": false },
     { "type": "docs", "section": "📚 Documentation", "hidden": false },
-
     { "type": "build", "section": "🤖 Automation", "hidden": false },
     { "type": "ci", "section": "🤖 Automation", "hidden": true },
-
     { "type": "chore", "section": "🧹 Chores", "hidden": true }
   ]
 }
index 9e2531a990ee84e4f84509d250986d591ad84a0c..0454f48f8c51f2e2506543d25f3907f572b1e86e 100644 (file)
@@ -1,5 +1,7 @@
 {
   ".": "4.4.0",
+  "ui/common": "4.4.0",
+  "ui/cli": "4.4.0",
   "ui/web": "4.4.0",
   "tests/ocpp-server": "4.4.0"
 }
index 9981671fe8a78c574204915f9ce5c901c9f73906..37d183acf51affee903f0eb8f01db47b47efea69 100644 (file)
@@ -32,9 +32,13 @@ jobs:
 
       - run: pnpm format
 
+      - working-directory: ui/common
+        run: pnpm format
+
+      - working-directory: ui/cli
+        run: pnpm format
+
       - working-directory: ui/web
-        run: |
-          pnpm format
-          pnpm lint:fix
+        run: pnpm format
 
       - uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8
index 396fb09c5341d80adb6ecadfb1bf47cd38ae9be8..a5048d3008e0e43257b9b9408d1c530690b9f93f 100644 (file)
@@ -21,6 +21,7 @@ jobs:
           else
             echo "defined=false" >> $GITHUB_OUTPUT;
           fi
+
   build-ocpp-server:
     strategy:
       matrix:
@@ -54,6 +55,7 @@ jobs:
       - name: Test with coverage
         if: ${{ github.repository == 'sap/e-mobility-charging-stations-simulator' && matrix.os == 'ubuntu-latest' && matrix.python == '3.13' }}
         run: poetry run task test_coverage
+
   build-simulator:
     needs: [check-secrets]
     strategy:
@@ -106,6 +108,104 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+
+  build-common:
+    needs: [check-secrets]
+    name: Build UI common library with Node ${{ matrix.node }} on ${{ matrix.os }}
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ubuntu-latest]
+        node: ['24.x']
+    defaults:
+      run:
+        working-directory: ui/common
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+      - uses: pnpm/action-setup@v6
+      - name: Setup node ${{ matrix.node }}
+        uses: actions/setup-node@v6
+        with:
+          node-version: ${{ matrix.node }}
+          cache: 'pnpm'
+      - name: pnpm install
+        run: pnpm install --ignore-scripts --frozen-lockfile
+      - name: pnpm typecheck
+        run: pnpm typecheck
+      - name: pnpm lint
+        run: pnpm lint
+      - name: pnpm test
+        if: ${{ !(github.repository == 'sap/e-mobility-charging-stations-simulator' && matrix.os == 'ubuntu-latest' && matrix.node == '24.x') }}
+        run: pnpm test
+      - name: pnpm test:coverage
+        if: ${{ github.repository == 'sap/e-mobility-charging-stations-simulator' && matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
+        run: pnpm test:coverage
+      - name: SonarCloud Scan
+        if: ${{ needs.check-secrets.outputs.sonar-token-exists == 'true' && github.repository == 'sap/e-mobility-charging-stations-simulator' && matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
+        uses: sonarsource/sonarqube-scan-action@v7.1.0
+        with:
+          projectBaseDir: ui/common
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+
+  build-cli:
+    needs: [check-secrets, build-common]
+    strategy:
+      matrix:
+        os: [windows-latest, macos-latest, ubuntu-latest]
+        node: ['22.x', '24.x', 'latest']
+    name: Build CLI with Node ${{ matrix.node }} on ${{ matrix.os }}
+    runs-on: ${{ matrix.os }}
+    defaults:
+      run:
+        working-directory: ui/cli
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+      - name: Dependency Review
+        if: ${{ github.event_name == 'push' && matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
+        uses: actions/dependency-review-action@v4
+        with:
+          base-ref: ${{ github.ref_name }}
+          head-ref: ${{ github.sha }}
+      - name: Pull Request Dependency Review
+        if: ${{ github.event_name == 'pull_request' && matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
+        uses: actions/dependency-review-action@v4
+      - uses: pnpm/action-setup@v6
+      - name: Setup node ${{ matrix.node }}
+        uses: actions/setup-node@v6
+        with:
+          node-version: ${{ matrix.node }}
+          cache: 'pnpm'
+      - name: pnpm install
+        run: pnpm install --ignore-scripts --frozen-lockfile
+      - 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
+        if: ${{ !(github.repository == 'sap/e-mobility-charging-stations-simulator' && matrix.os == 'ubuntu-latest' && matrix.node == '24.x') }}
+        run: pnpm test
+      - name: pnpm test:coverage
+        if: ${{ github.repository == 'sap/e-mobility-charging-stations-simulator' && matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
+        run: pnpm test:coverage
+      - name: SonarCloud Scan
+        if: ${{ needs.check-secrets.outputs.sonar-token-exists == 'true' && github.repository == 'sap/e-mobility-charging-stations-simulator' && matrix.os == 'ubuntu-latest' && matrix.node == '24.x' }}
+        uses: sonarsource/sonarqube-scan-action@v7.1.0
+        with:
+          projectBaseDir: ui/cli
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+
   build-dashboard:
     needs: [check-secrets]
     strategy:
@@ -163,6 +263,7 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+
   build-simulator-docker-image:
     runs-on: ubuntu-latest
     name: Build simulator docker image
@@ -175,6 +276,7 @@ jobs:
         run: |
           cd docker
           make SUBMODULES_INIT=false
+
   build-dashboard-docker-image:
     runs-on: ubuntu-latest
     defaults:
index 75989c0acbfdafbb84173e36d747a685b506a017..aefb1a938bec8dd19c93f7d64c1d556dc76e7672 100644 (file)
@@ -2,6 +2,8 @@ coverage
 dist
 outputs
 .nyc_output
+ui/common
+ui/cli
 ui/web
 pnpm-lock.yaml
 package-lock.json
index a421ba1d649635d2ed15969c652c6343d0ee37cc..06cf8156cb6de8210f934291bc6b8ae3193a40c5 100644 (file)
@@ -275,6 +275,80 @@ importers:
         specifier: ^6.0.6
         version: 6.0.6
 
+  ui/cli:
+    dependencies:
+      chalk:
+        specifier: ^5.6.2
+        version: 5.6.2
+      cli-table3:
+        specifier: ^0.6.5
+        version: 0.6.5
+      commander:
+        specifier: ^14.0.0
+        version: 14.0.3
+      ora:
+        specifier: ^8.2.0
+        version: 8.2.0
+      ui-common:
+        specifier: workspace:*
+        version: link:../common
+      ws:
+        specifier: ^8.20.0
+        version: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
+    devDependencies:
+      '@types/node':
+        specifier: ^24.12.2
+        version: 24.12.2
+      '@types/ws':
+        specifier: ^8.18.1
+        version: 8.18.1
+      cross-env:
+        specifier: ^10.1.0
+        version: 10.1.0
+      esbuild:
+        specifier: ^0.28.0
+        version: 0.28.0
+      esbuild-plugin-clean:
+        specifier: ^1.0.1
+        version: 1.0.1(esbuild@0.28.0)
+      prettier:
+        specifier: ^3.8.2
+        version: 3.8.2
+      rimraf:
+        specifier: ^6.1.3
+        version: 6.1.3
+      tsx:
+        specifier: ^4.21.0
+        version: 4.21.0
+      typescript:
+        specifier: ~6.0.2
+        version: 6.0.2
+
+  ui/common:
+    dependencies:
+      zod:
+        specifier: ^4.3.6
+        version: 4.3.6
+    devDependencies:
+      '@types/node':
+        specifier: ^24.12.2
+        version: 24.12.2
+      cross-env:
+        specifier: ^10.1.0
+        version: 10.1.0
+      prettier:
+        specifier: ^3.8.2
+        version: 3.8.2
+      rimraf:
+        specifier: ^6.1.3
+        version: 6.1.3
+      tsx:
+        specifier: ^4.21.0
+        version: 4.21.0
+      typescript:
+        specifier: ~6.0.2
+        version: 6.0.2
+
   ui/web:
     dependencies:
       finalhandler:
@@ -4134,6 +4208,10 @@ packages:
     resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
     engines: {node: '>=8'}
 
+  is-interactive@2.0.0:
+    resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
+    engines: {node: '>=12'}
+
   is-map@2.0.3:
     resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
     engines: {node: '>= 0.4'}
@@ -4222,6 +4300,14 @@ packages:
     resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
     engines: {node: '>=10'}
 
+  is-unicode-supported@1.3.0:
+    resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==}
+    engines: {node: '>=12'}
+
+  is-unicode-supported@2.1.0:
+    resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
+    engines: {node: '>=18'}
+
   is-weakmap@2.0.2:
     resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
     engines: {node: '>= 0.4'}
@@ -4601,6 +4687,10 @@ packages:
     resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
     engines: {node: '>=10'}
 
+  log-symbols@6.0.0:
+    resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
+    engines: {node: '>=18'}
+
   log-update@6.1.0:
     resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
     engines: {node: '>=18'}
@@ -5139,6 +5229,10 @@ packages:
     resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
     engines: {node: '>=10'}
 
+  ora@8.2.0:
+    resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
+    engines: {node: '>=18'}
+
   os-browserify@0.3.0:
     resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==}
 
@@ -5889,6 +5983,10 @@ packages:
   std-env@4.0.0:
     resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
 
+  stdin-discarder@0.2.2:
+    resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
+    engines: {node: '>=18'}
+
   stop-iteration-iterator@1.1.0:
     resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
     engines: {node: '>= 0.4'}
@@ -11202,6 +11300,8 @@ snapshots:
 
   is-interactive@1.0.0: {}
 
+  is-interactive@2.0.0: {}
+
   is-map@2.0.3: {}
 
   is-negative-zero@2.0.3: {}
@@ -11267,6 +11367,10 @@ snapshots:
 
   is-unicode-supported@0.1.0: {}
 
+  is-unicode-supported@1.3.0: {}
+
+  is-unicode-supported@2.1.0: {}
+
   is-weakmap@2.0.2: {}
 
   is-weakref@1.1.1:
@@ -11612,6 +11716,11 @@ snapshots:
       chalk: 4.1.2
       is-unicode-supported: 0.1.0
 
+  log-symbols@6.0.0:
+    dependencies:
+      chalk: 5.6.2
+      is-unicode-supported: 1.3.0
+
   log-update@6.1.0:
     dependencies:
       ansi-escapes: 7.3.0
@@ -12166,6 +12275,18 @@ snapshots:
       strip-ansi: 6.0.1
       wcwidth: 1.0.1
 
+  ora@8.2.0:
+    dependencies:
+      chalk: 5.6.2
+      cli-cursor: 5.0.0
+      cli-spinners: 2.9.2
+      is-interactive: 2.0.0
+      is-unicode-supported: 2.1.0
+      log-symbols: 6.0.0
+      stdin-discarder: 0.2.2
+      string-width: 7.2.0
+      strip-ansi: 7.2.0
+
   os-browserify@0.3.0: {}
 
   os-name@4.0.1:
@@ -13036,6 +13157,8 @@ snapshots:
 
   std-env@4.0.0: {}
 
+  stdin-discarder@0.2.2: {}
+
   stop-iteration-iterator@1.1.0:
     dependencies:
       es-errors: 1.3.0
index e39d0ca66d765b4b60e8ab6c64019af23f90ce5a..3513759d4bb1a5225cec350f91084538fd7a7fbe 100644 (file)
@@ -1,5 +1,7 @@
 packages:
   - ./
+  - ./ui/common
+  - ./ui/cli
   - ./ui/web
 
 overrides:
diff --git a/ui/cli/.editorconfig b/ui/cli/.editorconfig
new file mode 100644 (file)
index 0000000..be65bb5
--- /dev/null
@@ -0,0 +1,21 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+end_of_line = lf
+max_line_length = 100
+
+[*.ts{,x}]
+quote_type = single
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
+
+[{Makefile,**.mk}]
+# Use tabs for indentation (Makefiles require tabs)
+indent_style = tab
diff --git a/ui/cli/.lintstagedrc.js b/ui/cli/.lintstagedrc.js
new file mode 100644 (file)
index 0000000..a13b0e2
--- /dev/null
@@ -0,0 +1,4 @@
+export default {
+  '*.{css,json,md,yml,yaml,html,js,jsx,cjs,mjs,ts,tsx,cts,mts}': 'prettier --cache --write',
+  '*.{js,jsx,cjs,mjs,ts,tsx,cts,mts}': 'eslint --cache --fix',
+}
diff --git a/ui/cli/.npmrc b/ui/cli/.npmrc
new file mode 100644 (file)
index 0000000..22144e5
--- /dev/null
@@ -0,0 +1,2 @@
+auto-install-peers=true
+legacy-peer-deps=true
diff --git a/ui/cli/.prettierignore b/ui/cli/.prettierignore
new file mode 100644 (file)
index 0000000..9f7f384
--- /dev/null
@@ -0,0 +1,3 @@
+coverage
+dist
+pnpm-lock.yaml
diff --git a/ui/cli/.prettierrc.json b/ui/cli/.prettierrc.json
new file mode 100644 (file)
index 0000000..cafa923
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "printWidth": 100,
+  "arrowParens": "avoid",
+  "singleQuote": true,
+  "semi": false,
+  "trailingComma": "es5"
+}
diff --git a/ui/cli/README.md b/ui/cli/README.md
new file mode 100644 (file)
index 0000000..5b9321d
--- /dev/null
@@ -0,0 +1,251 @@
+# CLI
+
+Command-line tool to manage the e-mobility charging stations simulator via its WebSocket UI service (SRPC protocol).
+
+## Prerequisites
+
+The simulator must have its UI server enabled. Add a `uiServer` section to the simulator configuration:
+
+```json
+{
+  "uiServer": {
+    "enabled": true,
+    "type": "ws",
+    "authentication": {
+      "enabled": true,
+      "type": "protocol-basic-auth",
+      "username": "admin",
+      "password": "admin"
+    }
+  }
+}
+```
+
+See the [simulator configuration](../../README.md#charging-stations-simulator-configuration).
+
+## Installation
+
+### Quick install
+
+```shell
+cd ui/cli
+./install.sh
+```
+
+This builds the CLI and installs it to `~/.local/bin/evse-cli`. Options:
+
+| Flag              | Description                                             |
+| ----------------- | ------------------------------------------------------- |
+| `--bin-dir <dir>` | Install to a custom directory (default: `~/.local/bin`) |
+| `--no-build`      | Skip the build step (use existing `dist/cli.js`)        |
+
+Ensure `~/.local/bin` is in your `$PATH`:
+
+```shell
+export PATH="$HOME/.local/bin:$PATH"
+```
+
+### Manual build
+
+```shell
+pnpm install
+pnpm --filter cli build
+node ui/cli/dist/cli.js --help
+```
+
+## Configuration
+
+The CLI reads its configuration from the XDG config directory:
+
+```
+${XDG_CONFIG_HOME:-$HOME/.config}/evse-cli/config.json
+```
+
+The install script creates a default config file. To override, edit `~/.config/evse-cli/config.json`:
+
+```json
+{
+  "uiServer": {
+    "host": "localhost",
+    "port": 8080,
+    "protocol": "ui",
+    "version": "0.0.1",
+    "secure": false,
+    "authentication": {
+      "enabled": true,
+      "type": "protocol-basic-auth",
+      "username": "admin",
+      "password": "admin"
+    }
+  }
+}
+```
+
+### Configuration precedence
+
+Defaults < config file < `--config <path>` < `--url <url>` (highest priority).
+
+Use `--config <path>` to load a specific config file instead of the XDG default.
+
+| Option     | Default     |
+| ---------- | ----------- |
+| `host`     | `localhost` |
+| `port`     | `8080`      |
+| `protocol` | `ui`        |
+| `version`  | `0.0.1`     |
+| `secure`   | `false`     |
+
+## Usage
+
+```shell
+node dist/cli.js [global-options] <command> [subcommand] [options]
+```
+
+### Global Options
+
+| Option                | Description                                       |
+| --------------------- | ------------------------------------------------- |
+| `-V, --version`       | Print version                                     |
+| `-C, --config <path>` | Path to configuration file                        |
+| `--json`              | Machine-readable JSON output on stdout            |
+| `--url <url>`         | WebSocket URL (overrides config host/port/secure) |
+| `-h, --help`          | Show help                                         |
+
+### Commands
+
+#### simulator
+
+```shell
+node dist/cli.js simulator state   # Get simulator state and statistics
+node dist/cli.js simulator start   # Start the simulator
+node dist/cli.js simulator stop    # Stop the simulator
+```
+
+#### station
+
+```shell
+node dist/cli.js station list                          # List all charging stations
+node dist/cli.js station start [hashId...]             # Start station(s)
+node dist/cli.js station stop [hashId...]              # Stop station(s)
+node dist/cli.js station add -t <template> -n <count>  # Add stations from template
+node dist/cli.js station delete [hashId...]            # Delete station(s)
+```
+
+**`station add` options:**
+
+| Option                    | Required | Description               |
+| ------------------------- | -------- | ------------------------- |
+| `-t, --template <name>`   | Yes      | Station template name     |
+| `-n, --count <n>`         | Yes      | Number of stations to add |
+| `--supervision-url <url>` | No       | Override supervision URL  |
+| `--auto-start`            | No       | Auto-start added stations |
+
+#### template
+
+```shell
+node dist/cli.js template list     # List available station templates
+```
+
+#### connection
+
+```shell
+node dist/cli.js connection open [hashId...]   # Open WebSocket connection
+node dist/cli.js connection close [hashId...]  # Close WebSocket connection
+```
+
+#### connector
+
+```shell
+node dist/cli.js connector lock --connector-id <id> [hashId...]   # Lock connector
+node dist/cli.js connector unlock --connector-id <id> [hashId...]  # Unlock connector
+```
+
+#### atg
+
+```shell
+node dist/cli.js atg start [hashId...] [--connector-ids <ids...>]  # Start ATG
+node dist/cli.js atg stop [hashId...]  [--connector-ids <ids...>]  # Stop ATG
+```
+
+#### transaction
+
+```shell
+node dist/cli.js transaction start --connector-id <id> --id-tag <tag> [hashId...]
+node dist/cli.js transaction stop --transaction-id <id> [hashId...]
+```
+
+#### ocpp
+
+Send OCPP commands directly to charging stations:
+
+```shell
+node dist/cli.js ocpp heartbeat [hashId...]
+node dist/cli.js ocpp authorize --id-tag <tag> [hashId...]
+node dist/cli.js ocpp boot-notification [hashId...]
+```
+
+Available OCPP commands: `authorize`, `boot-notification`, `data-transfer`, `diagnostics-status-notification`, `firmware-status-notification`, `get-15118-ev-certificate`, `get-certificate-status`, `heartbeat`, `log-status-notification`, `meter-values`, `notify-customer-information`, `notify-report`, `security-event-notification`, `sign-certificate`, `status-notification`, `transaction-event`.
+
+#### performance
+
+```shell
+node dist/cli.js performance stats  # Get performance statistics
+```
+
+#### supervision
+
+```shell
+node dist/cli.js supervision set-url --url <url> [hashId...]  # Set supervision URL
+```
+
+### JSON Output Mode
+
+Use `--json` for machine-readable output on stdout:
+
+```shell
+node dist/cli.js --json simulator state
+# {"status":"success","state":{...}}
+```
+
+Errors are written to stdout as JSON in `--json` mode.
+
+### Using hashIds
+
+Most station commands accept optional `[hashId...]` variadic arguments. Omitting them applies the command to all stations:
+
+```shell
+# All stations:
+node dist/cli.js station start
+
+# Specific stations:
+node dist/cli.js station start abc123 def456
+```
+
+## Exit Codes
+
+| Code  | Meaning                                                    |
+| ----- | ---------------------------------------------------------- |
+| `0`   | Success                                                    |
+| `1`   | Error (connection, server, authentication, or usage error) |
+| `130` | Interrupted (SIGINT / Ctrl+C)                              |
+| `143` | Terminated (SIGTERM)                                       |
+
+## Environment Variables
+
+| Variable   | Description                                                      |
+| ---------- | ---------------------------------------------------------------- |
+| `NO_COLOR` | Disable color output (see [no-color.org](https://no-color.org/)) |
+
+## Available Scripts
+
+| Script                  | Description                                |
+| ----------------------- | ------------------------------------------ |
+| `pnpm build`            | Build the CLI to `dist/`                   |
+| `pnpm start`            | Run the built CLI                          |
+| `pnpm typecheck`        | Type-check without building                |
+| `pnpm lint`             | Run ESLint                                 |
+| `pnpm lint:fix`         | Run ESLint with auto-fix                   |
+| `pnpm format`           | Run Prettier and ESLint auto-fix           |
+| `pnpm test`             | Run unit tests                             |
+| `pnpm test:coverage`    | Run unit tests with coverage               |
+| `pnpm test:integration` | Run integration tests (requires built CLI) |
diff --git a/ui/cli/install.sh b/ui/cli/install.sh
new file mode 100755 (executable)
index 0000000..12386e9
--- /dev/null
@@ -0,0 +1,78 @@
+#!/usr/bin/env sh
+# install.sh — build and install evse-cli to ~/.local/bin
+set -eu
+
+BINARY_NAME="evse-cli"
+BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+BUILT_FILE="$SCRIPT_DIR/dist/cli.js"
+SKIP_BUILD=0
+
+while [ "$#" -gt 0 ]; do
+  case "$1" in
+    -b|--bin-dir)
+      [ "$#" -ge 2 ] || { printf 'ERROR: --bin-dir requires a value\n' >&2; exit 1; }
+      BIN_DIR="$2"; shift 2 ;;
+    --no-build)    SKIP_BUILD=1; shift ;;
+    -h|--help)
+      printf 'Usage: %s [--bin-dir <dir>] [--no-build]\n' "$0"
+      printf '  --bin-dir <dir>  Install directory (default: %s)\n' "$BIN_DIR"
+      printf '  --no-build       Skip build step\n'
+      exit 0 ;;
+    *) printf 'Unknown option: %s\n' "$1" >&2; exit 1 ;;
+  esac
+done
+
+info()  { printf '\033[1;32m>\033[0m %s\n' "$*"; }
+warn()  { printf '\033[1;33m!\033[0m %s\n' "$*"; }
+error() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
+
+command -v node >/dev/null 2>&1 || error "node is not installed (required: >=22)"
+NODE_MAJOR=$(node -e 'process.stdout.write(process.versions.node.split(".")[0])')
+[ "$NODE_MAJOR" -ge 22 ] || error "Node.js >=22 required, found $(node --version)"
+
+if [ "$SKIP_BUILD" -eq 0 ]; then
+  command -v pnpm >/dev/null 2>&1 || error "pnpm is not installed"
+  info "Installing dependencies..."
+  (cd "$SCRIPT_DIR" && pnpm install --frozen-lockfile)
+  info "Building evse-cli..."
+  (cd "$SCRIPT_DIR" && pnpm build)
+fi
+
+[ -f "$BUILT_FILE" ] || error "Built file not found: $BUILT_FILE"
+
+mkdir -p "$BIN_DIR"
+cp "$BUILT_FILE" "$BIN_DIR/$BINARY_NAME"
+chmod +x "$BIN_DIR/$BINARY_NAME"
+info "Installed $BINARY_NAME → $BIN_DIR/$BINARY_NAME"
+
+XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
+CONFIG_DIR="$XDG_CONFIG_HOME/evse-cli"
+CONFIG_FILE="$CONFIG_DIR/config.json"
+
+if [ ! -f "$CONFIG_FILE" ]; then
+  mkdir -p "$CONFIG_DIR"
+  cat > "$CONFIG_FILE" << 'CONF'
+{
+  "uiServer": {
+    "host": "localhost",
+    "port": 8080,
+    "protocol": "ui",
+    "version": "0.0.1",
+    "secure": false
+  }
+}
+CONF
+  info "Created default config → $CONFIG_FILE"
+fi
+
+case ":$PATH:" in
+  *":$BIN_DIR:"*) ;;
+  *)
+    warn "$BIN_DIR is not in your \$PATH"
+    warn "Add to your shell profile (~/.bashrc, ~/.zshrc, ~/.profile):"
+    printf '\n    export PATH="%s:$PATH"\n\n' "$BIN_DIR"
+    ;;
+esac
+
+info "Done! Run: $BINARY_NAME --help"
diff --git a/ui/cli/package.json b/ui/cli/package.json
new file mode 100644 (file)
index 0000000..65b1ede
--- /dev/null
@@ -0,0 +1,50 @@
+{
+  "$schema": "https://json.schemastore.org/package",
+  "name": "cli",
+  "version": "4.4.0",
+  "engines": {
+    "node": ">=22.0.0",
+    "pnpm": ">=10.9.0"
+  },
+  "volta": {
+    "node": "24.14.1",
+    "pnpm": "10.33.0"
+  },
+  "packageManager": "pnpm@10.33.0",
+  "type": "module",
+  "bin": {
+    "evse-cli": "./dist/cli.js"
+  },
+  "scripts": {
+    "build": "node scripts/bundle.js",
+    "start": "node dist/cli.js",
+    "clean:dist": "pnpm exec rimraf dist",
+    "clean:node_modules": "pnpm exec rimraf node_modules",
+    "lint": "cross-env TIMING=1 eslint --cache .",
+    "lint:fix": "cross-env TIMING=1 eslint --cache --fix .",
+    "format": "prettier --cache --write .; eslint --cache --fix .",
+    "test": "cross-env NODE_ENV=test node --import tsx --test --test-force-exit 'tests/*.test.ts'",
+    "test:integration": "pnpm build && cross-env NODE_ENV=test node --import tsx --test --test-force-exit 'tests/integration/**/*.test.ts'",
+    "test:coverage": "mkdir -p coverage && cross-env NODE_ENV=test node --import tsx --test --test-force-exit --experimental-test-coverage --test-coverage-include='src/**/*.ts' --test-reporter=lcov --test-reporter-destination=coverage/lcov.info 'tests/*.test.ts'",
+    "typecheck": "tsc --noEmit --skipLibCheck"
+  },
+  "dependencies": {
+    "chalk": "^5.6.2",
+    "cli-table3": "^0.6.5",
+    "commander": "^14.0.0",
+    "ora": "^8.2.0",
+    "ui-common": "workspace:*",
+    "ws": "^8.20.0"
+  },
+  "devDependencies": {
+    "@types/node": "^24.12.2",
+    "@types/ws": "^8.18.1",
+    "cross-env": "^10.1.0",
+    "esbuild": "^0.28.0",
+    "esbuild-plugin-clean": "^1.0.1",
+    "prettier": "^3.8.2",
+    "rimraf": "^6.1.3",
+    "tsx": "^4.21.0",
+    "typescript": "~6.0.2"
+  }
+}
diff --git a/ui/cli/scripts/bundle.js b/ui/cli/scripts/bundle.js
new file mode 100644 (file)
index 0000000..dd81b5a
--- /dev/null
@@ -0,0 +1,32 @@
+/* eslint-disable n/no-unpublished-import */
+
+import { build } from 'esbuild'
+import { clean } from 'esbuild-plugin-clean'
+import { readFileSync } from 'node:fs'
+import { env } from 'node:process'
+
+const pkg = JSON.parse(readFileSync('./package.json', 'utf8'))
+
+const isDevelopmentBuild = env.BUILD === 'development'
+
+await build({
+  banner: {
+    js: "#!/usr/bin/env node\nimport { createRequire } from 'node:module'; const require = createRequire(import.meta.url);",
+  },
+  bundle: true,
+  define: {
+    __CLI_VERSION__: JSON.stringify(pkg.version),
+    'process.env.WS_NO_BUFFER_UTIL': JSON.stringify('1'),
+    'process.env.WS_NO_UTF_8_VALIDATE': JSON.stringify('1'),
+  },
+  entryPoints: ['src/cli.ts'],
+  external: ['bufferutil', 'utf-8-validate'],
+  format: 'esm',
+  minify: !isDevelopmentBuild,
+  outfile: 'dist/cli.js',
+  platform: 'node',
+  plugins: [clean({ patterns: ['dist'] })],
+  sourcemap: isDevelopmentBuild,
+  target: 'node22',
+  treeShaking: true,
+})
diff --git a/ui/cli/sonar-project.properties b/ui/cli/sonar-project.properties
new file mode 100644 (file)
index 0000000..bcc1cc8
--- /dev/null
@@ -0,0 +1,17 @@
+sonar.projectKey=e-mobility-charging-stations-simulator-cli
+sonar.organization=sap-1
+
+# This is the name and version displayed in the SonarCloud UI.
+sonar.projectName=e-mobility-charging-stations-simulator-cli
+# x-release-please-start-version
+sonar.projectVersion=4.4.0
+# x-release-please-end
+
+# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
+sonar.sources=src
+sonar.tests=tests
+
+sonar.typescript.lcov.reportPaths=coverage/lcov.info
+
+# Encoding of the source code. Default is default system encoding
+#sonar.sourceEncoding=UTF-8
diff --git a/ui/cli/src/cli.ts b/ui/cli/src/cli.ts
new file mode 100644 (file)
index 0000000..355a271
--- /dev/null
@@ -0,0 +1,40 @@
+import { Command } from 'commander'
+import { argv } from 'node:process'
+
+import { registerSignalHandlers } from './client/lifecycle.js'
+import { createAtgCommands } from './commands/atg.js'
+import { createConnectionCommands } from './commands/connection.js'
+import { createConnectorCommands } from './commands/connector.js'
+import { createOcppCommands } from './commands/ocpp.js'
+import { createPerformanceCommands } from './commands/performance.js'
+import { createSimulatorCommands } from './commands/simulator.js'
+import { createStationCommands } from './commands/station.js'
+import { createSupervisionCommands } from './commands/supervision.js'
+import { createTemplateCommands } from './commands/template.js'
+import { createTransactionCommands } from './commands/transaction.js'
+
+declare const __CLI_VERSION__: string
+
+const program = new Command()
+
+program
+  .name('evse-cli')
+  .description('CLI to manage the e-mobility charging stations simulator via WebSocket UI service')
+  .version(__CLI_VERSION__, '-V, --version', 'output the version number')
+  .option('-C, --config <path>', 'path to configuration file')
+  .option('--json', 'output results as JSON (machine-readable)', false)
+  .option('--url <url>', 'simulator UI server WebSocket URL (overrides config)')
+
+program.addCommand(createSimulatorCommands(program))
+program.addCommand(createStationCommands(program))
+program.addCommand(createTemplateCommands(program))
+program.addCommand(createConnectionCommands(program))
+program.addCommand(createConnectorCommands(program))
+program.addCommand(createAtgCommands(program))
+program.addCommand(createTransactionCommands(program))
+program.addCommand(createOcppCommands(program))
+program.addCommand(createPerformanceCommands(program))
+program.addCommand(createSupervisionCommands(program))
+
+registerSignalHandlers()
+await program.parseAsync(argv)
diff --git a/ui/cli/src/client/errors.ts b/ui/cli/src/client/errors.ts
new file mode 100644 (file)
index 0000000..80b1fbe
--- /dev/null
@@ -0,0 +1,13 @@
+export class ConnectionError extends Error {
+  public readonly url: string
+
+  public constructor (url: string, cause?: unknown) {
+    const causeMsg = cause instanceof Error && cause.message.length > 0 ? `: ${cause.message}` : ''
+    super(`Failed to connect to ${url}${causeMsg}`)
+    this.name = 'ConnectionError'
+    this.url = url
+    if (cause != null) {
+      this.cause = cause
+    }
+  }
+}
diff --git a/ui/cli/src/client/lifecycle.ts b/ui/cli/src/client/lifecycle.ts
new file mode 100644 (file)
index 0000000..c7a7b24
--- /dev/null
@@ -0,0 +1,106 @@
+import process from 'node:process'
+import ora from 'ora'
+import {
+  type ProcedureName,
+  type RequestPayload,
+  type ResponsePayload,
+  UI_WEBSOCKET_REQUEST_TIMEOUT_MS,
+  type UIServerConfig,
+  WebSocketClient,
+  type WebSocketFactory,
+  type WebSocketLike,
+} from 'ui-common'
+import { WebSocket as WsWebSocket } from 'ws'
+
+import type { Formatter } from '../output/formatter.js'
+
+import { ConnectionError } from './errors.js'
+
+const createWsFactory = (): WebSocketFactory => {
+  return (url: string, protocols: string | string[]): WebSocketLike => {
+    const ws = new WsWebSocket(url, protocols)
+    return ws as unknown as WebSocketLike
+  }
+}
+
+let activeClient: undefined | WebSocketClient
+let activeSpinner: ReturnType<typeof ora> | undefined
+let cleanupInProgress = false
+
+export interface ExecuteOptions {
+  config: UIServerConfig
+  formatter: Formatter
+  payload: RequestPayload
+  procedureName: ProcedureName
+  timeoutMs?: number
+}
+
+export const executeCommand = async (options: ExecuteOptions): Promise<void> => {
+  const { config, formatter, payload, procedureName, timeoutMs } = options
+
+  const factory = createWsFactory()
+  const client = new WebSocketClient(factory, config, timeoutMs)
+  const { url } = client
+
+  const isInteractive = process.stderr.isTTY
+  const spinner = isInteractive
+    ? ora({ stream: process.stderr }).start(`Connecting to ${url}`)
+    : null
+
+  activeSpinner = spinner ?? undefined
+  activeClient = client
+
+  let connectTimeoutId: ReturnType<typeof setTimeout> | undefined
+  try {
+    const connectPromise = client.connect()
+    connectPromise.catch(() => undefined)
+    await Promise.race([
+      connectPromise,
+      new Promise<never>((_resolve, reject) => {
+        connectTimeoutId = setTimeout(() => {
+          reject(new Error(`Connection to ${url} timed out`))
+        }, timeoutMs ?? UI_WEBSOCKET_REQUEST_TIMEOUT_MS)
+      }),
+    ])
+  } catch (error: unknown) {
+    spinner?.fail()
+    client.disconnect()
+    throw new ConnectionError(url, error)
+  } finally {
+    clearTimeout(connectTimeoutId)
+  }
+
+  try {
+    if (spinner != null) {
+      spinner.text = `Sending ${procedureName}...`
+    }
+    const response: ResponsePayload = await client.sendRequest(procedureName, payload)
+    spinner?.stop()
+    formatter.output(response)
+  } catch (error: unknown) {
+    spinner?.fail()
+    throw error
+  } finally {
+    activeClient = undefined
+    activeSpinner = undefined
+    client.disconnect()
+  }
+}
+
+export const registerSignalHandlers = (): void => {
+  const cleanup = (code: number): void => {
+    if (cleanupInProgress) return
+    cleanupInProgress = true
+    activeSpinner?.stop()
+    activeClient?.disconnect()
+
+    process.exit(code)
+  }
+
+  process.on('SIGINT', () => {
+    cleanup(130)
+  })
+  process.on('SIGTERM', () => {
+    cleanup(143)
+  })
+}
diff --git a/ui/cli/src/commands/action.ts b/ui/cli/src/commands/action.ts
new file mode 100644 (file)
index 0000000..17c1ac6
--- /dev/null
@@ -0,0 +1,39 @@
+import type { Command } from 'commander'
+
+import process from 'node:process'
+import { type ProcedureName, type RequestPayload, ServerFailureError } from 'ui-common'
+
+import type { GlobalOptions } from '../types.js'
+
+import { executeCommand } from '../client/lifecycle.js'
+import { loadConfig } from '../config/loader.js'
+import { createFormatter } from '../output/formatter.js'
+
+export const parseInteger = (value: string): number => {
+  const n = Number.parseInt(value, 10)
+  if (Number.isNaN(n)) {
+    throw new Error(`Expected integer, got '${value}'`)
+  }
+  return n
+}
+
+export const runAction = async (
+  program: Command,
+  procedureName: ProcedureName,
+  payload: RequestPayload
+): Promise<void> => {
+  const rootOpts = program.opts<GlobalOptions>()
+  const formatter = createFormatter(rootOpts.json)
+  try {
+    const config = await loadConfig({ configPath: rootOpts.config, url: rootOpts.url })
+    await executeCommand({ config, formatter, payload, procedureName })
+    process.exitCode = 0
+  } catch (error: unknown) {
+    if (error instanceof ServerFailureError) {
+      formatter.output(error.payload)
+    } else {
+      formatter.error(error)
+    }
+    process.exitCode = 1
+  }
+}
diff --git a/ui/cli/src/commands/atg.ts b/ui/cli/src/commands/atg.ts
new file mode 100644 (file)
index 0000000..290d182
--- /dev/null
@@ -0,0 +1,42 @@
+import { Command } from 'commander'
+import { ProcedureName, type RequestPayload } from 'ui-common'
+
+import { runAction } from './action.js'
+
+const parseCommaSeparatedInts = (value: string): number[] => {
+  const parsed = value.split(',').map(s => Number.parseInt(s.trim(), 10))
+  if (parsed.some(n => Number.isNaN(n))) {
+    throw new Error(`Invalid connector IDs: '${value}' (expected comma-separated integers)`)
+  }
+  return parsed
+}
+
+export const createAtgCommands = (program: Command): Command => {
+  const cmd = new Command('atg').description('Automatic Transaction Generator management')
+
+  cmd
+    .command('start [hashIds...]')
+    .description('Start ATG on station(s)')
+    .option('--connector-ids <ids>', 'comma-separated connector IDs', parseCommaSeparatedInts)
+    .action(async (hashIds: string[], options: { connectorIds?: number[] }) => {
+      const payload: RequestPayload = {
+        ...(options.connectorIds != null && { connectorIds: options.connectorIds }),
+        ...(hashIds.length > 0 && { hashIds }),
+      }
+      await runAction(program, ProcedureName.START_AUTOMATIC_TRANSACTION_GENERATOR, payload)
+    })
+
+  cmd
+    .command('stop [hashIds...]')
+    .description('Stop ATG on station(s)')
+    .option('--connector-ids <ids>', 'comma-separated connector IDs', parseCommaSeparatedInts)
+    .action(async (hashIds: string[], options: { connectorIds?: number[] }) => {
+      const payload: RequestPayload = {
+        ...(options.connectorIds != null && { connectorIds: options.connectorIds }),
+        ...(hashIds.length > 0 && { hashIds }),
+      }
+      await runAction(program, ProcedureName.STOP_AUTOMATIC_TRANSACTION_GENERATOR, payload)
+    })
+
+  return cmd
+}
diff --git a/ui/cli/src/commands/connection.ts b/ui/cli/src/commands/connection.ts
new file mode 100644 (file)
index 0000000..e201c05
--- /dev/null
@@ -0,0 +1,26 @@
+import { Command } from 'commander'
+import { ProcedureName, type RequestPayload } from 'ui-common'
+
+import { runAction } from './action.js'
+
+export const createConnectionCommands = (program: Command): Command => {
+  const cmd = new Command('connection').description('WebSocket connection management')
+
+  cmd
+    .command('open [hashIds...]')
+    .description('Open WebSocket connection')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.OPEN_CONNECTION, payload)
+    })
+
+  cmd
+    .command('close [hashIds...]')
+    .description('Close WebSocket connection')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.CLOSE_CONNECTION, payload)
+    })
+
+  return cmd
+}
diff --git a/ui/cli/src/commands/connector.ts b/ui/cli/src/commands/connector.ts
new file mode 100644 (file)
index 0000000..902277f
--- /dev/null
@@ -0,0 +1,34 @@
+import { Command } from 'commander'
+import { ProcedureName, type RequestPayload } from 'ui-common'
+
+import { parseInteger, runAction } from './action.js'
+
+export const createConnectorCommands = (program: Command): Command => {
+  const cmd = new Command('connector').description('Connector management')
+
+  cmd
+    .command('lock [hashIds...]')
+    .description('Lock a connector')
+    .requiredOption('--connector-id <id>', 'connector ID', parseInteger)
+    .action(async (hashIds: string[], options: { connectorId: number }) => {
+      const payload: RequestPayload = {
+        connectorId: options.connectorId,
+        ...(hashIds.length > 0 && { hashIds }),
+      }
+      await runAction(program, ProcedureName.LOCK_CONNECTOR, payload)
+    })
+
+  cmd
+    .command('unlock [hashIds...]')
+    .description('Unlock a connector')
+    .requiredOption('--connector-id <id>', 'connector ID', parseInteger)
+    .action(async (hashIds: string[], options: { connectorId: number }) => {
+      const payload: RequestPayload = {
+        connectorId: options.connectorId,
+        ...(hashIds.length > 0 && { hashIds }),
+      }
+      await runAction(program, ProcedureName.UNLOCK_CONNECTOR, payload)
+    })
+
+  return cmd
+}
diff --git a/ui/cli/src/commands/ocpp.ts b/ui/cli/src/commands/ocpp.ts
new file mode 100644 (file)
index 0000000..3c4e226
--- /dev/null
@@ -0,0 +1,172 @@
+import { Command } from 'commander'
+import { ProcedureName, type RequestPayload } from 'ui-common'
+
+import { parseInteger, runAction } from './action.js'
+
+export const createOcppCommands = (program: Command): Command => {
+  const cmd = new Command('ocpp').description('OCPP protocol commands')
+
+  cmd
+    .command('authorize [hashIds...]')
+    .description('Send OCPP Authorize')
+    .requiredOption('--id-tag <tag>', 'RFID tag for authorization')
+    .action(async (hashIds: string[], options: { idTag: string }) => {
+      const payload: RequestPayload = {
+        idTag: options.idTag,
+        ...(hashIds.length > 0 && { hashIds }),
+      }
+      await runAction(program, ProcedureName.AUTHORIZE, payload)
+    })
+
+  cmd
+    .command('boot-notification [hashIds...]')
+    .description('Send OCPP BootNotification')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.BOOT_NOTIFICATION, payload)
+    })
+
+  cmd
+    .command('data-transfer [hashIds...]')
+    .description('Send OCPP DataTransfer')
+    .option('--vendor-id <id>', 'vendor identifier')
+    .option('--message-id <id>', 'message identifier')
+    .option('--data <json>', 'data payload (JSON string)')
+    .action(
+      async (
+        hashIds: string[],
+        options: { data?: string; messageId?: string; vendorId?: string }
+      ) => {
+        const payload: RequestPayload = {
+          ...(options.vendorId != null && { vendorId: options.vendorId }),
+          ...(options.messageId != null && { messageId: options.messageId }),
+          ...(options.data != null && { data: options.data }),
+          ...(hashIds.length > 0 && { hashIds }),
+        }
+        await runAction(program, ProcedureName.DATA_TRANSFER, payload)
+      }
+    )
+
+  cmd
+    .command('diagnostics-status-notification [hashIds...]')
+    .description('Send OCPP DiagnosticsStatusNotification')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.DIAGNOSTICS_STATUS_NOTIFICATION, payload)
+    })
+
+  cmd
+    .command('firmware-status-notification [hashIds...]')
+    .description('Send OCPP FirmwareStatusNotification')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.FIRMWARE_STATUS_NOTIFICATION, payload)
+    })
+
+  cmd
+    .command('get-15118-ev-certificate [hashIds...]')
+    .description('Send OCPP Get15118EVCertificate')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.GET_15118_EV_CERTIFICATE, payload)
+    })
+
+  cmd
+    .command('get-certificate-status [hashIds...]')
+    .description('Send OCPP GetCertificateStatus')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.GET_CERTIFICATE_STATUS, payload)
+    })
+
+  cmd
+    .command('heartbeat [hashIds...]')
+    .description('Send OCPP Heartbeat')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.HEARTBEAT, payload)
+    })
+
+  cmd
+    .command('log-status-notification [hashIds...]')
+    .description('Send OCPP LogStatusNotification')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.LOG_STATUS_NOTIFICATION, payload)
+    })
+
+  cmd
+    .command('meter-values [hashIds...]')
+    .description('Send OCPP MeterValues')
+    .requiredOption('--connector-id <id>', 'connector ID', parseInteger)
+    .action(async (hashIds: string[], options: { connectorId: number }) => {
+      const payload: RequestPayload = {
+        connectorId: options.connectorId,
+        ...(hashIds.length > 0 && { hashIds }),
+      }
+      await runAction(program, ProcedureName.METER_VALUES, payload)
+    })
+
+  cmd
+    .command('notify-customer-information [hashIds...]')
+    .description('Send OCPP NotifyCustomerInformation')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.NOTIFY_CUSTOMER_INFORMATION, payload)
+    })
+
+  cmd
+    .command('notify-report [hashIds...]')
+    .description('Send OCPP NotifyReport')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.NOTIFY_REPORT, payload)
+    })
+
+  cmd
+    .command('security-event-notification [hashIds...]')
+    .description('Send OCPP SecurityEventNotification')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.SECURITY_EVENT_NOTIFICATION, payload)
+    })
+
+  cmd
+    .command('sign-certificate [hashIds...]')
+    .description('Send OCPP SignCertificate')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.SIGN_CERTIFICATE, payload)
+    })
+
+  cmd
+    .command('status-notification [hashIds...]')
+    .description('Send OCPP StatusNotification')
+    .requiredOption('--connector-id <id>', 'connector ID', parseInteger)
+    .requiredOption('--error-code <code>', 'connector error code')
+    .requiredOption('--status <status>', 'connector status')
+    .action(
+      async (
+        hashIds: string[],
+        options: { connectorId: number; errorCode: string; status: string }
+      ) => {
+        const payload: RequestPayload = {
+          connectorId: options.connectorId,
+          errorCode: options.errorCode,
+          status: options.status,
+          ...(hashIds.length > 0 && { hashIds }),
+        }
+        await runAction(program, ProcedureName.STATUS_NOTIFICATION, payload)
+      }
+    )
+
+  cmd
+    .command('transaction-event [hashIds...]')
+    .description('Send OCPP TransactionEvent')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.TRANSACTION_EVENT, payload)
+    })
+
+  return cmd
+}
diff --git a/ui/cli/src/commands/performance.ts b/ui/cli/src/commands/performance.ts
new file mode 100644 (file)
index 0000000..b9f551d
--- /dev/null
@@ -0,0 +1,17 @@
+import { Command } from 'commander'
+import { ProcedureName } from 'ui-common'
+
+import { runAction } from './action.js'
+
+export const createPerformanceCommands = (program: Command): Command => {
+  const cmd = new Command('performance').description('Performance statistics')
+
+  cmd
+    .command('stats')
+    .description('Get performance statistics')
+    .action(async () => {
+      await runAction(program, ProcedureName.PERFORMANCE_STATISTICS, {})
+    })
+
+  return cmd
+}
diff --git a/ui/cli/src/commands/simulator.ts b/ui/cli/src/commands/simulator.ts
new file mode 100644 (file)
index 0000000..e375e7a
--- /dev/null
@@ -0,0 +1,31 @@
+import { Command } from 'commander'
+import { ProcedureName } from 'ui-common'
+
+import { runAction } from './action.js'
+
+export const createSimulatorCommands = (program: Command): Command => {
+  const cmd = new Command('simulator').description('Simulator lifecycle management')
+
+  cmd
+    .command('state')
+    .description('Get simulator state and statistics')
+    .action(async () => {
+      await runAction(program, ProcedureName.SIMULATOR_STATE, {})
+    })
+
+  cmd
+    .command('start')
+    .description('Start the simulator')
+    .action(async () => {
+      await runAction(program, ProcedureName.START_SIMULATOR, {})
+    })
+
+  cmd
+    .command('stop')
+    .description('Stop the simulator')
+    .action(async () => {
+      await runAction(program, ProcedureName.STOP_SIMULATOR, {})
+    })
+
+  return cmd
+}
diff --git a/ui/cli/src/commands/station.ts b/ui/cli/src/commands/station.ts
new file mode 100644 (file)
index 0000000..33b75a2
--- /dev/null
@@ -0,0 +1,83 @@
+import { Command } from 'commander'
+import { ProcedureName, type RequestPayload } from 'ui-common'
+
+import { parseInteger, runAction } from './action.js'
+
+export const createStationCommands = (program: Command): Command => {
+  const cmd = new Command('station').description('Charging station management')
+
+  cmd
+    .command('list')
+    .description('List all charging stations')
+    .action(async () => {
+      await runAction(program, ProcedureName.LIST_CHARGING_STATIONS, {})
+    })
+
+  cmd
+    .command('start [hashIds...]')
+    .description('Start charging station(s)')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.START_CHARGING_STATION, payload)
+    })
+
+  cmd
+    .command('stop [hashIds...]')
+    .description('Stop charging station(s)')
+    .action(async (hashIds: string[]) => {
+      const payload: RequestPayload = hashIds.length > 0 ? { hashIds } : {}
+      await runAction(program, ProcedureName.STOP_CHARGING_STATION, payload)
+    })
+
+  cmd
+    .command('add')
+    .description('Add charging stations from template')
+    .requiredOption('-t, --template <name>', 'station template name')
+    .requiredOption('-n, --count <n>', 'number of stations to add', parseInteger)
+    .option('--supervision-url <url>', 'supervision URL for new stations')
+    .option('--auto-start', 'auto-start added stations')
+    .option('--persistent-config', 'enable persistent OCPP configuration')
+    .option('--ocpp-strict', 'enable OCPP strict compliance')
+    .action(
+      async (options: {
+        autoStart?: true
+        count: number
+        ocppStrict?: true
+        persistentConfig?: true
+        supervisionUrl?: string
+        template: string
+      }) => {
+        const payload: RequestPayload = {
+          numberOfStations: options.count,
+          options: {
+            ...(options.autoStart != null && { autoStart: options.autoStart }),
+            ...(options.ocppStrict != null && {
+              ocppStrictCompliance: options.ocppStrict,
+            }),
+            ...(options.persistentConfig != null && {
+              persistentConfiguration: options.persistentConfig,
+            }),
+            ...(options.supervisionUrl != null && {
+              supervisionUrls: options.supervisionUrl,
+            }),
+          },
+          template: options.template,
+        }
+        await runAction(program, ProcedureName.ADD_CHARGING_STATIONS, payload)
+      }
+    )
+
+  cmd
+    .command('delete [hashIds...]')
+    .description('Delete charging station(s)')
+    .option('--delete-config', 'delete station configuration files')
+    .action(async (hashIds: string[], options: { deleteConfig?: true }) => {
+      const payload: RequestPayload = {
+        ...(options.deleteConfig != null && { deleteConfiguration: options.deleteConfig }),
+        ...(hashIds.length > 0 && { hashIds }),
+      }
+      await runAction(program, ProcedureName.DELETE_CHARGING_STATIONS, payload)
+    })
+
+  return cmd
+}
diff --git a/ui/cli/src/commands/supervision.ts b/ui/cli/src/commands/supervision.ts
new file mode 100644 (file)
index 0000000..36db940
--- /dev/null
@@ -0,0 +1,22 @@
+import { Command } from 'commander'
+import { ProcedureName, type RequestPayload } from 'ui-common'
+
+import { runAction } from './action.js'
+
+export const createSupervisionCommands = (program: Command): Command => {
+  const cmd = new Command('supervision').description('Supervision URL management')
+
+  cmd
+    .command('set-url [hashIds...]')
+    .description('Set supervision URL for station(s)')
+    .requiredOption('--url <url>', 'supervision URL')
+    .action(async (hashIds: string[], options: { url: string }) => {
+      const payload: RequestPayload = {
+        url: options.url,
+        ...(hashIds.length > 0 && { hashIds }),
+      }
+      await runAction(program, ProcedureName.SET_SUPERVISION_URL, payload)
+    })
+
+  return cmd
+}
diff --git a/ui/cli/src/commands/template.ts b/ui/cli/src/commands/template.ts
new file mode 100644 (file)
index 0000000..8822856
--- /dev/null
@@ -0,0 +1,17 @@
+import { Command } from 'commander'
+import { ProcedureName } from 'ui-common'
+
+import { runAction } from './action.js'
+
+export const createTemplateCommands = (program: Command): Command => {
+  const cmd = new Command('template').description('Template management')
+
+  cmd
+    .command('list')
+    .description('List available station templates')
+    .action(async () => {
+      await runAction(program, ProcedureName.LIST_TEMPLATES, {})
+    })
+
+  return cmd
+}
diff --git a/ui/cli/src/commands/transaction.ts b/ui/cli/src/commands/transaction.ts
new file mode 100644 (file)
index 0000000..67a1b8b
--- /dev/null
@@ -0,0 +1,36 @@
+import { Command } from 'commander'
+import { ProcedureName, type RequestPayload } from 'ui-common'
+
+import { parseInteger, runAction } from './action.js'
+
+export const createTransactionCommands = (program: Command): Command => {
+  const cmd = new Command('transaction').description('Transaction management')
+
+  cmd
+    .command('start [hashIds...]')
+    .description('Start a transaction')
+    .requiredOption('--connector-id <id>', 'connector ID', parseInteger)
+    .requiredOption('--id-tag <tag>', 'RFID tag for authorization')
+    .action(async (hashIds: string[], options: { connectorId: number; idTag: string }) => {
+      const payload: RequestPayload = {
+        connectorId: options.connectorId,
+        idTag: options.idTag,
+        ...(hashIds.length > 0 && { hashIds }),
+      }
+      await runAction(program, ProcedureName.START_TRANSACTION, payload)
+    })
+
+  cmd
+    .command('stop [hashIds...]')
+    .description('Stop a transaction')
+    .requiredOption('--transaction-id <id>', 'transaction ID', parseInteger)
+    .action(async (hashIds: string[], options: { transactionId: number }) => {
+      const payload: RequestPayload = {
+        transactionId: options.transactionId,
+        ...(hashIds.length > 0 && { hashIds }),
+      }
+      await runAction(program, ProcedureName.STOP_TRANSACTION, payload)
+    })
+
+  return cmd
+}
diff --git a/ui/cli/src/config/defaults.ts b/ui/cli/src/config/defaults.ts
new file mode 100644 (file)
index 0000000..075fbab
--- /dev/null
@@ -0,0 +1,5 @@
+export const DEFAULT_HOST = 'localhost'
+export const DEFAULT_PORT = 8080
+export const DEFAULT_PROTOCOL = 'ui'
+export const DEFAULT_VERSION = '0.0.1'
+export const DEFAULT_SECURE = false
diff --git a/ui/cli/src/config/loader.ts b/ui/cli/src/config/loader.ts
new file mode 100644 (file)
index 0000000..914b5a4
--- /dev/null
@@ -0,0 +1,105 @@
+import { readFile } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import process from 'node:process'
+import { type UIServerConfig, uiServerConfigSchema } from 'ui-common'
+
+import {
+  DEFAULT_HOST,
+  DEFAULT_PORT,
+  DEFAULT_PROTOCOL,
+  DEFAULT_SECURE,
+  DEFAULT_VERSION,
+} from './defaults.js'
+
+interface LoadConfigOptions {
+  configPath?: string
+  url?: string
+}
+
+interface ParsedUrl {
+  host: string
+  port: number
+  secure: boolean
+}
+
+const getXdgConfigPath = (): string => {
+  const xdgConfigHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config')
+  return join(xdgConfigHome, 'evse-cli', 'config.json')
+}
+
+const parseServerUrl = (url: string): ParsedUrl => {
+  const parsed = new URL(url)
+  if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
+    throw new Error(`Invalid URL scheme '${parsed.protocol}' — expected ws: or wss:`)
+  }
+  const secure = parsed.protocol === 'wss:'
+  const port = parsed.port !== '' ? Number.parseInt(parsed.port, 10) : secure ? 443 : 80
+  return {
+    host: parsed.hostname,
+    port,
+    secure,
+  }
+}
+
+const readJsonFile = async (filePath: string): Promise<unknown> => {
+  const content = await readFile(filePath, 'utf8')
+  return JSON.parse(content) as unknown
+}
+
+const loadConfigFile = async (configPath?: string): Promise<Partial<UIServerConfig>> => {
+  const targetPath = configPath ?? getXdgConfigPath()
+  try {
+    const raw = await readJsonFile(targetPath)
+    if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
+      const parsed = raw as Record<string, unknown>
+      const uiServer = parsed.uiServer ?? parsed
+      if (Array.isArray(uiServer)) {
+        throw new Error('Config contains multiple uiServer entries; the CLI supports only one')
+      }
+      if (typeof uiServer !== 'object') {
+        throw new Error('Config uiServer must be an object')
+      }
+      return uiServer as Partial<UIServerConfig>
+    }
+    throw new Error(`Config file '${targetPath}' must contain a JSON object`)
+  } catch (error: unknown) {
+    if (
+      configPath != null ||
+      !(error instanceof Error && 'code' in error && error.code === 'ENOENT')
+    ) {
+      const message = error instanceof Error ? error.message : String(error)
+      const context = configPath != null ? `'${configPath}'` : `'${targetPath}'`
+      throw new Error(`Failed to load configuration file ${context}: ${message}`, { cause: error })
+    }
+    return {}
+  }
+}
+
+export const loadConfig = async (options: LoadConfigOptions = {}): Promise<UIServerConfig> => {
+  const defaults: UIServerConfig = {
+    host: DEFAULT_HOST,
+    port: DEFAULT_PORT,
+    protocol: DEFAULT_PROTOCOL,
+    secure: DEFAULT_SECURE,
+    version: DEFAULT_VERSION,
+  }
+
+  const fileConfig = await loadConfigFile(options.configPath)
+
+  const cliOverrides: Partial<UIServerConfig> = {}
+  if (options.url != null) {
+    const parsed = parseServerUrl(options.url)
+    cliOverrides.host = parsed.host
+    cliOverrides.port = parsed.port
+    cliOverrides.secure = parsed.secure
+  }
+
+  const merged = {
+    ...defaults,
+    ...fileConfig,
+    ...cliOverrides,
+  }
+
+  return uiServerConfigSchema.parse(merged)
+}
diff --git a/ui/cli/src/output/formatter.ts b/ui/cli/src/output/formatter.ts
new file mode 100644 (file)
index 0000000..3540b5b
--- /dev/null
@@ -0,0 +1,26 @@
+import type { ResponsePayload } from 'ui-common'
+
+import { printError } from './human.js'
+import { outputJson, outputJsonError } from './json.js'
+import { outputTable } from './table.js'
+
+export interface Formatter {
+  error: (error: unknown) => void
+  output: (payload: ResponsePayload) => void
+}
+
+export const createFormatter = (jsonMode: boolean): Formatter => {
+  if (jsonMode) {
+    return {
+      error: outputJsonError,
+      output: outputJson,
+    }
+  }
+  return {
+    error: (error: unknown) => {
+      const message = error instanceof Error ? error.message : String(error)
+      printError(message)
+    },
+    output: outputTable,
+  }
+}
diff --git a/ui/cli/src/output/human.ts b/ui/cli/src/output/human.ts
new file mode 100644 (file)
index 0000000..62ce3ea
--- /dev/null
@@ -0,0 +1,6 @@
+import chalk from 'chalk'
+import process from 'node:process'
+
+export const printError = (message: string): void => {
+  process.stderr.write(chalk.red(`✗ ${message}\n`))
+}
diff --git a/ui/cli/src/output/json.ts b/ui/cli/src/output/json.ts
new file mode 100644 (file)
index 0000000..e62f8ae
--- /dev/null
@@ -0,0 +1,13 @@
+import process from 'node:process'
+import { type ResponsePayload, ResponseStatus } from 'ui-common'
+
+export const outputJson = (payload: ResponsePayload): void => {
+  process.stdout.write(JSON.stringify(payload, null, 2) + '\n')
+}
+
+export const outputJsonError = (error: unknown): void => {
+  const message = error instanceof Error ? error.message : String(error)
+  process.stdout.write(
+    JSON.stringify({ error: true, message, status: ResponseStatus.FAILURE }, null, 2) + '\n'
+  )
+}
diff --git a/ui/cli/src/output/table.ts b/ui/cli/src/output/table.ts
new file mode 100644 (file)
index 0000000..4ce7280
--- /dev/null
@@ -0,0 +1,44 @@
+import chalk from 'chalk'
+import Table from 'cli-table3'
+import process from 'node:process'
+import { type ResponsePayload, ResponseStatus } from 'ui-common'
+
+export const outputTable = (payload: ResponsePayload): void => {
+  if (payload.hashIdsSucceeded != null && payload.hashIdsSucceeded.length > 0) {
+    process.stdout.write(
+      chalk.green(`✓ Succeeded (${payload.hashIdsSucceeded.length.toString()}):\n`)
+    )
+    const table = new Table({ head: [chalk.white('Hash ID')] })
+    for (const id of payload.hashIdsSucceeded) {
+      table.push([id])
+    }
+    process.stdout.write(table.toString() + '\n')
+  }
+
+  if (payload.hashIdsFailed != null && payload.hashIdsFailed.length > 0) {
+    process.stderr.write(chalk.red(`✗ Failed (${payload.hashIdsFailed.length.toString()}):\n`))
+    const table = new Table({ head: [chalk.white('Hash ID')] })
+    for (const id of payload.hashIdsFailed) {
+      table.push([id])
+    }
+    process.stderr.write(table.toString() + '\n')
+  }
+
+  if (
+    (payload.hashIdsSucceeded == null || payload.hashIdsSucceeded.length === 0) &&
+    (payload.hashIdsFailed == null || payload.hashIdsFailed.length === 0)
+  ) {
+    displayGenericPayload(payload)
+  }
+}
+
+const displayGenericPayload = (payload: ResponsePayload): void => {
+  const { status, ...rest } = payload
+  if (Object.keys(rest).length > 0) {
+    process.stdout.write(JSON.stringify(rest, null, 2) + '\n')
+  } else if (status === ResponseStatus.SUCCESS) {
+    process.stdout.write(chalk.green('✓ Success\n'))
+  } else {
+    process.stderr.write(chalk.red(`✗ ${status}\n`))
+  }
+}
diff --git a/ui/cli/src/types.ts b/ui/cli/src/types.ts
new file mode 100644 (file)
index 0000000..115bc9c
--- /dev/null
@@ -0,0 +1,5 @@
+export interface GlobalOptions {
+  config?: string
+  json: boolean
+  url?: string
+}
diff --git a/ui/cli/tests/config.test.ts b/ui/cli/tests/config.test.ts
new file mode 100644 (file)
index 0000000..242c662
--- /dev/null
@@ -0,0 +1,132 @@
+import assert from 'node:assert'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import {
+  DEFAULT_HOST,
+  DEFAULT_PORT,
+  DEFAULT_PROTOCOL,
+  DEFAULT_SECURE,
+  DEFAULT_VERSION,
+} from '../src/config/defaults.js'
+import { loadConfig } from '../src/config/loader.js'
+
+let tempDir: string
+let originalXdgConfigHome: string | undefined
+
+await describe('CLI config loader', async () => {
+  beforeEach(async () => {
+    tempDir = await mkdtemp(join(tmpdir(), 'evse-cli-test-'))
+    originalXdgConfigHome = process.env.XDG_CONFIG_HOME
+    process.env.XDG_CONFIG_HOME = tempDir
+  })
+
+  afterEach(async () => {
+    if (originalXdgConfigHome != null) {
+      process.env.XDG_CONFIG_HOME = originalXdgConfigHome
+    } else {
+      delete process.env.XDG_CONFIG_HOME
+    }
+    await rm(tempDir, { force: true, recursive: true })
+  })
+
+  await it('should use defaults when no config file exists', async () => {
+    const config = await loadConfig()
+    assert.strictEqual(config.host, DEFAULT_HOST)
+    assert.strictEqual(config.port, DEFAULT_PORT)
+    assert.strictEqual(config.protocol, DEFAULT_PROTOCOL)
+    assert.strictEqual(config.version, DEFAULT_VERSION)
+    assert.strictEqual(config.secure, DEFAULT_SECURE)
+  })
+
+  await it('should load config from XDG default path', async () => {
+    const configDir = join(tempDir, 'evse-cli')
+    await mkdir(configDir, { recursive: true })
+    await writeFile(
+      join(configDir, 'config.json'),
+      JSON.stringify({
+        uiServer: {
+          host: 'xdg-host.example.com',
+          port: 7777,
+          protocol: 'ui',
+          version: '0.0.1',
+        },
+      })
+    )
+    const config = await loadConfig()
+    assert.strictEqual(config.host, 'xdg-host.example.com')
+    assert.strictEqual(config.port, 7777)
+  })
+
+  await it('should load config from explicit path', async () => {
+    const configFile = join(tempDir, 'config.json')
+    await writeFile(
+      configFile,
+      JSON.stringify({
+        uiServer: {
+          host: 'remote-server.example.com',
+          port: 9090,
+          protocol: 'ui',
+          version: '0.0.1',
+        },
+      })
+    )
+    const config = await loadConfig({ configPath: configFile })
+    assert.strictEqual(config.host, 'remote-server.example.com')
+    assert.strictEqual(config.port, 9090)
+  })
+
+  await it('should throw on explicit path that does not exist', async () => {
+    await assert.rejects(async () => loadConfig({ configPath: '/nonexistent/config.json' }), {
+      message: /Failed to load configuration file/,
+    })
+  })
+
+  await it('should throw on malformed JSON in config file', async () => {
+    const configFile = join(tempDir, 'bad.json')
+    await writeFile(configFile, '{invalid json')
+    await assert.rejects(async () => loadConfig({ configPath: configFile }), {
+      message: /Failed to load configuration file/,
+    })
+  })
+
+  await it('should apply CLI url override with highest priority', async () => {
+    const config = await loadConfig({ url: 'ws://simulator.example.com:9090' })
+    assert.strictEqual(config.host, 'simulator.example.com')
+    assert.strictEqual(config.port, 9090)
+    assert.strictEqual(config.secure, false)
+  })
+
+  await it('should detect secure connection from wss:// url', async () => {
+    const config = await loadConfig({
+      url: 'wss://simulator.example.com:443',
+    })
+    assert.strictEqual(config.secure, true)
+    assert.strictEqual(config.host, 'simulator.example.com')
+    assert.strictEqual(config.port, 443)
+  })
+
+  await it('should merge config file with CLI overrides', async () => {
+    const configFile = join(tempDir, 'config.json')
+    await writeFile(
+      configFile,
+      JSON.stringify({
+        uiServer: {
+          host: 'file-host.example.com',
+          port: 7070,
+          protocol: 'ui',
+          version: '0.0.1',
+        },
+      })
+    )
+    const config = await loadConfig({
+      configPath: configFile,
+      url: 'ws://override-host:8888',
+    })
+    assert.strictEqual(config.host, 'override-host')
+    assert.strictEqual(config.port, 8888)
+    assert.strictEqual(config.protocol, 'ui')
+  })
+})
diff --git a/ui/cli/tests/integration/cli.test.ts b/ui/cli/tests/integration/cli.test.ts
new file mode 100644 (file)
index 0000000..a338f7a
--- /dev/null
@@ -0,0 +1,85 @@
+import assert from 'node:assert'
+import { spawn } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { describe, it } from 'node:test'
+import { fileURLToPath } from 'node:url'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const cliPath = join(__dirname, '../../dist/cli.js')
+
+const runCli = (args: string[]): Promise<{ code: number; stderr: string; stdout: string }> => {
+  return new Promise(resolve => {
+    const stdoutChunks: Buffer[] = []
+    const stderrChunks: Buffer[] = []
+    const child = spawn('node', [cliPath, ...args], {
+      env: { ...process.env, NO_COLOR: '1' },
+    })
+    child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk))
+    child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
+    child.on('close', code => {
+      resolve({
+        code: code ?? 1,
+        stderr: Buffer.concat(stderrChunks).toString(),
+        stdout: Buffer.concat(stdoutChunks).toString(),
+      })
+    })
+  })
+}
+
+await describe('evse-cli integration tests', async () => {
+  await it('should exit 0 and show help', async () => {
+    assert.ok(existsSync(cliPath), `CLI not built: ${cliPath}`)
+    const result = await runCli(['--help'])
+    assert.strictEqual(result.code, 0)
+    assert.ok(result.stdout.includes('evse-cli'), `Expected evse-cli in help: ${result.stdout}`)
+    assert.ok(result.stdout.includes('simulator'), `Expected simulator command: ${result.stdout}`)
+    assert.ok(result.stdout.includes('station'), `Expected station command: ${result.stdout}`)
+  })
+
+  await it('should exit 0 and show version', async () => {
+    const result = await runCli(['--version'])
+    assert.strictEqual(result.code, 0)
+    assert.match(result.stdout, /\d+\.\d+\.\d+/)
+  })
+
+  await it('should exit 0 and show simulator subcommand help', async () => {
+    const result = await runCli(['simulator', '--help'])
+    assert.strictEqual(result.code, 0)
+    assert.ok(result.stdout.includes('state'))
+    assert.ok(result.stdout.includes('start'))
+    assert.ok(result.stdout.includes('stop'))
+  })
+
+  await it('should exit 0 and show station subcommand help', async () => {
+    const result = await runCli(['station', '--help'])
+    assert.strictEqual(result.code, 0)
+    assert.ok(result.stdout.includes('list'))
+    assert.ok(result.stdout.includes('add'))
+    assert.ok(result.stdout.includes('delete'))
+  })
+
+  await it('should exit 0 and show ocpp subcommand help with commands', async () => {
+    const result = await runCli(['ocpp', '--help'])
+    assert.strictEqual(result.code, 0)
+    assert.ok(result.stdout.includes('authorize'))
+    assert.ok(result.stdout.includes('heartbeat'))
+    assert.ok(result.stdout.includes('transaction-event'))
+  })
+
+  await it('should exit 1 with connection error when no simulator running', async () => {
+    const result = await runCli(['--url', 'ws://localhost:19999', 'simulator', 'state'])
+    assert.strictEqual(result.code, 1)
+    assert.ok(result.stderr.length > 0 || result.stdout.length > 0, 'Expected error output')
+  })
+
+  await it('should exit 1 and output JSON error in --json mode when no simulator running', async () => {
+    const result = await runCli(['--url', 'ws://localhost:19999', '--json', 'simulator', 'state'])
+    assert.strictEqual(result.code, 1)
+  })
+
+  await it('should exit 1 when required options missing (station add)', async () => {
+    const result = await runCli(['station', 'add'])
+    assert.strictEqual(result.code, 1)
+  })
+})
diff --git a/ui/cli/tests/lifecycle.test.ts b/ui/cli/tests/lifecycle.test.ts
new file mode 100644 (file)
index 0000000..74da70e
--- /dev/null
@@ -0,0 +1,19 @@
+import assert from 'node:assert'
+import { describe, it } from 'node:test'
+
+import { ConnectionError } from '../src/client/errors.js'
+
+await describe('CLI error types', async () => {
+  await it('should create ConnectionError with url', () => {
+    const err = new ConnectionError('ws://localhost:8080')
+    assert.strictEqual(err.name, 'ConnectionError')
+    assert.strictEqual(err.url, 'ws://localhost:8080')
+    assert.ok(err.message.includes('ws://localhost:8080'))
+  })
+
+  await it('should create ConnectionError with cause', () => {
+    const cause = new Error('ECONNREFUSED')
+    const err = new ConnectionError('ws://localhost:8080', cause)
+    assert.strictEqual(err.cause, cause)
+  })
+})
diff --git a/ui/cli/tests/output.test.ts b/ui/cli/tests/output.test.ts
new file mode 100644 (file)
index 0000000..e70fcf2
--- /dev/null
@@ -0,0 +1,168 @@
+import assert from 'node:assert'
+import { describe, it } from 'node:test'
+import { ResponseStatus } from 'ui-common'
+
+import { createFormatter } from '../src/output/formatter.js'
+import { printError } from '../src/output/human.js'
+import { outputJson, outputJsonError } from '../src/output/json.js'
+import { outputTable } from '../src/output/table.js'
+
+const captureStdout = (fn: () => void): string => {
+  const chunks: string[] = []
+  const original = process.stdout.write.bind(process.stdout)
+  process.stdout.write = ((chunk: string): boolean => {
+    chunks.push(chunk)
+    return true
+  }) as typeof process.stdout.write
+  try {
+    fn()
+  } finally {
+    process.stdout.write = original
+  }
+  return chunks.join('')
+}
+
+const captureStderr = (fn: () => void): string => {
+  const chunks: string[] = []
+  const original = process.stderr.write.bind(process.stderr)
+  process.stderr.write = ((chunk: string): boolean => {
+    chunks.push(chunk)
+    return true
+  }) as typeof process.stderr.write
+  try {
+    fn()
+  } finally {
+    process.stderr.write = original
+  }
+  return chunks.join('')
+}
+
+await describe('output formatters', async () => {
+  await it('should create JSON formatter when jsonMode is true', () => {
+    const formatter = createFormatter(true)
+    assert.strictEqual(typeof formatter.output, 'function')
+    assert.strictEqual(typeof formatter.error, 'function')
+  })
+
+  await it('should create table formatter when jsonMode is false', () => {
+    const formatter = createFormatter(false)
+    assert.strictEqual(typeof formatter.output, 'function')
+    assert.strictEqual(typeof formatter.error, 'function')
+  })
+
+  await it('should write valid JSON to stdout for success payload', () => {
+    const payload = {
+      hashIdsSucceeded: ['cs-001', 'cs-002'],
+      status: ResponseStatus.SUCCESS,
+    }
+    const output = captureStdout(() => {
+      outputJson(payload)
+    })
+    const parsed = JSON.parse(output) as typeof payload
+    assert.strictEqual(parsed.status, ResponseStatus.SUCCESS)
+    assert.deepStrictEqual(parsed.hashIdsSucceeded, ['cs-001', 'cs-002'])
+  })
+
+  await it('should write valid JSON to stdout for failure payload', () => {
+    const payload = { status: ResponseStatus.FAILURE }
+    const output = captureStdout(() => {
+      outputJson(payload)
+    })
+    const parsed = JSON.parse(output) as typeof payload
+    assert.strictEqual(parsed.status, ResponseStatus.FAILURE)
+  })
+
+  await it('should write error JSON to stdout', () => {
+    const output = captureStdout(() => {
+      outputJsonError(new Error('test error'))
+    })
+    const parsed = JSON.parse(output) as { error: boolean; message: string; status: string }
+    assert.strictEqual(parsed.error, true)
+    assert.strictEqual(parsed.message, 'test error')
+    assert.strictEqual(parsed.status, ResponseStatus.FAILURE)
+  })
+
+  await it('should handle non-Error objects in JSON error output', () => {
+    const output = captureStdout(() => {
+      outputJsonError('string error')
+    })
+    const parsed = JSON.parse(output) as { message: string }
+    assert.strictEqual(parsed.message, 'string error')
+  })
+
+  await it('should write table output for payload with hash IDs', () => {
+    const payload = {
+      hashIdsSucceeded: ['cs-001'],
+      status: ResponseStatus.SUCCESS,
+    }
+    const output = captureStdout(() => {
+      outputTable(payload)
+    })
+    assert.ok(output.includes('cs-001'))
+  })
+
+  await it('should display generic payload when no hash IDs present', () => {
+    const payload = { state: { version: '1.0' }, status: ResponseStatus.SUCCESS }
+    const output = captureStdout(() => {
+      outputTable(payload)
+    })
+    assert.ok(output.includes('version'))
+  })
+
+  await it('should write generic success when no hash IDs in table mode', () => {
+    const payload = { status: ResponseStatus.SUCCESS }
+    const output = captureStdout(() => {
+      outputTable(payload)
+    })
+    assert.ok(output.includes('Success'))
+  })
+
+  await it('should write error message via printError', () => {
+    const output = captureStderr(() => {
+      printError('oops')
+    })
+    assert.ok(output.includes('oops'))
+  })
+
+  await it('should output JSON when using JSON formatter', () => {
+    const formatter = createFormatter(true)
+    const payload = {
+      hashIdsSucceeded: ['cs-100'],
+      status: ResponseStatus.SUCCESS,
+    }
+    const output = captureStdout(() => {
+      formatter.output(payload)
+    })
+    const parsed = JSON.parse(output) as typeof payload
+    assert.strictEqual(parsed.status, ResponseStatus.SUCCESS)
+  })
+
+  await it('should output table when using table formatter', () => {
+    const formatter = createFormatter(false)
+    const payload = {
+      hashIdsSucceeded: ['cs-200'],
+      status: ResponseStatus.SUCCESS,
+    }
+    const output = captureStdout(() => {
+      formatter.output(payload)
+    })
+    assert.ok(output.includes('cs-200'))
+  })
+
+  await it('should handle error with JSON formatter', () => {
+    const formatter = createFormatter(true)
+    const output = captureStdout(() => {
+      formatter.error(new Error('json err'))
+    })
+    const parsed = JSON.parse(output) as { message: string }
+    assert.strictEqual(parsed.message, 'json err')
+  })
+
+  await it('should handle error with table formatter', () => {
+    const formatter = createFormatter(false)
+    const output = captureStderr(() => {
+      formatter.error(new Error('table err'))
+    })
+    assert.ok(output.includes('table err'))
+  })
+})
diff --git a/ui/cli/tsconfig.json b/ui/cli/tsconfig.json
new file mode 100644 (file)
index 0000000..b152b92
--- /dev/null
@@ -0,0 +1,19 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "compilerOptions": {
+    "target": "ESNext",
+    "module": "NodeNext",
+    "lib": ["ESNext"],
+    "types": ["node"],
+    "removeComments": true,
+    "strict": true,
+    "moduleResolution": "NodeNext",
+    "resolveJsonModule": true,
+    "allowSyntheticDefaultImports": true,
+    "verbatimModuleSyntax": true,
+    "forceConsistentCasingInFileNames": true,
+    "noImplicitOverride": true,
+    "outDir": "./dist"
+  },
+  "include": ["src/**/*.ts", "tests/**/*.ts"]
+}
diff --git a/ui/common/.editorconfig b/ui/common/.editorconfig
new file mode 100644 (file)
index 0000000..be65bb5
--- /dev/null
@@ -0,0 +1,21 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+end_of_line = lf
+max_line_length = 100
+
+[*.ts{,x}]
+quote_type = single
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
+
+[{Makefile,**.mk}]
+# Use tabs for indentation (Makefiles require tabs)
+indent_style = tab
diff --git a/ui/common/.lintstagedrc.js b/ui/common/.lintstagedrc.js
new file mode 100644 (file)
index 0000000..a13b0e2
--- /dev/null
@@ -0,0 +1,4 @@
+export default {
+  '*.{css,json,md,yml,yaml,html,js,jsx,cjs,mjs,ts,tsx,cts,mts}': 'prettier --cache --write',
+  '*.{js,jsx,cjs,mjs,ts,tsx,cts,mts}': 'eslint --cache --fix',
+}
diff --git a/ui/common/.npmrc b/ui/common/.npmrc
new file mode 100644 (file)
index 0000000..22144e5
--- /dev/null
@@ -0,0 +1,2 @@
+auto-install-peers=true
+legacy-peer-deps=true
diff --git a/ui/common/.prettierignore b/ui/common/.prettierignore
new file mode 100644 (file)
index 0000000..9f7f384
--- /dev/null
@@ -0,0 +1,3 @@
+coverage
+dist
+pnpm-lock.yaml
diff --git a/ui/common/.prettierrc.json b/ui/common/.prettierrc.json
new file mode 100644 (file)
index 0000000..cafa923
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "printWidth": 100,
+  "arrowParens": "avoid",
+  "singleQuote": true,
+  "semi": false,
+  "trailingComma": "es5"
+}
diff --git a/ui/common/README.md b/ui/common/README.md
new file mode 100644 (file)
index 0000000..3ef5f22
--- /dev/null
@@ -0,0 +1,84 @@
+# UI Common
+
+Shared library for the e-mobility charging stations simulator UI clients. Provides the SRPC WebSocket client, UI protocol type definitions, configuration types, and Zod validation schemas.
+
+## Exported API
+
+### Types
+
+```typescript
+import type {
+  ProcedureName, // enum — all 35 UI protocol procedures
+  RequestPayload, // SRPC request payload interface
+  ResponsePayload, // SRPC response payload interface
+  ResponseStatus, // enum — 'success' | 'failure'
+  AuthenticationType, // enum — 'protocol-basic-auth'
+  ServerNotification, // enum — 'refresh'
+  ProtocolRequest, // [UUIDv4, ProcedureName, RequestPayload]
+  ProtocolResponse, // [UUIDv4, ResponsePayload]
+  UIServerConfigurationSection, // UI server config interface
+  UIServerConfig, // Zod-inferred UI server config type
+  Configuration, // Full config type (single or multiple servers)
+  UUIDv4, // Branded UUID type
+  JsonType, // JSON value type
+  JsonObject, // JSON object type
+} from 'ui-common'
+```
+
+### WebSocketClient
+
+SRPC WebSocket client with dependency injection. Consumers provide a WebSocket factory so the client works in any environment.
+
+```typescript
+import { WebSocketClient, ProcedureName } from 'ui-common'
+import type { WebSocketFactory, WebSocketLike } from 'ui-common'
+import { WebSocket } from 'ws'
+
+const factory: WebSocketFactory = (url, protocols) =>
+  new WebSocket(url, protocols) as unknown as WebSocketLike
+
+const client = new WebSocketClient(factory, {
+  host: 'localhost',
+  port: 8080,
+  protocol: 'ui',
+  version: '0.0.1',
+  authentication: {
+    enabled: true,
+    type: 'protocol-basic-auth',
+    username: 'admin',
+    password: 'admin',
+  },
+})
+
+await client.connect()
+const response = await client.sendRequest(ProcedureName.SIMULATOR_STATE, {})
+client.disconnect()
+```
+
+### Config Validation
+
+```typescript
+import { uiServerConfigSchema, configurationSchema } from 'ui-common'
+
+const config = uiServerConfigSchema.parse(rawConfig)
+const result = uiServerConfigSchema.safeParse(rawConfig)
+```
+
+### UUID Utilities
+
+```typescript
+import { randomUUID, validateUUID } from 'ui-common'
+
+const id = randomUUID() // UUIDv4
+const valid = validateUUID(id) // boolean
+```
+
+## Available Scripts
+
+| Script               | Description                      |
+| -------------------- | -------------------------------- |
+| `pnpm typecheck`     | Type-check                       |
+| `pnpm lint`          | Run ESLint                       |
+| `pnpm format`        | Run Prettier and ESLint auto-fix |
+| `pnpm test`          | Run unit tests                   |
+| `pnpm test:coverage` | Run unit tests with coverage     |
diff --git a/ui/common/package.json b/ui/common/package.json
new file mode 100644 (file)
index 0000000..ce18d77
--- /dev/null
@@ -0,0 +1,38 @@
+{
+  "$schema": "https://json.schemastore.org/package",
+  "name": "ui-common",
+  "version": "4.4.0",
+  "engines": {
+    "node": ">=22.0.0",
+    "pnpm": ">=10.9.0"
+  },
+  "volta": {
+    "node": "24.14.1",
+    "pnpm": "10.33.0"
+  },
+  "packageManager": "pnpm@10.33.0",
+  "type": "module",
+  "exports": "./src/index.ts",
+  "scripts": {
+    "build": "tsc --noEmit --skipLibCheck",
+    "clean:dist": "pnpm exec rimraf dist",
+    "clean:node_modules": "pnpm exec rimraf node_modules",
+    "lint": "cross-env TIMING=1 eslint --cache .",
+    "lint:fix": "cross-env TIMING=1 eslint --cache --fix .",
+    "format": "prettier --cache --write .; eslint --cache --fix .",
+    "test": "cross-env NODE_ENV=test node --import tsx --test --test-force-exit 'tests/**/*.test.ts'",
+    "test:coverage": "mkdir -p coverage && cross-env NODE_ENV=test node --import tsx --test --test-force-exit --experimental-test-coverage --test-coverage-include='src/**/*.ts' --test-reporter=lcov --test-reporter-destination=coverage/lcov.info 'tests/**/*.test.ts'",
+    "typecheck": "tsc --noEmit --skipLibCheck"
+  },
+  "dependencies": {
+    "zod": "^4.3.6"
+  },
+  "devDependencies": {
+    "@types/node": "^24.12.2",
+    "cross-env": "^10.1.0",
+    "prettier": "^3.8.2",
+    "rimraf": "^6.1.3",
+    "tsx": "^4.21.0",
+    "typescript": "~6.0.2"
+  }
+}
diff --git a/ui/common/sonar-project.properties b/ui/common/sonar-project.properties
new file mode 100644 (file)
index 0000000..148945c
--- /dev/null
@@ -0,0 +1,17 @@
+sonar.projectKey=e-mobility-charging-stations-simulator-ui-common
+sonar.organization=sap-1
+
+# This is the name and version displayed in the SonarCloud UI.
+sonar.projectName=e-mobility-charging-stations-simulator-ui-common
+# x-release-please-start-version
+sonar.projectVersion=4.4.0
+# x-release-please-end
+
+# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
+sonar.sources=src
+sonar.tests=tests
+
+sonar.typescript.lcov.reportPaths=coverage/lcov.info
+
+# Encoding of the source code. Default is default system encoding
+#sonar.sourceEncoding=UTF-8
diff --git a/ui/common/src/client/WebSocketClient.ts b/ui/common/src/client/WebSocketClient.ts
new file mode 100644 (file)
index 0000000..419cc23
--- /dev/null
@@ -0,0 +1,183 @@
+import { Buffer } from 'node:buffer'
+
+import type { ProcedureName, RequestPayload, ResponsePayload } from '../types/UIProtocol.js'
+import type { UUIDv4 } from '../types/UUID.js'
+import type { ClientConfig, ResponseHandler, WebSocketFactory, WebSocketLike } from './types.js'
+
+import { UI_WEBSOCKET_REQUEST_TIMEOUT_MS } from '../constants.js'
+import { AuthenticationType, ResponseStatus } from '../types/UIProtocol.js'
+import { randomUUID, validateUUID } from '../utils/UUID.js'
+import { WebSocketReadyState } from './types.js'
+
+export class ServerFailureError extends Error {
+  public readonly payload: ResponsePayload
+
+  public constructor (payload: ResponsePayload) {
+    const details =
+      payload.hashIdsFailed != null && payload.hashIdsFailed.length > 0
+        ? `: ${payload.hashIdsFailed.length.toString()} station(s) failed`
+        : ''
+    super(`Server returned failure status${details}`)
+    this.name = 'ServerFailureError'
+    this.payload = payload
+  }
+}
+
+export class WebSocketClient {
+  public get url (): string {
+    const scheme = this.config.secure === true ? 'wss' : 'ws'
+    return `${scheme}://${this.config.host}:${this.config.port.toString()}`
+  }
+
+  private readonly config: ClientConfig
+  private readonly factory: WebSocketFactory
+  private readonly responseHandlers: Map<UUIDv4, ResponseHandler>
+  private readonly timeoutMs: number
+
+  private ws?: WebSocketLike
+
+  public constructor (
+    factory: WebSocketFactory,
+    config: ClientConfig,
+    timeoutMs = UI_WEBSOCKET_REQUEST_TIMEOUT_MS
+  ) {
+    this.factory = factory
+    this.config = config
+    this.timeoutMs = timeoutMs
+    this.responseHandlers = new Map()
+  }
+
+  public connect (): Promise<void> {
+    return new Promise<void>((resolve, reject) => {
+      const protocols = this.buildProtocols()
+      const url = this.url
+      this.ws = this.factory(url, protocols)
+      let settled = false
+      this.ws.onopen = () => {
+        settled = true
+        if (this.ws != null) {
+          this.ws.onerror = event => {
+            const err =
+              event.error instanceof Error
+                ? event.error
+                : new Error(event.message.length > 0 ? event.message : 'WebSocket error')
+            this.failAllPending(err)
+          }
+        }
+        resolve()
+      }
+      this.ws.onerror = event => {
+        settled = true
+        const err =
+          event.error instanceof Error
+            ? event.error
+            : new Error(event.message.length > 0 ? event.message : 'WebSocket connection error')
+        reject(err)
+      }
+      this.ws.onmessage = event => {
+        this.handleMessage(event.data)
+      }
+      this.ws.onclose = event => {
+        if (!settled) {
+          settled = true
+          reject(
+            new Error(
+              `WebSocket closed before connection established (code: ${event.code.toString()})`
+            )
+          )
+        }
+        this.clearHandlers()
+      }
+    })
+  }
+
+  public disconnect (): void {
+    this.clearHandlers()
+    this.ws?.close()
+  }
+
+  public sendRequest (
+    procedureName: ProcedureName,
+    payload: RequestPayload
+  ): Promise<ResponsePayload> {
+    return new Promise<ResponsePayload>((resolve, reject) => {
+      if (this.ws?.readyState !== WebSocketReadyState.OPEN) {
+        reject(new Error('WebSocket is not open'))
+        return
+      }
+      const uuid = randomUUID()
+      const message = JSON.stringify([uuid, procedureName, payload])
+      const timeoutId = setTimeout(() => {
+        this.responseHandlers.delete(uuid)
+        reject(
+          new Error(`Request '${procedureName}' timed out after ${this.timeoutMs.toString()}ms`)
+        )
+      }, this.timeoutMs)
+      this.responseHandlers.set(uuid, { reject, resolve, timeoutId })
+      this.ws.send(message)
+    })
+  }
+
+  private buildProtocols (): string | string[] {
+    const primary = `${this.config.protocol}${this.config.version}`
+    const auth = this.config.authentication
+    if (
+      auth?.enabled === true &&
+      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+      auth.type === AuthenticationType.PROTOCOL_BASIC_AUTH &&
+      auth.username != null &&
+      auth.password != null
+    ) {
+      const encoded = Buffer.from(`${auth.username}:${auth.password}`)
+        .toString('base64')
+        .replace(/={1,2}$/, '')
+      return [primary, `authorization.basic.${encoded}`]
+    }
+    return primary
+  }
+
+  private clearHandlers (): void {
+    this.failAllPending(new Error('Connection closed'))
+  }
+
+  private failAllPending (error: Error): void {
+    const handlers = [...this.responseHandlers.values()]
+    this.responseHandlers.clear()
+    for (const handler of handlers) {
+      clearTimeout(handler.timeoutId)
+      handler.reject(error)
+    }
+  }
+
+  private handleMessage (data: string): void {
+    let message: unknown
+    try {
+      message = JSON.parse(data) as unknown
+    } catch {
+      return
+    }
+    if (!Array.isArray(message) || message.length !== 2) return
+    const [uuid, responsePayload] = message as [unknown, unknown]
+    if (!validateUUID(uuid)) return
+    const handler = this.responseHandlers.get(uuid)
+    if (handler == null) return
+    if (
+      responsePayload == null ||
+      typeof responsePayload !== 'object' ||
+      !('status' in responsePayload)
+    ) {
+      clearTimeout(handler.timeoutId)
+      this.responseHandlers.delete(uuid)
+      handler.reject(new Error('Server sent malformed response payload'))
+      return
+    }
+    clearTimeout(handler.timeoutId)
+    this.responseHandlers.delete(uuid)
+    const payload = responsePayload as ResponsePayload
+    if (payload.status === ResponseStatus.SUCCESS) {
+      handler.resolve(payload)
+    } else {
+      handler.reject(new ServerFailureError(payload))
+    }
+  }
+}
diff --git a/ui/common/src/client/types.ts b/ui/common/src/client/types.ts
new file mode 100644 (file)
index 0000000..d1b49d8
--- /dev/null
@@ -0,0 +1,42 @@
+import type { AuthenticationType, ResponsePayload } from '../types/UIProtocol.js'
+
+export const enum WebSocketReadyState {
+  CONNECTING = 0,
+  OPEN = 1,
+  CLOSING = 2,
+  CLOSED = 3,
+}
+
+export interface AuthenticationConfig {
+  enabled: boolean
+  password?: string
+  type: AuthenticationType
+  username?: string
+}
+
+export interface ClientConfig {
+  authentication?: AuthenticationConfig
+  host: string
+  port: number
+  protocol: string
+  secure?: boolean
+  version: string
+}
+
+export interface ResponseHandler {
+  reject: (reason?: unknown) => void
+  resolve: (value: ResponsePayload) => void
+  timeoutId: ReturnType<typeof setTimeout>
+}
+
+export type WebSocketFactory = (url: string, protocols: string | string[]) => WebSocketLike
+
+export interface WebSocketLike {
+  close(code?: number, reason?: string): void
+  onclose: ((event: { code: number; reason: string }) => void) | null
+  onerror: ((event: { error: unknown; message: string }) => void) | null
+  onmessage: ((event: { data: string }) => void) | null
+  onopen: (() => void) | null
+  readonly readyState: WebSocketReadyState
+  send(data: string): void
+}
diff --git a/ui/common/src/config/schema.ts b/ui/common/src/config/schema.ts
new file mode 100644 (file)
index 0000000..5f1ceb1
--- /dev/null
@@ -0,0 +1,43 @@
+import { z } from 'zod'
+
+import { AuthenticationType } from '../types/UIProtocol.js'
+
+export const authenticationConfigSchema = z
+  .object({
+    enabled: z.boolean(),
+    password: z.string().optional(),
+    type: z.enum(AuthenticationType),
+    username: z.string().optional(),
+  })
+  .refine(
+    data =>
+      !data.enabled ||
+      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+      data.type !== AuthenticationType.PROTOCOL_BASIC_AUTH ||
+      (data.username != null &&
+        data.username.length > 0 &&
+        data.password != null &&
+        data.password.length > 0),
+    {
+      message:
+        'username and password are required when authentication is enabled with protocol-basic-auth',
+    }
+  )
+
+export const uiServerConfigSchema = z.object({
+  authentication: authenticationConfigSchema.optional(),
+  host: z.string().min(1),
+  name: z.string().optional(),
+  port: z.number().int().min(1).max(65535),
+  protocol: z.string().min(1),
+  secure: z.boolean().optional(),
+  version: z.string().min(1),
+})
+
+export const configurationSchema = z.object({
+  uiServer: z.union([uiServerConfigSchema, z.array(uiServerConfigSchema)]),
+})
+
+export type Configuration = z.infer<typeof configurationSchema>
+export type UIServerConfig = z.infer<typeof uiServerConfigSchema>
+export type { UIServerConfig as UIServerConfigurationSection }
diff --git a/ui/common/src/constants.ts b/ui/common/src/constants.ts
new file mode 100644 (file)
index 0000000..2e5b197
--- /dev/null
@@ -0,0 +1 @@
+export const UI_WEBSOCKET_REQUEST_TIMEOUT_MS = 60_000
diff --git a/ui/common/src/index.ts b/ui/common/src/index.ts
new file mode 100644 (file)
index 0000000..2bdc27d
--- /dev/null
@@ -0,0 +1,8 @@
+export * from './client/types.js'
+export * from './client/WebSocketClient.js'
+export * from './config/schema.js'
+export * from './constants.js'
+export * from './types/JsonType.js'
+export * from './types/UIProtocol.js'
+export * from './types/UUID.js'
+export * from './utils/UUID.js'
diff --git a/ui/common/src/types/JsonType.ts b/ui/common/src/types/JsonType.ts
new file mode 100644 (file)
index 0000000..927f3c4
--- /dev/null
@@ -0,0 +1,5 @@
+export type JsonObject = { [K in string]?: JsonType }
+
+export type JsonPrimitive = boolean | null | number | string
+
+export type JsonType = JsonObject | JsonPrimitive | JsonType[]
diff --git a/ui/common/src/types/UIProtocol.ts b/ui/common/src/types/UIProtocol.ts
new file mode 100644 (file)
index 0000000..901573e
--- /dev/null
@@ -0,0 +1,86 @@
+import type { JsonObject } from './JsonType.js'
+import type { UUIDv4 } from './UUID.js'
+
+export enum AuthenticationType {
+  PROTOCOL_BASIC_AUTH = 'protocol-basic-auth',
+}
+
+export enum ProcedureName {
+  ADD_CHARGING_STATIONS = 'addChargingStations',
+  AUTHORIZE = 'authorize',
+  BOOT_NOTIFICATION = 'bootNotification',
+  CLOSE_CONNECTION = 'closeConnection',
+  DATA_TRANSFER = 'dataTransfer',
+  DELETE_CHARGING_STATIONS = 'deleteChargingStations',
+  DIAGNOSTICS_STATUS_NOTIFICATION = 'diagnosticsStatusNotification',
+  FIRMWARE_STATUS_NOTIFICATION = 'firmwareStatusNotification',
+  GET_15118_EV_CERTIFICATE = 'get15118EVCertificate',
+  GET_CERTIFICATE_STATUS = 'getCertificateStatus',
+  HEARTBEAT = 'heartbeat',
+  LIST_CHARGING_STATIONS = 'listChargingStations',
+  LIST_TEMPLATES = 'listTemplates',
+  LOCK_CONNECTOR = 'lockConnector',
+  LOG_STATUS_NOTIFICATION = 'logStatusNotification',
+  METER_VALUES = 'meterValues',
+  NOTIFY_CUSTOMER_INFORMATION = 'notifyCustomerInformation',
+  NOTIFY_REPORT = 'notifyReport',
+  OPEN_CONNECTION = 'openConnection',
+  PERFORMANCE_STATISTICS = 'performanceStatistics',
+  SECURITY_EVENT_NOTIFICATION = 'securityEventNotification',
+  SET_SUPERVISION_URL = 'setSupervisionUrl',
+  SIGN_CERTIFICATE = 'signCertificate',
+  SIMULATOR_STATE = 'simulatorState',
+  START_AUTOMATIC_TRANSACTION_GENERATOR = 'startAutomaticTransactionGenerator',
+  START_CHARGING_STATION = 'startChargingStation',
+  START_SIMULATOR = 'startSimulator',
+  START_TRANSACTION = 'startTransaction',
+  STATUS_NOTIFICATION = 'statusNotification',
+  STOP_AUTOMATIC_TRANSACTION_GENERATOR = 'stopAutomaticTransactionGenerator',
+  STOP_CHARGING_STATION = 'stopChargingStation',
+  STOP_SIMULATOR = 'stopSimulator',
+  STOP_TRANSACTION = 'stopTransaction',
+  TRANSACTION_EVENT = 'transactionEvent',
+  UNLOCK_CONNECTOR = 'unlockConnector',
+}
+
+export enum ProtocolVersion {
+  '0.0.1' = '0.0.1',
+}
+
+export enum ResponseStatus {
+  FAILURE = 'failure',
+  SUCCESS = 'success',
+}
+
+export enum ServerNotification {
+  REFRESH = 'refresh',
+}
+
+export interface BroadcastChannelResponsePayload extends JsonObject {
+  hashId: string | undefined
+  status: ResponseStatus
+}
+
+export type ProtocolNotification = [ServerNotification]
+
+export type ProtocolRequest = [UUIDv4, ProcedureName, RequestPayload]
+
+export type ProtocolRequestHandler = (
+  uuid?: UUIDv4,
+  procedureName?: ProcedureName,
+  payload?: RequestPayload
+) => Promise<ResponsePayload> | Promise<undefined> | ResponsePayload | undefined
+
+export type ProtocolResponse = [UUIDv4, ResponsePayload]
+
+export interface RequestPayload extends JsonObject {
+  connectorIds?: number[]
+  hashIds?: string[]
+}
+
+export interface ResponsePayload extends JsonObject {
+  hashIdsFailed?: string[]
+  hashIdsSucceeded?: string[]
+  responsesFailed?: BroadcastChannelResponsePayload[]
+  status: ResponseStatus
+}
diff --git a/ui/common/src/types/UUID.ts b/ui/common/src/types/UUID.ts
new file mode 100644 (file)
index 0000000..17d8563
--- /dev/null
@@ -0,0 +1 @@
+export type UUIDv4 = `${string}-${string}-4${string}-${string}-${string}`
diff --git a/ui/common/src/utils/UUID.ts b/ui/common/src/utils/UUID.ts
new file mode 100644 (file)
index 0000000..d623647
--- /dev/null
@@ -0,0 +1,13 @@
+import { randomUUID as cryptoRandomUUID } from 'node:crypto'
+
+import type { UUIDv4 } from '../types/UUID.js'
+
+const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
+
+export const randomUUID = (): UUIDv4 => {
+  return cryptoRandomUUID() as UUIDv4
+}
+
+export const validateUUID = (uuid: unknown): uuid is UUIDv4 => {
+  return typeof uuid === 'string' && UUID_V4_REGEX.test(uuid)
+}
diff --git a/ui/common/tests/UUID.test.ts b/ui/common/tests/UUID.test.ts
new file mode 100644 (file)
index 0000000..5dffbd9
--- /dev/null
@@ -0,0 +1,36 @@
+import assert from 'node:assert'
+import { describe, it } from 'node:test'
+
+import { randomUUID, validateUUID } from '../src/utils/UUID.js'
+
+await describe('UUID utilities', async () => {
+  await it('should generate a valid UUIDv4', () => {
+    const uuid = randomUUID()
+    assert.strictEqual(typeof uuid, 'string')
+    assert.ok(validateUUID(uuid), `Expected ${uuid} to be a valid UUIDv4`)
+  })
+
+  await it('should validate a correct UUIDv4', () => {
+    const valid = '550e8400-e29b-41d4-a716-446655440000'
+    assert.strictEqual(validateUUID(valid), true)
+  })
+
+  await it('should reject an invalid UUID', () => {
+    assert.strictEqual(validateUUID('not-a-uuid'), false)
+    assert.strictEqual(validateUUID(''), false)
+    assert.strictEqual(validateUUID('550e8400-e29b-31d4-a716-446655440000'), false) // v3 not v4
+  })
+
+  await it('should reject non-string values', () => {
+    assert.strictEqual(validateUUID(123), false)
+    assert.strictEqual(validateUUID(null), false)
+    assert.strictEqual(validateUUID(undefined), false)
+    assert.strictEqual(validateUUID({}), false)
+    assert.strictEqual(validateUUID(true), false)
+  })
+
+  await it('should generate unique UUIDs', () => {
+    const uuids = new Set(Array.from({ length: 100 }, () => randomUUID()))
+    assert.strictEqual(uuids.size, 100)
+  })
+})
diff --git a/ui/common/tests/WebSocketClient.test.ts b/ui/common/tests/WebSocketClient.test.ts
new file mode 100644 (file)
index 0000000..4ba5ae7
--- /dev/null
@@ -0,0 +1,357 @@
+import assert from 'node:assert'
+import { describe, it } from 'node:test'
+
+import type { WebSocketFactory, WebSocketLike } from '../src/client/types.js'
+import type { ResponsePayload } from '../src/types/UIProtocol.js'
+
+import { ServerFailureError, WebSocketClient } from '../src/client/WebSocketClient.js'
+import { AuthenticationType, ProcedureName, ResponseStatus } from '../src/types/UIProtocol.js'
+
+/**
+ * @returns Mock WebSocket with trigger methods for testing.
+ */
+function createMockWS (): WebSocketLike & {
+  sentMessages: string[]
+  triggerClose: () => void
+  triggerError: (message: string) => void
+  triggerMessage: (data: string) => void
+  triggerOpen: () => void
+} {
+  let oncloseFn: ((event: { code: number; reason: string }) => void) | null = null
+  let onerrorFn: ((event: { error: unknown; message: string }) => void) | null = null
+  let onmessageFn: ((event: { data: string }) => void) | null = null
+  let onopenFn: (() => void) | null = null
+  const sentMessages: string[] = []
+  let readyState: 0 | 1 | 2 | 3 = 1
+
+  return {
+    close () {
+      readyState = 3
+      oncloseFn?.({ code: 1000, reason: '' })
+    },
+    get onclose () {
+      return oncloseFn
+    },
+    set onclose (l: ((event: { code: number; reason: string }) => void) | null) {
+      oncloseFn = l
+    },
+    get onerror () {
+      return onerrorFn
+    },
+    set onerror (l: ((event: { error: unknown; message: string }) => void) | null) {
+      onerrorFn = l
+    },
+    get onmessage () {
+      return onmessageFn
+    },
+    set onmessage (l: ((event: { data: string }) => void) | null) {
+      onmessageFn = l
+    },
+    get onopen () {
+      return onopenFn
+    },
+    set onopen (l: (() => void) | null) {
+      onopenFn = l
+    },
+    get readyState () {
+      return readyState
+    },
+    send (data) {
+      sentMessages.push(data)
+    },
+    sentMessages,
+    triggerClose () {
+      readyState = 3
+      oncloseFn?.({ code: 1000, reason: '' })
+    },
+    triggerError (message) {
+      onerrorFn?.({ error: new Error(message), message })
+    },
+    triggerMessage (data) {
+      onmessageFn?.({ data })
+    },
+    triggerOpen () {
+      onopenFn?.()
+    },
+  }
+}
+
+await describe('WebSocketClient', async () => {
+  await it('should connect successfully', async () => {
+    const mockWs = createMockWS()
+    const factory: WebSocketFactory = () => mockWs
+    const client = new WebSocketClient(factory, {
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    mockWs.triggerOpen()
+    await connectPromise
+  })
+
+  await it('should build protocol-basic-auth credentials correctly', async () => {
+    const mockWs = createMockWS()
+    let capturedProtocols: string | string[] = ''
+    const factory: WebSocketFactory = (_url, protocols) => {
+      capturedProtocols = protocols
+      return mockWs
+    }
+    const client = new WebSocketClient(factory, {
+      authentication: {
+        enabled: true,
+        password: 'admin',
+        type: AuthenticationType.PROTOCOL_BASIC_AUTH,
+        username: 'admin',
+      },
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    mockWs.triggerOpen()
+    await connectPromise
+    assert.ok(Array.isArray(capturedProtocols))
+    assert.strictEqual(capturedProtocols[0], 'ui0.0.1')
+    assert.strictEqual(capturedProtocols[1], 'authorization.basic.YWRtaW46YWRtaW4')
+  })
+
+  await it('should send SRPC formatted request', async () => {
+    const mockWs = createMockWS()
+    const factory: WebSocketFactory = () => mockWs
+    const client = new WebSocketClient(factory, {
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    mockWs.triggerOpen()
+    await connectPromise
+
+    const requestPromise = client.sendRequest(ProcedureName.SIMULATOR_STATE, {})
+
+    assert.strictEqual(mockWs.sentMessages.length, 1)
+    const msg = JSON.parse(mockWs.sentMessages[0]) as unknown[]
+    assert.strictEqual(msg.length, 3)
+    assert.strictEqual(typeof msg[0], 'string')
+    assert.strictEqual(msg[1], ProcedureName.SIMULATOR_STATE)
+    assert.deepStrictEqual(msg[2], {})
+
+    const responsePayload: ResponsePayload = { status: ResponseStatus.SUCCESS }
+    mockWs.triggerMessage(JSON.stringify([msg[0], responsePayload]))
+    const result = await requestPromise
+    assert.strictEqual(result.status, ResponseStatus.SUCCESS)
+  })
+
+  await it('should correlate responses by UUID', async () => {
+    const mockWs = createMockWS()
+    const factory: WebSocketFactory = () => mockWs
+    const client = new WebSocketClient(factory, {
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    mockWs.triggerOpen()
+    await connectPromise
+
+    const p1 = client.sendRequest(ProcedureName.START_SIMULATOR, {})
+    const p2 = client.sendRequest(ProcedureName.STOP_SIMULATOR, {})
+
+    const uuid1 = (JSON.parse(mockWs.sentMessages[0]) as unknown[])[0] as string
+    const uuid2 = (JSON.parse(mockWs.sentMessages[1]) as unknown[])[0] as string
+    assert.notStrictEqual(uuid1, uuid2)
+
+    mockWs.triggerMessage(JSON.stringify([uuid2, { status: ResponseStatus.SUCCESS }]))
+    mockWs.triggerMessage(JSON.stringify([uuid1, { status: ResponseStatus.FAILURE }]))
+
+    const r2 = await p2
+    assert.strictEqual(r2.status, ResponseStatus.SUCCESS)
+    await assert.rejects(async () => {
+      await p1
+    })
+  })
+
+  await it('should reject with ServerFailureError containing the payload', async () => {
+    const mockWs = createMockWS()
+    const factory: WebSocketFactory = () => mockWs
+    const client = new WebSocketClient(factory, {
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    mockWs.triggerOpen()
+    await connectPromise
+
+    const request = client.sendRequest(ProcedureName.START_SIMULATOR, {})
+    const uuid = (JSON.parse(mockWs.sentMessages[0]) as unknown[])[0] as string
+    const failurePayload: ResponsePayload = {
+      hashIdsFailed: ['station-1', 'station-2'],
+      status: ResponseStatus.FAILURE,
+    }
+    mockWs.triggerMessage(JSON.stringify([uuid, failurePayload]))
+
+    await assert.rejects(
+      async () => {
+        await request
+      },
+      (error: unknown) => {
+        assert.ok(error instanceof ServerFailureError)
+        assert.ok(error instanceof Error)
+        assert.strictEqual(error.name, 'ServerFailureError')
+        assert.strictEqual(error.message, 'Server returned failure status: 2 station(s) failed')
+        assert.strictEqual(error.payload.status, ResponseStatus.FAILURE)
+        assert.deepStrictEqual(error.payload.hashIdsFailed, ['station-1', 'station-2'])
+        return true
+      }
+    )
+  })
+
+  await it('should handle connection errors', async () => {
+    const mockWs = createMockWS()
+    const factory: WebSocketFactory = () => mockWs
+    const client = new WebSocketClient(factory, {
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    mockWs.triggerError('Connection refused')
+    await assert.rejects(
+      async () => {
+        await connectPromise
+      },
+      { message: 'Connection refused' }
+    )
+  })
+
+  await it('should reject pending requests on disconnect', async () => {
+    const mockWs = createMockWS()
+    const factory: WebSocketFactory = () => mockWs
+    const client = new WebSocketClient(factory, {
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    mockWs.triggerOpen()
+    await connectPromise
+
+    const pendingRequest = client.sendRequest(ProcedureName.LIST_CHARGING_STATIONS, {})
+    client.disconnect()
+    await assert.rejects(async () => {
+      await pendingRequest
+    })
+  })
+
+  await it('should reject request when WebSocket is not open', async () => {
+    const mockWs = createMockWS()
+    const factory: WebSocketFactory = () => mockWs
+    const client = new WebSocketClient(factory, {
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    mockWs.triggerOpen()
+    await connectPromise
+
+    client.disconnect()
+    await assert.rejects(
+      async () => {
+        await client.sendRequest(ProcedureName.SIMULATOR_STATE, {})
+      },
+      { message: 'WebSocket is not open' }
+    )
+  })
+
+  await it('should build wss URL when secure is true', async () => {
+    const mockWs = createMockWS()
+    let capturedUrl = ''
+    const factory: WebSocketFactory = url => {
+      capturedUrl = url
+      return mockWs
+    }
+    const client = new WebSocketClient(factory, {
+      host: 'example.com',
+      port: 443,
+      protocol: 'ui',
+      secure: true,
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    mockWs.triggerOpen()
+    await connectPromise
+    assert.strictEqual(capturedUrl, 'wss://example.com:443')
+  })
+
+  await it('should ignore malformed messages', async () => {
+    const mockWs = createMockWS()
+    const factory: WebSocketFactory = () => mockWs
+    const client = new WebSocketClient(factory, {
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    mockWs.triggerOpen()
+    await connectPromise
+
+    mockWs.triggerMessage('not json')
+    mockWs.triggerMessage(JSON.stringify({ not: 'an array' }))
+    mockWs.triggerMessage(JSON.stringify([1, 2, 3]))
+    mockWs.triggerMessage(JSON.stringify(['not-a-uuid', {}]))
+  })
+
+  await it('should reject on malformed response payload with matching UUID', async () => {
+    const mockWs = createMockWS()
+    const factory: WebSocketFactory = () => mockWs
+    const client = new WebSocketClient(factory, {
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    mockWs.triggerOpen()
+    await connectPromise
+
+    const requestPromise = client.sendRequest(ProcedureName.SIMULATOR_STATE, {})
+    const uuid = (JSON.parse(mockWs.sentMessages[0]) as unknown[])[0] as string
+
+    mockWs.triggerMessage(JSON.stringify([uuid, null]))
+    await assert.rejects(async () => requestPromise, {
+      message: 'Server sent malformed response payload',
+    })
+  })
+
+  await it('should reject connect if socket closes before open', async () => {
+    const mockWs = createMockWS()
+    const factory: WebSocketFactory = () => mockWs
+    const client = new WebSocketClient(factory, {
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    const connectPromise = client.connect()
+    // Close without opening — simulates handshake rejection
+    mockWs.triggerClose()
+    await assert.rejects(
+      async () => {
+        await connectPromise
+      },
+      { message: 'WebSocket closed before connection established (code: 1000)' }
+    )
+  })
+})
diff --git a/ui/common/tests/config.test.ts b/ui/common/tests/config.test.ts
new file mode 100644 (file)
index 0000000..9946aab
--- /dev/null
@@ -0,0 +1,148 @@
+import assert from 'node:assert'
+import { describe, it } from 'node:test'
+
+import { configurationSchema, uiServerConfigSchema } from '../src/config/schema.js'
+
+await describe('config schema validation', async () => {
+  await it('should validate a minimal valid config', () => {
+    const result = uiServerConfigSchema.safeParse({
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    assert.strictEqual(result.success, true)
+  })
+
+  await it('should reject config with empty protocol', () => {
+    const result = uiServerConfigSchema.safeParse({
+      host: 'localhost',
+      port: 8080,
+      protocol: '',
+      version: '0.0.1',
+    })
+    assert.strictEqual(result.success, false)
+  })
+
+  await it('should reject missing required host field', () => {
+    const result = uiServerConfigSchema.safeParse({
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    assert.strictEqual(result.success, false)
+  })
+
+  await it('should reject invalid port number', () => {
+    const result = uiServerConfigSchema.safeParse({
+      host: 'localhost',
+      port: 99999,
+    })
+    assert.strictEqual(result.success, false)
+  })
+
+  await it('should reject empty host string', () => {
+    const result = uiServerConfigSchema.safeParse({
+      host: '',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    assert.strictEqual(result.success, false)
+  })
+
+  await it('should validate full config with authentication', () => {
+    const result = uiServerConfigSchema.safeParse({
+      authentication: {
+        enabled: true,
+        password: 'admin',
+        type: 'protocol-basic-auth',
+        username: 'admin',
+      },
+      host: 'simulator.example.com',
+      name: 'My Simulator',
+      port: 8080,
+      protocol: 'ui',
+      secure: true,
+      version: '0.0.1',
+    })
+    assert.strictEqual(result.success, true)
+  })
+
+  await it('should validate configuration with array of servers', () => {
+    const result = configurationSchema.safeParse({
+      uiServer: [
+        { host: 'server1.example.com', port: 8080, protocol: 'ui', version: '0.0.1' },
+        { host: 'server2.example.com', port: 8080, protocol: 'ui', version: '0.0.1' },
+      ],
+    })
+    assert.strictEqual(result.success, true)
+  })
+
+  await it('should validate configuration with single server', () => {
+    const result = configurationSchema.safeParse({
+      uiServer: { host: 'localhost', port: 8080, protocol: 'ui', version: '0.0.1' },
+    })
+    assert.strictEqual(result.success, true)
+  })
+
+  await it('should reject auth config when enabled with protocol-basic-auth but missing credentials', () => {
+    const result = uiServerConfigSchema.safeParse({
+      authentication: {
+        enabled: true,
+        type: 'protocol-basic-auth',
+      },
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    assert.strictEqual(result.success, false)
+  })
+
+  await it('should reject auth config when enabled with protocol-basic-auth but empty username', () => {
+    const result = uiServerConfigSchema.safeParse({
+      authentication: {
+        enabled: true,
+        password: 'admin',
+        type: 'protocol-basic-auth',
+        username: '',
+      },
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    assert.strictEqual(result.success, false)
+  })
+
+  await it('should accept auth config when enabled with protocol-basic-auth and credentials present', () => {
+    const result = uiServerConfigSchema.safeParse({
+      authentication: {
+        enabled: true,
+        password: 'admin',
+        type: 'protocol-basic-auth',
+        username: 'admin',
+      },
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    assert.strictEqual(result.success, true)
+  })
+
+  await it('should accept auth config when disabled with protocol-basic-auth and no credentials', () => {
+    const result = uiServerConfigSchema.safeParse({
+      authentication: {
+        enabled: false,
+        type: 'protocol-basic-auth',
+      },
+      host: 'localhost',
+      port: 8080,
+      protocol: 'ui',
+      version: '0.0.1',
+    })
+    assert.strictEqual(result.success, true)
+  })
+})
diff --git a/ui/common/tsconfig.json b/ui/common/tsconfig.json
new file mode 100644 (file)
index 0000000..5701aa0
--- /dev/null
@@ -0,0 +1,18 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "compilerOptions": {
+    "target": "ESNext",
+    "module": "NodeNext",
+    "lib": ["ESNext"],
+    "types": ["node"],
+    "removeComments": true,
+    "strict": true,
+    "moduleResolution": "NodeNext",
+    "resolveJsonModule": true,
+    "allowSyntheticDefaultImports": true,
+    "verbatimModuleSyntax": true,
+    "forceConsistentCasingInFileNames": true,
+    "noImplicitOverride": true
+  },
+  "include": ["src/**/*.ts", "tests/**/*.ts"]
+}
index a1eb1128c2019b2795478ff66751740baf2bd887..0db84acf2ec75cf6e896f5d7542ebb6842652e61 100644 (file)
@@ -73,7 +73,9 @@ export interface RequestPayload extends JsonObject {
 }
 
 export interface ResponsePayload extends JsonObject {
-  hashIds?: string[]
+  hashIdsFailed?: string[]
+  hashIdsSucceeded?: string[]
+  responsesFailed?: JsonObject[]
   status: ResponseStatus
 }