]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp2): add GetVariables command support (#1568)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 24 Oct 2025 15:00:43 +0000 (17:00 +0200)
committerGitHub <noreply@github.com>
Fri, 24 Oct 2025 15:00:43 +0000 (17:00 +0200)
* feat(ocpp2): add GetVariables command support

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test: add GetVariables command UTs

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test: fix ocpp2 test expectation

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: refine OCPP2 type definitions and usages

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test: factor out mock charging station instance creation code

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test: add OCPP2 GetBaseReport command tests

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: cleanup GetBaseReport implementation

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: address review comment

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test: add OCPP 2 BootNotification and NotifyReport commands tests

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test: improve OCPP2 mock server commands support

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix: request handler are not supposed to throw OCPPError

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* chore: refine Serena MCP setup

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* chore: refine Serena MCP configuration

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test: add more OCPP2 commands UTs

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix: variable manager compliance with specs

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: cleanup OCPP2 namespace

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
34 files changed:
.serena/.gitignore [new file with mode: 0644]
.serena/memories/code_style_conventions.md [new file with mode: 0644]
.serena/memories/ocpp_architecture.md [new file with mode: 0644]
.serena/memories/project_overview.md [new file with mode: 0644]
.serena/memories/suggested_commands.md [new file with mode: 0644]
.serena/memories/task_completion_checklist.md [new file with mode: 0644]
.serena/project.yml [new file with mode: 0644]
README.md
src/charging-station/ChargingStation.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20VariableManager.ts [new file with mode: 0644]
src/charging-station/ocpp/index.ts
src/types/index.ts
src/types/ocpp/2.0/Common.ts
src/types/ocpp/2.0/Requests.ts
src/types/ocpp/2.0/Responses.ts
src/types/ocpp/2.0/Variables.ts
src/utils/Constants.ts
tests/ChargingStationFactory.ts [new file with mode: 0644]
tests/charging-station/Helpers.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20RequestService-BootNotification.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20RequestService-HeartBeat.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20TestConstants.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts [new file with mode: 0644]
tests/ocpp-server/README.md
tests/ocpp-server/pyproject.toml
tests/ocpp-server/server.py
tests/ocpp-server/test_server.py [new file with mode: 0644]
tests/utils/ErrorUtils.test.ts

diff --git a/.serena/.gitignore b/.serena/.gitignore
new file mode 100644 (file)
index 0000000..14d86ad
--- /dev/null
@@ -0,0 +1 @@
+/cache
diff --git a/.serena/memories/code_style_conventions.md b/.serena/memories/code_style_conventions.md
new file mode 100644 (file)
index 0000000..9d5f749
--- /dev/null
@@ -0,0 +1,33 @@
+# Code Style and Conventions
+
+## TypeScript/Node.js Conventions
+
+- **Naming**: camelCase for variables/functions/methods, PascalCase for classes/types/enums/interfaces
+- **Async operations**: Prefer async/await over raw Promises; handle rejections explicitly with try/catch
+- **Error handling**: Use typed errors (BaseError, OCPPError) with structured properties; avoid generic Error
+- **Null safety**: Avoid non-null assertions (!); use optional chaining (?.) and nullish coalescing (??)
+- **Type safety**: Prefer explicit types over any; use type guards and discriminated unions where appropriate
+
+## OCPP-specific Conventions
+
+- **Command naming**: Follow OCPP standard naming exactly (e.g., RemoteStartTransaction, BootNotification, StatusNotification)
+- **Enumeration naming**: Use standard OCPP specifications enumeration names and values exactly
+- **Version handling**: Clearly distinguish between OCPP 1.6 and 2.0.x implementations in separate namespaces/files
+- **Payload validation**: Validate against OCPP JSON schemas when ocppStrictCompliance is enabled
+- **Message format**: Use standard SRPC format: [messageTypeId, messageId, action, payload]
+
+## Testing Conventions
+
+- Use `describe()` and `it()` functions from Node.js test runner
+- Test files should be named `*.test.ts`
+- Use `@std/expect` for assertions
+- Mock charging stations with `createChargingStation()` or `createChargingStationWithEvses()`
+- Use `/* eslint-disable */` comments for specific test requirements
+- Async tests should use `await` in describe/it callbacks
+
+## File Organization
+
+- OCPP 1.6 code in `src/charging-station/ocpp/1.6/`
+- OCPP 2.0 code in `src/charging-station/ocpp/2.0/`
+- Types in `src/types/` with proper exports through index files
+- Tests mirror source structure in `tests/`
diff --git a/.serena/memories/ocpp_architecture.md b/.serena/memories/ocpp_architecture.md
new file mode 100644 (file)
index 0000000..c40ce5f
--- /dev/null
@@ -0,0 +1,38 @@
+# OCPP Implementation Architecture
+
+## Overview
+
+The project implements both OCPP 1.6 and OCPP 2.0.x protocols with clear separation:
+
+## Key Components
+
+### OCPP 2.0 Request Service
+
+- **Location**: `src/charging-station/ocpp/2.0/OCPP20RequestService.ts`
+- **Purpose**: Handles outgoing OCPP 2.0 requests
+- **Key Methods**:
+  - `buildRequestPayload()`: Constructs request payloads
+  - `requestHandler()`: Handles request processing
+  - Constructor sets up payload validation functions
+
+### Supported OCPP 2.0 Commands
+
+- `BOOT_NOTIFICATION`: Station startup notification
+- `HEARTBEAT`: Keep-alive messages
+- `NOTIFY_REPORT`: Configuration reports
+- `STATUS_NOTIFICATION`: Status updates
+
+### Request/Response Flow
+
+1. Request constructed via `buildRequestPayload()`
+2. Validation performed using JSON schemas
+3. Request sent via WebSocket
+4. Response handled by `OCPP20ResponseService`
+
+### Testing Patterns
+
+- Tests located in `tests/charging-station/ocpp/2.0/`
+- Use `OCPP20IncomingRequestService` for testing incoming requests
+- Use `OCPP20RequestService` for testing outgoing requests
+- Mock charging stations created with factory functions
+- Follow integration testing approach with real service instances
diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md
new file mode 100644 (file)
index 0000000..e54e0a7
--- /dev/null
@@ -0,0 +1,40 @@
+# E-Mobility Charging Stations Simulator - Project Overview
+
+## Purpose
+
+Simple Node.js software to simulate and scale a set of charging stations based on the OCPP-J protocol as part of SAP e-Mobility solution.
+
+## Tech Stack
+
+- **Runtime**: Node.js (>=20.11.0)
+- **Language**: TypeScript
+- **Package Manager**: pnpm (>=9.0.0)
+- **Testing Framework**: Node.js native test runner with @std/expect
+- **Build Tool**: esbuild
+- **Code Quality**: ESLint, Prettier, neostandard
+- **OCPP Versions**: 1.6 and 2.0.x support
+
+## Project Structure
+
+```
+src/
+├── charging-station/          # Core charging station implementation
+│   ├── ocpp/                 # OCPP protocol implementations
+│   │   ├── 1.6/             # OCPP 1.6 specific code
+│   │   └── 2.0/             # OCPP 2.0 specific code
+│   └── ui-server/           # UI server implementation
+├── types/                    # TypeScript type definitions
+├── utils/                   # Utility functions
+tests/
+├── charging-station/        # Charging station tests
+│   └── ocpp/               # OCPP-specific tests
+ui/web/                     # Web UI application
+```
+
+## Key Features
+
+- OCPP 1.6 and 2.0.x protocol support
+- Charging stations simulation and scaling
+- Web UI for monitoring and control
+- WebSocket and HTTP protocols
+- Docker deployment support
diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md
new file mode 100644 (file)
index 0000000..e68b69d
--- /dev/null
@@ -0,0 +1,52 @@
+# Development Commands and Workflow
+
+## Essential Development Commands
+
+### Package Management
+
+```bash
+pnpm install                 # Install dependencies
+pnpm clean:node_modules     # Clean node_modules
+```
+
+### Building
+
+```bash
+pnpm build                  # Production build
+pnpm build:dev              # Development build with source maps
+pnpm clean:dist             # Clean build output
+```
+
+### Running
+
+```bash
+pnpm start                  # Start production version
+pnpm start:dev              # Start development version
+pnpm start:dev:debug        # Start with debugging enabled
+```
+
+### Testing
+
+```bash
+pnpm build:dev              # Development build with source maps
+pnpm test                   # Run all tests
+pnpm test:debug             # Run tests with debugging
+pnpm coverage               # Generate coverage report
+pnpm coverage:html          # Generate HTML coverage report
+```
+
+### Code Quality
+
+```bash
+pnpm lint                   # Run linter
+pnpm lint:fix               # Fix linting issues
+pnpm format                 # Format code with Prettier and fix ESLint issues
+```
+
+### UI Development
+
+```bash
+cd ui/web
+pnpm dev                    # Start web UI development server
+pnpm build                  # Build web UI for production
+```
diff --git a/.serena/memories/task_completion_checklist.md b/.serena/memories/task_completion_checklist.md
new file mode 100644 (file)
index 0000000..2a902c2
--- /dev/null
@@ -0,0 +1,46 @@
+# Task Completion Checklist
+
+## After Completing Any Task
+
+### 1. Code Quality Checks
+
+- [ ] Run `pnpm lint` to check for linting issues
+- [ ] Run `pnpm format` to format code and fix auto-fixable issues
+- [ ] Ensure TypeScript compilation passes (part of build process)
+
+### 2. Testing
+
+- [ ] Run `pnpm test` to ensure all tests pass
+- [ ] If new functionality added, ensure appropriate tests are included
+- [ ] Check test coverage if relevant: `pnpm coverage`
+
+### 3. Build Verification
+
+- [ ] Run `pnpm build` to ensure production build succeeds
+- [ ] For development changes, verify `pnpm build:dev` works
+
+### 4. Documentation
+
+- [ ] Update relevant documentation if public API changed
+- [ ] Ensure commit messages follow Conventional Commits format
+- [ ] Update CHANGELOG.md if needed for user-facing changes
+
+### 5. OCPP Compliance (if applicable)
+
+- [ ] Verify OCPP standard compliance
+- [ ] Check that new OCPP commands/responses follow specification exactly
+- [ ] Validate against JSON schemas when `ocppStrictCompliance` is enabled
+
+## Git Workflow
+
+- Use Conventional Commits format for commit messages
+- Branch from `main` for new features
+- Ensure all quality gates pass before merging
+
+## Pre-commit Hooks
+
+The project uses husky for pre-commit hooks that automatically:
+
+- Run linting
+- Run formatting
+- Validate commit messages
diff --git a/.serena/project.yml b/.serena/project.yml
new file mode 100644 (file)
index 0000000..3854669
--- /dev/null
@@ -0,0 +1,71 @@
+# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
+#  * For C, use cpp
+#  * For JavaScript, use typescript
+# Special requirements:
+#  * csharp: Requires the presence of a .sln file in the project folder.
+language: typescript
+
+# the encoding used by text files in the project
+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+encoding: 'utf-8'
+
+# whether to use the project's gitignore file to ignore files
+# Added on 2025-04-07
+ignore_all_files_in_gitignore: true
+# list of additional paths to ignore
+# same syntax as gitignore, so you can use * and **
+# Was previously called `ignored_dirs`, please update your config if you are using that.
+# Added (renamed) on 2025-04-07
+ignored_paths: []
+
+# whether the project is in read-only mode
+# If set to true, all editing tools will be disabled and attempts to use them will result in an error
+# Added on 2025-04-18
+read_only: false
+
+# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
+# Below is the complete list of tools for convenience.
+# To make sure you have the latest list of tools, and to view their descriptions,
+# execute `uv run scripts/print_tool_overview.py`.
+#
+#  * `activate_project`: Activates a project by name.
+#  * `check_onboarding_performed`: Checks whether project onboarding was already performed.
+#  * `create_text_file`: Creates/overwrites a file in the project directory.
+#  * `delete_lines`: Deletes a range of lines within a file.
+#  * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
+#  * `execute_shell_command`: Executes a shell command.
+#  * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
+#  * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
+#  * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
+#  * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
+#  * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
+#  * `initial_instructions`: Gets the initial instructions for the current project.
+#     Should only be used in settings where the system prompt cannot be set,
+#     e.g. in clients you have no control over, like Claude Desktop.
+#  * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
+#  * `insert_at_line`: Inserts content at a given line in a file.
+#  * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
+#  * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
+#  * `list_memories`: Lists memories in Serena's project-specific memory store.
+#  * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
+#  * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
+#  * `read_file`: Reads a file within the project directory.
+#  * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
+#  * `remove_project`: Removes a project from the Serena configuration.
+#  * `replace_lines`: Replaces a range of lines within a file with new content.
+#  * `replace_symbol_body`: Replaces the full definition of a symbol.
+#  * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
+#  * `search_for_pattern`: Performs a search for a pattern in the project.
+#  * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
+#  * `switch_modes`: Activates modes by providing a list of their names
+#  * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
+#  * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
+#  * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
+#  * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
+excluded_tools: []
+
+# initial prompt for the project. It will always be given to the LLM upon activating the project
+# (contrary to the memories, which are loaded on demand).
+initial_prompt: 'You are working on a free and open source software project that simulates charging stations. Refer to the memories for more details and `.github/copilot-instructions.md` for development guidelines.'
+
+project_name: 'e-mobility-charging-stations-simulator'
index 431f354d0d36dbbf49c91063ba33feaf37abb8f8..56e9dd47c4070acdb950ea1bdb392fa43a74c842 100644 (file)
--- a/README.md
+++ b/README.md
@@ -498,6 +498,7 @@ make SUBMODULES_INIT=true
 
 - :white_check_mark: BootNotification
 - :white_check_mark: GetBaseReport (partial)
+- :white_check_mark: GetVariables
 - :white_check_mark: NotifyReport
 
 #### Authorization
index 721d8625ec7f04c29ccd0aa295f7bec5c44b27c4..5f8003ebeaeb4f74e81b488c6772af5a70fe0e06 100644 (file)
@@ -386,6 +386,16 @@ export class ChargingStation extends EventEmitter {
     return this.getConfigurationFromFile()?.automaticTransactionGeneratorStatuses
   }
 
+  public getConnectionTimeout (): number {
+    if (getConfigurationKey(this, StandardParametersKey.ConnectionTimeOut) != null) {
+      return convertToInt(
+        getConfigurationKey(this, StandardParametersKey.ConnectionTimeOut)?.value ??
+          Constants.DEFAULT_CONNECTION_TIMEOUT
+      )
+    }
+    return Constants.DEFAULT_CONNECTION_TIMEOUT
+  }
+
   public getConnectorIdByTransactionId (transactionId: number | undefined): number | undefined {
     if (transactionId == null) {
       return undefined
@@ -596,6 +606,12 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
+  public getWebSocketPingInterval (): number {
+    return getConfigurationKey(this, StandardParametersKey.WebSocketPingInterval) != null
+      ? convertToInt(getConfigurationKey(this, StandardParametersKey.WebSocketPingInterval)?.value)
+      : Constants.DEFAULT_WEBSOCKET_PING_INTERVAL
+  }
+
   public hasConnector (connectorId: number): boolean {
     if (this.hasEvses) {
       for (const evseStatus of this.evses.values()) {
@@ -1196,17 +1212,6 @@ export class ChargingStation extends EventEmitter {
     throw new BaseError(errorMsg)
   }
 
-  // 0 for disabling
-  private getConnectionTimeout (): number {
-    if (getConfigurationKey(this, StandardParametersKey.ConnectionTimeOut) != null) {
-      return convertToInt(
-        getConfigurationKey(this, StandardParametersKey.ConnectionTimeOut)?.value ??
-          Constants.DEFAULT_CONNECTION_TIMEOUT
-      )
-    }
-    return Constants.DEFAULT_CONNECTION_TIMEOUT
-  }
-
   private getCurrentOutType (stationInfo?: ChargingStationInfo): CurrentType {
     return (
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -1441,12 +1446,6 @@ export class ChargingStation extends EventEmitter {
     )
   }
 
-  private getWebSocketPingInterval (): number {
-    return getConfigurationKey(this, StandardParametersKey.WebSocketPingInterval) != null
-      ? convertToInt(getConfigurationKey(this, StandardParametersKey.WebSocketPingInterval)?.value)
-      : 0
-  }
-
   private handleErrorMessage (errorResponse: ErrorResponse): void {
     const [messageType, messageId, errorType, errorMessage, errorDetails] = errorResponse
     if (!this.requests.has(messageId)) {
index 572fc211651db890decf906cc44a021c8a821d8a..17abca51315bc2110b2bc0fc81a8ebdbf66d7624 100644 (file)
@@ -6,16 +6,22 @@ import type { ChargingStation } from '../../../charging-station/index.js'
 
 import { OCPPError } from '../../../exception/index.js'
 import {
+  AttributeEnumType,
   ConnectorEnumType,
   ConnectorStatusEnum,
+  DataEnumType,
   ErrorType,
   GenericDeviceModelStatusEnumType,
   type IncomingRequestHandler,
   type JsonType,
   type OCPP20ClearCacheRequest,
   OCPP20ComponentName,
+  OCPP20ConnectorStatusEnumType,
+  OCPP20DeviceInfoVariableName,
   type OCPP20GetBaseReportRequest,
   type OCPP20GetBaseReportResponse,
+  type OCPP20GetVariablesRequest,
+  type OCPP20GetVariablesResponse,
   OCPP20IncomingRequestCommand,
   type OCPP20NotifyReportRequest,
   type OCPP20NotifyReportResponse,
@@ -27,6 +33,7 @@ import {
 import { isAsyncFunction, logger } from '../../../utils/index.js'
 import { OCPPIncomingRequestService } from '../OCPPIncomingRequestService.js'
 import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js'
+import { OCPP20VariableManager } from './OCPP20VariableManager.js'
 
 const moduleName = 'OCPP20IncomingRequestService'
 
@@ -46,6 +53,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     this.incomingRequestHandlers = new Map<OCPP20IncomingRequestCommand, IncomingRequestHandler>([
       [OCPP20IncomingRequestCommand.CLEAR_CACHE, super.handleRequestClearCache.bind(this)],
       [OCPP20IncomingRequestCommand.GET_BASE_REPORT, this.handleRequestGetBaseReport.bind(this)],
+      [OCPP20IncomingRequestCommand.GET_VARIABLES, this.handleRequestGetVariables.bind(this)],
     ])
     this.payloadValidateFunctions = new Map<
       OCPP20IncomingRequestCommand,
@@ -71,6 +79,16 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           )
         ),
       ],
+      [
+        OCPP20IncomingRequestCommand.GET_VARIABLES,
+        this.ajv.compile(
+          OCPP20ServiceUtils.parseJsonSchemaFile<OCPP20GetVariablesRequest>(
+            'assets/json-schemas/ocpp/2.0/GetVariablesRequest.json',
+            moduleName,
+            'constructor'
+          )
+        ),
+      ],
     ])
     // Handle incoming request events
     this.on(
@@ -95,6 +113,28 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     this.validatePayload = this.validatePayload.bind(this)
   }
 
+  public handleRequestGetVariables (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20GetVariablesRequest
+  ): OCPP20GetVariablesResponse {
+    const getVariablesResponse: OCPP20GetVariablesResponse = {
+      getVariableResult: [],
+    }
+
+    // Use VariableManager to get variables
+    const variableManager = OCPP20VariableManager.getInstance()
+
+    // Get variables using VariableManager
+    const results = variableManager.getVariables(chargingStation, commandPayload.getVariableData)
+    getVariablesResponse.getVariableResult = results
+
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetVariables: Processed ${String(commandPayload.getVariableData.length)} variable requests, returning ${String(results.length)} results`
+    )
+
+    return getVariablesResponse
+  }
+
   // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
   public async incomingRequestHandler<ReqType extends JsonType, ResType extends JsonType>(
     chargingStation: ChargingStation,
@@ -214,12 +254,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
               },
               variableAttribute: [
                 {
-                  type: 'Actual',
+                  type: AttributeEnumType.Actual as string,
                   value: configKey.value,
                 },
               ],
               variableCharacteristics: {
-                dataType: 'string',
+                dataType: DataEnumType.string,
                 supportsMonitoring: false,
               },
             })
@@ -234,33 +274,44 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           if (stationInfo.chargePointModel) {
             reportData.push({
               component: { name: OCPP20ComponentName.ChargingStation },
-              variable: { name: 'Model' },
-              variableAttribute: [{ type: 'Actual', value: stationInfo.chargePointModel }],
-              variableCharacteristics: { dataType: 'string', supportsMonitoring: false },
+              variable: { name: OCPP20DeviceInfoVariableName.Model },
+              variableAttribute: [
+                { type: AttributeEnumType.Actual as string, value: stationInfo.chargePointModel },
+              ],
+              variableCharacteristics: { dataType: DataEnumType.string, supportsMonitoring: false },
             })
           }
           if (stationInfo.chargePointVendor) {
             reportData.push({
               component: { name: OCPP20ComponentName.ChargingStation },
-              variable: { name: 'VendorName' },
-              variableAttribute: [{ type: 'Actual', value: stationInfo.chargePointVendor }],
-              variableCharacteristics: { dataType: 'string', supportsMonitoring: false },
+              variable: { name: OCPP20DeviceInfoVariableName.VendorName },
+              variableAttribute: [
+                { type: AttributeEnumType.Actual as string, value: stationInfo.chargePointVendor },
+              ],
+              variableCharacteristics: { dataType: DataEnumType.string, supportsMonitoring: false },
             })
           }
           if (stationInfo.chargePointSerialNumber) {
             reportData.push({
               component: { name: OCPP20ComponentName.ChargingStation },
-              variable: { name: 'SerialNumber' },
-              variableAttribute: [{ type: 'Actual', value: stationInfo.chargePointSerialNumber }],
-              variableCharacteristics: { dataType: 'string', supportsMonitoring: false },
+              variable: { name: OCPP20DeviceInfoVariableName.SerialNumber },
+              variableAttribute: [
+                {
+                  type: AttributeEnumType.Actual as string,
+                  value: stationInfo.chargePointSerialNumber,
+                },
+              ],
+              variableCharacteristics: { dataType: DataEnumType.string, supportsMonitoring: false },
             })
           }
           if (stationInfo.firmwareVersion) {
             reportData.push({
               component: { name: OCPP20ComponentName.ChargingStation },
-              variable: { name: 'FirmwareVersion' },
-              variableAttribute: [{ type: 'Actual', value: stationInfo.firmwareVersion }],
-              variableCharacteristics: { dataType: 'string', supportsMonitoring: false },
+              variable: { name: OCPP20DeviceInfoVariableName.FirmwareVersion },
+              variableAttribute: [
+                { type: AttributeEnumType.Actual as string, value: stationInfo.firmwareVersion },
+              ],
+              variableCharacteristics: { dataType: DataEnumType.string, supportsMonitoring: false },
             })
           }
         }
@@ -269,9 +320,15 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         if (chargingStation.ocppConfiguration?.configurationKey) {
           for (const configKey of chargingStation.ocppConfiguration.configurationKey) {
             const variableAttributes = []
-            variableAttributes.push({ type: 'Actual', value: configKey.value })
+            variableAttributes.push({
+              type: AttributeEnumType.Actual as string,
+              value: configKey.value,
+            })
             if (!configKey.readonly) {
-              variableAttributes.push({ type: 'Target', value: undefined })
+              variableAttributes.push({
+                type: AttributeEnumType.Target as string,
+                value: undefined,
+              })
             }
 
             reportData.push({
@@ -279,7 +336,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
               variable: { name: configKey.key },
               variableAttribute: variableAttributes,
               variableCharacteristics: {
-                dataType: 'string',
+                dataType: DataEnumType.string,
                 supportsMonitoring: false,
               },
             })
@@ -294,9 +351,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
                 evse: { id: evseId },
                 name: OCPP20ComponentName.EVSE,
               },
-              variable: { name: 'AvailabilityState' },
-              variableAttribute: [{ type: 'Actual', value: evse.availability }],
-              variableCharacteristics: { dataType: 'string', supportsMonitoring: true },
+              variable: { name: OCPP20DeviceInfoVariableName.AvailabilityState },
+              variableAttribute: [
+                { type: AttributeEnumType.Actual as string, value: evse.availability },
+              ],
+              variableCharacteristics: { dataType: DataEnumType.string, supportsMonitoring: true },
             })
             if (evse.connectors.size > 0) {
               for (const [connectorId, connector] of evse.connectors) {
@@ -305,11 +364,17 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
                     evse: { connectorId, id: evseId },
                     name: OCPP20ComponentName.EVSE,
                   },
-                  variable: { name: 'ConnectorType' },
+                  variable: { name: OCPP20DeviceInfoVariableName.ConnectorType },
                   variableAttribute: [
-                    { type: 'Actual', value: connector.type ?? ConnectorEnumType.Unknown },
+                    {
+                      type: AttributeEnumType.Actual as string,
+                      value: connector.type ?? ConnectorEnumType.Unknown,
+                    },
                   ],
-                  variableCharacteristics: { dataType: 'string', supportsMonitoring: false },
+                  variableCharacteristics: {
+                    dataType: DataEnumType.string,
+                    supportsMonitoring: false,
+                  },
                 })
               }
             }
@@ -323,11 +388,17 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
                   evse: { connectorId, id: 1 },
                   name: OCPP20ComponentName.Connector,
                 },
-                variable: { name: 'ConnectorType' },
+                variable: { name: OCPP20DeviceInfoVariableName.ConnectorType },
                 variableAttribute: [
-                  { type: 'Actual', value: connector.type ?? ConnectorEnumType.Unknown },
+                  {
+                    type: AttributeEnumType.Actual as string,
+                    value: connector.type ?? ConnectorEnumType.Unknown,
+                  },
                 ],
-                variableCharacteristics: { dataType: 'string', supportsMonitoring: false },
+                variableCharacteristics: {
+                  dataType: DataEnumType.string,
+                  supportsMonitoring: false,
+                },
               })
             }
           }
@@ -340,39 +411,47 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           if (stationInfo.chargePointModel) {
             reportData.push({
               component: { name: OCPP20ComponentName.ChargingStation },
-              variable: { name: 'Model' },
-              variableAttribute: [{ type: 'Actual', value: stationInfo.chargePointModel }],
-              variableCharacteristics: { dataType: 'string', supportsMonitoring: false },
+              variable: { name: OCPP20DeviceInfoVariableName.Model },
+              variableAttribute: [
+                { type: AttributeEnumType.Actual as string, value: stationInfo.chargePointModel },
+              ],
+              variableCharacteristics: { dataType: DataEnumType.string, supportsMonitoring: false },
             })
           }
           if (stationInfo.chargePointVendor) {
             reportData.push({
               component: { name: OCPP20ComponentName.ChargingStation },
-              variable: { name: 'VendorName' },
-              variableAttribute: [{ type: 'Actual', value: stationInfo.chargePointVendor }],
-              variableCharacteristics: { dataType: 'string', supportsMonitoring: false },
+              variable: { name: OCPP20DeviceInfoVariableName.VendorName },
+              variableAttribute: [
+                { type: AttributeEnumType.Actual as string, value: stationInfo.chargePointVendor },
+              ],
+              variableCharacteristics: { dataType: DataEnumType.string, supportsMonitoring: false },
             })
           }
           if (stationInfo.firmwareVersion) {
             reportData.push({
               component: { name: OCPP20ComponentName.ChargingStation },
-              variable: { name: 'FirmwareVersion' },
-              variableAttribute: [{ type: 'Actual', value: stationInfo.firmwareVersion }],
-              variableCharacteristics: { dataType: 'string', supportsMonitoring: false },
+              variable: { name: OCPP20DeviceInfoVariableName.FirmwareVersion },
+              variableAttribute: [
+                { type: AttributeEnumType.Actual as string, value: stationInfo.firmwareVersion },
+              ],
+              variableCharacteristics: { dataType: DataEnumType.string, supportsMonitoring: false },
             })
           }
         }
 
         reportData.push({
           component: { name: OCPP20ComponentName.ChargingStation },
-          variable: { name: 'AvailabilityState' },
+          variable: { name: OCPP20DeviceInfoVariableName.AvailabilityState },
           variableAttribute: [
             {
-              type: 'Actual',
-              value: chargingStation.inAcceptedState() ? 'Available' : 'Unavailable',
+              type: AttributeEnumType.Actual as string,
+              value: chargingStation.inAcceptedState()
+                ? OCPP20ConnectorStatusEnumType.Available
+                : OCPP20ConnectorStatusEnumType.Unavailable,
             },
           ],
-          variableCharacteristics: { dataType: 'string', supportsMonitoring: true },
+          variableCharacteristics: { dataType: DataEnumType.string, supportsMonitoring: true },
         })
 
         if (chargingStation.evses.size > 0) {
@@ -382,9 +461,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
                 evse: { id: evseId },
                 name: OCPP20ComponentName.EVSE,
               },
-              variable: { name: 'AvailabilityState' },
-              variableAttribute: [{ type: 'Actual', value: evse.availability }],
-              variableCharacteristics: { dataType: 'string', supportsMonitoring: true },
+              variable: { name: OCPP20DeviceInfoVariableName.AvailabilityState },
+              variableAttribute: [
+                { type: AttributeEnumType.Actual as string, value: evse.availability },
+              ],
+              variableCharacteristics: { dataType: DataEnumType.string, supportsMonitoring: true },
             })
           }
         } else if (chargingStation.connectors.size > 0) {
@@ -396,11 +477,17 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
                   evse: { connectorId, id: 1 },
                   name: OCPP20ComponentName.Connector,
                 },
-                variable: { name: 'AvailabilityState' },
+                variable: { name: OCPP20DeviceInfoVariableName.AvailabilityState },
                 variableAttribute: [
-                  { type: 'Actual', value: connector.status ?? ConnectorStatusEnum.Unavailable },
+                  {
+                    type: AttributeEnumType.Actual as string,
+                    value: connector.status ?? ConnectorStatusEnum.Unavailable,
+                  },
                 ],
-                variableCharacteristics: { dataType: 'string', supportsMonitoring: true },
+                variableCharacteristics: {
+                  dataType: DataEnumType.string,
+                  supportsMonitoring: true,
+                },
               })
             }
           }
@@ -426,13 +513,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetBaseReport: GetBaseReport request received with requestId ${commandPayload.requestId} and reportBase ${commandPayload.reportBase}`
     )
 
-    const supportedReportBases = [
-      ReportBaseEnumType.ConfigurationInventory,
-      ReportBaseEnumType.FullInventory,
-      ReportBaseEnumType.SummaryInventory,
-    ]
-
-    if (!supportedReportBases.includes(commandPayload.reportBase)) {
+    if (!Object.values(ReportBaseEnumType).includes(commandPayload.reportBase)) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetBaseReport: Unsupported reportBase ${commandPayload.reportBase}`
       )
diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts b/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts
new file mode 100644 (file)
index 0000000..cdb386a
--- /dev/null
@@ -0,0 +1,371 @@
+// Partial Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+import { millisecondsToSeconds } from 'date-fns'
+
+import {
+  AttributeEnumType,
+  type ComponentType,
+  GenericDeviceModelStatusEnumType,
+  GetVariableStatusEnumType,
+  MutabilityEnumType,
+  OCPP20ComponentName,
+  type OCPP20GetVariableDataType,
+  type OCPP20GetVariableResultType,
+  OCPP20OptionalVariableName,
+  OCPP20RequiredVariableName,
+  type VariableType,
+} from '../../../types/index.js'
+import { Constants, logger } from '../../../utils/index.js'
+import { type ChargingStation } from '../../ChargingStation.js'
+
+/**
+ * Configuration for a standard OCPP 2.0 variable
+ */
+interface StandardVariableConfig {
+  attributeTypes: AttributeEnumType[]
+  defaultValue?: string
+  mutability: MutabilityEnumType
+  persistent: boolean
+}
+
+/**
+ * Centralized manager for OCPP 2.0 variables handling.
+ * Manages standard variables and provides unified access to variable data.
+ */
+export class OCPP20VariableManager {
+  private static instance: null | OCPP20VariableManager = null
+
+  private readonly standardVariables = new Map<string, StandardVariableConfig>()
+
+  private constructor () {
+    this.initializeStandardVariables()
+  }
+
+  public static getInstance (): OCPP20VariableManager {
+    OCPP20VariableManager.instance ??= new OCPP20VariableManager()
+    return OCPP20VariableManager.instance
+  }
+
+  /**
+   * Get variable data for a charging station
+   * @param chargingStation - The charging station instance
+   * @param getVariableData - Array of variable data to retrieve
+   * @returns Array of variable results
+   */
+  public getVariables (
+    chargingStation: ChargingStation,
+    getVariableData: OCPP20GetVariableDataType[]
+  ): OCPP20GetVariableResultType[] {
+    const results: OCPP20GetVariableResultType[] = []
+
+    for (const variableData of getVariableData) {
+      try {
+        const result = this.getVariable(chargingStation, variableData)
+        results.push(result)
+      } catch (error) {
+        logger.error(
+          `${chargingStation.logPrefix()} Error getting variable ${variableData.variable.name}:`,
+          error
+        )
+        results.push({
+          attributeStatus: GetVariableStatusEnumType.Rejected,
+          attributeType: variableData.attributeType,
+          component: variableData.component,
+          statusInfo: {
+            additionalInfo: 'Internal error occurred while retrieving variable',
+            reasonCode: GenericDeviceModelStatusEnumType.Rejected,
+          },
+          variable: variableData.variable,
+        })
+      }
+    }
+
+    return results
+  }
+
+  /**
+   * Get a single variable
+   * @param chargingStation - The charging station instance
+   * @param variableData - Variable data to retrieve
+   * @returns Variable result
+   */
+  private getVariable (
+    chargingStation: ChargingStation,
+    variableData: OCPP20GetVariableDataType
+  ): OCPP20GetVariableResultType {
+    const { attributeType, component, variable } = variableData
+
+    // Check if component is valid for this charging station
+    if (!this.isComponentValid(chargingStation, component)) {
+      return {
+        attributeStatus: GetVariableStatusEnumType.UnknownComponent,
+        attributeStatusInfo: {
+          additionalInfo: `Component ${component.name} is not supported by this charging station`,
+          reasonCode: GenericDeviceModelStatusEnumType.NotSupported,
+        },
+        attributeType,
+        component,
+        variable,
+      }
+    }
+
+    // Check if variable exists
+    if (!this.isVariableSupported(chargingStation, component, variable)) {
+      return {
+        attributeStatus: GetVariableStatusEnumType.UnknownVariable,
+        attributeStatusInfo: {
+          additionalInfo: `Variable ${variable.name} is not supported for component ${component.name}`,
+          reasonCode: GenericDeviceModelStatusEnumType.NotSupported,
+        },
+        attributeType,
+        component,
+        variable,
+      }
+    }
+
+    // Check if attribute type is supported
+    if (attributeType && !this.isAttributeTypeSupported(variable, attributeType)) {
+      return {
+        attributeStatus: GetVariableStatusEnumType.NotSupportedAttributeType,
+        attributeStatusInfo: {
+          additionalInfo: `Attribute type ${attributeType} is not supported for variable ${variable.name}`,
+          reasonCode: GenericDeviceModelStatusEnumType.NotSupported,
+        },
+        attributeType,
+        component,
+        variable,
+      }
+    }
+
+    // Get the variable value
+    const variableValue = this.getVariableValue(chargingStation, component, variable, attributeType)
+
+    return {
+      attributeStatus: GetVariableStatusEnumType.Accepted,
+      attributeType,
+      attributeValue: variableValue,
+      component,
+      variable,
+    }
+  }
+
+  /**
+   * Get the actual variable value from the charging station
+   * @param chargingStation - The charging station instance
+   * @param component - The component containing the variable
+   * @param variable - The variable to get the value for
+   * @param attributeType - The type of attribute (Actual, Target, etc.)
+   * @returns The variable value as string
+   */
+  private getVariableValue (
+    chargingStation: ChargingStation,
+    component: ComponentType,
+    variable: VariableType,
+    attributeType?: AttributeEnumType
+  ): string {
+    const variableName = variable.name
+    const componentName = component.name
+
+    // Handle standard ChargingStation variables
+    if (componentName === (OCPP20ComponentName.ChargingStation as string)) {
+      if (variableName === (OCPP20OptionalVariableName.HeartbeatInterval as string)) {
+        return millisecondsToSeconds(chargingStation.getHeartbeatInterval()).toString()
+      }
+
+      if (variableName === (OCPP20OptionalVariableName.WebSocketPingInterval as string)) {
+        return chargingStation.getWebSocketPingInterval().toString()
+      }
+
+      if (variableName === (OCPP20RequiredVariableName.EVConnectionTimeOut as string)) {
+        return Constants.DEFAULT_EV_CONNECTION_TIMEOUT.toString()
+      }
+
+      if (variableName === (OCPP20RequiredVariableName.MessageTimeout as string)) {
+        return chargingStation.getConnectionTimeout().toString()
+      }
+
+      // Try to get from OCPP configuration
+      const configKey = chargingStation.ocppConfiguration?.configurationKey?.find(
+        key => key.key === variableName
+      )
+      return configKey?.value ?? ''
+    }
+
+    // Handle Connector variables
+    if (componentName === (OCPP20ComponentName.Connector as string)) {
+      const connectorId = component.instance ? parseInt(component.instance, 10) : 1
+      const connector = chargingStation.connectors.get(connectorId)
+
+      if (connector) {
+        // Add connector-specific variable handling here
+        switch (variableName) {
+          // Add connector variables as needed
+          default:
+            return ''
+        }
+      }
+    }
+
+    // Handle EVSE variables
+    if (componentName === (OCPP20ComponentName.EVSE as string)) {
+      const evseId = component.instance ? parseInt(component.instance, 10) : 1
+      const evse = chargingStation.evses.get(evseId)
+
+      if (evse) {
+        // Add EVSE-specific variable handling here
+        switch (variableName) {
+          // Add EVSE variables as needed
+          default:
+            return ''
+        }
+      }
+    }
+
+    return ''
+  }
+
+  /**
+   * Initialize standard OCPP 2.0 variables configuration
+   */
+  private initializeStandardVariables (): void {
+    // ChargingStation component variables
+    this.standardVariables.set(
+      `${OCPP20ComponentName.ChargingStation}.${OCPP20OptionalVariableName.HeartbeatInterval}`,
+      {
+        attributeTypes: [AttributeEnumType.Actual, AttributeEnumType.Target],
+        defaultValue: millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL).toString(),
+        mutability: MutabilityEnumType.ReadWrite,
+        persistent: true,
+      }
+    )
+
+    this.standardVariables.set(
+      `${OCPP20ComponentName.ChargingStation}.${OCPP20OptionalVariableName.WebSocketPingInterval}`,
+      {
+        attributeTypes: [AttributeEnumType.Actual, AttributeEnumType.Target],
+        defaultValue: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL.toString(),
+        mutability: MutabilityEnumType.ReadWrite,
+        persistent: true,
+      }
+    )
+
+    this.standardVariables.set(
+      `${OCPP20ComponentName.ChargingStation}.${OCPP20RequiredVariableName.EVConnectionTimeOut}`,
+      {
+        attributeTypes: [AttributeEnumType.Actual, AttributeEnumType.Target],
+        defaultValue: Constants.DEFAULT_EV_CONNECTION_TIMEOUT.toString(),
+        mutability: MutabilityEnumType.ReadWrite,
+        persistent: true,
+      }
+    )
+
+    this.standardVariables.set(
+      `${OCPP20ComponentName.ChargingStation}.${OCPP20RequiredVariableName.MessageTimeout}`,
+      {
+        attributeTypes: [AttributeEnumType.Actual, AttributeEnumType.Target],
+        defaultValue: Constants.DEFAULT_CONNECTION_TIMEOUT.toString(),
+        mutability: MutabilityEnumType.ReadWrite,
+        persistent: true,
+      }
+    )
+
+    // Add more standard variables as needed
+  }
+
+  /**
+   * Check if attribute type is supported for the variable
+   * @param variable - The variable to check attribute support for
+   * @param attributeType - The attribute type to validate
+   * @returns True if the attribute type is supported by the variable
+   */
+  private isAttributeTypeSupported (
+    variable: VariableType,
+    attributeType: AttributeEnumType
+  ): boolean {
+    // Most variables support only Actual attribute by default
+    // Only certain variables support other attribute types like Target, MinSet, MaxSet
+    if (attributeType === AttributeEnumType.Actual) {
+      return true
+    }
+
+    // For other attribute types, check if variable supports them
+    // This is a simplified implementation - in production you'd have a configuration map
+    const variablesWithConfigurableAttributes: string[] = [
+      OCPP20OptionalVariableName.WebSocketPingInterval,
+      // Add other variables that support configuration
+    ]
+
+    return variablesWithConfigurableAttributes.includes(variable.name)
+  }
+
+  /**
+   * Check if a component is valid for the charging station
+   * @param chargingStation - The charging station instance to validate against
+   * @param component - The component to check validity for
+   * @returns True if the component is valid for the charging station
+   */
+  private isComponentValid (chargingStation: ChargingStation, component: ComponentType): boolean {
+    const componentName = component.name
+
+    // Always support ChargingStation component
+    if (componentName === (OCPP20ComponentName.ChargingStation as string)) {
+      return true
+    }
+
+    // Support Connector components if station has connectors
+    if (
+      componentName === (OCPP20ComponentName.Connector as string) &&
+      chargingStation.connectors.size > 0
+    ) {
+      // Check if specific connector instance exists
+      if (component.instance != null) {
+        const connectorId = parseInt(component.instance, 10)
+        return chargingStation.connectors.has(connectorId)
+      }
+      return true
+    }
+
+    // Support EVSE components if station has EVSEs
+    if (componentName === (OCPP20ComponentName.EVSE as string) && chargingStation.hasEvses) {
+      // Check if specific EVSE instance exists
+      if (component.instance != null) {
+        const evseId = parseInt(component.instance, 10)
+        return chargingStation.evses.has(evseId)
+      }
+      return true
+    }
+
+    // Other components can be added here as needed
+    return false
+  }
+
+  /**
+   * Check if a variable is supported by the component
+   * @param chargingStation - The charging station instance
+   * @param component - The component to check
+   * @param variable - The variable to validate
+   * @returns True if the variable is supported by the component
+   */
+  private isVariableSupported (
+    chargingStation: ChargingStation,
+    component: ComponentType,
+    variable: VariableType
+  ): boolean {
+    const variableKey = `${component.name}.${variable.name}`
+
+    // Check standard variables
+    if (this.standardVariables.has(variableKey)) {
+      return true
+    }
+
+    // Check known optional and required variables
+    const knownVariables = [
+      ...Object.values(OCPP20OptionalVariableName),
+      ...Object.values(OCPP20RequiredVariableName),
+    ]
+
+    return knownVariables.includes(
+      variable.name as OCPP20OptionalVariableName | OCPP20RequiredVariableName
+    )
+  }
+}
index 6357eaaa83ebec846d1ba53dcd7260a2839a6625..2cd780c5827b76700af0add3ad4d388898e24665 100644 (file)
@@ -4,6 +4,7 @@ export { OCPP16ResponseService } from './1.6/OCPP16ResponseService.js'
 export { OCPP20IncomingRequestService } from './2.0/OCPP20IncomingRequestService.js'
 export { OCPP20RequestService } from './2.0/OCPP20RequestService.js'
 export { OCPP20ResponseService } from './2.0/OCPP20ResponseService.js'
+export { OCPP20VariableManager } from './2.0/OCPP20VariableManager.js'
 export { OCPPIncomingRequestService } from './OCPPIncomingRequestService.js'
 export { OCPPRequestService } from './OCPPRequestService.js'
 export {
index fb737e72db5626befde8b0012d555120aef38702..738301edd5acaebe0ac264336cd500a31bdd097a 100644 (file)
@@ -143,7 +143,9 @@ export {
 } from './ocpp/1.6/Transaction.js'
 export {
   BootReasonEnumType,
+  type ComponentType,
   type CustomDataType,
+  DataEnumType,
   GenericDeviceModelStatusEnumType,
   OCPP20ComponentName,
   OCPP20ConnectorStatusEnumType,
@@ -154,6 +156,7 @@ export {
   type OCPP20BootNotificationRequest,
   type OCPP20ClearCacheRequest,
   type OCPP20GetBaseReportRequest,
+  type OCPP20GetVariablesRequest,
   type OCPP20HeartbeatRequest,
   OCPP20IncomingRequestCommand,
   type OCPP20NotifyReportRequest,
@@ -164,11 +167,22 @@ export type {
   OCPP20BootNotificationResponse,
   OCPP20ClearCacheResponse,
   OCPP20GetBaseReportResponse,
+  OCPP20GetVariablesResponse,
   OCPP20HeartbeatResponse,
   OCPP20NotifyReportResponse,
   OCPP20StatusNotificationResponse,
 } from './ocpp/2.0/Responses.js'
-export { OCPP20OptionalVariableName } from './ocpp/2.0/Variables.js'
+export {
+  AttributeEnumType,
+  GetVariableStatusEnumType,
+  MutabilityEnumType,
+  OCPP20DeviceInfoVariableName,
+  type OCPP20GetVariableDataType,
+  type OCPP20GetVariableResultType,
+  OCPP20OptionalVariableName,
+  OCPP20RequiredVariableName,
+  type VariableType,
+} from './ocpp/2.0/Variables.js'
 export { ChargePointErrorCode } from './ocpp/ChargePointErrorCode.js'
 export {
   type ChargingProfile,
index e66beac70e9a2e90806adbd03178e6ff646f7cfc..ac2acd396d34baae4ee13a86af5c853e62c478f9 100644 (file)
@@ -281,6 +281,6 @@ interface VariableAttributeType extends JsonObject {
 }
 
 interface VariableCharacteristicsType extends JsonObject {
-  dataType: string
+  dataType: DataEnumType
   supportsMonitoring: boolean
 }
index f3c42d4d2e964ce029a406bb57d0279aeef28edb..1b734cb35b03f79796c4e7ef0729f97bfeaccc77 100644 (file)
@@ -8,11 +8,12 @@ import type {
   ReportBaseEnumType,
   ReportDataType,
 } from './Common.js'
-import type { OCPP20SetVariableDataType } from './Variables.js'
+import type { OCPP20GetVariableDataType, OCPP20SetVariableDataType } from './Variables.js'
 
 export enum OCPP20IncomingRequestCommand {
   CLEAR_CACHE = 'ClearCache',
   GET_BASE_REPORT = 'GetBaseReport',
+  GET_VARIABLES = 'GetVariables',
   REQUEST_START_TRANSACTION = 'RequestStartTransaction',
   REQUEST_STOP_TRANSACTION = 'RequestStopTransaction',
 }
@@ -36,6 +37,10 @@ export interface OCPP20GetBaseReportRequest extends JsonObject {
   requestId: number
 }
 
+export interface OCPP20GetVariablesRequest extends JsonObject {
+  getVariableData: OCPP20GetVariableDataType[]
+}
+
 export type OCPP20HeartbeatRequest = EmptyObject
 
 export interface OCPP20InstallCertificateRequest extends JsonObject {
index 7f49836754e2f6b0c5e2d192a4f1952dec817854..d833d5732a275d23e31842645c873ddf72b52910 100644 (file)
@@ -7,7 +7,7 @@ import type {
   InstallCertificateStatusEnumType,
   StatusInfoType,
 } from './Common.js'
-import type { OCPP20SetVariableResultType } from './Variables.js'
+import type { OCPP20GetVariableResultType, OCPP20SetVariableResultType } from './Variables.js'
 
 export interface OCPP20BootNotificationResponse extends JsonObject {
   currentTime: Date
@@ -26,6 +26,10 @@ export interface OCPP20GetBaseReportResponse extends JsonObject {
   statusInfo?: StatusInfoType
 }
 
+export interface OCPP20GetVariablesResponse extends JsonObject {
+  getVariableResult: OCPP20GetVariableResultType[]
+}
+
 export interface OCPP20HeartbeatResponse extends JsonObject {
   currentTime: Date
 }
index 9168975caea582f832ba0de4c01fdff310f3b91e..db9cc82c50774cf41855319a8b890ff95075da59 100644 (file)
@@ -1,6 +1,36 @@
 import type { JsonObject } from '../../JsonType.js'
 import type { ComponentType, StatusInfoType } from './Common.js'
 
+export enum AttributeEnumType {
+  Actual = 'Actual',
+  MaxSet = 'MaxSet',
+  MinSet = 'MinSet',
+  Target = 'Target',
+}
+
+export enum GetVariableStatusEnumType {
+  Accepted = 'Accepted',
+  NotSupportedAttributeType = 'NotSupportedAttributeType',
+  Rejected = 'Rejected',
+  UnknownComponent = 'UnknownComponent',
+  UnknownVariable = 'UnknownVariable',
+}
+
+export enum MutabilityEnumType {
+  ReadOnly = 'ReadOnly',
+  ReadWrite = 'ReadWrite',
+  WriteOnly = 'WriteOnly',
+}
+
+export enum OCPP20DeviceInfoVariableName {
+  AvailabilityState = 'AvailabilityState',
+  ConnectorType = 'ConnectorType',
+  FirmwareVersion = 'FirmwareVersion',
+  Model = 'Model',
+  SerialNumber = 'SerialNumber',
+  VendorName = 'VendorName',
+}
+
 export enum OCPP20OptionalVariableName {
   HeartbeatInterval = 'HeartbeatInterval',
   WebSocketPingInterval = 'WebSocketPingInterval',
@@ -41,13 +71,6 @@ export enum OCPP20VendorVariableName {
   ConnectionUrl = 'ConnectionUrl',
 }
 
-enum AttributeEnumType {
-  Actual = 'Actual',
-  MaxSet = 'MaxSet',
-  MinSet = 'MinSet',
-  Target = 'Target',
-}
-
 enum SetVariableStatusEnumType {
   Accepted = 'Accepted',
   NotSupportedAttributeType = 'NotSupportedAttributeType',
@@ -62,6 +85,21 @@ export interface OCPP20ComponentVariableType extends JsonObject {
   variable?: VariableType
 }
 
+export interface OCPP20GetVariableDataType extends JsonObject {
+  attributeType?: AttributeEnumType
+  component: ComponentType
+  variable: VariableType
+}
+
+export interface OCPP20GetVariableResultType extends JsonObject {
+  attributeStatus: GetVariableStatusEnumType
+  attributeStatusInfo?: StatusInfoType
+  attributeType?: AttributeEnumType
+  attributeValue?: string
+  component: ComponentType
+  variable: VariableType
+}
+
 export interface OCPP20SetVariableDataType extends JsonObject {
   attributeType?: AttributeEnumType
   attributeValue: string
@@ -83,6 +121,7 @@ export interface VariableType extends JsonObject {
 }
 
 type VariableName =
+  | OCPP20DeviceInfoVariableName
   | OCPP20OptionalVariableName
   | OCPP20RequiredVariableName
   | OCPP20VendorVariableName
index 9732d386d7629d8cb722d8c6f7464e69f5d916b3..4bcb0ed76de96693062ead38cd6ae5fe52a8f323 100644 (file)
@@ -28,6 +28,7 @@ export class Constants {
 
   static readonly DEFAULT_CIRCULAR_BUFFER_CAPACITY = 386
   static readonly DEFAULT_CONNECTION_TIMEOUT = 30 // Seconds
+  static readonly DEFAULT_EV_CONNECTION_TIMEOUT = 180 // Seconds
 
   static readonly DEFAULT_FLUCTUATION_PERCENT = 5
   static readonly DEFAULT_HASH_ALGORITHM = 'sha384'
@@ -43,9 +44,9 @@ export class Constants {
   static readonly DEFAULT_METER_VALUES_INTERVAL = 60000 // Ms
 
   static readonly DEFAULT_PERFORMANCE_DIRECTORY = 'performance'
-
   static readonly DEFAULT_PERFORMANCE_RECORDS_DB_NAME = 'e-mobility-charging-stations-simulator'
   static readonly DEFAULT_PERFORMANCE_RECORDS_FILENAME = 'performanceRecords.json'
+
   static readonly DEFAULT_STATION_INFO: Readonly<Partial<ChargingStationInfo>> = Object.freeze({
     automaticTransactionGeneratorPersistentConfiguration: true,
     autoReconnectMaxRetries: -1,
@@ -83,8 +84,10 @@ export class Constants {
   })
 
   static readonly DEFAULT_UI_SERVER_HOST = 'localhost'
-
   static readonly DEFAULT_UI_SERVER_PORT = 8080
+
+  static readonly DEFAULT_WEBSOCKET_PING_INTERVAL = 30 // Seconds
+
   static readonly EMPTY_FROZEN_OBJECT = Object.freeze({})
 
   static readonly EMPTY_FUNCTION: () => void = Object.freeze(() => {
diff --git a/tests/ChargingStationFactory.ts b/tests/ChargingStationFactory.ts
new file mode 100644 (file)
index 0000000..3264f5d
--- /dev/null
@@ -0,0 +1,124 @@
+import type { ChargingStation } from '../src/charging-station/index.js'
+import type {
+  ChargingStationConfiguration,
+  ChargingStationInfo,
+  ChargingStationTemplate,
+} from '../src/types/index.js'
+
+import {
+  OCPP20ConnectorStatusEnumType,
+  OCPP20OptionalVariableName,
+  OCPPVersion,
+} from '../src/types/index.js'
+import { Constants } from '../src/utils/index.js'
+
+/**
+ * Options to customize the construction of a ChargingStation test instance
+ */
+export interface ChargingStationOptions {
+  baseName?: string
+  connectionTimeout?: number
+  hasEvses?: boolean
+  heartbeatInterval?: number
+  ocppConfiguration?: ChargingStationConfiguration
+  started?: boolean
+  starting?: boolean
+  stationInfo?: Partial<ChargingStationInfo>
+  websocketPingInterval?: number
+}
+
+const CHARGING_STATION_BASE_NAME = 'CS-TEST'
+
+/**
+ * Creates a ChargingStation instance for tests
+ * @param options - Options to customize the ChargingStation instance
+ * @returns A mock ChargingStation instance
+ */
+export function createChargingStation (options: ChargingStationOptions = {}): ChargingStation {
+  const baseName = options.baseName ?? CHARGING_STATION_BASE_NAME
+  const templateIndex = 1
+  const connectionTimeout = options.connectionTimeout ?? Constants.DEFAULT_CONNECTION_TIMEOUT
+  const heartbeatInterval = options.heartbeatInterval ?? Constants.DEFAULT_HEARTBEAT_INTERVAL
+  const websocketPingInterval =
+    options.websocketPingInterval ?? Constants.DEFAULT_WEBSOCKET_PING_INTERVAL
+
+  return {
+    connectors: new Map(),
+    evses: new Map(),
+    getConnectionTimeout: () => connectionTimeout,
+    getHeartbeatInterval: () => heartbeatInterval,
+    getWebSocketPingInterval: () => websocketPingInterval,
+    hasEvses: options.hasEvses ?? false,
+    inAcceptedState: () => true,
+    logPrefix: () => `${baseName} |`,
+    ocppConfiguration: options.ocppConfiguration ?? {
+      configurationKey: [
+        {
+          key: OCPP20OptionalVariableName.WebSocketPingInterval,
+          value: websocketPingInterval.toString(),
+        },
+        { key: OCPP20OptionalVariableName.HeartbeatInterval, value: heartbeatInterval.toString() },
+      ],
+    },
+    started: options.started ?? false,
+    starting: options.starting,
+    stationInfo: {
+      baseName,
+      chargingStationId: `${baseName}-00001`,
+      hashId: 'test-hash-id',
+      maximumAmperage: 16,
+      maximumPower: 12000,
+      templateIndex,
+      templateName: 'test-template.json',
+      ...options.stationInfo,
+    },
+    wsConnection: {
+      pingInterval: websocketPingInterval,
+    },
+  } as unknown as ChargingStation
+}
+
+/**
+ * Creates a ChargingStation template for tests
+ * @param baseName - Base name for the template
+ * @returns A ChargingStationTemplate instance
+ */
+export function createChargingStationTemplate (
+  baseName = CHARGING_STATION_BASE_NAME
+): ChargingStationTemplate {
+  return {
+    baseName,
+  } as ChargingStationTemplate
+}
+
+/**
+ * Creates a ChargingStation instance with connectors and EVSEs configured for OCPP 2.0
+ * @param options - Options to customize the ChargingStation instance
+ * @returns A mock ChargingStation instance with EVSEs
+ */
+export function createChargingStationWithEvses (
+  options: ChargingStationOptions = {}
+): ChargingStation {
+  const chargingStation = createChargingStation({
+    hasEvses: true,
+    stationInfo: {
+      ocppVersion: OCPPVersion.VERSION_201,
+      ...options.stationInfo,
+    },
+    ...options,
+  })
+
+  // Add default connectors and EVSEs
+  Object.assign(chargingStation, {
+    connectors: new Map([
+      [1, { status: OCPP20ConnectorStatusEnumType.Available }],
+      [2, { status: OCPP20ConnectorStatusEnumType.Available }],
+    ]),
+    evses: new Map([
+      [1, { connectors: new Map([[1, {}]]) }],
+      [2, { connectors: new Map([[1, {}]]) }],
+    ]),
+  })
+
+  return chargingStation
+}
index 71a1a00953f8f5b23511549c7f7333d6e50cf80c..4300d5a6d02db32e1f004634cf8517be383faef0 100644 (file)
@@ -3,8 +3,6 @@
 import { expect } from '@std/expect'
 import { describe, it } from 'node:test'
 
-import type { ChargingStation } from '../../src/charging-station/index.js'
-
 import {
   checkChargingStationState,
   checkConfiguration,
@@ -23,22 +21,15 @@ import {
   type ChargingStationTemplate,
   type ConnectorStatus,
   ConnectorStatusEnum,
-  type EvseStatus,
   OCPPVersion,
 } from '../../src/types/index.js'
 import { logger } from '../../src/utils/Logger.js'
+import { createChargingStation, createChargingStationTemplate } from '../ChargingStationFactory.js'
 
 await describe('Helpers test suite', async () => {
   const baseName = 'CS-TEST'
-  const chargingStationTemplate = {
-    baseName,
-  } as ChargingStationTemplate
-  const chargingStation = {
-    connectors: new Map<number, ConnectorStatus>(),
-    evses: new Map<number, EvseStatus>(),
-    logPrefix: () => `${baseName} |`,
-    started: false,
-  } as ChargingStation
+  const chargingStationTemplate = createChargingStationTemplate(baseName)
+  const chargingStation = createChargingStation({ baseName })
 
   await it('Verify getChargingStationId()', () => {
     expect(getChargingStationId(1, chargingStationTemplate)).toBe(`${baseName}-00001`)
@@ -51,6 +42,8 @@ await describe('Helpers test suite', async () => {
   })
 
   await it('Verify validateStationInfo()', () => {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    delete (chargingStation as any).stationInfo
     expect(() => {
       validateStationInfo(chargingStation)
     }).toThrow(new BaseError('Missing charging station information'))
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts
new file mode 100644 (file)
index 0000000..b77d335
--- /dev/null
@@ -0,0 +1,54 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { expect } from '@std/expect'
+import { describe, it } from 'node:test'
+
+import { IdTagsCache } from '../../../../src/charging-station/IdTagsCache.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { createChargingStationWithEvses } from '../../../ChargingStationFactory.js'
+import { TEST_CHARGING_STATION_NAME } from './OCPP20TestConstants.js'
+
+await describe('OCPP20IncomingRequestService ClearCache integration tests', async () => {
+  const mockChargingStation = createChargingStationWithEvses({
+    baseName: TEST_CHARGING_STATION_NAME,
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    stationInfo: {
+      ocppStrictCompliance: false,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+
+  // Initialize idTagsCache to avoid undefined errors
+  mockChargingStation.idTagsCache = IdTagsCache.getInstance()
+
+  const incomingRequestService = new OCPP20IncomingRequestService()
+
+  await it('Should handle ClearCache request successfully', async () => {
+    const response = await (incomingRequestService as any).handleRequestClearCache(
+      mockChargingStation
+    )
+
+    expect(response).toBeDefined()
+    expect(typeof response).toBe('object')
+    expect(response.status).toBeDefined()
+    expect(typeof response.status).toBe('string')
+    expect(['Accepted', 'Rejected']).toContain(response.status)
+  })
+
+  await it('Should return correct status based on cache clearing result', async () => {
+    // Test the actual behavior - ClearCache should work with ID tags cache
+
+    const response = await (incomingRequestService as any).handleRequestClearCache(
+      mockChargingStation
+    )
+
+    expect(response).toBeDefined()
+    expect(response.status).toBeDefined()
+    // Should be either Accepted or Rejected based on cache state
+    expect(['Accepted', 'Rejected']).toContain(response.status)
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts
new file mode 100644 (file)
index 0000000..136450d
--- /dev/null
@@ -0,0 +1,250 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { expect } from '@std/expect'
+import { describe, it } from 'node:test'
+
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import {
+  GenericDeviceModelStatusEnumType,
+  OCPP20ComponentName,
+  OCPP20DeviceInfoVariableName,
+  type OCPP20GetBaseReportRequest,
+  ReportBaseEnumType,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { createChargingStationWithEvses } from '../../../ChargingStationFactory.js'
+import {
+  TEST_CHARGE_POINT_MODEL,
+  TEST_CHARGE_POINT_SERIAL_NUMBER,
+  TEST_CHARGE_POINT_VENDOR,
+  TEST_CHARGING_STATION_NAME,
+  TEST_FIRMWARE_VERSION,
+} from './OCPP20TestConstants.js'
+
+await describe('OCPP20IncomingRequestService GetBaseReport integration tests', async () => {
+  const mockChargingStation = createChargingStationWithEvses({
+    baseName: TEST_CHARGING_STATION_NAME,
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    stationInfo: {
+      chargePointModel: TEST_CHARGE_POINT_MODEL,
+      chargePointSerialNumber: TEST_CHARGE_POINT_SERIAL_NUMBER,
+      chargePointVendor: TEST_CHARGE_POINT_VENDOR,
+      firmwareVersion: TEST_FIRMWARE_VERSION,
+      ocppStrictCompliance: false,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+
+  const incomingRequestService = new OCPP20IncomingRequestService()
+
+  await it('Should handle GetBaseReport request with ConfigurationInventory', () => {
+    const request: OCPP20GetBaseReportRequest = {
+      reportBase: ReportBaseEnumType.ConfigurationInventory,
+      requestId: 1,
+    }
+
+    const response = (incomingRequestService as any).handleRequestGetBaseReport(
+      mockChargingStation,
+      request
+    )
+
+    expect(response).toBeDefined()
+    expect(response.status).toBe(GenericDeviceModelStatusEnumType.Accepted)
+  })
+
+  await it('Should handle GetBaseReport request with FullInventory', () => {
+    const request: OCPP20GetBaseReportRequest = {
+      reportBase: ReportBaseEnumType.FullInventory,
+      requestId: 2,
+    }
+
+    const response = (incomingRequestService as any).handleRequestGetBaseReport(
+      mockChargingStation,
+      request
+    )
+
+    expect(response).toBeDefined()
+    expect(response.status).toBe(GenericDeviceModelStatusEnumType.Accepted)
+  })
+
+  await it('Should handle GetBaseReport request with SummaryInventory', () => {
+    const request: OCPP20GetBaseReportRequest = {
+      reportBase: ReportBaseEnumType.SummaryInventory,
+      requestId: 3,
+    }
+
+    const response = (incomingRequestService as any).handleRequestGetBaseReport(
+      mockChargingStation,
+      request
+    )
+
+    expect(response).toBeDefined()
+    expect(response.status).toBe(GenericDeviceModelStatusEnumType.Accepted)
+  })
+
+  await it('Should return NotSupported for unsupported reportBase', () => {
+    const request: OCPP20GetBaseReportRequest = {
+      reportBase: 'UnsupportedReportBase' as any,
+      requestId: 4,
+    }
+
+    const response = (incomingRequestService as any).handleRequestGetBaseReport(
+      mockChargingStation,
+      request
+    )
+
+    expect(response).toBeDefined()
+    expect(response.status).toBe(GenericDeviceModelStatusEnumType.NotSupported)
+  })
+
+  await it('Should return EmptyResultSet when no data is available', () => {
+    // Create a charging station with minimal configuration
+    const minimalChargingStation = createChargingStationWithEvses({
+      baseName: 'CS-MINIMAL',
+      ocppConfiguration: {
+        configurationKey: [],
+      },
+      stationInfo: {
+        ocppStrictCompliance: false,
+      },
+    })
+
+    const request: OCPP20GetBaseReportRequest = {
+      reportBase: ReportBaseEnumType.ConfigurationInventory,
+      requestId: 5,
+    }
+
+    const response = (incomingRequestService as any).handleRequestGetBaseReport(
+      minimalChargingStation,
+      request
+    )
+
+    expect(response).toBeDefined()
+    expect(response.status).toBe(GenericDeviceModelStatusEnumType.EmptyResultSet)
+  })
+
+  await it('Should build correct report data for ConfigurationInventory', () => {
+    const request: OCPP20GetBaseReportRequest = {
+      reportBase: ReportBaseEnumType.ConfigurationInventory,
+      requestId: 6,
+    }
+
+    // Test the buildReportData method indirectly by calling handleRequestGetBaseReport
+    // and checking if it returns Accepted status (which means data was built successfully)
+    const response = (incomingRequestService as any).handleRequestGetBaseReport(
+      mockChargingStation,
+      request
+    )
+
+    expect(response).toBeDefined()
+    expect(response.status).toBe(GenericDeviceModelStatusEnumType.Accepted)
+
+    // We can also test the buildReportData method directly if needed
+    const reportData = (incomingRequestService as any).buildReportData(
+      mockChargingStation,
+      ReportBaseEnumType.ConfigurationInventory
+    )
+
+    expect(Array.isArray(reportData)).toBe(true)
+    expect(reportData.length).toBeGreaterThan(0)
+
+    // Check that each report data item has the expected structure
+    for (const item of reportData) {
+      expect(item.component).toBeDefined()
+      expect(item.component.name).toBeDefined()
+      expect(item.variable).toBeDefined()
+      expect(item.variable.name).toBeDefined()
+      expect(item.variableAttribute).toBeDefined()
+      expect(Array.isArray(item.variableAttribute)).toBe(true)
+      expect(item.variableCharacteristics).toBeDefined()
+    }
+  })
+
+  await it('Should build correct report data for FullInventory with station info', () => {
+    const reportData = (incomingRequestService as any).buildReportData(
+      mockChargingStation,
+      ReportBaseEnumType.FullInventory
+    )
+
+    expect(Array.isArray(reportData)).toBe(true)
+    expect(reportData.length).toBeGreaterThan(0)
+
+    // Check for station info variables
+    const modelVariable = reportData.find(
+      (item: any) =>
+        item.variable.name === OCPP20DeviceInfoVariableName.Model &&
+        item.component.name === OCPP20ComponentName.ChargingStation
+    )
+    expect(modelVariable).toBeDefined()
+    expect(modelVariable.variableAttribute[0].value).toBe(TEST_CHARGE_POINT_MODEL)
+
+    const vendorVariable = reportData.find(
+      (item: any) =>
+        item.variable.name === OCPP20DeviceInfoVariableName.VendorName &&
+        item.component.name === OCPP20ComponentName.ChargingStation
+    )
+    expect(vendorVariable).toBeDefined()
+    expect(vendorVariable.variableAttribute[0].value).toBe(TEST_CHARGE_POINT_VENDOR)
+  })
+
+  await it('Should build correct report data for SummaryInventory', () => {
+    const reportData = (incomingRequestService as any).buildReportData(
+      mockChargingStation,
+      ReportBaseEnumType.SummaryInventory
+    )
+
+    expect(Array.isArray(reportData)).toBe(true)
+    expect(reportData.length).toBeGreaterThan(0)
+
+    // Check for availability state variable
+    const availabilityVariable = reportData.find(
+      (item: any) =>
+        item.variable.name === OCPP20DeviceInfoVariableName.AvailabilityState &&
+        item.component.name === OCPP20ComponentName.ChargingStation
+    )
+    expect(availabilityVariable).toBeDefined()
+    expect(availabilityVariable.variableCharacteristics.supportsMonitoring).toBe(true)
+  })
+
+  await it('Should handle GetBaseReport with EVSE structure', () => {
+    // The createChargingStationWithEvses should create a station with EVSEs
+    const stationWithEvses = createChargingStationWithEvses({
+      baseName: 'CS-EVSE-001',
+      hasEvses: true,
+      stationInfo: {
+        chargePointModel: 'EVSE Test Model',
+        chargePointVendor: 'EVSE Test Vendor',
+        ocppStrictCompliance: false,
+      },
+    })
+
+    const reportData = (incomingRequestService as any).buildReportData(
+      stationWithEvses,
+      ReportBaseEnumType.FullInventory
+    )
+
+    expect(Array.isArray(reportData)).toBe(true)
+    expect(reportData.length).toBeGreaterThan(0)
+
+    // Check if EVSE components are included when EVSEs exist
+    const evseComponents = reportData.filter(
+      (item: any) => item.component.name === OCPP20ComponentName.EVSE
+    )
+    if (stationWithEvses.evses.size > 0) {
+      expect(evseComponents.length).toBeGreaterThan(0)
+    }
+  })
+
+  await it('Should validate unsupported reportBase correctly', () => {
+    const reportData = (incomingRequestService as any).buildReportData(
+      mockChargingStation,
+      'InvalidReportBase' as any
+    )
+
+    expect(Array.isArray(reportData)).toBe(true)
+    expect(reportData.length).toBe(0)
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts
new file mode 100644 (file)
index 0000000..52c089d
--- /dev/null
@@ -0,0 +1,204 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+
+import { expect } from '@std/expect'
+import { millisecondsToSeconds } from 'date-fns'
+import { describe, it } from 'node:test'
+
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import {
+  AttributeEnumType,
+  GetVariableStatusEnumType,
+  OCPP20ComponentName,
+  type OCPP20GetVariablesRequest,
+  OCPP20OptionalVariableName,
+  OCPP20RequiredVariableName,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { createChargingStationWithEvses } from '../../../ChargingStationFactory.js'
+import {
+  TEST_CHARGING_STATION_NAME,
+  TEST_CONNECTOR_INVALID_INSTANCE,
+  TEST_CONNECTOR_VALID_INSTANCE,
+} from './OCPP20TestConstants.js'
+
+await describe('OCPP20IncomingRequestService GetVariables integration tests', async () => {
+  const mockChargingStation = createChargingStationWithEvses({
+    baseName: TEST_CHARGING_STATION_NAME,
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    stationInfo: {
+      ocppStrictCompliance: false,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+
+  const incomingRequestService = new OCPP20IncomingRequestService()
+
+  await it('Should handle GetVariables request with valid variables', async () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          attributeType: AttributeEnumType.Actual,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ],
+    }
+
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+    const response = await (incomingRequestService as any).handleRequestGetVariables(
+      mockChargingStation,
+      request
+    )
+
+    expect(response).toBeDefined()
+    expect(response.getVariableResult).toBeDefined()
+    expect(Array.isArray(response.getVariableResult)).toBe(true)
+    expect(response.getVariableResult).toHaveLength(2)
+
+    // Check first variable (HeartbeatInterval)
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+    const firstResult = response.getVariableResult[0]
+    expect(firstResult.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(firstResult.attributeType).toBe(AttributeEnumType.Actual)
+    expect(firstResult.attributeValue).toBe(
+      millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL).toString()
+    )
+    expect(firstResult.component.name).toBe(OCPP20ComponentName.ChargingStation)
+    expect(firstResult.variable.name).toBe(OCPP20OptionalVariableName.HeartbeatInterval)
+    expect(firstResult.attributeStatusInfo).toBeUndefined()
+
+    // Check second variable (WebSocketPingInterval)
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+    const secondResult = response.getVariableResult[1]
+    expect(secondResult.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(secondResult.attributeType).toBeUndefined()
+    expect(secondResult.attributeValue).toBe(Constants.DEFAULT_WEBSOCKET_PING_INTERVAL.toString())
+    expect(secondResult.component.name).toBe(OCPP20ComponentName.ChargingStation)
+    expect(secondResult.variable.name).toBe(OCPP20OptionalVariableName.WebSocketPingInterval)
+    expect(secondResult.attributeStatusInfo).toBeUndefined()
+  })
+
+  await it('Should handle GetVariables request with invalid variables', async () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
+          variable: { name: 'InvalidVariable' as any },
+        },
+        {
+          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
+          component: { name: 'InvalidComponent' as any },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ],
+    }
+
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+    const response = await (incomingRequestService as any).handleRequestGetVariables(
+      mockChargingStation,
+      request
+    )
+
+    expect(response).toBeDefined()
+    expect(response.getVariableResult).toBeDefined()
+    expect(Array.isArray(response.getVariableResult)).toBe(true)
+    expect(response.getVariableResult).toHaveLength(2)
+
+    // Check first variable (should be UnknownVariable)
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+    const firstResult = response.getVariableResult[0]
+    expect(firstResult.attributeStatus).toBe(GetVariableStatusEnumType.UnknownVariable)
+    expect(firstResult.attributeType).toBeUndefined()
+    expect(firstResult.attributeValue).toBeUndefined()
+    expect(firstResult.component.name).toBe(OCPP20ComponentName.ChargingStation)
+    expect(firstResult.variable.name).toBe('InvalidVariable')
+    expect(firstResult.attributeStatusInfo).toBeDefined()
+
+    // Check second variable (should be UnknownComponent)
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+    const secondResult = response.getVariableResult[1]
+    expect(secondResult.attributeStatus).toBe(GetVariableStatusEnumType.UnknownComponent)
+    expect(secondResult.attributeType).toBeUndefined()
+    expect(secondResult.attributeValue).toBeUndefined()
+    expect(secondResult.component.name).toBe('InvalidComponent')
+    expect(secondResult.variable.name).toBe(OCPP20OptionalVariableName.HeartbeatInterval)
+    expect(secondResult.attributeStatusInfo).toBeDefined()
+  })
+
+  await it('Should handle GetVariables request with unsupported attribute types', async () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          attributeType: AttributeEnumType.Target, // Not supported for HeartbeatInterval
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ],
+    }
+
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+    const response = await (incomingRequestService as any).handleRequestGetVariables(
+      mockChargingStation,
+      request
+    )
+
+    expect(response).toBeDefined()
+    expect(response.getVariableResult).toBeDefined()
+    expect(Array.isArray(response.getVariableResult)).toBe(true)
+    expect(response.getVariableResult).toHaveLength(1)
+
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+  })
+
+  await it('Should handle GetVariables request with Connector components', async () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: {
+            instance: TEST_CONNECTOR_VALID_INSTANCE,
+            name: OCPP20ComponentName.Connector,
+          },
+          variable: { name: OCPP20RequiredVariableName.AuthorizeRemoteStart },
+        },
+        {
+          component: {
+            instance: TEST_CONNECTOR_INVALID_INSTANCE, // Non-existent connector
+            name: OCPP20ComponentName.Connector,
+          },
+          variable: { name: OCPP20RequiredVariableName.AuthorizeRemoteStart },
+        },
+      ],
+    }
+
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+    const response = await (incomingRequestService as any).handleRequestGetVariables(
+      mockChargingStation,
+      request
+    )
+
+    expect(response).toBeDefined()
+    expect(response.getVariableResult).toBeDefined()
+    expect(Array.isArray(response.getVariableResult)).toBe(true)
+    expect(response.getVariableResult).toHaveLength(2)
+
+    // Check valid connector
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+    const firstResult = response.getVariableResult[0]
+    expect(firstResult.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(firstResult.component.name).toBe(OCPP20ComponentName.Connector)
+    expect(firstResult.component.instance).toBe(TEST_CONNECTOR_VALID_INSTANCE)
+
+    // Check invalid connector
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+    const secondResult = response.getVariableResult[1]
+    expect(secondResult.attributeStatus).toBe(GetVariableStatusEnumType.UnknownComponent)
+    expect(secondResult.component.instance).toBe(TEST_CONNECTOR_INVALID_INSTANCE)
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-BootNotification.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-BootNotification.test.ts
new file mode 100644 (file)
index 0000000..cf21f20
--- /dev/null
@@ -0,0 +1,210 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { expect } from '@std/expect'
+import { describe, it } from 'node:test'
+
+import { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestService.js'
+import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
+import {
+  BootReasonEnumType,
+  type OCPP20BootNotificationRequest,
+  OCPP20RequestCommand,
+} from '../../../../src/types/index.js'
+import { type ChargingStationType } from '../../../../src/types/ocpp/2.0/Common.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { createChargingStation } from '../../../ChargingStationFactory.js'
+import {
+  TEST_CHARGE_POINT_MODEL,
+  TEST_CHARGE_POINT_SERIAL_NUMBER,
+  TEST_CHARGE_POINT_VENDOR,
+  TEST_CHARGING_STATION_NAME,
+  TEST_FIRMWARE_VERSION,
+} from './OCPP20TestConstants.js'
+
+await describe('OCPP20RequestService BootNotification integration tests', async () => {
+  const mockResponseService = new OCPP20ResponseService()
+  const requestService = new OCPP20RequestService(mockResponseService)
+
+  const mockChargingStation = createChargingStation({
+    baseName: TEST_CHARGING_STATION_NAME,
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    stationInfo: {
+      chargePointModel: TEST_CHARGE_POINT_MODEL,
+      chargePointSerialNumber: TEST_CHARGE_POINT_SERIAL_NUMBER,
+      chargePointVendor: TEST_CHARGE_POINT_VENDOR,
+      firmwareVersion: TEST_FIRMWARE_VERSION,
+      ocppStrictCompliance: false,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+
+  await it('Should build BootNotification request payload correctly with PowerUp reason', () => {
+    const chargingStationInfo: ChargingStationType = {
+      firmwareVersion: TEST_FIRMWARE_VERSION,
+      model: TEST_CHARGE_POINT_MODEL,
+      serialNumber: TEST_CHARGE_POINT_SERIAL_NUMBER,
+      vendorName: TEST_CHARGE_POINT_VENDOR,
+    }
+
+    const requestParams: OCPP20BootNotificationRequest = {
+      chargingStation: chargingStationInfo,
+      reason: BootReasonEnumType.PowerUp,
+    }
+
+    // Access the private buildRequestPayload method via type assertion
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.BOOT_NOTIFICATION,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.chargingStation).toBeDefined()
+    expect(payload.chargingStation.model).toBe(TEST_CHARGE_POINT_MODEL)
+    expect(payload.chargingStation.vendorName).toBe(TEST_CHARGE_POINT_VENDOR)
+    expect(payload.chargingStation.firmwareVersion).toBe(TEST_FIRMWARE_VERSION)
+    expect(payload.chargingStation.serialNumber).toBe(TEST_CHARGE_POINT_SERIAL_NUMBER)
+    expect(payload.reason).toBe(BootReasonEnumType.PowerUp)
+  })
+
+  await it('Should build BootNotification request payload correctly with ApplicationReset reason', () => {
+    const chargingStationInfo: ChargingStationType = {
+      firmwareVersion: '2.1.3',
+      model: 'Advanced Model X1',
+      serialNumber: 'ADV-SN-002',
+      vendorName: 'Advanced Vendor',
+    }
+
+    const requestParams: OCPP20BootNotificationRequest = {
+      chargingStation: chargingStationInfo,
+      reason: BootReasonEnumType.ApplicationReset,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.BOOT_NOTIFICATION,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.chargingStation).toBeDefined()
+    expect(payload.chargingStation.model).toBe('Advanced Model X1')
+    expect(payload.chargingStation.vendorName).toBe('Advanced Vendor')
+    expect(payload.chargingStation.firmwareVersion).toBe('2.1.3')
+    expect(payload.chargingStation.serialNumber).toBe('ADV-SN-002')
+    expect(payload.reason).toBe(BootReasonEnumType.ApplicationReset)
+  })
+
+  await it('Should build BootNotification request payload correctly with minimal required fields', () => {
+    const chargingStationInfo: ChargingStationType = {
+      model: 'Basic Model',
+      vendorName: 'Basic Vendor',
+      // Optional fields omitted: firmwareVersion, serialNumber, customData, modem
+    }
+
+    const requestParams: OCPP20BootNotificationRequest = {
+      chargingStation: chargingStationInfo,
+      reason: BootReasonEnumType.FirmwareUpdate,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.BOOT_NOTIFICATION,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.chargingStation).toBeDefined()
+    expect(payload.chargingStation.model).toBe('Basic Model')
+    expect(payload.chargingStation.vendorName).toBe('Basic Vendor')
+    expect(payload.chargingStation.firmwareVersion).toBeUndefined()
+    expect(payload.chargingStation.serialNumber).toBeUndefined()
+    expect(payload.reason).toBe(BootReasonEnumType.FirmwareUpdate)
+  })
+
+  await it('Should handle all BootReasonEnumType values correctly', () => {
+    const chargingStationInfo: ChargingStationType = {
+      model: TEST_CHARGE_POINT_MODEL,
+      vendorName: TEST_CHARGE_POINT_VENDOR,
+    }
+
+    const testReasons = [
+      BootReasonEnumType.ApplicationReset,
+      BootReasonEnumType.FirmwareUpdate,
+      BootReasonEnumType.LocalReset,
+      BootReasonEnumType.PowerUp,
+      BootReasonEnumType.RemoteReset,
+      BootReasonEnumType.ScheduledReset,
+      BootReasonEnumType.Triggered,
+      BootReasonEnumType.Unknown,
+      BootReasonEnumType.Watchdog,
+    ]
+
+    testReasons.forEach(reason => {
+      const requestParams: OCPP20BootNotificationRequest = {
+        chargingStation: chargingStationInfo,
+        reason,
+      }
+
+      const payload = (requestService as any).buildRequestPayload(
+        mockChargingStation,
+        OCPP20RequestCommand.BOOT_NOTIFICATION,
+        requestParams
+      )
+
+      expect(payload).toBeDefined()
+      expect(payload.reason).toBe(reason)
+      expect(payload.chargingStation).toBeDefined()
+    })
+  })
+
+  await it('Should validate payload structure matches OCPP20BootNotificationRequest interface', () => {
+    const chargingStationInfo: ChargingStationType = {
+      customData: {
+        vendorId: 'TEST_VENDOR',
+      },
+      firmwareVersion: '3.0.0',
+      model: 'Validation Test Model',
+      serialNumber: 'VAL-001',
+      vendorName: 'Validation Vendor',
+    }
+
+    const requestParams: OCPP20BootNotificationRequest = {
+      chargingStation: chargingStationInfo,
+      reason: BootReasonEnumType.PowerUp,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.BOOT_NOTIFICATION,
+      requestParams
+    )
+
+    // Validate that the payload has the exact structure of OCPP20BootNotificationRequest
+    expect(typeof payload).toBe('object')
+    expect(payload).toHaveProperty('chargingStation')
+    expect(payload).toHaveProperty('reason')
+    expect(Object.keys(payload as object)).toHaveLength(2)
+
+    // Validate chargingStation structure
+    expect(typeof payload.chargingStation).toBe('object')
+    expect(payload.chargingStation).toHaveProperty('model')
+    expect(payload.chargingStation).toHaveProperty('vendorName')
+    expect(typeof payload.chargingStation.model).toBe('string')
+    expect(typeof payload.chargingStation.vendorName).toBe('string')
+
+    // Validate optional fields
+    if (payload.chargingStation.firmwareVersion !== undefined) {
+      expect(typeof payload.chargingStation.firmwareVersion).toBe('string')
+    }
+    if (payload.chargingStation.serialNumber !== undefined) {
+      expect(typeof payload.chargingStation.serialNumber).toBe('string')
+    }
+    if (payload.chargingStation.customData !== undefined) {
+      expect(typeof payload.chargingStation.customData).toBe('object')
+    }
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-HeartBeat.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-HeartBeat.test.ts
new file mode 100644 (file)
index 0000000..64fec03
--- /dev/null
@@ -0,0 +1,159 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { expect } from '@std/expect'
+import { describe, it } from 'node:test'
+
+import { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestService.js'
+import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
+import { type OCPP20HeartbeatRequest, OCPP20RequestCommand } from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { createChargingStation } from '../../../ChargingStationFactory.js'
+import {
+  TEST_CHARGE_POINT_MODEL,
+  TEST_CHARGE_POINT_SERIAL_NUMBER,
+  TEST_CHARGE_POINT_VENDOR,
+  TEST_CHARGING_STATION_NAME,
+  TEST_FIRMWARE_VERSION,
+} from './OCPP20TestConstants.js'
+
+await describe('OCPP20RequestService HeartBeat integration tests', async () => {
+  const mockResponseService = new OCPP20ResponseService()
+  const requestService = new OCPP20RequestService(mockResponseService)
+
+  const mockChargingStation = createChargingStation({
+    baseName: TEST_CHARGING_STATION_NAME,
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    stationInfo: {
+      chargePointModel: TEST_CHARGE_POINT_MODEL,
+      chargePointSerialNumber: TEST_CHARGE_POINT_SERIAL_NUMBER,
+      chargePointVendor: TEST_CHARGE_POINT_VENDOR,
+      firmwareVersion: TEST_FIRMWARE_VERSION,
+      ocppStrictCompliance: false,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+
+  await it('Should build HeartBeat request payload correctly with empty object', () => {
+    const requestParams: OCPP20HeartbeatRequest = {}
+
+    // Access the private buildRequestPayload method via type assertion
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.HEARTBEAT,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(typeof payload).toBe('object')
+    expect(Object.keys(payload as object)).toHaveLength(0)
+  })
+
+  await it('Should build HeartBeat request payload correctly without parameters', () => {
+    // Test without passing any request parameters
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.HEARTBEAT
+    )
+
+    expect(payload).toBeDefined()
+    expect(typeof payload).toBe('object')
+    expect(Object.keys(payload as object)).toHaveLength(0)
+  })
+
+  await it('Should validate payload structure matches OCPP20HeartbeatRequest interface', () => {
+    const requestParams: OCPP20HeartbeatRequest = {}
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.HEARTBEAT,
+      requestParams
+    )
+
+    // Validate that the payload is an empty object as required by OCPP 2.0 spec
+    expect(typeof payload).toBe('object')
+    expect(payload).not.toBeNull()
+    expect(Array.isArray(payload)).toBe(false)
+    expect(Object.keys(payload as object)).toHaveLength(0)
+    expect(JSON.stringify(payload)).toBe('{}')
+  })
+
+  await it('Should handle HeartBeat request consistently across multiple calls', () => {
+    const requestParams: OCPP20HeartbeatRequest = {}
+
+    // Call buildRequestPayload multiple times to ensure consistency
+    const payload1 = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.HEARTBEAT,
+      requestParams
+    )
+
+    const payload2 = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.HEARTBEAT,
+      requestParams
+    )
+
+    const payload3 = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.HEARTBEAT
+    )
+
+    // All payloads should be identical empty objects
+    expect(payload1).toEqual(payload2)
+    expect(payload2).toEqual(payload3)
+    expect(JSON.stringify(payload1)).toBe('{}')
+    expect(JSON.stringify(payload2)).toBe('{}')
+    expect(JSON.stringify(payload3)).toBe('{}')
+  })
+
+  await it('Should handle HeartBeat request with different charging station configurations', () => {
+    const alternativeChargingStation = createChargingStation({
+      baseName: 'CS-ALTERNATIVE-002',
+      heartbeatInterval: 120,
+      stationInfo: {
+        chargePointModel: 'Alternative Model',
+        chargePointSerialNumber: 'ALT-SN-002',
+        chargePointVendor: 'Alternative Vendor',
+        firmwareVersion: '2.5.1',
+        ocppStrictCompliance: true,
+      },
+      websocketPingInterval: 45,
+    })
+
+    const requestParams: OCPP20HeartbeatRequest = {}
+
+    const payload = (requestService as any).buildRequestPayload(
+      alternativeChargingStation,
+      OCPP20RequestCommand.HEARTBEAT,
+      requestParams
+    )
+
+    // HeartBeat payload should remain empty regardless of charging station configuration
+    expect(payload).toBeDefined()
+    expect(typeof payload).toBe('object')
+    expect(Object.keys(payload as object)).toHaveLength(0)
+    expect(JSON.stringify(payload)).toBe('{}')
+  })
+
+  await it('Should verify HeartBeat request conforms to OCPP 2.0 specification', () => {
+    const requestParams: OCPP20HeartbeatRequest = {}
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.HEARTBEAT,
+      requestParams
+    )
+
+    // According to OCPP 2.0 specification, HeartBeat request should be an empty object
+    // This validates compliance with the official OCPP 2.0 standard
+    expect(payload).toBeDefined()
+    expect(payload).toEqual({})
+    expect(Object.prototype.hasOwnProperty.call(payload, 'constructor')).toBe(false)
+
+    // Ensure it's a plain object and not an instance of another type
+    expect(Object.getPrototypeOf(payload)).toBe(Object.prototype)
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts
new file mode 100644 (file)
index 0000000..03d8684
--- /dev/null
@@ -0,0 +1,543 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { expect } from '@std/expect'
+import { describe, it } from 'node:test'
+
+import { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestService.js'
+import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
+import {
+  AttributeEnumType,
+  DataEnumType,
+  OCPP20ComponentName,
+  OCPP20DeviceInfoVariableName,
+  type OCPP20NotifyReportRequest,
+  OCPP20OptionalVariableName,
+  OCPP20RequestCommand,
+  type ReportDataType,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { createChargingStation } from '../../../ChargingStationFactory.js'
+import {
+  TEST_CHARGE_POINT_MODEL,
+  TEST_CHARGE_POINT_SERIAL_NUMBER,
+  TEST_CHARGE_POINT_VENDOR,
+  TEST_CHARGING_STATION_NAME,
+  TEST_FIRMWARE_VERSION,
+} from './OCPP20TestConstants.js'
+
+await describe('OCPP20RequestService NotifyReport integration tests', async () => {
+  const mockResponseService = new OCPP20ResponseService()
+  const requestService = new OCPP20RequestService(mockResponseService)
+
+  const mockChargingStation = createChargingStation({
+    baseName: TEST_CHARGING_STATION_NAME,
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    stationInfo: {
+      chargePointModel: TEST_CHARGE_POINT_MODEL,
+      chargePointSerialNumber: TEST_CHARGE_POINT_SERIAL_NUMBER,
+      chargePointVendor: TEST_CHARGE_POINT_VENDOR,
+      firmwareVersion: TEST_FIRMWARE_VERSION,
+      ocppStrictCompliance: false,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+
+  await it('Should build NotifyReport request payload correctly with minimal required fields', () => {
+    const requestParams: OCPP20NotifyReportRequest = {
+      generatedAt: new Date('2023-10-22T10:30:00.000Z'),
+      requestId: 123,
+      seqNo: 0,
+    }
+
+    // Access the private buildRequestPayload method via type assertion
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.NOTIFY_REPORT,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.generatedAt).toBeInstanceOf(Date)
+    expect(payload.requestId).toBe(123)
+    expect(payload.seqNo).toBe(0)
+    expect(payload.tbc).toBeUndefined()
+    expect(payload.reportData).toBeUndefined()
+  })
+
+  await it('Should build NotifyReport request payload correctly with reportData', () => {
+    const reportData: ReportDataType[] = [
+      {
+        component: {
+          name: OCPP20ComponentName.ChargingStation,
+        },
+        variable: {
+          name: OCPP20DeviceInfoVariableName.Model,
+        },
+        variableAttribute: [
+          {
+            type: AttributeEnumType.Actual,
+            value: 'Test Model X1',
+          },
+        ],
+        variableCharacteristics: {
+          dataType: DataEnumType.string,
+          supportsMonitoring: false,
+        },
+      },
+    ]
+
+    const requestParams: OCPP20NotifyReportRequest = {
+      generatedAt: new Date('2023-10-22T14:15:30.000Z'),
+      reportData,
+      requestId: 456,
+      seqNo: 1,
+      tbc: false,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.NOTIFY_REPORT,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.generatedAt).toBeInstanceOf(Date)
+    expect(payload.requestId).toBe(456)
+    expect(payload.seqNo).toBe(1)
+    expect(payload.tbc).toBe(false)
+    expect(payload.reportData).toEqual(reportData)
+    expect(Array.isArray(payload.reportData)).toBe(true)
+    expect(payload.reportData).toHaveLength(1)
+  })
+
+  await it('Should build NotifyReport request payload correctly with multiple reportData items', () => {
+    const reportData: ReportDataType[] = [
+      {
+        component: {
+          name: OCPP20ComponentName.ChargingStation,
+        },
+        variable: {
+          name: OCPP20DeviceInfoVariableName.Model,
+        },
+        variableAttribute: [
+          {
+            type: AttributeEnumType.Actual,
+            value: 'Advanced Model',
+          },
+        ],
+        variableCharacteristics: {
+          dataType: DataEnumType.string,
+          supportsMonitoring: false,
+        },
+      },
+      {
+        component: {
+          name: OCPP20ComponentName.ChargingStation,
+        },
+        variable: {
+          name: OCPP20DeviceInfoVariableName.VendorName,
+        },
+        variableAttribute: [
+          {
+            type: AttributeEnumType.Actual,
+            value: 'Advanced Vendor',
+          },
+        ],
+        variableCharacteristics: {
+          dataType: DataEnumType.string,
+          supportsMonitoring: false,
+        },
+      },
+      {
+        component: {
+          name: OCPP20ComponentName.OCPPCommCtrlr,
+        },
+        variable: {
+          name: OCPP20OptionalVariableName.HeartbeatInterval,
+        },
+        variableAttribute: [
+          {
+            type: AttributeEnumType.Actual,
+            value: '60',
+          },
+          {
+            type: AttributeEnumType.Target,
+            value: '60',
+          },
+        ],
+        variableCharacteristics: {
+          dataType: DataEnumType.integer,
+          supportsMonitoring: true,
+        },
+      },
+    ]
+
+    const requestParams: OCPP20NotifyReportRequest = {
+      generatedAt: new Date('2023-10-22T16:45:00.000Z'),
+      reportData,
+      requestId: 789,
+      seqNo: 2,
+      tbc: true,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.NOTIFY_REPORT,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.generatedAt).toBeInstanceOf(Date)
+    expect(payload.requestId).toBe(789)
+    expect(payload.seqNo).toBe(2)
+    expect(payload.tbc).toBe(true)
+    expect(payload.reportData).toEqual(reportData)
+    expect(Array.isArray(payload.reportData)).toBe(true)
+    expect(payload.reportData).toHaveLength(3)
+  })
+
+  await it('Should build NotifyReport request payload correctly with fragmented report (tbc=true)', () => {
+    const reportData: ReportDataType[] = [
+      {
+        component: {
+          name: OCPP20ComponentName.ChargingStation,
+        },
+        variable: {
+          name: OCPP20DeviceInfoVariableName.SerialNumber,
+        },
+        variableAttribute: [
+          {
+            type: AttributeEnumType.Actual,
+            value: 'SN-FRAGMENT-001',
+          },
+        ],
+        variableCharacteristics: {
+          dataType: DataEnumType.string,
+          supportsMonitoring: false,
+        },
+      },
+    ]
+
+    const requestParams: OCPP20NotifyReportRequest = {
+      generatedAt: new Date('2023-10-22T18:20:15.000Z'),
+      reportData,
+      requestId: 999,
+      seqNo: 0,
+      tbc: true, // Indicates more fragments to follow
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.NOTIFY_REPORT,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.generatedAt).toBeInstanceOf(Date)
+    expect(payload.requestId).toBe(999)
+    expect(payload.seqNo).toBe(0)
+    expect(payload.tbc).toBe(true)
+    expect(payload.reportData).toEqual(reportData)
+    expect(Array.isArray(payload.reportData)).toBe(true)
+    expect(payload.reportData).toHaveLength(1)
+  })
+
+  await it('Should build NotifyReport request payload correctly with empty reportData array', () => {
+    const requestParams: OCPP20NotifyReportRequest = {
+      generatedAt: new Date('2023-10-22T09:00:00.000Z'),
+      reportData: [], // Empty array
+      requestId: 100,
+      seqNo: 0,
+      tbc: false,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.NOTIFY_REPORT,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.generatedAt).toBeInstanceOf(Date)
+    expect(payload.requestId).toBe(100)
+    expect(payload.seqNo).toBe(0)
+    expect(payload.tbc).toBe(false)
+    expect(payload.reportData).toEqual([])
+    expect(Array.isArray(payload.reportData)).toBe(true)
+    expect(payload.reportData).toHaveLength(0)
+  })
+
+  await it('Should handle different AttributeEnumType values correctly', () => {
+    const testAttributes = [
+      AttributeEnumType.Actual,
+      AttributeEnumType.Target,
+      AttributeEnumType.MinSet,
+      AttributeEnumType.MaxSet,
+    ]
+
+    testAttributes.forEach((attributeType, index) => {
+      const reportData: ReportDataType[] = [
+        {
+          component: {
+            name: 'TestComponent',
+          },
+          variable: {
+            name: 'TestVariable',
+          },
+          variableAttribute: [
+            {
+              type: attributeType,
+              value: `Test Value ${index.toString()}`,
+            },
+          ],
+          variableCharacteristics: {
+            dataType: DataEnumType.string,
+            supportsMonitoring: true,
+          },
+        },
+      ]
+
+      const requestParams: OCPP20NotifyReportRequest = {
+        generatedAt: new Date(),
+        reportData,
+        requestId: 200 + index,
+        seqNo: index,
+        tbc: false,
+      }
+
+      const payload = (requestService as any).buildRequestPayload(
+        mockChargingStation,
+        OCPP20RequestCommand.NOTIFY_REPORT,
+        requestParams
+      )
+
+      expect(payload).toBeDefined()
+      expect(payload.reportData[0].variableAttribute[0].type).toBe(attributeType)
+      expect(payload.reportData[0].variableAttribute[0].value).toBe(
+        `Test Value ${index.toString()}`
+      )
+    })
+  })
+
+  await it('Should handle different DataEnumType values correctly', () => {
+    const testDataTypes = [
+      { dataType: DataEnumType.string, value: 'test string' },
+      { dataType: DataEnumType.integer, value: '42' },
+      { dataType: DataEnumType.decimal, value: '3.14' },
+      { dataType: DataEnumType.boolean, value: 'true' },
+      { dataType: DataEnumType.dateTime, value: '2023-10-22T12:00:00Z' },
+    ]
+
+    testDataTypes.forEach((testCase, index) => {
+      const reportData: ReportDataType[] = [
+        {
+          component: {
+            name: 'DataTypeTestComponent',
+          },
+          variable: {
+            name: `${testCase.dataType}Variable`,
+          },
+          variableAttribute: [
+            {
+              type: AttributeEnumType.Actual,
+              value: testCase.value,
+            },
+          ],
+          variableCharacteristics: {
+            dataType: testCase.dataType,
+            supportsMonitoring: false,
+          },
+        },
+      ]
+
+      const requestParams: OCPP20NotifyReportRequest = {
+        generatedAt: new Date(),
+        reportData,
+        requestId: 300 + index,
+        seqNo: index,
+        tbc: false,
+      }
+
+      const payload = (requestService as any).buildRequestPayload(
+        mockChargingStation,
+        OCPP20RequestCommand.NOTIFY_REPORT,
+        requestParams
+      )
+
+      expect(payload).toBeDefined()
+      expect(payload.reportData[0].variableCharacteristics.dataType).toBe(testCase.dataType)
+      expect(payload.reportData[0].variableAttribute[0].value).toBe(testCase.value)
+    })
+  })
+
+  await it('Should validate payload structure matches OCPP20NotifyReportRequest interface', () => {
+    const reportData: ReportDataType[] = [
+      {
+        component: {
+          name: 'ValidationTest',
+        },
+        variable: {
+          name: 'ValidationVariable',
+        },
+        variableAttribute: [
+          {
+            type: AttributeEnumType.Actual,
+            value: 'validation value',
+          },
+        ],
+        variableCharacteristics: {
+          dataType: DataEnumType.string,
+          supportsMonitoring: true,
+        },
+      },
+    ]
+
+    const requestParams: OCPP20NotifyReportRequest = {
+      generatedAt: new Date('2023-10-22T13:30:45.000Z'),
+      reportData,
+      requestId: 1001,
+      seqNo: 5,
+      tbc: false,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.NOTIFY_REPORT,
+      requestParams
+    )
+
+    // Validate that the payload has the exact structure of OCPP20NotifyReportRequest
+    expect(typeof payload).toBe('object')
+    expect(payload).toHaveProperty('generatedAt')
+    expect(payload).toHaveProperty('requestId')
+    expect(payload).toHaveProperty('seqNo')
+    expect(payload).toHaveProperty('reportData')
+    expect(payload).toHaveProperty('tbc')
+
+    // Validate required fields
+    expect(payload.generatedAt).toBeInstanceOf(Date)
+    expect(typeof payload.requestId).toBe('number')
+    expect(typeof payload.seqNo).toBe('number')
+
+    // Validate optional fields
+    if (payload.reportData !== undefined) {
+      expect(Array.isArray(payload.reportData)).toBe(true)
+      if (payload.reportData.length > 0) {
+        expect(typeof payload.reportData[0]).toBe('object')
+        expect(payload.reportData[0]).toHaveProperty('component')
+        expect(payload.reportData[0]).toHaveProperty('variable')
+        expect(payload.reportData[0]).toHaveProperty('variableAttribute')
+      }
+    }
+
+    if (payload.tbc !== undefined) {
+      expect(typeof payload.tbc).toBe('boolean')
+    }
+  })
+
+  await it('Should handle complex reportData with multiple variable attributes', () => {
+    const reportData: ReportDataType[] = [
+      {
+        component: {
+          name: 'ComplexComponent',
+        },
+        variable: {
+          name: 'ComplexVariable',
+        },
+        variableAttribute: [
+          {
+            type: AttributeEnumType.Actual,
+            value: 'actual value',
+          },
+          {
+            type: AttributeEnumType.Target,
+            value: 'target value',
+          },
+          {
+            type: AttributeEnumType.MinSet,
+            value: '0',
+          },
+          {
+            type: AttributeEnumType.MaxSet,
+            value: '100',
+          },
+        ],
+        variableCharacteristics: {
+          dataType: DataEnumType.integer,
+          supportsMonitoring: true,
+        },
+      },
+    ]
+
+    const requestParams: OCPP20NotifyReportRequest = {
+      generatedAt: new Date('2023-10-22T20:15:30.000Z'),
+      reportData,
+      requestId: 2001,
+      seqNo: 10,
+      tbc: false,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.NOTIFY_REPORT,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.reportData[0].variableAttribute).toHaveLength(4)
+    expect(payload.reportData[0].variableAttribute[0].type).toBe(AttributeEnumType.Actual)
+    expect(payload.reportData[0].variableAttribute[1].type).toBe(AttributeEnumType.Target)
+    expect(payload.reportData[0].variableAttribute[2].type).toBe(AttributeEnumType.MinSet)
+    expect(payload.reportData[0].variableAttribute[3].type).toBe(AttributeEnumType.MaxSet)
+  })
+
+  await it('Should preserve all payload properties correctly', () => {
+    const testDate = new Date('2023-10-22T11:22:33.444Z')
+    const reportData: ReportDataType[] = [
+      {
+        component: {
+          name: 'PreserveTestComponent',
+        },
+        variable: {
+          name: 'PreserveTestVariable',
+        },
+        variableAttribute: [
+          {
+            type: AttributeEnumType.Actual,
+            value: 'preserve test value',
+          },
+        ],
+      },
+    ]
+
+    const requestParams: OCPP20NotifyReportRequest = {
+      generatedAt: testDate,
+      reportData,
+      requestId: 3001,
+      seqNo: 15,
+      tbc: true,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.NOTIFY_REPORT,
+      requestParams
+    )
+
+    // Verify all input properties are preserved exactly
+    expect(payload.generatedAt).toBe(testDate)
+    expect(payload.requestId).toBe(3001)
+    expect(payload.seqNo).toBe(15)
+    expect(payload.tbc).toBe(true)
+    expect(payload.reportData).toBe(reportData)
+
+    // Verify no additional properties are added
+    const expectedKeys = ['generatedAt', 'reportData', 'requestId', 'seqNo', 'tbc']
+    const actualKeys = Object.keys(payload as object).sort()
+    expectedKeys.sort()
+    expect(actualKeys).toEqual(expectedKeys)
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts
new file mode 100644 (file)
index 0000000..2af94e7
--- /dev/null
@@ -0,0 +1,254 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { expect } from '@std/expect'
+import { describe, it } from 'node:test'
+
+import { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestService.js'
+import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
+import {
+  OCPP20ConnectorStatusEnumType,
+  OCPP20RequestCommand,
+  type OCPP20StatusNotificationRequest,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { createChargingStationWithEvses } from '../../../ChargingStationFactory.js'
+import {
+  TEST_FIRMWARE_VERSION,
+  TEST_STATUS_CHARGE_POINT_MODEL,
+  TEST_STATUS_CHARGE_POINT_SERIAL_NUMBER,
+  TEST_STATUS_CHARGE_POINT_VENDOR,
+  TEST_STATUS_CHARGING_STATION_NAME,
+} from './OCPP20TestConstants.js'
+
+await describe('OCPP20RequestService StatusNotification integration tests', async () => {
+  const mockResponseService = new OCPP20ResponseService()
+  const requestService = new OCPP20RequestService(mockResponseService)
+
+  const mockChargingStation = createChargingStationWithEvses({
+    baseName: TEST_STATUS_CHARGING_STATION_NAME,
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    stationInfo: {
+      chargePointModel: TEST_STATUS_CHARGE_POINT_MODEL,
+      chargePointSerialNumber: TEST_STATUS_CHARGE_POINT_SERIAL_NUMBER,
+      chargePointVendor: TEST_STATUS_CHARGE_POINT_VENDOR,
+      firmwareVersion: TEST_FIRMWARE_VERSION,
+      ocppStrictCompliance: false,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+
+  await it('Should build StatusNotification request payload correctly with Available status', () => {
+    const testTimestamp = new Date('2024-01-15T10:30:00.000Z')
+
+    const requestParams: OCPP20StatusNotificationRequest = {
+      connectorId: 1,
+      connectorStatus: OCPP20ConnectorStatusEnumType.Available,
+      evseId: 1,
+      timestamp: testTimestamp,
+    }
+
+    // Access the private buildRequestPayload method via type assertion
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.STATUS_NOTIFICATION,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.connectorId).toBe(1)
+    expect(payload.connectorStatus).toBe(OCPP20ConnectorStatusEnumType.Available)
+    expect(payload.evseId).toBe(1)
+    expect(payload.timestamp).toBe(testTimestamp)
+  })
+
+  await it('Should build StatusNotification request payload correctly with Occupied status', () => {
+    const testTimestamp = new Date('2024-01-15T11:45:30.000Z')
+
+    const requestParams: OCPP20StatusNotificationRequest = {
+      connectorId: 2,
+      connectorStatus: OCPP20ConnectorStatusEnumType.Occupied,
+      evseId: 2,
+      timestamp: testTimestamp,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.STATUS_NOTIFICATION,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.connectorId).toBe(2)
+    expect(payload.connectorStatus).toBe(OCPP20ConnectorStatusEnumType.Occupied)
+    expect(payload.evseId).toBe(2)
+    expect(payload.timestamp).toBe(testTimestamp)
+  })
+
+  await it('Should build StatusNotification request payload correctly with Faulted status', () => {
+    const testTimestamp = new Date('2024-01-15T12:15:45.500Z')
+
+    const requestParams: OCPP20StatusNotificationRequest = {
+      connectorId: 1,
+      connectorStatus: OCPP20ConnectorStatusEnumType.Faulted,
+      evseId: 1,
+      timestamp: testTimestamp,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.STATUS_NOTIFICATION,
+      requestParams
+    )
+
+    expect(payload).toBeDefined()
+    expect(payload.connectorId).toBe(1)
+    expect(payload.connectorStatus).toBe(OCPP20ConnectorStatusEnumType.Faulted)
+    expect(payload.evseId).toBe(1)
+    expect(payload.timestamp).toBe(testTimestamp)
+  })
+
+  await it('Should handle all OCPP20ConnectorStatusEnumType values correctly', () => {
+    const testTimestamp = new Date('2024-01-15T13:00:00.000Z')
+
+    const statusValues = [
+      OCPP20ConnectorStatusEnumType.Available,
+      OCPP20ConnectorStatusEnumType.Faulted,
+      OCPP20ConnectorStatusEnumType.Occupied,
+      OCPP20ConnectorStatusEnumType.Reserved,
+      OCPP20ConnectorStatusEnumType.Unavailable,
+    ]
+
+    statusValues.forEach((status, index) => {
+      const requestParams: OCPP20StatusNotificationRequest = {
+        connectorId: index + 1,
+        connectorStatus: status,
+        evseId: index + 1,
+        timestamp: testTimestamp,
+      }
+
+      const payload = (requestService as any).buildRequestPayload(
+        mockChargingStation,
+        OCPP20RequestCommand.STATUS_NOTIFICATION,
+        requestParams
+      )
+
+      expect(payload).toBeDefined()
+      expect(payload.connectorStatus).toBe(status)
+      expect(payload.connectorId).toBe(index + 1)
+      expect(payload.evseId).toBe(index + 1)
+      expect(payload.timestamp).toBe(testTimestamp)
+    })
+  })
+
+  await it('Should validate payload structure matches OCPP20StatusNotificationRequest interface', () => {
+    const testTimestamp = new Date('2024-01-15T14:30:15.123Z')
+
+    const requestParams: OCPP20StatusNotificationRequest = {
+      connectorId: 3,
+      connectorStatus: OCPP20ConnectorStatusEnumType.Reserved,
+      evseId: 2,
+      timestamp: testTimestamp,
+    }
+
+    const payload = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.STATUS_NOTIFICATION,
+      requestParams
+    )
+
+    // Validate that the payload has the exact structure of OCPP20StatusNotificationRequest
+    expect(typeof payload).toBe('object')
+    expect(payload).toHaveProperty('connectorId')
+    expect(payload).toHaveProperty('connectorStatus')
+    expect(payload).toHaveProperty('evseId')
+    expect(payload).toHaveProperty('timestamp')
+    expect(Object.keys(payload as object)).toHaveLength(4)
+
+    // Validate field types
+    expect(typeof payload.connectorId).toBe('number')
+    expect(typeof payload.connectorStatus).toBe('string')
+    expect(typeof payload.evseId).toBe('number')
+    expect(payload.timestamp).toBeInstanceOf(Date)
+
+    // Validate field values
+    expect(payload.connectorId).toBe(3)
+    expect(payload.connectorStatus).toBe(OCPP20ConnectorStatusEnumType.Reserved)
+    expect(payload.evseId).toBe(2)
+    expect(payload.timestamp).toBe(testTimestamp)
+  })
+
+  await it('Should handle edge case connector and EVSE IDs correctly', () => {
+    const testTimestamp = new Date('2024-01-15T15:45:00.000Z')
+
+    // Test with connector ID 0 (valid in OCPP 2.0 for the charging station itself)
+    const requestParamsConnector0: OCPP20StatusNotificationRequest = {
+      connectorId: 0,
+      connectorStatus: OCPP20ConnectorStatusEnumType.Available,
+      evseId: 1,
+      timestamp: testTimestamp,
+    }
+
+    const payloadConnector0 = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.STATUS_NOTIFICATION,
+      requestParamsConnector0
+    )
+
+    expect(payloadConnector0).toBeDefined()
+    expect(payloadConnector0.connectorId).toBe(0)
+    expect(payloadConnector0.connectorStatus).toBe(OCPP20ConnectorStatusEnumType.Available)
+    expect(payloadConnector0.evseId).toBe(1)
+    expect(payloadConnector0.timestamp).toBe(testTimestamp)
+
+    // Test with EVSE ID 0 (valid in OCPP 2.0 for the charging station itself)
+    const requestParamsEvse0: OCPP20StatusNotificationRequest = {
+      connectorId: 1,
+      connectorStatus: OCPP20ConnectorStatusEnumType.Unavailable,
+      evseId: 0,
+      timestamp: testTimestamp,
+    }
+
+    const payloadEvse0 = (requestService as any).buildRequestPayload(
+      mockChargingStation,
+      OCPP20RequestCommand.STATUS_NOTIFICATION,
+      requestParamsEvse0
+    )
+
+    expect(payloadEvse0).toBeDefined()
+    expect(payloadEvse0.connectorId).toBe(1)
+    expect(payloadEvse0.connectorStatus).toBe(OCPP20ConnectorStatusEnumType.Unavailable)
+    expect(payloadEvse0.evseId).toBe(0)
+    expect(payloadEvse0.timestamp).toBe(testTimestamp)
+  })
+
+  await it('Should handle different timestamp formats correctly', () => {
+    const testCases = [
+      new Date('2024-01-01T00:00:00.000Z'), // Start of year
+      new Date('2024-12-31T23:59:59.999Z'), // End of year
+      new Date(), // Current time
+      new Date('2024-06-15T12:30:45.678Z'), // Mid-year with milliseconds
+    ]
+
+    testCases.forEach((timestamp, index) => {
+      const requestParams: OCPP20StatusNotificationRequest = {
+        connectorId: 1,
+        connectorStatus: OCPP20ConnectorStatusEnumType.Available,
+        evseId: 1,
+        timestamp,
+      }
+
+      const payload = (requestService as any).buildRequestPayload(
+        mockChargingStation,
+        OCPP20RequestCommand.STATUS_NOTIFICATION,
+        requestParams
+      )
+
+      expect(payload).toBeDefined()
+      expect(payload.timestamp).toBe(timestamp)
+      expect(payload.timestamp).toBeInstanceOf(Date)
+    })
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20TestConstants.ts b/tests/charging-station/ocpp/2.0/OCPP20TestConstants.ts
new file mode 100644 (file)
index 0000000..5ef7c18
--- /dev/null
@@ -0,0 +1,20 @@
+/**
+ * Common test constants for OCPP 2.0 tests
+ */
+
+// Test charging station information
+export const TEST_CHARGING_STATION_NAME = 'CS-TEST-001'
+export const TEST_CHARGE_POINT_MODEL = 'Test Model'
+export const TEST_CHARGE_POINT_SERIAL_NUMBER = 'TEST-SN-001'
+export const TEST_CHARGE_POINT_VENDOR = 'Test Vendor'
+export const TEST_FIRMWARE_VERSION = '1.0.0'
+
+// Test connector instances
+export const TEST_CONNECTOR_VALID_INSTANCE = '1'
+export const TEST_CONNECTOR_INVALID_INSTANCE = '999'
+
+// Test charging station information for status notification tests
+export const TEST_STATUS_CHARGING_STATION_NAME = 'CS-TEST-STATUS-001'
+export const TEST_STATUS_CHARGE_POINT_MODEL = 'Test Status Model'
+export const TEST_STATUS_CHARGE_POINT_SERIAL_NUMBER = 'TEST-STATUS-SN-001'
+export const TEST_STATUS_CHARGE_POINT_VENDOR = 'Test Status Vendor'
diff --git a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts
new file mode 100644 (file)
index 0000000..a3d4fec
--- /dev/null
@@ -0,0 +1,375 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+
+import { expect } from '@std/expect'
+import { millisecondsToSeconds } from 'date-fns'
+import { describe, it } from 'node:test'
+
+import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
+import {
+  AttributeEnumType,
+  type ComponentType,
+  GetVariableStatusEnumType,
+  OCPP20ComponentName,
+  type OCPP20GetVariableDataType,
+  OCPP20OptionalVariableName,
+  OCPP20RequiredVariableName,
+  type VariableType,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { createChargingStationWithEvses } from '../../../ChargingStationFactory.js'
+import { TEST_CHARGING_STATION_NAME } from './OCPP20TestConstants.js'
+
+await describe('OCPP20VariableManager test suite', async () => {
+  // Create mock ChargingStation with EVSEs for OCPP 2.0 testing
+  const mockChargingStation = createChargingStationWithEvses({
+    baseName: TEST_CHARGING_STATION_NAME,
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+
+  await it('Verify that OCPP20VariableManager can be instantiated as singleton', () => {
+    const manager1 = OCPP20VariableManager.getInstance()
+    const manager2 = OCPP20VariableManager.getInstance()
+
+    expect(manager1).toBeDefined()
+    expect(manager1).toBe(manager2) // Same instance (singleton)
+  })
+
+  await describe('getVariables method tests', async () => {
+    const manager = OCPP20VariableManager.getInstance()
+
+    await it('Should handle valid ChargingStation component requests', () => {
+      const request: OCPP20GetVariableDataType[] = [
+        {
+          attributeType: AttributeEnumType.Actual,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+        {
+          attributeType: AttributeEnumType.Actual,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
+        },
+      ]
+
+      const result = manager.getVariables(mockChargingStation, request)
+
+      expect(Array.isArray(result)).toBe(true)
+      expect(result).toHaveLength(2)
+      // First variable: HeartbeatInterval
+      expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(result[0].attributeType).toBe(AttributeEnumType.Actual)
+      expect(result[0].attributeValue).toBe(
+        millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL).toString()
+      )
+      expect(result[0].component.name).toBe(OCPP20ComponentName.ChargingStation)
+      expect(result[0].variable.name).toBe(OCPP20OptionalVariableName.HeartbeatInterval)
+      expect(result[0].attributeStatusInfo).toBeUndefined()
+      // Second variable: EVConnectionTimeOut
+      expect(result[1].attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(result[1].attributeType).toBe(AttributeEnumType.Actual)
+      expect(result[1].attributeValue).toBe(Constants.DEFAULT_EV_CONNECTION_TIMEOUT.toString())
+      expect(result[1].component.name).toBe(OCPP20ComponentName.ChargingStation)
+      expect(result[1].variable.name).toBe(OCPP20RequiredVariableName.EVConnectionTimeOut)
+      expect(result[1].attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should handle valid Connector component requests', () => {
+      const request: OCPP20GetVariableDataType[] = [
+        {
+          component: {
+            instance: '1',
+            name: OCPP20ComponentName.Connector,
+          },
+          variable: { name: OCPP20RequiredVariableName.AuthorizeRemoteStart },
+        },
+      ]
+
+      const result = manager.getVariables(mockChargingStation, request)
+
+      expect(Array.isArray(result)).toBe(true)
+      expect(result).toHaveLength(1)
+      expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(result[0].attributeType).toBeUndefined()
+      expect(result[0].attributeValue).toBe('')
+      expect(result[0].component.name).toBe(OCPP20ComponentName.Connector)
+      expect(result[0].component.instance).toBe('1')
+      expect(result[0].variable.name).toBe(OCPP20RequiredVariableName.AuthorizeRemoteStart)
+      expect(result[0].attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should handle invalid component gracefully', () => {
+      const request: OCPP20GetVariableDataType[] = [
+        {
+          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
+          component: { name: 'InvalidComponent' as any },
+          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
+          variable: { name: 'SomeVariable' as any },
+        },
+      ]
+
+      const result = manager.getVariables(mockChargingStation, request)
+
+      expect(Array.isArray(result)).toBe(true)
+      expect(result).toHaveLength(1)
+      expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.UnknownComponent)
+      expect(result[0].attributeType).toBeUndefined()
+      expect(result[0].attributeValue).toBeUndefined()
+      expect(result[0].component.name).toBe('InvalidComponent')
+      expect(result[0].variable.name).toBe('SomeVariable')
+      expect(result[0].attributeStatusInfo).toBeDefined()
+      expect(result[0].attributeStatusInfo?.reasonCode).toBe('NotSupported')
+      expect(result[0].attributeStatusInfo?.additionalInfo).toContain(
+        'Component InvalidComponent is not supported'
+      )
+    })
+
+    await it('Should handle invalid variable gracefully', () => {
+      const request: OCPP20GetVariableDataType[] = [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
+          variable: { name: 'InvalidVariable' as any },
+        },
+      ]
+
+      const result = manager.getVariables(mockChargingStation, request)
+
+      expect(Array.isArray(result)).toBe(true)
+      expect(result).toHaveLength(1)
+      expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.UnknownVariable)
+      expect(result[0].attributeType).toBeUndefined()
+      expect(result[0].attributeValue).toBeUndefined()
+      expect(result[0].component.name).toBe(OCPP20ComponentName.ChargingStation)
+      expect(result[0].variable.name).toBe('InvalidVariable')
+      expect(result[0].attributeStatusInfo).toBeDefined()
+      expect(result[0].attributeStatusInfo?.reasonCode).toBe('NotSupported')
+      expect(result[0].attributeStatusInfo?.additionalInfo).toContain(
+        'Variable InvalidVariable is not supported'
+      )
+    })
+
+    await it('Should handle unsupported attribute type gracefully', () => {
+      const request: OCPP20GetVariableDataType[] = [
+        {
+          attributeType: AttributeEnumType.Target, // Not supported for this variable
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ]
+
+      const result = manager.getVariables(mockChargingStation, request)
+
+      expect(Array.isArray(result)).toBe(true)
+      expect(result).toHaveLength(1)
+      expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+      expect(result[0].attributeType).toBe(AttributeEnumType.Target)
+      expect(result[0].attributeValue).toBeUndefined()
+      expect(result[0].component.name).toBe(OCPP20ComponentName.ChargingStation)
+      expect(result[0].variable.name).toBe(OCPP20OptionalVariableName.HeartbeatInterval)
+      expect(result[0].attributeStatusInfo).toBeDefined()
+      expect(result[0].attributeStatusInfo?.reasonCode).toBe('NotSupported')
+      expect(result[0].attributeStatusInfo?.additionalInfo).toContain(
+        'Attribute type Target is not supported'
+      )
+    })
+
+    await it('Should handle non-existent connector instance', () => {
+      const request: OCPP20GetVariableDataType[] = [
+        {
+          component: {
+            instance: '999', // Non-existent connector
+            name: OCPP20ComponentName.Connector,
+          },
+          variable: { name: OCPP20RequiredVariableName.AuthorizeRemoteStart },
+        },
+      ]
+
+      const result = manager.getVariables(mockChargingStation, request)
+
+      expect(Array.isArray(result)).toBe(true)
+      expect(result).toHaveLength(1)
+      expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.UnknownComponent)
+      expect(result[0].attributeType).toBeUndefined()
+      expect(result[0].attributeValue).toBeUndefined()
+      expect(result[0].component.name).toBe(OCPP20ComponentName.Connector)
+      expect(result[0].component.instance).toBe('999')
+      expect(result[0].variable.name).toBe(OCPP20RequiredVariableName.AuthorizeRemoteStart)
+      expect(result[0].attributeStatusInfo).toBeDefined()
+      expect(result[0].attributeStatusInfo?.reasonCode).toBe('NotSupported')
+      expect(result[0].attributeStatusInfo?.additionalInfo).toContain(
+        'Component Connector is not supported'
+      )
+    })
+
+    await it('Should handle multiple variables in single request', () => {
+      const request: OCPP20GetVariableDataType[] = [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        {
+          attributeType: AttributeEnumType.Actual,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20RequiredVariableName.MessageTimeout },
+        },
+      ]
+
+      const result = manager.getVariables(mockChargingStation, request)
+
+      expect(Array.isArray(result)).toBe(true)
+      expect(result).toHaveLength(3)
+      // First variable: HeartbeatInterval
+      expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(result[0].attributeType).toBeUndefined()
+      expect(result[0].attributeValue).toBe(
+        millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL).toString()
+      )
+      expect(result[0].component.name).toBe(OCPP20ComponentName.ChargingStation)
+      expect(result[0].variable.name).toBe(OCPP20OptionalVariableName.HeartbeatInterval)
+      expect(result[0].attributeStatusInfo).toBeUndefined()
+      // Second variable: WebSocketPingInterval
+      expect(result[1].attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(result[1].attributeType).toBeUndefined()
+      expect(result[1].attributeValue).toBe(Constants.DEFAULT_WEBSOCKET_PING_INTERVAL.toString())
+      expect(result[1].component.name).toBe(OCPP20ComponentName.ChargingStation)
+      expect(result[1].variable.name).toBe(OCPP20OptionalVariableName.WebSocketPingInterval)
+      expect(result[1].attributeStatusInfo).toBeUndefined()
+      // Third variable: MessageTimeout
+      expect(result[2].attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(result[2].attributeType).toBe(AttributeEnumType.Actual)
+      expect(result[2].attributeValue).toBe(mockChargingStation.getConnectionTimeout().toString())
+      expect(result[2].component.name).toBe(OCPP20ComponentName.ChargingStation)
+      expect(result[2].variable.name).toBe(OCPP20RequiredVariableName.MessageTimeout)
+      expect(result[2].attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should handle EVSE component when supported', () => {
+      const request: OCPP20GetVariableDataType[] = [
+        {
+          component: {
+            instance: '1',
+            name: OCPP20ComponentName.EVSE,
+          },
+          variable: { name: OCPP20RequiredVariableName.AuthorizeRemoteStart },
+        },
+      ]
+
+      const result = manager.getVariables(mockChargingStation, request)
+
+      expect(Array.isArray(result)).toBe(true)
+      expect(result).toHaveLength(1)
+      // Should be accepted since mockChargingStation has EVSEs
+      expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(result[0].attributeType).toBeUndefined()
+      expect(result[0].attributeValue).toBe('')
+      expect(result[0].component.name).toBe(OCPP20ComponentName.EVSE)
+      expect(result[0].component.instance).toBe('1')
+      expect(result[0].variable.name).toBe(OCPP20RequiredVariableName.AuthorizeRemoteStart)
+      expect(result[0].attributeStatusInfo).toBeUndefined()
+    })
+  })
+
+  await describe('Component validation tests', async () => {
+    const manager = OCPP20VariableManager.getInstance()
+
+    await it('Should validate ChargingStation component as always valid', () => {
+      const component: ComponentType = { name: OCPP20ComponentName.ChargingStation }
+
+      // Access private method through any casting for testing
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+      const isValid = (manager as any).isComponentValid(mockChargingStation, component)
+      expect(isValid).toBe(true)
+    })
+
+    await it('Should validate Connector component when connectors exist', () => {
+      const component: ComponentType = { instance: '1', name: OCPP20ComponentName.Connector }
+
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+      const isValid = (manager as any).isComponentValid(mockChargingStation, component)
+      expect(isValid).toBe(true)
+    })
+
+    await it('Should reject invalid connector instance', () => {
+      const component: ComponentType = { instance: '999', name: OCPP20ComponentName.Connector }
+
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+      const isValid = (manager as any).isComponentValid(mockChargingStation, component)
+      expect(isValid).toBe(false)
+    })
+  })
+
+  await describe('Variable support validation tests', async () => {
+    const manager = OCPP20VariableManager.getInstance()
+
+    await it('Should support standard HeartbeatInterval variable', () => {
+      const component: ComponentType = { name: OCPP20ComponentName.ChargingStation }
+      const variable: VariableType = { name: OCPP20OptionalVariableName.HeartbeatInterval }
+
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+      const isSupported = (manager as any).isVariableSupported(
+        mockChargingStation,
+        component,
+        variable
+      )
+      expect(isSupported).toBe(true)
+    })
+
+    await it('Should support known OCPP variables', () => {
+      const component: ComponentType = { name: OCPP20ComponentName.ChargingStation }
+      const variable: VariableType = { name: OCPP20OptionalVariableName.WebSocketPingInterval }
+
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+      const isSupported = (manager as any).isVariableSupported(
+        mockChargingStation,
+        component,
+        variable
+      )
+      expect(isSupported).toBe(true)
+    })
+
+    await it('Should reject unknown variables', () => {
+      const component: ComponentType = { name: OCPP20ComponentName.ChargingStation }
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
+      const variable: VariableType = { name: 'UnknownVariable' as any }
+
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+      const isSupported = (manager as any).isVariableSupported(
+        mockChargingStation,
+        component,
+        variable
+      )
+      expect(isSupported).toBe(false)
+    })
+  })
+
+  await describe('Attribute type validation tests', async () => {
+    const manager = OCPP20VariableManager.getInstance()
+
+    await it('Should support Actual attribute by default', () => {
+      const variable: VariableType = { name: OCPP20OptionalVariableName.HeartbeatInterval }
+
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+      const isSupported = (manager as any).isAttributeTypeSupported(
+        variable,
+        AttributeEnumType.Actual
+      )
+      expect(isSupported).toBe(true)
+    })
+
+    await it('Should reject unsupported attribute types for most variables', () => {
+      const variable: VariableType = { name: OCPP20OptionalVariableName.HeartbeatInterval }
+
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+      const isSupported = (manager as any).isAttributeTypeSupported(
+        variable,
+        AttributeEnumType.Target
+      )
+      expect(isSupported).toBe(false)
+    })
+  })
+})
index 47abe385fe1ac184ea99c95dfbac450fb9dfec12..535c1d00fb55ebd4411920393383e71e0246f9d9 100644 (file)
@@ -38,26 +38,45 @@ The server will start listening for connections on port 9000.
 
 ## Running the server with OCPP command sending
 
-You can also specify a command and a period duration with the --command and --period options respectively when running the server. The server will then send your chosen command to the connected client(s) every period seconds.
+### Command Line Interface
 
-### GetBaseReport Command
+```shell
+poetry run task server --command <COMMAND_NAME> --period <SECONDS>
+```
+
+**Options:**
+
+- `--command <COMMAND_NAME>`: The OCPP command to send (see available commands below)
+- `--period <SECONDS>`: Interval in seconds between command sends
 
-To run the server and send a GetBaseReport command every 5 seconds, use:
+**Example:**
 
 ```shell
 poetry run task server --command GetBaseReport --period 5
 ```
 
-### ClearCache Command
+### Available Outgoing Commands
 
-To run the server and send a ClearCache command every 5 seconds, use:
+- `ClearCache` - Clear the charging station cache
+- `GetBaseReport` - Request a base configuration report
+- `GetVariables` - Get variable values from the charging station
+- `SetVariables` - Set variable values on the charging station
+- `RequestStartTransaction` - Request to start a transaction
+- `RequestStopTransaction` - Request to stop a transaction
+- `Reset` - Reset the charging station
+- `UnlockConnector` - Unlock a specific connector
+- `ChangeAvailability` - Change connector availability
+- `TriggerMessage` - Trigger a specific message
+- `DataTransfer` - Send custom data
+
+### Testing the Server
+
+To run the test suite and validate all implemented commands:
 
 ```shell
-poetry run task server --command ClearCache --period 5
+poetry run task test
 ```
 
-Please be mindful that these commands were examples according to the provided scenario, the available commands and their syntax might vary depending on the ocpp version and the implemented functionalities on your client.
-
 ## Overview of the Server Scripts
 
 ### Server.py
index fa03e4116abcb6b368b1b602b2c41aaefefb6205..9792d61d1cc027c31c1309542edbf36eefd0cd61 100644 (file)
@@ -16,6 +16,7 @@ ruff = "^0.14"
 
 [tool.taskipy.tasks]
 server = "poetry run python server.py"
+test = "poetry run python test_server.py"
 format = "ruff check --fix . && ruff format ."
 lint = "ruff check --diff . && ruff format --check --diff ."
 
index 6a772e0ab7ca91a9a7ebf07a2b87effed33d3e7c..2abd3e58752da3a67e2bbd1a180209804601e702 100644 (file)
@@ -12,11 +12,19 @@ from ocpp.routing import on
 from ocpp.v201.enums import (
     Action,
     AuthorizationStatusEnumType,
+    ChangeAvailabilityStatusEnumType,
     ClearCacheStatusEnumType,
+    DataTransferStatusEnumType,
     GenericDeviceModelStatusEnumType,
+    MessageTriggerEnumType,
+    OperationalStatusEnumType,
     RegistrationStatusEnumType,
     ReportBaseEnumType,
+    ResetEnumType,
+    ResetStatusEnumType,
     TransactionEventEnumType,
+    TriggerMessageStatusEnumType,
+    UnlockStatusEnumType,
 )
 from websockets import ConnectionClosed
 
@@ -103,6 +111,28 @@ class ChargePoint(ocpp.v201.ChargePoint):
         logging.info("Received %s", Action.notify_report)
         return ocpp.v201.call_result.NotifyReport()
 
+    @on(Action.data_transfer)
+    async def on_data_transfer(self, vendor_id: str, **kwargs):
+        logging.info("Received %s", Action.data_transfer)
+        return ocpp.v201.call_result.DataTransfer(
+            status=DataTransferStatusEnumType.accepted
+        )
+
+    @on(Action.firmware_status_notification)
+    async def on_firmware_status_notification(self, status, **kwargs):
+        logging.info("Received %s", Action.firmware_status_notification)
+        return ocpp.v201.call_result.FirmwareStatusNotification()
+
+    @on(Action.log_status_notification)
+    async def on_log_status_notification(self, status, request_id: int, **kwargs):
+        logging.info("Received %s", Action.log_status_notification)
+        return ocpp.v201.call_result.LogStatusNotification()
+
+    @on(Action.security_event_notification)
+    async def on_security_event_notification(self, event_type, timestamp, **kwargs):
+        logging.info("Received %s", Action.security_event_notification)
+        return ocpp.v201.call_result.SecurityEventNotification()
+
     # Request handlers to emit OCPP messages.
     async def _send_clear_cache(self):
         request = ocpp.v201.call.ClearCache()
@@ -125,6 +155,107 @@ class ChargePoint(ocpp.v201.ChargePoint):
         else:
             logging.info("%s failed", Action.get_base_report)
 
+    async def _send_get_variables(self):
+        request = ocpp.v201.call.GetVariables(
+            get_variable_data=[
+                {
+                    "component": {"name": "ChargingStation"},
+                    "variable": {"name": "AvailabilityState"},
+                }
+            ]
+        )
+        await self.call(request)
+        logging.info("%s response received", Action.get_variables)
+
+    async def _send_set_variables(self):
+        request = ocpp.v201.call.SetVariables(
+            set_variable_data=[
+                {
+                    "component": {"name": "ChargingStation"},
+                    "variable": {"name": "HeartbeatInterval"},
+                    "attribute_value": "30",
+                }
+            ]
+        )
+        await self.call(request)
+        logging.info("%s response received", Action.set_variables)
+
+    async def _send_request_start_transaction(self):
+        request = ocpp.v201.call.RequestStartTransaction(
+            id_token={"id_token": "test_token", "type": "ISO14443"},
+            evse_id=1,
+            remote_start_id=randint(1, 1000),  # noqa: S311
+        )
+        await self.call(request)
+        logging.info("%s response received", Action.request_start_transaction)
+
+    async def _send_request_stop_transaction(self):
+        request = ocpp.v201.call.RequestStopTransaction(
+            transaction_id="test_transaction_123"
+        )
+        await self.call(request)
+        logging.info("%s response received", Action.request_stop_transaction)
+
+    async def _send_reset(self):
+        request = ocpp.v201.call.Reset(type=ResetEnumType.immediate)
+        response = await self.call(request)
+
+        if (
+            hasattr(response, "status")
+            and response.status == ResetStatusEnumType.accepted
+        ):
+            logging.info("%s successful", Action.reset)
+        else:
+            logging.info("%s failed", Action.reset)
+
+    async def _send_unlock_connector(self):
+        request = ocpp.v201.call.UnlockConnector(evse_id=1, connector_id=1)
+        response = await self.call(request)
+
+        if response.status == UnlockStatusEnumType.unlocked:
+            logging.info("%s successful", Action.unlock_connector)
+        else:
+            logging.info("%s failed", Action.unlock_connector)
+
+    async def _send_change_availability(self):
+        request = ocpp.v201.call.ChangeAvailability(
+            operational_status=OperationalStatusEnumType.operative
+        )
+        response = await self.call(request)
+
+        if (
+            hasattr(response, "status")
+            and response.status == ChangeAvailabilityStatusEnumType.accepted
+        ):
+            logging.info("%s successful", Action.change_availability)
+        else:
+            logging.info("%s failed", Action.change_availability)
+
+    async def _send_trigger_message(self):
+        request = ocpp.v201.call.TriggerMessage(
+            requested_message=MessageTriggerEnumType.status_notification
+        )
+        response = await self.call(request)
+
+        if (
+            hasattr(response, "status")
+            and response.status == TriggerMessageStatusEnumType.accepted
+        ):
+            logging.info("%s successful", Action.trigger_message)
+        else:
+            logging.info("%s failed", Action.trigger_message)
+
+    async def _send_data_transfer(self):
+        request = ocpp.v201.call.DataTransfer(
+            vendor_id="TestVendor", message_id="TestMessage", data="test_data"
+        )
+        response = await self.call(request)
+
+        if response.status == DataTransferStatusEnumType.accepted:
+            logging.info("%s successful", Action.data_transfer)
+        else:
+            logging.info("%s failed", Action.data_transfer)
+
     async def _send_command(self, command_name: Action):
         logging.debug("Sending OCPP command %s", command_name)
         match command_name:
@@ -132,6 +263,24 @@ class ChargePoint(ocpp.v201.ChargePoint):
                 await self._send_clear_cache()
             case Action.get_base_report:
                 await self._send_get_base_report()
+            case Action.get_variables:
+                await self._send_get_variables()
+            case Action.set_variables:
+                await self._send_set_variables()
+            case Action.request_start_transaction:
+                await self._send_request_start_transaction()
+            case Action.request_stop_transaction:
+                await self._send_request_stop_transaction()
+            case Action.reset:
+                await self._send_reset()
+            case Action.unlock_connector:
+                await self._send_unlock_connector()
+            case Action.change_availability:
+                await self._send_change_availability()
+            case Action.trigger_message:
+                await self._send_trigger_message()
+            case Action.data_transfer:
+                await self._send_data_transfer()
             case _:
                 logging.info(f"Not supported command {command_name}")
 
diff --git a/tests/ocpp-server/test_server.py b/tests/ocpp-server/test_server.py
new file mode 100644 (file)
index 0000000..0e16124
--- /dev/null
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""
+Test script to verify OCPP 2.0 commands supported by the mock server
+"""
+
+import logging
+
+# Configure logging
+logging.basicConfig(level=logging.INFO)
+
+
+def test_outgoing_commands():
+    """Test the presence of outgoing command methods"""
+    try:
+        from server import ChargePoint
+    except ImportError as e:
+        logging.error(f"Failed to import ChargePoint: {e}")
+        return [("import_error", "❌ FAIL: Cannot import server module")]
+
+    expected_methods = [
+        "_send_clear_cache",
+        "_send_get_base_report",
+        "_send_get_variables",
+        "_send_set_variables",
+        "_send_request_start_transaction",
+        "_send_request_stop_transaction",
+        "_send_reset",
+        "_send_unlock_connector",
+        "_send_change_availability",
+        "_send_trigger_message",
+        "_send_data_transfer",
+    ]
+
+    results = []
+
+    for method_name in expected_methods:
+        command_name = method_name.replace("_send_", "")
+        if hasattr(ChargePoint, method_name):
+            results.append((command_name, "✅ PASS"))
+            logging.info(f"Method {method_name}: PASS")
+        else:
+            results.append((command_name, "❌ FAIL: Missing method"))
+            logging.error(f"Method {method_name}: FAIL - Missing method")
+
+    return results
+
+
+def check_incoming_handlers():
+    """Verify that all incoming handlers are present"""
+    try:
+        from server import ChargePoint
+    except ImportError as e:
+        logging.error(f"Failed to import ChargePoint: {e}")
+        return [("import_error", "❌ FAIL: Cannot import server module")]
+
+    expected_handlers = [
+        "boot_notification",
+        "heartbeat",
+        "status_notification",
+        "authorize",
+        "transaction_event",
+        "meter_values",
+        "notify_report",
+        "data_transfer",
+        "firmware_status_notification",
+        "log_status_notification",
+        "security_event_notification",
+    ]
+
+    results = []
+
+    for action_name in expected_handlers:
+        handler_name = f"on_{action_name}"
+        if hasattr(ChargePoint, handler_name):
+            results.append((action_name, "✅ PASS"))
+            logging.info(f"Handler {handler_name}: PASS")
+        else:
+            results.append((action_name, "❌ FAIL: Missing handler"))
+            logging.error(f"Handler {handler_name}: FAIL - Missing handler")
+
+    return results
+
+
+def main():
+    """Main test function"""
+    print("=" * 60)
+    print("OCPP 2.0 Mock Server Test")
+    print("=" * 60)
+
+    print("\n🔄 Testing outgoing commands...")
+    outgoing_results = test_outgoing_commands()
+
+    print("\n🔄 Testing incoming handlers...")
+    incoming_results = check_incoming_handlers()
+
+    print("\n📊 Test results:")
+    print("\n--- Outgoing commands ---")
+    for command, status in outgoing_results:
+        print(f"{status} {command}")
+
+    print("\n--- Incoming handlers ---")
+    for handler, status in incoming_results:
+        print(f"{status} {handler}")
+
+    # Statistics
+    outgoing_pass = len([r for r in outgoing_results if "PASS" in r[1]])
+    incoming_pass = len([r for r in incoming_results if "PASS" in r[1]])
+    total_pass = outgoing_pass + incoming_pass
+    total_tests = len(outgoing_results) + len(incoming_results)
+
+    print("\n📈 Statistics:")
+    print(f"   Outgoing commands: {outgoing_pass}/{len(outgoing_results)}")
+    print(f"   Incoming handlers: {incoming_pass}/{len(incoming_results)}")
+    print(
+        f"   Total: {total_pass}/{total_tests} ({total_pass / total_tests * 100:.1f}%)"
+    )
+
+    if total_pass == total_tests:
+        print("\n🎉 All tests passed!")
+        return 0
+    else:
+        print(f"\n⚠️  {total_tests - total_pass} test(s) failed")
+        return 1
+
+
+if __name__ == "__main__":
+    exit_code = main()
+    exit(exit_code)
index 77ebcc09754c54983c893ec4d6f0019a3955b891..9cbeff5b96bec67b94636ce78897c17fb6b037a5 100644 (file)
@@ -3,8 +3,6 @@
 import { expect } from '@std/expect'
 import { describe, it } from 'node:test'
 
-import type { ChargingStation } from '../../src/charging-station/index.js'
-
 import {
   FileType,
   GenericStatus,
@@ -18,11 +16,10 @@ import {
   handleSendMessageError,
 } from '../../src/utils/ErrorUtils.js'
 import { logger } from '../../src/utils/Logger.js'
+import { createChargingStation } from '../ChargingStationFactory.js'
 
 await describe('ErrorUtils test suite', async () => {
-  const chargingStation = {
-    logPrefix: () => 'CS-TEST |',
-  } as ChargingStation
+  const chargingStation = createChargingStation({ baseName: 'CS-TEST' })
 
   await it('Verify handleFileException()', t => {
     t.mock.method(console, 'warn')