"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"]
{
"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 }
]
}
{
".": "4.4.0",
+ "ui/common": "4.4.0",
+ "ui/cli": "4.4.0",
"ui/web": "4.4.0",
"tests/ocpp-server": "4.4.0"
}
- 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
else
echo "defined=false" >> $GITHUB_OUTPUT;
fi
+
build-ocpp-server:
strategy:
matrix:
- 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:
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:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+
build-simulator-docker-image:
runs-on: ubuntu-latest
name: Build simulator docker image
run: |
cd docker
make SUBMODULES_INIT=false
+
build-dashboard-docker-image:
runs-on: ubuntu-latest
defaults:
dist
outputs
.nyc_output
+ui/common
+ui/cli
ui/web
pnpm-lock.yaml
package-lock.json
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:
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'}
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'}
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'}
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==}
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'}
is-interactive@1.0.0: {}
+ is-interactive@2.0.0: {}
+
is-map@2.0.3: {}
is-negative-zero@2.0.3: {}
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:
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
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:
std-env@4.0.0: {}
+ stdin-discarder@0.2.2: {}
+
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
packages:
- ./
+ - ./ui/common
+ - ./ui/cli
- ./ui/web
overrides:
--- /dev/null
+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
--- /dev/null
+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',
+}
--- /dev/null
+auto-install-peers=true
+legacy-peer-deps=true
--- /dev/null
+coverage
+dist
+pnpm-lock.yaml
--- /dev/null
+{
+ "printWidth": 100,
+ "arrowParens": "avoid",
+ "singleQuote": true,
+ "semi": false,
+ "trailingComma": "es5"
+}
--- /dev/null
+# 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) |
--- /dev/null
+#!/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"
--- /dev/null
+{
+ "$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"
+ }
+}
--- /dev/null
+/* 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,
+})
--- /dev/null
+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
--- /dev/null
+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)
--- /dev/null
+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
+ }
+ }
+}
--- /dev/null
+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)
+ })
+}
--- /dev/null
+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
+ }
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
--- /dev/null
+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)
+}
--- /dev/null
+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,
+ }
+}
--- /dev/null
+import chalk from 'chalk'
+import process from 'node:process'
+
+export const printError = (message: string): void => {
+ process.stderr.write(chalk.red(`✗ ${message}\n`))
+}
--- /dev/null
+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'
+ )
+}
--- /dev/null
+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`))
+ }
+}
--- /dev/null
+export interface GlobalOptions {
+ config?: string
+ json: boolean
+ url?: string
+}
--- /dev/null
+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')
+ })
+})
--- /dev/null
+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)
+ })
+})
--- /dev/null
+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)
+ })
+})
--- /dev/null
+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'))
+ })
+})
--- /dev/null
+{
+ "$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"]
+}
--- /dev/null
+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
--- /dev/null
+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',
+}
--- /dev/null
+auto-install-peers=true
+legacy-peer-deps=true
--- /dev/null
+coverage
+dist
+pnpm-lock.yaml
--- /dev/null
+{
+ "printWidth": 100,
+ "arrowParens": "avoid",
+ "singleQuote": true,
+ "semi": false,
+ "trailingComma": "es5"
+}
--- /dev/null
+# 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 |
--- /dev/null
+{
+ "$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"
+ }
+}
--- /dev/null
+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
--- /dev/null
+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))
+ }
+ }
+}
--- /dev/null
+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
+}
--- /dev/null
+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 }
--- /dev/null
+export const UI_WEBSOCKET_REQUEST_TIMEOUT_MS = 60_000
--- /dev/null
+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'
--- /dev/null
+export type JsonObject = { [K in string]?: JsonType }
+
+export type JsonPrimitive = boolean | null | number | string
+
+export type JsonType = JsonObject | JsonPrimitive | JsonType[]
--- /dev/null
+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
+}
--- /dev/null
+export type UUIDv4 = `${string}-${string}-4${string}-${string}-${string}`
--- /dev/null
+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)
+}
--- /dev/null
+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)
+ })
+})
--- /dev/null
+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)' }
+ )
+ })
+})
--- /dev/null
+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)
+ })
+})
--- /dev/null
+{
+ "$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"]
+}
}
export interface ResponsePayload extends JsonObject {
- hashIds?: string[]
+ hashIdsFailed?: string[]
+ hashIdsSucceeded?: string[]
+ responsesFailed?: JsonObject[]
status: ResponseStatus
}