From 8fd32d22c9e6f4fe3c63878b488f1eef67aab19d Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Tue, 24 Mar 2026 00:59:59 +0100 Subject: [PATCH] feat(ui-server): add MCP transport and deprecate HTTP (#1746) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(types): add MCP application protocol type * chore(deps): add @modelcontextprotocol/sdk and zod * refactor(ui-server): deprecate UIHttpServer in favor of MCP transport * feat(ui-server): add MCP tool zod schemas for all procedures * feat(ui-server): add UIMCPServer transport and MCP resource handlers * feat(ui-server): integrate UIMCPServer into UIServerFactory * test(ui-server): add MCP factory integration tests * test(ui-server): add UIMCPServer unit tests * test(ui-server): add MCP endpoint integration tests and fix lint * refactor(ui-server): extract HttpMethod enum to shared UIServerUtils * docs: restructure UI Protocol section — MCP first, remove redundant API listing * fix(ui-server): address PR review — security hardening and schema corrections - Send 404 + destroy for non-/mcp requests instead of hanging socket - Add rate limiting (429) matching UIHttpServer behavior - Add body size limit (createBodySizeLimiter) to prevent DoS - Register res.on('close') before handleRequest to prevent race - Return 400 for JSON parse errors, 500 for server errors - Fix setSupervisionUrl schema: supervisionUrl → url - Fix stopTransaction schema: transactionIds string[] → transactionId number * refactor(tests): use HttpMethod enum instead of string literals * docs: remove redundant MCP discoverability statement * docs: restructure MCP section for agent config, update uiServer type in config table * feat(ui-server): log deprecation warning when HTTP transport is used * fix(ui-server): harmonize log levels and message patterns with existing transports * test(ui-server): align MCP tests with style guide - Use createLoggerMocks() for log assertions instead of trivial checks - Add server.stop() to factory fallback tests to prevent resource leaks - Replace fragile globalThis.clearTimeout override with t.mock.method * docs: restore PDU acronym definition in WebSocket Protocol section * fix(ui-server): use UI protocol version for MCP server version * fix(ui-server): derive log file paths from Configuration instead of hardcoding * perf(ui-server): tail-read log files instead of loading entire file into memory * refactor(ui-server): convert log resources to tools with tail parameter Follow MCP best practices: resources are for static snapshots, tools are for parameterized on-demand data. - Replace log://combined and log://error resources with readCombinedLog and readErrorLog tools - Add tail parameter (default 200, max 5000) following the filesystem server head/tail pattern - Add readOnlyHint annotation per MCP tool spec - Return totalLines metadata in response for context - Keep station and template resources (small, static data) * feat(ui-server): expose OCPP JSON schemas as MCP resource templates Add schema://ocpp/{version} listing and schema://ocpp/{version}/{command} resource templates that serve the existing JSON schema files from src/assets/json-schemas/ocpp/ on demand. LLM agents can discover available OCPP commands per version and read the full JSON schema for any command without bloating tools/list. Schemas are read from disk on demand — no memory duplication with the AJV validators already loaded by OCPP services. * feat(ui-server): add version-aware OCPP tool schemas with JSON Schema injection - Add ocppSchemaMapping linking ProcedureName to JSON Schema files - 16 OCPP tools now have ocpp16Payload/ocpp20Payload fields with version affinity in descriptions - Intercept tools/list to inject full JSON Schemas from disk into inputSchema.properties (no Zod duplication, DRY) - Add payload flattening: extract versioned payload, spread flat - Add mutual exclusivity: reject if both payloads provided - Add pre-flight version check: clear error on version mismatch - Runtime guard: graceful fallback if SDK internal API changes * refactor(ui-server): use OCPPVersion enum instead of string literals * refactor(ui-server): improve MCP code elegance and project rule compliance - Replace as-unknown-as cast with Reflect.get() for SDK handler access - Replace generic Error with BaseError in stop/readRequestBody - Extract createToolErrorResponse/createToolResponse static helpers (DRY) - Extract closeTransportSafely for duplicate transport cleanup - Extract registerLogReadTool factory for duplicate log tools (DRY) - Remove unnecessary String() conversions in resource handlers * fix(ui-server): fix body size double-counting and schema resource path - readRequestBody: pass chunk.length (not accumulated total) to createBodySizeLimiter — matches UIHttpServer pattern - getSchemaBaseDir: use correct relative path instead of broken resolve().includes('assets') condition (always true) * refactor(ui-server): consolidate version fallback guards and clarify README version docs * fix(ui-server): harden MCP schema resources against path traversal and fix transport leak - Add path traversal guard in schema resource handlers to validate resolved paths stay within the OCPP schema base directory - Close MCP transport on 405 Method Not Allowed responses to prevent leak - Fix log file path to use direct path when rotation is disabled - Fix misleading Heartbeat tool description that referenced unsupported ocpp payload fields * fix(ui-server): address remaining review findings and lint warnings - Reorder stop() to delete-before-reject preventing re-entrant issues - Use ephemeral port in integration test instead of fixed port 18999 - Document synchronous authenticate() assumption and SDK pattern deviation - Add 'modelcontextprotocol' to cspell wordlist - Convert JSDoc to inline comment to fix jsdoc lint warnings * fix(ui-server): exact MCP path match, pin SDK version, log schema injection - Use exact pathname match (url.pathname !== '/mcp') instead of startsWith to prevent unintended paths from reaching the MCP handler - Pin @modelcontextprotocol/sdk to ~1.27.1 (patch-only) since the OCPP schema injection relies on SDK internals pinned to 1.27.x - Add startup log confirming successful OCPP JSON schema injection * test(ui-server): add comprehensive UIMCPServer unit tests Cover private method logic missing from initial test suite: - invokeProcedure: service null, dual payload, version mismatch, direct response, service error, 30s timeout (mock timers) - checkVersionCompatibility: OCPP 1.6/2.0/2.0.1 matching, cross-version errors, hashIds filtering, all-stations fallback - readRequestBody: valid JSON, payload too large, invalid JSON, stream error - loadOcppSchemas: disk loading, cache entry validation Refactor for elegance and guide compliance: - Centralize Reflect.get wrappers in TestableUIMCPServer class - Extract assertToolError() helper to eliminate repeated triplet - Move createMockChargingStationDataWithVersion() to UIServerTestUtils - Replace magic number with DEFAULT_MAX_PAYLOAD_SIZE import * fix(ui-server): per-request McpServer factory, async iterator body reader, transport leak - Create a new McpServer per HTTP request via createMcpServer() factory to fix concurrency bug: McpServer.connect() overwrites a single internal _transport field, causing cross-talk under concurrent requests. OCPP schema cache and UI service are pre-loaded at startup; tool registration is ~12µs per request (negligible). - Replace event-listener readRequestBody with for-await-of async iterator pattern. Single settlement guaranteed by language semantics, eliminating the double-reject bug on payload-too-large. - Close transport on mcpServer.connect() failure to prevent resource leak. - Clean up both transport and McpServer on response close via unified cleanup callback. * fix: align StatusNotification field name with OCPP 2.0.1 schema, fix asset paths - Use 'connectorStatus' (OCPP 2.0.1 schema field name) instead of 'status' (non-standard) in OCPP20RequestService.buildRequestPayload and both call sites in OCPP20IncomingRequestService - Fix esbuild-relative asset paths for OCPP JSON schemas and MCP resource handlers — import.meta.url resolves to dist/start.js, not the original source tree depth * refactor(ocpp): align payload builders to object-param API, fix StatusNotification field names - Refactor buildStatusNotificationRequest from positional params to object-based StatusNotificationRequest input. Builder accepts both 'status' (internal/OCPP 1.6) and 'connectorStatus' (OCPP 2.0.1) with consistent fallback order (connectorStatus ?? status). - Refactor buildTransactionEvent from 3 overloads to single object-based OCPP20TransactionEventRequest input, removing positional param complexity and non-null assertions. - Refactor sendAndSetConnectorStatus from positional params to object-based StatusNotificationRequest input. Callers pass version-correct field names: 'status' in OCPP 1.6 code, 'connectorStatus' in OCPP 2.0 code, either in common code. - Extract getSchemaBaseDir() as protected method on UIMCPServer for test overridability, replacing fragile symlink hack. - Migrate all call sites (28 sendAndSetConnectorStatus, 60+ buildTransactionEvent) and their corresponding tests. * build: externalize @modelcontextprotocol/sdk and zod from esbuild bundle Add @modelcontextprotocol/* and zod to esbuild external list for consistency with all other production dependencies (ajv, ws, winston, etc.) which are already externalized. Reduces bundle size and avoids bundling the SDK's transitive dependency tree. * fix(ui-server): fix SonarCloud quality gate — localeCompare sort, reduce cognitive complexity - Add localeCompare to schema file sort in MCPResourceHandlers to ensure locale-aware alphabetical ordering (SonarCloud bug) - Extract sendErrorResponse helper from handleMcpRequest to reduce cognitive complexity from 16 to below threshold (SonarCloud smell) * chore: update .serena/project.yml with new default config fields * refactor(ocpp): buildMeterValue resolves connectorId/evseId from transactionId - Refactor buildMeterValue signature from (station, connectorId, transactionId, interval) to (station, transactionId, interval). connectorId and evseId are resolved internally from the unique transactionId via getConnectorIdByTransactionId/getEvseIdByTransactionId. - Refactor getSampledValueTemplate to accept optional evseId. When provided, EVSE-level MeterValues templates take priority; falls back to merging connector-level templates from all EVSE connectors. - Add MeterValues field to EvseStatus and EvseTemplate types to support EVSE-level meter value template definitions. - Refactor handleMeterValues in BroadcastChannel from if/else to switch/case on OCPP version with explicit default error. - Simplify OCPP20IncomingRequestService fallback MeterValues (no transaction = static empty meter value, skip buildMeterValue). - Add buildMeterValue unit tests covering OCPP 1.6/2.0 resolution, unknown transactionId throw, EVSE-level template priority, and connector template merge fallback. - Add TEST_TRANSACTION_ID_STRING constant for OCPP 2.0 tests, fix BroadcastChannel test to use string transactionId for OCPP 2.0.1. * docs: add MCP Protocol to ToC, fix HTTP Protocol link, document EVSE-level MeterValues - Add MCP Protocol entry to table of contents - Fix HTTP Protocol anchor to match 'deprecated' heading - Document EVSE-level MeterValues templates in Evses section examples with priority explanation (EVSE-level overrides connector-level) * fix(ocpp): version-aware Authorize response mapping, complete cross-version types - Fix commandResponseToResponseStatus for Authorize: use switch/case on OCPP version. OCPP 1.6 checks idTagInfo.status, OCPP 2.0 checks idTokenInfo.status. Previously only checked idTagInfo (1.6 field), causing MCP to report 'failure' for successful OCPP 2.0 authorizations. - Make AuthorizeResponse and AuthorizeRequest cross-version union types (OCPP16 | OCPP20) consistent with AuthorizationStatus pattern. - Make isIdTagRemoteAuthorized version-aware: sends idTag for OCPP 1.6, idToken for OCPP 2.0, and checks the version-correct response field. - Use version-specific casts (OCPP16AuthorizeResponse, OCPP20AuthorizeResponse) in version-switched code paths instead of agnostic union type. * refactor(ocpp): harmonize version switch/case in auth functions - Refactor isIdTagAuthorizedUnified and isIdTagAuthorized from if/else on OCPP version to switch/case with explicit VERSION_16, VERSION_20/201 cases and separate default fallback. - Remove unnecessary optional chaining on stationInfo inside version-switched cases where stationInfo is guaranteed non-null. - Separate VERSION_16 from default in all auth switch/case blocks for spec-compliant version handling. * fix(ocpp): buildMeterValue from transactionId, log date locale fix, MeterValues EVSE templates - Refactor buildMeterValue to resolve connectorId/evseId from transactionId internally. Returns empty MeterValue when transactionId is undefined (MCP pass-through with provided meterValue). - Fix log file date resolution: use local date instead of UTC to match Winston DailyRotateFile naming convention. - Add optional date parameter to readCombinedLog/readErrorLog MCP tools for accessing rotated log files by date (YYYY-MM-DD). - Add MeterValues field to EvseStatus/EvseTemplate types for EVSE-level meter value template definitions. getSampledValueTemplate checks EVSE-level templates first, falls back to merging connector templates. - Refactor handleMeterValues to switch/case on OCPP version with explicit default error. - Make AuthorizeRequest/AuthorizeResponse cross-version union types. Fix commandResponseToResponseStatus and isIdTagRemoteAuthorized with version-aware switch/case for OCPP 1.6 (idTagInfo) vs 2.0 (idTokenInfo). - Add integration tests for readCombinedLog tool (default date, explicit date, missing file error). Add buildMeterValue unit tests. --- .serena/project.yml | 14 + README.md | 78 +- eslint.config.js | 4 + package.json | 4 +- pnpm-lock.yaml | 324 +++++++- scripts/bundle.js | 2 + src/charging-station/ChargingStation.ts | 45 +- .../ChargingStationWorkerBroadcastChannel.ts | 144 ++-- .../ocpp/1.6/OCPP16IncomingRequestService.ts | 44 +- .../ocpp/1.6/OCPP16RequestService.ts | 15 +- .../ocpp/1.6/OCPP16ResponseService.ts | 26 +- .../ocpp/1.6/OCPP16ServiceUtils.ts | 17 +- .../ocpp/2.0/OCPP20IncomingRequestService.ts | 68 +- .../ocpp/2.0/OCPP20RequestService.ts | 12 +- .../ocpp/2.0/OCPP20ResponseService.ts | 8 +- .../ocpp/2.0/OCPP20ServiceUtils.ts | 102 +-- src/charging-station/ocpp/OCPPServiceUtils.ts | 334 ++++---- .../ui-server/UIHttpServer.ts | 17 +- src/charging-station/ui-server/UIMCPServer.ts | 482 ++++++++++++ .../ui-server/UIServerFactory.ts | 21 +- .../ui-server/UIServerUtils.ts | 8 + .../ui-server/mcp/MCPResourceHandlers.ts | 309 ++++++++ .../ui-server/mcp/MCPToolSchemas.ts | 414 ++++++++++ src/charging-station/ui-server/mcp/index.ts | 7 + src/types/Evse.ts | 3 + src/types/UIProtocol.ts | 1 + src/types/ocpp/Transaction.ts | 6 +- .../ChargingStationTestConstants.ts | 1 + ...rgingStationWorkerBroadcastChannel.test.ts | 2 + ...omingRequestService-TriggerMessage.test.ts | 4 +- .../OCPP20RequestService-CallChain.test.ts | 4 +- ...0RequestService-StatusNotification.test.ts | 43 +- ...CPP20ServiceUtils-TransactionEvent.test.ts | 740 ++++++++---------- .../OCPPServiceUtils-connectorStatus.test.ts | 51 +- .../ocpp/OCPPServiceUtils-meterValues.test.ts | 153 ++++ .../ui-server/UIHttpServer.test.ts | 2 + .../ui-server/UIMCPServer.integration.test.ts | 207 +++++ .../ui-server/UIMCPServer.test.ts | 683 ++++++++++++++++ .../ui-server/UIServerFactory.test.ts | 63 ++ .../ui-server/UIServerTestUtils.ts | 27 +- 40 files changed, 3640 insertions(+), 849 deletions(-) create mode 100644 src/charging-station/ui-server/UIMCPServer.ts create mode 100644 src/charging-station/ui-server/mcp/MCPResourceHandlers.ts create mode 100644 src/charging-station/ui-server/mcp/MCPToolSchemas.ts create mode 100644 src/charging-station/ui-server/mcp/index.ts create mode 100644 tests/charging-station/ocpp/OCPPServiceUtils-meterValues.test.ts create mode 100644 tests/charging-station/ui-server/UIMCPServer.integration.test.ts create mode 100644 tests/charging-station/ui-server/UIMCPServer.test.ts create mode 100644 tests/charging-station/ui-server/UIServerFactory.test.ts diff --git a/.serena/project.yml b/.serena/project.yml index 7c0ff4d8..4c098ef2 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -134,3 +134,17 @@ read_only_memory_patterns: [] # Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) # This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. line_ending: + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} diff --git a/README.md b/README.md index dd189303..a0ae13af 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,9 @@ Simple [node.js](https://nodejs.org/) software to simulate and scale a set of ch - [Version 1.6](#version-16-1) - [Version 2.0.x](#version-20x-1) - [UI Protocol](#ui-protocol) + - [MCP Protocol](#mcp-protocol-model-context-protocol) - [WebSocket Protocol](#websocket-protocol) - - [HTTP Protocol](#http-protocol) + - [HTTP Protocol (deprecated)](#http-protocol-deprecated) - [Support, Feedback, Contributing](#support-feedback-contributing) - [Code of Conduct](#code-of-conduct) - [Licensing](#licensing) @@ -173,7 +174,7 @@ But the modifications to test have to be done to the files in the build target d | supervisionUrlDistribution | round-robin/random/charging-station-affinity | charging-station-affinity | string | supervision urls distribution policy to simulated charging stations | | log | | {
"enabled": true,
"file": "logs/combined.log",
"errorFile": "logs/error.log",
"statisticsInterval": 60,
"level": "info",
"console": false,
"format": "simple",
"rotate": true
} | {
enabled?: boolean;
file?: string;
errorFile?: string;
statisticsInterval?: number;
level?: string;
console?: boolean;
format?: string;
rotate?: boolean;
maxFiles?: string \| number;
maxSize?: string \| number;
} | Log configuration section:
- _enabled_: enable logging
- _file_: log file relative path
- _errorFile_: error log file relative path
- _statisticsInterval_: seconds between charging stations statistics output in the logs
- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level
- _console_: output logs on the console
- _format_: [winston](https://github.com/winstonjs/winston) log format
- _rotate_: enable daily log files rotation
- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options
- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options | | worker | | {
"processType": "workerSet",
"startDelay": 500,
"elementAddDelay": 0,
"elementsPerWorker": 'auto',
"poolMinSize": 4,
"poolMaxSize": 16
} | {
processType?: WorkerProcessType;
startDelay?: number;
elementAddDelay?: number;
elementsPerWorker?: number \| 'auto' \| 'all';
poolMinSize?: number;
poolMaxSize?: number;
resourceLimits?: ResourceLimits;
} | Worker configuration section:
- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)
- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)
- _elementAddDelay_: milliseconds to wait between charging station add
- _elementsPerWorker_: number of charging stations per worker threads for the `workerSet` process type (`auto` means (number of stations) / (number of CPUs) \* 1.5 if (number of stations) > (number of CPUs), otherwise 1; `all` means a unique worker will run all charging stations)
- _poolMinSize_: worker threads pool minimum number of threads
- _poolMaxSize_: worker threads pool maximum number of threads
- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option | -| uiServer | | {
"enabled": false,
"type": "ws",
"version": "1.1",
"options": {
"host": "localhost",
"port": 8080
}
} | {
enabled?: boolean;
type?: ApplicationProtocol;
version?: ApplicationProtocolVersion;
options?: ServerOptions;
authentication?: {
enabled: boolean;
type: AuthenticationType;
username?: string;
password?: string;
}
} | UI server configuration section:
- _enabled_: enable UI server
- _type_: 'http' or 'ws'
- _version_: HTTP version '1.1' or '2.0'
- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)
- _authentication_: authentication type configuration section | +| uiServer | | {
"enabled": false,
"type": "ws",
"version": "1.1",
"options": {
"host": "localhost",
"port": 8080
}
} | {
enabled?: boolean;
type?: ApplicationProtocol;
version?: ApplicationProtocolVersion;
options?: ServerOptions;
authentication?: {
enabled: boolean;
type: AuthenticationType;
username?: string;
password?: string;
}
} | UI server configuration section:
- _enabled_: enable UI server
- _type_: 'ws', 'mcp' or 'http' (deprecated)
- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')
- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)
- _authentication_: authentication type configuration section | | performanceStorage | | {
"enabled": true,
"type": "none",
} | {
enabled?: boolean;
type?: string;
uri?: string;
} | Performance storage configuration section:
- _enabled_: enable performance storage
- _type_: 'jsonfile', 'mongodb' or 'none'
- _uri_: storage URI | | stationTemplateUrls | | {}[] | {
file: string;
numberOfStations: number;
provisionedNumberOfStations?: number;
}[] | array of charging station templates URIs configuration section:
- _file_: charging station configuration template file relative path
- _numberOfStations_: template number of stations at startup
- _provisionedNumberOfStations_: template provisioned number of stations after startup | @@ -350,6 +351,50 @@ type AutomaticTransactionGeneratorConfiguration = { #### Evses section syntax example +`MeterValues` can be defined at EVSE level or at connector level. EVSE-level definitions apply to all connectors of the EVSE and override connector-level definitions. + +##### MeterValues at EVSE level + +```json + "Evses": { + "0": { + "Connectors": { + "0": {} + } + }, + "1": { + "MeterValues": [ + ... + { + "unit": "W", + "measurand": "Power.Active.Import", + "phase": "L1-N", + "value": "5000", + "fluctuationPercent": "10" + }, + ... + { + "unit": "A", + "measurand": "Current.Import", + "minimum": "0.5" + }, + ... + { + "unit": "Wh" + }, + ... + ], + "Connectors": { + "1": { + "bootStatus": "Available" + } + } + } + }, +``` + +##### MeterValues at connector level + ```json "Evses": { "0": { @@ -636,19 +681,32 @@ All kind of OCPP parameters are supported in charging station configuration or c ## UI Protocol -Protocol to control the simulator via a WebSocket or HTTP server: +Protocol to control the simulator via the UI server. Three transport types are available: + +### MCP Protocol (Model Context Protocol) + +The recommended transport for programmatic access. [MCP](https://spec.modelcontextprotocol.io) enables LLM agents and AI tools to discover and use the simulator's capabilities automatically. + +#### Agent configuration + +| Parameter | Value | Description | +| ---------------- | -------------------------- | ------------------------------------------------------------------------------------- | +| URL | `http://:/mcp` | Streamable HTTP endpoint (stateless) | +| Transport | Streamable HTTP | `POST /mcp` for requests, `GET /mcp` for SSE stream, `DELETE /mcp` for session close | +| Authentication | Basic Auth (optional) | If enabled in simulator config, use `Authorization: Basic ` header | +| Protocol version | `2025-03-26` | MCP specification version | + +### WebSocket Protocol + +SRPC protocol over WebSocket for real-time dashboard communication. PDU stands for 'Protocol Data Unit'. ```mermaid sequenceDiagram Client->>UI Server: request UI Server->>Client: response -Note over UI Server,Client: HTTP or WebSocket +Note over UI Server,Client: WebSocket ``` -### WebSocket Protocol - -SRPC protocol over WebSocket. PDU stands for 'Protocol Data Unit'. - - Request: [`uuid`, `ProcedureName`, `PDU`] `uuid`: String uniquely representing this request @@ -1007,7 +1065,9 @@ Examples: `responsesFailed`: failed responses payload array (optional) } -### HTTP Protocol +### HTTP Protocol (deprecated) + +> **Deprecated**: Use `"type": "mcp"` for HTTP-based access to the simulator. To learn how to use the HTTP protocol to pilot the simulator, an [Insomnia](https://insomnia.rest/) HTTP requests collection is available in [src/assets/ui-protocol](./src/assets/ui-protocol) directory. diff --git a/eslint.config.js b/eslint.config.js index 69be31d7..43fcfd7a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -92,6 +92,10 @@ export default defineConfig([ 'reservability', // VPN protocol acronyms 'PPTP', + // UI server protocol acronyms + 'UIMCP', + 'Streamable', + 'modelcontextprotocol', ], }, }, diff --git a/package.json b/package.json index 02ea2736..8f7fb9f6 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@mikro-orm/core": "^6.6.9", "@mikro-orm/mariadb": "^6.6.9", "@mikro-orm/reflection": "^6.6.9", + "@modelcontextprotocol/sdk": "~1.27.1", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", "basic-ftp": "^5.2.0", @@ -92,7 +93,8 @@ "tar": "^7.5.12", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", - "ws": "^8.20.0" + "ws": "^8.20.0", + "zod": "^4.3.6" }, "optionalDependencies": { "bufferutil": "^4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a99d676..d406ba4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@mikro-orm/reflection': specifier: ^6.6.9 version: 6.6.9(@mikro-orm/core@6.6.9) + '@modelcontextprotocol/sdk': + specifier: ~1.27.1 + version: 1.27.1(zod@4.3.6) ajv: specifier: ^8.18.0 version: 8.18.0 @@ -75,6 +78,9 @@ importers: ws: specifier: ^8.20.0 version: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@commitlint/cli': specifier: ^20.5.0 @@ -988,6 +994,12 @@ packages: '@noble/hashes': optional: true + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1083,6 +1095,16 @@ packages: peerDependencies: '@mikro-orm/core': ^6.0.0 + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@mongodb-js/saslprep@1.4.6': resolution: {integrity: sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==} @@ -1557,6 +1579,10 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1838,6 +1864,10 @@ packages: bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -1934,6 +1964,10 @@ packages: resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==} engines: {node: '>=20'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cacheable-lookup@7.0.0: resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} engines: {node: '>=14.16'} @@ -2198,6 +2232,14 @@ packages: constants-browserify@1.0.0: resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + conventional-changelog-angular@8.3.0: resolution: {integrity: sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==} engines: {node: '>=18'} @@ -2220,6 +2262,14 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} @@ -2229,6 +2279,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cosmiconfig-typescript-loader@6.2.0: resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==} engines: {node: '>=v18'} @@ -2897,6 +2951,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + evp_bytestokey@1.0.3: resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} @@ -2915,6 +2977,16 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -3044,6 +3116,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3308,6 +3384,10 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + hono@4.12.8: + resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -3378,6 +3458,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3462,6 +3546,14 @@ packages: iota-array@1.0.0: resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-any-array@2.0.1: resolution: {integrity: sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==} @@ -3606,6 +3698,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} @@ -3710,6 +3805,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} engines: {node: '>=14'} @@ -3765,6 +3863,9 @@ packages: json-schema-typed@7.0.3: resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -4080,6 +4181,10 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} @@ -4087,6 +4192,10 @@ packages: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-source-map@1.0.4: resolution: {integrity: sha512-PGSmS0kfnTnMJCzJ16BLLCEe6oeYCamKFFdQKshi4BmM6FUwipjVOcBFGxqtQtirtAG4iZvHlqST9CpZKqlRjA==} @@ -4330,6 +4439,10 @@ packages: ndarray@1.0.19: resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neostandard@0.13.0: resolution: {integrity: sha512-R3iglFr+Dla/8qFBqsMxBvcYBOgP6rAGw7uRHKMpM3bUP0wLDRzUstxtEI9RfEwn7xszE/UUnh8H090Ru4Z52A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -4615,6 +4728,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -4654,6 +4770,10 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -4743,6 +4863,10 @@ packages: resolution: {integrity: sha512-hNp56d5uuREVde7UqP+dmBkwzxrhJwYU5nL/mdivyFfkRZdgAgojkyBeU3jKo7ZHrjdSx6Q1CwUmYJI6INt20g==} hasBin: true + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + public-encrypt@4.0.3: resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} @@ -4801,6 +4925,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -4944,6 +5072,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -5539,6 +5671,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} @@ -5619,6 +5755,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unplugin-utils@0.3.1: resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} engines: {node: '>=20.19.0'} @@ -5679,6 +5819,10 @@ packages: varint@5.0.2: resolution: {integrity: sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + verror@1.10.0: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} @@ -6010,6 +6154,14 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: 0x@5.8.0: @@ -6895,6 +7047,10 @@ snapshots: '@exodus/bytes@1.15.0': {} + '@hono/node-server@1.19.11(hono@4.12.8)': + dependencies: + hono: 4.12.8 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -7041,6 +7197,28 @@ snapshots: globby: 11.1.0 ts-morph: 27.0.2 + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.8) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.8 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@mongodb-js/saslprep@1.4.6': dependencies: sparse-bitfield: 3.0.3 @@ -7569,6 +7747,11 @@ snapshots: dependencies: event-target-shim: 5.0.1 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -7856,6 +8039,20 @@ snapshots: bn.js@5.2.3: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} boxen@5.1.2: @@ -8036,6 +8233,8 @@ snapshots: byte-counter@0.1.0: {} + bytes@3.1.2: {} + cacheable-lookup@7.0.0: {} cacheable-request@13.0.18: @@ -8340,6 +8539,10 @@ snapshots: constants-browserify@1.0.0: {} + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + conventional-changelog-angular@8.3.0: dependencies: compare-func: 2.0.0 @@ -8359,6 +8562,10 @@ snapshots: convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 @@ -8367,6 +8574,11 @@ snapshots: core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cosmiconfig-typescript-loader@6.2.0(@types/node@24.12.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): dependencies: '@types/node': 24.12.0 @@ -9272,6 +9484,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + evp_bytestokey@1.0.3: dependencies: md5.js: 1.3.5 @@ -9297,6 +9515,44 @@ snapshots: expect-type@1.3.0: {} + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} ext@1.7.0: @@ -9426,6 +9682,8 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + fresh@2.0.0: {} from2-string@1.1.0: @@ -9720,6 +9978,8 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 + hono@4.12.8: {} + hookable@5.5.3: {} hsl-to-rgb-for-reals@1.1.1: {} @@ -9787,6 +10047,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -9880,6 +10144,10 @@ snapshots: iota-array@1.0.0: {} + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + is-any-array@2.0.1: {} is-arguments@1.2.0: @@ -10005,6 +10273,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-property@1.0.2: {} is-regex@1.2.1: @@ -10102,6 +10372,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-beautify@1.15.4: dependencies: config-chain: 1.1.13 @@ -10162,6 +10434,8 @@ snapshots: json-schema-typed@7.0.3: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -10452,16 +10726,20 @@ snapshots: md5.js@1.3.5: dependencies: - hash-base: 3.0.5 + hash-base: 3.1.2 inherits: 2.0.4 safe-buffer: 5.2.1 mdn-data@2.27.1: {} + media-typer@1.1.0: {} + memory-pager@1.5.0: {} meow@13.2.0: {} + merge-descriptors@2.0.0: {} + merge-source-map@1.0.4: dependencies: source-map: 0.5.7 @@ -10710,6 +10988,8 @@ snapshots: iota-array: 1.0.0 is-buffer: 1.1.6 + negotiator@1.0.0: {} + neostandard@0.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@humanwhocodes/gitignore-to-minimatch': 1.0.2 @@ -11008,6 +11288,8 @@ snapshots: lru-cache: 11.2.7 minipass: 7.1.3 + path-to-regexp@8.3.0: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -11037,6 +11319,8 @@ snapshots: pify@2.3.0: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -11137,6 +11421,11 @@ snapshots: transitivePeerDependencies: - react-native-b4a + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + public-encrypt@4.0.3: dependencies: bn.js: 5.2.3 @@ -11198,6 +11487,13 @@ snapshots: range-parser@1.2.1: {} + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -11387,6 +11683,16 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.10 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.10 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + run-async@2.4.1: {} run-parallel@1.2.0: @@ -12069,6 +12375,12 @@ snapshots: type-fest@4.41.0: {} + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + type@2.7.3: {} typed-array-buffer@1.0.3: @@ -12163,6 +12475,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + unplugin-utils@0.3.1: dependencies: pathe: 2.0.3 @@ -12239,6 +12553,8 @@ snapshots: varint@5.0.2: {} + vary@1.1.2: {} + verror@1.10.0: dependencies: assert-plus: 1.0.0 @@ -12575,3 +12891,9 @@ snapshots: yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} + + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/scripts/bundle.js b/scripts/bundle.js index 21080354..5cf07899 100644 --- a/scripts/bundle.js +++ b/scripts/bundle.js @@ -16,6 +16,7 @@ await build({ entryPoints: ['./src/start.ts', './src/charging-station/ChargingStationWorker.ts'], external: [ '@mikro-orm/*', + '@modelcontextprotocol/*', 'ajv', 'ajv-formats', 'basic-ftp', @@ -33,6 +34,7 @@ await build({ 'winston/*', 'winston-daily-rotate-file', 'ws', + 'zod', ], format: 'esm', minify: true, diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index a89f8c55..fec2fcb6 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -52,6 +52,7 @@ import { type Response, StandardParametersKey, type Status, + type StatusNotificationRequest, type StopTransactionReason, SupervisionUrlDistribution, SupportedFeatureProfiles, @@ -302,9 +303,10 @@ export class ChargingStation extends EventEmitter { connectorStatus.reservation = reservation await sendAndSetConnectorStatus( this, - reservation.connectorId, - ConnectorStatusEnum.Reserved, - undefined, + { + connectorId: reservation.connectorId, + status: ConnectorStatusEnum.Reserved, + } as unknown as StatusNotificationRequest, { send: reservation.connectorId !== 0 } ) } @@ -850,9 +852,10 @@ export class ChargingStation extends EventEmitter { case ReservationTerminationReason.RESERVATION_CANCELED: await sendAndSetConnectorStatus( this, - reservation.connectorId, - ConnectorStatusEnum.Available, - undefined, + { + connectorId: reservation.connectorId, + status: ConnectorStatusEnum.Available, + } as unknown as StatusNotificationRequest, { send: reservation.connectorId !== 0 } ) delete connector.reservation @@ -2415,12 +2418,11 @@ export class ChargingStation extends EventEmitter { for (const [evseId, evseStatus] of this.evses) { if (evseId > 0) { for (const [connectorId, connectorStatus] of evseStatus.connectors) { - await sendAndSetConnectorStatus( - this, + await sendAndSetConnectorStatus(this, { connectorId, - getBootConnectorStatus(this, connectorId, connectorStatus), - evseId - ) + evseId, + status: getBootConnectorStatus(this, connectorId, connectorStatus), + } as unknown as StatusNotificationRequest) } } } @@ -2434,11 +2436,10 @@ export class ChargingStation extends EventEmitter { ) continue } - await sendAndSetConnectorStatus( - this, + await sendAndSetConnectorStatus(this, { connectorId, - getBootConnectorStatus(this, connectorId, connectorStatus) - ) + status: getBootConnectorStatus(this, connectorId, connectorStatus), + } as unknown as StatusNotificationRequest) } } } @@ -2506,12 +2507,11 @@ export class ChargingStation extends EventEmitter { for (const [evseId, evseStatus] of this.evses) { if (evseId > 0) { for (const [connectorId, connectorStatus] of evseStatus.connectors) { - await sendAndSetConnectorStatus( - this, + await sendAndSetConnectorStatus(this, { connectorId, - ConnectorStatusEnum.Unavailable, - evseId - ) + evseId, + status: ConnectorStatusEnum.Unavailable, + } as unknown as StatusNotificationRequest) delete connectorStatus.status } } @@ -2519,7 +2519,10 @@ export class ChargingStation extends EventEmitter { } else { for (const connectorId of this.connectors.keys()) { if (connectorId > 0) { - await sendAndSetConnectorStatus(this, connectorId, ConnectorStatusEnum.Unavailable) + await sendAndSetConnectorStatus(this, { + connectorId, + status: ConnectorStatusEnum.Unavailable, + } as unknown as StatusNotificationRequest) delete this.getConnectorStatus(connectorId)?.status } } diff --git a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts index 559f9cd7..b0f86d84 100644 --- a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts +++ b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts @@ -29,7 +29,9 @@ import { type MessageEvent, type MeterValuesRequest, type MeterValuesResponse, + type OCPP16AuthorizeResponse, OCPP20AuthorizationStatusEnumType, + type OCPP20AuthorizeResponse, type OCPP20Get15118EVCertificateRequest, type OCPP20Get15118EVCertificateResponse, type OCPP20GetCertificateStatusRequest, @@ -261,12 +263,33 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne ): ResponseStatus { switch (command) { case BroadcastChannelProcedureName.AUTHORIZE: + switch (this.chargingStation.stationInfo?.ocppVersion) { + case OCPPVersion.VERSION_16: + if ( + (commandResponse as OCPP16AuthorizeResponse).idTagInfo.status === + AuthorizationStatus.ACCEPTED + ) { + return ResponseStatus.SUCCESS + } + return ResponseStatus.FAILURE + case OCPPVersion.VERSION_20: + case OCPPVersion.VERSION_201: + if ( + (commandResponse as OCPP20AuthorizeResponse).idTokenInfo.status === + AuthorizationStatus.Accepted + ) { + return ResponseStatus.SUCCESS + } + return ResponseStatus.FAILURE + default: + return ResponseStatus.FAILURE + } case BroadcastChannelProcedureName.START_TRANSACTION: case BroadcastChannelProcedureName.STOP_TRANSACTION: if ( ( commandResponse as - | AuthorizeResponse + | OCPP16AuthorizeResponse | StartTransactionResponse | StopTransactionResponse ).idTagInfo?.status === AuthorizationStatus.ACCEPTED @@ -471,65 +494,68 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne requestPayload?: BroadcastChannelRequestPayload ): Promise { const connectorId = requestPayload?.connectorId - if ( - this.chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_20 || - this.chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_201 - ) { - const alignedDataInterval = OCPP20ServiceUtils.getAlignedDataInterval(this.chargingStation) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const evseId = this.chargingStation.getEvseIdByConnectorId(connectorId!) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const transactionId = this.chargingStation.getConnectorStatus(connectorId!)?.transactionId - return await this.chargingStation.ocppRequestService.requestHandler< - MeterValuesRequest, - MeterValuesResponse - >( - this.chargingStation, - RequestCommand.METER_VALUES, - { - evseId, - meterValue: [ - buildMeterValue( - this.chargingStation, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - connectorId!, - transactionId, - alignedDataInterval - ), - ], - ...requestPayload, - } as MeterValuesRequest, - this.requestParams - ) + switch (this.chargingStation.stationInfo?.ocppVersion) { + case OCPPVersion.VERSION_16: { + const configuredMeterValueSampleInterval = getConfigurationKey( + this.chargingStation, + StandardParametersKey.MeterValueSampleInterval + ) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const transactionId = this.chargingStation.getConnectorStatus(connectorId!)?.transactionId + return await this.chargingStation.ocppRequestService.requestHandler< + MeterValuesRequest, + MeterValuesResponse + >( + this.chargingStation, + RequestCommand.METER_VALUES, + { + meterValue: [ + buildMeterValue( + this.chargingStation, + convertToInt(transactionId), + configuredMeterValueSampleInterval != null + ? secondsToMilliseconds(convertToInt(configuredMeterValueSampleInterval.value)) + : Constants.DEFAULT_METER_VALUES_INTERVAL + ), + ], + ...requestPayload, + } as MeterValuesRequest, + this.requestParams + ) + } + case OCPPVersion.VERSION_20: + case OCPPVersion.VERSION_201: { + const alignedDataInterval = OCPP20ServiceUtils.getAlignedDataInterval(this.chargingStation) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const evseId = this.chargingStation.getEvseIdByConnectorId(connectorId!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const transactionId = this.chargingStation.getConnectorStatus(connectorId!)?.transactionId + return await this.chargingStation.ocppRequestService.requestHandler< + MeterValuesRequest, + MeterValuesResponse + >( + this.chargingStation, + RequestCommand.METER_VALUES, + { + evseId, + meterValue: [ + buildMeterValue( + this.chargingStation, + + transactionId, + alignedDataInterval + ), + ], + ...requestPayload, + } as MeterValuesRequest, + this.requestParams + ) + } + default: + throw new BaseError( + `${this.chargingStation.logPrefix()} ${moduleName}.handleMeterValues: Unsupported OCPP version for MeterValues` + ) } - const configuredMeterValueSampleInterval = getConfigurationKey( - this.chargingStation, - StandardParametersKey.MeterValueSampleInterval - ) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const transactionId = this.chargingStation.getConnectorStatus(connectorId!)?.transactionId - return await this.chargingStation.ocppRequestService.requestHandler< - MeterValuesRequest, - MeterValuesResponse - >( - this.chargingStation, - RequestCommand.METER_VALUES, - { - meterValue: [ - buildMeterValue( - this.chargingStation, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - connectorId!, - convertToInt(transactionId), - configuredMeterValueSampleInterval != null - ? secondsToMilliseconds(convertToInt(configuredMeterValueSampleInterval.value)) - : Constants.DEFAULT_METER_VALUES_INTERVAL - ), - ], - ...requestPayload, - } as MeterValuesRequest, - this.requestParams - ) } private async handleNotifyCustomerInformation ( diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index 11bdfbfa..df6c1536 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -437,7 +437,6 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { meterValue: [ buildMeterValue( chargingStation, - connectorId, convertToInt(connectorStatus.transactionId), 0 ) as OCPP16MeterValue, @@ -463,7 +462,6 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { meterValue: [ buildMeterValue( chargingStation, - id, convertToInt(cs.transactionId), 0 ) as OCPP16MeterValue, @@ -742,11 +740,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion chargingStation.getConnectorStatus(connectorId)!.availability = type - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { connectorId, - chargePointStatus - ) + status: chargePointStatus, + } as OCPP16StatusNotificationRequest) return OCPP16Constants.OCPP_AVAILABILITY_RESPONSE_ACCEPTED } return OCPP16Constants.OCPP_AVAILABILITY_RESPONSE_REJECTED @@ -1569,11 +1566,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { } return OCPP16Constants.OCPP_RESPONSE_UNLOCK_FAILED } - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { connectorId, - OCPP16ChargePointStatus.Available - ) + status: OCPP16ChargePointStatus.Available, + } as OCPP16StatusNotificationRequest) return OCPP16Constants.OCPP_RESPONSE_UNLOCKED } @@ -1667,11 +1663,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { if (evseId > 0) { for (const [connectorId, connectorStatus] of evseStatus.connectors) { if (connectorStatus.transactionStarted === false) { - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { connectorId, - OCPP16ChargePointStatus.Unavailable - ) + status: OCPP16ChargePointStatus.Unavailable, + } as OCPP16StatusNotificationRequest) } } } @@ -1682,11 +1677,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { connectorId > 0 && chargingStation.getConnectorStatus(connectorId)?.transactionStarted === false ) { - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { connectorId, - OCPP16ChargePointStatus.Unavailable - ) + status: OCPP16ChargePointStatus.Unavailable, + } as OCPP16StatusNotificationRequest) } } } @@ -1742,11 +1736,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { if (evseId > 0) { for (const [connectorId, connectorStatus] of evseStatus.connectors) { if (connectorStatus.status !== OCPP16ChargePointStatus.Unavailable) { - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { connectorId, - OCPP16ChargePointStatus.Unavailable - ) + status: OCPP16ChargePointStatus.Unavailable, + } as OCPP16StatusNotificationRequest) } } } @@ -1758,11 +1751,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { chargingStation.getConnectorStatus(connectorId)?.status !== OCPP16ChargePointStatus.Unavailable ) { - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { connectorId, - OCPP16ChargePointStatus.Unavailable - ) + status: OCPP16ChargePointStatus.Unavailable, + } as OCPP16StatusNotificationRequest) } } } diff --git a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts index 9ac47a5e..d55e10d7 100644 --- a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts @@ -5,7 +5,6 @@ import type { OCPPResponseService } from '../OCPPResponseService.js' import { OCPPError } from '../../../exception/index.js' import { - type ConnectorStatusEnum, ErrorType, type JsonObject, type JsonType, @@ -13,6 +12,7 @@ import { type OCPP16MeterValue, OCPP16RequestCommand, type OCPP16StartTransactionRequest, + type OCPP16StatusNotificationRequest, OCPPVersion, type RequestParams, } from '../../../types/index.js' @@ -110,11 +110,10 @@ export class OCPP16RequestService extends OCPPRequestService { // Pre request actions hook switch (commandName) { case OCPP16RequestCommand.START_TRANSACTION: - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, - (commandParams as OCPP16StartTransactionRequest).connectorId, - OCPP16ChargePointStatus.Preparing - ) + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { + connectorId: (commandParams as OCPP16StartTransactionRequest).connectorId, + status: OCPP16ChargePointStatus.Preparing, + } as OCPP16StatusNotificationRequest) break } const response = (await this.sendMessage( @@ -213,9 +212,7 @@ export class OCPP16RequestService extends OCPPRequestService { case OCPP16RequestCommand.STATUS_NOTIFICATION: return buildStatusNotificationRequest( chargingStation, - commandParams.connectorId as number, - commandParams.status as ConnectorStatusEnum, - commandParams.evseId as number | undefined + commandParams as unknown as OCPP16StatusNotificationRequest ) as unknown as Request case OCPP16RequestCommand.STOP_TRANSACTION: chargingStation.stationInfo?.transactionDataMeterValues === true && diff --git a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts index 12dfd7c1..827a85d2 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts @@ -26,6 +26,7 @@ import { OCPP16StandardParametersKey, type OCPP16StartTransactionRequest, type OCPP16StartTransactionResponse, + type OCPP16StatusNotificationRequest, type OCPP16StopTransactionRequest, type OCPP16StopTransactionResponse, OCPPVersion, @@ -423,11 +424,10 @@ export class OCPP16ResponseService extends OCPPResponseService { meterValue: [connectorStatus.transactionBeginMeterValue], transactionId: payload.transactionId, } satisfies OCPP16MeterValuesRequest)) - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { connectorId, - OCPP16ChargePointStatus.Charging - ) + status: OCPP16ChargePointStatus.Charging, + } as OCPP16StatusNotificationRequest) logger.info( `${chargingStation.logPrefix()} ${moduleName}.handleResponseStartTransaction: Transaction with id ${payload.transactionId.toString()} STARTED on ${ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions @@ -502,17 +502,15 @@ export class OCPP16ResponseService extends OCPPResponseService { !chargingStation.isChargingStationAvailable() || !chargingStation.isConnectorAvailable(transactionConnectorId) ) { - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, - transactionConnectorId, - OCPP16ChargePointStatus.Unavailable - ) + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { + connectorId: transactionConnectorId, + status: OCPP16ChargePointStatus.Unavailable, + } as OCPP16StatusNotificationRequest) } else { - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, - transactionConnectorId, - OCPP16ChargePointStatus.Available - ) + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { + connectorId: transactionConnectorId, + status: OCPP16ChargePointStatus.Available, + } as OCPP16StatusNotificationRequest) } if (chargingStation.stationInfo?.powerSharedByConnectors === true) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index ff5b5971..3166fb90 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -32,6 +32,7 @@ import { OCPP16RequestCommand, type OCPP16SampledValue, OCPP16StandardParametersKey, + type OCPP16StatusNotificationRequest, OCPP16StopTransactionReason, type OCPP16SupportedFeatureProfiles, OCPPVersion, @@ -152,11 +153,10 @@ export class OCPP16ServiceUtils extends OCPPServiceUtils { } connectorStatus.availability = availabilityType if (response === OCPP16Constants.OCPP_AVAILABILITY_RESPONSE_ACCEPTED) { - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { connectorId, - chargePointStatus - ) + status: chargePointStatus, + } as OCPP16StatusNotificationRequest) } responses.push(response) } @@ -535,11 +535,10 @@ export class OCPP16ServiceUtils extends OCPPServiceUtils { chargingStation: ChargingStation, connectorId: number ): Promise => { - await OCPP16ServiceUtils.sendAndSetConnectorStatus( - chargingStation, + await OCPP16ServiceUtils.sendAndSetConnectorStatus(chargingStation, { connectorId, - OCPP16ChargePointStatus.Finishing - ) + status: OCPP16ChargePointStatus.Finishing, + } as OCPP16StatusNotificationRequest) const stopResponse = await OCPP16ServiceUtils.stopTransactionOnConnector( chargingStation, connectorId, @@ -619,7 +618,7 @@ export class OCPP16ServiceUtils extends OCPPServiceUtils { } connectorStatus.transactionMeterValuesSetInterval = setInterval(() => { const transactionId = convertToInt(connectorStatus.transactionId) - const meterValue = buildMeterValue(chargingStation, connectorId, transactionId, interval) + const meterValue = buildMeterValue(chargingStation, transactionId, interval) chargingStation.ocppRequestService .requestHandler( chargingStation, diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index e7dfc92c..c2853d06 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -516,7 +516,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation) const meterValue = buildMeterValue( chargingStation, - cId, connector.transactionId, txUpdatedInterval ) as OCPP20MeterValue @@ -531,27 +530,16 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } } if (!hasSentTransactionEvent) { - const fallbackEvseId = evse?.id ?? 0 - let meterValue: OCPP20MeterValue - try { - meterValue = buildMeterValue( - chargingStation, - fallbackEvseId > 0 ? fallbackEvseId : 1, - undefined, - OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation) - ) as OCPP20MeterValue - } catch { - meterValue = { - sampledValue: [{ value: 0 }], - timestamp: new Date(), - } + const meterValue: OCPP20MeterValue = { + sampledValue: [{ value: 0 }], + timestamp: new Date(), } chargingStation.ocppRequestService .requestHandler( chargingStation, OCPP20RequestCommand.METER_VALUES, { - evseId: fallbackEvseId, + evseId: evse?.id ?? 1, meterValue: [meterValue], }, { skipBufferingOnError: true, triggerMessage: true } @@ -1113,11 +1101,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { ? this.getRestoredConnectorStatus(chargingStation, connectorId) : newConnectorStatus - sendAndSetConnectorStatus( - chargingStation, + sendAndSetConnectorStatus(chargingStation, { connectorId, - resolvedStatus as ConnectorStatusEnum - ).catch((error: unknown) => { + connectorStatus: resolvedStatus, + } as unknown as OCPP20StatusNotificationRequest).catch((error: unknown) => { logger.error( `${chargingStation.logPrefix()} ${moduleName}.handleConnectorChangeAvailability: Error sending status notification for connector ${connectorId.toString()}:`, error @@ -2710,12 +2697,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { `${chargingStation.logPrefix()} ${moduleName}.handleRequestUnlockConnector: Unlocking connector ${connectorId.toString()} on EVSE ${evseId.toString()}` ) - await sendAndSetConnectorStatus( - chargingStation, + await sendAndSetConnectorStatus(chargingStation, { connectorId, - ConnectorStatusEnum.Available, - evseId - ) + connectorStatus: ConnectorStatusEnum.Available, + evseId, + } as unknown as OCPP20StatusNotificationRequest) return { status: UnlockStatusEnumType.Unlocked } } catch (error) { @@ -3181,11 +3167,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { ): void { for (const [, evse] of chargingStation.evses) { for (const [connectorId] of evse.connectors) { - sendAndSetConnectorStatus( - chargingStation, + sendAndSetConnectorStatus(chargingStation, { connectorId, - status as ConnectorStatusEnum - ).catch((error: unknown) => { + connectorStatus: status, + } as unknown as OCPP20StatusNotificationRequest).catch((error: unknown) => { logger.error( `${chargingStation.logPrefix()} ${moduleName}.sendAllConnectorsStatusNotifications: Error sending status notification for connector ${connectorId.toString()}:`, error @@ -3209,11 +3194,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { const evse = chargingStation.getEvseStatus(evseId) if (evse) { for (const [connectorId] of evse.connectors) { - sendAndSetConnectorStatus( - chargingStation, + sendAndSetConnectorStatus(chargingStation, { connectorId, - status as ConnectorStatusEnum - ).catch((error: unknown) => { + connectorStatus: status, + } as unknown as OCPP20StatusNotificationRequest).catch((error: unknown) => { logger.error( `${chargingStation.logPrefix()} ${moduleName}.sendEvseStatusNotifications: Error sending status notification for connector ${connectorId.toString()}:`, error @@ -3347,11 +3331,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { if (evseId > 0) { for (const [connectorId] of evseStatus.connectors) { const restoredStatus = this.getRestoredConnectorStatus(chargingStation, connectorId) - sendAndSetConnectorStatus( - chargingStation, + sendAndSetConnectorStatus(chargingStation, { connectorId, - restoredStatus as ConnectorStatusEnum - ).catch((error: unknown) => { + connectorStatus: restoredStatus, + } as unknown as OCPP20StatusNotificationRequest).catch((error: unknown) => { logger.error( `${chargingStation.logPrefix()} ${moduleName}.sendRestoredAllConnectorsStatusNotifications: Error sending status notification for connector ${connectorId.toString()}:`, error @@ -3370,11 +3353,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { if (evse) { for (const [connectorId] of evse.connectors) { const restoredStatus = this.getRestoredConnectorStatus(chargingStation, connectorId) - sendAndSetConnectorStatus( - chargingStation, + sendAndSetConnectorStatus(chargingStation, { connectorId, - restoredStatus as ConnectorStatusEnum - ).catch((error: unknown) => { + connectorStatus: restoredStatus, + } as unknown as OCPP20StatusNotificationRequest).catch((error: unknown) => { logger.error( `${chargingStation.logPrefix()} ${moduleName}.sendRestoredEvseStatusNotifications: Error sending status notification for connector ${connectorId.toString()}:`, error @@ -3638,7 +3620,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { .requestHandler< OCPP20StatusNotificationRequest, OCPP20StatusNotificationResponse - >(chargingStation, OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId, evseId, status: resolvedStatus } as unknown as OCPP20StatusNotificationRequest, { skipBufferingOnError: true, triggerMessage: true }) + >(chargingStation, OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId, connectorStatus: resolvedStatus, evseId } as unknown as OCPP20StatusNotificationRequest, { skipBufferingOnError: true, triggerMessage: true }) .catch(errorHandler) } } @@ -3658,7 +3640,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { .requestHandler< OCPP20StatusNotificationRequest, OCPP20StatusNotificationResponse - >(chargingStation, OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: evse.connectorId, evseId: evse.id, status: resolvedStatus } as unknown as OCPP20StatusNotificationRequest, { skipBufferingOnError: true, triggerMessage: true }) + >(chargingStation, OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: evse.connectorId, connectorStatus: resolvedStatus, evseId: evse.id } as unknown as OCPP20StatusNotificationRequest, { skipBufferingOnError: true, triggerMessage: true }) .catch(errorHandler) } else if (chargingStation.hasEvses) { this.triggerAllEvseStatusNotifications(chargingStation, errorHandler) diff --git a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts index 8115b4a6..3e04d7c7 100644 --- a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts @@ -6,12 +6,13 @@ import type { OCPPResponseService } from '../OCPPResponseService.js' import { OCPPError } from '../../../exception/index.js' import { type CertificateSigningUseEnumType, - type ConnectorStatusEnum, ErrorType, type JsonObject, type JsonType, OCPP20RequestCommand, type OCPP20SignCertificateRequest, + type OCPP20StatusNotificationRequest, + type OCPP20TransactionEventRequest, OCPPVersion, type RequestParams, } from '../../../types/index.js' @@ -198,12 +199,13 @@ export class OCPP20RequestService extends OCPPRequestService { case OCPP20RequestCommand.STATUS_NOTIFICATION: return buildStatusNotificationRequest( chargingStation, - commandParams.connectorId as number, - commandParams.status as ConnectorStatusEnum, - commandParams.evseId as number | undefined + commandParams as unknown as OCPP20StatusNotificationRequest ) as unknown as Request case OCPP20RequestCommand.TRANSACTION_EVENT: - return buildTransactionEvent(chargingStation, commandParams) as unknown as Request + return buildTransactionEvent( + chargingStation, + commandParams as unknown as OCPP20TransactionEventRequest + ) as unknown as Request default: { // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError(). const errorMsg = `Unsupported OCPP command ${commandName as string} for payload building` diff --git a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts index 75a737ad..23f4e6e4 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts @@ -21,6 +21,7 @@ import { OCPP20RequestCommand, type OCPP20SecurityEventNotificationResponse, type OCPP20SignCertificateResponse, + type OCPP20StatusNotificationRequest, type OCPP20StatusNotificationResponse, OCPP20TransactionEventEnumType, type OCPP20TransactionEventRequest, @@ -375,11 +376,10 @@ export class OCPP20ResponseService extends OCPPResponseService { payload.idTokenInfo == null || payload.idTokenInfo.status === OCPP20AuthorizationStatusEnumType.Accepted if (connectorId != null && isIdTokenAccepted) { - sendAndSetConnectorStatus( - chargingStation, + sendAndSetConnectorStatus(chargingStation, { connectorId, - ConnectorStatusEnum.Occupied - ).catch((error: unknown) => { + connectorStatus: ConnectorStatusEnum.Occupied, + } as unknown as OCPP20StatusNotificationRequest).catch((error: unknown) => { logger.error( `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error sending StatusNotification(Occupied):`, error diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index abea3b60..bb1f69ff 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -6,7 +6,6 @@ import { type ConnectorStatus, ConnectorStatusEnum, ErrorType, - type JsonObject, OCPP20ChargingStateEnumType, OCPP20ComponentName, type OCPP20EVSEType, @@ -17,6 +16,7 @@ import { OCPP20ReasonEnumType, OCPP20RequestCommand, OCPP20RequiredVariableName, + type OCPP20StatusNotificationRequest, OCPP20TransactionEventEnumType, type OCPP20TransactionEventOptions, type OCPP20TransactionEventRequest, @@ -481,14 +481,14 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { // Offline: build and queue pre-built payload (sent as-is via rawPayload on reconnect) if (!chargingStation.isWebSocketConnectionOpened()) { // E04.FR.03: offline flag SHALL be TRUE for any TransactionEventRequest that occurred while offline - const transactionEventRequest = buildTransactionEvent( - chargingStation, - eventType, - triggerReason, + const transactionEventRequest = buildTransactionEvent(chargingStation, { connectorId, + eventType, transactionId, - { ...options, offline: true } - ) + triggerReason, + ...options, + offline: true, + } as unknown as OCPP20TransactionEventRequest) logger.info( `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Station offline, queueing TransactionEvent with seqNo=${transactionEventRequest.seqNo.toString()}` ) @@ -587,7 +587,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { } const meterValue = buildMeterValue( chargingStation, - connectorId, connectorStatus.transactionId, interval ) as OCPP20MeterValue @@ -832,76 +831,45 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId) resetConnectorStatus(connectorStatus) - await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available) + await sendAndSetConnectorStatus(chargingStation, { + connectorId, + connectorStatus: ConnectorStatusEnum.Available, + } as unknown as OCPP20StatusNotificationRequest) return response } } -export function buildTransactionEvent ( - chargingStation: ChargingStation, - eventType: OCPP20TransactionEventEnumType, - triggerReason: OCPP20TriggerReasonEnumType, - connectorId: number, - transactionId: string, - options?: OCPP20TransactionEventOptions -): OCPP20TransactionEventRequest -export function buildTransactionEvent ( - chargingStation: ChargingStation, - commandParams: JsonObject -): OCPP20TransactionEventRequest + /** * @param chargingStation - Charging station instance - * @param eventTypeOrParams - Event type enum or minimal params object - * @param triggerReasonArg - Trigger reason (explicit overload) - * @param connectorIdArg - Connector identifier (explicit overload) - * @param transactionIdArg - Transaction UUID (explicit overload) - * @param options - Optional transaction event fields + * @param commandParams - Transaction event request parameters * @returns Built TransactionEventRequest */ export function buildTransactionEvent ( chargingStation: ChargingStation, - eventTypeOrParams: JsonObject | OCPP20TransactionEventEnumType, - triggerReasonArg?: OCPP20TriggerReasonEnumType, - connectorIdArg?: number, - transactionIdArg?: string, - options: OCPP20TransactionEventOptions = {} + commandParams: OCPP20TransactionEventRequest ): OCPP20TransactionEventRequest { - let eventType: OCPP20TransactionEventEnumType - let triggerReason: OCPP20TriggerReasonEnumType - let connectorId: number - let transactionId: string - - if (typeof eventTypeOrParams === 'object') { - const params = eventTypeOrParams - eventType = params.eventType as OCPP20TransactionEventEnumType - triggerReason = - params.triggerReason != null - ? (params.triggerReason as OCPP20TriggerReasonEnumType) - : eventType === OCPP20TransactionEventEnumType.Ended - ? OCPP20TriggerReasonEnumType.RemoteStop - : OCPP20TriggerReasonEnumType.Authorized - const evse = params.evse as undefined | { connectorId?: number; id?: number } - connectorId = - params.connectorId != null - ? (params.connectorId as number) - : (evse?.connectorId ?? evse?.id ?? 1) - transactionId = - params.transactionId != null - ? (params.transactionId as string) - : eventType === OCPP20TransactionEventEnumType.Ended - ? (chargingStation.getConnectorStatus(connectorId)?.transactionId?.toString() ?? - generateUUID()) - : generateUUID() - options = params as unknown as OCPP20TransactionEventOptions - } else { - eventType = eventTypeOrParams - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - triggerReason = triggerReasonArg! - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - connectorId = connectorIdArg! - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - transactionId = transactionIdArg! - } + const params = commandParams as Record + const eventType = params.eventType as OCPP20TransactionEventEnumType + const triggerReason = + params.triggerReason != null + ? (params.triggerReason as OCPP20TriggerReasonEnumType) + : eventType === OCPP20TransactionEventEnumType.Ended + ? OCPP20TriggerReasonEnumType.RemoteStop + : OCPP20TriggerReasonEnumType.Authorized + const inputEvse = params.evse as undefined | { connectorId?: number; id?: number } + const connectorId = + params.connectorId != null + ? (params.connectorId as number) + : (inputEvse?.connectorId ?? inputEvse?.id ?? 1) + const transactionId = + params.transactionId != null + ? (params.transactionId as string) + : eventType === OCPP20TransactionEventEnumType.Ended + ? (chargingStation.getConnectorStatus(connectorId)?.transactionId?.toString() ?? + generateUUID()) + : generateUUID() + const options = params as unknown as OCPP20TransactionEventOptions if (!validateIdentifierString(transactionId, 36)) { const errorMsg = `Invalid transaction ID format (must be non-empty string ≤36 characters): ${transactionId}` diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index 6ab76a06..7b30acd1 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -16,7 +16,6 @@ import { BaseError, OCPPError } from '../../exception/index.js' import { AuthorizationStatus, type AuthorizeRequest, - type AuthorizeResponse, ChargePointErrorCode, ChargingStationEvents, type ConnectorStatus, @@ -36,17 +35,19 @@ import { MeterValueMeasurand, MeterValuePhase, MeterValueUnit, - type OCPP16ChargePointStatus, + type OCPP16AuthorizeResponse, type OCPP16MeterValue, type OCPP16SampledValue, type OCPP16StatusNotificationRequest, OCPP16StopTransactionReason, OCPP20AuthorizationStatusEnumType, + type OCPP20AuthorizeResponse, type OCPP20ConnectorStatusEnumType, OCPP20IdTokenEnumType, type OCPP20MeterValue, OCPP20ReasonEnumType, type OCPP20SampledValue, + type OCPP20StatusNotificationRequest, OCPP20TransactionEventEnumType, OCPP20TriggerReasonEnumType, OCPPVersion, @@ -111,19 +112,23 @@ export const getMessageTypeString = (messageType: MessageType | undefined): stri export const buildStatusNotificationRequest = ( chargingStation: ChargingStation, - connectorId: number, - status: ConnectorStatusEnum, - evseId?: number + commandParams: StatusNotificationRequest ): StatusNotificationRequest => { switch (chargingStation.stationInfo?.ocppVersion) { - case OCPPVersion.VERSION_16: + case OCPPVersion.VERSION_16: { + const params = commandParams as OCPP16StatusNotificationRequest return { - connectorId, + connectorId: params.connectorId, errorCode: ChargePointErrorCode.NO_ERROR, - status: status as OCPP16ChargePointStatus, + status: params.status, } satisfies OCPP16StatusNotificationRequest + } case OCPPVersion.VERSION_20: case OCPPVersion.VERSION_201: { + const params = commandParams as Record + const connectorId = params.connectorId as number + const connectorStatus = (params.connectorStatus ?? params.status) as ConnectorStatusEnum + const evseId = params.evseId as number | undefined const resolvedEvseId = evseId ?? chargingStation.getEvseIdByConnectorId(connectorId) if (resolvedEvseId === undefined) { throw new OCPPError( @@ -134,10 +139,10 @@ export const buildStatusNotificationRequest = ( } return { connectorId, - connectorStatus: status as OCPP20ConnectorStatusEnumType, + connectorStatus: connectorStatus as OCPP20ConnectorStatusEnumType, evseId: resolvedEvseId, timestamp: new Date(), - } satisfies StatusNotificationRequest + } satisfies OCPP20StatusNotificationRequest } default: throw new OCPPError( @@ -162,66 +167,54 @@ export const isIdTagAuthorizedUnified = async ( connectorId: number, idTag: string ): Promise => { - const stationOcppVersion = chargingStation.stationInfo?.ocppVersion - // OCPP 2.0+ always uses unified auth system - // OCPP 1.6 can optionally use unified or legacy system - const shouldUseUnified = - stationOcppVersion === OCPPVersion.VERSION_20 || stationOcppVersion === OCPPVersion.VERSION_201 - - if (shouldUseUnified) { - try { - logger.debug( - `${chargingStation.logPrefix()} Using unified auth system for idTag '${idTag}' on connector ${connectorId.toString()}` - ) + switch (chargingStation.stationInfo?.ocppVersion) { + case OCPPVersion.VERSION_20: + case OCPPVersion.VERSION_201: + try { + logger.debug( + `${chargingStation.logPrefix()} Using unified auth system for idTag '${idTag}' on connector ${connectorId.toString()}` + ) - // Dynamic import to avoid circular dependencies - const { OCPPAuthServiceFactory } = await import('./auth/services/OCPPAuthServiceFactory.js') - const { - AuthContext, - AuthorizationStatus: UnifiedAuthorizationStatus, - IdentifierType, - } = await import('./auth/types/AuthTypes.js') + // Dynamic import to avoid circular dependencies + const { OCPPAuthServiceFactory } = await import('./auth/services/OCPPAuthServiceFactory.js') + const { + AuthContext, + AuthorizationStatus: UnifiedAuthorizationStatus, + IdentifierType, + } = await import('./auth/types/AuthTypes.js') - // Get unified auth service - const authService = await OCPPAuthServiceFactory.getInstance(chargingStation) + // Get unified auth service + const authService = await OCPPAuthServiceFactory.getInstance(chargingStation) - // Create auth request with unified types - const authResult = await authService.authorize({ - allowOffline: false, - connectorId, - context: AuthContext.TRANSACTION_START, - identifier: { - type: IdentifierType.ID_TAG, - value: idTag, - }, - timestamp: new Date(), - }) + // Create auth request with unified types + const authResult = await authService.authorize({ + allowOffline: false, + connectorId, + context: AuthContext.TRANSACTION_START, + identifier: { + type: IdentifierType.ID_TAG, + value: idTag, + }, + timestamp: new Date(), + }) + + logger.debug( + `${chargingStation.logPrefix()} Unified auth result for idTag '${idTag}': ${authResult.status} using ${authResult.method} method` + ) + return authResult.status === UnifiedAuthorizationStatus.ACCEPTED + } catch (error) { + logger.error(`${chargingStation.logPrefix()} Unified auth failed for OCPP 2.0`, error) + return false + } + case OCPPVersion.VERSION_16: logger.debug( - `${chargingStation.logPrefix()} Unified auth result for idTag '${idTag}': ${authResult.status} using ${authResult.method} method` - ) - - // Use AuthorizationStatus enum from unified system - return authResult.status === UnifiedAuthorizationStatus.ACCEPTED - } catch (error) { - logger.error( - `${chargingStation.logPrefix()} Unified auth failed, falling back to legacy system`, - error + `${chargingStation.logPrefix()} Using legacy auth system for idTag '${idTag}' on connector ${connectorId.toString()}` ) - // Fall back to legacy system on error (only for OCPP 1.6) - if (chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_16) { - return isIdTagAuthorized(chargingStation, connectorId, idTag) - } - // For OCPP 2.0, return false on error (no legacy fallback) + return isIdTagAuthorized(chargingStation, connectorId, idTag) + default: return false - } } - - // Use legacy auth system for OCPP 1.6 when unified auth not explicitly enabled - logger.debug( - `${chargingStation.logPrefix()} Using legacy auth system for idTag '${idTag}' on connector ${connectorId.toString()}` - ) - return isIdTagAuthorized(chargingStation, connectorId, idTag) } /** @@ -237,36 +230,36 @@ export const isIdTagAuthorized = async ( connectorId: number, idTag: string ): Promise => { - // OCPP 2.0+ always delegates to unified system - if ( - chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_20 || - chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_201 - ) { - return isIdTagAuthorizedUnified(chargingStation, connectorId, idTag) - } - - // Legacy authorization logic for OCPP 1.6 - if ( - !chargingStation.getLocalAuthListEnabled() && - chargingStation.stationInfo?.remoteAuthorization === false - ) { - logger.warn( - `${chargingStation.logPrefix()} The charging station expects to authorize RFID tags but nor local authorization nor remote authorization are enabled. Misbehavior may occur` - ) - } - const connectorStatus = chargingStation.getConnectorStatus(connectorId) - if ( - connectorStatus != null && - chargingStation.getLocalAuthListEnabled() && - isIdTagLocalAuthorized(chargingStation, idTag) - ) { - connectorStatus.localAuthorizeIdTag = idTag - connectorStatus.idTagLocalAuthorized = true - return true - } else if (chargingStation.stationInfo?.remoteAuthorization === true) { - return await isIdTagRemoteAuthorized(chargingStation, connectorId, idTag) + switch (chargingStation.stationInfo?.ocppVersion) { + case OCPPVersion.VERSION_16: { + if ( + !chargingStation.getLocalAuthListEnabled() && + chargingStation.stationInfo.remoteAuthorization === false + ) { + logger.warn( + `${chargingStation.logPrefix()} The charging station expects to authorize RFID tags but nor local authorization nor remote authorization are enabled. Misbehavior may occur` + ) + } + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + if ( + connectorStatus != null && + chargingStation.getLocalAuthListEnabled() && + isIdTagLocalAuthorized(chargingStation, idTag) + ) { + connectorStatus.localAuthorizeIdTag = idTag + connectorStatus.idTagLocalAuthorized = true + return true + } else if (chargingStation.stationInfo.remoteAuthorization === true) { + return await isIdTagRemoteAuthorized(chargingStation, connectorId, idTag) + } + return false + } + case OCPPVersion.VERSION_20: + case OCPPVersion.VERSION_201: + return isIdTagAuthorizedUnified(chargingStation, connectorId, idTag) + default: + return false } - return false } const isIdTagLocalAuthorized = (chargingStation: ChargingStation, idTag: string): boolean => { @@ -288,27 +281,44 @@ const isIdTagRemoteAuthorized = async ( ): Promise => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion chargingStation.getConnectorStatus(connectorId)!.authorizeIdTag = idTag - return ( - ( - await chargingStation.ocppRequestService.requestHandler( - chargingStation, - RequestCommand.AUTHORIZE, - { - idTag, - } + switch (chargingStation.stationInfo?.ocppVersion) { + case OCPPVersion.VERSION_16: + return ( + ( + await chargingStation.ocppRequestService.requestHandler< + AuthorizeRequest, + OCPP16AuthorizeResponse + >(chargingStation, RequestCommand.AUTHORIZE, { + idTag, + }) + ).idTagInfo.status === AuthorizationStatus.ACCEPTED ) - ).idTagInfo.status === AuthorizationStatus.ACCEPTED - ) + case OCPPVersion.VERSION_20: + case OCPPVersion.VERSION_201: + return ( + ( + await chargingStation.ocppRequestService.requestHandler< + AuthorizeRequest, + OCPP20AuthorizeResponse + >(chargingStation, RequestCommand.AUTHORIZE, { + idToken: { idToken: idTag, type: OCPP20IdTokenEnumType.ISO14443 }, + }) + ).idTokenInfo.status === AuthorizationStatus.Accepted + ) + default: + return false + } } export const sendAndSetConnectorStatus = async ( chargingStation: ChargingStation, - connectorId: number, - status: ConnectorStatusEnum, - evseId?: number, + commandParams: StatusNotificationRequest, options?: { send: boolean } ): Promise => { options = { send: true, ...options } + const params = commandParams as Record + const connectorId = params.connectorId as number + const status = (params.connectorStatus ?? params.status) as ConnectorStatusEnum const connectorStatus = chargingStation.getConnectorStatus(connectorId) if (connectorStatus == null) { return @@ -318,11 +328,7 @@ export const sendAndSetConnectorStatus = async ( await chargingStation.ocppRequestService.requestHandler< StatusNotificationRequest, StatusNotificationResponse - >(chargingStation, RequestCommand.STATUS_NOTIFICATION, { - connectorId, - evseId, - status, - } as unknown as StatusNotificationRequest) + >(chargingStation, RequestCommand.STATUS_NOTIFICATION, commandParams) } connectorStatus.status = status chargingStation.emitChargingStationEvent(ChargingStationEvents.connectorStatusChanged, { @@ -340,9 +346,15 @@ export const restoreConnectorStatus = async ( connectorStatus?.reservation != null && connectorStatus.status !== ConnectorStatusEnum.Reserved ) { - await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Reserved) + await sendAndSetConnectorStatus(chargingStation, { + connectorId, + status: ConnectorStatusEnum.Reserved, + } as unknown as StatusNotificationRequest) } else if (connectorStatus?.status !== ConnectorStatusEnum.Available) { - await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available) + await sendAndSetConnectorStatus(chargingStation, { + connectorId, + status: ConnectorStatusEnum.Available, + } as unknown as StatusNotificationRequest) } } @@ -773,12 +785,14 @@ export const convertDateToISOString = (object: T): void => { const buildSocMeasurandValue = ( chargingStation: ChargingStation, - connectorId: number + connectorId: number, + evseId?: number ): null | SingleValueMeasurandData => { const socSampledValueTemplate = getSampledValueTemplate( chargingStation, connectorId, - MeterValueMeasurand.STATE_OF_CHARGE + MeterValueMeasurand.STATE_OF_CHARGE, + evseId ) if (socSampledValueTemplate == null) { return null @@ -824,12 +838,14 @@ const validateSocMeasurandValue = ( const buildVoltageMeasurandValue = ( chargingStation: ChargingStation, - connectorId: number + connectorId: number, + evseId?: number ): null | SingleValueMeasurandData => { const voltageSampledValueTemplate = getSampledValueTemplate( chargingStation, connectorId, - MeterValueMeasurand.VOLTAGE + MeterValueMeasurand.VOLTAGE, + evseId ) if (voltageSampledValueTemplate == null) { return null @@ -898,6 +914,7 @@ const addPhaseVoltageToMeterValue = ( chargingStation, connectorId, MeterValueMeasurand.VOLTAGE, + undefined, phaseLineToNeutralValue ) let voltagePhaseLineToNeutralMeasurandValue: number | undefined @@ -955,6 +972,7 @@ const addLineToLineVoltageToMeterValue = ( chargingStation, connectorId, MeterValueMeasurand.VOLTAGE, + undefined, phaseLineToLineValue ) let voltagePhaseLineToLineMeasurandValue: number | undefined @@ -985,9 +1003,10 @@ const addLineToLineVoltageToMeterValue = ( const buildEnergyMeasurandValue = ( chargingStation: ChargingStation, connectorId: number, - interval: number + interval: number, + evseId?: number ): null | SingleValueMeasurandData => { - const energyTemplate = getSampledValueTemplate(chargingStation, connectorId) + const energyTemplate = getSampledValueTemplate(chargingStation, connectorId, undefined, evseId) if (energyTemplate == null) { return null } @@ -1067,12 +1086,14 @@ const validateEnergyMeasurandValue = ( const buildPowerMeasurandValue = ( chargingStation: ChargingStation, - connectorId: number + connectorId: number, + evseId?: number ): MultiPhaseMeasurandData | null => { const powerTemplate = getSampledValueTemplate( chargingStation, connectorId, - MeterValueMeasurand.POWER_ACTIVE_IMPORT + MeterValueMeasurand.POWER_ACTIVE_IMPORT, + evseId ) if (powerTemplate == null) { return null @@ -1085,18 +1106,21 @@ const buildPowerMeasurandValue = ( chargingStation, connectorId, MeterValueMeasurand.POWER_ACTIVE_IMPORT, + evseId, MeterValuePhase.L1_N ), L2: getSampledValueTemplate( chargingStation, connectorId, MeterValueMeasurand.POWER_ACTIVE_IMPORT, + evseId, MeterValuePhase.L2_N ), L3: getSampledValueTemplate( chargingStation, connectorId, MeterValueMeasurand.POWER_ACTIVE_IMPORT, + evseId, MeterValuePhase.L3_N ), } @@ -1337,12 +1361,14 @@ const validateCurrentMeasurandPhaseValue = ( const buildCurrentMeasurandValue = ( chargingStation: ChargingStation, - connectorId: number + connectorId: number, + evseId?: number ): MultiPhaseMeasurandData | null => { const currentTemplate = getSampledValueTemplate( chargingStation, connectorId, - MeterValueMeasurand.CURRENT_IMPORT + MeterValueMeasurand.CURRENT_IMPORT, + evseId ) if (currentTemplate == null) { return null @@ -1355,18 +1381,21 @@ const buildCurrentMeasurandValue = ( chargingStation, connectorId, MeterValueMeasurand.CURRENT_IMPORT, + evseId, MeterValuePhase.L1 ), L2: getSampledValueTemplate( chargingStation, connectorId, MeterValueMeasurand.CURRENT_IMPORT, + evseId, MeterValuePhase.L2 ), L3: getSampledValueTemplate( chargingStation, connectorId, MeterValueMeasurand.CURRENT_IMPORT, + evseId, MeterValuePhase.L3 ), } @@ -1526,15 +1555,24 @@ const buildCurrentMeasurandValue = ( export const buildMeterValue = ( chargingStation: ChargingStation, - connectorId: number, transactionId: number | string | undefined, interval: number, debug = false ): MeterValue => { - const connector = chargingStation.getConnectorStatus(connectorId) - + if (transactionId == null) { + return { sampledValue: [], timestamp: new Date() } + } switch (chargingStation.stationInfo?.ocppVersion) { case OCPPVersion.VERSION_16: { + const connectorId = chargingStation.getConnectorIdByTransactionId(transactionId) + if (connectorId == null) { + throw new OCPPError( + ErrorType.INTERNAL_ERROR, + `Cannot build MeterValues: no connector found for transaction ${String(transactionId)}`, + RequestCommand.METER_VALUES + ) + } + const connector = chargingStation.getConnectorStatus(connectorId) const meterValue: OCPP16MeterValue = { sampledValue: [], timestamp: new Date(), @@ -1747,6 +1785,16 @@ export const buildMeterValue = ( } case OCPPVersion.VERSION_20: case OCPPVersion.VERSION_201: { + const connectorId = chargingStation.getConnectorIdByTransactionId(transactionId) + const evseId = chargingStation.getEvseIdByTransactionId(transactionId) + if (connectorId == null || evseId == null) { + throw new OCPPError( + ErrorType.INTERNAL_ERROR, + `Cannot build MeterValues: no connector/EVSE found for transaction ${String(transactionId)}`, + RequestCommand.METER_VALUES + ) + } + const connector = chargingStation.getConnectorStatus(connectorId) const meterValue: OCPP20MeterValue = { sampledValue: [], timestamp: new Date(), @@ -1760,7 +1808,7 @@ export const buildMeterValue = ( return buildSampledValueForOCPP20(sampledValueTemplate, value, context, phase) } // SoC measurand - const socMeasurand = buildSocMeasurandValue(chargingStation, connectorId) + const socMeasurand = buildSocMeasurandValue(chargingStation, connectorId, evseId) if (socMeasurand != null) { const socSampledValue = buildVersionedSampledValue( socMeasurand.template, @@ -1777,7 +1825,7 @@ export const buildMeterValue = ( ) } // Voltage measurand - const voltageMeasurand = buildVoltageMeasurandValue(chargingStation, connectorId) + const voltageMeasurand = buildVoltageMeasurandValue(chargingStation, connectorId, evseId) if (voltageMeasurand != null) { addMainVoltageToMeterValue( chargingStation, @@ -1809,7 +1857,12 @@ export const buildMeterValue = ( } } // Energy.Active.Import.Register measurand - const energyMeasurand = buildEnergyMeasurandValue(chargingStation, connectorId, interval) + const energyMeasurand = buildEnergyMeasurandValue( + chargingStation, + connectorId, + interval, + evseId + ) if (energyMeasurand != null) { updateConnectorEnergyValues(connector, energyMeasurand.value) const unitDivider = @@ -1842,7 +1895,7 @@ export const buildMeterValue = ( ) } // Power.Active.Import measurand - const powerMeasurand = buildPowerMeasurandValue(chargingStation, connectorId) + const powerMeasurand = buildPowerMeasurandValue(chargingStation, connectorId, evseId) if (powerMeasurand?.values.allPhases != null) { const powerSampledValue = buildVersionedSampledValue( powerMeasurand.template, @@ -1851,7 +1904,7 @@ export const buildMeterValue = ( meterValue.sampledValue.push(powerSampledValue) } // Current.Import measurand - const currentMeasurand = buildCurrentMeasurandValue(chargingStation, connectorId) + const currentMeasurand = buildCurrentMeasurandValue(chargingStation, connectorId, evseId) if (currentMeasurand?.values.allPhases != null) { const currentSampledValue = buildVersionedSampledValue( currentMeasurand.template, @@ -1987,6 +2040,7 @@ const getSampledValueTemplate = ( chargingStation: ChargingStation, connectorId: number, measurand: MeterValueMeasurand = MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + evseId?: number, phase?: MeterValuePhase ): SampledValueTemplate | undefined => { const onPhaseStr = phase != null ? `on phase ${phase} ` : '' @@ -2010,9 +2064,25 @@ const getSampledValueTemplate = ( ) return } - const sampledValueTemplates = - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - chargingStation.getConnectorStatus(connectorId)!.MeterValues + let sampledValueTemplates: SampledValueTemplate[] | undefined + if (evseId != null) { + const evseStatus = chargingStation.getEvseStatus(evseId) + if (evseStatus != null) { + if (isNotEmptyArray(evseStatus.MeterValues)) { + sampledValueTemplates = evseStatus.MeterValues + } else { + const connectorTemplates: SampledValueTemplate[] = [] + for (const connectorStatus of evseStatus.connectors.values()) { + if (isNotEmptyArray(connectorStatus.MeterValues)) { + connectorTemplates.push(...connectorStatus.MeterValues) + } + } + sampledValueTemplates = isNotEmptyArray(connectorTemplates) ? connectorTemplates : undefined + } + } + } else { + sampledValueTemplates = chargingStation.getConnectorStatus(connectorId)?.MeterValues + } for ( let index = 0; isNotEmptyArray(sampledValueTemplates) && index < sampledValueTemplates.length; diff --git a/src/charging-station/ui-server/UIHttpServer.ts b/src/charging-station/ui-server/UIHttpServer.ts index 08268713..463613b8 100644 --- a/src/charging-station/ui-server/UIHttpServer.ts +++ b/src/charging-station/ui-server/UIHttpServer.ts @@ -27,19 +27,15 @@ import { DEFAULT_RATE_LIMIT, DEFAULT_RATE_WINDOW, } from './UIServerSecurity.js' -import { isProtocolAndVersionSupported } from './UIServerUtils.js' +import { HttpMethod, isProtocolAndVersionSupported } from './UIServerUtils.js' const moduleName = 'UIHttpServer' const rateLimiter = createRateLimiter(DEFAULT_RATE_LIMIT, DEFAULT_RATE_WINDOW) -enum HttpMethods { - GET = 'GET', - PATCH = 'PATCH', - POST = 'POST', - PUT = 'PUT', -} - +/** + * @deprecated Use UIMCPServer (ApplicationProtocol.MCP) instead. Will be removed in a future major version. + */ export class UIHttpServer extends AbstractUIServer { protected override readonly uiServerType = 'UI HTTP Server' @@ -100,6 +96,9 @@ export class UIHttpServer extends AbstractUIServer { } } + /** + * @deprecated Use UIMCPServer (ApplicationProtocol.MCP) instead. Will be removed in a future major version. + */ public start (): void { this.httpServer.on('request', this.requestListener.bind(this)) this.startHttpServer() @@ -174,7 +173,7 @@ export class UIHttpServer extends AbstractUIServer { } }) - if (req.method !== HttpMethods.POST) { + if (req.method !== HttpMethod.POST) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new BaseError(`Unsupported HTTP method: '${req.method}'`) } diff --git a/src/charging-station/ui-server/UIMCPServer.ts b/src/charging-station/ui-server/UIMCPServer.ts new file mode 100644 index 00000000..2d4934f4 --- /dev/null +++ b/src/charging-station/ui-server/UIMCPServer.ts @@ -0,0 +1,482 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import type { IncomingMessage, ServerResponse } from 'node:http' + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { AbstractUIService } from './ui-services/AbstractUIService.js' + +import { BaseError } from '../../exception/index.js' +import { + OCPPVersion, + type ProcedureName, + type ProtocolRequest, + type ProtocolResponse, + ProtocolVersion, + type RequestPayload, + type ResponsePayload, + type UIServerConfiguration, + type UUIDv4, +} from '../../types/index.js' +import { generateUUID, logger } from '../../utils/index.js' +import { AbstractUIServer } from './AbstractUIServer.js' +import { + mcpToolSchemas, + ocppSchemaMapping, + registerMCPLogTools, + registerMCPResources, + registerMCPSchemaResources, +} from './mcp/index.js' +import { + createRateLimiter, + DEFAULT_MAX_PAYLOAD_SIZE, + DEFAULT_RATE_LIMIT, + DEFAULT_RATE_WINDOW, +} from './UIServerSecurity.js' +import { HttpMethod } from './UIServerUtils.js' + +const moduleName = 'UIMCPServer' + +const MCP_TOOL_TIMEOUT_MS = 30_000 + +const rateLimiter = createRateLimiter(DEFAULT_RATE_LIMIT, DEFAULT_RATE_WINDOW) + +export class UIMCPServer extends AbstractUIServer { + protected override readonly uiServerType = 'UI MCP Server' + + private ocppSchemaCache: Map + + private readonly pendingMcpRequests: Map< + UUIDv4, + { + reject: (error: Error) => void + resolve: (payload: ResponsePayload) => void + timeout: ReturnType + } + > + + private service: AbstractUIService | undefined + + public constructor (protected override readonly uiServerConfiguration: UIServerConfiguration) { + super(uiServerConfiguration) + this.pendingMcpRequests = new Map() + this.ocppSchemaCache = new Map() + } + + private static createToolErrorResponse (error: string): CallToolResult { + return { + content: [{ text: JSON.stringify({ error, status: 'failure' }), type: 'text' as const }], + isError: true, + } + } + + private static createToolResponse (payload: unknown): CallToolResult { + return { content: [{ text: JSON.stringify(payload), type: 'text' as const }] } + } + + public override hasResponseHandler (uuid: UUIDv4): boolean { + return super.hasResponseHandler(uuid) || this.pendingMcpRequests.has(uuid) + } + + public sendRequest (_request: ProtocolRequest): void { + logger.warn( + `${this.logPrefix(moduleName, 'sendRequest')} Server-initiated requests not supported in stateless MCP mode` + ) + } + + public sendResponse (response: ProtocolResponse): void { + const [uuid, payload] = response + const pending = this.pendingMcpRequests.get(uuid) + if (pending != null) { + clearTimeout(pending.timeout) + this.pendingMcpRequests.delete(uuid) + pending.resolve(payload) + } else { + logger.error( + `${this.logPrefix(moduleName, 'sendResponse')} Response for unknown request id: ${uuid}` + ) + } + } + + public start (): void { + const version = ProtocolVersion['0.0.1'] + this.registerProtocolVersionUIService(version) + this.service = this.uiServices.get(version) + this.ocppSchemaCache = this.loadOcppSchemas() + + this.httpServer.on('request', (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`) + if (url.pathname !== '/mcp') { + res.writeHead(404, { 'Content-Type': 'text/plain' }).end('404 Not Found') + if (!req.complete) { + req.destroy() + } + return + } + + const clientIp = req.socket.remoteAddress ?? 'unknown' + if (!rateLimiter(clientIp)) { + res.writeHead(429, { 'Content-Type': 'text/plain' }).end('429 Too Many Requests') + return + } + + let authError: Error | undefined + // authenticate() is synchronous — authError is set before the if-check + this.authenticate(req, err => { + authError = err + }) + if (authError != null) { + res + .writeHead(401, { + 'Content-Type': 'text/plain', + 'WWW-Authenticate': 'Basic realm=users', + }) + .end('401 Unauthorized') + return + } + + this.handleMcpRequest(req, res).catch((error: unknown) => { + logger.error( + `${this.logPrefix(moduleName, 'start.httpServer.request')} Unhandled MCP request error:`, + error + ) + }) + }) + + this.startHttpServer() + } + + public override stop (): void { + for (const [uuid, pending] of [...this.pendingMcpRequests]) { + clearTimeout(pending.timeout) + this.pendingMcpRequests.delete(uuid) + pending.reject(new BaseError('Server stopping')) + } + super.stop() + } + + protected getSchemaBaseDir (): string { + return join(dirname(fileURLToPath(import.meta.url)), 'assets', 'json-schemas', 'ocpp') + } + + private checkVersionCompatibility ( + hashIds: string[] | undefined, + ocpp16Payload: Record | undefined, + ocpp20Payload: Record | undefined, + procedureName: ProcedureName + ): CallToolResult | undefined { + if (ocpp16Payload == null && ocpp20Payload == null) { + return undefined + } + const expectedVersion = ocpp16Payload != null ? OCPPVersion.VERSION_16 : OCPPVersion.VERSION_20 + const payloadLabel = ocpp16Payload != null ? 'ocpp16Payload' : 'ocpp20Payload' + const alternativeLabel = ocpp16Payload != null ? 'ocpp20Payload' : 'ocpp16Payload' + const stationsToCheck = + hashIds != null + ? hashIds + .map(id => { + const data = this.getChargingStationData(id) + return data != null + ? { hashId: id, version: data.stationInfo.ocppVersion } + : undefined + }) + .filter(s => s != null) + : this.listChargingStationData().map(data => ({ + hashId: data.stationInfo.hashId, + version: data.stationInfo.ocppVersion, + })) + const mismatched = stationsToCheck.filter(s => { + if (expectedVersion === OCPPVersion.VERSION_16) { + return s.version !== OCPPVersion.VERSION_16 + } + return s.version !== OCPPVersion.VERSION_20 && s.version !== OCPPVersion.VERSION_201 + }) + if (mismatched.length > 0) { + const ids = mismatched.map(s => s.hashId).join(', ') + const versions = [...new Set(mismatched.map(s => s.version ?? 'unknown'))].join(', ') + return UIMCPServer.createToolErrorResponse( + `Station(s) ${ids} run OCPP ${versions} but received ${payloadLabel} for '${procedureName}'. ` + + `Use ${alternativeLabel} instead, or target only compatible stations via hashIds.` + ) + } + return undefined + } + + private closeTransportSafely (transport: StreamableHTTPServerTransport): void { + transport.close().catch((error: unknown) => { + logger.error( + `${this.logPrefix(moduleName, 'handleMcpRequest')} MCP transport close error:`, + error + ) + }) + } + + // Per the MCP SDK design, McpServer.connect() overwrites a single internal _transport field. + // A new McpServer must be created per request to avoid transport cross-talk under concurrency. + // Tool registration is ~12µs for 33 tools (Map.set + closure allocation) — negligible. + private createMcpServer (): McpServer { + const mcpServer = new McpServer({ + name: 'e-mobility-charging-stations-simulator', + version: ProtocolVersion['0.0.1'], + }) + + for (const [procedureName, schema] of mcpToolSchemas) { + mcpServer.registerTool( + procedureName, + { + description: schema.description, + inputSchema: schema.inputSchema.shape, + }, + async (input: Record) => { + return await this.invokeProcedure(procedureName, input as RequestPayload, this.service) + } + ) + } + + registerMCPResources(mcpServer, this) + registerMCPSchemaResources(mcpServer) + registerMCPLogTools(mcpServer) + this.injectOcppJsonSchemas(mcpServer) + + return mcpServer + } + + private async handleMcpRequest (req: IncomingMessage, res: ServerResponse): Promise { + const mcpServer = this.createMcpServer() + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }) + try { + await mcpServer.connect(transport) + } catch (error: unknown) { + logger.error(`${this.logPrefix(moduleName, 'handleMcpRequest')} MCP connect error:`, error) + this.closeTransportSafely(transport) + this.sendErrorResponse(res, 500) + return + } + + const cleanup = (): void => { + this.closeTransportSafely(transport) + mcpServer.close().catch((error: unknown) => { + logger.error( + `${this.logPrefix(moduleName, 'handleMcpRequest')} MCP server close error:`, + error + ) + }) + } + + try { + if (req.method === HttpMethod.POST) { + const body = await this.readRequestBody(req) + res.on('close', cleanup) + await transport.handleRequest(req, res, body) + } else if (req.method === HttpMethod.GET || req.method === HttpMethod.DELETE) { + res.on('close', cleanup) + await transport.handleRequest(req, res) + } else { + this.sendErrorResponse(res, 405) + cleanup() + } + } catch (error: unknown) { + logger.error(`${this.logPrefix(moduleName, 'handleMcpRequest')} MCP transport error:`, error) + const isBadRequest = + error instanceof SyntaxError || + (error instanceof Error && error.message.includes('Payload too large')) + this.sendErrorResponse(res, isBadRequest ? 400 : 500) + } + } + + private injectOcppJsonSchemas (mcpServer: McpServer): void { + if (this.ocppSchemaCache.size === 0) { + return + } + // Access MCP SDK internal handler map — pinned to @modelcontextprotocol/sdk@~1.27.x + // The SDK does not provide a public API for wrapping existing handlers. + // setRequestHandler() replaces handlers entirely, losing Zod→JSON Schema conversion. + const handlers = Reflect.get(mcpServer.server, '_requestHandlers') as + | Map Promise> + | undefined + if (handlers == null || !(handlers instanceof Map)) { + logger.warn( + `${this.logPrefix(moduleName, 'injectOcppJsonSchemas')} MCP SDK internal API changed — OCPP schema injection disabled` + ) + return + } + const originalHandler = handlers.get('tools/list') + if (originalHandler == null) { + return + } + handlers.set('tools/list', async (...args: unknown[]) => { + const result = (await originalHandler(...args)) as { + tools: { inputSchema: { properties: Record }; name: string }[] + } + for (const tool of result.tools) { + const schemas = this.ocppSchemaCache.get(tool.name) + if (schemas == null) { + continue + } + if (schemas.ocpp16 != null && tool.inputSchema.properties.ocpp16Payload != null) { + tool.inputSchema.properties.ocpp16Payload = { + ...schemas.ocpp16, + description: `OCPP 1.6 ${tool.name} request payload`, + } + } + if (schemas.ocpp20 != null && tool.inputSchema.properties.ocpp20Payload != null) { + tool.inputSchema.properties.ocpp20Payload = { + ...schemas.ocpp20, + description: `OCPP 2.0.1 ${tool.name} request payload`, + } + } + } + return result + }) + } + + private async invokeProcedure ( + procedureName: ProcedureName, + input: RequestPayload, + service: AbstractUIService | undefined + ): Promise { + if (service == null) { + return UIMCPServer.createToolErrorResponse('UI service not available') + } + + const { ocpp16Payload, ocpp20Payload, ...rest } = input as RequestPayload & { + ocpp16Payload?: Record + ocpp20Payload?: Record + } + + if (ocpp16Payload != null && ocpp20Payload != null) { + return UIMCPServer.createToolErrorResponse( + 'Cannot provide both ocpp16Payload and ocpp20Payload. Use ocpp16Payload for OCPP 1.6 stations or ocpp20Payload for OCPP 2.0 stations.' + ) + } + + const versionMismatchError = this.checkVersionCompatibility( + rest.hashIds, + ocpp16Payload, + ocpp20Payload, + procedureName + ) + if (versionMismatchError != null) { + return versionMismatchError + } + + const flatPayload = { + ...rest, + ...(ocpp16Payload ?? ocpp20Payload), + } as RequestPayload + + const uuid = generateUUID() + + return await new Promise(resolve => { + const timeout = setTimeout(() => { + this.pendingMcpRequests.delete(uuid) + resolve(UIMCPServer.createToolErrorResponse(`Tool '${procedureName}' timed out`)) + }, MCP_TOOL_TIMEOUT_MS) + + this.pendingMcpRequests.set(uuid, { + reject: (error: Error) => { + resolve(UIMCPServer.createToolErrorResponse(error.message)) + }, + resolve: (payload: ResponsePayload) => { + resolve(UIMCPServer.createToolResponse(payload)) + }, + timeout, + }) + + const request = this.buildProtocolRequest(uuid, procedureName, flatPayload) + service + .requestHandler(request) + .then(directResponse => { + if (directResponse != null) { + const pending = this.pendingMcpRequests.get(uuid) + if (pending != null) { + clearTimeout(pending.timeout) + this.pendingMcpRequests.delete(uuid) + const [, payload] = directResponse + resolve(UIMCPServer.createToolResponse(payload)) + } + } + return undefined + }) + .catch((error: unknown) => { + const pending = this.pendingMcpRequests.get(uuid) + if (pending != null) { + clearTimeout(pending.timeout) + this.pendingMcpRequests.delete(uuid) + } + resolve( + UIMCPServer.createToolErrorResponse( + error instanceof Error ? error.message : String(error) + ) + ) + }) + }) + } + + private loadOcppSchemas (): Map { + const cache = new Map() + const baseDir = this.getSchemaBaseDir() + for (const [procedureName, mapping] of ocppSchemaMapping) { + const entry: { ocpp16?: unknown; ocpp20?: unknown } = {} + if (mapping.ocpp16 != null) { + try { + entry.ocpp16 = JSON.parse( + readFileSync(join(baseDir, OCPPVersion.VERSION_16, `${mapping.ocpp16}.json`), 'utf8') + ) + } catch { + logger.warn( + `${this.logPrefix(moduleName, 'loadOcppSchemas')} Failed to load OCPP 1.6 schema for ${procedureName}` + ) + } + } + if (mapping.ocpp20 != null) { + try { + entry.ocpp20 = JSON.parse( + readFileSync(join(baseDir, OCPPVersion.VERSION_20, `${mapping.ocpp20}.json`), 'utf8') + ) + } catch { + logger.warn( + `${this.logPrefix(moduleName, 'loadOcppSchemas')} Failed to load OCPP 2.0 schema for ${procedureName}` + ) + } + } + if (entry.ocpp16 != null || entry.ocpp20 != null) { + cache.set(procedureName, entry) + } + } + if (cache.size > 0) { + logger.info( + `${this.logPrefix(moduleName, 'loadOcppSchemas')} OCPP JSON schema injection enabled for ${cache.size.toString()} tool(s)` + ) + } + return cache + } + + private async readRequestBody (req: IncomingMessage): Promise { + const chunks: Buffer[] = [] + let received = 0 + for await (const chunk of req) { + received += (chunk as Buffer).length + if (received > DEFAULT_MAX_PAYLOAD_SIZE) { + throw new BaseError('Payload too large') + } + chunks.push(chunk as Buffer) + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) + } + + private sendErrorResponse (res: ServerResponse, statusCode: number): void { + if (res.headersSent) return + const messages: Record = { + 400: '400 Bad Request', + 405: '405 Method Not Allowed', + 500: '500 Internal Server Error', + } + res + .writeHead(statusCode, { 'Content-Type': 'text/plain' }) + .end(messages[statusCode] ?? `${statusCode.toString()} Error`) + } +} diff --git a/src/charging-station/ui-server/UIServerFactory.ts b/src/charging-station/ui-server/UIServerFactory.ts index b0c87bba..ce8d3993 100644 --- a/src/charging-station/ui-server/UIServerFactory.ts +++ b/src/charging-station/ui-server/UIServerFactory.ts @@ -10,6 +10,7 @@ import { } from '../../types/index.js' import { logger, logPrefix } from '../../utils/index.js' import { UIHttpServer } from './UIHttpServer.js' +import { UIMCPServer } from './UIMCPServer.js' import { isLoopback } from './UIServerUtils.js' import { UIWebSocketServer } from './UIWebSocketServer.js' @@ -48,16 +49,32 @@ export class UIServerFactory { logger.warn(`${UIServerFactory.logPrefix()} ${logMsg}`) } if ( - uiServerConfiguration.type === ApplicationProtocol.WS && + (uiServerConfiguration.type === ApplicationProtocol.WS || + uiServerConfiguration.type === ApplicationProtocol.MCP) && uiServerConfiguration.version !== ApplicationProtocolVersion.VERSION_11 ) { const logMsg = `Only version ${ApplicationProtocolVersion.VERSION_11} with application protocol type '${uiServerConfiguration.type}' is supported in '${ConfigurationSection.uiServer}' configuration section. Falling back to version ${ApplicationProtocolVersion.VERSION_11}` logger.warn(`${UIServerFactory.logPrefix()} ${logMsg}`) uiServerConfiguration.version = ApplicationProtocolVersion.VERSION_11 } + if ( + uiServerConfiguration.type === ApplicationProtocol.MCP && + uiServerConfiguration.authentication?.enabled === true && + uiServerConfiguration.authentication.type === AuthenticationType.PROTOCOL_BASIC_AUTH + ) { + throw new BaseError( + `'${uiServerConfiguration.authentication.type}' authentication type with application protocol type '${uiServerConfiguration.type}' is not supported in '${ConfigurationSection.uiServer}' configuration section` + ) + } switch (uiServerConfiguration.type) { - case ApplicationProtocol.HTTP: + case ApplicationProtocol.HTTP: { + const logMsg = `Application protocol type '${uiServerConfiguration.type}' is deprecated in '${ConfigurationSection.uiServer}' configuration section. Use '${ApplicationProtocol.MCP}' instead` + logger.warn(`${UIServerFactory.logPrefix()} ${logMsg}`) + // eslint-disable-next-line @typescript-eslint/no-deprecated return new UIHttpServer(uiServerConfiguration) + } + case ApplicationProtocol.MCP: + return new UIMCPServer(uiServerConfiguration) case ApplicationProtocol.WS: default: if ( diff --git a/src/charging-station/ui-server/UIServerUtils.ts b/src/charging-station/ui-server/UIServerUtils.ts index dbed5ba1..b789823c 100644 --- a/src/charging-station/ui-server/UIServerUtils.ts +++ b/src/charging-station/ui-server/UIServerUtils.ts @@ -4,6 +4,14 @@ import { BaseError } from '../../exception/index.js' import { Protocol, ProtocolVersion } from '../../types/index.js' import { getErrorMessage, isEmpty, logger, logPrefix } from '../../utils/index.js' +export enum HttpMethod { + DELETE = 'DELETE', + GET = 'GET', + PATCH = 'PATCH', + POST = 'POST', + PUT = 'PUT', +} + export const getUsernameAndPasswordFromAuthorizationToken = ( authorizationToken: string, next: (err?: Error) => void diff --git a/src/charging-station/ui-server/mcp/MCPResourceHandlers.ts b/src/charging-station/ui-server/mcp/MCPResourceHandlers.ts new file mode 100644 index 00000000..ec134516 --- /dev/null +++ b/src/charging-station/ui-server/mcp/MCPResourceHandlers.ts @@ -0,0 +1,309 @@ +import { type McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js' +import { open, readdir, readFile, stat } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { z } from 'zod' + +import type { AbstractUIServer } from '../AbstractUIServer.js' + +import { ConfigurationSection, type LogConfiguration, OCPPVersion } from '../../../types/index.js' +import { Configuration } from '../../../utils/Configuration.js' + +const MAX_TAIL_LINES = 5000 +const DEFAULT_TAIL_LINES = 200 +const TAIL_BYTES = 65_536 + +const getLogFilePath = (configField: 'errorFile' | 'file', date?: string): string | undefined => { + const logConfig = Configuration.getConfigurationSection( + ConfigurationSection.log + ) + const relativePath = logConfig[configField] + if (relativePath == null) { + return undefined + } + if (logConfig.rotate !== true) { + return resolve(relativePath) + } + const now = new Date() + const localDate = + date ?? + `${now.getFullYear().toString()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}` + const dir = dirname(resolve(relativePath)) + const baseName = configField === 'file' ? `combined-${localDate}.log` : `error-${localDate}.log` + return join(dir, baseName) +} + +const tailFile = async ( + filePath: string, + maxLines: number +): Promise<{ lines: string[]; totalLines: number }> => { + const fileStat = await stat(filePath) + const fileHandle = await open(filePath, 'r') + try { + const fullContent = fileStat.size <= TAIL_BYTES + const readSize = Math.min(TAIL_BYTES, fileStat.size) + const position = Math.max(0, fileStat.size - readSize) + const buffer = Buffer.alloc(readSize) + await fileHandle.read(buffer, 0, readSize, position) + const allLines = buffer.toString('utf8').split('\n') + if (position > 0) { + allLines.shift() + } + const totalLines = fullContent ? allLines.length : -1 + return { lines: allLines.slice(-maxLines), totalLines } + } finally { + await fileHandle.close() + } +} + +export const registerMCPResources = (server: McpServer, uiServer: AbstractUIServer): void => { + server.registerResource( + 'station-list', + 'station://list', + { + description: 'List all charging stations with their current status and info', + mimeType: 'application/json', + }, + _uri => ({ + contents: [ + { + mimeType: 'application/json', + text: JSON.stringify(uiServer.listChargingStationData(), null, 2), + uri: 'station://list', + }, + ], + }) + ) + + server.registerResource( + 'station-by-id', + new ResourceTemplate('station://{hashId}', { list: undefined }), + { + description: 'Get data for a specific charging station by its hash ID', + mimeType: 'application/json', + }, + (uri, { hashId }) => { + const data = uiServer.getChargingStationData(hashId as string) + return { + contents: [ + { + mimeType: 'application/json', + text: + data != null + ? JSON.stringify(data, null, 2) + : JSON.stringify({ error: `Station '${hashId as string}' not found` }), + uri: uri.href, + }, + ], + } + } + ) + + server.registerResource( + 'template-list', + 'template://list', + { + description: 'List all available charging station configuration templates', + mimeType: 'application/json', + }, + _uri => ({ + contents: [ + { + mimeType: 'application/json', + text: JSON.stringify(uiServer.getChargingStationTemplates(), null, 2), + uri: 'template://list', + }, + ], + }) + ) +} + +const OCPP_SCHEMA_VERSIONS = [OCPPVersion.VERSION_16, OCPPVersion.VERSION_20] as const + +const getSchemaBaseDir = (): string => { + const currentDir = dirname(fileURLToPath(import.meta.url)) + return join(currentDir, 'assets', 'json-schemas', 'ocpp') +} + +// Path traversal guard: validate that the resolved path stays within the expected base directory. +const isPathWithinBase = (candidatePath: string, baseDir: string): boolean => { + const resolvedBase = resolve(baseDir) + const resolvedCandidate = resolve(candidatePath) + return resolvedCandidate.startsWith(`${resolvedBase}/`) || resolvedCandidate === resolvedBase +} + +export const registerMCPSchemaResources = (server: McpServer): void => { + for (const version of OCPP_SCHEMA_VERSIONS) { + server.registerResource( + `ocpp-${version}-schema-list`, + `schema://ocpp/${version}`, + { + description: `List all available OCPP ${version} JSON command schemas`, + mimeType: 'application/json', + }, + async _uri => { + try { + const baseDir = getSchemaBaseDir() + const schemaDir = join(baseDir, version) + if (!isPathWithinBase(schemaDir, baseDir)) { + return { + contents: [ + { + mimeType: 'application/json', + text: JSON.stringify({ error: `Invalid OCPP version '${version}'` }), + uri: `schema://ocpp/${version}`, + }, + ], + } + } + const files = await readdir(schemaDir) + const commands = files + .filter(f => f.endsWith('.json')) + .map(f => f.replace('.json', '')) + .sort((a, b) => a.localeCompare(b)) + return { + contents: [ + { + mimeType: 'application/json', + text: JSON.stringify({ commands, count: commands.length, version }, null, 2), + uri: `schema://ocpp/${version}`, + }, + ], + } + } catch { + return { + contents: [ + { + mimeType: 'application/json', + text: JSON.stringify({ error: `OCPP ${version} schemas not available` }), + uri: `schema://ocpp/${version}`, + }, + ], + } + } + } + ) + } + + server.registerResource( + 'ocpp-schema-by-command', + new ResourceTemplate('schema://ocpp/{version}/{command}', { list: undefined }), + { + description: + 'Full OCPP JSON schema for a specific command (e.g., schema://ocpp/1.6/Authorize or schema://ocpp/2.0/AuthorizeRequest)', + mimeType: 'application/json', + }, + async (uri, { command, version }) => { + try { + const versionStr = version as string + const commandStr = command as string + const baseDir = getSchemaBaseDir() + const schemaPath = join(baseDir, versionStr, `${commandStr}.json`) + if (!isPathWithinBase(schemaPath, baseDir)) { + return { + contents: [ + { + mimeType: 'application/json', + text: JSON.stringify({ + error: `Invalid schema path for '${commandStr}' in OCPP ${versionStr}`, + }), + uri: uri.href, + }, + ], + } + } + const content = await readFile(schemaPath, 'utf8') + return { + contents: [ + { + mimeType: 'application/json', + text: content, + uri: uri.href, + }, + ], + } + } catch { + return { + contents: [ + { + mimeType: 'application/json', + text: JSON.stringify({ + error: `Schema '${command as string}' not found for OCPP ${version as string}`, + }), + uri: uri.href, + }, + ], + } + } + } + ) +} + +const registerLogReadTool = ( + server: McpServer, + name: string, + configField: 'errorFile' | 'file', + description: string +): void => { + const label = configField === 'file' ? 'Log' : 'Error log' + server.registerTool( + name, + { + annotations: { readOnlyHint: true }, + description, + inputSchema: { + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe('Log file date in YYYY-MM-DD format. Defaults to current local date'), + tail: z + .number() + .int() + .positive() + .max(MAX_TAIL_LINES) + .default(DEFAULT_TAIL_LINES) + .describe('Number of lines to return from the end of the log'), + }, + }, + async ({ date, tail }) => { + try { + const logPath = getLogFilePath(configField, date) + if (logPath == null) { + return { + content: [{ text: `${label} file not configured`, type: 'text' as const }], + isError: true, + } + } + const { lines, totalLines } = await tailFile(logPath, tail) + const meta = + totalLines >= 0 + ? `Showing last ${String(lines.length)} of ${String(totalLines)} lines` + : `Showing last ${String(lines.length)} lines` + return { + content: [{ text: `${meta}\n\n${lines.join('\n')}`, type: 'text' as const }], + } + } catch { + return { + content: [{ text: `${label} file not available`, type: 'text' as const }], + isError: true, + } + } + } + ) +} + +export const registerMCPLogTools = (server: McpServer): void => { + registerLogReadTool( + server, + 'readCombinedLog', + 'file', + 'Read recent entries from the combined simulator log file. Returns the last N lines (default 200, max 5000). Optionally specify a date (YYYY-MM-DD) for rotated log files.' + ) + registerLogReadTool( + server, + 'readErrorLog', + 'errorFile', + 'Read recent entries from the error log file. Returns the last N lines (default 200, max 5000). Optionally specify a date (YYYY-MM-DD) for rotated log files.' + ) +} diff --git a/src/charging-station/ui-server/mcp/MCPToolSchemas.ts b/src/charging-station/ui-server/mcp/MCPToolSchemas.ts new file mode 100644 index 00000000..2eee1257 --- /dev/null +++ b/src/charging-station/ui-server/mcp/MCPToolSchemas.ts @@ -0,0 +1,414 @@ +import { z } from 'zod' + +import { ProcedureName } from '../../../types/index.js' + +export interface MCPToolSchema { + description: string + inputSchema: z.ZodObject +} + +const hashIds = z + .array(z.string()) + .optional() + .describe('Target station hash IDs (omit for all stations)') + +const connectorIds = z + .array(z.number().int().positive()) + .optional() + .describe('Target connector IDs') + +const broadcastInputSchema = z.object({ + connectorIds, + hashIds, +}) + +const emptyInputSchema = z.object({}) + +const chargingStationOptionsSchema = z.object({ + autoRegister: z.boolean().optional().describe('Set stations as registered at boot notification'), + autoStart: z.boolean().optional().describe('Enable automatic start of added charging station'), + enableStatistics: z.boolean().optional().describe('Enable charging station statistics'), + ocppStrictCompliance: z + .boolean() + .optional() + .describe('Enable strict OCPP specifications adherence'), + persistentConfiguration: z + .boolean() + .optional() + .describe('Enable persistent OCPP parameters storage'), + stopTransactionsOnStopped: z + .boolean() + .optional() + .describe('Enable stop transactions on station stop'), + supervisionUrls: z + .union([z.url(), z.array(z.url())]) + .optional() + .describe('OCPP server supervision URL(s)'), +}) + +/** Maps ProcedureName to OCPP JSON Schema file base names per version */ +export const ocppSchemaMapping = new Map([ + [ProcedureName.AUTHORIZE, { ocpp16: 'Authorize', ocpp20: 'AuthorizeRequest' }], + [ + ProcedureName.BOOT_NOTIFICATION, + { ocpp16: 'BootNotification', ocpp20: 'BootNotificationRequest' }, + ], + [ProcedureName.DATA_TRANSFER, { ocpp16: 'DataTransfer', ocpp20: 'DataTransferRequest' }], + [ProcedureName.DIAGNOSTICS_STATUS_NOTIFICATION, { ocpp16: 'DiagnosticsStatusNotification' }], + [ + ProcedureName.FIRMWARE_STATUS_NOTIFICATION, + { ocpp16: 'FirmwareStatusNotification', ocpp20: 'FirmwareStatusNotificationRequest' }, + ], + [ProcedureName.GET_15118_EV_CERTIFICATE, { ocpp20: 'Get15118EVCertificateRequest' }], + [ProcedureName.GET_CERTIFICATE_STATUS, { ocpp20: 'GetCertificateStatusRequest' }], + [ProcedureName.LOG_STATUS_NOTIFICATION, { ocpp20: 'LogStatusNotificationRequest' }], + [ProcedureName.METER_VALUES, { ocpp16: 'MeterValues', ocpp20: 'MeterValuesRequest' }], + [ProcedureName.NOTIFY_CUSTOMER_INFORMATION, { ocpp20: 'NotifyCustomerInformationRequest' }], + [ProcedureName.NOTIFY_REPORT, { ocpp20: 'NotifyReportRequest' }], + [ProcedureName.SECURITY_EVENT_NOTIFICATION, { ocpp20: 'SecurityEventNotificationRequest' }], + [ProcedureName.SIGN_CERTIFICATE, { ocpp20: 'SignCertificateRequest' }], + [ProcedureName.START_TRANSACTION, { ocpp16: 'StartTransaction' }], + [ + ProcedureName.STATUS_NOTIFICATION, + { ocpp16: 'StatusNotification', ocpp20: 'StatusNotificationRequest' }, + ], + [ProcedureName.STOP_TRANSACTION, { ocpp16: 'StopTransaction' }], + [ProcedureName.TRANSACTION_EVENT, { ocpp20: 'TransactionEventRequest' }], +]) + +const ocpp16PayloadField = z + .record(z.string(), z.unknown()) + .optional() + .describe('OCPP 1.6 request payload') + +const ocpp20PayloadField = z + .record(z.string(), z.unknown()) + .optional() + .describe('OCPP 2.0.1 request payload') + +const buildOcppInputSchema = (mapping: { + ocpp16?: string + ocpp20?: string +}): z.ZodObject => { + const fields: Record = { connectorIds, hashIds } + if (mapping.ocpp16 != null) { + fields.ocpp16Payload = ocpp16PayloadField + } + if (mapping.ocpp20 != null) { + fields.ocpp20Payload = ocpp20PayloadField + } + return z.object(fields) +} + +const buildVersionAffinity = (mapping: { ocpp16?: string; ocpp20?: string }): string => { + if (mapping.ocpp16 != null && mapping.ocpp20 != null) return '(OCPP 1.6 & 2.0.x)' + if (mapping.ocpp16 != null) return '(OCPP 1.6 only)' + return '(OCPP 2.0.x only)' +} + +const getMapping = (name: ProcedureName): { ocpp16?: string; ocpp20?: string } => + ocppSchemaMapping.get(name) ?? {} + +const ocppDescription = (base: string, name: ProcedureName): string => { + const mapping = getMapping(name) + const affinity = buildVersionAffinity(mapping) + const hint = + mapping.ocpp16 != null && mapping.ocpp20 != null + ? '. Provide ocpp16Payload for 1.6 stations, ocpp20Payload for 2.0 stations.' + : '' + return `${base} ${affinity}${hint}` +} + +const ocppInputSchema = (name: ProcedureName): z.ZodObject => + buildOcppInputSchema(getMapping(name)) + +export const mcpToolSchemas = new Map([ + [ + ProcedureName.ADD_CHARGING_STATIONS, + { + description: 'Add new charging stations from a configuration template', + inputSchema: z.object({ + numberOfStations: z + .number() + .int() + .positive() + .describe('Number of charging stations to add'), + options: chargingStationOptionsSchema + .optional() + .describe('Configuration overrides for the new stations'), + template: z.string().describe('Name of the charging station template to use'), + }), + }, + ], + [ + ProcedureName.AUTHORIZE, + { + description: ocppDescription('Send an Authorize request', ProcedureName.AUTHORIZE), + inputSchema: ocppInputSchema(ProcedureName.AUTHORIZE), + }, + ], + [ + ProcedureName.BOOT_NOTIFICATION, + { + description: ocppDescription( + 'Send a BootNotification request', + ProcedureName.BOOT_NOTIFICATION + ), + inputSchema: ocppInputSchema(ProcedureName.BOOT_NOTIFICATION), + }, + ], + [ + ProcedureName.CLOSE_CONNECTION, + { + description: + 'Close the WebSocket connection to the OCPP server for one or more charging stations', + inputSchema: broadcastInputSchema, + }, + ], + [ + ProcedureName.DATA_TRANSFER, + { + description: ocppDescription('Send a DataTransfer request', ProcedureName.DATA_TRANSFER), + inputSchema: ocppInputSchema(ProcedureName.DATA_TRANSFER), + }, + ], + [ + ProcedureName.DELETE_CHARGING_STATIONS, + { + description: 'Delete one or more charging stations from the simulator', + inputSchema: z.object({ + deleteConfiguration: z + .boolean() + .optional() + .describe('Whether to delete persistent configuration files'), + hashIds, + }), + }, + ], + [ + ProcedureName.DIAGNOSTICS_STATUS_NOTIFICATION, + { + description: ocppDescription( + 'Send a DiagnosticsStatusNotification', + ProcedureName.DIAGNOSTICS_STATUS_NOTIFICATION + ), + inputSchema: ocppInputSchema(ProcedureName.DIAGNOSTICS_STATUS_NOTIFICATION), + }, + ], + [ + ProcedureName.FIRMWARE_STATUS_NOTIFICATION, + { + description: ocppDescription( + 'Send a FirmwareStatusNotification', + ProcedureName.FIRMWARE_STATUS_NOTIFICATION + ), + inputSchema: ocppInputSchema(ProcedureName.FIRMWARE_STATUS_NOTIFICATION), + }, + ], + [ + ProcedureName.GET_15118_EV_CERTIFICATE, + { + description: ocppDescription( + 'Request an ISO 15118 EV certificate', + ProcedureName.GET_15118_EV_CERTIFICATE + ), + inputSchema: ocppInputSchema(ProcedureName.GET_15118_EV_CERTIFICATE), + }, + ], + [ + ProcedureName.GET_CERTIFICATE_STATUS, + { + description: ocppDescription( + 'Get the certificate status', + ProcedureName.GET_CERTIFICATE_STATUS + ), + inputSchema: ocppInputSchema(ProcedureName.GET_CERTIFICATE_STATUS), + }, + ], + [ + ProcedureName.HEARTBEAT, + { + description: 'Send a Heartbeat request (OCPP 1.6 & 2.0.x)', + inputSchema: broadcastInputSchema, + }, + ], + [ + ProcedureName.LIST_CHARGING_STATIONS, + { + description: 'List all charging stations with their current data and connection status', + inputSchema: emptyInputSchema, + }, + ], + [ + ProcedureName.LIST_TEMPLATES, + { + description: 'List available charging station configuration templates', + inputSchema: emptyInputSchema, + }, + ], + [ + ProcedureName.LOG_STATUS_NOTIFICATION, + { + description: ocppDescription( + 'Send a LogStatusNotification', + ProcedureName.LOG_STATUS_NOTIFICATION + ), + inputSchema: ocppInputSchema(ProcedureName.LOG_STATUS_NOTIFICATION), + }, + ], + [ + ProcedureName.METER_VALUES, + { + description: ocppDescription('Send MeterValues', ProcedureName.METER_VALUES), + inputSchema: ocppInputSchema(ProcedureName.METER_VALUES), + }, + ], + [ + ProcedureName.NOTIFY_CUSTOMER_INFORMATION, + { + description: ocppDescription( + 'Send a NotifyCustomerInformation', + ProcedureName.NOTIFY_CUSTOMER_INFORMATION + ), + inputSchema: ocppInputSchema(ProcedureName.NOTIFY_CUSTOMER_INFORMATION), + }, + ], + [ + ProcedureName.NOTIFY_REPORT, + { + description: ocppDescription('Send a NotifyReport', ProcedureName.NOTIFY_REPORT), + inputSchema: ocppInputSchema(ProcedureName.NOTIFY_REPORT), + }, + ], + [ + ProcedureName.OPEN_CONNECTION, + { + description: + 'Open the WebSocket connection to the OCPP server for one or more charging stations', + inputSchema: broadcastInputSchema, + }, + ], + [ + ProcedureName.PERFORMANCE_STATISTICS, + { + description: + 'Get performance statistics of the charging stations simulator when storage is enabled', + inputSchema: emptyInputSchema, + }, + ], + [ + ProcedureName.SECURITY_EVENT_NOTIFICATION, + { + description: ocppDescription( + 'Send a SecurityEventNotification', + ProcedureName.SECURITY_EVENT_NOTIFICATION + ), + inputSchema: ocppInputSchema(ProcedureName.SECURITY_EVENT_NOTIFICATION), + }, + ], + [ + ProcedureName.SET_SUPERVISION_URL, + { + description: 'Set the OCPP server supervision URL for one or more charging stations', + inputSchema: z.object({ + hashIds, + url: z.url().describe('The OCPP server supervision URL to set'), + }), + }, + ], + [ + ProcedureName.SIGN_CERTIFICATE, + { + description: ocppDescription( + 'Send a SignCertificate request', + ProcedureName.SIGN_CERTIFICATE + ), + inputSchema: ocppInputSchema(ProcedureName.SIGN_CERTIFICATE), + }, + ], + [ + ProcedureName.SIMULATOR_STATE, + { + description: + 'Get the current state of the simulator including version, configuration, started status, and template statistics', + inputSchema: emptyInputSchema, + }, + ], + [ + ProcedureName.START_AUTOMATIC_TRANSACTION_GENERATOR, + { + description: 'Start the automatic transaction generator on one or more charging stations', + inputSchema: broadcastInputSchema, + }, + ], + [ + ProcedureName.START_CHARGING_STATION, + { + description: 'Start one or more charging stations', + inputSchema: broadcastInputSchema, + }, + ], + [ + ProcedureName.START_SIMULATOR, + { + description: 'Start the charging stations simulator', + inputSchema: emptyInputSchema, + }, + ], + [ + ProcedureName.START_TRANSACTION, + { + description: ocppDescription('Start a charging transaction', ProcedureName.START_TRANSACTION), + inputSchema: ocppInputSchema(ProcedureName.START_TRANSACTION), + }, + ], + [ + ProcedureName.STATUS_NOTIFICATION, + { + description: ocppDescription('Send a StatusNotification', ProcedureName.STATUS_NOTIFICATION), + inputSchema: ocppInputSchema(ProcedureName.STATUS_NOTIFICATION), + }, + ], + [ + ProcedureName.STOP_AUTOMATIC_TRANSACTION_GENERATOR, + { + description: 'Stop the automatic transaction generator on one or more charging stations', + inputSchema: broadcastInputSchema, + }, + ], + [ + ProcedureName.STOP_CHARGING_STATION, + { + description: 'Stop one or more charging stations', + inputSchema: broadcastInputSchema, + }, + ], + [ + ProcedureName.STOP_SIMULATOR, + { + description: 'Stop the charging stations simulator', + inputSchema: emptyInputSchema, + }, + ], + [ + ProcedureName.STOP_TRANSACTION, + { + description: ocppDescription('Stop a charging transaction', ProcedureName.STOP_TRANSACTION), + inputSchema: z.object({ + hashIds, + ocpp16Payload: z + .record(z.string(), z.unknown()) + .optional() + .describe('OCPP 1.6 StopTransaction payload'), + transactionId: z.number().int().optional().describe('Transaction ID to stop'), + }), + }, + ], + [ + ProcedureName.TRANSACTION_EVENT, + { + description: ocppDescription('Send a TransactionEvent', ProcedureName.TRANSACTION_EVENT), + inputSchema: ocppInputSchema(ProcedureName.TRANSACTION_EVENT), + }, + ], +]) diff --git a/src/charging-station/ui-server/mcp/index.ts b/src/charging-station/ui-server/mcp/index.ts new file mode 100644 index 00000000..ad2469de --- /dev/null +++ b/src/charging-station/ui-server/mcp/index.ts @@ -0,0 +1,7 @@ +export { + registerMCPLogTools, + registerMCPResources, + registerMCPSchemaResources, +} from './MCPResourceHandlers.js' +export { mcpToolSchemas, ocppSchemaMapping } from './MCPToolSchemas.js' +export type { MCPToolSchema } from './MCPToolSchemas.js' diff --git a/src/types/Evse.ts b/src/types/Evse.ts index 96eba54d..e1e36b02 100644 --- a/src/types/Evse.ts +++ b/src/types/Evse.ts @@ -1,11 +1,14 @@ import type { ConnectorStatus } from './ConnectorStatus.js' +import type { SampledValueTemplate } from './MeasurandPerPhaseSampledValueTemplates.js' import type { AvailabilityType } from './ocpp/Requests.js' export interface EvseStatus { availability: AvailabilityType connectors: Map + MeterValues?: SampledValueTemplate[] } export interface EvseTemplate { Connectors: Record + MeterValues?: SampledValueTemplate[] } diff --git a/src/types/UIProtocol.ts b/src/types/UIProtocol.ts index 76e60db5..4fc5305b 100644 --- a/src/types/UIProtocol.ts +++ b/src/types/UIProtocol.ts @@ -4,6 +4,7 @@ import type { BroadcastChannelResponsePayload } from './WorkerBroadcastChannel.j export enum ApplicationProtocol { HTTP = 'http', + MCP = 'mcp', WS = 'ws', } diff --git a/src/types/ocpp/Transaction.ts b/src/types/ocpp/Transaction.ts index a96c5d05..63ca2edd 100644 --- a/src/types/ocpp/Transaction.ts +++ b/src/types/ocpp/Transaction.ts @@ -8,6 +8,8 @@ import { type OCPP16StopTransactionRequest, type OCPP16StopTransactionResponse, } from './1.6/Transaction.js' +import { type OCPP20AuthorizeRequest } from './2.0/Requests.js' +import { type OCPP20AuthorizeResponse } from './2.0/Responses.js' import { OCPP20AuthorizationStatusEnumType, OCPP20ReasonEnumType } from './2.0/Transaction.js' export const AuthorizationStatus = { @@ -17,9 +19,9 @@ export const AuthorizationStatus = { // eslint-disable-next-line @typescript-eslint/no-redeclare export type AuthorizationStatus = OCPP16AuthorizationStatus | OCPP20AuthorizationStatusEnumType -export type AuthorizeRequest = OCPP16AuthorizeRequest +export type AuthorizeRequest = OCPP16AuthorizeRequest | OCPP20AuthorizeRequest -export type AuthorizeResponse = OCPP16AuthorizeResponse +export type AuthorizeResponse = OCPP16AuthorizeResponse | OCPP20AuthorizeResponse export type StartTransactionRequest = OCPP16StartTransactionRequest diff --git a/tests/charging-station/ChargingStationTestConstants.ts b/tests/charging-station/ChargingStationTestConstants.ts index 47416a07..bbf770ff 100644 --- a/tests/charging-station/ChargingStationTestConstants.ts +++ b/tests/charging-station/ChargingStationTestConstants.ts @@ -63,6 +63,7 @@ export const TEST_ID_TAG_BLOCKED = 'BLOCKED_TAG' * Test values for transaction-related operations */ export const TEST_TRANSACTION_ID = 1 +export const TEST_TRANSACTION_ID_STRING = 'tx-ocpp20-1' export const TEST_TRANSACTION_ENERGY_WH = 5000 /** diff --git a/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts b/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts index 437673ea..9a6303e8 100644 --- a/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts +++ b/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts @@ -26,6 +26,7 @@ import { } from '../../../src/types/index.js' import { Constants } from '../../../src/utils/index.js' import { flushMicrotasks, standardCleanup } from '../../helpers/TestLifecycleHelpers.js' +import { TEST_TRANSACTION_ID_STRING } from '../ChargingStationTestConstants.js' import { createMockChargingStation } from '../ChargingStationTestUtils.js' import { createMockStationWithRequestTracking } from '../ocpp/2.0/OCPP20TestUtils.js' @@ -750,6 +751,7 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => { value: 0, }, ] + connectorStatus.transactionId = TEST_TRANSACTION_ID_STRING } instance = new ChargingStationWorkerBroadcastChannel(station) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts index 334cf166..519fbe05 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts @@ -516,7 +516,7 @@ await describe('F06 - TriggerMessage', async () => { assert.notStrictEqual(payload, undefined) assert.ok('evseId' in payload, 'Expected payload to include evseId') assert.ok('connectorId' in payload, 'Expected payload to include connectorId') - assert.ok('status' in payload, 'Expected payload to include status') + assert.ok('connectorStatus' in payload, 'Expected payload to include connectorStatus') assert.ok( payload.evseId != null && payload.evseId > 0, 'Expected evseId > 0 (EVSE 0 excluded)' @@ -553,7 +553,7 @@ await describe('F06 - TriggerMessage', async () => { assert.strictEqual(command, OCPP20RequestCommand.STATUS_NOTIFICATION) assert.strictEqual(payload.evseId, 1) assert.strictEqual(payload.connectorId, 1) - assert.ok('status' in payload) + assert.ok('connectorStatus' in payload) assert.strictEqual(options.skipBufferingOnError, true) assert.strictEqual(options.triggerMessage, true) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-CallChain.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-CallChain.test.ts index 9de0149d..5cdba844 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20RequestService-CallChain.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-CallChain.test.ts @@ -61,8 +61,8 @@ await describe('OCPP 2.0 Request Call Chain — requestHandler → buildRequestP await it('should build complete StatusNotificationRequest from connectorId + status', async () => { await service.requestHandler(station, OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: 1, + connectorStatus: ConnectorStatusEnum.Available, evseId: 1, - status: ConnectorStatusEnum.Available, }) assert.strictEqual(sendMessageMock.mock.calls.length, 1) @@ -77,7 +77,7 @@ await describe('OCPP 2.0 Request Call Chain — requestHandler → buildRequestP await it('should resolve evseId from station when not provided', async () => { await service.requestHandler(station, OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: 1, - status: ConnectorStatusEnum.Occupied, + connectorStatus: ConnectorStatusEnum.Occupied, }) assert.strictEqual(sendMessageMock.mock.calls.length, 1) diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts index a19718d1..68d70c5f 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts @@ -8,7 +8,6 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import type { ChargingStation } from '../../../../src/charging-station/index.js' import { - ConnectorStatusEnum, OCPP20ConnectorStatusEnumType, OCPP20RequestCommand, type OCPP20StatusNotificationRequest, @@ -55,8 +54,8 @@ await describe('G01 - Status Notification', async () => { OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: 1, + connectorStatus: OCPP20ConnectorStatusEnumType.Available, evseId: 1, - status: ConnectorStatusEnum.Available, } ) as OCPP20StatusNotificationRequest @@ -74,8 +73,8 @@ await describe('G01 - Status Notification', async () => { OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: 2, + connectorStatus: OCPP20ConnectorStatusEnumType.Occupied, evseId: 2, - status: ConnectorStatusEnum.Occupied, } ) as OCPP20StatusNotificationRequest @@ -93,8 +92,8 @@ await describe('G01 - Status Notification', async () => { OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: 1, + connectorStatus: OCPP20ConnectorStatusEnumType.Faulted, evseId: 1, - status: ConnectorStatusEnum.Faulted, } ) as OCPP20StatusNotificationRequest @@ -107,27 +106,27 @@ await describe('G01 - Status Notification', async () => { // FR: G01.FR.04 await it('should handle all OCPP20ConnectorStatusEnumType values correctly', () => { - const statusValues: [ConnectorStatusEnum, OCPP20ConnectorStatusEnumType][] = [ - [ConnectorStatusEnum.Available, OCPP20ConnectorStatusEnumType.Available], - [ConnectorStatusEnum.Faulted, OCPP20ConnectorStatusEnumType.Faulted], - [ConnectorStatusEnum.Occupied, OCPP20ConnectorStatusEnumType.Occupied], - [ConnectorStatusEnum.Reserved, OCPP20ConnectorStatusEnumType.Reserved], - [ConnectorStatusEnum.Unavailable, OCPP20ConnectorStatusEnumType.Unavailable], + const statusValues: OCPP20ConnectorStatusEnumType[] = [ + OCPP20ConnectorStatusEnumType.Available, + OCPP20ConnectorStatusEnumType.Faulted, + OCPP20ConnectorStatusEnumType.Occupied, + OCPP20ConnectorStatusEnumType.Reserved, + OCPP20ConnectorStatusEnumType.Unavailable, ] - statusValues.forEach(([inputStatus, expectedConnectorStatus], index) => { + statusValues.forEach((connectorStatus, index) => { const payload = testableRequestService.buildRequestPayload( station, OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: index + 1, + connectorStatus, evseId: index + 1, - status: inputStatus, } ) as OCPP20StatusNotificationRequest assert.notStrictEqual(payload, undefined) - assert.strictEqual(payload.connectorStatus, expectedConnectorStatus) + assert.strictEqual(payload.connectorStatus, connectorStatus) assert.strictEqual(payload.connectorId, index + 1) assert.strictEqual(payload.evseId, index + 1) assert.ok(payload.timestamp instanceof Date) @@ -141,8 +140,8 @@ await describe('G01 - Status Notification', async () => { OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: 3, + connectorStatus: OCPP20ConnectorStatusEnumType.Reserved, evseId: 2, - status: ConnectorStatusEnum.Reserved, } ) as OCPP20StatusNotificationRequest @@ -174,8 +173,8 @@ await describe('G01 - Status Notification', async () => { OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: 0, + connectorStatus: OCPP20ConnectorStatusEnumType.Available, evseId: 1, - status: ConnectorStatusEnum.Available, } ) as OCPP20StatusNotificationRequest @@ -191,8 +190,8 @@ await describe('G01 - Status Notification', async () => { OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: 1, + connectorStatus: OCPP20ConnectorStatusEnumType.Unavailable, evseId: 0, - status: ConnectorStatusEnum.Unavailable, } ) as OCPP20StatusNotificationRequest @@ -208,21 +207,21 @@ await describe('G01 - Status Notification', async () => { // buildRequestPayload now generates its own timestamp via buildStatusNotificationRequest, // so we verify the output always has a valid Date timestamp const statusValues = [ - ConnectorStatusEnum.Available, - ConnectorStatusEnum.Occupied, - ConnectorStatusEnum.Faulted, - ConnectorStatusEnum.Reserved, + OCPP20ConnectorStatusEnumType.Available, + OCPP20ConnectorStatusEnumType.Occupied, + OCPP20ConnectorStatusEnumType.Faulted, + OCPP20ConnectorStatusEnumType.Reserved, ] const beforeBuild = new Date() - statusValues.forEach(status => { + statusValues.forEach(connectorStatus => { const payload = testableRequestService.buildRequestPayload( station, OCPP20RequestCommand.STATUS_NOTIFICATION, { connectorId: 1, + connectorStatus, evseId: 1, - status, } ) as OCPP20StatusNotificationRequest diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts index 0af0ef59..bc61f6d3 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -38,6 +38,7 @@ import { OCPP20RequestCommand, OCPP20RequiredVariableName, OCPP20TransactionEventEnumType, + type OCPP20TransactionEventRequest, type OCPP20TransactionType, OCPP20TriggerReasonEnumType, OCPPVersion, @@ -122,13 +123,12 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Reset sequence number to simulate new transaction OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const transactionEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - triggerReason, + const transactionEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId, + triggerReason, + } as unknown as OCPP20TransactionEventRequest) // Validate required fields assert.strictEqual(transactionEvent.eventType, OCPP20TransactionEventEnumType.Started) @@ -157,32 +157,29 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Build first event (Started) - const startEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const startEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) // Build second event (Updated) - const updateEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.MeterValuePeriodic, + const updateEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic, + } as unknown as OCPP20TransactionEventRequest) // Build third event (Ended) - const endEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.StopAuthorized, + const endEvent = buildTransactionEvent(mockStation, { connectorId, + eventType: OCPP20TransactionEventEnumType.Ended, + stoppedReason: OCPP20ReasonEnumType.Local, transactionId, - { stoppedReason: OCPP20ReasonEnumType.Local } - ) + triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized, + } as unknown as OCPP20TransactionEventRequest) // Validate sequence number progression: 0 → 1 → 2 assert.strictEqual(startEvent.seqNo, 0) @@ -211,14 +208,19 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { reservationId: 67890, } - const transactionEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, + const transactionEvent = buildTransactionEvent(mockStation, { + cableMaxCurrent: options.cableMaxCurrent, + chargingState: options.chargingState, connectorId, + eventType: OCPP20TransactionEventEnumType.Updated, + idToken: options.idToken, + numberOfPhasesUsed: options.numberOfPhasesUsed, + offline: options.offline, + remoteStartId: options.remoteStartId, + reservationId: options.reservationId, transactionId, - options - ) + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest) // Validate optional fields are included if (transactionEvent.idToken == null) { @@ -243,13 +245,12 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { 'this-string-is-way-too-long-for-a-valid-transaction-id-exceeds-36-chars' try { - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + buildTransactionEvent(mockStation, { connectorId, - invalidTransactionId - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId: invalidTransactionId, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) throw new Error('Should have thrown error for invalid identifier string') } catch (error) { assert.ok((error as Error).message.includes('Invalid transaction ID format')) @@ -290,13 +291,12 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) for (const triggerReason of triggerReasons) { - const transactionEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - triggerReason, + const transactionEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId, + triggerReason, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(transactionEvent.triggerReason, triggerReason) assert.strictEqual(transactionEvent.eventType, OCPP20TransactionEventEnumType.Updated) @@ -366,13 +366,12 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { const connectorId = 1 // First, build a transaction event to set sequence number - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + buildTransactionEvent(mockStation, { connectorId, - generateUUID() - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId: generateUUID(), + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) // Verify sequence number is set const connectorStatus = mockStation.getConnectorStatus(connectorId) @@ -403,19 +402,16 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const transactionEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const transactionEvent = buildTransactionEvent(mockStation, { connectorId, + eventType: OCPP20TransactionEventEnumType.Started, + idToken: { + idToken: 'SCHEMA_TEST_TOKEN', + type: OCPP20IdTokenEnumType.ISO14443, + }, transactionId, - { - idToken: { - idToken: 'SCHEMA_TEST_TOKEN', - type: OCPP20IdTokenEnumType.ISO14443, - }, - } - ) + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) // Validate all required fields exist const requiredFields = [ @@ -464,13 +460,12 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const transactionEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const transactionEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) // For this test setup, EVSE ID should match connector ID if (transactionEvent.evse == null) { @@ -547,13 +542,12 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Old method call should still work - const oldEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const oldEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(oldEvent.eventType, OCPP20TransactionEventEnumType.Started) assert.strictEqual(oldEvent.triggerReason, OCPP20TriggerReasonEnumType.Authorized) @@ -625,14 +619,13 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const startedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - expectedStartTrigger, + const startedEvent = buildTransactionEvent(mockStation, { connectorId, + eventType: OCPP20TransactionEventEnumType.Started, transactionId, - idToken != null ? { idToken } : undefined - ) + triggerReason: expectedStartTrigger, + ...(idToken != null ? { idToken } : {}), + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(startedEvent.eventType, OCPP20TransactionEventEnumType.Started) assert.strictEqual(startedEvent.triggerReason, expectedStartTrigger) @@ -658,33 +651,30 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Step 1: Started event - const startedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - expectedStartTrigger, + const startedEvent = buildTransactionEvent(mockStation, { connectorId, + eventType: OCPP20TransactionEventEnumType.Started, transactionId, - idToken != null ? { idToken } : undefined - ) + triggerReason: expectedStartTrigger, + ...(idToken != null ? { idToken } : {}), + } as unknown as OCPP20TransactionEventRequest) // Step 2: Charging state change - const chargingEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, + const chargingEvent = buildTransactionEvent(mockStation, { + chargingState: OCPP20ChargingStateEnumType.Charging, connectorId, + eventType: OCPP20TransactionEventEnumType.Updated, transactionId, - { chargingState: OCPP20ChargingStateEnumType.Charging } - ) + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest) // Step 3: Ended event - const endedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.StopAuthorized, + const endedEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Ended, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized, + } as unknown as OCPP20TransactionEventRequest) // Validate event sequence assert.strictEqual(startedEvent.seqNo, 0) @@ -707,40 +697,36 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connector2) // Start transaction on connector 1 - const conn1Event1 = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - expectedStartTrigger, - connector1, - transaction1Id - ) + const conn1Event1 = buildTransactionEvent(mockStation, { + connectorId: connector1, + eventType: OCPP20TransactionEventEnumType.Started, + transactionId: transaction1Id, + triggerReason: expectedStartTrigger, + } as unknown as OCPP20TransactionEventRequest) // Start transaction on connector 2 - const conn2Event1 = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - expectedStartTrigger, - connector2, - transaction2Id - ) + const conn2Event1 = buildTransactionEvent(mockStation, { + connectorId: connector2, + eventType: OCPP20TransactionEventEnumType.Started, + transactionId: transaction2Id, + triggerReason: expectedStartTrigger, + } as unknown as OCPP20TransactionEventRequest) // Update connector 1 - const conn1Event2 = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, - connector1, - transaction1Id - ) + const conn1Event2 = buildTransactionEvent(mockStation, { + connectorId: connector1, + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId: transaction1Id, + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest) // Update connector 2 - const conn2Event2 = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, - connector2, - transaction2Id - ) + const conn2Event2 = buildTransactionEvent(mockStation, { + connectorId: connector2, + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId: transaction2Id, + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest) // Verify independent sequence numbers assert.strictEqual(conn1Event1.seqNo, 0) @@ -771,32 +757,29 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Step 1: Cable plugged in (Started) - const cablePluggedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.CablePluggedIn, + const cablePluggedEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.CablePluggedIn, + } as unknown as OCPP20TransactionEventRequest) // Step 2: EV detected (Updated) - const evDetectedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.EVDetected, + const evDetectedEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.EVDetected, + } as unknown as OCPP20TransactionEventRequest) // Step 3: Charging starts (Updated with ChargingStateChanged) - const chargingStartedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, + const chargingStartedEvent = buildTransactionEvent(mockStation, { + chargingState: OCPP20ChargingStateEnumType.Charging, connectorId, + eventType: OCPP20TransactionEventEnumType.Updated, transactionId, - { chargingState: OCPP20ChargingStateEnumType.Charging } - ) + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest) // Assert sequence numbers follow correct order assert.strictEqual(cablePluggedEvent.seqNo, 0) @@ -821,22 +804,20 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Start transaction with cable plug - const startEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.CablePluggedIn, + const startEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.CablePluggedIn, + } as unknown as OCPP20TransactionEventRequest) // End transaction with EV departure (cable removal) - const endEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.EVDeparted, + const endEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Ended, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.EVDeparted, + } as unknown as OCPP20TransactionEventRequest) // Assert proper sequencing for cable-initiated start and end assert.strictEqual(startEvent.seqNo, 0) @@ -856,35 +837,31 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Build full cable-first flow const events = [ - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.CablePluggedIn, + buildTransactionEvent(mockStation, { connectorId, - transactionId - ), - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.EVDetected, + eventType: OCPP20TransactionEventEnumType.Started, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.CablePluggedIn, + } as unknown as OCPP20TransactionEventRequest), + buildTransactionEvent(mockStation, { connectorId, - transactionId - ), - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.Authorized, + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.EVDetected, + } as unknown as OCPP20TransactionEventRequest), + buildTransactionEvent(mockStation, { connectorId, - transactionId - ), - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest), + buildTransactionEvent(mockStation, { + chargingState: OCPP20ChargingStateEnumType.Charging, connectorId, + eventType: OCPP20TransactionEventEnumType.Updated, transactionId, - { chargingState: OCPP20ChargingStateEnumType.Charging } - ), + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest), ] // Assert EVDetected comes after CablePluggedIn and before authorization @@ -975,48 +952,43 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Cable-first flow with suspended state const events = [ // 1. Cable plugged - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.CablePluggedIn, + buildTransactionEvent(mockStation, { connectorId, - transactionId - ), + eventType: OCPP20TransactionEventEnumType.Started, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.CablePluggedIn, + } as unknown as OCPP20TransactionEventRequest), // 2. Start charging - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, + buildTransactionEvent(mockStation, { + chargingState: OCPP20ChargingStateEnumType.Charging, connectorId, + eventType: OCPP20TransactionEventEnumType.Updated, transactionId, - { chargingState: OCPP20ChargingStateEnumType.Charging } - ), + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest), // 3. Suspended by EV - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, + buildTransactionEvent(mockStation, { + chargingState: OCPP20ChargingStateEnumType.SuspendedEV, connectorId, + eventType: OCPP20TransactionEventEnumType.Updated, transactionId, - { chargingState: OCPP20ChargingStateEnumType.SuspendedEV } - ), + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest), // 4. Resume charging - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, + buildTransactionEvent(mockStation, { + chargingState: OCPP20ChargingStateEnumType.Charging, connectorId, + eventType: OCPP20TransactionEventEnumType.Updated, transactionId, - { chargingState: OCPP20ChargingStateEnumType.Charging } - ), + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest), // 5. EV departed - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.EVDeparted, + buildTransactionEvent(mockStation, { connectorId, - transactionId - ), + eventType: OCPP20TransactionEventEnumType.Ended, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.EVDeparted, + } as unknown as OCPP20TransactionEventRequest), ] // Verify sequence numbers are continuous through suspend/resume @@ -1043,14 +1015,13 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Build Started event with idToken (E03.FR.01: IdToken must be in first event) - const startedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const startedEvent = buildTransactionEvent(mockStation, { connectorId, + eventType: OCPP20TransactionEventEnumType.Started, + idToken, transactionId, - { idToken } - ) + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) if (startedEvent.idToken == null) { assert.fail('Expected idToken to be defined') @@ -1072,24 +1043,23 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // First event includes idToken - const startedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const startedEvent = buildTransactionEvent(mockStation, { connectorId, + eventType: OCPP20TransactionEventEnumType.Started, + idToken, transactionId, - { idToken } - ) + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) // Second event should NOT include idToken (flag is set after first inclusion) - const updatedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, + const updatedEvent = buildTransactionEvent(mockStation, { + chargingState: OCPP20ChargingStateEnumType.Charging, connectorId, + eventType: OCPP20TransactionEventEnumType.Updated, + idToken, transactionId, - { chargingState: OCPP20ChargingStateEnumType.Charging, idToken } - ) + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest) assert.notStrictEqual(startedEvent.idToken, undefined) assert.strictEqual(updatedEvent.idToken, undefined) @@ -1107,14 +1077,13 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { type: OCPP20IdTokenEnumType.ISO14443, } - const rfidEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const rfidEvent = buildTransactionEvent(mockStation, { connectorId, + eventType: OCPP20TransactionEventEnumType.Started, + idToken: rfidToken, transactionId, - { idToken: rfidToken } - ) + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(rfidEvent.idToken?.type, OCPP20IdTokenEnumType.ISO14443) @@ -1131,14 +1100,13 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { type: OCPP20IdTokenEnumType.eMAID, } - const emaidEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const emaidEvent = buildTransactionEvent(mockStation, { connectorId, - generateUUID(), - { idToken: emaidToken } - ) + eventType: OCPP20TransactionEventEnumType.Started, + idToken: emaidToken, + transactionId: generateUUID(), + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(emaidEvent.idToken?.type, OCPP20IdTokenEnumType.eMAID) assert.strictEqual(emaidEvent.idToken.idToken, 'DE*ABC*E123456*1') @@ -1157,42 +1125,38 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // E03 Step 1: IdToken presented and authorized (Started with Authorized trigger) - const authorizedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const authorizedEvent = buildTransactionEvent(mockStation, { connectorId, + eventType: OCPP20TransactionEventEnumType.Started, + idToken, transactionId, - { idToken } - ) + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) // E03 Step 2: Cable connected (Updated event) - const cableConnectedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.CablePluggedIn, + const cableConnectedEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.CablePluggedIn, + } as unknown as OCPP20TransactionEventRequest) // E03 Step 3: Charging starts - const chargingEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, + const chargingEvent = buildTransactionEvent(mockStation, { + chargingState: OCPP20ChargingStateEnumType.Charging, connectorId, + eventType: OCPP20TransactionEventEnumType.Updated, transactionId, - { chargingState: OCPP20ChargingStateEnumType.Charging } - ) + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest) // E03 Step 4: Transaction ends - const endedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.StopAuthorized, + const endedEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Ended, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized, + } as unknown as OCPP20TransactionEventRequest) // Validate event sequence assert.strictEqual(authorizedEvent.eventType, OCPP20TransactionEventEnumType.Started) @@ -1237,14 +1201,13 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { connectorStatus.transactionIdTokenSent = undefined } - const e03Start = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const e03Start = buildTransactionEvent(mockStation, { connectorId, - e03TransactionId, - { idToken } - ) + eventType: OCPP20TransactionEventEnumType.Started, + idToken, + transactionId: e03TransactionId, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) // E02 Cable-First: Starts with CablePluggedIn trigger OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) @@ -1252,13 +1215,12 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { connectorStatus.transactionIdTokenSent = undefined } - const e02Start = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.CablePluggedIn, + const e02Start = buildTransactionEvent(mockStation, { connectorId, - e02TransactionId - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId: e02TransactionId, + triggerReason: OCPP20TriggerReasonEnumType.CablePluggedIn, + } as unknown as OCPP20TransactionEventRequest) // Key difference: E03 starts with Authorized, E02 starts with CablePluggedIn assert.strictEqual(e03Start.triggerReason, OCPP20TriggerReasonEnumType.Authorized) @@ -1282,23 +1244,21 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // E03.FR.05: User authorizes with IdToken - const authorizedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const authorizedEvent = buildTransactionEvent(mockStation, { connectorId, + eventType: OCPP20TransactionEventEnumType.Started, + idToken, transactionId, - { idToken } - ) + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) // E03.FR.06: Cable not connected within timeout - transaction ends with Timeout - const timeoutEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.EVConnectTimeout, + const timeoutEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Ended, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.EVConnectTimeout, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(authorizedEvent.eventType, OCPP20TransactionEventEnumType.Started) assert.strictEqual(authorizedEvent.triggerReason, OCPP20TriggerReasonEnumType.Authorized) @@ -1330,23 +1290,21 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Transaction started with authorization - const startEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const startEvent = buildTransactionEvent(mockStation, { connectorId, + eventType: OCPP20TransactionEventEnumType.Started, + idToken, transactionId, - { idToken } - ) + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) // Transaction ended due to deauthorization (e.g., token revoked mid-session) - const revokedEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.Deauthorized, + const revokedEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Ended, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.Deauthorized, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(startEvent.eventType, OCPP20TransactionEventEnumType.Started) assert.strictEqual(revokedEvent.eventType, OCPP20TransactionEventEnumType.Ended) @@ -1366,42 +1324,37 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) const events = [ - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + buildTransactionEvent(mockStation, { connectorId, + eventType: OCPP20TransactionEventEnumType.Started, + idToken, transactionId, - { idToken } - ), - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.CablePluggedIn, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest), + buildTransactionEvent(mockStation, { connectorId, - transactionId - ), - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.CablePluggedIn, + } as unknown as OCPP20TransactionEventRequest), + buildTransactionEvent(mockStation, { connectorId, - transactionId - ), - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.MeterValuePeriodic, + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.ChargingStateChanged, + } as unknown as OCPP20TransactionEventRequest), + buildTransactionEvent(mockStation, { connectorId, - transactionId - ), - buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.StopAuthorized, + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic, + } as unknown as OCPP20TransactionEventRequest), + buildTransactionEvent(mockStation, { connectorId, - transactionId - ), + eventType: OCPP20TransactionEventEnumType.Ended, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized, + } as unknown as OCPP20TransactionEventRequest), ] // E03.FR.07: Sequence numbers must be continuous @@ -1421,23 +1374,21 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // E03.FR.08: transactionId MUST be unique assert.notStrictEqual(transaction1Id, transaction2Id) - const event1 = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const event1 = buildTransactionEvent(mockStation, { connectorId, - transaction1Id - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId: transaction1Id, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const event2 = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const event2 = buildTransactionEvent(mockStation, { connectorId, - transaction2Id - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId: transaction2Id, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(event1.transactionInfo.transactionId, transaction1Id) assert.strictEqual(event2.transactionInfo.transactionId, transaction2Id) @@ -2132,24 +2083,22 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Send initial Started event - const startEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const startEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(startEvent.seqNo, 0) // Send multiple periodic events (simulating timer ticks) for (let i = 1; i <= 3; i++) { - const periodicEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.MeterValuePeriodic, + const periodicEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(periodicEvent.seqNo, i) } @@ -2227,35 +2176,32 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Simulate full transaction lifecycle with periodic updates // 1. Started event (seqNo: 0) - const startEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, + const startEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Started, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(startEvent.seqNo, 0) // 2. Multiple periodic updates (seqNo: 1, 2, 3) for (let i = 1; i <= 3; i++) { - const updateEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.MeterValuePeriodic, + const updateEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(updateEvent.seqNo, i) } // 3. Ended event (seqNo: 4) - const endEvent = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.StopAuthorized, + const endEvent = buildTransactionEvent(mockStation, { connectorId, - transactionId - ) + eventType: OCPP20TransactionEventEnumType.Ended, + transactionId, + triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized, + } as unknown as OCPP20TransactionEventRequest) assert.strictEqual(endEvent.seqNo, 4) }) @@ -2268,36 +2214,32 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, 2) // Build events for connector 1 - const event1Start = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - 1, - transactionId1 - ) - const event1Update = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.MeterValuePeriodic, - 1, - transactionId1 - ) + const event1Start = buildTransactionEvent(mockStation, { + connectorId: 1, + eventType: OCPP20TransactionEventEnumType.Started, + transactionId: transactionId1, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) + const event1Update = buildTransactionEvent(mockStation, { + connectorId: 1, + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId: transactionId1, + triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic, + } as unknown as OCPP20TransactionEventRequest) // Build events for connector 2 - const event2Start = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - 2, - transactionId2 - ) - const event2Update = buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.MeterValuePeriodic, - 2, - transactionId2 - ) + const event2Start = buildTransactionEvent(mockStation, { + connectorId: 2, + eventType: OCPP20TransactionEventEnumType.Started, + transactionId: transactionId2, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } as unknown as OCPP20TransactionEventRequest) + const event2Update = buildTransactionEvent(mockStation, { + connectorId: 2, + eventType: OCPP20TransactionEventEnumType.Updated, + transactionId: transactionId2, + triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic, + } as unknown as OCPP20TransactionEventRequest) // Verify independent sequence numbers assert.strictEqual(event1Start.seqNo, 0) diff --git a/tests/charging-station/ocpp/OCPPServiceUtils-connectorStatus.test.ts b/tests/charging-station/ocpp/OCPPServiceUtils-connectorStatus.test.ts index b196e5d5..94ab7111 100644 --- a/tests/charging-station/ocpp/OCPPServiceUtils-connectorStatus.test.ts +++ b/tests/charging-station/ocpp/OCPPServiceUtils-connectorStatus.test.ts @@ -18,7 +18,12 @@ import { restoreConnectorStatus, sendAndSetConnectorStatus, } from '../../../src/charging-station/ocpp/OCPPServiceUtils.js' -import { ConnectorStatusEnum, OCPPVersion } from '../../../src/types/index.js' +import { + ConnectorStatusEnum, + type OCPP16StatusNotificationRequest, + type OCPP20StatusNotificationRequest, + OCPPVersion, +} from '../../../src/types/index.js' import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' import { createMockChargingStation } from '../ChargingStationTestUtils.js' @@ -48,7 +53,10 @@ await describe('OCPPServiceUtils — connector status management', async () => { await it('should send StatusNotification and update connector status', async () => { const { requestHandler, station } = createStationWithRequestHandler() - await sendAndSetConnectorStatus(station, 1, ConnectorStatusEnum.Occupied) + await sendAndSetConnectorStatus(station, { + connectorId: 1, + status: ConnectorStatusEnum.Occupied, + } as unknown as OCPP16StatusNotificationRequest) assert.strictEqual(requestHandler.mock.calls.length, 1) assert.strictEqual(station.getConnectorStatus(1)?.status, ConnectorStatusEnum.Occupied) @@ -57,7 +65,10 @@ await describe('OCPPServiceUtils — connector status management', async () => { await it('should return early when connector does not exist', async () => { const { requestHandler, station } = createStationWithRequestHandler() - await sendAndSetConnectorStatus(station, 99, ConnectorStatusEnum.Occupied) + await sendAndSetConnectorStatus(station, { + connectorId: 99, + status: ConnectorStatusEnum.Occupied, + } as unknown as OCPP16StatusNotificationRequest) assert.strictEqual(requestHandler.mock.calls.length, 0) }) @@ -65,9 +76,16 @@ await describe('OCPPServiceUtils — connector status management', async () => { await it('should skip sending when options.send is false', async () => { const { requestHandler, station } = createStationWithRequestHandler() - await sendAndSetConnectorStatus(station, 1, ConnectorStatusEnum.Occupied, undefined, { - send: false, - }) + await sendAndSetConnectorStatus( + station, + { + connectorId: 1, + status: ConnectorStatusEnum.Occupied, + } as unknown as OCPP16StatusNotificationRequest, + { + send: false, + } + ) assert.strictEqual(requestHandler.mock.calls.length, 0) assert.strictEqual(station.getConnectorStatus(1)?.status, ConnectorStatusEnum.Occupied) @@ -78,7 +96,10 @@ await describe('OCPPServiceUtils — connector status management', async () => { assert.strictEqual(station.getConnectorStatus(1)?.status, ConnectorStatusEnum.Available) - await sendAndSetConnectorStatus(station, 1, ConnectorStatusEnum.Unavailable) + await sendAndSetConnectorStatus(station, { + connectorId: 1, + status: ConnectorStatusEnum.Unavailable, + } as unknown as OCPP16StatusNotificationRequest) assert.strictEqual(station.getConnectorStatus(1)?.status, ConnectorStatusEnum.Unavailable) }) @@ -88,7 +109,10 @@ await describe('OCPPServiceUtils — connector status management', async () => { const stationObj = station as unknown as { emitChargingStationEvent: () => void } const emitSpy = mock.method(stationObj, 'emitChargingStationEvent') - await sendAndSetConnectorStatus(station, 1, ConnectorStatusEnum.Occupied) + await sendAndSetConnectorStatus(station, { + connectorId: 1, + status: ConnectorStatusEnum.Occupied, + } as unknown as OCPP16StatusNotificationRequest) assert.strictEqual(emitSpy.mock.calls.length, 1) }) @@ -98,7 +122,11 @@ await describe('OCPPServiceUtils — connector status management', async () => { ocppVersion: OCPPVersion.VERSION_20, }) - await sendAndSetConnectorStatus(station, 1, ConnectorStatusEnum.Occupied, 1) + await sendAndSetConnectorStatus(station, { + connectorId: 1, + connectorStatus: ConnectorStatusEnum.Occupied, + evseId: 1, + } as unknown as OCPP20StatusNotificationRequest) assert.strictEqual(requestHandler.mock.calls.length, 1) assert.strictEqual(station.getConnectorStatus(1)?.status, ConnectorStatusEnum.Occupied) @@ -107,7 +135,10 @@ await describe('OCPPServiceUtils — connector status management', async () => { await it('should default options.send to true when options not provided', async () => { const { requestHandler, station } = createStationWithRequestHandler() - await sendAndSetConnectorStatus(station, 1, ConnectorStatusEnum.Occupied) + await sendAndSetConnectorStatus(station, { + connectorId: 1, + status: ConnectorStatusEnum.Occupied, + } as unknown as OCPP16StatusNotificationRequest) assert.strictEqual(requestHandler.mock.calls.length, 1) }) diff --git a/tests/charging-station/ocpp/OCPPServiceUtils-meterValues.test.ts b/tests/charging-station/ocpp/OCPPServiceUtils-meterValues.test.ts new file mode 100644 index 00000000..9418d7aa --- /dev/null +++ b/tests/charging-station/ocpp/OCPPServiceUtils-meterValues.test.ts @@ -0,0 +1,153 @@ +/** + * @file Tests for OCPPServiceUtils buildMeterValue + * @description Verifies buildMeterValue resolves connectorId/evseId from transactionId + * and that getSampledValueTemplate handles EVSE-level and connector-level templates + * + * Covers: + * - buildMeterValue — resolves connectorId from transactionId for OCPP 1.6 + * - buildMeterValue — resolves connectorId + evseId from transactionId for OCPP 2.0 + * - buildMeterValue — throws when transactionId not found + * - getSampledValueTemplate — EVSE-level templates take priority over connector-level + * - getSampledValueTemplate — merges connector templates when no EVSE-level templates + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../../src/charging-station/index.js' + +import { buildMeterValue } from '../../../src/charging-station/ocpp/OCPPServiceUtils.js' +import { + MeterValueMeasurand, + OCPPVersion, + type SampledValueTemplate, +} from '../../../src/types/index.js' +import { Constants } from '../../../src/utils/index.js' +import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' +import { + TEST_CHARGING_STATION_BASE_NAME, + TEST_TRANSACTION_ID, + TEST_TRANSACTION_ID_STRING, +} from '../ChargingStationTestConstants.js' +import { createMockChargingStation } from '../ChargingStationTestUtils.js' + +const energyTemplate: SampledValueTemplate = { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + unit: 'Wh', + value: '0', +} as unknown as SampledValueTemplate + +await describe('buildMeterValue', async () => { + let station: ChargingStation + + afterEach(() => { + standardCleanup() + }) + + await describe('OCPP 1.6', async () => { + beforeEach(() => { + const { station: s } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + stationInfo: { ocppVersion: OCPPVersion.VERSION_16 }, + websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = [energyTemplate] + connectorStatus.transactionId = TEST_TRANSACTION_ID + } + }) + + await it('should resolve connectorId from transactionId and build meter value', () => { + const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID, 0) + + assert.ok(meterValue.timestamp instanceof Date) + assert.ok(Array.isArray(meterValue.sampledValue)) + }) + + await it('should throw when transactionId not found', () => { + assert.throws( + () => buildMeterValue(station, 999, 0), + (error: Error) => { + assert.ok(error.message.includes('no connector found')) + return true + } + ) + }) + }) + + await describe('OCPP 2.0', async () => { + beforeEach(() => { + const { station: s } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + evseConfiguration: { evsesCount: 1 }, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + stationInfo: { ocppVersion: OCPPVersion.VERSION_201 }, + websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = [energyTemplate] + connectorStatus.transactionId = TEST_TRANSACTION_ID_STRING + } + }) + + await it('should resolve connectorId and evseId from transactionId', () => { + const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0) + + assert.ok(meterValue.timestamp instanceof Date) + assert.ok(Array.isArray(meterValue.sampledValue)) + }) + + await it('should throw when transactionId not found', () => { + assert.throws( + () => buildMeterValue(station, 'unknown-tx', 0), + (error: Error) => { + assert.ok(error.message.includes('no connector')) + return true + } + ) + }) + + await it('should use EVSE-level MeterValues templates when available', () => { + const evseStatus = station.getEvseStatus(1) + if (evseStatus != null) { + evseStatus.MeterValues = [energyTemplate] + // Clear connector-level templates to prove EVSE-level is used + for (const connectorStatus of evseStatus.connectors.values()) { + connectorStatus.MeterValues = [] + } + } + + const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0) + + assert.ok(meterValue.timestamp instanceof Date) + assert.ok(Array.isArray(meterValue.sampledValue)) + assert.ok( + meterValue.sampledValue.length > 0, + 'should have sampled values from EVSE-level template' + ) + }) + + await it('should merge connector templates when no EVSE-level templates', () => { + const evseStatus = station.getEvseStatus(1) + if (evseStatus != null) { + evseStatus.MeterValues = undefined + } + + const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0) + + assert.ok(meterValue.timestamp instanceof Date) + assert.ok(Array.isArray(meterValue.sampledValue)) + assert.ok( + meterValue.sampledValue.length > 0, + 'should have sampled values from connector templates' + ) + }) + }) +}) diff --git a/tests/charging-station/ui-server/UIHttpServer.test.ts b/tests/charging-station/ui-server/UIHttpServer.test.ts index c4f794f6..89d2f54c 100644 --- a/tests/charging-station/ui-server/UIHttpServer.test.ts +++ b/tests/charging-station/ui-server/UIHttpServer.test.ts @@ -20,6 +20,7 @@ import { waitForStreamFlush, } from './UIServerTestUtils.js' +// eslint-disable-next-line @typescript-eslint/no-deprecated class TestableUIHttpServer extends UIHttpServer { public addResponseHandler (uuid: UUIDv4, res: MockServerResponse): void { this.responseHandlers.set(uuid, res as never) @@ -173,6 +174,7 @@ await describe('UIHttpServer', async () => { }) await it('should create server with custom host and port', () => { + // eslint-disable-next-line @typescript-eslint/no-deprecated const serverCustom = new UIHttpServer( createMockUIServerConfiguration({ options: { diff --git a/tests/charging-station/ui-server/UIMCPServer.integration.test.ts b/tests/charging-station/ui-server/UIMCPServer.integration.test.ts new file mode 100644 index 00000000..c3e669f5 --- /dev/null +++ b/tests/charging-station/ui-server/UIMCPServer.integration.test.ts @@ -0,0 +1,207 @@ +/** + * @file UIMCPServer Integration Tests + * @description HTTP integration tests verifying MCP endpoint responds correctly + */ + +import type { AddressInfo } from 'node:net' + +import assert from 'node:assert/strict' +import { writeFileSync } from 'node:fs' +import { request as httpRequest, type Server } from 'node:http' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import { UIMCPServer } from '../../../src/charging-station/ui-server/UIMCPServer.js' +import { HttpMethod } from '../../../src/charging-station/ui-server/UIServerUtils.js' +import { ApplicationProtocol } from '../../../src/types/index.js' +import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' +import { createMockUIServerConfiguration } from './UIServerTestUtils.js' + +/** + * Parse SSE events from raw response body. + * MCP Streamable HTTP transport sends responses as SSE `event: message` frames. + * @param raw - Raw SSE response body string + * @returns Array of parsed JSON objects from SSE data lines + */ +const parseSseEvents = (raw: string): object[] => { + const events: object[] = [] + for (const block of raw.split('\n\n')) { + const dataLine = block.split('\n').find(line => line.startsWith('data: ')) + if (dataLine != null) { + const jsonStr = dataLine.slice('data: '.length).trim() + if (jsonStr.length > 0) { + events.push(JSON.parse(jsonStr) as object) + } + } + } + return events +} + +const makeMcpPost = (port: number, body: object): Promise<{ events: object[]; status: number }> => { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body) + const req = httpRequest( + { + headers: { + Accept: 'application/json, text/event-stream', + 'Content-Length': Buffer.byteLength(payload), + 'Content-Type': 'application/json', + }, + hostname: 'localhost', + method: HttpMethod.POST, + path: '/mcp', + port, + }, + res => { + const chunks: Buffer[] = [] + res.on('data', (c: Buffer) => chunks.push(c)) + res.on('end', () => { + const raw = Buffer.concat(chunks).toString() + const contentType = res.headers['content-type'] ?? '' + if (contentType.includes('text/event-stream')) { + resolve({ events: parseSseEvents(raw), status: res.statusCode ?? 0 }) + } else { + try { + resolve({ events: [JSON.parse(raw) as object], status: res.statusCode ?? 0 }) + } catch { + reject(new Error(`Invalid response: ${raw}`)) + } + } + }) + } + ) + req.on('error', reject) + req.write(payload) + req.end() + }) +} + +const callTool = async ( + port: number, + toolName: string, + args: Record = {} +): Promise<{ content: { text: string; type: string }[]; isError?: boolean }> => { + // Initialize session + await makeMcpPost(port, { + id: 'init', + jsonrpc: '2.0', + method: 'initialize', + params: { + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-03-26', + }, + }) + // Call tool + const response = await makeMcpPost(port, { + id: 'call', + jsonrpc: '2.0', + method: 'tools/call', + params: { arguments: args, name: toolName }, + }) + assert.strictEqual(response.status, 200) + assert.ok(response.events.length > 0) + const body = response.events[response.events.length - 1] as Record + assert.strictEqual(body.jsonrpc, '2.0') + assert.strictEqual(body.id, 'call') + return body.result as { content: { text: string; type: string }[]; isError?: boolean } +} + +await describe('UIMCPServer HTTP Integration', async () => { + let server: UIMCPServer + let testPort: number + + beforeEach(async () => { + server = new UIMCPServer( + createMockUIServerConfiguration({ + options: { host: 'localhost', port: 0 }, + type: ApplicationProtocol.MCP, + }) + ) + server.start() + const httpServer = Reflect.get(server, 'httpServer') as Server + await new Promise(resolve => { + if (httpServer.listening) { + resolve() + } else { + httpServer.on('listening', resolve) + } + }) + testPort = (httpServer.address() as AddressInfo).port + }) + + afterEach(async () => { + server.stop() + await new Promise(resolve => { + setTimeout(resolve, 50) + }) + standardCleanup() + }) + + await it('should respond to MCP initialize request with serverInfo and capabilities', async () => { + const response = await makeMcpPost(testPort, { + id: '1', + jsonrpc: '2.0', + method: 'initialize', + params: { + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-03-26', + }, + }) + + assert.strictEqual(response.status, 200) + assert.ok(response.events.length > 0, 'Should receive at least one SSE event') + const body = response.events[response.events.length - 1] as Record + assert.strictEqual(body.jsonrpc, '2.0') + assert.strictEqual(body.id, '1') + assert.ok('result' in body, 'Response should have a result field') + const result = body.result as Record + assert.ok('serverInfo' in result, 'Result should have serverInfo') + assert.ok('capabilities' in result, 'Result should have capabilities') + }) + + await describe('readCombinedLog tool', async () => { + await it('should return log content with default date (current local date)', async () => { + // Arrange - create a log file for today's local date + const now = new Date() + const todayDate = `${now.getFullYear().toString()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}` + const logDir = join(process.cwd(), 'logs') + const logFile = join(logDir, `combined-${todayDate}.log`) + writeFileSync(logFile, 'info: test log line 1\ninfo: test log line 2\n', { flag: 'a' }) + + // Act + const result = await callTool(testPort, 'readCombinedLog', { tail: 10 }) + + // Assert + assert.strictEqual(result.isError, undefined) + assert.ok(result.content.length > 0) + assert.strictEqual(result.content[0].type, 'text') + assert.ok(result.content[0].text.includes('Showing last')) + }) + + await it('should return log content for explicit date parameter', async () => { + // Arrange - create a log file for a specific date + const logDir = join(process.cwd(), 'logs') + const testDate = '2020-01-01' + const logFile = join(logDir, `combined-${testDate}.log`) + writeFileSync(logFile, 'info: historical log entry\n') + + // Act + const result = await callTool(testPort, 'readCombinedLog', { date: testDate, tail: 10 }) + + // Assert + assert.strictEqual(result.isError, undefined) + assert.ok(result.content.length > 0) + assert.strictEqual(result.content[0].type, 'text') + assert.ok(result.content[0].text.includes('historical log entry')) + }) + + await it('should return error for non-existent date log file', async () => { + const result = await callTool(testPort, 'readCombinedLog', { date: '1999-01-01', tail: 10 }) + + assert.strictEqual(result.isError, true) + assert.ok(result.content[0].text.includes('not available')) + }) + }) +}) diff --git a/tests/charging-station/ui-server/UIMCPServer.test.ts b/tests/charging-station/ui-server/UIMCPServer.test.ts new file mode 100644 index 00000000..54567ed3 --- /dev/null +++ b/tests/charging-station/ui-server/UIMCPServer.test.ts @@ -0,0 +1,683 @@ +/** + * @file Tests for UIMCPServer + * @description Unit tests for MCP-based UI server transport and Promise bridge + */ + +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import type { IncomingMessage } from 'node:http' + +import assert from 'node:assert/strict' +import { dirname, join } from 'node:path' +import { Readable } from 'node:stream' +import { afterEach, beforeEach, describe, it } from 'node:test' +import { fileURLToPath } from 'node:url' + +import type { ProtocolResponse, RequestPayload, ResponsePayload } from '../../../src/types/index.js' + +import { mcpToolSchemas } from '../../../src/charging-station/ui-server/mcp/index.js' +import { UIMCPServer } from '../../../src/charging-station/ui-server/UIMCPServer.js' +import { DEFAULT_MAX_PAYLOAD_SIZE } from '../../../src/charging-station/ui-server/UIServerSecurity.js' +import { BaseError } from '../../../src/exception/index.js' +import { + ApplicationProtocol, + OCPPVersion, + ProcedureName, + ResponseStatus, +} from '../../../src/types/index.js' +import { logger } from '../../../src/utils/Logger.js' +import { + createLoggerMocks, + standardCleanup, + withMockTimers, +} from '../../helpers/TestLifecycleHelpers.js' +import { TEST_HASH_ID, TEST_HASH_ID_2, TEST_UUID, TEST_UUID_2 } from './UIServerTestConstants.js' +import { + createMockChargingStationDataWithVersion, + createMockUIServerConfiguration, +} from './UIServerTestUtils.js' + +class TestableUIMCPServer extends UIMCPServer { + public callCheckVersionCompatibility ( + hashIds: string[] | undefined, + ocpp16Payload: Record | undefined, + ocpp20Payload: Record | undefined, + procedureName: ProcedureName + ): CallToolResult | undefined { + return ( + Reflect.get(this, 'checkVersionCompatibility') as ( + hashIds: string[] | undefined, + ocpp16Payload: Record | undefined, + ocpp20Payload: Record | undefined, + procedureName: ProcedureName + ) => CallToolResult | undefined + ).call(this, hashIds, ocpp16Payload, ocpp20Payload, procedureName) + } + + public callInvokeProcedure ( + procedureName: ProcedureName, + input: RequestPayload, + service?: { requestHandler: (request: unknown) => Promise } + ): Promise { + return ( + Reflect.get(this, 'invokeProcedure') as ( + procedureName: ProcedureName, + input: RequestPayload, + service: + | undefined + | { requestHandler: (request: unknown) => Promise } + ) => Promise + ).call(this, procedureName, input, service) + } + + public callLoadOcppSchemas (): Map { + return ( + Reflect.get(this, 'loadOcppSchemas') as () => Map< + string, + { ocpp16?: unknown; ocpp20?: unknown } + > + ).call(this) + } + + public callReadRequestBody (req: IncomingMessage): Promise { + return ( + Reflect.get(this, 'readRequestBody') as (req: IncomingMessage) => Promise + ).call(this, req) + } + + public getPendingMcpRequest (uuid: string): + | undefined + | { + reject: (error: Error) => void + resolve: (payload: ResponsePayload) => void + timeout: ReturnType + } { + return ( + Reflect.get(this, 'pendingMcpRequests') as Map< + string, + { + reject: (error: Error) => void + resolve: (payload: ResponsePayload) => void + timeout: ReturnType + } + > + ).get(uuid) + } + + public getPendingMcpRequestsMap (): Map< + string, + { + reject: (error: Error) => void + resolve: (payload: ResponsePayload) => void + timeout: ReturnType + } + > { + return Reflect.get(this, 'pendingMcpRequests') as Map< + string, + { + reject: (error: Error) => void + resolve: (payload: ResponsePayload) => void + timeout: ReturnType + } + > + } + + public getPendingMcpRequestsSize (): number { + return (Reflect.get(this, 'pendingMcpRequests') as Map).size + } + + protected override getSchemaBaseDir (): string { + return join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', + 'src', + 'assets', + 'json-schemas', + 'ocpp' + ) + } +} + +const createMcpServerConfig = () => + createMockUIServerConfiguration({ type: ApplicationProtocol.MCP }) + +/** + * Assert that a CallToolResult is an error containing the expected substring. + * @param result - MCP tool result to validate + * @param expectedSubstring - Text expected in the error message + */ +const assertToolError = (result: CallToolResult, expectedSubstring: string): void => { + assert.strictEqual(result.isError, true) + const text = result.content[0] + assert.ok('text' in text) + assert.ok(text.text.includes(expectedSubstring)) +} + +await describe('UIMCPServer', async () => { + let server: TestableUIMCPServer + + beforeEach(() => { + server = new TestableUIMCPServer(createMcpServerConfig()) + }) + + afterEach(() => { + standardCleanup() + }) + + await describe('Construction and type', async () => { + await it('should have uiServerType of UI MCP Server', () => { + assert.strictEqual(Reflect.get(server, 'uiServerType'), 'UI MCP Server') + }) + + await it('should create HTTP server', () => { + assert.notStrictEqual(Reflect.get(server, 'httpServer'), undefined) + }) + }) + + await describe('Tool schema registration', async () => { + await it('should have a tool schema for every ProcedureName', () => { + assert.strictEqual(mcpToolSchemas.size, Object.keys(ProcedureName).length) + }) + }) + + await describe('hasResponseHandler override', async () => { + await it('should return false when no handler registered', () => { + assert.strictEqual(server.hasResponseHandler(TEST_UUID), false) + }) + + await it('should return true when response handler registered via base class', () => { + // eslint-disable-next-line @typescript-eslint/dot-notation + server['responseHandlers'].set(TEST_UUID, {} as never) + assert.strictEqual(server.hasResponseHandler(TEST_UUID), true) + // eslint-disable-next-line @typescript-eslint/dot-notation + server['responseHandlers'].delete(TEST_UUID) + }) + + await it('should return true when uuid is in pendingMcpRequests', () => { + const timeout = setTimeout(() => undefined, 30000) + const pendingMap = server.getPendingMcpRequestsMap() + pendingMap.set(TEST_UUID, { + reject: (_error: Error) => undefined, + resolve: (_payload?: ResponsePayload) => undefined, + timeout, + }) + + assert.strictEqual(server.hasResponseHandler(TEST_UUID), true) + + clearTimeout(timeout) + pendingMap.delete(TEST_UUID) + }) + }) + + await describe('sendResponse Promise bridge', async () => { + await it('should resolve pending Promise when sendResponse called with matching UUID', () => { + let resolvedPayload: ResponsePayload | undefined + const timeout = setTimeout(() => undefined, 30000) + const pendingMap = server.getPendingMcpRequestsMap() + pendingMap.set(TEST_UUID, { + reject: (_error: Error) => undefined, + resolve: (payload: ResponsePayload) => { + resolvedPayload = payload + }, + timeout, + }) + + const expectedPayload: ResponsePayload = { status: ResponseStatus.SUCCESS } + server.sendResponse([TEST_UUID, expectedPayload]) + + assert.ok(resolvedPayload != null, 'resolvedPayload should be defined') + assert.deepStrictEqual(resolvedPayload, expectedPayload) + }) + + await it('should clear timeout when resolving pending request', t => { + const clearTimeoutMock = t.mock.method(globalThis, 'clearTimeout') + + const timeout = setTimeout(() => undefined, 30000) + const pendingMap = server.getPendingMcpRequestsMap() + pendingMap.set(TEST_UUID, { + reject: (_error: Error) => undefined, + resolve: (_payload?: ResponsePayload) => undefined, + timeout, + }) + + server.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }]) + + assert.ok(clearTimeoutMock.mock.calls.length > 0) + }) + + await it('should delete pending entry after resolve', () => { + const timeout = setTimeout(() => undefined, 30000) + const pendingMap = server.getPendingMcpRequestsMap() + pendingMap.set(TEST_UUID, { + reject: (_error: Error) => undefined, + resolve: (_payload?: ResponsePayload) => undefined, + timeout, + }) + + assert.strictEqual(server.getPendingMcpRequestsSize(), 1) + + server.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }]) + + assert.strictEqual(server.getPendingMcpRequestsSize(), 0) + }) + + await it('should log error when sendResponse called for unknown UUID', t => { + const { errorMock } = createLoggerMocks(t, logger) + + server.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }]) + + assert.strictEqual(errorMock.mock.calls.length, 1) + }) + }) + + await describe('sendRequest warning', async () => { + await it('should log warning when sendRequest is called in stateless mode', t => { + const { warnMock } = createLoggerMocks(t, logger) + + server.sendRequest([TEST_UUID, ProcedureName.LIST_CHARGING_STATIONS, {}]) + + assert.strictEqual(warnMock.mock.calls.length, 1) + }) + }) + + await describe('stop cleanup', async () => { + await it('should reject all pending requests on stop', () => { + const rejectedErrors: Error[] = [] + const timeout1 = setTimeout(() => undefined, 30000) + const timeout2 = setTimeout(() => undefined, 30000) + const pendingMap = server.getPendingMcpRequestsMap() + + pendingMap.set(TEST_UUID, { + reject: (error: Error) => { + rejectedErrors.push(error) + }, + resolve: (_payload?: ResponsePayload) => undefined, + timeout: timeout1, + }) + pendingMap.set(TEST_UUID_2, { + reject: (error: Error) => { + rejectedErrors.push(error) + }, + resolve: (_payload?: ResponsePayload) => undefined, + timeout: timeout2, + }) + + assert.strictEqual(server.getPendingMcpRequestsSize(), 2) + + server.stop() + + assert.strictEqual(rejectedErrors.length, 2) + assert.ok(rejectedErrors[0] instanceof Error) + assert.ok(rejectedErrors[1] instanceof Error) + assert.strictEqual(rejectedErrors[0].message, 'Server stopping') + assert.strictEqual(rejectedErrors[1].message, 'Server stopping') + }) + + await it('should clear all timeouts on stop', t => { + const clearTimeoutMock = t.mock.method(globalThis, 'clearTimeout') + + const timeout1 = setTimeout(() => undefined, 30000) + const timeout2 = setTimeout(() => undefined, 30000) + const pendingMap = server.getPendingMcpRequestsMap() + + pendingMap.set(TEST_UUID, { + reject: (_error: Error) => undefined, + resolve: (_payload?: ResponsePayload) => undefined, + timeout: timeout1, + }) + pendingMap.set(TEST_UUID_2, { + reject: (_error: Error) => undefined, + resolve: (_payload?: ResponsePayload) => undefined, + timeout: timeout2, + }) + + server.stop() + + assert.ok(clearTimeoutMock.mock.calls.length >= 2) + }) + + await it('should clear pending map on stop', () => { + const timeout = setTimeout(() => undefined, 30000) + const pendingMap = server.getPendingMcpRequestsMap() + + pendingMap.set(TEST_UUID, { + reject: (_error: Error) => undefined, + resolve: (_payload?: ResponsePayload) => undefined, + timeout, + }) + + assert.strictEqual(server.getPendingMcpRequestsSize(), 1) + + server.stop() + + assert.strictEqual(server.getPendingMcpRequestsSize(), 0) + }) + }) + + await describe('invokeProcedure', async () => { + await it('should return error response when service is null', async () => { + const result = await server.callInvokeProcedure( + ProcedureName.LIST_CHARGING_STATIONS, + {}, + undefined + ) + + assertToolError(result, 'UI service not available') + }) + + await it('should return error response when both ocpp16Payload and ocpp20Payload are provided', async () => { + const mockService = { + requestHandler: () => Promise.resolve(undefined), + } + const input = { + ocpp16Payload: { idTag: 'TAG1' }, + ocpp20Payload: { idToken: {} }, + } as unknown as RequestPayload + + const result = await server.callInvokeProcedure(ProcedureName.AUTHORIZE, input, mockService) + + assertToolError(result, 'Cannot provide both') + }) + + await it('should return error response when version compatibility check fails', async () => { + // Arrange - station is OCPP 2.0, but sending ocpp16Payload + server.setChargingStationData( + TEST_HASH_ID, + createMockChargingStationDataWithVersion(TEST_HASH_ID, OCPPVersion.VERSION_20) + ) + const mockService = { + requestHandler: () => Promise.resolve(undefined), + } + const input = { + hashIds: [TEST_HASH_ID], + ocpp16Payload: { idTag: 'TAG1' }, + } as unknown as RequestPayload + + // Act + const result = await server.callInvokeProcedure(ProcedureName.AUTHORIZE, input, mockService) + + // Assert + assertToolError(result, TEST_HASH_ID) + }) + + await it('should resolve with direct response when service returns immediately', async () => { + const directPayload: ResponsePayload = { + hashIdsSucceeded: ['station-1'], + status: ResponseStatus.SUCCESS, + } + const mockService = { + requestHandler: (request: unknown) => { + const [uuid] = request as [string, string, unknown] + return Promise.resolve([uuid, directPayload] as ProtocolResponse) + }, + } + + const result = await server.callInvokeProcedure( + ProcedureName.LIST_CHARGING_STATIONS, + {}, + mockService + ) + + assert.strictEqual(result.isError, undefined) + const text = result.content[0] + assert.ok('text' in text) + const parsed = JSON.parse(text.text) as ResponsePayload + assert.strictEqual(parsed.status, ResponseStatus.SUCCESS) + assert.deepStrictEqual(parsed.hashIdsSucceeded, ['station-1']) + }) + + await it('should return error response when service throws', async () => { + const mockService = { + requestHandler: () => Promise.reject(new Error('Service failure')), + } + + const result = await server.callInvokeProcedure( + ProcedureName.LIST_CHARGING_STATIONS, + {}, + mockService + ) + + assertToolError(result, 'Service failure') + }) + + await it('should return timeout error after MCP_TOOL_TIMEOUT_MS', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange - service returns undefined (broadcast/async) and never resolves + const mockService = { + requestHandler: () => Promise.resolve(undefined), + } + + // Act + const resultPromise = server.callInvokeProcedure( + ProcedureName.START_CHARGING_STATION, + {}, + mockService + ) + + // Allow the service.requestHandler microtask to complete + await Promise.resolve() + await Promise.resolve() + + // Tick past the 30s timeout + t.mock.timers.tick(30_000) + + const result = await resultPromise + + // Assert + assertToolError(result, 'timed out') + }) + }) + }) + + await describe('checkVersionCompatibility', async () => { + await it('should return undefined when both payloads are undefined', () => { + const result = server.callCheckVersionCompatibility( + undefined, + undefined, + undefined, + ProcedureName.AUTHORIZE + ) + + assert.strictEqual(result, undefined) + }) + + await it('should return undefined when ocpp16Payload matches 1.6 station', () => { + server.setChargingStationData( + TEST_HASH_ID, + createMockChargingStationDataWithVersion(TEST_HASH_ID, OCPPVersion.VERSION_16) + ) + + const result = server.callCheckVersionCompatibility( + [TEST_HASH_ID], + { idTag: 'TAG1' }, + undefined, + ProcedureName.AUTHORIZE + ) + + assert.strictEqual(result, undefined) + }) + + await it('should return undefined when ocpp20Payload matches 2.0 station', () => { + server.setChargingStationData( + TEST_HASH_ID, + createMockChargingStationDataWithVersion(TEST_HASH_ID, OCPPVersion.VERSION_20) + ) + + const result = server.callCheckVersionCompatibility( + [TEST_HASH_ID], + undefined, + { idToken: {} }, + ProcedureName.AUTHORIZE + ) + + assert.strictEqual(result, undefined) + }) + + await it('should return undefined when ocpp20Payload matches 2.0.1 station', () => { + server.setChargingStationData( + TEST_HASH_ID, + createMockChargingStationDataWithVersion(TEST_HASH_ID, OCPPVersion.VERSION_201) + ) + + const result = server.callCheckVersionCompatibility( + [TEST_HASH_ID], + undefined, + { idToken: {} }, + ProcedureName.AUTHORIZE + ) + + assert.strictEqual(result, undefined) + }) + + await it('should return error when ocpp16Payload sent to 2.0 station', () => { + server.setChargingStationData( + TEST_HASH_ID, + createMockChargingStationDataWithVersion(TEST_HASH_ID, OCPPVersion.VERSION_20) + ) + + const result = server.callCheckVersionCompatibility( + [TEST_HASH_ID], + { idTag: 'TAG1' }, + undefined, + ProcedureName.AUTHORIZE + ) + + assert.ok(result != null, 'Expected error result') + assertToolError(result, TEST_HASH_ID) + const text = result.content[0] + assert.ok('text' in text) + assert.ok(text.text.includes('ocpp20Payload')) + }) + + await it('should return error when ocpp20Payload sent to 1.6 station', () => { + server.setChargingStationData( + TEST_HASH_ID, + createMockChargingStationDataWithVersion(TEST_HASH_ID, OCPPVersion.VERSION_16) + ) + + const result = server.callCheckVersionCompatibility( + [TEST_HASH_ID], + undefined, + { idToken: {} }, + ProcedureName.AUTHORIZE + ) + + assert.ok(result != null, 'Expected error result') + assertToolError(result, TEST_HASH_ID) + const text = result.content[0] + assert.ok('text' in text) + assert.ok(text.text.includes('ocpp16Payload')) + }) + + await it('should check only specified hashIds when provided', () => { + server.setChargingStationData( + TEST_HASH_ID, + createMockChargingStationDataWithVersion(TEST_HASH_ID, OCPPVersion.VERSION_16) + ) + server.setChargingStationData( + TEST_HASH_ID_2, + createMockChargingStationDataWithVersion(TEST_HASH_ID_2, OCPPVersion.VERSION_20) + ) + + const result = server.callCheckVersionCompatibility( + [TEST_HASH_ID], + { idTag: 'TAG1' }, + undefined, + ProcedureName.AUTHORIZE + ) + + assert.strictEqual(result, undefined) + }) + + await it('should check all stations when hashIds is undefined', () => { + server.setChargingStationData( + TEST_HASH_ID, + createMockChargingStationDataWithVersion(TEST_HASH_ID, OCPPVersion.VERSION_16) + ) + server.setChargingStationData( + TEST_HASH_ID_2, + createMockChargingStationDataWithVersion(TEST_HASH_ID_2, OCPPVersion.VERSION_20) + ) + + const result = server.callCheckVersionCompatibility( + undefined, + { idTag: 'TAG1' }, + undefined, + ProcedureName.AUTHORIZE + ) + + assert.ok(result != null, 'Expected error result') + assertToolError(result, TEST_HASH_ID_2) + }) + }) + + await describe('readRequestBody', async () => { + await it('should resolve with parsed JSON on valid body', async () => { + const expected = { jsonrpc: '2.0', method: 'tools/list' } + const mockReq = Readable.from([Buffer.from(JSON.stringify(expected))]) + + const result = await server.callReadRequestBody(mockReq as unknown as IncomingMessage) + assert.deepStrictEqual(result, expected) + }) + + await it('should reject with BaseError when payload too large', async () => { + const oversizedChunk = Buffer.alloc(DEFAULT_MAX_PAYLOAD_SIZE + 1) + const mockReq = Readable.from([oversizedChunk]) + + await assert.rejects( + server.callReadRequestBody(mockReq as unknown as IncomingMessage), + (error: Error) => { + assert.ok(error instanceof BaseError) + assert.ok(error.message.includes('Payload too large')) + return true + } + ) + }) + + await it('should reject with error on invalid JSON', async () => { + const mockReq = Readable.from([Buffer.from('not valid json {{{')]) + + await assert.rejects(server.callReadRequestBody(mockReq as unknown as IncomingMessage)) + }) + + await it('should reject with error on stream error', async () => { + const mockReq = new Readable({ + read () { + this.destroy(new Error('Connection reset')) + }, + }) + + await assert.rejects( + server.callReadRequestBody(mockReq as unknown as IncomingMessage), + (error: Error) => { + assert.strictEqual(error.message, 'Connection reset') + return true + } + ) + }) + }) + + await describe('loadOcppSchemas', async () => { + await it('should load and cache OCPP schemas from disk', () => { + const cache = server.callLoadOcppSchemas() + + assert.ok(cache.size > 0, 'Schema cache should not be empty') + const authorizeSchemas = cache.get(ProcedureName.AUTHORIZE) + assert.ok(authorizeSchemas != null, 'Should have schemas for authorize') + assert.ok(authorizeSchemas.ocpp16 != null, 'Should have OCPP 1.6 schema for authorize') + assert.ok(authorizeSchemas.ocpp20 != null, 'Should have OCPP 2.0 schema for authorize') + }) + + await it('should only cache entries that have at least one schema loaded', () => { + const cache = server.callLoadOcppSchemas() + + for (const [, entry] of cache) { + assert.ok( + entry.ocpp16 != null || entry.ocpp20 != null, + 'Cached entry should have at least one schema' + ) + } + }) + }) +}) diff --git a/tests/charging-station/ui-server/UIServerFactory.test.ts b/tests/charging-station/ui-server/UIServerFactory.test.ts new file mode 100644 index 00000000..1916a1a6 --- /dev/null +++ b/tests/charging-station/ui-server/UIServerFactory.test.ts @@ -0,0 +1,63 @@ +/** + * @file Tests for UIServerFactory + * @description Unit tests for UI server factory and protocol-specific server creation + */ + +import assert from 'node:assert/strict' +import { afterEach, describe, it } from 'node:test' + +import { UIHttpServer } from '../../../src/charging-station/ui-server/UIHttpServer.js' +import { UIMCPServer } from '../../../src/charging-station/ui-server/UIMCPServer.js' +import { UIServerFactory } from '../../../src/charging-station/ui-server/UIServerFactory.js' +import { UIWebSocketServer } from '../../../src/charging-station/ui-server/UIWebSocketServer.js' +import { ApplicationProtocol, ApplicationProtocolVersion } from '../../../src/types/index.js' +import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' +import { createMockUIServerConfiguration } from './UIServerTestUtils.js' + +await describe('UIServerFactory', async () => { + afterEach(() => { + standardCleanup() + }) + + await it('should create UIHttpServer for HTTP protocol', () => { + const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) + const server = UIServerFactory.getUIServerImplementation(config) + // eslint-disable-next-line @typescript-eslint/no-deprecated + assert.ok(server instanceof UIHttpServer) + server.stop() + }) + + await it('should create UIWebSocketServer for WS protocol', () => { + const config = createMockUIServerConfiguration({ type: ApplicationProtocol.WS }) + const server = UIServerFactory.getUIServerImplementation(config) + assert.ok(server instanceof UIWebSocketServer) + server.stop() + }) + + await it('should create UIMCPServer for MCP protocol', () => { + const config = createMockUIServerConfiguration({ type: ApplicationProtocol.MCP }) + const server = UIServerFactory.getUIServerImplementation(config) + assert.ok(server instanceof UIMCPServer) + server.stop() + }) + + await it('should fall back to VERSION_11 for MCP with VERSION_20', () => { + const config = createMockUIServerConfiguration({ + type: ApplicationProtocol.MCP, + version: ApplicationProtocolVersion.VERSION_20, + }) + const server = UIServerFactory.getUIServerImplementation(config) + assert.strictEqual(config.version, ApplicationProtocolVersion.VERSION_11) + server.stop() + }) + + await it('should fall back to VERSION_11 for WS with VERSION_20', () => { + const config = createMockUIServerConfiguration({ + type: ApplicationProtocol.WS, + version: ApplicationProtocolVersion.VERSION_20, + }) + const server = UIServerFactory.getUIServerImplementation(config) + assert.strictEqual(config.version, ApplicationProtocolVersion.VERSION_11) + server.stop() + }) +}) diff --git a/tests/charging-station/ui-server/UIServerTestUtils.ts b/tests/charging-station/ui-server/UIServerTestUtils.ts index 0919913d..c162f0b8 100644 --- a/tests/charging-station/ui-server/UIServerTestUtils.ts +++ b/tests/charging-station/ui-server/UIServerTestUtils.ts @@ -17,11 +17,13 @@ import type { UUIDv4, } from '../../../src/types/index.js' +import { HttpMethod } from '../../../src/charging-station/ui-server/UIServerUtils.js' import { UIWebSocketServer } from '../../../src/charging-station/ui-server/UIWebSocketServer.js' import { ApplicationProtocol, ApplicationProtocolVersion, AuthenticationType, + type OCPPVersion, ResponseStatus, } from '../../../src/types/index.js' import { MockWebSocket } from '../mocks/MockWebSocket.js' @@ -179,7 +181,7 @@ export const createMockIncomingMessage = ( ): IncomingMessage => { return { headers: {}, - method: 'POST', + method: HttpMethod.POST, url: '/ui', ...overrides, } as IncomingMessage @@ -288,3 +290,26 @@ export const waitForStreamFlush = async (delayMs: number): Promise => { setTimeout(resolve, delayMs) }) } + +/** + * Create mock charging station data with a specific OCPP version. + * @param hashId - Unique identifier for the charging station + * @param ocppVersion - OCPP protocol version + * @returns ChargingStationData with the specified OCPP version + */ +export const createMockChargingStationDataWithVersion = ( + hashId: string, + ocppVersion: OCPPVersion +): ChargingStationData => + createMockChargingStationData(hashId, { + stationInfo: { + baseName: 'test', + chargePointModel: 'TestModel', + chargePointVendor: 'TestVendor', + chargingStationId: hashId, + hashId, + ocppVersion, + templateIndex: 0, + templateName: 'test-template', + }, + }) -- 2.43.0