]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ui-server): add MCP transport and deprecate HTTP (#1746)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Mon, 23 Mar 2026 23:59:59 +0000 (00:59 +0100)
committerGitHub <noreply@github.com>
Mon, 23 Mar 2026 23:59:59 +0000 (00:59 +0100)
* 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.

40 files changed:
.serena/project.yml
README.md
eslint.config.js
package.json
pnpm-lock.yaml
scripts/bundle.js
src/charging-station/ChargingStation.ts
src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/1.6/OCPP16RequestService.ts
src/charging-station/ocpp/1.6/OCPP16ResponseService.ts
src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20RequestService.ts
src/charging-station/ocpp/2.0/OCPP20ResponseService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
src/charging-station/ui-server/UIHttpServer.ts
src/charging-station/ui-server/UIMCPServer.ts [new file with mode: 0644]
src/charging-station/ui-server/UIServerFactory.ts
src/charging-station/ui-server/UIServerUtils.ts
src/charging-station/ui-server/mcp/MCPResourceHandlers.ts [new file with mode: 0644]
src/charging-station/ui-server/mcp/MCPToolSchemas.ts [new file with mode: 0644]
src/charging-station/ui-server/mcp/index.ts [new file with mode: 0644]
src/types/Evse.ts
src/types/UIProtocol.ts
src/types/ocpp/Transaction.ts
tests/charging-station/ChargingStationTestConstants.ts
tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts
tests/charging-station/ocpp/2.0/OCPP20RequestService-CallChain.test.ts
tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts
tests/charging-station/ocpp/OCPPServiceUtils-connectorStatus.test.ts
tests/charging-station/ocpp/OCPPServiceUtils-meterValues.test.ts [new file with mode: 0644]
tests/charging-station/ui-server/UIHttpServer.test.ts
tests/charging-station/ui-server/UIMCPServer.integration.test.ts [new file with mode: 0644]
tests/charging-station/ui-server/UIMCPServer.test.ts [new file with mode: 0644]
tests/charging-station/ui-server/UIServerFactory.test.ts [new file with mode: 0644]
tests/charging-station/ui-server/UIServerTestUtils.ts

index 7c0ff4d8eef611f3982356d0969d24708a6557c2..4c098ef2dfa927117301b9d099dd9cb26df31a7c 100644 (file)
@@ -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: {}
index dd189303d568604e296435eccc9d006244cd2612..a0ae13af99287286238a7cbe04143b09fbb74dbd 100644 (file)
--- 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                        |                                              | {<br />"enabled": true,<br />"file": "logs/combined.log",<br />"errorFile": "logs/error.log",<br />"statisticsInterval": 60,<br />"level": "info",<br />"console": false,<br />"format": "simple",<br />"rotate": true<br />} | {<br />enabled?: boolean;<br />file?: string;<br />errorFile?: string;<br />statisticsInterval?: number;<br />level?: string;<br />console?: boolean;<br />format?: string;<br />rotate?: boolean;<br />maxFiles?: string \| number;<br />maxSize?: string \| number;<br />}    | Log configuration section:<br />- _enabled_: enable logging<br />- _file_: log file relative path<br />- _errorFile_: error log file relative path<br />- _statisticsInterval_: seconds between charging stations statistics output in the logs<br />- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level</br >- _console_: output logs on the console<br />- _format_: [winston](https://github.com/winstonjs/winston) log format<br />- _rotate_: enable daily log files rotation<br />- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options<br />- _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                     |                                              | {<br />"processType": "workerSet",<br />"startDelay": 500,<br />"elementAddDelay": 0,<br />"elementsPerWorker": 'auto',<br />"poolMinSize": 4,<br />"poolMaxSize": 16<br />}                                                  | {<br />processType?: WorkerProcessType;<br />startDelay?: number;<br />elementAddDelay?: number;<br />elementsPerWorker?: number \| 'auto' \| 'all';<br />poolMinSize?: number;<br />poolMaxSize?: number;<br />resourceLimits?: ResourceLimits;<br />}                         | Worker configuration section:<br />- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)<br />- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)<br />- _elementAddDelay_: milliseconds to wait between charging station add<br />- _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)<br />- _poolMinSize_: worker threads pool minimum number of threads</br >- _poolMaxSize_: worker threads pool maximum number of threads<br />- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option |
-| uiServer                   |                                              | {<br />"enabled": false,<br />"type": "ws",<br />"version": "1.1",<br />"options": {<br />"host": "localhost",<br />"port": 8080<br />}<br />}                                                                                | {<br />enabled?: boolean;<br />type?: ApplicationProtocol;<br />version?: ApplicationProtocolVersion;<br />options?: ServerOptions;<br />authentication?: {<br />enabled: boolean;<br />type: AuthenticationType;<br />username?: string;<br />password?: string;<br />}<br />} | UI server configuration section:<br />- _enabled_: enable UI server<br />- _type_: 'http' or 'ws'<br />- _version_: HTTP version '1.1' or '2.0'<br />- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)<br />- _authentication_: authentication type configuration section                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
+| uiServer                   |                                              | {<br />"enabled": false,<br />"type": "ws",<br />"version": "1.1",<br />"options": {<br />"host": "localhost",<br />"port": 8080<br />}<br />}                                                                                | {<br />enabled?: boolean;<br />type?: ApplicationProtocol;<br />version?: ApplicationProtocolVersion;<br />options?: ServerOptions;<br />authentication?: {<br />enabled: boolean;<br />type: AuthenticationType;<br />username?: string;<br />password?: string;<br />}<br />} | UI server configuration section:<br />- _enabled_: enable UI server<br />- _type_: 'ws', 'mcp' or 'http' (deprecated)<br />- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')<br />- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)<br />- _authentication_: authentication type configuration section                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
 | performanceStorage         |                                              | {<br />"enabled": true,<br />"type": "none",<br />}                                                                                                                                                                           | {<br />enabled?: boolean;<br />type?: string;<br />uri?: string;<br />}                                                                                                                                                                                                         | Performance storage configuration section:<br />- _enabled_: enable performance storage<br />- _type_: 'jsonfile', 'mongodb' or 'none'<br />- _uri_: storage URI                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
 | stationTemplateUrls        |                                              | {}[]                                                                                                                                                                                                                          | {<br />file: string;<br />numberOfStations: number;<br />provisionedNumberOfStations?: number;<br />}[]                                                                                                                                                                         | array of charging station templates URIs configuration section:<br />- _file_: charging station configuration template file relative path<br />- _numberOfStations_: template number of stations at startup<br />- _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://<host>:<port>/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 <base64(user:pass)>` 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.
 
index 69be31d7aff4e07fa9be8f40260eba978c295381..43fcfd7a853e6e67d44fa92849016f55db60d424 100644 (file)
@@ -92,6 +92,10 @@ export default defineConfig([
               'reservability',
               // VPN protocol acronyms
               'PPTP',
+              // UI server protocol acronyms
+              'UIMCP',
+              'Streamable',
+              'modelcontextprotocol',
             ],
           },
         },
index 02ea2736a0ce67cde6b98be91fab1187dade4cbe..8f7fb9f68b921b5145bf436f76e9aa8f7624c3ae 100644 (file)
@@ -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",
index 9a99d676eb6f1e465271d12df59d1fcbe67294ec..d406ba4a9c09c538adbdeb6e4d000815cb342a2a 100644 (file)
@@ -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: {}
index 21080354efb9ec48f5feef99915277f29728e891..5cf078993b66769e6def778a2f8799511b3963ec 100644 (file)
@@ -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,
index a89f8c55dfa05beca83f71a8dddfb7ba4a347d7a..fec2fcb68017fda69c0f07613a5385276ac9bbe8 100644 (file)
@@ -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
         }
       }
index 559f9cd7d6fae0dfbd9ac05519d72cf354f8404b..b0f86d8433ef64266dc927c2adde2893256f2e87 100644 (file)
@@ -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<MeterValuesResponse> {
     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 (
index 11bdfbfa9b8e9a7f098103ee8d490116d7cc47f7..df6c1536422384f2b07f632b89c69670ef753ee0 100644 (file)
@@ -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)
             }
           }
         }
index 9ac47a5e5c4461e9caf2d32fe9ad2f042b931958..d55e10d7b2dfcee5dd3b740c7de7c843524a1455 100644 (file)
@@ -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 &&
index 12dfd7c10276071ca49ad3d26fead1500b33af6c..827a85d258d5abdba430b0afd7722005329b2309 100644 (file)
@@ -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
index ff5b5971944dff54720bb343284becd31800265a..3166fb903824c57d9bd8a1404843a22a5f80cc1f 100644 (file)
@@ -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<GenericResponse> => {
-    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<MeterValuesRequest, MeterValuesResponse>(
           chargingStation,
index e7dfc92c125ab71c40a1d6aece45833084ce3892..c2853d06341f3d53b446b96533df749fe22bb811 100644 (file)
@@ -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<OCPP20MeterValuesRequest, OCPP20MeterValuesResponse>(
                   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)
index 8115b4a6938dc8351a837d6c6b33d2b43711eeb2..3e04d7c75a42b0a54c6f6d8007480a2f46429366 100644 (file)
@@ -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`
index 75a737ad9f44a7bcc99147eb1dcdc26a0367681d..23f4e6e4ca6812ce815318645e4663d98190d91d 100644 (file)
@@ -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
index abea3b6036d12c89d05afd96ed2e13a12839c01a..bb1f69ffc880693706d32c19837604c239bb3f15 100644 (file)
@@ -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<string, unknown>
+  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}`
index 6ab76a06f7d70fb37c227caf74dda4132328b89d..7b30acd1ca79cbe065a4d22e0accfe8c99df3b15 100644 (file)
@@ -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<string, unknown>
+      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<boolean> => {
-  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<boolean> => {
-  // 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<boolean> => {
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   chargingStation.getConnectorStatus(connectorId)!.authorizeIdTag = idTag
-  return (
-    (
-      await chargingStation.ocppRequestService.requestHandler<AuthorizeRequest, AuthorizeResponse>(
-        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<void> => {
   options = { send: true, ...options }
+  const params = commandParams as Record<string, unknown>
+  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 = <T extends JsonType>(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 = <TSampledValue extends SampledValue>(
     chargingStation,
     connectorId,
     MeterValueMeasurand.VOLTAGE,
+    undefined,
     phaseLineToNeutralValue
   )
   let voltagePhaseLineToNeutralMeasurandValue: number | undefined
@@ -955,6 +972,7 @@ const addLineToLineVoltageToMeterValue = <TSampledValue extends SampledValue>(
     chargingStation,
     connectorId,
     MeterValueMeasurand.VOLTAGE,
+    undefined,
     phaseLineToLineValue
   )
   let voltagePhaseLineToLineMeasurandValue: number | undefined
@@ -985,9 +1003,10 @@ const addLineToLineVoltageToMeterValue = <TSampledValue extends SampledValue>(
 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;
index 08268713aef32ec05a297d7638ac7f513422e956..463613b84832ed0dcdd8afeea824bca42fc68e7e 100644 (file)
@@ -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 (file)
index 0000000..2d4934f
--- /dev/null
@@ -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<string, { ocpp16?: unknown; ocpp20?: unknown }>
+
+  private readonly pendingMcpRequests: Map<
+    UUIDv4,
+    {
+      reject: (error: Error) => void
+      resolve: (payload: ResponsePayload) => void
+      timeout: ReturnType<typeof setTimeout>
+    }
+  >
+
+  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<string, unknown> | undefined,
+    ocpp20Payload: Record<string, unknown> | 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<string, unknown>) => {
+          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<void> {
+    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<string, (...args: unknown[]) => Promise<unknown>>
+      | 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<string, unknown> }; 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<CallToolResult> {
+    if (service == null) {
+      return UIMCPServer.createToolErrorResponse('UI service not available')
+    }
+
+    const { ocpp16Payload, ocpp20Payload, ...rest } = input as RequestPayload & {
+      ocpp16Payload?: Record<string, unknown>
+      ocpp20Payload?: Record<string, unknown>
+    }
+
+    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<CallToolResult>(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<string, { ocpp16?: unknown; ocpp20?: unknown }> {
+    const cache = new Map<string, { ocpp16?: unknown; ocpp20?: unknown }>()
+    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<unknown> {
+    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<number, string> = {
+      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`)
+  }
+}
index b0c87bba778b5963337d3f135793bb97f018b477..ce8d399363a376d90d0bb2bdbfca5796d7efb08a 100644 (file)
@@ -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 (
index dbed5ba1b0f883ac700d219499747aa06040be44..b789823c31d6a9c624755b29af0cc8a5745381c2 100644 (file)
@@ -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 (file)
index 0000000..ec13451
--- /dev/null
@@ -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<LogConfiguration>(
+    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 (file)
index 0000000..2eee125
--- /dev/null
@@ -0,0 +1,414 @@
+import { z } from 'zod'
+
+import { ProcedureName } from '../../../types/index.js'
+
+export interface MCPToolSchema {
+  description: string
+  inputSchema: z.ZodObject<z.ZodRawShape>
+}
+
+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, { ocpp16?: string; ocpp20?: string }>([
+  [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<z.ZodRawShape> => {
+  const fields: Record<string, z.ZodType> = { 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<z.ZodRawShape> =>
+  buildOcppInputSchema(getMapping(name))
+
+export const mcpToolSchemas = new Map<ProcedureName, MCPToolSchema>([
+  [
+    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 (file)
index 0000000..ad2469d
--- /dev/null
@@ -0,0 +1,7 @@
+export {
+  registerMCPLogTools,
+  registerMCPResources,
+  registerMCPSchemaResources,
+} from './MCPResourceHandlers.js'
+export { mcpToolSchemas, ocppSchemaMapping } from './MCPToolSchemas.js'
+export type { MCPToolSchema } from './MCPToolSchemas.js'
index 96eba54d065acf74cdcc8b78a53ffa66efe7e03f..e1e36b02a336cad8fa367b101d8bca15a244454a 100644 (file)
@@ -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<number, ConnectorStatus>
+  MeterValues?: SampledValueTemplate[]
 }
 
 export interface EvseTemplate {
   Connectors: Record<string, ConnectorStatus>
+  MeterValues?: SampledValueTemplate[]
 }
index 76e60db57416bbe27e4856d174860f47ae3f976d..4fc5305b85ba2c22cfaf384045f946e49cfa4c1f 100644 (file)
@@ -4,6 +4,7 @@ import type { BroadcastChannelResponsePayload } from './WorkerBroadcastChannel.j
 
 export enum ApplicationProtocol {
   HTTP = 'http',
+  MCP = 'mcp',
   WS = 'ws',
 }
 
index a96c5d0529f0c758cc65a08875499fe237c2417b..63ca2edddec37d7893b35903b16d4e50df9d10cd 100644 (file)
@@ -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
 
index 47416a0799a7a1f6aafae42898f9b12d94f7fc19..bbf770ff7b4871ccef82e6084243d74dea3496a3 100644 (file)
@@ -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
 
 /**
index 437673ea1e4b4637f6a6acbcc8dba5f3d7a09110..9a6303e8e677b3e0c55f246871db2c988f92e6a3 100644 (file)
@@ -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)
index 334cf166d8676cf044a9f4836c9f43306378aa89..519fbe052928a271c7dd083a648b2b831e0825af 100644 (file)
@@ -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)
     })
index 9de0149dbfae98585e5548153c105499be1fdb5f..5cdba8440d5b4838891edfc76f1c7ce3e3ff9170 100644 (file)
@@ -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)
index a19718d1087b1941a02529a9a4b44fc9eb0c0cdd..68d70c5f3638047e7ba2f69b6f1ba24222c034c3 100644 (file)
@@ -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
 
index 0af0ef59421a8a80e71291fc98ebfc13a7cf3561..bc61f6d3755b14fa9a48d301657b429ab33149a8 100644 (file)
@@ -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)
index b196e5d59872383d63d46b7c2b70b58692c4e6f3..94ab7111dde59e2c6f474e2b5d3c15ccf79a245c 100644 (file)
@@ -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 (file)
index 0000000..9418d7a
--- /dev/null
@@ -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'
+      )
+    })
+  })
+})
index c4f794f69805efad9db7af61dded8ad564d31262..89d2f54c012cb1294cb3ab6714a89a4a034a93aa 100644 (file)
@@ -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 (file)
index 0000000..c3e669f
--- /dev/null
@@ -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<string, unknown> = {}
+): 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<string, unknown>
+  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<void>(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<string, unknown>
+    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<string, unknown>
+    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 (file)
index 0000000..54567ed
--- /dev/null
@@ -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<string, unknown> | undefined,
+    ocpp20Payload: Record<string, unknown> | undefined,
+    procedureName: ProcedureName
+  ): CallToolResult | undefined {
+    return (
+      Reflect.get(this, 'checkVersionCompatibility') as (
+        hashIds: string[] | undefined,
+        ocpp16Payload: Record<string, unknown> | undefined,
+        ocpp20Payload: Record<string, unknown> | undefined,
+        procedureName: ProcedureName
+      ) => CallToolResult | undefined
+    ).call(this, hashIds, ocpp16Payload, ocpp20Payload, procedureName)
+  }
+
+  public callInvokeProcedure (
+    procedureName: ProcedureName,
+    input: RequestPayload,
+    service?: { requestHandler: (request: unknown) => Promise<ProtocolResponse | undefined> }
+  ): Promise<CallToolResult> {
+    return (
+      Reflect.get(this, 'invokeProcedure') as (
+        procedureName: ProcedureName,
+        input: RequestPayload,
+        service:
+          | undefined
+          | { requestHandler: (request: unknown) => Promise<ProtocolResponse | undefined> }
+      ) => Promise<CallToolResult>
+    ).call(this, procedureName, input, service)
+  }
+
+  public callLoadOcppSchemas (): Map<string, { ocpp16?: unknown; ocpp20?: unknown }> {
+    return (
+      Reflect.get(this, 'loadOcppSchemas') as () => Map<
+        string,
+        { ocpp16?: unknown; ocpp20?: unknown }
+      >
+    ).call(this)
+  }
+
+  public callReadRequestBody (req: IncomingMessage): Promise<unknown> {
+    return (
+      Reflect.get(this, 'readRequestBody') as (req: IncomingMessage) => Promise<unknown>
+    ).call(this, req)
+  }
+
+  public getPendingMcpRequest (uuid: string):
+    | undefined
+    | {
+      reject: (error: Error) => void
+      resolve: (payload: ResponsePayload) => void
+      timeout: ReturnType<typeof setTimeout>
+    } {
+    return (
+      Reflect.get(this, 'pendingMcpRequests') as Map<
+        string,
+        {
+          reject: (error: Error) => void
+          resolve: (payload: ResponsePayload) => void
+          timeout: ReturnType<typeof setTimeout>
+        }
+      >
+    ).get(uuid)
+  }
+
+  public getPendingMcpRequestsMap (): Map<
+    string,
+    {
+      reject: (error: Error) => void
+      resolve: (payload: ResponsePayload) => void
+      timeout: ReturnType<typeof setTimeout>
+    }
+  > {
+    return Reflect.get(this, 'pendingMcpRequests') as Map<
+      string,
+      {
+        reject: (error: Error) => void
+        resolve: (payload: ResponsePayload) => void
+        timeout: ReturnType<typeof setTimeout>
+      }
+    >
+  }
+
+  public getPendingMcpRequestsSize (): number {
+    return (Reflect.get(this, 'pendingMcpRequests') as Map<string, unknown>).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 (file)
index 0000000..1916a1a
--- /dev/null
@@ -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()
+  })
+})
index 0919913da4a844876430cbfb7adbbfe34a7dc6e9..c162f0b8f6e0636026c6f9925c40d7b1cd4e0b85 100644 (file)
@@ -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<void> => {
     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',
+    },
+  })