]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp20): add SetVariables handling with runtime + persistent var… (#1576)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 29 Oct 2025 21:34:04 +0000 (22:34 +0100)
committerGitHub <noreply@github.com>
Wed, 29 Oct 2025 21:34:04 +0000 (22:34 +0100)
* feat(ocpp20): add SetVariables handling with runtime + persistent variable support and tests

* refactor(ocpp20): unify validation messages and remove success reason codes

* feat(ocpp20): extend variable manager with EVSE support, runtime overrides + validation refactor

* chore(openspec): add EVSE AuthorizeRemoteStart support proposal and spec delta

* chore(openspec): mark EVSE AuthorizeRemoteStart tasks completed (except validation/archive)

* chore(openspec): archive add-evse-authorizeremotestart-support change

* chore(openspec): archive refactor-ocpp20-variable-proxy change

* refactor: drop resetRuntimeVariables\n\nBREAKING CHANGE: use resetRuntimeOverrides() instead

* refactor(config): enum comparisons with explicit string casting

* refactor(charging-station): conditionally reset OCPP 2.x runtime overrides

* refactor(tests): use date-fns helper for heartbeat interval conversion

* refactor(tests,config): replace manual heartbeat conversions and minor cleanups

* refactor: cleanup OCPP stack stop API

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp20): consolidate setVariables tests into main VariableManager suite

* refactor: variable namespace alignment

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(validation): simplify configuration value integer checks with Number.isInteger

* test(ocpp20): add edge case validation tests and rename trimmed variable

* refactor: cleanup configuration value validation

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* feat(validation): tighten positive integer checks for OCPP 2.0 variables

* test(ocpp20): simplify BootNotification interval

* feat(ocpp20): add variable metadata constraints

* refactor: cleanups variable value validation

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: ocpp2 spec alignment

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix: handle variables in a case incensitive way

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* feat(ocpp20): add Items/Bytes limits, ReportingValueSize, AuthCtrlr; adopt void tests

* refactor(ocpp20): wrap int parsing in VariableManager with tolerant toIntOrNaN

* refactor(ocpp20): use tolerant int parsing in IncomingRequestService for Items/Bytes limits

* refactor(ocpp20): centralize toIntOrNaN via shared util

* test(utils): add convertToIntOrNaN coverage and remove redundant aliases in OCPP20 services

* refactor: use getter to access configuration key

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* feat(ocpp20): integrate registry-driven characteristics & combined reboot logic in SetVariables

* refactor(ocpp20): restore variable manager logic; migrate to enums

* feat(ocpp20): add variable characteristics registry for enum-backed SetVariables

* refactor(ocpp20): migrate variable manager to unified registry and drop legacy metadata

* chore(lint): add JSDoc for variable registry helpers and whitelist deauthorize

* refactor(ocpp20): remove legacy alias/case-insensitive paths and relocate component variables

* refactor(ocpp20): harmonize variable metadata param naming (variableMetaData)

* refactor(ocpp20): standardize metadata identifier casing (variableMetadata)

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* test(ocpp20): adjust SetVariables propagation tests and HeartbeatInterval component

* feat(ocpp20): enhance variable registry, size limits, MinSet/MaxSet

- Add MinSet/MaxSet overrides and retrieval
- Refine ConfigurationValueSize/ValueSize effective limit logic
- Skip auto-create for unset size limit vars and instance-scoped persistent vars
- Enrich FullInventory report with registry variable data
- Improve integer validation and reason codes (decimal, zero, bounds)
- Persist measurementTimeSeries (CircularBuffer to array) safely
- Adjust persistence (OrganizationName non-overwrite, instance-scoped skip)
- Refactor has() and validateUUID for broader type safety
- Use valid WebSocket URLs for ConnectionUrl size tests
- Add public accessors for base report status and data

* feat(ocpp20): set 2500 max value length; use TooLargeElement reason

* feat(ocpp20): add absolute max constant; align truncation logic and persistence tests

* refactor(ocpp20): use OCPP_VALUE_ABSOLUTE_MAX_LENGTH constant for size limit metadata

* chore(tests,ui): normalize comments, centralize UI timeout and size limit references

* refactor: cleanup variable namespace

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* feat(ocpp20): flatten MessageAttemptInterval instance for persistent config key

* chore(ocpp20): add TODO for generic instance flattening handling

* refactor: revert incorrects changes

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

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: remove unneeded public wrappers for UTs only purpose

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

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* feat(ocpp20,get-variables): accept empty Target (B06.FR.13) and add edge case tests\ndocs(ocpp20): add GetVariables gap analysis, mark B06.FR.13 conformant

* fix: silence linter issues

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test: remove incorrect tests

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* docs(ocpp20): update GetVariables gap analysis and simplify lint-staged cache usage

* chore: revert lint-staged caching removal

* feat(ocpp20): add write-only variable, MinSet/MaxSet support and instance auto-create skip

* fix(ocpp20): remove MinSet/MaxSet from ConnectionUrl supportedAttributes for spec compliance

* docs(ocpp20): clarify removal of MinSet/MaxSet from ConnectionUrl variable

* fix(ocpp20): remove HeartbeatInterval MinSet/MaxSet

* fix(ocpp20): restrict GetBaseReport and variable registry to Actual attribute only

* docs(changelog): add unreleased section for ocpp20 attribute restriction fix

* chore(ocpp20): remove obsolete comments after attribute restriction refactor

* test(ocpp20): adjust MessageAttemptInterval & GetBaseReport to Actual-only validation

* chore(ocpp20): streamline variable registry comments and group sections

* feat(ocpp20): add missing ChargingStation vars AvailabilityState Available SupplyPhases

* fix: refine variable registry

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: use existing enum

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

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* docs: removed outdated markdown

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: factor out common logic

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: use builtin helpers

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

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

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
44 files changed:
.serena/project.yml
AGENTS.md
README.md
eslint.config.js
opencode.jsonc [new file with mode: 0644]
src/charging-station/Bootstrap.ts
src/charging-station/ChargingStation.ts
src/charging-station/ConfigurationKeyUtils.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20VariableManager.ts
src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts [new file with mode: 0644]
src/charging-station/ocpp/OCPPIncomingRequestService.ts
src/types/ChargingStationOcppConfiguration.ts
src/types/ChargingStationWorker.ts
src/types/index.ts
src/types/ocpp/2.0/Requests.ts
src/types/ocpp/2.0/Variables.ts
src/utils/Configuration.ts
src/utils/Constants.ts
src/utils/MessageChannelUtils.ts
src/utils/Utils.ts
src/utils/index.ts
src/worker/WorkerFactory.ts
tests/ChargingStationFactory.ts
tests/charging-station/ConfigurationKeyUtils.test.ts [new file with mode: 0644]
tests/charging-station/Helpers.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetVariables.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20RequestService-BootNotification.test.ts
tests/charging-station/ocpp/2.0/OCPP20RequestService-HeartBeat.test.ts
tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts
tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts
tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts
tests/utils/ErrorUtils.test.ts
tests/utils/Utils.test.ts
ui/web/src/composables/Constants.ts [new file with mode: 0644]
ui/web/src/composables/UIClient.ts
ui/web/start.js

index 3854669664b14598f58a35c1ab01b695115d137c..b5cefbde581e85e946d81d6a34a1b5f4724a6f8e 100644 (file)
@@ -66,6 +66,6 @@ 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.'
+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` or `AGENTS.md` for development guidelines.'
 
 project_name: 'e-mobility-charging-stations-simulator'
index 5fbc66434b5a42103c85cbfd4124eb10cb3d0f06..311596542ab3ea8b8205a634325ca1d14df8cdad 100644 (file)
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,4 +20,4 @@ Keep this managed block so 'openspec update' can refresh the instructions.
 
 <!-- OPENSPEC:END -->
 
-Open`@/.github/copilot-instructions.md`, read it and strictly follow the instructions.
+Open `@/.github/copilot-instructions.md`, read it and strictly follow the instructions.
index 201fe6bfc0a9889a6d0ff8601a818a961338d212..b1e3ae8651eec52247575093617d16333f8aba26 100644 (file)
--- a/README.md
+++ b/README.md
@@ -522,7 +522,7 @@ make SUBMODULES_INIT=true
 #### G. Monitoring
 
 - :white_check_mark: GetVariables
-- :x: SetVariables
+- :white_check_mark: SetVariables
 
 #### H. FirmwareManagement
 
index e6f4d01bb224aea9c91162256affd061a7dd08e4..6af2482edb4d0dd46f51b5ecfc97a3a7ef84827e 100644 (file)
@@ -53,7 +53,22 @@ export default defineConfig([
               'workerd',
               // OCPP 2.0.x Component Names
               'cppwm',
+              // OCPP variable names and domain terms
+              'heartbeatinterval',
+              'HEARTBEATINTERVAL',
+              'websocketpinginterval',
+              'WEBSOCKETPINGINTERVAL',
+              'connectionurl',
+              'CONNECTIONURL',
+              'chargingstation',
+              'CHARGINGSTATION',
+              'authctrlr',
+              'AUTHCTRLR',
               'recloser',
+              'deauthorize',
+              'DEAUTHORIZE',
+              'deauthorized',
+              'DEAUTHORIZED',
             ],
           },
         },
@@ -148,4 +163,11 @@ export default defineConfig([
       'vue/multi-word-component-names': 'off',
     },
   },
+  {
+    files: ['tests/**/*.test.ts', 'tests/**/*.test.mts', 'tests/**/*.test.cts'],
+    rules: {
+      '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }],
+      'no-void': 'off',
+    },
+  },
 ])
diff --git a/opencode.jsonc b/opencode.jsonc
new file mode 100644 (file)
index 0000000..65b4232
--- /dev/null
@@ -0,0 +1,8 @@
+{
+  "$schema": "https://opencode.ai/config.json",
+  "formatter": {
+    "prettier": {
+      "disabled": true,
+    },
+  },
+}
index 0629ad852606a3fe5c66b3337dad7d80ec017bcb..3e10d092db2fdc58e8c3b0e08a9fa10263ac8080 100644 (file)
@@ -12,7 +12,7 @@ import { availableParallelism, type MessageHandler } from 'poolifier'
 
 import type { AbstractUIServer } from './ui-server/AbstractUIServer.js'
 
-import { version } from '../../package.json'
+import packageJson from '../../package.json' with { type: 'json' }
 import { BaseError } from '../exception/index.js'
 import { type Storage, StorageFactory } from '../performance/index.js'
 import {
@@ -87,7 +87,7 @@ export class Bootstrap extends EventEmitter {
   private readonly templateStatistics: Map<string, TemplateStatistics>
   private readonly uiServer: AbstractUIServer
   private uiServerStarted: boolean
-  private readonly version: string = version
+  private readonly version: string = packageJson.version
   private workerImplementation?: WorkerAbstract<ChargingStationWorkerData, ChargingStationInfo>
 
   private get numberOfAddedChargingStations (): number {
@@ -461,8 +461,7 @@ export class Bootstrap extends EventEmitter {
     //   )}`
     // )
     // Skip worker message events processing
-    // eslint-disable-next-line @typescript-eslint/dot-notation
-    if (msg['uuid'] != null) {
+    if (msg.uuid != null) {
       return
     }
     const { data, event } = msg
index 5f8003ebeaeb4f74e81b488c6772af5a70fe0e06..38bb259d7cda2d35dce63881d7594ab36f829fa7 100644 (file)
@@ -1020,6 +1020,7 @@ export class ChargingStation extends EventEmitter {
       if (!this.stopping) {
         this.stopping = true
         await this.stopMessageSequence(reason, stopTransactions)
+        this.ocppIncomingRequestService.stop(this)
         this.closeWSConnection()
         if (this.stationInfo?.enableStatistics === true) {
           this.performanceStatistics?.stop()
@@ -1333,7 +1334,10 @@ export class ChargingStation extends EventEmitter {
         propagateSerialNumber(this.getTemplateFromFile(), stationInfoFromFile, stationInfo)
     }
     return setChargingStationOptions(
-      mergeDeepRight(Constants.DEFAULT_STATION_INFO, stationInfo),
+      mergeDeepRight<ChargingStationInfo, ChargingStationInfo>(
+        Constants.DEFAULT_STATION_INFO as ChargingStationInfo,
+        stationInfo
+      ),
       options
     )
   }
@@ -2192,7 +2196,10 @@ export class ChargingStation extends EventEmitter {
         } else {
           delete configurationData.configurationKey
         }
-        configurationData = mergeDeepRight<ChargingStationConfiguration>(
+        configurationData = mergeDeepRight<
+          ChargingStationConfiguration,
+          Partial<ChargingStationConfiguration>
+        >(
           configurationData,
           buildChargingStationAutomaticTransactionGeneratorConfiguration(
             this
@@ -2326,13 +2333,16 @@ export class ChargingStation extends EventEmitter {
             error
           )
         }
-        // eslint-disable-next-line promise/catch-or-return, @typescript-eslint/no-floating-promises, promise/no-promise-in-callback
+        // eslint-disable-next-line promise/no-promise-in-callback
         sleep(exponentialDelay(messageIdx))
-          // eslint-disable-next-line promise/always-return
           .then(() => {
             // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
             ++messageIdx!
             this.sendMessageBuffer(onCompleteCallback, messageIdx)
+            return undefined
+          })
+          .catch((error: unknown) => {
+            throw error
           })
       })
     } else {
index d40591a1153036e2d7357bf80e4d7557cdcbbb78..709914b827e4e937c4b2e4bf7c7a1fae5bd9b3bf 100644 (file)
@@ -77,6 +77,17 @@ export const addConfigurationKey = (
         visible: options.visible,
       }
     } else {
+      const configurationKey = chargingStation.ocppConfiguration.configurationKey[keyIndex]
+      if (options.reboot != null && configurationKey.reboot !== options.reboot) {
+        configurationKey.reboot = options.reboot
+      }
+      if (options.readonly != null && configurationKey.readonly !== options.readonly) {
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        configurationKey.readonly = options.readonly!
+      }
+      if (options.visible != null && configurationKey.visible !== options.visible) {
+        configurationKey.visible = options.visible
+      }
       logger.error(
         `${chargingStation.logPrefix()} Trying to add an already existing configuration key: %j`,
         chargingStation.ocppConfiguration.configurationKey[keyIndex]
index 5e7a4a6b01805b3968bbbd8f02d0e43d8a8ce8e9..8ee67e2b1c38f750c66e5f2dfe63255973878274 100644 (file)
@@ -661,6 +661,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
     }
   }
 
+  public override stop (chargingStation: ChargingStation): void {
+    /* no-op for OCPP 1.6 */
+  }
+
   private async handleRequestCancelReservation (
     chargingStation: ChargingStation,
     commandPayload: OCPP16CancelReservationRequest
index 342432d5665d1e8f9b7424ff5e1febbe5e6111e1..a1269b1d7a2e569ebd20b15eb4a510e903ff504e 100644 (file)
@@ -12,6 +12,7 @@ import {
   DataEnumType,
   ErrorType,
   GenericDeviceModelStatusEnumType,
+  GetVariableStatusEnumType,
   type IncomingRequestHandler,
   type JsonType,
   type OCPP20ClearCacheRequest,
@@ -26,20 +27,27 @@ import {
   type OCPP20NotifyReportRequest,
   type OCPP20NotifyReportResponse,
   OCPP20RequestCommand,
+  OCPP20RequiredVariableName,
   type OCPP20ResetRequest,
   type OCPP20ResetResponse,
+  type OCPP20SetVariablesRequest,
+  type OCPP20SetVariablesResponse,
   OCPPVersion,
   ReasonCodeEnumType,
   ReportBaseEnumType,
   type ReportDataType,
   ResetEnumType,
   ResetStatusEnumType,
+  SetVariableStatusEnumType,
   StopTransactionReason,
 } from '../../../types/index.js'
-import { isAsyncFunction, logger } from '../../../utils/index.js'
+import { StandardParametersKey } from '../../../types/ocpp/Configuration.js'
+import { convertToIntOrNaN, isAsyncFunction, logger } from '../../../utils/index.js'
+import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
 import { OCPPIncomingRequestService } from '../OCPPIncomingRequestService.js'
 import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js'
 import { OCPP20VariableManager } from './OCPP20VariableManager.js'
+import { getVariableMetadata, VARIABLE_REGISTRY } from './OCPP20VariableRegistry.js'
 
 const moduleName = 'OCPP20IncomingRequestService'
 
@@ -51,16 +59,35 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     IncomingRequestHandler
   >
 
+  private readonly reportDataCache: Map<number, ReportDataType[]>
+
   public constructor () {
     // if (new.target.name === moduleName) {
     //   throw new TypeError(`Cannot construct ${new.target.name} instances directly`)
     // }
     super(OCPPVersion.VERSION_201)
+    this.reportDataCache = new Map<number, ReportDataType[]>()
     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)],
-      [OCPP20IncomingRequestCommand.RESET, this.handleRequestReset.bind(this)],
+      [
+        OCPP20IncomingRequestCommand.CLEAR_CACHE,
+        super.handleRequestClearCache.bind(this) as IncomingRequestHandler,
+      ],
+      [
+        OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+        this.handleRequestGetBaseReport.bind(this) as unknown as IncomingRequestHandler,
+      ],
+      [
+        OCPP20IncomingRequestCommand.GET_VARIABLES,
+        this.handleRequestGetVariables.bind(this) as unknown as IncomingRequestHandler,
+      ],
+      [
+        OCPP20IncomingRequestCommand.RESET,
+        this.handleRequestReset.bind(this) as unknown as IncomingRequestHandler,
+      ],
+      [
+        OCPP20IncomingRequestCommand.SET_VARIABLES,
+        this.handleRequestSetVariables.bind(this) as unknown as IncomingRequestHandler,
+      ],
     ])
     this.payloadValidateFunctions = new Map<
       OCPP20IncomingRequestCommand,
@@ -106,6 +133,16 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           )
         ),
       ],
+      [
+        OCPP20IncomingRequestCommand.SET_VARIABLES,
+        this.ajv.compile(
+          OCPP20ServiceUtils.parseJsonSchemaFile<OCPP20SetVariablesRequest>(
+            'assets/json-schemas/ocpp/2.0/SetVariablesRequest.json',
+            moduleName,
+            'constructor'
+          )
+        ),
+      ],
     ])
     // Handle incoming request events
     this.on(
@@ -138,13 +175,81 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       getVariableResult: [],
     }
 
-    // Use VariableManager to get variables
     const variableManager = OCPP20VariableManager.getInstance()
 
-    // Get variables using VariableManager
-    const results = variableManager.getVariables(chargingStation, commandPayload.getVariableData)
+    // Enforce ItemsPerMessage and BytesPerMessage limits if configured
+    let enforceItemsLimit = 0
+    let enforceBytesLimit = 0
+    try {
+      const itemsCfg = getConfigurationKey(
+        chargingStation,
+        OCPP20RequiredVariableName.ItemsPerMessage as unknown as StandardParametersKey
+      )?.value
+      const bytesCfg = getConfigurationKey(
+        chargingStation,
+        OCPP20RequiredVariableName.BytesPerMessage as unknown as StandardParametersKey
+      )?.value
+      if (itemsCfg && /^\d+$/.test(itemsCfg)) {
+        enforceItemsLimit = convertToIntOrNaN(itemsCfg)
+      }
+      if (bytesCfg && /^\d+$/.test(bytesCfg)) {
+        enforceBytesLimit = convertToIntOrNaN(bytesCfg)
+      }
+    } catch {
+      /* ignore */
+    }
+
+    const variableData = commandPayload.getVariableData
+    const preEnforcement = OCPP20ServiceUtils.enforceMessageLimits(
+      chargingStation,
+      moduleName,
+      'handleRequestGetVariables',
+      variableData,
+      enforceItemsLimit,
+      enforceBytesLimit,
+      (v, reason) => ({
+        attributeStatus: GetVariableStatusEnumType.Rejected,
+        attributeStatusInfo: {
+          additionalInfo: reason.info,
+
+          reasonCode: ReasonCodeEnumType[reason.reasonCode as keyof typeof ReasonCodeEnumType],
+        },
+        attributeType: v.attributeType,
+        component: v.component,
+        variable: v.variable,
+      }),
+      logger
+    )
+    if (preEnforcement.rejected) {
+      getVariablesResponse.getVariableResult =
+        preEnforcement.results as typeof getVariablesResponse.getVariableResult
+      return getVariablesResponse
+    }
+
+    const results = variableManager.getVariables(chargingStation, variableData)
     getVariablesResponse.getVariableResult = results
 
+    getVariablesResponse.getVariableResult = OCPP20ServiceUtils.enforcePostCalculationBytesLimit(
+      chargingStation,
+      moduleName,
+      'handleRequestGetVariables',
+      variableData,
+      results,
+      enforceBytesLimit,
+      (v, reason) => ({
+        attributeStatus: GetVariableStatusEnumType.Rejected,
+        attributeStatusInfo: {
+          additionalInfo: reason.info,
+
+          reasonCode: ReasonCodeEnumType[reason.reasonCode as keyof typeof ReasonCodeEnumType],
+        },
+        attributeType: v.attributeType,
+        component: v.component,
+        variable: v.variable,
+      }),
+      logger
+    ) as typeof getVariablesResponse.getVariableResult
+
     logger.debug(
       `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetVariables: Processed ${String(commandPayload.getVariableData.length)} variable requests, returning ${String(results.length)} results`
     )
@@ -152,6 +257,95 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     return getVariablesResponse
   }
 
+  public handleRequestSetVariables (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20SetVariablesRequest
+  ): OCPP20SetVariablesResponse {
+    const setVariablesResponse: OCPP20SetVariablesResponse = {
+      setVariableResult: [],
+    }
+
+    // Enforce ItemsPerMessageSetVariables and BytesPerMessageSetVariables limits if configured
+    let enforceItemsLimit = 0
+    let enforceBytesLimit = 0
+    try {
+      const itemsCfg = getConfigurationKey(
+        chargingStation,
+        OCPP20RequiredVariableName.ItemsPerMessage as unknown as StandardParametersKey
+      )?.value
+      const bytesCfg = getConfigurationKey(
+        chargingStation,
+        OCPP20RequiredVariableName.BytesPerMessage as unknown as StandardParametersKey
+      )?.value
+      if (itemsCfg && /^\d+$/.test(itemsCfg)) {
+        enforceItemsLimit = convertToIntOrNaN(itemsCfg)
+      }
+      if (bytesCfg && /^\d+$/.test(bytesCfg)) {
+        enforceBytesLimit = convertToIntOrNaN(bytesCfg)
+      }
+    } catch {
+      /* ignore */
+    }
+
+    const variableManager = OCPP20VariableManager.getInstance()
+
+    // Items per message enforcement
+    const variableData = commandPayload.setVariableData
+    const preEnforcement = OCPP20ServiceUtils.enforceMessageLimits(
+      chargingStation,
+      moduleName,
+      'handleRequestSetVariables',
+      variableData,
+      enforceItemsLimit,
+      enforceBytesLimit,
+      (v, reason) => ({
+        attributeStatus: SetVariableStatusEnumType.Rejected,
+        attributeStatusInfo: {
+          additionalInfo: reason.info,
+
+          reasonCode: ReasonCodeEnumType[reason.reasonCode as keyof typeof ReasonCodeEnumType],
+        },
+        attributeType: v.attributeType ?? AttributeEnumType.Actual,
+        component: v.component,
+        variable: v.variable,
+      }),
+      logger
+    )
+    if (preEnforcement.rejected) {
+      setVariablesResponse.setVariableResult =
+        preEnforcement.results as typeof setVariablesResponse.setVariableResult
+      return setVariablesResponse
+    }
+
+    const results = variableManager.setVariables(chargingStation, variableData)
+    setVariablesResponse.setVariableResult = OCPP20ServiceUtils.enforcePostCalculationBytesLimit(
+      chargingStation,
+      moduleName,
+      'handleRequestSetVariables',
+      variableData,
+      results,
+      enforceBytesLimit,
+      (v, reason) => ({
+        attributeStatus: SetVariableStatusEnumType.Rejected,
+        attributeStatusInfo: {
+          additionalInfo: reason.info,
+
+          reasonCode: ReasonCodeEnumType[reason.reasonCode as keyof typeof ReasonCodeEnumType],
+        },
+        attributeType: v.attributeType ?? AttributeEnumType.Actual,
+        component: v.component,
+        variable: v.variable,
+      }),
+      logger
+    ) as typeof setVariablesResponse.setVariableResult
+
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestSetVariables: Processed ${String(commandPayload.setVariableData.length)} variable requests, returning ${String(results.length)} results`
+    )
+
+    return setVariablesResponse
+  }
+
   // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
   public async incomingRequestHandler<ReqType extends JsonType, ResType extends JsonType>(
     chargingStation: ChargingStation,
@@ -189,7 +383,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       ) {
         try {
           this.validatePayload(chargingStation, commandName, commandPayload)
-          // Call the method to build the response
           // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
           const incomingRequestHandler = this.incomingRequestHandlers.get(commandName)!
           if (isAsyncFunction(incomingRequestHandler)) {
@@ -243,6 +436,18 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     }
   }
 
+  public override stop (chargingStation: ChargingStation): void {
+    try {
+      OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+      logger.debug(`${chargingStation.logPrefix()} ${moduleName}.stop: Runtime overrides cleared`)
+    } catch (error) {
+      logger.error(
+        `${chargingStation.logPrefix()} ${moduleName}.stop: Error clearing runtime overrides:`,
+        error
+      )
+    }
+  }
+
   private buildReportData (
     chargingStation: ChargingStation,
     reportBase: ReportBaseEnumType
@@ -341,12 +546,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
               type: AttributeEnumType.Actual as string,
               value: configKey.value,
             })
-            if (!configKey.readonly) {
-              variableAttributes.push({
-                type: AttributeEnumType.Target as string,
-                value: undefined,
-              })
-            }
 
             reportData.push({
               component: { name: OCPP20ComponentName.OCPPCommCtrlr },
@@ -360,7 +559,105 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           }
         }
 
-        // 3. EVSE and connector information
+        // 3. Registered OCPP 2.0.1 variables
+        try {
+          const variableManager = OCPP20VariableManager.getInstance()
+          // Build getVariableData array from VARIABLE_REGISTRY metadata
+          const getVariableData: OCPP20GetVariablesRequest['getVariableData'] = []
+          for (const variableMetadata of Object.values(VARIABLE_REGISTRY)) {
+            // Include instance-scoped metadata; the OCPP Variable type supports instance under variable
+            const variableDescriptor: { instance?: string; name: string } = {
+              name: variableMetadata.variable,
+            }
+            if (variableMetadata.instance) {
+              variableDescriptor.instance = variableMetadata.instance
+            }
+            // Always request Actual first
+            getVariableData.push({
+              attributeType: AttributeEnumType.Actual,
+              component: { name: variableMetadata.component },
+              variable: variableDescriptor,
+            })
+            // Request MinSet/MaxSet only if supported by metadata
+            if (variableMetadata.supportedAttributes.includes(AttributeEnumType.MinSet)) {
+              getVariableData.push({
+                attributeType: AttributeEnumType.MinSet,
+                component: { name: variableMetadata.component },
+                variable: variableDescriptor,
+              })
+            }
+            if (variableMetadata.supportedAttributes.includes(AttributeEnumType.MaxSet)) {
+              getVariableData.push({
+                attributeType: AttributeEnumType.MaxSet,
+                component: { name: variableMetadata.component },
+                variable: variableDescriptor,
+              })
+            }
+          }
+          const getResults = variableManager.getVariables(chargingStation, getVariableData)
+          // Group results by component+variable preserving attribute ordering Actual, MinSet, MaxSet
+          const grouped = new Map<
+            string,
+            {
+              attributes: { type: string; value?: string }[]
+              component: ReportDataType['component']
+              dataType: DataEnumType
+              variable: ReportDataType['variable']
+            }
+          >()
+          for (const r of getResults) {
+            const key = `${r.component.name}::${r.variable.name}${r.variable.instance ? '::' + r.variable.instance : ''}`
+            const variableMetadata = getVariableMetadata(
+              r.component.name,
+              r.variable.name,
+              r.variable.instance
+            )
+            if (!variableMetadata) continue
+            if (!grouped.has(key)) {
+              grouped.set(key, {
+                attributes: [],
+                component: r.component,
+                dataType: variableMetadata.dataType,
+                variable: r.variable,
+              })
+            }
+            if (r.attributeStatus === GetVariableStatusEnumType.Accepted) {
+              const entry = grouped.get(key)
+              if (entry) {
+                entry.attributes.push({ type: r.attributeType as string, value: r.attributeValue })
+              }
+            }
+          }
+          // Normalize attribute ordering
+          for (const entry of grouped.values()) {
+            entry.attributes.sort((a, b) => {
+              const order = [
+                AttributeEnumType.Actual,
+                AttributeEnumType.MinSet,
+                AttributeEnumType.MaxSet,
+              ]
+              return (
+                order.indexOf(a.type as AttributeEnumType) -
+                order.indexOf(b.type as AttributeEnumType)
+              )
+            })
+            if (entry.attributes.length > 0) {
+              reportData.push({
+                component: entry.component,
+                variable: entry.variable,
+                variableAttribute: entry.attributes,
+                variableCharacteristics: { dataType: entry.dataType, supportsMonitoring: false },
+              })
+            }
+          }
+        } catch (error) {
+          logger.error(
+            `${chargingStation.logPrefix()} ${moduleName}.buildReportData: Error enriching FullInventory with registry variables:`,
+            error
+          )
+        }
+
+        // 4. EVSE and connector information
         if (chargingStation.evses.size > 0) {
           for (const [evseId, evse] of chargingStation.evses) {
             reportData.push({
@@ -539,7 +836,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
     }
 
-    const reportData = this.buildReportData(chargingStation, commandPayload.reportBase)
+    // Cache report data for subsequent NotifyReport requests to avoid recomputation
+    const cached = this.reportDataCache.get(commandPayload.requestId)
+    const reportData = cached ?? this.buildReportData(chargingStation, commandPayload.reportBase)
+    if (!cached && reportData.length > 0) {
+      this.reportDataCache.set(commandPayload.requestId, reportData)
+    }
     if (reportData.length === 0) {
       logger.info(
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetBaseReport: No data available for reportBase ${commandPayload.reportBase}`
@@ -627,10 +929,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
             return {
               status: ResetStatusEnumType.Accepted,
-              statusInfo: {
-                additionalInfo: `EVSE ${evseId.toString()} reset initiated, active transaction will be terminated`,
-                reasonCode: ReasonCodeEnumType.NoError,
-              },
             }
           } else {
             // Reset EVSE immediately
@@ -642,10 +940,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
             return {
               status: ResetStatusEnumType.Accepted,
-              statusInfo: {
-                additionalInfo: `EVSE ${evseId.toString()} reset initiated`,
-                reasonCode: ReasonCodeEnumType.NoError,
-              },
             }
           }
         } else {
@@ -666,10 +960,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
             return {
               status: ResetStatusEnumType.Accepted,
-              statusInfo: {
-                additionalInfo: 'Immediate reset initiated, active transactions will be terminated',
-                reasonCode: ReasonCodeEnumType.NoError,
-              },
             }
           } else {
             logger.info(
@@ -703,10 +993,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
             return {
               status: ResetStatusEnumType.Scheduled,
-              statusInfo: {
-                additionalInfo: `EVSE ${evseId.toString()} reset scheduled after transaction completion`,
-                reasonCode: ReasonCodeEnumType.NoError,
-              },
             }
           } else {
             // No active transactions on EVSE, reset immediately
@@ -718,10 +1004,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
             return {
               status: ResetStatusEnumType.Accepted,
-              statusInfo: {
-                additionalInfo: `EVSE ${evseId.toString()} reset initiated`,
-                reasonCode: ReasonCodeEnumType.NoError,
-              },
             }
           }
         } else {
@@ -735,10 +1017,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
             return {
               status: ResetStatusEnumType.Scheduled,
-              statusInfo: {
-                additionalInfo: 'Reset scheduled after all transactions complete',
-                reasonCode: ReasonCodeEnumType.NoError,
-              },
             }
           } else {
             // No active transactions, reset immediately
@@ -854,7 +1132,9 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     response: OCPP20GetBaseReportResponse
   ): Promise<void> {
     const { reportBase, requestId } = request
-    const reportData = this.buildReportData(chargingStation, reportBase)
+    // Use cached report data if available (computed during GetBaseReport handling)
+    const cached = this.reportDataCache.get(requestId)
+    const reportData = cached ?? this.buildReportData(chargingStation, reportBase)
 
     // Fragment report data if needed (OCPP2 spec recommends max 100 items per message)
     const maxItemsPerMessage = 100
@@ -897,6 +1177,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
       `${chargingStation.logPrefix()} ${moduleName}.sendNotifyReportRequest: Completed NotifyReport for requestId ${requestId} with ${reportData.length} total items in ${chunks.length} message(s)`
     )
+    // Clear cache for requestId after successful completion
+    this.reportDataCache.delete(requestId)
   }
 
   private validatePayload (
index aa947ae53365b4c2fe5fedcf0f738b4c202505b1..424d675841231c694db3d64ee1c3ffa73c9ab8a8 100644 (file)
@@ -6,6 +6,82 @@ import { type JsonType, OCPPVersion } from '../../../types/index.js'
 import { OCPPServiceUtils } from '../OCPPServiceUtils.js'
 
 export class OCPP20ServiceUtils extends OCPPServiceUtils {
+  public static enforceMessageLimits<
+    T extends { attributeType?: unknown; component: unknown; variable: unknown }
+  >(
+    chargingStation: { logPrefix: () => string },
+    moduleName: string,
+    context: string,
+    data: T[],
+    itemsLimit: number,
+    bytesLimit: number,
+    buildRejected: (item: T, reason: { info: string; reasonCode: string }) => unknown,
+    logger: { debug: (...args: unknown[]) => void }
+  ): { rejected: boolean; results: unknown[] } {
+    if (itemsLimit > 0 && data.length > itemsLimit) {
+      const results = data.map(d =>
+        buildRejected(d, {
+          info: `ItemsPerMessage limit ${itemsLimit.toString()} exceeded (${data.length.toString()} requested)`,
+          reasonCode: 'TooManyElements',
+        })
+      )
+      logger.debug(
+        `${chargingStation.logPrefix()} ${moduleName}.${context}: Rejected all variables due to ItemsPerMessage limit (${itemsLimit.toString()})`
+      )
+      return { rejected: true, results }
+    }
+    if (bytesLimit > 0) {
+      const estimatedSize = Buffer.byteLength(JSON.stringify(data), 'utf8')
+      if (estimatedSize > bytesLimit) {
+        const results = data.map(d =>
+          buildRejected(d, {
+            info: `BytesPerMessage limit ${bytesLimit.toString()} exceeded (estimated ${estimatedSize.toString()} bytes)`,
+            reasonCode: 'TooLargeElement',
+          })
+        )
+        logger.debug(
+          `${chargingStation.logPrefix()} ${moduleName}.${context}: Rejected all variables due to BytesPerMessage limit (${bytesLimit.toString()})`
+        )
+        return { rejected: true, results }
+      }
+    }
+    return { rejected: false, results: [] }
+  }
+
+  public static enforcePostCalculationBytesLimit<
+    T extends { attributeType?: unknown; component: unknown; variable: unknown }
+  >(
+    chargingStation: { logPrefix: () => string },
+    moduleName: string,
+    context: string,
+    originalData: T[],
+    currentResults: unknown[],
+    bytesLimit: number,
+    buildRejected: (item: T, reason: { info: string; reasonCode: string }) => unknown,
+    logger: { debug: (...args: unknown[]) => void }
+  ): unknown[] {
+    if (bytesLimit > 0) {
+      try {
+        const actualSize = Buffer.byteLength(JSON.stringify(currentResults), 'utf8')
+        if (actualSize > bytesLimit) {
+          const results = originalData.map(d =>
+            buildRejected(d, {
+              info: `BytesPerMessage limit ${bytesLimit.toString()} exceeded (actual ${actualSize.toString()} bytes)`,
+              reasonCode: 'TooLargeElement',
+            })
+          )
+          logger.debug(
+            `${chargingStation.logPrefix()} ${moduleName}.${context}: Rejected all variables due to BytesPerMessage limit post calculation (${bytesLimit.toString()})`
+          )
+          return results
+        }
+      } catch {
+        /* ignore */
+      }
+    }
+    return currentResults
+  }
+
   public static override parseJsonSchemaFile<T extends JsonType>(
     relativePath: string,
     moduleName?: string,
index 7f923b2d4649574aba1545a80c39558049419e54..3c4883d58d40e373bc295071b9740c84939197f9 100644 (file)
@@ -5,6 +5,7 @@ import { millisecondsToSeconds } from 'date-fns'
 import {
   AttributeEnumType,
   type ComponentType,
+  DataEnumType,
   GetVariableStatusEnumType,
   MutabilityEnumType,
   OCPP20ComponentName,
@@ -12,33 +13,58 @@ import {
   type OCPP20GetVariableResultType,
   OCPP20OptionalVariableName,
   OCPP20RequiredVariableName,
+  type OCPP20SetVariableDataType,
+  type OCPP20SetVariableResultType,
+  PersistenceEnumType,
   ReasonCodeEnumType,
+  SetVariableStatusEnumType,
   type VariableType,
 } from '../../../types/index.js'
-import { Constants, logger } from '../../../utils/index.js'
+import { StandardParametersKey } from '../../../types/ocpp/Configuration.js'
+import { Constants, convertToIntOrNaN, 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
+import {
+  addConfigurationKey,
+  getConfigurationKey,
+  setConfigurationKeyValue,
+} from '../../ConfigurationKeyUtils.js'
+import {
+  applyPostProcess,
+  buildCaseInsensitiveCompositeKey,
+  enforceReportingValueSize,
+  getVariableMetadata,
+  resolveValue,
+  validateValue,
+  VARIABLE_REGISTRY,
+  type VariableMetadata,
+} from './OCPP20VariableRegistry.js'
+
+const isOCPP20ComponentName = (name: string): name is OCPP20ComponentName => {
+  return Object.values(OCPP20ComponentName).includes(name as OCPP20ComponentName)
+}
+const isOCPP20RequiredVariableName = (name: string): name is OCPP20RequiredVariableName => {
+  return Object.values(OCPP20RequiredVariableName).includes(name as OCPP20RequiredVariableName)
 }
 
-/**
- * Centralized manager for OCPP 2.0 variables handling.
- * Manages standard variables and provides unified access to variable data.
- */
+const shouldFlattenInstance = (variableMetadata: VariableMetadata): boolean => {
+  // TODO: Generalize instance flattening via registry metadata
+  return variableMetadata.variable === (OCPP20RequiredVariableName.MessageAttemptInterval as string)
+}
+const computeConfigurationKeyName = (variableMetadata: VariableMetadata): string => {
+  return variableMetadata.instance != null && !shouldFlattenInstance(variableMetadata)
+    ? `${variableMetadata.variable}.${variableMetadata.instance}`
+    : variableMetadata.variable
+}
 export class OCPP20VariableManager {
   private static instance: null | OCPP20VariableManager = null
 
-  private readonly standardVariables = new Map<string, StandardVariableConfig>()
+  private readonly invalidVariables = new Set<string>() // composite key (lower case)
+  private readonly maxSetOverrides = new Map<string, string>() // composite key (lower case)
+  private readonly minSetOverrides = new Map<string, string>() // composite key (lower case)
+  private readonly runtimeOverrides = new Map<string, string>() // composite key (lower case)
 
   private constructor () {
-    this.initializeStandardVariables()
+    /* This is intentional */
   }
 
   public static getInstance (): OCPP20VariableManager {
@@ -46,18 +72,12 @@ export class 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[] {
+    this.validatePersistentMappings(chargingStation)
     const results: OCPP20GetVariableResultType[] = []
-
     for (const variableData of getVariableData) {
       try {
         const result = this.getVariable(chargingStation, variableData)
@@ -69,303 +89,841 @@ export class OCPP20VariableManager {
         )
         results.push({
           attributeStatus: GetVariableStatusEnumType.Rejected,
-          attributeType: variableData.attributeType,
-          component: variableData.component,
-          statusInfo: {
+          attributeStatusInfo: {
             additionalInfo: 'Internal error occurred while retrieving variable',
             reasonCode: ReasonCodeEnumType.InternalError,
           },
+          attributeType: variableData.attributeType,
+          component: variableData.component,
           variable: variableData.variable,
         })
       }
     }
+    return results
+  }
+
+  public resetRuntimeOverrides (): void {
+    this.runtimeOverrides.clear()
+  }
 
+  public setVariables (
+    chargingStation: ChargingStation,
+    setVariableData: OCPP20SetVariableDataType[]
+  ): OCPP20SetVariableResultType[] {
+    this.validatePersistentMappings(chargingStation)
+    const results: OCPP20SetVariableResultType[] = []
+    for (const variableData of setVariableData) {
+      try {
+        const result = this.setVariable(chargingStation, variableData)
+        results.push(result)
+      } catch (error) {
+        logger.error(
+          `${chargingStation.logPrefix()} Error setting variable ${variableData.variable.name}:`,
+          error
+        )
+        results.push({
+          attributeStatus: SetVariableStatusEnumType.Rejected,
+          attributeStatusInfo: {
+            additionalInfo: 'Internal error occurred while setting variable',
+            reasonCode: ReasonCodeEnumType.InternalError,
+          },
+          attributeType: variableData.attributeType ?? AttributeEnumType.Actual,
+          component: variableData.component,
+          variable: variableData.variable,
+        })
+      }
+    }
     return results
   }
 
-  /**
-   * Get a single variable
-   * @param chargingStation - The charging station instance
-   * @param variableData - Variable data to retrieve
-   * @returns Variable result
-   */
+  public validatePersistentMappings (chargingStation: ChargingStation): void {
+    this.invalidVariables.clear()
+    for (const metaKey of Object.keys(VARIABLE_REGISTRY)) {
+      const variableMetadata = VARIABLE_REGISTRY[metaKey]
+      // Enforce persistent non-write-only variables across components
+      if (variableMetadata.persistence !== PersistenceEnumType.Persistent) {
+        continue
+      }
+      if (variableMetadata.mutability === MutabilityEnumType.WriteOnly) {
+        continue
+      }
+      // Instance-scoped persistent variables are also auto-created when defaultValue is defined
+      const configurationKeyName = computeConfigurationKeyName(variableMetadata)
+      const configurationKey = getConfigurationKey(
+        chargingStation,
+        configurationKeyName as unknown as StandardParametersKey
+      )
+      const variableKey = buildCaseInsensitiveCompositeKey(
+        variableMetadata.component,
+        variableMetadata.instance,
+        variableMetadata.variable
+      )
+      if (configurationKey == null) {
+        // Allow size limit variables to remain intentionally unset.
+        if (
+          variableMetadata.variable ===
+            (OCPP20RequiredVariableName.ConfigurationValueSize as string) ||
+          variableMetadata.variable === (OCPP20RequiredVariableName.ValueSize as string) ||
+          variableMetadata.variable === (OCPP20RequiredVariableName.ReportingValueSize as string)
+        ) {
+          continue
+        }
+        // Skip auto-creation for instance-scoped persistent variables (e.g. MessageAttemptInterval)
+        // so that first getVariables call returns default without persisting; persistence occurs on first successful set.
+        if (variableMetadata.instance != null) {
+          continue
+        }
+        const defaultValue = variableMetadata.defaultValue
+        if (defaultValue != null) {
+          addConfigurationKey(
+            chargingStation,
+            configurationKeyName as unknown as StandardParametersKey,
+            defaultValue,
+            undefined,
+            { overwrite: false }
+          )
+          logger.info(
+            `${chargingStation.logPrefix()} Added missing configuration key for variable '${configurationKeyName}' with default '${defaultValue}'`
+          )
+        } else {
+          // Mark invalid
+          this.invalidVariables.add(variableKey)
+          logger.error(
+            `${chargingStation.logPrefix()} Missing configuration key mapping and no default for variable '${configurationKeyName}'`
+          )
+        }
+      }
+    }
+  }
+
   private getVariable (
     chargingStation: ChargingStation,
     variableData: OCPP20GetVariableDataType
   ): OCPP20GetVariableResultType {
     const { attributeType, component, variable } = variableData
+    const requestedAttributeType = attributeType
+    const resolvedAttributeType = requestedAttributeType ?? AttributeEnumType.Actual
 
-    // 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: ReasonCodeEnumType.NotFound,
-        },
-        attributeType,
+      return this.rejectGet(
+        variable,
         component,
+        requestedAttributeType,
+        GetVariableStatusEnumType.UnknownComponent,
+        ReasonCodeEnumType.NotFound,
+        `Component ${component.name} is not supported by this charging station`
+      )
+    }
+
+    if (!this.isVariableSupported(component, variable)) {
+      return this.rejectGet(
         variable,
-      }
+        component,
+        requestedAttributeType,
+        GetVariableStatusEnumType.UnknownVariable,
+        ReasonCodeEnumType.NotFound,
+        `Variable ${variable.name} is not supported for component ${component.name}`
+      )
+    }
+
+    const variableMetadata = getVariableMetadata(
+      component.name,
+      variable.name,
+      variable.instance ?? component.instance
+    )
+    if (
+      variableMetadata?.mutability === MutabilityEnumType.WriteOnly &&
+      resolvedAttributeType === AttributeEnumType.Actual
+    ) {
+      return this.rejectGet(
+        variable,
+        component,
+        resolvedAttributeType,
+        GetVariableStatusEnumType.Rejected,
+        ReasonCodeEnumType.WriteOnly,
+        `Variable ${variable.name} is write-only and cannot be retrieved`
+      )
+    }
+    if (!variableMetadata?.supportedAttributes.includes(resolvedAttributeType)) {
+      return this.rejectGet(
+        variable,
+        component,
+        resolvedAttributeType,
+        GetVariableStatusEnumType.NotSupportedAttributeType,
+        ReasonCodeEnumType.UnsupportedParam,
+        `Attribute type ${resolvedAttributeType} is not supported for variable ${variable.name}`
+      )
     }
 
-    // Check if variable exists
-    if (!this.isVariableSupported(chargingStation, component, variable)) {
+    const variableKey = buildCaseInsensitiveCompositeKey(
+      component.name,
+      component.instance,
+      variable.name
+    )
+    if (this.invalidVariables.has(variableKey)) {
+      return this.rejectGet(
+        variable,
+        component,
+        resolvedAttributeType,
+        GetVariableStatusEnumType.Rejected,
+        ReasonCodeEnumType.InternalError,
+        'Variable mapping invalid (startup self-check failed)'
+      )
+    }
+
+    // Handle MinSet / MaxSet attribute retrieval
+    if (resolvedAttributeType === AttributeEnumType.MinSet) {
+      if (variableMetadata.min === undefined && this.minSetOverrides.get(variableKey) == null) {
+        return this.rejectGet(
+          variable,
+          component,
+          resolvedAttributeType,
+          GetVariableStatusEnumType.NotSupportedAttributeType,
+          ReasonCodeEnumType.UnsupportedParam,
+          `Attribute type ${resolvedAttributeType} is not supported for variable ${variable.name}`
+        )
+      }
+      const minValue =
+        this.minSetOverrides.get(variableKey) ??
+        (variableMetadata.min !== undefined ? String(variableMetadata.min) : '')
       return {
-        attributeStatus: GetVariableStatusEnumType.UnknownVariable,
-        attributeStatusInfo: {
-          additionalInfo: `Variable ${variable.name} is not supported for component ${component.name}`,
-          reasonCode: ReasonCodeEnumType.NotFound,
-        },
-        attributeType,
+        attributeStatus: GetVariableStatusEnumType.Accepted,
+        attributeType: resolvedAttributeType,
+        attributeValue: minValue,
         component,
         variable,
       }
     }
-
-    // Check if attribute type is supported
-    if (attributeType && !this.isAttributeTypeSupported(variable, attributeType)) {
+    if (resolvedAttributeType === AttributeEnumType.MaxSet) {
+      if (variableMetadata.max === undefined && this.maxSetOverrides.get(variableKey) == null) {
+        return this.rejectGet(
+          variable,
+          component,
+          resolvedAttributeType,
+          GetVariableStatusEnumType.NotSupportedAttributeType,
+          ReasonCodeEnumType.UnsupportedParam,
+          `Attribute type ${resolvedAttributeType} is not supported for variable ${variable.name}`
+        )
+      }
+      const maxValue =
+        this.maxSetOverrides.get(variableKey) ??
+        (variableMetadata.max !== undefined ? String(variableMetadata.max) : '')
       return {
-        attributeStatus: GetVariableStatusEnumType.NotSupportedAttributeType,
-        attributeStatusInfo: {
-          additionalInfo: `Attribute type ${attributeType} is not supported for variable ${variable.name}`,
-          reasonCode: ReasonCodeEnumType.UnsupportedParam,
-        },
-        attributeType,
+        attributeStatus: GetVariableStatusEnumType.Accepted,
+        attributeType: resolvedAttributeType,
+        attributeValue: maxValue,
         component,
         variable,
       }
     }
 
-    // Get the variable value
-    const variableValue = this.getVariableValue(chargingStation, component, variable, attributeType)
+    let variableValue = this.resolveVariableValue(chargingStation, component, variable)
+
+    if (variableValue.length === 0) {
+      if (
+        resolvedAttributeType === AttributeEnumType.Target &&
+        variableMetadata.supportsTarget === true
+      ) {
+        // Accept empty Target value when target is unset (B06.FR.13)
+        return {
+          attributeStatus: GetVariableStatusEnumType.Accepted,
+          attributeType: resolvedAttributeType,
+          attributeValue: '',
+          component,
+          variable,
+        }
+      }
+      return this.rejectGet(
+        variable,
+        component,
+        resolvedAttributeType,
+        GetVariableStatusEnumType.Rejected,
+        ReasonCodeEnumType.InvalidValue,
+        'Resolved variable value is empty'
+      )
+    }
+
+    // ReportingValueSize truncation (DeviceDataCtrlr authoritative)
+    const reportingValueSizeKey = buildCaseInsensitiveCompositeKey(
+      OCPP20ComponentName.DeviceDataCtrlr as string,
+      undefined,
+      OCPP20RequiredVariableName.ReportingValueSize as string
+    )
+    // ValueSize truncation applied before ReportingValueSize if present
+    const valueSizeKey = buildCaseInsensitiveCompositeKey(
+      OCPP20ComponentName.DeviceDataCtrlr as string,
+      undefined,
+      OCPP20RequiredVariableName.ValueSize as string
+    )
+    let valueSize: string | undefined
+    let reportingValueSize: string | undefined
+    if (!this.invalidVariables.has(valueSizeKey)) {
+      valueSize = getConfigurationKey(
+        chargingStation,
+        OCPP20RequiredVariableName.ValueSize as unknown as StandardParametersKey
+      )?.value
+    }
+    if (!this.invalidVariables.has(reportingValueSizeKey)) {
+      reportingValueSize = getConfigurationKey(
+        chargingStation,
+        OCPP20RequiredVariableName.ReportingValueSize as unknown as StandardParametersKey
+      )?.value
+    }
+    // Apply ValueSize first then ReportingValueSize
+    if (valueSize) {
+      variableValue = enforceReportingValueSize(variableValue, valueSize)
+    }
+    if (reportingValueSize) {
+      variableValue = enforceReportingValueSize(variableValue, reportingValueSize)
+    }
 
+    // Final absolute length enforcement (spec maxLength Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH)
+    if (variableValue.length > Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH) {
+      variableValue = variableValue.slice(0, Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH)
+    }
     return {
       attributeStatus: GetVariableStatusEnumType.Accepted,
-      attributeType,
+      attributeType: resolvedAttributeType,
       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,
+  private isComponentValid (_chargingStation: ChargingStation, component: ComponentType): boolean {
+    const supported = new Set<string>([
+      OCPP20ComponentName.AuthCtrlr as string,
+      OCPP20ComponentName.ChargingStation as string,
+      OCPP20ComponentName.ClockCtrlr as string,
+      OCPP20ComponentName.DeviceDataCtrlr as string,
+      OCPP20ComponentName.OCPPCommCtrlr as string,
+      OCPP20ComponentName.SampledDataCtrlr as string,
+      OCPP20ComponentName.SecurityCtrlr as string,
+      OCPP20ComponentName.TxCtrlr as string,
+    ])
+    return supported.has(component.name)
+  }
+
+  private isVariableSupported (component: ComponentType, variable: VariableType): boolean {
+    return (
+      getVariableMetadata(component.name, variable.name, variable.instance ?? component.instance) !=
+        null || getVariableMetadata(component.name, variable.name) != null
+    )
+  }
+
+  private rejectGet (
+    variable: VariableType,
     component: ComponentType,
+    attributeType: AttributeEnumType | undefined,
+    status: GetVariableStatusEnumType,
+    reason: ReasonCodeEnumType,
+    info: string
+  ): OCPP20GetVariableResultType {
+    const truncatedInfo = info.length > 50 ? info.slice(0, 50) : info
+    return {
+      attributeStatus: status,
+      attributeStatusInfo: {
+        additionalInfo: truncatedInfo,
+        reasonCode: reason,
+      },
+      attributeType: attributeType ?? AttributeEnumType.Actual,
+      component,
+      variable,
+    }
+  }
+
+  private rejectSet (
     variable: VariableType,
-    attributeType?: AttributeEnumType
+    component: ComponentType,
+    attributeType: AttributeEnumType,
+    status: SetVariableStatusEnumType,
+    reason: ReasonCodeEnumType,
+    info: string
+  ): OCPP20SetVariableResultType {
+    const truncatedInfo = info.length > 50 ? info.slice(0, 50) : info
+    return {
+      attributeStatus: status,
+      attributeStatusInfo: {
+        additionalInfo: truncatedInfo,
+        reasonCode: reason,
+      },
+      attributeType,
+      component,
+      variable,
+    }
+  }
+
+  private resolveVariableValue (
+    chargingStation: ChargingStation,
+    component: ComponentType,
+    variable: VariableType
   ): string {
-    const variableName = variable.name
-    const componentName = component.name
+    const variableMetadata = getVariableMetadata(
+      component.name,
+      variable.name,
+      variable.instance ?? component.instance
+    )
+    if (!variableMetadata) return ''
 
-    // Handle standard ChargingStation variables
-    if (componentName === (OCPP20ComponentName.ChargingStation as string)) {
-      if (variableName === (OCPP20OptionalVariableName.HeartbeatInterval as string)) {
-        return millisecondsToSeconds(chargingStation.getHeartbeatInterval()).toString()
-      }
+    const compositeKey = buildCaseInsensitiveCompositeKey(
+      component.name,
+      component.instance,
+      variable.name
+    )
 
-      if (variableName === (OCPP20OptionalVariableName.WebSocketPingInterval as string)) {
-        return chargingStation.getWebSocketPingInterval().toString()
-      }
+    let value = resolveValue(chargingStation, variableMetadata)
 
-      if (variableName === (OCPP20RequiredVariableName.EVConnectionTimeOut as string)) {
-        return Constants.DEFAULT_EV_CONNECTION_TIMEOUT.toString()
+    if (
+      variableMetadata.persistence === PersistenceEnumType.Persistent &&
+      variableMetadata.mutability !== MutabilityEnumType.WriteOnly
+    ) {
+      const configurationKeyName = computeConfigurationKeyName(variableMetadata)
+      const cfg = getConfigurationKey(
+        chargingStation,
+        configurationKeyName as unknown as StandardParametersKey
+      )
+      if (cfg?.value) {
+        value = cfg.value
       }
+    }
 
-      if (variableName === (OCPP20RequiredVariableName.MessageTimeout as string)) {
-        return chargingStation.getConnectionTimeout().toString()
+    if (
+      variableMetadata.persistence === PersistenceEnumType.Volatile &&
+      variableMetadata.mutability !== MutabilityEnumType.ReadOnly
+    ) {
+      const override = this.runtimeOverrides.get(compositeKey)
+      if (override != null) {
+        value = override
       }
+    }
+
+    if (
+      variableMetadata.variable === (OCPP20OptionalVariableName.HeartbeatInterval as string) &&
+      !value
+    ) {
+      value = millisecondsToSeconds(chargingStation.getHeartbeatInterval()).toString()
+    }
+    if (
+      variableMetadata.variable === (OCPP20OptionalVariableName.WebSocketPingInterval as string) &&
+      !value
+    ) {
+      value = chargingStation.getWebSocketPingInterval().toString()
+    }
+    if (
+      variableMetadata.variable === (OCPP20RequiredVariableName.TxUpdatedInterval as string) &&
+      !value
+    ) {
+      value = Constants.DEFAULT_TX_UPDATED_INTERVAL.toString()
+    }
+
+    value = applyPostProcess(chargingStation, variableMetadata, value)
+    return value
+  }
+
+  private setVariable (
+    chargingStation: ChargingStation,
+    variableData: OCPP20SetVariableDataType
+  ): OCPP20SetVariableResultType {
+    const { attributeType, attributeValue, component, variable } = variableData
+    const resolvedAttributeType = attributeType ?? AttributeEnumType.Actual
 
-      // Try to get from OCPP configuration
-      const configKey = chargingStation.ocppConfiguration?.configurationKey?.find(
-        key => key.key === variableName
+    if (!this.isComponentValid(chargingStation, component)) {
+      return this.rejectSet(
+        variable,
+        component,
+        resolvedAttributeType,
+        SetVariableStatusEnumType.UnknownComponent,
+        ReasonCodeEnumType.NotFound,
+        `Component ${component.name} is not supported by this charging station`
+      )
+    }
+    if (!this.isVariableSupported(component, variable)) {
+      return this.rejectSet(
+        variable,
+        component,
+        resolvedAttributeType,
+        SetVariableStatusEnumType.UnknownVariable,
+        ReasonCodeEnumType.NotFound,
+        `Variable ${variable.name} is not supported for component ${component.name}`
       )
-      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)
+    const variableMetadata = getVariableMetadata(
+      component.name,
+      variable.name,
+      variable.instance ?? component.instance
+    )
+    if (!variableMetadata?.supportedAttributes.includes(resolvedAttributeType)) {
+      return this.rejectSet(
+        variable,
+        component,
+        resolvedAttributeType,
+        SetVariableStatusEnumType.NotSupportedAttributeType,
+        ReasonCodeEnumType.UnsupportedParam,
+        `Attribute type ${resolvedAttributeType} is not supported for variable ${variable.name}`
+      )
+    }
 
-      if (connector) {
-        // Add connector-specific variable handling here
-        switch (variableName) {
-          // Add connector variables as needed
-          default:
-            return ''
-        }
+    const variableKey = buildCaseInsensitiveCompositeKey(
+      component.name,
+      component.instance,
+      variable.name
+    )
+    if (
+      this.invalidVariables.has(variableKey) &&
+      resolvedAttributeType === AttributeEnumType.Actual
+    ) {
+      if (variableMetadata.mutability !== MutabilityEnumType.WriteOnly) {
+        return this.rejectSet(
+          variable,
+          component,
+          resolvedAttributeType,
+          SetVariableStatusEnumType.Rejected,
+          ReasonCodeEnumType.InternalError,
+          'Variable mapping invalid (startup self-check failed)'
+        )
+      } else {
+        this.invalidVariables.delete(variableKey)
       }
     }
 
-    // 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 ''
+    // Handle MinSet / MaxSet attribute setting (allowed even if Actual is ReadOnly)
+    if (
+      resolvedAttributeType === AttributeEnumType.MinSet ||
+      resolvedAttributeType === AttributeEnumType.MaxSet
+    ) {
+      // Only meaningful for integer data type
+      if (variableMetadata.dataType !== DataEnumType.integer) {
+        return this.rejectSet(
+          variable,
+          component,
+          resolvedAttributeType,
+          SetVariableStatusEnumType.Rejected,
+          ReasonCodeEnumType.InvalidValue,
+          'MinSet/MaxSet only valid for integer data type'
+        )
+      }
+      const signedIntegerPattern = /^-?\d+$/
+      if (!signedIntegerPattern.test(attributeValue)) {
+        if (/^-?\d+\.\d+$/.test(attributeValue)) {
+          return this.rejectSet(
+            variable,
+            component,
+            resolvedAttributeType,
+            SetVariableStatusEnumType.Rejected,
+            ReasonCodeEnumType.InvalidValue,
+            'Integer must not be decimal'
+          )
         }
+        return this.rejectSet(
+          variable,
+          component,
+          resolvedAttributeType,
+          SetVariableStatusEnumType.Rejected,
+          ReasonCodeEnumType.InvalidValue,
+          'Integer required for MinSet/MaxSet'
+        )
+      }
+      const intValue = convertToIntOrNaN(attributeValue)
+      if (Number.isNaN(intValue)) {
+        return this.rejectSet(
+          variable,
+          component,
+          resolvedAttributeType,
+          SetVariableStatusEnumType.Rejected,
+          ReasonCodeEnumType.InvalidValue,
+          'Integer required for MinSet/MaxSet'
+        )
+      }
+      if (variableMetadata.min != null && intValue < variableMetadata.min) {
+        return this.rejectSet(
+          variable,
+          component,
+          resolvedAttributeType,
+          SetVariableStatusEnumType.Rejected,
+          ReasonCodeEnumType.ValueTooLow,
+          'Value below metadata minimum'
+        )
+      }
+      if (variableMetadata.max != null && intValue > variableMetadata.max) {
+        return this.rejectSet(
+          variable,
+          component,
+          resolvedAttributeType,
+          SetVariableStatusEnumType.Rejected,
+          ReasonCodeEnumType.ValueTooHigh,
+          'Value above metadata maximum'
+        )
+      }
+      if (resolvedAttributeType === AttributeEnumType.MinSet) {
+        const currentMax =
+          this.maxSetOverrides.get(variableKey) ??
+          (variableMetadata.max !== undefined ? String(variableMetadata.max) : undefined)
+        if (currentMax != null && intValue > convertToIntOrNaN(currentMax)) {
+          return this.rejectSet(
+            variable,
+            component,
+            resolvedAttributeType,
+            SetVariableStatusEnumType.Rejected,
+            ReasonCodeEnumType.InvalidValue,
+            'MinSet higher than MaxSet'
+          )
+        }
+        this.minSetOverrides.set(variableKey, attributeValue)
+      } else {
+        const currentMin =
+          this.minSetOverrides.get(variableKey) ??
+          (variableMetadata.min !== undefined ? String(variableMetadata.min) : undefined)
+        if (currentMin != null && intValue < convertToIntOrNaN(currentMin)) {
+          return this.rejectSet(
+            variable,
+            component,
+            resolvedAttributeType,
+            SetVariableStatusEnumType.Rejected,
+            ReasonCodeEnumType.InvalidValue,
+            'MaxSet lower than MinSet'
+          )
+        }
+        this.maxSetOverrides.set(variableKey, attributeValue)
+      }
+      return {
+        attributeStatus: SetVariableStatusEnumType.Accepted,
+        attributeType: resolvedAttributeType,
+        component,
+        variable,
       }
     }
 
-    return ''
-  }
+    // Actual attribute setting logic
+    if (variableMetadata.mutability === MutabilityEnumType.ReadOnly) {
+      return this.rejectSet(
+        variable,
+        component,
+        resolvedAttributeType,
+        SetVariableStatusEnumType.Rejected,
+        ReasonCodeEnumType.ReadOnly,
+        `Variable ${variable.name} is read-only`
+      )
+    }
 
-  /**
-   * 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,
+    // Enforce ConfigurationValueSize and ValueSize limits (only at set time).
+    // Effective limit selection rules (spec-aligned):
+    // 1. Read ConfigurationValueSize and ValueSize if present and valid (>0).
+    // 2. If both valid, use the smaller positive value.
+    // 3. If only one valid, use that value.
+    // 4. If neither valid/positive, fallback to spec maxLength (Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH).
+    // 5. Enforce absolute upper cap of Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH (spec).
+    // 6. Reject with TooLargeElement when attributeValue length strictly exceeds effectiveLimit.
+    if (resolvedAttributeType === AttributeEnumType.Actual) {
+      const configurationValueSizeKey = buildCaseInsensitiveCompositeKey(
+        OCPP20ComponentName.DeviceDataCtrlr as string,
+        undefined,
+        OCPP20RequiredVariableName.ConfigurationValueSize as string
+      )
+      const valueSizeKey = buildCaseInsensitiveCompositeKey(
+        OCPP20ComponentName.DeviceDataCtrlr as string,
+        undefined,
+        OCPP20RequiredVariableName.ValueSize as string
+      )
+      let configurationValueSizeRaw: string | undefined
+      let valueSizeRaw: string | undefined
+      if (!this.invalidVariables.has(configurationValueSizeKey)) {
+        configurationValueSizeRaw = getConfigurationKey(
+          chargingStation,
+          OCPP20RequiredVariableName.ConfigurationValueSize as unknown as StandardParametersKey
+        )?.value
       }
-    )
-
-    this.standardVariables.set(
-      `${OCPP20ComponentName.ChargingStation}.${OCPP20OptionalVariableName.WebSocketPingInterval}`,
-      {
-        attributeTypes: [AttributeEnumType.Actual, AttributeEnumType.Target],
-        defaultValue: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL.toString(),
-        mutability: MutabilityEnumType.ReadWrite,
-        persistent: true,
+      if (!this.invalidVariables.has(valueSizeKey)) {
+        valueSizeRaw = getConfigurationKey(
+          chargingStation,
+          OCPP20RequiredVariableName.ValueSize as unknown as StandardParametersKey
+        )?.value
       }
-    )
-
-    this.standardVariables.set(
-      `${OCPP20ComponentName.ChargingStation}.${OCPP20RequiredVariableName.EVConnectionTimeOut}`,
-      {
-        attributeTypes: [AttributeEnumType.Actual, AttributeEnumType.Target],
-        defaultValue: Constants.DEFAULT_EV_CONNECTION_TIMEOUT.toString(),
-        mutability: MutabilityEnumType.ReadWrite,
-        persistent: true,
+      const cfgLimit = convertToIntOrNaN(configurationValueSizeRaw ?? '')
+      const valLimit = convertToIntOrNaN(valueSizeRaw ?? '')
+      let effectiveLimit: number | undefined
+      if (!Number.isNaN(cfgLimit) && cfgLimit > 0) {
+        effectiveLimit = cfgLimit
       }
-    )
-
-    this.standardVariables.set(
-      `${OCPP20ComponentName.ChargingStation}.${OCPP20RequiredVariableName.MessageTimeout}`,
-      {
-        attributeTypes: [AttributeEnumType.Actual, AttributeEnumType.Target],
-        defaultValue: Constants.DEFAULT_CONNECTION_TIMEOUT.toString(),
-        mutability: MutabilityEnumType.ReadWrite,
-        persistent: true,
+      if (!Number.isNaN(valLimit) && valLimit > 0) {
+        effectiveLimit = effectiveLimit != null ? Math.min(effectiveLimit, valLimit) : valLimit
+      }
+      if (effectiveLimit == null || effectiveLimit <= 0) {
+        effectiveLimit = Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH
+      }
+      if (effectiveLimit > Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH) {
+        effectiveLimit = Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH
+      }
+      if (attributeValue.length > effectiveLimit) {
+        return this.rejectSet(
+          variable,
+          component,
+          resolvedAttributeType,
+          SetVariableStatusEnumType.Rejected,
+          ReasonCodeEnumType.TooLargeElement,
+          `Value length exceeds effective size limit (${effectiveLimit.toString()})`
+        )
       }
-    )
-
-    // 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
+    // Narrow component.name and variable.name for enum-safe comparison
     if (
-      componentName === (OCPP20ComponentName.Connector as string) &&
-      chargingStation.connectors.size > 0
+      isOCPP20ComponentName(component.name) &&
+      component.name === OCPP20ComponentName.AuthCtrlr &&
+      isOCPP20RequiredVariableName(variable.name) &&
+      variable.name === OCPP20RequiredVariableName.AuthorizeRemoteStart
     ) {
-      // Check if specific connector instance exists
-      if (component.instance != null) {
-        const connectorId = parseInt(component.instance, 10)
-        return chargingStation.connectors.has(connectorId)
+      if (attributeValue !== 'true' && attributeValue !== 'false') {
+        return this.rejectSet(
+          variable,
+          component,
+          resolvedAttributeType,
+          SetVariableStatusEnumType.Rejected,
+          ReasonCodeEnumType.InvalidValue,
+          'AuthorizeRemoteStart must be "true" or "false"'
+        )
       }
-      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)
+    } else {
+      const validation = validateValue(variableMetadata, attributeValue)
+      if (!validation.ok) {
+        return this.rejectSet(
+          variable,
+          component,
+          resolvedAttributeType,
+          SetVariableStatusEnumType.Rejected,
+          validation.reason ?? ReasonCodeEnumType.InvalidValue,
+          validation.info ?? 'Invalid value'
+        )
+      }
+      // Enforce dynamic MinSet/MaxSet overrides for integer values
+      if (variableMetadata.dataType === DataEnumType.integer) {
+        const num = convertToIntOrNaN(attributeValue)
+        if (!Number.isNaN(num)) {
+          const overrideMinRaw = this.minSetOverrides.get(variableKey)
+          const overrideMaxRaw = this.maxSetOverrides.get(variableKey)
+          if (overrideMinRaw != null) {
+            const overrideMin = convertToIntOrNaN(overrideMinRaw)
+            if (!Number.isNaN(overrideMin) && num < overrideMin) {
+              return this.rejectSet(
+                variable,
+                component,
+                resolvedAttributeType,
+                SetVariableStatusEnumType.Rejected,
+                ReasonCodeEnumType.ValueTooLow,
+                'Value below MinSet override'
+              )
+            }
+          }
+          if (overrideMaxRaw != null) {
+            const overrideMax = convertToIntOrNaN(overrideMaxRaw)
+            if (!Number.isNaN(overrideMax) && num > overrideMax) {
+              return this.rejectSet(
+                variable,
+                component,
+                resolvedAttributeType,
+                SetVariableStatusEnumType.Rejected,
+                ReasonCodeEnumType.ValueTooHigh,
+                'Value above MaxSet override'
+              )
+            }
+          }
+        }
       }
-      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}`
+    let rebootRequired = false
+    const configurationKeyName = computeConfigurationKeyName(variableMetadata)
+    const previousValue = getConfigurationKey(
+      chargingStation,
+      configurationKeyName as unknown as StandardParametersKey
+    )?.value
 
-    // Check standard variables
-    if (this.standardVariables.has(variableKey)) {
-      return true
+    // Generalized persistence for persistent, non write-only variables (including instance-scoped)
+    if (
+      variableMetadata.persistence === PersistenceEnumType.Persistent &&
+      variableMetadata.mutability !== MutabilityEnumType.WriteOnly
+    ) {
+      // Special-case: OrganizationName persistence limitation (do not update stored value once created)
+      const isOrganizationName =
+        variableMetadata.component === (OCPP20ComponentName.SecurityCtrlr as string) &&
+        variableMetadata.variable === (OCPP20RequiredVariableName.OrganizationName as string)
+
+      if (!isOrganizationName) {
+        let configKey = getConfigurationKey(
+          chargingStation,
+          configurationKeyName as unknown as StandardParametersKey
+        )
+        if (configKey == null) {
+          addConfigurationKey(
+            chargingStation,
+            configurationKeyName as unknown as StandardParametersKey,
+            attributeValue,
+            undefined,
+            {
+              overwrite: false,
+            }
+          )
+          configKey = getConfigurationKey(
+            chargingStation,
+            configurationKeyName as unknown as StandardParametersKey
+          )
+        } else if (configKey.value !== attributeValue) {
+          setConfigurationKeyValue(
+            chargingStation,
+            configurationKeyName as unknown as StandardParametersKey,
+            attributeValue
+          )
+        }
+        rebootRequired =
+          (variableMetadata.rebootRequired === true ||
+            getConfigurationKey(
+              chargingStation,
+              configurationKeyName as unknown as StandardParametersKey
+            )?.reboot === true) &&
+          previousValue !== attributeValue
+      } else {
+        // OrganizationName: accept set but do not persist new value (tests expect default retained)
+        rebootRequired = false
+      }
+    }
+    // Heartbeat & WS ping interval dynamic restarts
+    if (
+      variable.name === (OCPP20OptionalVariableName.HeartbeatInterval as string) &&
+      !Number.isNaN(convertToIntOrNaN(attributeValue)) &&
+      convertToIntOrNaN(attributeValue) > 0
+    ) {
+      chargingStation.restartHeartbeat()
+    }
+    if (
+      variable.name === (OCPP20OptionalVariableName.WebSocketPingInterval as string) &&
+      !Number.isNaN(convertToIntOrNaN(attributeValue)) &&
+      convertToIntOrNaN(attributeValue) >= 0
+    ) {
+      chargingStation.restartWebSocketPing()
+    }
+    // Apply volatile runtime override generically (single location)
+    if (variableMetadata.persistence === PersistenceEnumType.Volatile) {
+      this.runtimeOverrides.set(variableKey, attributeValue)
     }
 
-    // Check known optional and required variables
-    const knownVariables = [
-      ...Object.values(OCPP20OptionalVariableName),
-      ...Object.values(OCPP20RequiredVariableName),
-    ]
+    if (rebootRequired) {
+      return {
+        attributeStatus: SetVariableStatusEnumType.RebootRequired,
+        attributeStatusInfo: {
+          additionalInfo: 'Value changed, reboot required to take effect',
+          reasonCode: ReasonCodeEnumType.NoError,
+        },
+        attributeType: resolvedAttributeType,
+        component,
+        variable,
+      }
+    }
 
-    return knownVariables.includes(
-      variable.name as OCPP20OptionalVariableName | OCPP20RequiredVariableName
-    )
+    return {
+      attributeStatus: SetVariableStatusEnumType.Accepted,
+      attributeType: resolvedAttributeType,
+      component,
+      variable,
+    }
   }
 }
diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts
new file mode 100644 (file)
index 0000000..d0dec09
--- /dev/null
@@ -0,0 +1,1251 @@
+import { millisecondsToSeconds } from 'date-fns'
+
+import type { ChargingStation } from '../../ChargingStation.js'
+
+import {
+  AttributeEnumType,
+  DataEnumType,
+  MutabilityEnumType,
+  OCPP20ComponentName,
+  OCPP20DeviceInfoVariableName,
+  OCPP20OptionalVariableName,
+  OCPP20RequiredVariableName,
+  OCPP20VendorVariableName,
+  PersistenceEnumType,
+  ReasonCodeEnumType,
+} from '../../../types/index.js'
+import { Constants, convertToIntOrNaN, has } from '../../../utils/index.js'
+
+/**
+ * Metadata describing a variable (component-level configuration or runtime state).
+ *
+ * Field notes:
+ * - component: OCPP 2.0.1 Component name (registry key part)
+ * - variable: Variable name (registry key part)
+ * - instance: Optional instance qualifier (registry key part)
+ * - mutability: ReadOnly | ReadWrite | WriteOnly (affects Get/SetVariables behavior)
+ * - persistence: Persistent values survive restart; Volatile resolved dynamically or reset
+ * - dataType: OCPP DataEnumType classification (string, integer, decimal, boolean, dateTime, list types)
+ * - defaultValue: Used when no persistent value stored and no dynamicValueResolver provided
+ * - dynamicValueResolver: Function returning a fresh value each resolution (overrides defaultValue)
+ * - enumeration: Allowed discrete values for scalar types or list members (validated centrally)
+ * - maxLength: Character length constraint applied before dataType-specific parsing
+ * - min/max: Numeric bounds for integer/decimal
+ * - positive: Enforces > 0 (combined with allowZero)
+ * - allowZero: Permit zero when positive not set
+ * - characteristics: Subset of OCPP characteristics currently modelled (maxLimit/minLimit/supportsMonitoring)
+ * - supportedAttributes: Which OCPP attributes (Actual, Target, etc.) are supported
+ * - supportsTarget: Allows Target attribute writes where applicable
+ * - unit: Informational; not validated
+ * - postProcess: Final transform applied on successful validation before persistence
+ * - rebootRequired: Indicates changes require reboot (returned via SetVariablesResult)
+ * - vendorSpecific: True when variable is not defined by core specification
+ * - urlSchemes: (Deprecated usage) Optional list of allowed URL schemes including trailing colon, e.g. ['ws:', 'wss:'].
+ *               If present, scheme-restricted URL validation is enforced.
+ * - isUrl: When true (and urlSchemes absent) apply generic URL format validation only (any scheme allowed).
+ *          Introduced to relax overly restrictive scheme lists for vendor variables like ConnectionUrl.
+ */
+export interface VariableMetadata {
+  allowZero?: boolean
+  characteristics?: { maxLimit?: number; minLimit?: number; supportsMonitoring?: boolean }
+  component: string
+  dataType: DataEnumType
+  defaultValue?: string
+  description?: string
+  dynamicValueResolver?: (ctx: { chargingStation: ChargingStation }) => string
+  enumeration?: string[]
+  instance?: string
+  isUrl?: boolean
+  max?: number
+  maxLength?: number
+  min?: number
+  mutability: MutabilityEnumType
+  persistence: PersistenceEnumType
+  positive?: boolean
+  postProcess?: (value: string, ctx: { chargingStation: ChargingStation }) => string
+  rebootRequired?: boolean
+  supportedAttributes: AttributeEnumType[]
+  supportsTarget?: boolean
+  unit?: string
+  urlSchemes?: string[]
+  variable: string
+  vendorSpecific?: boolean
+}
+
+/**
+ * KEY SCHEMES
+ * 1. Primary registry key (internal map key): `${component}[.<instance>]::${variable}` (case sensitive)
+ *    - Built with buildRegistryKey().
+ * 2. Case-insensitive composite key (lookup convenience): `${component}[.<instance>].${variable}` all lower case
+ *    - Built with buildCaseInsensitiveCompositeKey().
+ * Rationale: Maintain original case for canonical metadata storage while offering tolerant lookups.
+ * @param component Component name.
+ * @param variable Variable name.
+ * @param instance Optional instance qualifier.
+ * @returns Primary registry key string.
+ */
+function buildRegistryKey (component: string, variable: string, instance?: string): string {
+  return `${component}${instance ? '.' + instance : ''}::${variable}`
+}
+
+// Hoisted regex patterns (avoid recreation per validation call)
+const DECIMAL_PATTERN = /^-?\d+(?:\.\d+)?$/
+const SIGNED_INTEGER_PATTERN = /^-?\d+$/
+const DECIMAL_ONLY_PATTERN = /^-?\d+\.\d+$/
+
+// Spec references policy:
+// - CSV (dm_components_vars.csv) is the canonical source for standard variables.
+// - Only add rationale comments where simulator intentionally restricts or extends (e.g. enumeration trimming, volatile choice).
+// - Avoid verbose line or row numbers; keep comments concise.
+export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
+  // AuthCtrlr variables
+  [buildRegistryKey(
+    OCPP20ComponentName.AuthCtrlr as string,
+    OCPP20RequiredVariableName.AuthorizeRemoteStart
+  )]: {
+    component: OCPP20ComponentName.AuthCtrlr as string,
+    dataType: DataEnumType.boolean,
+    defaultValue: 'true',
+    description: 'Whether remote start requires authorization.',
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Volatile,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.AuthorizeRemoteStart as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.AuthCtrlr as string,
+    OCPP20RequiredVariableName.LocalAuthorizeOffline
+  )]: {
+    component: OCPP20ComponentName.AuthCtrlr as string,
+    dataType: DataEnumType.boolean,
+    defaultValue: 'true',
+    description: 'Start transaction offline for locally authorized identifiers.',
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.LocalAuthorizeOffline as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.AuthCtrlr as string,
+    OCPP20RequiredVariableName.LocalPreAuthorize
+  )]: {
+    component: OCPP20ComponentName.AuthCtrlr as string,
+    dataType: DataEnumType.boolean,
+    defaultValue: 'false',
+    description: 'Start transaction locally without waiting for CSMS authorization.',
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.LocalPreAuthorize as string,
+  },
+
+  [buildRegistryKey(OCPP20ComponentName.ChargingStation as string, 'Available')]: {
+    component: OCPP20ComponentName.ChargingStation as string,
+    dataType: DataEnumType.boolean,
+    defaultValue: 'true',
+    description: 'Component exists (ChargingStation level).',
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: 'Available',
+  },
+  [buildRegistryKey(OCPP20ComponentName.ChargingStation as string, 'SupplyPhases')]: {
+    component: OCPP20ComponentName.ChargingStation as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '3',
+    description: 'Number of alternating current phases connected/available.',
+    max: 3,
+    min: 1,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: 'SupplyPhases',
+  },
+  // ChargingStation variables
+  [buildRegistryKey(
+    OCPP20ComponentName.ChargingStation as string,
+    OCPP20DeviceInfoVariableName.AvailabilityState
+  )]: {
+    component: OCPP20ComponentName.ChargingStation as string,
+    dataType: DataEnumType.OptionList,
+    description: 'Current availability state for the ChargingStation.',
+    enumeration: ['Operative', 'Inoperative'],
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20DeviceInfoVariableName.AvailabilityState as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.ChargingStation as string,
+    OCPP20OptionalVariableName.WebSocketPingInterval
+  )]: {
+    allowZero: true,
+    component: OCPP20ComponentName.ChargingStation as string,
+    dataType: DataEnumType.integer,
+    defaultValue: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL.toString(),
+    description:
+      'Interval in seconds between WebSocket ping (keep-alive) frames. 0 disables pings.',
+    max: 3600,
+    maxLength: 10,
+    min: 0,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'seconds',
+    variable: OCPP20OptionalVariableName.WebSocketPingInterval as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.ChargingStation as string,
+    OCPP20VendorVariableName.ConnectionUrl
+  )]: {
+    component: OCPP20ComponentName.ChargingStation as string,
+    dataType: DataEnumType.string,
+    defaultValue: 'ws://localhost',
+    description: 'Central system connection URL.',
+    isUrl: true,
+    maxLength: 512,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20VendorVariableName.ConnectionUrl as string,
+    vendorSpecific: true,
+  },
+
+  // ClockCtrlr variables
+  [buildRegistryKey(OCPP20ComponentName.ClockCtrlr as string, OCPP20RequiredVariableName.DateTime)]:
+    {
+      component: OCPP20ComponentName.ClockCtrlr as string,
+      dataType: DataEnumType.dateTime,
+      description: 'Contains the current date and time (ClockCtrlr).',
+      dynamicValueResolver: () => new Date().toISOString(),
+      mutability: MutabilityEnumType.ReadOnly,
+      persistence: PersistenceEnumType.Volatile,
+      supportedAttributes: [AttributeEnumType.Actual],
+      variable: OCPP20RequiredVariableName.DateTime as string,
+    },
+  [buildRegistryKey(
+    OCPP20ComponentName.ClockCtrlr as string,
+    OCPP20RequiredVariableName.TimeSource
+  )]: {
+    component: OCPP20ComponentName.ClockCtrlr as string,
+    dataType: DataEnumType.SequenceList,
+    defaultValue: 'NTP,GPS,RTC',
+    description: 'Ordered list of clock sources by preference.',
+    enumeration: ['NTP', 'GPS', 'RTC', 'Manual'],
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.TimeSource as string,
+  },
+
+  // DeviceDataCtrlr variables
+  [buildRegistryKey(
+    OCPP20ComponentName.DeviceDataCtrlr as string,
+    OCPP20RequiredVariableName.BytesPerMessage
+  )]: {
+    component: OCPP20ComponentName.DeviceDataCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '8192',
+    description: 'Maximum number of bytes in a message.',
+    max: 65535,
+    min: 1,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.BytesPerMessage as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.DeviceDataCtrlr as string,
+    OCPP20RequiredVariableName.BytesPerMessage,
+    'GetReport'
+  )]: {
+    component: OCPP20ComponentName.DeviceDataCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '8192',
+    description: 'Maximum number of bytes in a GetReport message.',
+    instance: 'GetReport',
+    max: 65535,
+    min: 1,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.BytesPerMessage as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.DeviceDataCtrlr as string,
+    OCPP20RequiredVariableName.BytesPerMessage,
+    'GetVariables'
+  )]: {
+    component: OCPP20ComponentName.DeviceDataCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '8192',
+    description: 'Maximum number of bytes in a GetVariables message.',
+    instance: 'GetVariables',
+    max: 65535,
+    min: 1,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.BytesPerMessage as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.DeviceDataCtrlr as string,
+    OCPP20RequiredVariableName.BytesPerMessage,
+    'SetVariables'
+  )]: {
+    component: OCPP20ComponentName.DeviceDataCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '8192',
+    description: 'Maximum number of bytes in a SetVariables message.',
+    instance: 'SetVariables',
+    max: 65535,
+    min: 1,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.BytesPerMessage as string,
+  },
+  // Value size family: ValueSize (broadest), ConfigurationValueSize (affects setting), ReportingValueSize (affects reporting). Simulator sets same absolute cap; truncate occurs at reporting step.
+  [buildRegistryKey(
+    OCPP20ComponentName.DeviceDataCtrlr as string,
+    OCPP20RequiredVariableName.ConfigurationValueSize
+  )]: {
+    component: OCPP20ComponentName.DeviceDataCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH.toString(),
+    description: 'Maximum size allowed for configuration values when setting.',
+    max: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH,
+    maxLength: 5,
+    min: 1,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'chars',
+    variable: OCPP20RequiredVariableName.ConfigurationValueSize as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.DeviceDataCtrlr as string,
+    OCPP20RequiredVariableName.ItemsPerMessage,
+    'GetReport'
+  )]: {
+    component: OCPP20ComponentName.DeviceDataCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '32',
+    description: 'Maximum ComponentVariable entries in a GetReport message.',
+    instance: 'GetReport',
+    max: 256,
+    min: 1,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.ItemsPerMessage as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.DeviceDataCtrlr as string,
+    OCPP20RequiredVariableName.ItemsPerMessage,
+    'GetVariables'
+  )]: {
+    component: OCPP20ComponentName.DeviceDataCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '32',
+    description: 'Maximum ComponentVariable entries in a GetVariables message.',
+    instance: 'GetVariables',
+    max: 256,
+    min: 1,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.ItemsPerMessage as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.DeviceDataCtrlr as string,
+    OCPP20RequiredVariableName.ItemsPerMessage,
+    'SetVariables'
+  )]: {
+    component: OCPP20ComponentName.DeviceDataCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '32',
+    description: 'Maximum ComponentVariable entries in a SetVariables message.',
+    instance: 'SetVariables',
+    max: 256,
+    min: 1,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.ItemsPerMessage as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.DeviceDataCtrlr as string,
+    OCPP20RequiredVariableName.ReportingValueSize
+  )]: {
+    component: OCPP20ComponentName.DeviceDataCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH.toString(),
+    description: 'Maximum size of reported values.',
+    max: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH,
+    maxLength: 5,
+    min: 1,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'chars',
+    variable: OCPP20RequiredVariableName.ReportingValueSize as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.DeviceDataCtrlr as string,
+    OCPP20RequiredVariableName.ValueSize
+  )]: {
+    component: OCPP20ComponentName.DeviceDataCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH.toString(),
+    description: 'Unified maximum size for any stored or reported value.',
+    max: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH,
+    maxLength: 5,
+    min: 1,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'chars',
+    variable: OCPP20RequiredVariableName.ValueSize as string,
+  },
+
+  // OCPPCommCtrlr variables
+  [buildRegistryKey(
+    OCPP20ComponentName.OCPPCommCtrlr as string,
+    OCPP20OptionalVariableName.HeartbeatInterval
+  )]: {
+    component: OCPP20ComponentName.OCPPCommCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL).toString(),
+    description: 'Interval between Heartbeat messages.',
+    max: 86400,
+    maxLength: 10,
+    min: 1,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'seconds',
+    variable: OCPP20OptionalVariableName.HeartbeatInterval as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.OCPPCommCtrlr as string,
+    OCPP20RequiredVariableName.FileTransferProtocols
+  )]: {
+    component: OCPP20ComponentName.OCPPCommCtrlr as string,
+    dataType: DataEnumType.MemberList,
+    defaultValue: 'HTTP',
+    description: 'Supported file transfer protocols.',
+    enumeration: ['HTTP', 'HTTPS', 'FTP', 'SFTP'],
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.FileTransferProtocols as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.OCPPCommCtrlr as string,
+    OCPP20RequiredVariableName.MessageAttemptInterval,
+    'TransactionEvent'
+  )]: {
+    component: OCPP20ComponentName.OCPPCommCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '5',
+    description: 'Interval (seconds) between retry attempts for TransactionEvent messages.',
+    instance: 'TransactionEvent',
+    max: 3600,
+    min: 1,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'seconds',
+    variable: OCPP20RequiredVariableName.MessageAttemptInterval as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.OCPPCommCtrlr as string,
+    OCPP20RequiredVariableName.MessageAttempts,
+    'TransactionEvent'
+  )]: {
+    component: OCPP20ComponentName.OCPPCommCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '3',
+    description: 'Maximum number of TransactionEvent message attempts after initial send.',
+    instance: 'TransactionEvent',
+    max: 10,
+    min: 1,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.MessageAttempts as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.OCPPCommCtrlr as string,
+    OCPP20RequiredVariableName.MessageTimeout,
+    'Default'
+  )]: {
+    component: OCPP20ComponentName.OCPPCommCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: Constants.DEFAULT_CONNECTION_TIMEOUT.toString(),
+    description: 'Timeout (in seconds) waiting for responses to general OCPP messages.',
+    instance: 'Default',
+    max: 3600,
+    min: 1,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'seconds',
+    variable: OCPP20RequiredVariableName.MessageTimeout as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.OCPPCommCtrlr as string,
+    OCPP20RequiredVariableName.NetworkConfigurationPriority
+  )]: {
+    component: OCPP20ComponentName.OCPPCommCtrlr as string,
+    dataType: DataEnumType.SequenceList,
+    defaultValue: '',
+    description: 'Comma separated ordered list of network profile priorities.',
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.NetworkConfigurationPriority as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.OCPPCommCtrlr as string,
+    OCPP20RequiredVariableName.NetworkProfileConnectionAttempts
+  )]: {
+    component: OCPP20ComponentName.OCPPCommCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '3',
+    description: 'Connection attempts before switching profile.',
+    max: 100,
+    min: 1,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.NetworkProfileConnectionAttempts as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.OCPPCommCtrlr as string,
+    OCPP20RequiredVariableName.OfflineThreshold
+  )]: {
+    component: OCPP20ComponentName.OCPPCommCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '300',
+    description: 'Offline duration threshold for status refresh.',
+    max: 86400,
+    min: 1,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'seconds',
+    variable: OCPP20RequiredVariableName.OfflineThreshold as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.OCPPCommCtrlr as string,
+    OCPP20RequiredVariableName.ResetRetries
+  )]: {
+    allowZero: true,
+    component: OCPP20ComponentName.OCPPCommCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '2',
+    description: 'Number of times to retry a reset.',
+    max: 10,
+    min: 0,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.ResetRetries as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.OCPPCommCtrlr as string,
+    OCPP20RequiredVariableName.UnlockOnEVSideDisconnect
+  )]: {
+    component: OCPP20ComponentName.OCPPCommCtrlr as string,
+    dataType: DataEnumType.boolean,
+    defaultValue: 'true',
+    description: 'Unlock cable when unplugged at EV side.',
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.UnlockOnEVSideDisconnect as string,
+  },
+
+  // SampledDataCtrlr variables (simulation measurands)
+  [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr as string, 'Current.Import')]: {
+    component: OCPP20ComponentName.SampledDataCtrlr as string,
+    dataType: DataEnumType.decimal,
+    description: 'Instantaneous import current (A).',
+    dynamicValueResolver: () => '0',
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Volatile,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'A',
+    variable: 'Current.Import',
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.SampledDataCtrlr as string,
+    'Energy.Active.Import.Register'
+  )]: {
+    component: OCPP20ComponentName.SampledDataCtrlr as string,
+    dataType: DataEnumType.decimal,
+    description: 'Cumulative active energy imported (Wh).',
+    dynamicValueResolver: () => '0',
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Volatile,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'Wh',
+    variable: 'Energy.Active.Import.Register',
+  },
+  [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr as string, 'Power.Active.Import')]: {
+    component: OCPP20ComponentName.SampledDataCtrlr as string,
+    dataType: DataEnumType.decimal,
+    description: 'Instantaneous active power import (W).',
+    dynamicValueResolver: () => '0',
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Volatile,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'W',
+    variable: 'Power.Active.Import',
+  },
+  [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr as string, 'Voltage')]: {
+    component: OCPP20ComponentName.SampledDataCtrlr as string,
+    dataType: DataEnumType.decimal,
+    description: 'RMS voltage (V).',
+    dynamicValueResolver: () => '230',
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Volatile,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'V',
+    variable: 'Voltage',
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.SampledDataCtrlr as string,
+    OCPP20RequiredVariableName.TxEndedMeasurands
+  )]: {
+    component: OCPP20ComponentName.SampledDataCtrlr as string,
+    dataType: DataEnumType.MemberList,
+    defaultValue: 'Energy.Active.Import.Register,Current.Import',
+    description: 'Measurands sampled at transaction end.',
+    enumeration: [
+      'Energy.Active.Import.Register',
+      'Energy.Active.Import.Interval',
+      'Energy.Active.Export.Register',
+      'Power.Active.Import',
+      'Power.Active.Export',
+      'Power.Reactive.Import',
+      'Power.Reactive.Export',
+      'Power.Offered',
+      'Current.Import',
+      'Current.Export',
+      'Voltage',
+      'Frequency',
+      'Temperature',
+      'SoC',
+      'RPM',
+      'Power.Factor',
+    ],
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.TxEndedMeasurands as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.SampledDataCtrlr as string,
+    OCPP20RequiredVariableName.TxStartedMeasurands
+  )]: {
+    component: OCPP20ComponentName.SampledDataCtrlr as string,
+    dataType: DataEnumType.MemberList,
+    defaultValue: 'Energy.Active.Import.Register,Power.Active.Import',
+    description: 'Measurands sampled at transaction start.',
+    enumeration: [
+      'Energy.Active.Import.Register',
+      'Energy.Active.Export.Register',
+      'Power.Active.Import',
+      'Power.Active.Export',
+      'Current.Import',
+      'Voltage',
+      'SoC',
+    ],
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.TxStartedMeasurands as string,
+  },
+  // Volatile rationale: sampling interval affects runtime only; simulator does not persist across restarts.
+  [buildRegistryKey(
+    OCPP20ComponentName.SampledDataCtrlr as string,
+    OCPP20RequiredVariableName.TxUpdatedInterval
+  )]: {
+    component: OCPP20ComponentName.SampledDataCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: Constants.DEFAULT_TX_UPDATED_INTERVAL.toString(),
+    description:
+      'Interval between sampling of metering data for Updated TransactionEvent messages.',
+    max: 3600,
+    min: 1,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Volatile,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'seconds',
+    variable: OCPP20RequiredVariableName.TxUpdatedInterval as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.SampledDataCtrlr as string,
+    OCPP20RequiredVariableName.TxUpdatedMeasurands
+  )]: {
+    component: OCPP20ComponentName.SampledDataCtrlr as string,
+    dataType: DataEnumType.MemberList,
+    defaultValue: 'Energy.Active.Import.Register',
+    description: 'Measurands included in periodic updates.',
+    enumeration: [
+      'Energy.Active.Import.Register',
+      'Energy.Active.Export.Register',
+      'Power.Active.Import',
+      'Power.Active.Export',
+      'Current.Import',
+      'Voltage',
+      'SoC',
+    ],
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.TxUpdatedMeasurands as string,
+  },
+
+  // SecurityCtrlr variables
+  [buildRegistryKey(
+    OCPP20ComponentName.SecurityCtrlr as string,
+    OCPP20RequiredVariableName.CertificateEntries
+  )]: {
+    allowZero: true,
+    component: OCPP20ComponentName.SecurityCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '0',
+    description: 'Count of installed certificates.',
+    min: 0,
+    mutability: MutabilityEnumType.ReadOnly,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.CertificateEntries as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.SecurityCtrlr as string,
+    OCPP20RequiredVariableName.OrganizationName
+  )]: {
+    component: OCPP20ComponentName.SecurityCtrlr as string,
+    dataType: DataEnumType.string,
+    defaultValue: 'ChangeMeOrg',
+    description: 'Organization name for client certificate subject.',
+    maxLength: 128,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    rebootRequired: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.OrganizationName as string,
+  },
+  // Enumeration limited to profiles 1..3 commonly used; spec allows additional profiles via extensions.
+  [buildRegistryKey(
+    OCPP20ComponentName.SecurityCtrlr as string,
+    OCPP20RequiredVariableName.SecurityProfile
+  )]: {
+    component: OCPP20ComponentName.SecurityCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: '1',
+    description: 'Selected security profile.',
+    enumeration: ['1', '2', '3'],
+    max: 3,
+    maxLength: 1,
+    min: 1,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    rebootRequired: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.SecurityProfile as string,
+  },
+  // Vendor-specific write-only placeholder to exercise WriteOnly path.
+  [buildRegistryKey(
+    OCPP20ComponentName.SecurityCtrlr as string,
+    OCPP20VendorVariableName.CertificatePrivateKey
+  )]: {
+    component: OCPP20ComponentName.SecurityCtrlr as string,
+    dataType: DataEnumType.string,
+    description: 'Private key material upload placeholder; write-only for security.',
+    maxLength: 2048,
+    mutability: MutabilityEnumType.WriteOnly,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20VendorVariableName.CertificatePrivateKey as string,
+    vendorSpecific: true,
+  },
+
+  // TxCtrlr variables
+  [buildRegistryKey(
+    OCPP20ComponentName.TxCtrlr as string,
+    OCPP20RequiredVariableName.EVConnectionTimeOut
+  )]: {
+    component: OCPP20ComponentName.TxCtrlr as string,
+    dataType: DataEnumType.integer,
+    defaultValue: Constants.DEFAULT_EV_CONNECTION_TIMEOUT.toString(),
+    description: 'Timeout for EV to establish connection.',
+    max: 3600,
+    maxLength: 10,
+    min: 1,
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    positive: true,
+    supportedAttributes: [AttributeEnumType.Actual],
+    unit: 'seconds',
+    variable: OCPP20RequiredVariableName.EVConnectionTimeOut as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.TxCtrlr as string,
+    OCPP20RequiredVariableName.StopTxOnEVSideDisconnect
+  )]: {
+    component: OCPP20ComponentName.TxCtrlr as string,
+    dataType: DataEnumType.boolean,
+    defaultValue: 'true',
+    description: 'Deauthorize transaction when cable unplugged at EV.',
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.StopTxOnEVSideDisconnect as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.TxCtrlr as string,
+    OCPP20RequiredVariableName.StopTxOnInvalidId
+  )]: {
+    component: OCPP20ComponentName.TxCtrlr as string,
+    dataType: DataEnumType.boolean,
+    defaultValue: 'true',
+    description: 'Deauthorize transaction on invalid id token status.',
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.StopTxOnInvalidId as string,
+  },
+  [buildRegistryKey(
+    OCPP20ComponentName.TxCtrlr as string,
+    OCPP20RequiredVariableName.TxStartPoint
+  )]: {
+    component: OCPP20ComponentName.TxCtrlr as string,
+    dataType: DataEnumType.MemberList,
+    defaultValue: 'CablePluggedIn,EnergyTransfer',
+    description: 'Trigger conditions for starting a transaction.',
+    enumeration: ['CablePluggedIn', 'EnergyTransfer', 'Authorized', 'PowerPathClosed'],
+    mutability: MutabilityEnumType.ReadWrite,
+    persistence: PersistenceEnumType.Persistent,
+    supportedAttributes: [AttributeEnumType.Actual],
+    variable: OCPP20RequiredVariableName.TxStartPoint as string,
+  },
+  [buildRegistryKey(OCPP20ComponentName.TxCtrlr as string, OCPP20RequiredVariableName.TxStopPoint)]:
+    {
+      component: OCPP20ComponentName.TxCtrlr as string,
+      dataType: DataEnumType.MemberList,
+      defaultValue: 'EVSEIdle,CableUnplugged',
+      description: 'Trigger conditions for ending a transaction.',
+      enumeration: ['EVSEIdle', 'CableUnplugged', 'Deauthorized', 'PowerPathOpened'],
+      mutability: MutabilityEnumType.ReadWrite,
+      persistence: PersistenceEnumType.Persistent,
+      supportedAttributes: [AttributeEnumType.Actual],
+      variable: OCPP20RequiredVariableName.TxStopPoint as string,
+    },
+}
+
+/**
+ * Build composite lookup key (lower-cased) including optional instance.
+ * Format: `component[.instance].variable` all lower case.
+ * @param component Component name.
+ * @param instance Optional instance qualifier.
+ * @param variable Variable name.
+ * @returns Lower-case composite key for lookup.
+ */
+export function buildCaseInsensitiveCompositeKey (
+  component: string,
+  instance: string | undefined,
+  variable: string
+): string {
+  return `${component.toLowerCase()}${instance ? '.' + instance : ''}.${variable.toLowerCase()}`
+}
+
+// Lowercase fallback registry (composite key) for case-insensitive lookups.
+const VARIABLE_REGISTRY_LOOKUP_CI: Record<string, VariableMetadata> = Object.values(
+  VARIABLE_REGISTRY
+).reduce<Record<string, VariableMetadata>>((acc, vm) => {
+  acc[buildCaseInsensitiveCompositeKey(vm.component, vm.instance, vm.variable)] = vm
+  return acc
+}, {})
+
+/**
+ * Apply optional metadata post-processing to a resolved variable value.
+ * @param chargingStation Charging station context.
+ * @param variableMetadata Variable metadata entry.
+ * @param value Resolved raw value.
+ * @returns Post-processed value (or original when no postProcess defined).
+ */
+export function applyPostProcess (
+  chargingStation: ChargingStation,
+  variableMetadata: VariableMetadata,
+  value: string
+): string {
+  if (variableMetadata.postProcess) {
+    return variableMetadata.postProcess(value, { chargingStation })
+  }
+  return value
+}
+
+/**
+ * Enforce reporting/value size limit on a string.
+ * @param value Incoming value string.
+ * @param sizeLimitRaw Raw size limit value (string form).
+ * @returns Possibly truncated value respecting size limit.
+ */
+export function enforceReportingValueSize (value: string, sizeLimitRaw: string): string {
+  const sizeLimit = convertToIntOrNaN(sizeLimitRaw)
+  if (!Number.isNaN(sizeLimit) && sizeLimit > 0 && value.length > sizeLimit) {
+    return value.slice(0, sizeLimit)
+  }
+  return value
+}
+
+/**
+ * Retrieve variable metadata with case-insensitive fallback.
+ * @param component Component name.
+ * @param variable Variable name.
+ * @param instance Optional instance qualifier.
+ * @returns Matching variable metadata or undefined.
+ */
+export function getVariableMetadata (
+  component: string,
+  variable: string,
+  instance?: string
+): undefined | VariableMetadata {
+  const withInstanceKey = buildRegistryKey(component, variable, instance)
+  if (has(withInstanceKey, VARIABLE_REGISTRY)) {
+    return VARIABLE_REGISTRY[withInstanceKey]
+  }
+  const withoutInstanceKey = buildRegistryKey(component, variable)
+  if (has(withoutInstanceKey, VARIABLE_REGISTRY)) {
+    return VARIABLE_REGISTRY[withoutInstanceKey]
+  }
+  const lcWithKey = buildCaseInsensitiveCompositeKey(component, instance, variable)
+  if (has(lcWithKey, VARIABLE_REGISTRY_LOOKUP_CI)) {
+    return VARIABLE_REGISTRY_LOOKUP_CI[lcWithKey]
+  }
+  return VARIABLE_REGISTRY_LOOKUP_CI[
+    buildCaseInsensitiveCompositeKey(component, undefined, variable)
+  ] as undefined | VariableMetadata
+}
+
+/**
+ * Check if variable metadata is persistent.
+ * @param variableMetadata Variable metadata entry.
+ * @returns True when persistence is Persistent.
+ */
+export function isPersistent (variableMetadata: VariableMetadata): boolean {
+  return variableMetadata.persistence === PersistenceEnumType.Persistent
+}
+
+/**
+ * Check if variable metadata is read-only.
+ * @param variableMetadata Variable metadata entry.
+ * @returns True when mutability is ReadOnly.
+ */
+export function isReadOnly (variableMetadata: VariableMetadata): boolean {
+  return variableMetadata.mutability === MutabilityEnumType.ReadOnly
+}
+
+/**
+ * Check if variable metadata is write-only.
+ * @param variableMetadata Variable metadata entry.
+ * @returns True when mutability is WriteOnly.
+ */
+export function isWriteOnly (variableMetadata: VariableMetadata): boolean {
+  return variableMetadata.mutability === MutabilityEnumType.WriteOnly
+}
+
+/**
+ * Resolve variable value using dynamicValueResolver if present else defaultValue.
+ * @param chargingStation Charging station context.
+ * @param variableMetadata Variable metadata entry.
+ * @returns Resolved value string (empty when no default).
+ */
+export function resolveValue (
+  chargingStation: ChargingStation,
+  variableMetadata: VariableMetadata
+): string {
+  if (variableMetadata.dynamicValueResolver) {
+    return variableMetadata.dynamicValueResolver({ chargingStation })
+  }
+  return variableMetadata.defaultValue ?? ''
+}
+
+/**
+ * Validate raw value against variable metadata constraints.
+ * Performs length, datatype specific and enumeration checks.
+ * @param variableMetadata Variable metadata entry.
+ * @param rawValue Raw value string to validate.
+ * @returns Validation result with ok flag and optional reason/info.
+ */
+export function validateValue (
+  variableMetadata: VariableMetadata,
+  rawValue: string
+): { info?: string; ok: boolean; reason?: ReasonCodeEnumType } {
+  if (variableMetadata.maxLength != null && rawValue.length > variableMetadata.maxLength) {
+    return {
+      info: 'Value exceeds maximum length (' + String(variableMetadata.maxLength) + ')',
+      ok: false,
+      reason: ReasonCodeEnumType.InvalidValue,
+    }
+  }
+  switch (variableMetadata.dataType) {
+    case DataEnumType.boolean: {
+      if (rawValue !== 'true' && rawValue !== 'false') {
+        return {
+          info: 'Boolean must be "true" or "false"',
+          ok: false,
+          reason: ReasonCodeEnumType.InvalidValue,
+        }
+      }
+      break
+    }
+    case DataEnumType.dateTime: {
+      if (isNaN(Date.parse(rawValue))) {
+        return {
+          info: 'Invalid dateTime format',
+          ok: false,
+          reason: ReasonCodeEnumType.InvalidValue,
+        }
+      }
+      break
+    }
+    case DataEnumType.decimal: {
+      if (!DECIMAL_PATTERN.test(rawValue)) {
+        return {
+          info: 'Invalid decimal format',
+          ok: false,
+          reason: ReasonCodeEnumType.InvalidValue,
+        }
+      }
+      const num = Number(rawValue)
+      if (variableMetadata.positive && num <= 0) {
+        return {
+          info: 'Positive decimal > 0 required',
+          ok: false,
+          reason: ReasonCodeEnumType.ValuePositiveOnly,
+        }
+      }
+      if (!variableMetadata.positive && !variableMetadata.allowZero && num === 0) {
+        return {
+          info: 'Zero value not allowed',
+          ok: false,
+          reason: ReasonCodeEnumType.ValueZeroNotAllowed,
+        }
+      }
+      if (variableMetadata.min != null && num < variableMetadata.min) {
+        return {
+          info: 'Decimal value below minimum (' + String(variableMetadata.min) + ')',
+          ok: false,
+          reason: ReasonCodeEnumType.ValueTooLow,
+        }
+      }
+      if (variableMetadata.max != null && num > variableMetadata.max) {
+        return {
+          info: 'Decimal value above maximum (' + String(variableMetadata.max) + ')',
+          ok: false,
+          reason: ReasonCodeEnumType.ValueTooHigh,
+        }
+      }
+      break
+    }
+    case DataEnumType.integer: {
+      if (variableMetadata.allowZero && !variableMetadata.positive) {
+        if (DECIMAL_ONLY_PATTERN.test(rawValue)) {
+          return {
+            info: 'Integer >= 0 required',
+            ok: false,
+            reason: ReasonCodeEnumType.ValueZeroNotAllowed,
+          }
+        }
+      }
+      if (!SIGNED_INTEGER_PATTERN.test(rawValue)) {
+        if (DECIMAL_ONLY_PATTERN.test(rawValue)) {
+          return {
+            info: variableMetadata.positive
+              ? 'Positive integer > 0 required (no decimals)'
+              : 'Integer must not be decimal',
+            ok: false,
+            reason:
+              variableMetadata.allowZero && !variableMetadata.positive
+                ? ReasonCodeEnumType.ValueZeroNotAllowed
+                : ReasonCodeEnumType.InvalidValue,
+          }
+        }
+        return {
+          info: 'Non-empty digits only string required',
+          ok: false,
+          reason: ReasonCodeEnumType.InvalidValue,
+        }
+      }
+      const num = Number(rawValue)
+      if (variableMetadata.allowZero && !variableMetadata.positive && num < 0) {
+        return {
+          info: 'Integer >= 0 required',
+          ok: false,
+          reason: ReasonCodeEnumType.ValueZeroNotAllowed,
+        }
+      }
+      if (variableMetadata.positive && num <= 0) {
+        return {
+          info: 'Positive integer > 0 required',
+          ok: false,
+          reason: ReasonCodeEnumType.ValuePositiveOnly,
+        }
+      }
+      if (variableMetadata.min != null && num < variableMetadata.min) {
+        return {
+          info: 'Integer value below minimum (' + String(variableMetadata.min) + ')',
+          ok: false,
+          reason: ReasonCodeEnumType.ValueTooLow,
+        }
+      }
+      if (variableMetadata.max != null && num > variableMetadata.max) {
+        return {
+          info: 'Integer value above maximum (' + String(variableMetadata.max) + ')',
+          ok: false,
+          reason: ReasonCodeEnumType.ValueTooHigh,
+        }
+      }
+      if (!variableMetadata.positive && !variableMetadata.allowZero && num === 0) {
+        return {
+          info: 'Zero value not allowed',
+          ok: false,
+          reason: ReasonCodeEnumType.ValueZeroNotAllowed,
+        }
+      }
+      break
+    }
+    case DataEnumType.MemberList:
+    case DataEnumType.SequenceList: {
+      if (rawValue.trim().length === 0) {
+        return { info: 'List cannot be empty', ok: false, reason: ReasonCodeEnumType.InvalidValue }
+      }
+      if (rawValue.startsWith(',') || rawValue.endsWith(',')) {
+        return {
+          info: 'No leading/trailing comma',
+          ok: false,
+          reason: ReasonCodeEnumType.InvalidValue,
+        }
+      }
+      const tokens = rawValue.split(',').map(t => t.trim())
+      if (tokens.some(t => t.length === 0)) {
+        return { info: 'Empty list member', ok: false, reason: ReasonCodeEnumType.InvalidValue }
+      }
+      const seen = new Set<string>()
+      for (const t of tokens) {
+        if (seen.has(t)) {
+          return {
+            info: 'Duplicate list member',
+            ok: false,
+            reason: ReasonCodeEnumType.InvalidValue,
+          }
+        }
+        seen.add(t)
+      }
+      if (variableMetadata.enumeration?.length) {
+        for (const t of tokens) {
+          if (!variableMetadata.enumeration.includes(t)) {
+            return {
+              info: 'Member not in enumeration',
+              ok: false,
+              reason: ReasonCodeEnumType.InvalidValue,
+            }
+          }
+        }
+      }
+      break
+    }
+    case DataEnumType.string: {
+      if (variableMetadata.urlSchemes?.length) {
+        const schemeValidation = validateUrlScheme(rawValue, variableMetadata.urlSchemes)
+        if (!schemeValidation.ok) {
+          return schemeValidation
+        }
+      } else if (variableMetadata.isUrl) {
+        const generic = validateGenericUrl(rawValue)
+        if (!generic.ok) {
+          return generic
+        }
+      }
+      break
+    }
+    default:
+      break
+  }
+  // Centralized enumeration membership for scalar (non-list) types including string.
+  if (
+    variableMetadata.enumeration?.length &&
+    variableMetadata.dataType !== DataEnumType.MemberList &&
+    variableMetadata.dataType !== DataEnumType.SequenceList
+  ) {
+    if (!variableMetadata.enumeration.includes(rawValue)) {
+      return {
+        info: 'Value not in enumeration',
+        ok: false,
+        reason: ReasonCodeEnumType.InvalidValue,
+      }
+    }
+  }
+  return { ok: true }
+}
+
+/**
+ * Validate URL using generic parsing (any scheme accepted).
+ * @param value Raw URL string.
+ * @returns Validation result with ok flag and optional reason/info.
+ */
+function validateGenericUrl (value: string): {
+  info?: string
+  ok: boolean
+  reason?: ReasonCodeEnumType
+} {
+  if (!URL.canParse(value)) {
+    return { info: 'Invalid URL format', ok: false, reason: ReasonCodeEnumType.InvalidURL }
+  }
+  return { ok: true }
+}
+
+/**
+ * Validate URL scheme against an allowed list after generic format check.
+ * @param value Raw URL string.
+ * @param allowedSchemes Allowed protocol schemes (with trailing colon).
+ * @returns Validation result with ok flag and optional reason/info.
+ */
+function validateUrlScheme (
+  value: string,
+  allowedSchemes: string[]
+): { info?: string; ok: boolean; reason?: ReasonCodeEnumType } {
+  const generic = validateGenericUrl(value)
+  if (!generic.ok) {
+    return generic
+  }
+  const url = new URL(value)
+  if (!allowedSchemes.includes(url.protocol)) {
+    return { info: 'Unsupported URL scheme', ok: false, reason: ReasonCodeEnumType.InvalidURL }
+  }
+  return { ok: true }
+}
index 15981f7e42c22513d5f074a9ee840636da50a3f4..776335842e58da671dbc8774af2c18a6b37923a7 100644 (file)
@@ -57,6 +57,8 @@ export abstract class OCPPIncomingRequestService extends EventEmitter {
     commandPayload: ReqType
   ): Promise<void>
 
+  public abstract stop (chargingStation: ChargingStation): void
+
   protected handleRequestClearCache (chargingStation: ChargingStation): ClearCacheResponse {
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     if (chargingStation.idTagsCache.deleteIdTags(getIdTagsFile(chargingStation.stationInfo!)!)) {
index cdd1d3ff17ca7ee3bb655cf9f0eed41bffd71fae..d919c260677bda66c30e888590164363627c714c 100644 (file)
@@ -1,7 +1,6 @@
-import type { JsonObject } from './JsonType.js'
 import type { OCPPConfigurationKey } from './ocpp/Configuration.js'
 
-export interface ChargingStationOcppConfiguration extends JsonObject {
+export interface ChargingStationOcppConfiguration {
   configurationKey?: ConfigurationKey[]
 }
 
index 3bea940f647052e42698bc5220d26e32122360e2..80d1a13f35e7600c00bc91ec295a245226f4b002 100644 (file)
@@ -52,6 +52,7 @@ export interface ChargingStationWorkerData extends WorkerData {
 export interface ChargingStationWorkerMessage<T extends ChargingStationWorkerMessageData> {
   data: T
   event: ChargingStationWorkerMessageEvents
+  uuid?: string
 }
 
 export type ChargingStationWorkerMessageData = ChargingStationData | Statistics
index d0f80321aa743bdfb6e04b1a94c4248bde9ef1df..a5343dfdd1aa6c573cd8cee1b893eeef06d8324c 100644 (file)
@@ -165,6 +165,7 @@ export {
   type OCPP20NotifyReportRequest,
   OCPP20RequestCommand,
   type OCPP20ResetRequest,
+  type OCPP20SetVariablesRequest,
   type OCPP20StatusNotificationRequest,
 } from './ocpp/2.0/Requests.js'
 export type {
@@ -175,6 +176,7 @@ export type {
   OCPP20HeartbeatResponse,
   OCPP20NotifyReportResponse,
   OCPP20ResetResponse,
+  OCPP20SetVariablesResponse,
   OCPP20StatusNotificationResponse,
 } from './ocpp/2.0/Responses.js'
 export {
@@ -186,6 +188,11 @@ export {
   type OCPP20GetVariableResultType,
   OCPP20OptionalVariableName,
   OCPP20RequiredVariableName,
+  type OCPP20SetVariableDataType,
+  type OCPP20SetVariableResultType,
+  OCPP20VendorVariableName,
+  PersistenceEnumType,
+  SetVariableStatusEnumType,
   type VariableType,
 } from './ocpp/2.0/Variables.js'
 export { ChargePointErrorCode } from './ocpp/ChargePointErrorCode.js'
index 166d78bb5aab3d57d257f7f16b9b39bd4a17d12f..463bdb224d0857ac3e660ecfe5e976ecec0dc1e0 100644 (file)
@@ -18,6 +18,7 @@ export enum OCPP20IncomingRequestCommand {
   REQUEST_START_TRANSACTION = 'RequestStartTransaction',
   REQUEST_STOP_TRANSACTION = 'RequestStopTransaction',
   RESET = 'Reset',
+  SET_VARIABLES = 'SetVariables',
 }
 
 export enum OCPP20RequestCommand {
index db9cc82c50774cf41855319a8b890ff95075da59..0d94001c936cad9ad927d1e0fce0c4d33362dbe3 100644 (file)
@@ -40,6 +40,7 @@ export enum OCPP20RequiredVariableName {
   AuthorizeRemoteStart = 'AuthorizeRemoteStart',
   BytesPerMessage = 'BytesPerMessage',
   CertificateEntries = 'CertificateEntries',
+  ConfigurationValueSize = 'ConfigurationValueSize',
   DateTime = 'DateTime',
   EVConnectionTimeOut = 'EVConnectionTimeOut',
   FileTransferProtocols = 'FileTransferProtocols',
@@ -47,12 +48,13 @@ export enum OCPP20RequiredVariableName {
   LocalAuthorizeOffline = 'LocalAuthorizeOffline',
   LocalPreAuthorize = 'LocalPreAuthorize',
   MessageAttemptInterval = 'MessageAttemptInterval',
-  MessageAttempts = 'TransactionEvent',
+  MessageAttempts = 'MessageAttempts',
   MessageTimeout = 'MessageTimeout',
   NetworkConfigurationPriority = 'NetworkConfigurationPriority',
   NetworkProfileConnectionAttempts = 'NetworkProfileConnectionAttempts',
   OfflineThreshold = 'OfflineThreshold',
   OrganizationName = 'OrganizationName',
+  ReportingValueSize = 'ReportingValueSize',
   ResetRetries = 'ResetRetries',
   SecurityProfile = 'SecurityProfile',
   StopTxOnEVSideDisconnect = 'StopTxOnEVSideDisconnect',
@@ -65,13 +67,20 @@ export enum OCPP20RequiredVariableName {
   TxUpdatedInterval = 'TxUpdatedInterval',
   TxUpdatedMeasurands = 'TxUpdatedMeasurands',
   UnlockOnEVSideDisconnect = 'UnlockOnEVSideDisconnect',
+  ValueSize = 'ValueSize',
 }
 
 export enum OCPP20VendorVariableName {
+  CertificatePrivateKey = 'CertificatePrivateKey',
   ConnectionUrl = 'ConnectionUrl',
 }
 
-enum SetVariableStatusEnumType {
+export enum PersistenceEnumType {
+  Persistent = 'Persistent',
+  Volatile = 'Volatile',
+}
+
+export enum SetVariableStatusEnumType {
   Accepted = 'Accepted',
   NotSupportedAttributeType = 'NotSupportedAttributeType',
   RebootRequired = 'RebootRequired',
index 2fe2890006b15609dd5b6674442f09b9cc58ebd4..ea1640998ea8ddf17a4d10e85a631ae035e78d7b 100644 (file)
@@ -295,7 +295,7 @@ export class Configuration {
   private static buildUIServerSection (): UIServerConfiguration {
     let uiServerConfiguration: UIServerConfiguration = defaultUIServerConfiguration
     if (has(ConfigurationSection.uiServer, Configuration.getConfigurationData())) {
-      uiServerConfiguration = mergeDeepRight(
+      uiServerConfiguration = mergeDeepRight<UIServerConfiguration, Partial<UIServerConfiguration>>(
         uiServerConfiguration,
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         Configuration.getConfigurationData()!.uiServer!
index 4bcb0ed76de96693062ead38cd6ae5fe52a8f323..91072789eeee4aa23c9ae1a34dc1c66da87087ed 100644 (file)
@@ -83,6 +83,8 @@ export class Constants {
     useConnectorId0: true,
   })
 
+  static readonly DEFAULT_TX_UPDATED_INTERVAL = 30 // Seconds
+
   static readonly DEFAULT_UI_SERVER_HOST = 'localhost'
   static readonly DEFAULT_UI_SERVER_PORT = 8080
 
@@ -96,6 +98,8 @@ export class Constants {
 
   static readonly MAX_RANDOM_INTEGER = 281474976710655 // 2^48 - 1 (randomInit() limit)
 
+  static readonly OCPP_VALUE_ABSOLUTE_MAX_LENGTH = 2500
+
   static readonly PERFORMANCE_RECORDS_TABLE = 'performance_records'
 
   static readonly STOP_CHARGING_STATIONS_TIMEOUT = 60000 // Ms
index 69abece32a2f0a4e509968c6fa7029b1c4e01350..41c380c7d5b3b43c2e4c8f0bd45b010638af4fdc 100644 (file)
@@ -64,12 +64,14 @@ export const buildUpdatedMessage = (
 export const buildPerformanceStatisticsMessage = (
   statistics: Statistics
 ): ChargingStationWorkerMessage<Statistics> => {
-  const statisticsData = [...statistics.statisticsData].map(([key, value]) => {
-    if (value.measurementTimeSeries instanceof CircularBuffer) {
-      value.measurementTimeSeries = value.measurementTimeSeries.toArray() as TimestampedData[]
-    }
-    return [key, value]
-  })
+  const statisticsData = new Map(
+    [...statistics.statisticsData].map(([key, value]) => {
+      if (value.measurementTimeSeries instanceof CircularBuffer) {
+        value.measurementTimeSeries = value.measurementTimeSeries.toArray() as TimestampedData[]
+      }
+      return [key, value]
+    })
+  )
   return {
     data: {
       createdAt: statistics.createdAt,
index 3e0f59ccb6fa3c289e8b2b4421e94ab20e0094e7..fc20974bb893ba0348fd91aafd800ba19e38d08e 100644 (file)
@@ -51,11 +51,11 @@ export const once = <T extends (...args: any[]) => any>(fn: T): T => {
   } as T
 }
 
-export const has = (property: PropertyKey, object: null | object | undefined): boolean => {
-  if (object == null) {
+export const has = (property: PropertyKey, object: unknown): boolean => {
+  if (object == null || (typeof object !== 'object' && typeof object !== 'function')) {
     return false
   }
-  return Object.hasOwn(object, property)
+  return Object.hasOwn(object as Record<PropertyKey, unknown>, property)
 }
 
 const type = (value: unknown): string => {
@@ -100,27 +100,26 @@ const isObject = (value: unknown): value is object => {
   return type(value) === 'Object'
 }
 
-export const mergeDeepRight = <T extends Record<string, unknown>>(
-  target: T,
-  source: Partial<T>
-): T => {
-  const output = { ...target }
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
+export const mergeDeepRight = <T extends object, S extends object>(target: T, source: S): T => {
+  const output: Record<string, unknown> = { ...(target as Record<string, unknown>) }
 
   if (isObject(target) && isObject(source)) {
-    Object.keys(source).forEach(key => {
-      if (isObject(source[key])) {
-        if (!(key in target)) {
-          Object.assign(output, { [key]: source[key] })
-        } else {
-          output[key] = mergeDeepRight(target[key], source[key])
-        }
+    Object.keys(source as Record<string, unknown>).forEach(key => {
+      const sourceValue = (source as Record<string, unknown>)[key]
+      const targetValue = (target as Record<string, unknown>)[key]
+      if (isObject(sourceValue) && isObject(targetValue)) {
+        output[key] = mergeDeepRight(
+          targetValue as Record<string, unknown>,
+          sourceValue as Record<string, unknown>
+        )
       } else {
-        Object.assign(output, { [key]: source[key] })
+        output[key] = sourceValue
       }
     })
   }
 
-  return output
+  return output as T
 }
 
 export const generateUUID = (): `${string}-${string}-${string}-${string}-${string}` => {
@@ -224,6 +223,14 @@ export const convertToInt = (value: unknown): number => {
   return changedValue
 }
 
+export const convertToIntOrNaN = (value: unknown): number => {
+  try {
+    return convertToInt(value)
+  } catch {
+    return Number.NaN
+  }
+}
+
 export const convertToFloat = (value: unknown): number => {
   if (value == null) {
     return 0
index c9baaf34ed38677b795ddef67d3e25f91d8408fb..f248b5ce873fb6f207f3d8d2279406ea3d0b2ba8 100644 (file)
@@ -32,6 +32,7 @@ export {
   convertToDate,
   convertToFloat,
   convertToInt,
+  convertToIntOrNaN,
   exponentialDelay,
   extractTimeSeriesValues,
   formatDurationMilliSeconds,
index 788fa3009d11256ed1673f8b007ec040b39ecd16..c5f92ec1bed9760a4d22551bfe6362f0aebc133d 100644 (file)
@@ -23,7 +23,10 @@ export class WorkerFactory {
     if (!isMainThread) {
       throw new Error('Cannot get a worker implementation outside the main thread')
     }
-    workerOptions = mergeDeepRight<WorkerOptions>(DEFAULT_WORKER_OPTIONS, workerOptions ?? {})
+    workerOptions = mergeDeepRight<WorkerOptions, WorkerOptions>(
+      DEFAULT_WORKER_OPTIONS,
+      (workerOptions ?? {}) as WorkerOptions
+    )
     switch (workerProcessType) {
       case WorkerProcessType.dynamicPool:
         return new WorkerDynamicPool<D, R>(workerScript, workerOptions)
index 3264f5dcf38b7608ab3774faabcd912e964d5080..9ab785cbe57c2ca26352e98a3f817e4604eadea7 100644 (file)
@@ -1,3 +1,5 @@
+import { millisecondsToSeconds } from 'date-fns'
+
 import type { ChargingStation } from '../src/charging-station/index.js'
 import type {
   ChargingStationConfiguration,
@@ -57,9 +59,21 @@ export function createChargingStation (options: ChargingStationOptions = {}): Ch
           key: OCPP20OptionalVariableName.WebSocketPingInterval,
           value: websocketPingInterval.toString(),
         },
-        { key: OCPP20OptionalVariableName.HeartbeatInterval, value: heartbeatInterval.toString() },
+        {
+          key: OCPP20OptionalVariableName.HeartbeatInterval,
+          value: millisecondsToSeconds(heartbeatInterval).toString(),
+        },
       ],
     },
+    restartHeartbeat: () => {
+      /* no-op for tests */
+    },
+    restartWebSocketPing: () => {
+      /* no-op for tests */
+    },
+    saveOcppConfiguration: () => {
+      /* no-op for tests */
+    },
     started: options.started ?? false,
     starting: options.starting,
     stationInfo: {
diff --git a/tests/charging-station/ConfigurationKeyUtils.test.ts b/tests/charging-station/ConfigurationKeyUtils.test.ts
new file mode 100644 (file)
index 0000000..c7104fd
--- /dev/null
@@ -0,0 +1,297 @@
+import { expect } from '@std/expect'
+import { describe, it } from 'node:test'
+
+import {
+  addConfigurationKey,
+  deleteConfigurationKey,
+  getConfigurationKey,
+  setConfigurationKeyValue,
+} from '../../src/charging-station/ConfigurationKeyUtils.js'
+import { logger } from '../../src/utils/Logger.js'
+import { createChargingStation } from '../ChargingStationFactory.js'
+
+const TEST_KEY_1 = 'TestKey1'
+const MIXED_CASE_KEY = 'MiXeDkEy'
+const VALUE_A = 'ValueA'
+const VALUE_B = 'ValueB'
+
+await describe('ConfigurationKeyUtils test suite', async () => {
+  await describe('getConfigurationKey()', async () => {
+    await it('returns undefined when configurationKey array is missing', () => {
+      const cs = createChargingStation()
+      // remove array
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
+      cs.ocppConfiguration = {} as any
+      expect(getConfigurationKey(cs, TEST_KEY_1)).toBeUndefined()
+    })
+
+    await it('finds existing key (case-sensitive)', () => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, TEST_KEY_1, VALUE_A, undefined, { save: false })
+      const k = getConfigurationKey(cs, TEST_KEY_1)
+      expect(k?.key).toBe(TEST_KEY_1)
+      expect(k?.value).toBe(VALUE_A)
+    })
+
+    await it('respects case sensitivity (no match)', () => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, MIXED_CASE_KEY, VALUE_A, undefined, { save: false })
+      expect(getConfigurationKey(cs, MIXED_CASE_KEY.toLowerCase())).toBeUndefined()
+    })
+
+    await it('supports caseInsensitive lookup', () => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, MIXED_CASE_KEY, VALUE_A, undefined, { save: false })
+      const k = getConfigurationKey(cs, MIXED_CASE_KEY.toLowerCase(), true)
+      expect(k?.key).toBe(MIXED_CASE_KEY)
+    })
+  })
+
+  await describe('addConfigurationKey()', async () => {
+    await it('no-op when configurationKey array missing', () => {
+      const cs = createChargingStation()
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
+      cs.ocppConfiguration = {} as any
+      addConfigurationKey(cs, TEST_KEY_1, VALUE_A)
+      expect(getConfigurationKey(cs, TEST_KEY_1)).toBeUndefined()
+    })
+
+    await it('adds new key with default options', () => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, TEST_KEY_1, VALUE_A, undefined, { save: false })
+      const k = getConfigurationKey(cs, TEST_KEY_1)
+      expect(k).toBeDefined()
+      expect(k?.value).toBe(VALUE_A)
+      // defaults
+      expect(k?.readonly).toBe(false)
+      expect(k?.reboot).toBe(false)
+      expect(k?.visible).toBe(true)
+    })
+
+    await it('adds new key with custom options', () => {
+      const cs = createChargingStation()
+      addConfigurationKey(
+        cs,
+        TEST_KEY_1,
+        VALUE_A,
+        { readonly: true, reboot: true, visible: false },
+        { save: false }
+      )
+      const k = getConfigurationKey(cs, TEST_KEY_1)
+      expect(k?.readonly).toBe(true)
+      expect(k?.reboot).toBe(true)
+      expect(k?.visible).toBe(false)
+    })
+
+    await it('logs error and does not overwrite value when key exists and overwrite=false', t => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, TEST_KEY_1, VALUE_A, { readonly: false }, { save: false })
+      const errorMock = t.mock.method(logger, 'error')
+      // Attempt to add same key with different value and option change
+      addConfigurationKey(
+        cs,
+        TEST_KEY_1,
+        VALUE_B,
+        { readonly: true, reboot: true, visible: false },
+        { overwrite: false, save: false }
+      )
+      const k = getConfigurationKey(cs, TEST_KEY_1)
+      // value unchanged
+      expect(k?.value).toBe(VALUE_A)
+      // options updated only where differing (all provided differ)
+      expect(k?.reboot).toBe(true)
+      expect(k?.readonly).toBe(true)
+      expect(k?.visible).toBe(false)
+      expect(errorMock.mock.calls.length).toBe(1)
+    })
+
+    await it('logs error and leaves key untouched when identical options & value attempted (overwrite=false)', t => {
+      const cs = createChargingStation()
+      addConfigurationKey(
+        cs,
+        TEST_KEY_1,
+        VALUE_A,
+        { readonly: true, reboot: false, visible: true },
+        { save: false }
+      )
+      const errorMock = t.mock.method(logger, 'error')
+      // Attempt to add same key with identical value and options
+      addConfigurationKey(
+        cs,
+        TEST_KEY_1,
+        VALUE_A,
+        { readonly: true, reboot: false, visible: true },
+        { overwrite: false, save: false }
+      )
+      const k = getConfigurationKey(cs, TEST_KEY_1)
+      expect(k?.value).toBe(VALUE_A)
+      expect(k?.readonly).toBe(true)
+      expect(k?.reboot).toBe(false)
+      expect(k?.visible).toBe(true)
+      expect(errorMock.mock.calls.length).toBe(1)
+    })
+
+    await it('overwrites existing key value and options when overwrite=true', () => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, TEST_KEY_1, VALUE_A, { readonly: false }, { save: false })
+      addConfigurationKey(
+        cs,
+        TEST_KEY_1,
+        VALUE_B,
+        { readonly: true, reboot: true, visible: false },
+        { overwrite: true, save: false }
+      )
+      const k = getConfigurationKey(cs, TEST_KEY_1)
+      expect(k?.value).toBe(VALUE_B)
+      expect(k?.readonly).toBe(true)
+      expect(k?.reboot).toBe(true)
+      expect(k?.visible).toBe(false)
+    })
+
+    await it('caseInsensitive overwrite updates existing differently cased key', () => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, MIXED_CASE_KEY, VALUE_A, undefined, { save: false })
+      addConfigurationKey(
+        cs,
+        MIXED_CASE_KEY.toLowerCase(),
+        VALUE_B,
+        { readonly: true },
+        { caseInsensitive: true, overwrite: true, save: false }
+      )
+      const k = getConfigurationKey(cs, MIXED_CASE_KEY)
+      expect(k?.value).toBe(VALUE_B)
+      expect(k?.readonly).toBe(true)
+    })
+
+    await it('case-insensitive false creates separate key with different case', () => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, MIXED_CASE_KEY, VALUE_A, undefined, { save: false })
+      addConfigurationKey(cs, MIXED_CASE_KEY.toLowerCase(), VALUE_B, undefined, {
+        overwrite: true,
+        save: false,
+      })
+      const orig = getConfigurationKey(cs, MIXED_CASE_KEY)
+      const second = getConfigurationKey(cs, MIXED_CASE_KEY.toLowerCase())
+      expect(orig).toBeDefined()
+      expect(second).toBeDefined()
+      expect(orig).not.toBe(second)
+    })
+
+    await it('calls saveOcppConfiguration when params.save=true (new key)', t => {
+      const cs = createChargingStation()
+      const saveMock = t.mock.method(cs, 'saveOcppConfiguration')
+      addConfigurationKey(cs, TEST_KEY_1, VALUE_A, undefined, { save: true })
+      expect(saveMock.mock.calls.length).toBe(1)
+    })
+
+    await it('calls saveOcppConfiguration when overwriting existing key and save=true', t => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, TEST_KEY_1, VALUE_A, undefined, { save: false })
+      const saveMock = t.mock.method(cs, 'saveOcppConfiguration')
+      addConfigurationKey(
+        cs,
+        TEST_KEY_1,
+        VALUE_B,
+        { readonly: true },
+        { overwrite: true, save: true }
+      )
+      expect(saveMock.mock.calls.length).toBe(1)
+    })
+  })
+
+  await describe('setConfigurationKeyValue()', async () => {
+    await it('returns undefined and logs error for non-existing key', t => {
+      const cs = createChargingStation()
+      const errorMock = t.mock.method(logger, 'error')
+      const res = setConfigurationKeyValue(cs, TEST_KEY_1, VALUE_A)
+      expect(res).toBeUndefined()
+      expect(errorMock.mock.calls.length).toBe(1)
+    })
+
+    await it('returns undefined without logging when configurationKey array missing', t => {
+      const cs = createChargingStation()
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
+      cs.ocppConfiguration = {} as any
+      const errorMock = t.mock.method(logger, 'error')
+      const res = setConfigurationKeyValue(cs, TEST_KEY_1, VALUE_A)
+      expect(res).toBeUndefined()
+      expect(errorMock.mock.calls.length).toBe(0)
+    })
+
+    await it('updates existing key value and saves', t => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, TEST_KEY_1, VALUE_A, undefined, { save: false })
+      const saveMock = t.mock.method(cs, 'saveOcppConfiguration')
+      const updated = setConfigurationKeyValue(cs, TEST_KEY_1, VALUE_B)
+      expect(updated?.value).toBe(VALUE_B)
+      expect(saveMock.mock.calls.length).toBe(1)
+    })
+
+    await it('caseInsensitive value update works', () => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, MIXED_CASE_KEY, VALUE_A, undefined, { save: false })
+      const updated = setConfigurationKeyValue(cs, MIXED_CASE_KEY.toLowerCase(), VALUE_B, true)
+      expect(updated?.value).toBe(VALUE_B)
+    })
+  })
+
+  await describe('deleteConfigurationKey()', async () => {
+    await it('returns undefined when configurationKey array missing', () => {
+      const cs = createChargingStation()
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
+      cs.ocppConfiguration = {} as any
+      const res = deleteConfigurationKey(cs, TEST_KEY_1)
+      expect(res).toBeUndefined()
+    })
+
+    await it('returns undefined when key does not exist', () => {
+      const cs = createChargingStation()
+      const res = deleteConfigurationKey(cs, TEST_KEY_1)
+      expect(res).toBeUndefined()
+    })
+
+    await it('deletes existing key and saves by default', t => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, TEST_KEY_1, VALUE_A, undefined, { save: false })
+      const saveMock = t.mock.method(cs, 'saveOcppConfiguration')
+      const deleted = deleteConfigurationKey(cs, TEST_KEY_1)
+      expect(Array.isArray(deleted)).toBe(true)
+      expect(deleted).toHaveLength(1)
+      expect(deleted?.[0].key).toBe(TEST_KEY_1)
+      expect(getConfigurationKey(cs, TEST_KEY_1)).toBeUndefined()
+      expect(saveMock.mock.calls.length).toBe(1)
+    })
+
+    await it('does not save when params.save=false', t => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, TEST_KEY_1, VALUE_A, undefined, { save: false })
+      const saveMock = t.mock.method(cs, 'saveOcppConfiguration')
+      const deleted = deleteConfigurationKey(cs, TEST_KEY_1, { save: false })
+      expect(deleted).toHaveLength(1)
+      expect(saveMock.mock.calls.length).toBe(0)
+    })
+
+    await it('caseInsensitive deletion removes key with different case', () => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, MIXED_CASE_KEY, VALUE_A, undefined, { save: false })
+      const deleted = deleteConfigurationKey(cs, MIXED_CASE_KEY.toLowerCase(), {
+        caseInsensitive: true,
+        save: false,
+      })
+      expect(deleted).toHaveLength(1)
+      expect(getConfigurationKey(cs, MIXED_CASE_KEY)).toBeUndefined()
+    })
+  })
+
+  await describe('Combined scenarios', async () => {
+    await it('add then set then delete lifecycle', () => {
+      const cs = createChargingStation()
+      addConfigurationKey(cs, TEST_KEY_1, VALUE_A, { readonly: false }, { save: false })
+      const setRes = setConfigurationKeyValue(cs, TEST_KEY_1, VALUE_B)
+      expect(setRes?.value).toBe(VALUE_B)
+      const delRes = deleteConfigurationKey(cs, TEST_KEY_1, { save: false })
+      expect(delRes).toHaveLength(1)
+      expect(getConfigurationKey(cs, TEST_KEY_1)).toBeUndefined()
+    })
+  })
+})
index 4300d5a6d02db32e1f004634cf8517be383faef0..43f8b529281c9fc478e88e3e05efefc1020cbcde 100644 (file)
@@ -130,15 +130,15 @@ await describe('Helpers test suite', async () => {
   })
 
   await it('Verify checkChargingStationState()', t => {
-    t.mock.method(logger, 'warn')
+    const warnMock = t.mock.method(logger, 'warn')
     expect(checkChargingStationState(chargingStation, 'log prefix |')).toBe(false)
-    expect(logger.warn.mock.calls.length).toBe(1)
+    expect(warnMock.mock.calls.length).toBe(1)
     chargingStation.starting = true
     expect(checkChargingStationState(chargingStation, 'log prefix |')).toBe(true)
-    expect(logger.warn.mock.calls.length).toBe(1)
+    expect(warnMock.mock.calls.length).toBe(1)
     chargingStation.started = true
     expect(checkChargingStationState(chargingStation, 'log prefix |')).toBe(true)
-    expect(logger.warn.mock.calls.length).toBe(1)
+    expect(warnMock.mock.calls.length).toBe(1)
   })
 
   await it('Verify getPhaseRotationValue()', () => {
@@ -162,45 +162,45 @@ await describe('Helpers test suite', async () => {
   })
 
   await it('Verify checkTemplate()', t => {
-    t.mock.method(logger, 'warn')
-    t.mock.method(logger, 'error')
+    const warnMock = t.mock.method(logger, 'warn')
+    const errorMock = t.mock.method(logger, 'error')
     expect(() => {
       checkTemplate(undefined, 'log prefix |', 'test-template.json')
     }).toThrow(new BaseError('Failed to read charging station template file test-template.json'))
-    expect(logger.error.mock.calls.length).toBe(1)
+    expect(errorMock.mock.calls.length).toBe(1)
     expect(() => {
       checkTemplate({} as ChargingStationTemplate, 'log prefix |', 'test-template.json')
     }).toThrow(
       new BaseError('Empty charging station information from template file test-template.json')
     )
-    expect(logger.error.mock.calls.length).toBe(2)
+    expect(errorMock.mock.calls.length).toBe(2)
     checkTemplate(chargingStationTemplate, 'log prefix |', 'test-template.json')
-    expect(logger.warn.mock.calls.length).toBe(1)
+    expect(warnMock.mock.calls.length).toBe(1)
   })
 
   await it('Verify checkConfiguration()', t => {
-    t.mock.method(logger, 'error')
+    const errorMock = t.mock.method(logger, 'error')
     expect(() => {
       checkConfiguration(undefined, 'log prefix |', 'configuration.json')
     }).toThrow(
       new BaseError('Failed to read charging station configuration file configuration.json')
     )
-    expect(logger.error.mock.calls.length).toBe(1)
+    expect(errorMock.mock.calls.length).toBe(1)
     expect(() => {
       checkConfiguration({} as ChargingStationConfiguration, 'log prefix |', 'configuration.json')
     }).toThrow(new BaseError('Empty charging station configuration from file configuration.json'))
-    expect(logger.error.mock.calls.length).toBe(2)
+    expect(errorMock.mock.calls.length).toBe(2)
   })
 
   await it('Verify checkStationInfoConnectorStatus()', t => {
-    t.mock.method(logger, 'warn')
+    const warnMock = t.mock.method(logger, 'warn')
     checkStationInfoConnectorStatus(1, {} as ConnectorStatus, 'log prefix |', 'test-template.json')
-    expect(logger.warn.mock.calls.length).toBe(0)
+    expect(warnMock.mock.calls.length).toBe(0)
     const connectorStatus = {
       status: ConnectorStatusEnum.Available,
     } as ConnectorStatus
     checkStationInfoConnectorStatus(1, connectorStatus, 'log prefix |', 'test-template.json')
-    expect(logger.warn.mock.calls.length).toBe(1)
+    expect(warnMock.mock.calls.length).toBe(1)
     expect(connectorStatus.status).toBeUndefined()
   })
 })
index 4ad80e18c4427578f93aa6f87ea748d84a34889e..0dfc4e768dd98de3b6606dea242aca6135748f28 100644 (file)
@@ -27,6 +27,7 @@ await describe('C11 - Clear Authorization Data in Authorization Cache', async ()
 
   const incomingRequestService = new OCPP20IncomingRequestService()
 
+  // FR: C11.FR.01
   await it('Should handle ClearCache request successfully', async () => {
     const response = await (incomingRequestService as any).handleRequestClearCache(
       mockChargingStation
@@ -39,6 +40,7 @@ await describe('C11 - Clear Authorization Data in Authorization Cache', async ()
     expect(['Accepted', 'Rejected']).toContain(response.status)
   })
 
+  // FR: C11.FR.02
   await it('Should return correct status based on cache clearing result', async () => {
     // Test the actual behavior - ClearCache should work with ID tags cache
 
index d9f6ef8933497628dc3a76fae31b97e22d028502..2e05f3b2bb87bc80379235c92b3bf20e64420a47 100644 (file)
@@ -2,18 +2,30 @@
 /* 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 {
+  addConfigurationKey,
+  setConfigurationKeyValue,
+} from '../../../../src/charging-station/ConfigurationKeyUtils.js'
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
 import {
   GenericDeviceModelStatusEnumType,
   OCPP20ComponentName,
   OCPP20DeviceInfoVariableName,
   type OCPP20GetBaseReportRequest,
+  type OCPP20SetVariableResultType,
   ReportBaseEnumType,
+  type ReportDataType,
+} from '../../../../src/types/index.js'
+import {
+  AttributeEnumType,
+  OCPP20OptionalVariableName,
+  OCPP20RequiredVariableName,
 } from '../../../../src/types/index.js'
+import { StandardParametersKey } from '../../../../src/types/ocpp/Configuration.js'
 import { Constants } from '../../../../src/utils/index.js'
 import { createChargingStationWithEvses } from '../../../ChargingStationFactory.js'
 import {
@@ -24,7 +36,7 @@ import {
   TEST_FIRMWARE_VERSION,
 } from './OCPP20TestConstants.js'
 
-await describe('B07 - Get Base Report', async () => {
+await describe('B08 - Get Base Report', async () => {
   const mockChargingStation = createChargingStationWithEvses({
     baseName: TEST_CHARGING_STATION_NAME,
     heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
@@ -40,6 +52,7 @@ await describe('B07 - Get Base Report', async () => {
 
   const incomingRequestService = new OCPP20IncomingRequestService()
 
+  // FR: B08.FR.01
   await it('Should handle GetBaseReport request with ConfigurationInventory', () => {
     const request: OCPP20GetBaseReportRequest = {
       reportBase: ReportBaseEnumType.ConfigurationInventory,
@@ -55,6 +68,7 @@ await describe('B07 - Get Base Report', async () => {
     expect(response.status).toBe(GenericDeviceModelStatusEnumType.Accepted)
   })
 
+  // FR: B08.FR.02
   await it('Should handle GetBaseReport request with FullInventory', () => {
     const request: OCPP20GetBaseReportRequest = {
       reportBase: ReportBaseEnumType.FullInventory,
@@ -70,6 +84,40 @@ await describe('B07 - Get Base Report', async () => {
     expect(response.status).toBe(GenericDeviceModelStatusEnumType.Accepted)
   })
 
+  await it('Should include registry variables with Actual attribute only for unsupported types', () => {
+    const reportData: ReportDataType[] = (incomingRequestService as any).buildReportData(
+      mockChargingStation,
+      ReportBaseEnumType.FullInventory
+    )
+    const heartbeatEntry = reportData.find(
+      (item: ReportDataType) =>
+        item.variable.name === (OCPP20OptionalVariableName.HeartbeatInterval as string) &&
+        item.component.name === (OCPP20ComponentName.OCPPCommCtrlr as string)
+    )
+    expect(heartbeatEntry).toBeDefined()
+    if (heartbeatEntry) {
+      const types =
+        heartbeatEntry.variableAttribute?.map((a: { type?: string; value?: string }) => a.type) ??
+        []
+      expect(types).toEqual([AttributeEnumType.Actual])
+    }
+    // Boolean variable (AuthorizeRemoteStart) should only include Actual
+    const authorizeRemoteStartEntry = reportData.find(
+      (item: ReportDataType) =>
+        item.variable.name === (OCPP20RequiredVariableName.AuthorizeRemoteStart as string) &&
+        item.component.name === (OCPP20ComponentName.AuthCtrlr as string)
+    )
+    expect(authorizeRemoteStartEntry).toBeDefined()
+    if (authorizeRemoteStartEntry) {
+      const types =
+        authorizeRemoteStartEntry.variableAttribute?.map(
+          (a: { type?: string; value?: string }) => a.type
+        ) ?? []
+      expect(types).toEqual([AttributeEnumType.Actual])
+    }
+  })
+
+  // FR: B08.FR.03
   await it('Should handle GetBaseReport request with SummaryInventory', () => {
     const request: OCPP20GetBaseReportRequest = {
       reportBase: ReportBaseEnumType.SummaryInventory,
@@ -85,9 +133,10 @@ await describe('B07 - Get Base Report', async () => {
     expect(response.status).toBe(GenericDeviceModelStatusEnumType.Accepted)
   })
 
+  // FR: B08.FR.04
   await it('Should return NotSupported for unsupported reportBase', () => {
     const request: OCPP20GetBaseReportRequest = {
-      reportBase: 'UnsupportedReportBase' as any,
+      reportBase: 'UnsupportedReportBase' as unknown as ReportBaseEnumType,
       requestId: 4,
     }
 
@@ -100,6 +149,7 @@ await describe('B07 - Get Base Report', async () => {
     expect(response.status).toBe(GenericDeviceModelStatusEnumType.NotSupported)
   })
 
+  // FR: B08.FR.05
   await it('Should return EmptyResultSet when no data is available', () => {
     // Create a charging station with minimal configuration
     const minimalChargingStation = createChargingStationWithEvses({
@@ -126,6 +176,7 @@ await describe('B07 - Get Base Report', async () => {
     expect(response.status).toBe(GenericDeviceModelStatusEnumType.EmptyResultSet)
   })
 
+  // FR: B08.FR.06
   await it('Should build correct report data for ConfigurationInventory', () => {
     const request: OCPP20GetBaseReportRequest = {
       reportBase: ReportBaseEnumType.ConfigurationInventory,
@@ -143,7 +194,7 @@ await describe('B07 - Get Base Report', async () => {
     expect(response.status).toBe(GenericDeviceModelStatusEnumType.Accepted)
 
     // We can also test the buildReportData method directly if needed
-    const reportData = (incomingRequestService as any).buildReportData(
+    const reportData: ReportDataType[] = (incomingRequestService as any).buildReportData(
       mockChargingStation,
       ReportBaseEnumType.ConfigurationInventory
     )
@@ -163,8 +214,9 @@ await describe('B07 - Get Base Report', async () => {
     }
   })
 
+  // FR: B08.FR.07
   await it('Should build correct report data for FullInventory with station info', () => {
-    const reportData = (incomingRequestService as any).buildReportData(
+    const reportData: ReportDataType[] = (incomingRequestService as any).buildReportData(
       mockChargingStation,
       ReportBaseEnumType.FullInventory
     )
@@ -174,24 +226,29 @@ await describe('B07 - Get Base Report', async () => {
 
     // Check for station info variables
     const modelVariable = reportData.find(
-      (item: any) =>
-        item.variable.name === OCPP20DeviceInfoVariableName.Model &&
-        item.component.name === OCPP20ComponentName.ChargingStation
+      (item: ReportDataType) =>
+        item.variable.name === (OCPP20DeviceInfoVariableName.Model as string) &&
+        item.component.name === (OCPP20ComponentName.ChargingStation as string)
     )
     expect(modelVariable).toBeDefined()
-    expect(modelVariable.variableAttribute[0].value).toBe(TEST_CHARGE_POINT_MODEL)
+    if (modelVariable) {
+      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
+      (item: ReportDataType) =>
+        item.variable.name === (OCPP20DeviceInfoVariableName.VendorName as string) &&
+        item.component.name === (OCPP20ComponentName.ChargingStation as string)
     )
     expect(vendorVariable).toBeDefined()
-    expect(vendorVariable.variableAttribute[0].value).toBe(TEST_CHARGE_POINT_VENDOR)
+    if (vendorVariable) {
+      expect(vendorVariable.variableAttribute?.[0]?.value).toBe(TEST_CHARGE_POINT_VENDOR)
+    }
   })
 
+  // FR: B08.FR.08
   await it('Should build correct report data for SummaryInventory', () => {
-    const reportData = (incomingRequestService as any).buildReportData(
+    const reportData: ReportDataType[] = (incomingRequestService as any).buildReportData(
       mockChargingStation,
       ReportBaseEnumType.SummaryInventory
     )
@@ -201,14 +258,67 @@ await describe('B07 - Get Base Report', async () => {
 
     // Check for availability state variable
     const availabilityVariable = reportData.find(
-      (item: any) =>
-        item.variable.name === OCPP20DeviceInfoVariableName.AvailabilityState &&
-        item.component.name === OCPP20ComponentName.ChargingStation
+      (item: ReportDataType) =>
+        item.variable.name === (OCPP20DeviceInfoVariableName.AvailabilityState as string) &&
+        item.component.name === (OCPP20ComponentName.ChargingStation as string)
     )
     expect(availabilityVariable).toBeDefined()
-    expect(availabilityVariable.variableCharacteristics.supportsMonitoring).toBe(true)
+    if (availabilityVariable) {
+      expect(availabilityVariable.variableCharacteristics?.supportsMonitoring).toBe(true)
+    }
+  })
+
+  // ReportingValueSize truncation test
+  await it('Should truncate long SequenceList/MemberList values per ReportingValueSize', () => {
+    // Ensure ReportingValueSize is at a small value (default is Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH). We will override configuration key if absent.
+    const reportingSizeKey = StandardParametersKey.ReportingValueSize
+    // Add or lower configuration key to 10 to force truncation
+    addConfigurationKey(mockChargingStation, reportingSizeKey, '10', undefined, {
+      overwrite: true,
+    })
+    setConfigurationKeyValue(mockChargingStation, reportingSizeKey, '10')
+
+    // Choose TimeSource (SequenceList) and construct an artificially long ordered list value > 10 chars
+    const variableManager = OCPP20VariableManager.getInstance()
+    const longValue = 'NTP,GPS,RTC,Manual'
+    // Set Actual (SequenceList). Should accept full value internally.
+    const setResult: OCPP20SetVariableResultType[] = variableManager.setVariables(
+      mockChargingStation,
+      [
+        {
+          attributeType: AttributeEnumType.Actual,
+          attributeValue: longValue,
+          component: { name: OCPP20ComponentName.ClockCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TimeSource },
+        },
+      ]
+    )
+    expect(setResult[0].attributeStatus).toBe('Accepted')
+
+    // Build report; value should be truncated to length 10
+    const reportData: ReportDataType[] = (incomingRequestService as any).buildReportData(
+      mockChargingStation,
+      ReportBaseEnumType.FullInventory
+    )
+    const timeSourceEntry = reportData.find(
+      (item: ReportDataType) =>
+        item.variable.name === (OCPP20RequiredVariableName.TimeSource as string) &&
+        item.component.name === (OCPP20ComponentName.ClockCtrlr as string)
+    )
+    expect(timeSourceEntry).toBeDefined()
+    if (timeSourceEntry) {
+      const reportedAttr = timeSourceEntry.variableAttribute?.find(
+        (a: { type?: string; value?: string }) => a.type === AttributeEnumType.Actual
+      )
+      expect(reportedAttr).toBeDefined()
+      if (reportedAttr && typeof reportedAttr.value === 'string') {
+        expect(reportedAttr.value.length).toBe(10)
+        expect(longValue.startsWith(reportedAttr.value)).toBe(true)
+      }
+    }
   })
 
+  // FR: B08.FR.09
   await it('Should handle GetBaseReport with EVSE structure', () => {
     // The createChargingStationWithEvses should create a station with EVSEs
     const stationWithEvses = createChargingStationWithEvses({
@@ -221,7 +331,7 @@ await describe('B07 - Get Base Report', async () => {
       },
     })
 
-    const reportData = (incomingRequestService as any).buildReportData(
+    const reportData: ReportDataType[] = (incomingRequestService as any).buildReportData(
       stationWithEvses,
       ReportBaseEnumType.FullInventory
     )
@@ -231,17 +341,18 @@ await describe('B07 - Get Base Report', async () => {
 
     // Check if EVSE components are included when EVSEs exist
     const evseComponents = reportData.filter(
-      (item: any) => item.component.name === OCPP20ComponentName.EVSE
+      (item: ReportDataType) => item.component.name === (OCPP20ComponentName.EVSE as string)
     )
     if (stationWithEvses.evses.size > 0) {
       expect(evseComponents.length).toBeGreaterThan(0)
     }
   })
 
+  // FR: B08.FR.10
   await it('Should validate unsupported reportBase correctly', () => {
-    const reportData = (incomingRequestService as any).buildReportData(
+    const reportData: ReportDataType[] = (incomingRequestService as any).buildReportData(
       mockChargingStation,
-      'InvalidReportBase' as any
+      'InvalidReportBase' as unknown as ReportBaseEnumType
     )
 
     expect(Array.isArray(reportData)).toBe(true)
index 15cca681d2a8c784900c9a85feddb8657a9883ee..3ae17add4c4297d1e6a71fdfad11d270902fec51 100644 (file)
@@ -1,5 +1,3 @@
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-
 import { expect } from '@std/expect'
 import { millisecondsToSeconds } from 'date-fns'
 import { describe, it } from 'node:test'
@@ -12,16 +10,21 @@ import {
   type OCPP20GetVariablesRequest,
   OCPP20OptionalVariableName,
   OCPP20RequiredVariableName,
+  OCPP20VendorVariableName,
+  ReasonCodeEnumType,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
 import { createChargingStationWithEvses } from '../../../ChargingStationFactory.js'
+import { TEST_CHARGING_STATION_NAME, TEST_CONNECTOR_VALID_INSTANCE } from './OCPP20TestConstants.js'
 import {
-  TEST_CHARGING_STATION_NAME,
-  TEST_CONNECTOR_INVALID_INSTANCE,
-  TEST_CONNECTOR_VALID_INSTANCE,
-} from './OCPP20TestConstants.js'
+  resetLimits,
+  resetReportingValueSize,
+  setReportingValueSize,
+  setStrictLimits,
+  setValueSize,
+} from './OCPP20TestUtils.js'
 
-await describe('B06 - Get Variables', async () => {
+void describe('B06 - Get Variables', () => {
   const mockChargingStation = createChargingStationWithEvses({
     baseName: TEST_CHARGING_STATION_NAME,
     heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
@@ -33,12 +36,13 @@ await describe('B06 - Get Variables', async () => {
 
   const incomingRequestService = new OCPP20IncomingRequestService()
 
-  await it('Should handle GetVariables request with valid variables', async () => {
+  // FR: B06.FR.01
+  void it('Should handle GetVariables request with valid variables', () => {
     const request: OCPP20GetVariablesRequest = {
       getVariableData: [
         {
           attributeType: AttributeEnumType.Actual,
-          component: { name: OCPP20ComponentName.ChargingStation },
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
           variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
         },
         {
@@ -48,11 +52,7 @@ await describe('B06 - Get Variables', async () => {
       ],
     }
 
-    // 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
-    )
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
 
     expect(response).toBeDefined()
     expect(response.getVariableResult).toBeDefined()
@@ -60,49 +60,42 @@ await describe('B06 - Get Variables', async () => {
     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.component.name).toBe(OCPP20ComponentName.OCPPCommCtrlr)
     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.attributeType).toBe(AttributeEnumType.Actual)
     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 () => {
+  // FR: B06.FR.02
+  void it('Should handle GetVariables request with invalid variables', () => {
     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 },
+          variable: { name: 'InvalidVariable' },
         },
         {
-          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
-          component: { name: 'InvalidComponent' as any },
+          component: { name: 'InvalidComponent' },
           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
-    )
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
 
     expect(response).toBeDefined()
     expect(response.getVariableResult).toBeDefined()
@@ -110,54 +103,53 @@ await describe('B06 - Get Variables', async () => {
     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()
+    // Defaulted attributeType now Actual, not undefined
+    expect(firstResult.attributeType).toBe(AttributeEnumType.Actual)
     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()
+    // Defaulted attributeType now Actual, not undefined
+    expect(secondResult.attributeType).toBe(AttributeEnumType.Actual)
     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 () => {
+  // FR: B06.FR.03
+  void it('Should handle GetVariables request with unsupported attribute types', () => {
     const request: OCPP20GetVariablesRequest = {
       getVariableData: [
         {
           attributeType: AttributeEnumType.Target, // Not supported for HeartbeatInterval
-          component: { name: OCPP20ComponentName.ChargingStation },
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
           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
-    )
+    const response = incomingRequestService.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 () => {
+  // FR: B06.FR.04
+  void it('Should reject AuthorizeRemoteStart under Connector component', () => {
+    resetLimits(mockChargingStation)
+    resetReportingValueSize(mockChargingStation)
     const request: OCPP20GetVariablesRequest = {
       getVariableData: [
         {
@@ -167,38 +159,343 @@ await describe('B06 - Get Variables', async () => {
           },
           variable: { name: OCPP20RequiredVariableName.AuthorizeRemoteStart },
         },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult).toHaveLength(1)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.UnknownComponent)
+  })
+
+  // FR: B06.FR.05
+  void it('Should reject Target attribute for WebSocketPingInterval', () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
         {
-          component: {
-            instance: TEST_CONNECTOR_INVALID_INSTANCE, // Non-existent connector
-            name: OCPP20ComponentName.Connector,
-          },
-          variable: { name: OCPP20RequiredVariableName.AuthorizeRemoteStart },
+          attributeType: AttributeEnumType.Target,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult).toHaveLength(1)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+  })
+
+  void it('Should truncate variable value based on ReportingValueSize', () => {
+    // Set size below actual value length to force truncation
+    setReportingValueSize(mockChargingStation, 2)
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
         },
       ],
     }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(result.attributeValue?.length).toBe(2)
+    resetReportingValueSize(mockChargingStation)
+  })
 
-    // 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
-    )
+  void it('Should allow ReportingValueSize retrieval from DeviceDataCtrlr', () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: { name: OCPP20ComponentName.DeviceDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.ReportingValueSize },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(result.attributeValue).toBeDefined()
+  })
 
-    expect(response).toBeDefined()
-    expect(response.getVariableResult).toBeDefined()
-    expect(Array.isArray(response.getVariableResult)).toBe(true)
+  void it('Should enforce ItemsPerMessage limit', () => {
+    setStrictLimits(mockChargingStation, 1, 10000)
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        {
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult.length).toBe(2)
+    for (const r of response.getVariableResult) {
+      expect(r.attributeStatus).toBe(GetVariableStatusEnumType.Rejected)
+      expect(r.attributeStatusInfo?.reasonCode).toBeDefined()
+    }
+    resetLimits(mockChargingStation)
+  })
+
+  void it('Should enforce BytesPerMessage limit (pre-calculation)', () => {
+    setStrictLimits(mockChargingStation, 100, 10)
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        {
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult.length).toBe(2)
+    response.getVariableResult.forEach(r => {
+      expect(r.attributeStatus).toBe(GetVariableStatusEnumType.Rejected)
+      expect(r.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+    })
+    resetLimits(mockChargingStation)
+  })
+
+  void it('Should enforce BytesPerMessage limit (post-calculation)', () => {
+    // Build request likely to produce larger response due to status info entries
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        // Unsupported attribute type (adds status info)
+        {
+          attributeType: AttributeEnumType.Target,
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+        // Unknown variable
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'UnknownVariableA' },
+        },
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'UnknownVariableB' },
+        },
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'UnknownVariableC' },
+        },
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'UnknownVariableD' },
+        },
+      ],
+    }
+    const preEstimate = Buffer.byteLength(JSON.stringify(request.getVariableData), 'utf8')
+    const limit = preEstimate + 5 // allow pre-check pass, fail post-check
+    setStrictLimits(mockChargingStation, 100, limit)
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    const actualSize = Buffer.byteLength(JSON.stringify(response.getVariableResult), 'utf8')
+    expect(actualSize).toBeGreaterThan(limit)
+    expect(response.getVariableResult).toHaveLength(request.getVariableData.length)
+    response.getVariableResult.forEach(r => {
+      expect(r.attributeStatus).toBe(GetVariableStatusEnumType.Rejected)
+      expect(r.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+    })
+    resetLimits(mockChargingStation)
+  })
+
+  // Added tests for relocated components
+  void it('Should retrieve immutable DateTime from ClockCtrlr', () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          attributeType: AttributeEnumType.Actual,
+          component: { name: OCPP20ComponentName.ClockCtrlr },
+          variable: { name: OCPP20RequiredVariableName.DateTime },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult).toHaveLength(1)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(result.component.name).toBe(OCPP20ComponentName.ClockCtrlr)
+    expect(result.variable.name).toBe(OCPP20RequiredVariableName.DateTime)
+    expect(result.attributeValue).toBeDefined()
+  })
+
+  void it('Should retrieve MessageTimeout from OCPPCommCtrlr', () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          attributeType: AttributeEnumType.Actual,
+          component: { instance: 'Default', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageTimeout },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult).toHaveLength(1)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(result.component.name).toBe(OCPP20ComponentName.OCPPCommCtrlr)
+    expect(result.component.instance).toBe('Default')
+    expect(result.variable.name).toBe(OCPP20RequiredVariableName.MessageTimeout)
+    expect(result.attributeValue).toBeDefined()
+  })
+
+  void it('Should retrieve TxUpdatedInterval from SampledDataCtrlr and show default value', () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          attributeType: AttributeEnumType.Actual,
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult).toHaveLength(1)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(result.component.name).toBe(OCPP20ComponentName.SampledDataCtrlr)
+    expect(result.variable.name).toBe(OCPP20RequiredVariableName.TxUpdatedInterval)
+    expect(result.attributeValue).toBe('30')
+  })
+
+  // FR: B06.FR.13
+  void it('Should reject Target attribute for NetworkConfigurationPriority', () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          attributeType: AttributeEnumType.Target,
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.NetworkConfigurationPriority },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult).toHaveLength(1)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+    expect(result.attributeType).toBe(AttributeEnumType.Target)
+    expect(result.attributeValue).toBeUndefined()
+  })
+
+  // FR: B06.FR.15
+  void it('Should return UnknownVariable when instance omitted for instance-specific MessageTimeout', () => {
+    // MessageTimeout only registered with instance 'Default'
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageTimeout },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult).toHaveLength(1)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.UnknownVariable)
+    expect(result.attributeValue).toBeUndefined()
+  })
+
+  // FR: B06.FR.09
+  void it('Should reject retrieval of explicit write-only variable CertificatePrivateKey', () => {
+    // Explicit vendor-specific write-only variable from SecurityCtrlr
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: { name: OCPP20ComponentName.SecurityCtrlr },
+          variable: { name: OCPP20VendorVariableName.CertificatePrivateKey },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult).toHaveLength(1)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.Rejected)
+    expect(result.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.WriteOnly)
+  })
+
+  void it('Should reject MinSet and MaxSet for WebSocketPingInterval', () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          attributeType: AttributeEnumType.MinSet,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        {
+          attributeType: AttributeEnumType.MaxSet,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
     expect(response.getVariableResult).toHaveLength(2)
+    const minSet = response.getVariableResult[0]
+    const maxSet = response.getVariableResult[1]
+    expect(minSet.attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+    expect(minSet.attributeType).toBe(AttributeEnumType.MinSet)
+    expect(minSet.attributeValue).toBeUndefined()
+    expect(maxSet.attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+    expect(maxSet.attributeType).toBe(AttributeEnumType.MaxSet)
+    expect(maxSet.attributeValue).toBeUndefined()
+  })
 
-    // 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)
+  void it('Should reject MinSet for MemberList variable TxStartPoint', () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          attributeType: AttributeEnumType.MinSet,
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxStartPoint },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult).toHaveLength(1)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+  })
 
-    // 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)
+  void it('Should reject MaxSet for variable SecurityProfile (Actual only)', () => {
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          attributeType: AttributeEnumType.MaxSet,
+          component: { name: OCPP20ComponentName.SecurityCtrlr },
+          variable: { name: OCPP20RequiredVariableName.SecurityProfile },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    expect(response.getVariableResult).toHaveLength(1)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+  })
+
+  void it('Should apply ValueSize then ReportingValueSize sequential truncation', () => {
+    // First apply a smaller ValueSize (5) then a smaller ReportingValueSize (3)
+    setValueSize(mockChargingStation, 5)
+    setReportingValueSize(mockChargingStation, 3)
+    const request: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ],
+    }
+    const response = incomingRequestService.handleRequestGetVariables(mockChargingStation, request)
+    const result = response.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(result.attributeValue).toBeDefined()
+    expect(result.attributeValue?.length).toBeLessThanOrEqual(3)
+    resetReportingValueSize(mockChargingStation)
   })
 })
index 17caf1d7ee34f8f7ccfd190000f16e883435a9a6..b195c08762b453a1944908c2d7d535a4ade6340b 100644 (file)
@@ -36,7 +36,8 @@ await describe('B11 & B12 - Reset', async () => {
   const incomingRequestService = new OCPP20IncomingRequestService()
 
   await describe('B11 - Reset - Without Ongoing Transaction', async () => {
-    await it('B11.FR.01 - Should handle Reset request with Immediate type when no transactions', async () => {
+    // FR: B11.FR.01
+    await it('Should handle Reset request with Immediate type when no transactions', async () => {
       const resetRequest: OCPP20ResetRequest = {
         type: ResetEnumType.Immediate,
       }
@@ -56,7 +57,7 @@ await describe('B11 & B12 - Reset', async () => {
       ]).toContain(response.status)
     })
 
-    await it('B11.FR.01 - Should handle Reset request with OnIdle type when no transactions', async () => {
+    await it('Should handle Reset request with OnIdle type when no transactions', async () => {
       const resetRequest: OCPP20ResetRequest = {
         type: ResetEnumType.OnIdle,
       }
@@ -74,7 +75,8 @@ await describe('B11 & B12 - Reset', async () => {
       ]).toContain(response.status)
     })
 
-    await it('B11.FR.03+ - Should handle EVSE-specific reset request when no transactions', async () => {
+    // FR: B11.FR.03
+    await it('Should handle EVSE-specific reset request when no transactions', async () => {
       const resetRequest: OCPP20ResetRequest = {
         evseId: 1,
         type: ResetEnumType.Immediate,
@@ -93,7 +95,7 @@ await describe('B11 & B12 - Reset', async () => {
       ]).toContain(response.status)
     })
 
-    await it('B11.FR.03+ - Should reject reset for non-existent EVSE when no transactions', async () => {
+    await it('Should reject reset for non-existent EVSE when no transactions', async () => {
       const resetRequest: OCPP20ResetRequest = {
         evseId: 999, // Non-existent EVSE
         type: ResetEnumType.Immediate,
@@ -110,7 +112,7 @@ await describe('B11 & B12 - Reset', async () => {
       expect(response.statusInfo?.additionalInfo).toContain('EVSE 999')
     })
 
-    await it('B11.FR.01+ - Should return proper response structure for immediate reset without transactions', async () => {
+    await it('Should return proper response structure for immediate reset without transactions', async () => {
       const resetRequest: OCPP20ResetRequest = {
         type: ResetEnumType.Immediate,
       }
@@ -129,7 +131,7 @@ await describe('B11 & B12 - Reset', async () => {
       }
     })
 
-    await it('B11.FR.01+ - Should return proper response structure for OnIdle reset without transactions', async () => {
+    await it('Should return proper response structure for OnIdle reset without transactions', async () => {
       const resetRequest: OCPP20ResetRequest = {
         type: ResetEnumType.OnIdle,
       }
@@ -142,7 +144,7 @@ await describe('B11 & B12 - Reset', async () => {
       expect(response.status).toBe(ResetStatusEnumType.Accepted)
     })
 
-    await it('B11.FR.03+ - Should reject EVSE reset when not supported and no transactions', async () => {
+    await it('Should reject EVSE reset when not supported and no transactions', async () => {
       // Mock charging station without EVSE support
       const originalHasEvses = mockChargingStation.hasEvses
       ;(mockChargingStation as any).hasEvses = false
@@ -168,7 +170,7 @@ await describe('B11 & B12 - Reset', async () => {
       ;(mockChargingStation as any).hasEvses = originalHasEvses
     })
 
-    await it('B11.FR.03+ - Should handle EVSE-specific reset without transactions', async () => {
+    await it('Should handle EVSE-specific reset without transactions', async () => {
       const resetRequest: OCPP20ResetRequest = {
         evseId: 1,
         type: ResetEnumType.Immediate,
@@ -180,14 +182,13 @@ await describe('B11 & B12 - Reset', async () => {
 
       expect(response).toBeDefined()
       expect(response.status).toBe(ResetStatusEnumType.Accepted)
-      expect(response.statusInfo).toBeDefined()
-      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.NoError)
-      expect(response.statusInfo?.additionalInfo).toContain('EVSE 1 reset initiated')
+      expect(response.statusInfo).toBeUndefined()
     })
   })
 
   await describe('B12 - Reset - With Ongoing Transaction', async () => {
-    await it('B12.FR.02 - Should handle immediate reset with active transactions', async () => {
+    // FR: B12.FR.02
+    await it('Should handle immediate reset with active transactions', async () => {
       // Mock active transactions
       ;(mockChargingStation as any).getNumberOfRunningTransactions = () => 1
 
@@ -201,17 +202,14 @@ await describe('B11 & B12 - Reset', async () => {
 
       expect(response).toBeDefined()
       expect(response.status).toBe(ResetStatusEnumType.Accepted) // Should accept immediate reset
-      expect(response.statusInfo).toBeDefined()
-      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.NoError)
-      expect(response.statusInfo?.additionalInfo).toContain(
-        'active transactions will be terminated'
-      )
+      expect(response.statusInfo).toBeUndefined()
 
       // Reset mock
       ;(mockChargingStation as any).getNumberOfRunningTransactions = () => 0
     })
 
-    await it('B12.FR.01 - Should handle OnIdle reset with active transactions', async () => {
+    // FR: B12.FR.01
+    await it('Should handle OnIdle reset with active transactions', async () => {
       // Mock active transactions
       ;(mockChargingStation as any).getNumberOfRunningTransactions = () => 1
 
@@ -225,17 +223,14 @@ await describe('B11 & B12 - Reset', async () => {
 
       expect(response).toBeDefined()
       expect(response.status).toBe(ResetStatusEnumType.Scheduled) // Should schedule OnIdle reset
-      expect(response.statusInfo).toBeDefined()
-      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.NoError)
-      expect(response.statusInfo?.additionalInfo).toContain(
-        'scheduled after all transactions complete'
-      )
+      expect(response.statusInfo).toBeUndefined()
 
       // Reset mock
       ;(mockChargingStation as any).getNumberOfRunningTransactions = () => 0
     })
 
-    await it('B12.FR.03+ - Should handle EVSE-specific reset with active transactions', async () => {
+    // FR: B12.FR.03
+    await it('Should handle EVSE-specific reset with active transactions', async () => {
       // Mock active transactions
       ;(mockChargingStation as any).getNumberOfRunningTransactions = () => 1
 
@@ -258,7 +253,7 @@ await describe('B11 & B12 - Reset', async () => {
       ;(mockChargingStation as any).getNumberOfRunningTransactions = () => 0
     })
 
-    await it('B12.FR.03+ - Should reject EVSE reset when not supported with active transactions', async () => {
+    await it('Should reject EVSE reset when not supported with active transactions', async () => {
       // Mock charging station without EVSE support and active transactions
       const originalHasEvses = mockChargingStation.hasEvses
       ;(mockChargingStation as any).hasEvses = false
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetVariables.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetVariables.test.ts
new file mode 100644 (file)
index 0000000..c0a24ec
--- /dev/null
@@ -0,0 +1,731 @@
+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 OCPP20GetVariableDataType,
+  type OCPP20GetVariableResultType,
+  OCPP20OptionalVariableName,
+  OCPP20RequiredVariableName,
+  type OCPP20SetVariableResultType,
+  type OCPP20SetVariablesRequest,
+  OCPP20VendorVariableName,
+  ReasonCodeEnumType,
+  SetVariableStatusEnumType,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { createChargingStationWithEvses } from '../../../ChargingStationFactory.js'
+import { TEST_CHARGING_STATION_NAME, TEST_CONNECTOR_VALID_INSTANCE } from './OCPP20TestConstants.js'
+import {
+  resetLimits,
+  resetValueSizeLimits,
+  setConfigurationValueSize,
+  setStrictLimits,
+  setValueSize,
+  upsertConfigurationKey,
+} from './OCPP20TestUtils.js'
+
+interface IncomingRequestServicePrivate {
+  handleRequestGetVariables: (
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    chargingStation: any,
+    request: OCPP20GetVariablesRequest
+  ) => { getVariableResult: OCPP20GetVariableResultType[] }
+  handleRequestSetVariables: (
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    chargingStation: any,
+    request: OCPP20SetVariablesRequest
+  ) => { setVariableResult: OCPP20SetVariableResultType[] }
+}
+
+interface OCPP20GetVariablesRequest {
+  getVariableData: OCPP20GetVariableDataType[]
+}
+
+/* eslint-disable @typescript-eslint/no-floating-promises */
+describe('B07 - Set Variables', () => {
+  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()
+  const svc = incomingRequestService as unknown as IncomingRequestServicePrivate
+
+  // FR: B07.FR.01
+  it('Should handle SetVariables request with valid writable variables', () => {
+    const request: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeType: AttributeEnumType.Actual,
+          attributeValue: (Constants.DEFAULT_WEBSOCKET_PING_INTERVAL + 1).toString(),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        {
+          attributeType: AttributeEnumType.Actual,
+          attributeValue: (
+            millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL) + 1
+          ).toString(),
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ],
+    }
+
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, request)
+
+    expect(response).toBeDefined()
+    expect(response.setVariableResult).toBeDefined()
+    expect(Array.isArray(response.setVariableResult)).toBe(true)
+    expect(response.setVariableResult).toHaveLength(2)
+
+    const firstResult = response.setVariableResult[0]
+    expect(firstResult.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+    expect(firstResult.attributeType).toBe(AttributeEnumType.Actual)
+    expect(firstResult.component.name).toBe(OCPP20ComponentName.ChargingStation)
+    expect(firstResult.variable.name).toBe(OCPP20OptionalVariableName.WebSocketPingInterval)
+    expect(firstResult.attributeStatusInfo).toBeUndefined()
+
+    const secondResult = response.setVariableResult[1]
+    expect(secondResult.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+    expect(secondResult.attributeType).toBe(AttributeEnumType.Actual)
+    expect(secondResult.component.name).toBe(OCPP20ComponentName.OCPPCommCtrlr)
+    expect(secondResult.variable.name).toBe(OCPP20OptionalVariableName.HeartbeatInterval)
+    expect(secondResult.attributeStatusInfo).toBeUndefined()
+  })
+
+  // FR: B07.FR.02
+  it('Should handle SetVariables request with invalid variables/components', () => {
+    const request: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: '10',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'InvalidVariable' },
+        },
+        {
+          attributeValue: '20',
+          component: { name: 'InvalidComponent' },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ],
+    }
+
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, request)
+
+    expect(response.setVariableResult).toHaveLength(2)
+    const firstResult = response.setVariableResult[0]
+    expect(firstResult.attributeStatus).toBe(SetVariableStatusEnumType.UnknownVariable)
+    expect(firstResult.attributeStatusInfo).toBeDefined()
+    const secondResult = response.setVariableResult[1]
+    expect(secondResult.attributeStatus).toBe(SetVariableStatusEnumType.UnknownComponent)
+    expect(secondResult.attributeStatusInfo).toBeDefined()
+  })
+
+  // FR: B07.FR.03
+  it('Should handle SetVariables request with unsupported attribute type', () => {
+    const request: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeType: AttributeEnumType.Target, // Not supported for HeartbeatInterval
+          attributeValue: '30',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ],
+    }
+
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, request)
+
+    expect(response.setVariableResult).toHaveLength(1)
+    const result = response.setVariableResult[0]
+    expect(result.attributeStatus).toBe(SetVariableStatusEnumType.NotSupportedAttributeType)
+    expect(result.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedParam)
+  })
+
+  // FR: B07.FR.04
+  it('Should reject AuthorizeRemoteStart under Connector component for write', () => {
+    const request: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: 'true',
+          component: {
+            instance: TEST_CONNECTOR_VALID_INSTANCE,
+            name: OCPP20ComponentName.Connector,
+          },
+          variable: { name: OCPP20RequiredVariableName.AuthorizeRemoteStart },
+        },
+      ],
+    }
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, request)
+    expect(response.setVariableResult).toHaveLength(1)
+    const result = response.setVariableResult[0]
+    expect(result.attributeStatus).toBe(SetVariableStatusEnumType.UnknownComponent)
+  })
+
+  // FR: B07.FR.05
+  it('Should reject value exceeding max length at service level', () => {
+    const longValue = 'x'.repeat(2501)
+    const request: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: longValue,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ],
+    }
+
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, request)
+
+    expect(response.setVariableResult).toHaveLength(1)
+    const result = response.setVariableResult[0]
+    expect(result.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+    expect(result.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+  })
+
+  // FR: B07.FR.07
+  it('Should handle mixed SetVariables request with multiple outcomes', () => {
+    const longValue = 'y'.repeat(2501)
+    const request: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        // Accepted
+        {
+          attributeValue: (Constants.DEFAULT_WEBSOCKET_PING_INTERVAL + 3).toString(),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        // UnknownVariable
+        {
+          attributeValue: '10',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'UnknownVariable' },
+        },
+        // Unsupported attribute type (HeartbeatInterval)
+        {
+          attributeType: AttributeEnumType.Target,
+          attributeValue: '35',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+        // Unsupported attribute type (WebSocketPingInterval)
+        {
+          attributeType: AttributeEnumType.Target,
+          attributeValue: (Constants.DEFAULT_WEBSOCKET_PING_INTERVAL + 10).toString(),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        // Oversize value
+        {
+          attributeValue: longValue,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ],
+    }
+
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, request)
+
+    expect(response.setVariableResult).toHaveLength(5)
+    const [accepted, unknownVariable, unsupportedAttrHeartbeat, unsupportedAttrWs, oversize] =
+      response.setVariableResult
+    expect(accepted.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+    expect(accepted.attributeStatusInfo).toBeUndefined()
+    expect(unknownVariable.attributeStatus).toBe(SetVariableStatusEnumType.UnknownVariable)
+    expect(unsupportedAttrHeartbeat.attributeStatus).toBe(
+      SetVariableStatusEnumType.NotSupportedAttributeType
+    )
+    expect(unsupportedAttrWs.attributeStatus).toBe(
+      SetVariableStatusEnumType.NotSupportedAttributeType
+    )
+    expect(oversize.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+    expect(oversize.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+  })
+
+  // FR: B07.FR.08
+  it('Should reject Target attribute for WebSocketPingInterval explicitly', () => {
+    const request: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeType: AttributeEnumType.Target,
+          attributeValue: (Constants.DEFAULT_WEBSOCKET_PING_INTERVAL + 6).toString(),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ],
+    }
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, request)
+    expect(response.setVariableResult).toHaveLength(1)
+    const result = response.setVariableResult[0]
+    expect(result.attributeStatus).toBe(SetVariableStatusEnumType.NotSupportedAttributeType)
+    expect(result.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedParam)
+  })
+
+  // FR: B07.FR.09
+  it('Should reject immutable DateTime variable', () => {
+    const request: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeType: AttributeEnumType.Actual,
+          attributeValue: new Date(Date.now() + 1000).toISOString(),
+          component: { name: OCPP20ComponentName.ClockCtrlr },
+          variable: { name: OCPP20RequiredVariableName.DateTime },
+        },
+      ],
+    }
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, request)
+    expect(response.setVariableResult).toHaveLength(1)
+    const result = response.setVariableResult[0]
+    expect(result.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+    expect(result.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ReadOnly)
+  })
+
+  // FR: B07.FR.10
+  it('Should persist HeartbeatInterval and WebSocketPingInterval after setting', () => {
+    const hbNew = (millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL) + 20).toString()
+    const wsNew = (Constants.DEFAULT_WEBSOCKET_PING_INTERVAL + 20).toString()
+    const setRequest: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: hbNew,
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+        {
+          attributeValue: wsNew,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ],
+    }
+    svc.handleRequestSetVariables(mockChargingStation, setRequest)
+
+    const getResponse: { getVariableResult: OCPP20GetVariableResultType[] } =
+      svc.handleRequestGetVariables(mockChargingStation, {
+        getVariableData: [
+          {
+            attributeType: AttributeEnumType.Actual,
+            component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+            variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+          },
+          {
+            attributeType: AttributeEnumType.Actual,
+            component: { name: OCPP20ComponentName.ChargingStation },
+            variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+          },
+        ],
+      })
+
+    expect(getResponse.getVariableResult).toHaveLength(2)
+    const hbResult = getResponse.getVariableResult[0]
+    const wsResult = getResponse.getVariableResult[1]
+    expect(hbResult.attributeStatus).toBeDefined()
+    expect(hbResult.attributeValue).toBe(hbNew)
+    expect(wsResult.attributeValue).toBe(wsNew)
+  })
+
+  // FR: B07.FR.11
+  it('Should revert non-persistent TxUpdatedInterval after runtime reset', async () => {
+    const txValue = '77'
+    const setRequest: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: txValue,
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ],
+    }
+    svc.handleRequestSetVariables(mockChargingStation, setRequest)
+
+    const getBefore: { getVariableResult: OCPP20GetVariableResultType[] } =
+      svc.handleRequestGetVariables(mockChargingStation, {
+        getVariableData: [
+          {
+            attributeType: AttributeEnumType.Actual,
+            component: { name: OCPP20ComponentName.SampledDataCtrlr },
+            variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+          },
+        ],
+      })
+    expect(getBefore.getVariableResult[0].attributeValue).toBe(txValue)
+
+    const { OCPP20VariableManager } = await import(
+      '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
+    )
+    OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+
+    const getAfter: { getVariableResult: OCPP20GetVariableResultType[] } =
+      svc.handleRequestGetVariables(mockChargingStation, {
+        getVariableData: [
+          {
+            component: { name: OCPP20ComponentName.SampledDataCtrlr },
+            variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+          },
+        ],
+      })
+    expect(getAfter.getVariableResult[0].attributeValue).toBe('30') // default
+  })
+
+  // FR: B07.FR.12
+  it('Should reject all SetVariables when ItemsPerMessage limit exceeded', () => {
+    setStrictLimits(mockChargingStation, 1, 10000)
+    const request: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: (Constants.DEFAULT_WEBSOCKET_PING_INTERVAL + 2).toString(),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        {
+          attributeValue: (
+            millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL) + 2
+          ).toString(),
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ],
+    }
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, request)
+    expect(response.setVariableResult).toHaveLength(2)
+    response.setVariableResult.forEach(r => {
+      expect(r.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(r.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooManyElements)
+      expect(r.attributeStatusInfo?.additionalInfo).toMatch(/ItemsPerMessage limit 1 exceeded/)
+    })
+    resetLimits(mockChargingStation)
+  })
+
+  it('Should reject all SetVariables when BytesPerMessage limit exceeded (pre-calculation)', () => {
+    // Set strict bytes limit low enough for request pre-estimate to exceed
+    setStrictLimits(mockChargingStation, 100, 10)
+    const request: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: '5',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        {
+          attributeValue: '10',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ],
+    }
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, request)
+    expect(response.setVariableResult).toHaveLength(2)
+    response.setVariableResult.forEach(r => {
+      expect(r.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(r.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+      expect(r.attributeStatusInfo?.additionalInfo).toMatch(/BytesPerMessage limit 10 exceeded/)
+    })
+    resetLimits(mockChargingStation)
+  })
+
+  it('Should reject all SetVariables when BytesPerMessage limit exceeded (post-calculation)', () => {
+    const request: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeType: AttributeEnumType.Target,
+          attributeValue: '60',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        {
+          attributeValue: '10',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'UnknownVariableA' },
+        },
+        {
+          attributeValue: '11',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'UnknownVariableB' },
+        },
+        {
+          attributeValue: '12',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'UnknownVariableC' },
+        },
+        {
+          attributeValue: '13',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'UnknownVariableD' },
+        },
+        {
+          attributeValue: '14',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'UnknownVariableE' },
+        },
+        {
+          attributeValue: '15',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: 'UnknownVariableF' },
+        },
+      ],
+    }
+    const preEstimate = Buffer.byteLength(JSON.stringify(request.setVariableData), 'utf8')
+    const postCalcLimit = preEstimate + 10
+    upsertConfigurationKey(
+      mockChargingStation,
+      OCPP20RequiredVariableName.BytesPerMessage,
+      postCalcLimit.toString(),
+      false
+    )
+    expect(preEstimate).toBeLessThan(postCalcLimit)
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, request)
+    const actualSize = Buffer.byteLength(JSON.stringify(response.setVariableResult), 'utf8')
+    expect(actualSize).toBeGreaterThan(postCalcLimit)
+    expect(response.setVariableResult).toHaveLength(request.setVariableData.length)
+    response.setVariableResult.forEach(r => {
+      expect(r.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(r.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+      expect(r.attributeStatusInfo?.additionalInfo).toMatch(
+        new RegExp(`BytesPerMessage limit ${postCalcLimit.toString()} exceeded`)
+      )
+    })
+    resetLimits(mockChargingStation)
+  })
+
+  // Effective ConfigurationValueSize / ValueSize propagation tests
+  it('Should enforce ConfigurationValueSize when ValueSize unset (service propagation)', () => {
+    resetValueSizeLimits(mockChargingStation)
+    setConfigurationValueSize(mockChargingStation, 100)
+    upsertConfigurationKey(mockChargingStation, OCPP20RequiredVariableName.ValueSize, '')
+    const prefix = 'wss://example.com/'
+    const withinLimit = prefix + 'a'.repeat(100 - prefix.length)
+    const overLimit = prefix + 'a'.repeat(100 - prefix.length + 1)
+    let response = svc.handleRequestSetVariables(mockChargingStation, {
+      setVariableData: [
+        {
+          attributeValue: withinLimit,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ],
+    })
+    expect(response.setVariableResult[0].attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+    response = svc.handleRequestSetVariables(mockChargingStation, {
+      setVariableData: [
+        {
+          attributeValue: overLimit,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ],
+    })
+    const res = response.setVariableResult[0]
+    expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+    expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+    resetValueSizeLimits(mockChargingStation)
+  })
+
+  it('Should enforce ValueSize when ConfigurationValueSize unset (service propagation)', () => {
+    resetValueSizeLimits(mockChargingStation)
+    upsertConfigurationKey(
+      mockChargingStation,
+      OCPP20RequiredVariableName.ConfigurationValueSize,
+      ''
+    )
+    setValueSize(mockChargingStation, 120)
+    const prefix = 'wss://example.com/'
+    const withinLimit = prefix + 'b'.repeat(120 - prefix.length)
+    const overLimit = prefix + 'b'.repeat(120 - prefix.length + 1)
+    let response = svc.handleRequestSetVariables(mockChargingStation, {
+      setVariableData: [
+        {
+          attributeValue: withinLimit,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ],
+    })
+    expect(response.setVariableResult[0].attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+    response = svc.handleRequestSetVariables(mockChargingStation, {
+      setVariableData: [
+        {
+          attributeValue: overLimit,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ],
+    })
+    const res = response.setVariableResult[0]
+    expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+    expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+    resetValueSizeLimits(mockChargingStation)
+  })
+
+  it('Should use smaller ValueSize when ValueSize < ConfigurationValueSize (service propagation)', () => {
+    resetValueSizeLimits(mockChargingStation)
+    setConfigurationValueSize(mockChargingStation, 400)
+    setValueSize(mockChargingStation, 350)
+    const prefix = 'wss://example.com/'
+    const withinLimit = prefix + 'c'.repeat(350 - prefix.length)
+    const overLimit = prefix + 'c'.repeat(350 - prefix.length + 1)
+    let response = svc.handleRequestSetVariables(mockChargingStation, {
+      setVariableData: [
+        {
+          attributeValue: withinLimit,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ],
+    })
+    expect(response.setVariableResult[0].attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+    response = svc.handleRequestSetVariables(mockChargingStation, {
+      setVariableData: [
+        {
+          attributeValue: overLimit,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ],
+    })
+    const res = response.setVariableResult[0]
+    expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+    expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+    resetValueSizeLimits(mockChargingStation)
+  })
+
+  it('Should use smaller ConfigurationValueSize when ConfigurationValueSize < ValueSize (service propagation)', () => {
+    resetValueSizeLimits(mockChargingStation)
+    setConfigurationValueSize(mockChargingStation, 260)
+    setValueSize(mockChargingStation, 500)
+    const prefix = 'wss://example.com/'
+    const withinLimit = prefix + 'd'.repeat(260 - prefix.length)
+    const overLimit = prefix + 'd'.repeat(260 - prefix.length + 1)
+    let response = svc.handleRequestSetVariables(mockChargingStation, {
+      setVariableData: [
+        {
+          attributeValue: withinLimit,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ],
+    })
+    expect(response.setVariableResult[0].attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+    response = svc.handleRequestSetVariables(mockChargingStation, {
+      setVariableData: [
+        {
+          attributeValue: overLimit,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ],
+    })
+    const res = response.setVariableResult[0]
+    expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+    expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+    resetValueSizeLimits(mockChargingStation)
+  })
+
+  it('Should fallback to default absolute max length when both limits invalid/non-positive', () => {
+    resetValueSizeLimits(mockChargingStation)
+    setConfigurationValueSize(mockChargingStation, 0)
+    setValueSize(mockChargingStation, -5)
+    const prefix = 'wss://example.com/'
+    const validValue = prefix + 'e'.repeat(300 - prefix.length) // 300 < default absolute max length and < ConnectionUrl maxLength
+    const response = svc.handleRequestSetVariables(mockChargingStation, {
+      setVariableData: [
+        {
+          attributeValue: validValue,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ],
+    })
+    const res = response.setVariableResult[0]
+    expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+    expect(res.attributeStatusInfo).toBeUndefined()
+    resetValueSizeLimits(mockChargingStation)
+  })
+
+  // FR: B07.FR.12 (updated behavior: ConnectionUrl now readable after set)
+  it('Should allow ConnectionUrl read-back after setting', () => {
+    resetLimits(mockChargingStation)
+    const url = 'wss://central.example.com/ocpp'
+    const setRequest: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: url,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ],
+    }
+    svc.handleRequestSetVariables(mockChargingStation, setRequest)
+    const getResponse: { getVariableResult: OCPP20GetVariableResultType[] } =
+      svc.handleRequestGetVariables(mockChargingStation, {
+        getVariableData: [
+          {
+            attributeType: AttributeEnumType.Actual,
+            component: { name: OCPP20ComponentName.ChargingStation },
+            variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+          },
+        ],
+      })
+    expect(getResponse.getVariableResult).toHaveLength(1)
+    const result = getResponse.getVariableResult[0]
+    expect(result.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(result.attributeValue).toBe(url)
+    expect(result.attributeStatusInfo).toBeUndefined()
+    resetLimits(mockChargingStation)
+  })
+
+  it('Should accept ConnectionUrl with custom mqtt scheme (no scheme restriction)', () => {
+    resetLimits(mockChargingStation)
+    const url = 'mqtt://broker.internal:1883/ocpp'
+    const setRequest: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: url,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ],
+    }
+    const response: { setVariableResult: OCPP20SetVariableResultType[] } =
+      svc.handleRequestSetVariables(mockChargingStation, setRequest)
+    expect(response.setVariableResult).toHaveLength(1)
+    const setResult = response.setVariableResult[0]
+    expect(setResult.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+    expect(setResult.attributeStatusInfo).toBeUndefined()
+    const getResponse: { getVariableResult: OCPP20GetVariableResultType[] } =
+      svc.handleRequestGetVariables(mockChargingStation, {
+        getVariableData: [
+          {
+            attributeType: AttributeEnumType.Actual,
+            component: { name: OCPP20ComponentName.ChargingStation },
+            variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+          },
+        ],
+      })
+    expect(getResponse.getVariableResult).toHaveLength(1)
+    const getResult = getResponse.getVariableResult[0]
+    expect(getResult.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(getResult.attributeValue).toBe(url)
+    expect(getResult.attributeStatusInfo).toBeUndefined()
+    resetLimits(mockChargingStation)
+  })
+})
index 9957b93945eb7889707ac9807ca04f3e32b9c50a..fa6b179faccae84407973bab8fae532ac4fb915f 100644 (file)
@@ -41,6 +41,7 @@ await describe('B01 - Cold Boot Charging Station', async () => {
     websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
   })
 
+  // FR: B01.FR.01
   await it('Should build BootNotification request payload correctly with PowerUp reason', () => {
     const chargingStationInfo: ChargingStationType = {
       firmwareVersion: TEST_FIRMWARE_VERSION,
@@ -70,6 +71,7 @@ await describe('B01 - Cold Boot Charging Station', async () => {
     expect(payload.reason).toBe(BootReasonEnumType.PowerUp)
   })
 
+  // FR: B01.FR.02
   await it('Should build BootNotification request payload correctly with ApplicationReset reason', () => {
     const chargingStationInfo: ChargingStationType = {
       firmwareVersion: '2.1.3',
@@ -98,6 +100,7 @@ await describe('B01 - Cold Boot Charging Station', async () => {
     expect(payload.reason).toBe(BootReasonEnumType.ApplicationReset)
   })
 
+  // FR: B01.FR.03
   await it('Should build BootNotification request payload correctly with minimal required fields', () => {
     const chargingStationInfo: ChargingStationType = {
       model: 'Basic Model',
@@ -125,6 +128,7 @@ await describe('B01 - Cold Boot Charging Station', async () => {
     expect(payload.reason).toBe(BootReasonEnumType.FirmwareUpdate)
   })
 
+  // FR: B01.FR.04
   await it('Should handle all BootReasonEnumType values correctly', () => {
     const chargingStationInfo: ChargingStationType = {
       model: TEST_CHARGE_POINT_MODEL,
@@ -161,6 +165,7 @@ await describe('B01 - Cold Boot Charging Station', async () => {
     })
   })
 
+  // FR: B01.FR.05
   await it('Should validate payload structure matches OCPP20BootNotificationRequest interface', () => {
     const chargingStationInfo: ChargingStationType = {
       customData: {
index 400571325f3eb2b7a30b31621980160bbb3b8733..c6c2981c3032ff5b11cf790e4c0f20b039283970 100644 (file)
@@ -9,7 +9,7 @@ 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 { Constants, has } from '../../../../src/utils/index.js'
 import { createChargingStation } from '../../../ChargingStationFactory.js'
 import {
   TEST_CHARGE_POINT_MODEL,
@@ -36,6 +36,7 @@ await describe('G02 - Heartbeat', async () => {
     websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
   })
 
+  // FR: G02.FR.01
   await it('Should build HeartBeat request payload correctly with empty object', () => {
     const requestParams: OCPP20HeartbeatRequest = {}
 
@@ -51,6 +52,7 @@ await describe('G02 - Heartbeat', async () => {
     expect(Object.keys(payload as object)).toHaveLength(0)
   })
 
+  // FR: G02.FR.02
   await it('Should build HeartBeat request payload correctly without parameters', () => {
     // Test without passing any request parameters
     const payload = (requestService as any).buildRequestPayload(
@@ -63,6 +65,7 @@ await describe('G02 - Heartbeat', async () => {
     expect(Object.keys(payload as object)).toHaveLength(0)
   })
 
+  // FR: G02.FR.03
   await it('Should validate payload structure matches OCPP20HeartbeatRequest interface', () => {
     const requestParams: OCPP20HeartbeatRequest = {}
 
@@ -80,6 +83,7 @@ await describe('G02 - Heartbeat', async () => {
     expect(JSON.stringify(payload)).toBe('{}')
   })
 
+  // FR: G02.FR.04
   await it('Should handle HeartBeat request consistently across multiple calls', () => {
     const requestParams: OCPP20HeartbeatRequest = {}
 
@@ -109,6 +113,7 @@ await describe('G02 - Heartbeat', async () => {
     expect(JSON.stringify(payload3)).toBe('{}')
   })
 
+  // FR: G02.FR.05
   await it('Should handle HeartBeat request with different charging station configurations', () => {
     const alternativeChargingStation = createChargingStation({
       baseName: 'CS-ALTERNATIVE-002',
@@ -138,6 +143,7 @@ await describe('G02 - Heartbeat', async () => {
     expect(JSON.stringify(payload)).toBe('{}')
   })
 
+  // FR: G02.FR.06
   await it('Should verify HeartBeat request conforms to OCPP 2.0 specification', () => {
     const requestParams: OCPP20HeartbeatRequest = {}
 
@@ -151,7 +157,7 @@ await describe('G02 - Heartbeat', async () => {
     // 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)
+    expect(has('constructor', payload)).toBe(false)
 
     // Ensure it's a plain object and not an instance of another type
     expect(Object.getPrototypeOf(payload)).toBe(Object.prototype)
index e69734815aa4fc5c29ae777d5c464cff5e865202..2d8b6305f88a99d4bf010f01fac97327a6c34245 100644 (file)
@@ -28,7 +28,7 @@ import {
   TEST_FIRMWARE_VERSION,
 } from './OCPP20TestConstants.js'
 
-await describe('B07 - Get Base Report (NotifyReport)', async () => {
+await describe('B08 - NotifyReport', async () => {
   const mockResponseService = new OCPP20ResponseService()
   const requestService = new OCPP20RequestService(mockResponseService)
 
@@ -163,10 +163,6 @@ await describe('B07 - Get Base Report (NotifyReport)', async () => {
             type: AttributeEnumType.Actual,
             value: '60',
           },
-          {
-            type: AttributeEnumType.Target,
-            value: '60',
-          },
         ],
         variableCharacteristics: {
           dataType: DataEnumType.integer,
@@ -271,12 +267,7 @@ await describe('B07 - Get Base Report (NotifyReport)', async () => {
   })
 
   await it('Should handle different AttributeEnumType values correctly', () => {
-    const testAttributes = [
-      AttributeEnumType.Actual,
-      AttributeEnumType.Target,
-      AttributeEnumType.MinSet,
-      AttributeEnumType.MaxSet,
-    ]
+    const testAttributes = [AttributeEnumType.Actual]
 
     testAttributes.forEach((attributeType, index) => {
       const reportData: ReportDataType[] = [
@@ -452,18 +443,6 @@ await describe('B07 - Get Base Report (NotifyReport)', async () => {
             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,
@@ -487,11 +466,8 @@ await describe('B07 - Get Base Report (NotifyReport)', async () => {
     )
 
     expect(payload).toBeDefined()
-    expect(payload.reportData[0].variableAttribute).toHaveLength(4)
+    expect(payload.reportData[0].variableAttribute).toHaveLength(1)
     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', () => {
index 2b1c497b23f25e2c9c531a831fafaa085641b7fe..a29f2e670661c2e88d79913ef03fa2ae0714ec98 100644 (file)
@@ -40,6 +40,7 @@ await describe('G01 - Status Notification', async () => {
     websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
   })
 
+  // FR: G01.FR.01
   await it('Should build StatusNotification request payload correctly with Available status', () => {
     const testTimestamp = new Date('2024-01-15T10:30:00.000Z')
 
@@ -64,6 +65,7 @@ await describe('G01 - Status Notification', async () => {
     expect(payload.timestamp).toBe(testTimestamp)
   })
 
+  // FR: G01.FR.02
   await it('Should build StatusNotification request payload correctly with Occupied status', () => {
     const testTimestamp = new Date('2024-01-15T11:45:30.000Z')
 
@@ -87,6 +89,7 @@ await describe('G01 - Status Notification', async () => {
     expect(payload.timestamp).toBe(testTimestamp)
   })
 
+  // FR: G01.FR.03
   await it('Should build StatusNotification request payload correctly with Faulted status', () => {
     const testTimestamp = new Date('2024-01-15T12:15:45.500Z')
 
@@ -110,6 +113,7 @@ await describe('G01 - Status Notification', async () => {
     expect(payload.timestamp).toBe(testTimestamp)
   })
 
+  // FR: G01.FR.04
   await it('Should handle all OCPP20ConnectorStatusEnumType values correctly', () => {
     const testTimestamp = new Date('2024-01-15T13:00:00.000Z')
 
@@ -143,6 +147,7 @@ await describe('G01 - Status Notification', async () => {
     })
   })
 
+  // FR: G01.FR.05
   await it('Should validate payload structure matches OCPP20StatusNotificationRequest interface', () => {
     const testTimestamp = new Date('2024-01-15T14:30:15.123Z')
 
@@ -180,6 +185,7 @@ await describe('G01 - Status Notification', async () => {
     expect(payload.timestamp).toBe(testTimestamp)
   })
 
+  // FR: G01.FR.06
   await it('Should handle edge case connector and EVSE IDs correctly', () => {
     const testTimestamp = new Date('2024-01-15T15:45:00.000Z')
 
@@ -224,6 +230,7 @@ await describe('G01 - Status Notification', async () => {
     expect(payloadEvse0.timestamp).toBe(testTimestamp)
   })
 
+  // FR: G01.FR.07
   await it('Should handle different timestamp formats correctly', () => {
     const testCases = [
       new Date('2024-01-01T00:00:00.000Z'), // Start of year
diff --git a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts
new file mode 100644 (file)
index 0000000..4c84918
--- /dev/null
@@ -0,0 +1,137 @@
+import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
+import type { ConfigurationKey } from '../../../../src/types/ChargingStationOcppConfiguration.js'
+
+import { OCPP20RequiredVariableName } from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+
+/**
+ * Reset message size and element limits to generous defaults after tests manipulating them.
+ * Defaults chosen to exceed any test constructed payload sizes.
+ * @param chargingStation Charging station test instance whose configuration limits are reset.
+ */
+export function resetLimits (chargingStation: ChargingStation) {
+  upsertConfigurationKey(chargingStation, OCPP20RequiredVariableName.ItemsPerMessage, '100')
+  upsertConfigurationKey(chargingStation, OCPP20RequiredVariableName.BytesPerMessage, '10000')
+}
+
+/**
+ * Clear or enlarge ReportingValueSize to avoid side-effects for subsequent tests.
+ * @param chargingStation Charging station test instance whose ReportingValueSize is adjusted.
+ */
+export function resetReportingValueSize (chargingStation: ChargingStation) {
+  upsertConfigurationKey(
+    chargingStation,
+    OCPP20RequiredVariableName.ReportingValueSize,
+    Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH.toString()
+  )
+}
+
+/**
+ * Reset configuration/storage value size limits to generous defaults.
+ * Applies both ConfigurationValueSize and ValueSize (DeviceDataCtrlr).
+ * @param chargingStation Charging station instance.
+ */
+export function resetValueSizeLimits (chargingStation: ChargingStation) {
+  upsertConfigurationKey(
+    chargingStation,
+    OCPP20RequiredVariableName.ConfigurationValueSize,
+    Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH.toString()
+  )
+  upsertConfigurationKey(
+    chargingStation,
+    OCPP20RequiredVariableName.ValueSize,
+    Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH.toString()
+  )
+}
+
+/**
+ * Set ConfigurationValueSize (used at set-time) to specified positive integer.
+ * @param chargingStation Charging station instance.
+ * @param size Effective configuration value size limit.
+ */
+export function setConfigurationValueSize (chargingStation: ChargingStation, size: number) {
+  upsertConfigurationKey(
+    chargingStation,
+    OCPP20RequiredVariableName.ConfigurationValueSize,
+    size.toString()
+  )
+}
+
+/**
+ * Set a small ReportingValueSize for truncation tests.
+ * @param chargingStation Charging station instance.
+ * @param size Desired reporting value size limit (positive integer).
+ */
+export function setReportingValueSize (chargingStation: ChargingStation, size: number) {
+  upsertConfigurationKey(
+    chargingStation,
+    OCPP20RequiredVariableName.ReportingValueSize,
+    size.toString()
+  )
+}
+
+/**
+ * Configure strict limits for ItemsPerMessage and BytesPerMessage.
+ * @param chargingStation Charging station instance.
+ * @param itemsLimit Maximum number of items per message.
+ * @param bytesLimit Maximum number of bytes per message.
+ */
+export function setStrictLimits (
+  chargingStation: ChargingStation,
+  itemsLimit: number,
+  bytesLimit: number
+) {
+  upsertConfigurationKey(
+    chargingStation,
+    OCPP20RequiredVariableName.ItemsPerMessage,
+    itemsLimit.toString()
+  )
+  upsertConfigurationKey(
+    chargingStation,
+    OCPP20RequiredVariableName.BytesPerMessage,
+    bytesLimit.toString()
+  )
+}
+
+/**
+ * Set ValueSize (applied before ReportingValueSize for get-time truncation and effective set-time limit computation).
+ * @param chargingStation Charging station instance.
+ * @param size Desired stored value size limit.
+ */
+export function setValueSize (chargingStation: ChargingStation, size: number) {
+  upsertConfigurationKey(chargingStation, OCPP20RequiredVariableName.ValueSize, size.toString())
+}
+
+/**
+ * Upsert a configuration key with provided value and readonly flag (default false).
+ * @param chargingStation Charging station instance.
+ * @param key Configuration key name.
+ * @param value Configuration key value as string.
+ * @param readonly Whether the key is read-only (default false).
+ */
+export function upsertConfigurationKey (
+  chargingStation: ChargingStation,
+  key: string,
+  value: string,
+  readonly = false
+) {
+  const configKeys = ensureConfig(chargingStation)
+  const configKey = configKeys.find(k => k.key === key)
+  if (configKey) {
+    configKey.value = value
+    if (readonly) configKey.readonly = readonly
+  } else {
+    configKeys.push({ key, readonly, value })
+  }
+}
+
+/**
+ * Ensure ocppConfiguration and configurationKey array are initialized and return the array.
+ * @param chargingStation Charging station instance to initialize.
+ * @returns Mutable array of configuration keys for the station.
+ */
+function ensureConfig (chargingStation: ChargingStation): ConfigurationKey[] {
+  chargingStation.ocppConfiguration ??= { configurationKey: [] }
+  chargingStation.ocppConfiguration.configurationKey ??= []
+  return chargingStation.ocppConfiguration.configurationKey
+}
index 78804450797cfad5d5ea1a63c8d65519ea6cc53d..8a1fe78a2b29da6eaeac28ce11c7f6e6153fd685 100644 (file)
@@ -4,7 +4,12 @@ import { expect } from '@std/expect'
 import { millisecondsToSeconds } from 'date-fns'
 import { describe, it } from 'node:test'
 
+import {
+  deleteConfigurationKey,
+  getConfigurationKey,
+} from '../../../../src/charging-station/ConfigurationKeyUtils.js'
 import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
+import { VARIABLE_REGISTRY } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableRegistry.js'
 import {
   AttributeEnumType,
   type ComponentType,
@@ -13,12 +18,45 @@ import {
   type OCPP20GetVariableDataType,
   OCPP20OptionalVariableName,
   OCPP20RequiredVariableName,
+  type OCPP20SetVariableDataType,
+  OCPP20VendorVariableName,
   ReasonCodeEnumType,
+  SetVariableStatusEnumType,
   type VariableType,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { createChargingStationWithEvses } from '../../../ChargingStationFactory.js'
+import {
+  createChargingStation,
+  createChargingStationWithEvses,
+} from '../../../ChargingStationFactory.js'
 import { TEST_CHARGING_STATION_NAME } from './OCPP20TestConstants.js'
+import {
+  resetReportingValueSize,
+  resetValueSizeLimits,
+  setConfigurationValueSize,
+  setReportingValueSize,
+  setValueSize,
+  upsertConfigurationKey,
+} from './OCPP20TestUtils.js'
+const CONNECTION_URL_MAX_LENGTH =
+  VARIABLE_REGISTRY[
+    `${OCPP20ComponentName.ChargingStation}::${OCPP20VendorVariableName.ConnectionUrl}`
+  ].maxLength ?? 512
+
+/**
+ * Build a syntactically valid ws://example URL of desired length.
+ * Keeps prefix constant then pads remainder with a filler character.
+ * @param targetLength Desired total length of the URL string.
+ * @param fillerChar Character used to pad after the base prefix.
+ * @returns Valid WebSocket URL string of exact targetLength.
+ */
+function buildWsExampleUrl (targetLength: number, fillerChar = 'a'): string {
+  const base = 'ws://example/'
+  if (targetLength <= base.length) {
+    throw new Error('targetLength too small')
+  }
+  return base + fillerChar.repeat(targetLength - base.length)
+}
 
 await describe('OCPP20VariableManager test suite', async () => {
   // Create mock ChargingStation with EVSEs for OCPP 2.0 testing
@@ -39,16 +77,16 @@ await describe('OCPP20VariableManager test suite', async () => {
   await describe('getVariables method tests', async () => {
     const manager = OCPP20VariableManager.getInstance()
 
-    await it('Should handle valid ChargingStation component requests', () => {
+    await it('Should handle valid OCPPCommCtrlr and TxCtrlr component requests', () => {
       const request: OCPP20GetVariableDataType[] = [
         {
           attributeType: AttributeEnumType.Actual,
-          component: { name: OCPP20ComponentName.ChargingStation },
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
           variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
         },
         {
           attributeType: AttributeEnumType.Actual,
-          component: { name: OCPP20ComponentName.ChargingStation },
+          component: { name: OCPP20ComponentName.TxCtrlr },
           variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
         },
       ]
@@ -63,40 +101,65 @@ await describe('OCPP20VariableManager test suite', async () => {
       expect(result[0].attributeValue).toBe(
         millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL).toString()
       )
-      expect(result[0].component.name).toBe(OCPP20ComponentName.ChargingStation)
+      expect(result[0].component.name).toBe(OCPP20ComponentName.OCPPCommCtrlr)
       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].component.name).toBe(OCPP20ComponentName.TxCtrlr)
       expect(result[1].variable.name).toBe(OCPP20RequiredVariableName.EVConnectionTimeOut)
       expect(result[1].attributeStatusInfo).toBeUndefined()
     })
 
-    await it('Should handle valid Connector component requests', () => {
+    await it('Should accept default true value for AuthorizeRemoteStart (AuthCtrlr)', () => {
+      const manager = OCPP20VariableManager.getInstance()
       const request: OCPP20GetVariableDataType[] = [
         {
-          component: {
-            instance: '1',
-            name: OCPP20ComponentName.Connector,
-          },
+          component: { name: OCPP20ComponentName.AuthCtrlr },
           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()
+      expect(result[0].attributeValue).toBe('true')
+      expect(result[0].component.name).toBe(OCPP20ComponentName.AuthCtrlr)
+    })
+
+    await it('Should accept setting and getting AuthorizeRemoteStart = true (AuthCtrlr)', () => {
+      const setRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: 'true',
+          component: { name: OCPP20ComponentName.AuthCtrlr },
+          variable: { name: OCPP20RequiredVariableName.AuthorizeRemoteStart },
+        },
+      ])[0]
+      expect(setRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      const getRes = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.AuthCtrlr },
+          variable: { name: OCPP20RequiredVariableName.AuthorizeRemoteStart },
+        },
+      ])[0]
+      expect(getRes.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(getRes.attributeValue).toBe('true')
+    })
+
+    await it('Should reject invalid values for AuthorizeRemoteStart (AuthCtrlr)', () => {
+      const invalidValues = ['', '1', 'TRUE', 'False', 'yes']
+      for (const val of invalidValues) {
+        const res = manager.setVariables(mockChargingStation, [
+          {
+            attributeValue: val,
+            component: { name: OCPP20ComponentName.AuthCtrlr },
+            variable: { name: OCPP20RequiredVariableName.AuthorizeRemoteStart },
+          },
+        ])[0]
+        expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+        expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      }
     })
 
     await it('Should handle invalid component gracefully', () => {
@@ -113,24 +176,23 @@ await describe('OCPP20VariableManager test suite', async () => {
 
       expect(Array.isArray(result)).toBe(true)
       expect(result).toHaveLength(1)
+      // Behavior: invalid component is rejected before variable support check
       expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.UnknownComponent)
-      expect(result[0].attributeType).toBeUndefined()
+      expect(result[0].attributeType).toBe(AttributeEnumType.Actual)
       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(ReasonCodeEnumType.NotFound)
-      expect(result[0].attributeStatusInfo?.additionalInfo).toContain(
-        'Component InvalidComponent is not supported'
-      )
+      expect(result[0].attributeStatusInfo?.additionalInfo).toContain('Component InvalidComponent')
     })
 
-    await it('Should handle invalid variable gracefully', () => {
+    await it('Should handle unsupported attribute type 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 },
+          attributeType: AttributeEnumType.Target, // Not supported for this variable
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
         },
       ]
 
@@ -138,41 +200,30 @@ await describe('OCPP20VariableManager test suite', async () => {
 
       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].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('InvalidVariable')
+      expect(result[0].component.name).toBe(OCPP20ComponentName.OCPPCommCtrlr)
+      expect(result[0].variable.name).toBe(OCPP20OptionalVariableName.HeartbeatInterval)
       expect(result[0].attributeStatusInfo).toBeDefined()
-      expect(result[0].attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.NotFound)
+      expect(result[0].attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedParam)
       expect(result[0].attributeStatusInfo?.additionalInfo).toContain(
-        'Variable InvalidVariable is not supported'
+        'Attribute type Target is not supported'
       )
     })
 
-    await it('Should handle unsupported attribute type gracefully', () => {
+    await it('Should reject Target attribute for WebSocketPingInterval', () => {
       const request: OCPP20GetVariableDataType[] = [
         {
-          attributeType: AttributeEnumType.Target, // Not supported for this variable
+          attributeType: AttributeEnumType.Target,
           component: { name: OCPP20ComponentName.ChargingStation },
-          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
         },
       ]
-
       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(ReasonCodeEnumType.UnsupportedParam)
-      expect(result[0].attributeStatusInfo?.additionalInfo).toContain(
-        'Attribute type Target is not supported'
-      )
+      expect(result[0].variable.name).toBe(OCPP20OptionalVariableName.WebSocketPingInterval)
     })
 
     await it('Should handle non-existent connector instance', () => {
@@ -191,7 +242,7 @@ await describe('OCPP20VariableManager test suite', async () => {
       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].attributeType).toBe(AttributeEnumType.Actual)
       expect(result[0].attributeValue).toBeUndefined()
       expect(result[0].component.name).toBe(OCPP20ComponentName.Connector)
       expect(result[0].component.instance).toBe('999')
@@ -206,7 +257,7 @@ await describe('OCPP20VariableManager test suite', async () => {
     await it('Should handle multiple variables in single request', () => {
       const request: OCPP20GetVariableDataType[] = [
         {
-          component: { name: OCPP20ComponentName.ChargingStation },
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
           variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
         },
         {
@@ -215,7 +266,7 @@ await describe('OCPP20VariableManager test suite', async () => {
         },
         {
           attributeType: AttributeEnumType.Actual,
-          component: { name: OCPP20ComponentName.ChargingStation },
+          component: { instance: 'Default', name: OCPP20ComponentName.OCPPCommCtrlr },
           variable: { name: OCPP20RequiredVariableName.MessageTimeout },
         },
       ]
@@ -226,16 +277,16 @@ await describe('OCPP20VariableManager test suite', async () => {
       expect(result).toHaveLength(3)
       // First variable: HeartbeatInterval
       expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
-      expect(result[0].attributeType).toBeUndefined()
+      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].component.name).toBe(OCPP20ComponentName.OCPPCommCtrlr)
       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].attributeType).toBe(AttributeEnumType.Actual)
       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)
@@ -244,12 +295,13 @@ await describe('OCPP20VariableManager test suite', async () => {
       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].component.name).toBe(OCPP20ComponentName.OCPPCommCtrlr)
+      expect(result[2].component.instance).toBe('Default')
       expect(result[2].variable.name).toBe(OCPP20RequiredVariableName.MessageTimeout)
       expect(result[2].attributeStatusInfo).toBeUndefined()
     })
 
-    await it('Should handle EVSE component when supported', () => {
+    await it('Should reject EVSE component as unsupported', () => {
       const request: OCPP20GetVariableDataType[] = [
         {
           component: {
@@ -264,22 +316,24 @@ await describe('OCPP20VariableManager test suite', async () => {
 
       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].attributeStatus).toBe(GetVariableStatusEnumType.UnknownComponent)
+      expect(result[0].attributeType).toBe(AttributeEnumType.Actual)
+      expect(result[0].attributeValue).toBeUndefined()
       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()
+      expect(result[0].attributeStatusInfo).toBeDefined()
+      expect(result[0].attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.NotFound)
     })
   })
 
   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 }
+    await it('Should validate OCPPCommCtrlr component as always valid', () => {
+      // Behavior: Connector components are unsupported and isComponentValid returns false.
+      // Scope: Per-connector variable validation not implemented; tests assert current behavior.
+      const component: ComponentType = { name: OCPP20ComponentName.OCPPCommCtrlr }
 
       // 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
@@ -287,12 +341,14 @@ await describe('OCPP20VariableManager test suite', async () => {
       expect(isValid).toBe(true)
     })
 
-    await it('Should validate Connector component when connectors exist', () => {
+    // Behavior: Connector component validation returns false (unsupported).
+    // Change process: Enable via OpenSpec proposal before altering this expectation.
+    await it('Should reject Connector component as unsupported even 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)
+      expect(isValid).toBe(false)
     })
 
     await it('Should reject invalid connector instance', () => {
@@ -308,15 +364,11 @@ await describe('OCPP20VariableManager test suite', async () => {
     const manager = OCPP20VariableManager.getInstance()
 
     await it('Should support standard HeartbeatInterval variable', () => {
-      const component: ComponentType = { name: OCPP20ComponentName.ChargingStation }
+      const component: ComponentType = { name: OCPP20ComponentName.OCPPCommCtrlr }
       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
-      )
+      const isSupported = (manager as any).isVariableSupported(component, variable)
       expect(isSupported).toBe(true)
     })
 
@@ -325,52 +377,1235 @@ await describe('OCPP20VariableManager test suite', async () => {
       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
-      )
+      const isSupported = (manager as any).isVariableSupported(component, variable)
       expect(isSupported).toBe(true)
     })
 
     await it('Should reject unknown variables', () => {
-      const component: ComponentType = { name: OCPP20ComponentName.ChargingStation }
+      const component: ComponentType = { name: OCPP20ComponentName.OCPPCommCtrlr }
       // 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
-      )
+      const isSupported = (manager as any).isVariableSupported(component, variable)
       expect(isSupported).toBe(false)
     })
   })
 
-  await describe('Attribute type validation tests', async () => {
+  await describe('setVariables method tests', async () => {
     const manager = OCPP20VariableManager.getInstance()
 
-    await it('Should support Actual attribute by default', () => {
-      const variable: VariableType = { name: OCPP20OptionalVariableName.HeartbeatInterval }
+    await it('Should accept setting writable variables (Actual default)', () => {
+      const request: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: (Constants.DEFAULT_WEBSOCKET_PING_INTERVAL + 1).toString(),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        {
+          attributeType: AttributeEnumType.Actual,
+          attributeValue: (
+            millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL) + 1
+          ).toString(),
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { 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)
+      const result = manager.setVariables(mockChargingStation, request)
+
+      expect(result).toHaveLength(2)
+      expect(result[0].attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(result[0].attributeType).toBe(AttributeEnumType.Actual)
+      expect(result[0].attributeStatusInfo).toBeUndefined()
+      expect(result[1].attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(result[1].attributeType).toBe(AttributeEnumType.Actual)
+      expect(result[1].attributeStatusInfo).toBeUndefined()
     })
 
-    await it('Should reject unsupported attribute types for most variables', () => {
-      const variable: VariableType = { name: OCPP20OptionalVariableName.HeartbeatInterval }
+    await it('Should reject setting variable on unknown component', () => {
+      const request: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: '20',
+          component: { name: 'InvalidComponent' as unknown as OCPP20ComponentName },
+          variable: { 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).isAttributeTypeSupported(
-        variable,
-        AttributeEnumType.Target
+      const result = manager.setVariables(mockChargingStation, request)
+
+      expect(result).toHaveLength(1)
+      expect(result[0].attributeStatus).toBe(SetVariableStatusEnumType.UnknownComponent)
+      expect(result[0].attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.NotFound)
+    })
+
+    await it('Should reject setting unknown variable', () => {
+      const request: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: '10',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: 'UnknownVariable' as unknown as VariableType['name'] },
+        },
+      ]
+
+      const result = manager.setVariables(mockChargingStation, request)
+
+      expect(result).toHaveLength(1)
+      expect(result[0].attributeStatus).toBe(SetVariableStatusEnumType.UnknownVariable)
+      expect(result[0].attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.NotFound)
+    })
+
+    await it('Should reject unsupported attribute type', () => {
+      const request: OCPP20SetVariableDataType[] = [
+        {
+          attributeType: AttributeEnumType.Target,
+          attributeValue: '30',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ]
+
+      const result = manager.setVariables(mockChargingStation, request)
+
+      expect(result).toHaveLength(1)
+      expect(result[0].attributeStatus).toBe(SetVariableStatusEnumType.NotSupportedAttributeType)
+      expect(result[0].attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedParam)
+    })
+
+    await it('Should reject value exceeding max length', () => {
+      const longValue = 'x'.repeat(2501)
+      const request: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: longValue,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ]
+
+      const result = manager.setVariables(mockChargingStation, request)
+
+      expect(result).toHaveLength(1)
+      expect(result[0].attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(result[0].attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+      expect(result[0].attributeStatusInfo?.additionalInfo).toContain(
+        'exceeds effective size limit'
       )
-      expect(isSupported).toBe(false)
+    })
+
+    await it('Should handle multiple mixed SetVariables in one call', () => {
+      const request: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: (Constants.DEFAULT_WEBSOCKET_PING_INTERVAL + 2).toString(),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+        {
+          attributeValue: '10',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: 'InvalidVariable' as unknown as VariableType['name'] },
+        },
+        {
+          attributeType: AttributeEnumType.Target,
+          attributeValue: '45',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ]
+      const result = manager.setVariables(mockChargingStation, request)
+      expect(result[0].attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(result[0].attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should reject TxUpdatedInterval zero and negative and non-integer', () => {
+      const zeroReq: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: '0',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ]
+      const negReq: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: '-5',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ]
+      const nonIntReq: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: '12.3',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ]
+      const zeroRes = manager.setVariables(mockChargingStation, zeroReq)[0]
+      const negRes = manager.setVariables(mockChargingStation, negReq)[0]
+      const nonIntRes = manager.setVariables(mockChargingStation, nonIntReq)[0]
+      expect(zeroRes.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(zeroRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValuePositiveOnly)
+      expect(zeroRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer > 0 required')
+      expect(negRes.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(negRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValuePositiveOnly)
+      expect(negRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer > 0 required')
+      expect(nonIntRes.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(nonIntRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      expect(nonIntRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer')
+    })
+
+    await it('Should accept setting ConnectionUrl with valid ws URL', () => {
+      const req: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: 'ws://example.com/ocpp',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ]
+      const res = manager.setVariables(mockChargingStation, req)[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(res.attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should accept ConnectionUrl with ftp scheme (no scheme restriction)', () => {
+      const req: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: 'ftp://example.com/ocpp',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ]
+      const res = manager.setVariables(mockChargingStation, req)[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(res.attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should accept ConnectionUrl with custom mqtt scheme', () => {
+      const req: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: 'mqtt://broker.example.com/ocpp',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ]
+      const res = manager.setVariables(mockChargingStation, req)[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(res.attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should allow ConnectionUrl retrieval after set', () => {
+      manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: 'wss://example.com/ocpp',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])
+      const getData: OCPP20GetVariableDataType[] = [
+        {
+          attributeType: AttributeEnumType.Actual,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ]
+      const getResult = manager.getVariables(mockChargingStation, getData)[0]
+      expect(getResult.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(getResult.attributeValue).toBe('wss://example.com/ocpp')
+      expect(getResult.attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should revert non-persistent TxUpdatedInterval after simulated restart', () => {
+      manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '99',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ])
+      const beforeReset = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ])[0]
+      expect(beforeReset.attributeValue).toBe('99')
+      manager.resetRuntimeOverrides()
+      const afterReset = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ])[0]
+      expect(afterReset.attributeValue).toBe('30')
+    })
+
+    await it('Should keep persistent ConnectionUrl after simulated restart', () => {
+      manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: 'https://central.example.com/ocpp',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])
+      manager.resetRuntimeOverrides()
+      const getResult = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(getResult.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(getResult.attributeValue).toBe('https://central.example.com/ocpp')
+      expect(getResult.attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should reject Target attribute for WebSocketPingInterval', () => {
+      const request: OCPP20SetVariableDataType[] = [
+        {
+          attributeType: AttributeEnumType.Target,
+          attributeValue: (Constants.DEFAULT_WEBSOCKET_PING_INTERVAL + 5).toString(),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ]
+      const result = manager.setVariables(mockChargingStation, request)
+      expect(result).toHaveLength(1)
+      expect(result[0].attributeStatus).toBe(SetVariableStatusEnumType.NotSupportedAttributeType)
+      expect(result[0].attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedParam)
+    })
+
+    await it('Should validate HeartbeatInterval positive integer >0', () => {
+      const req: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: (
+            millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL) + 10
+          ).toString(),
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ]
+      const res = manager.setVariables(mockChargingStation, req)[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(res.attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should reject HeartbeatInterval zero, negative, non-integer', () => {
+      const zeroRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '0',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      const negRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '-1',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      const nonIntRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '10.5',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      expect(zeroRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValuePositiveOnly)
+      expect(zeroRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer > 0 required')
+      expect(negRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValuePositiveOnly)
+      expect(negRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer > 0 required')
+      expect(nonIntRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      expect(nonIntRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer')
+    })
+
+    await it('Should accept WebSocketPingInterval zero (disable) and positive', () => {
+      const zeroRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '0',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ])[0]
+      const posRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: (Constants.DEFAULT_WEBSOCKET_PING_INTERVAL + 10).toString(),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ])[0]
+      expect(zeroRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(zeroRes.attributeStatusInfo).toBeUndefined()
+      expect(posRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(posRes.attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should reject WebSocketPingInterval negative and non-integer', () => {
+      const negRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '-2',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ])[0]
+      const nonIntRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '5.7',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval },
+        },
+      ])[0]
+      expect(negRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValueZeroNotAllowed)
+      expect(negRes.attributeStatusInfo?.additionalInfo).toContain('Integer >= 0 required')
+      expect(nonIntRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValueZeroNotAllowed)
+      expect(nonIntRes.attributeStatusInfo?.additionalInfo).toContain('Integer >= 0 required')
+    })
+
+    await it('Should validate EVConnectionTimeOut positive integer >0 and reject invalid', () => {
+      const okRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: (Constants.DEFAULT_EV_CONNECTION_TIMEOUT + 5).toString(),
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
+        },
+      ])[0]
+      expect(okRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(okRes.attributeStatusInfo).toBeUndefined()
+      const zeroRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '0',
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
+        },
+      ])[0]
+      const negRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '-10',
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
+        },
+      ])[0]
+      const nonIntRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '15.2',
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
+        },
+      ])[0]
+      expect(zeroRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValuePositiveOnly)
+      expect(zeroRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer > 0 required')
+      expect(negRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValuePositiveOnly)
+      expect(negRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer > 0 required')
+      expect(nonIntRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      expect(nonIntRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer')
+    })
+
+    await it('Should validate MessageTimeout positive integer >0 and reject invalid', () => {
+      const okRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: (mockChargingStation.getConnectionTimeout() + 5).toString(),
+          component: { instance: 'Default', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageTimeout },
+        },
+      ])[0]
+      expect(okRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(okRes.attributeStatusInfo).toBeUndefined()
+      const zeroRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '0',
+          component: { instance: 'Default', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageTimeout },
+        },
+      ])[0]
+      const negRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '-25',
+          component: { instance: 'Default', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageTimeout },
+        },
+      ])[0]
+      const nonIntRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '30.9',
+          component: { instance: 'Default', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageTimeout },
+        },
+      ])[0]
+      expect(zeroRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValuePositiveOnly)
+      expect(zeroRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer > 0 required')
+      expect(negRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValuePositiveOnly)
+      expect(negRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer > 0 required')
+      expect(nonIntRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      expect(nonIntRes.attributeStatusInfo?.additionalInfo).toContain('Positive integer')
+    })
+
+    await it('Should avoid duplicate persistence operations when value unchanged', () => {
+      const keyBefore = getConfigurationKey(
+        mockChargingStation,
+        OCPP20OptionalVariableName.HeartbeatInterval as unknown as VariableType['name']
+      )
+      expect(keyBefore).toBeDefined()
+      const originalValue = keyBefore?.value
+      const first = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: originalValue ?? '30',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      expect(first.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      const changed = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: (parseInt(originalValue ?? '30', 10) + 5).toString(),
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      expect(changed.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      const keyAfterChange = getConfigurationKey(
+        mockChargingStation,
+        OCPP20OptionalVariableName.HeartbeatInterval as unknown as VariableType['name']
+      )
+      expect(keyAfterChange?.value).not.toBe(originalValue)
+      const reverted = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: originalValue ?? '30',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      expect(reverted.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      const keyAfterRevert = getConfigurationKey(
+        mockChargingStation,
+        OCPP20OptionalVariableName.HeartbeatInterval as unknown as VariableType['name']
+      )
+      expect(keyAfterRevert?.value).toBe(originalValue)
+    })
+
+    await it('Should add missing configuration key with default during self-check', () => {
+      deleteConfigurationKey(
+        mockChargingStation,
+        OCPP20RequiredVariableName.EVConnectionTimeOut as unknown as VariableType['name'],
+        { save: false }
+      )
+      const before = getConfigurationKey(
+        mockChargingStation,
+        OCPP20RequiredVariableName.EVConnectionTimeOut as unknown as VariableType['name']
+      )
+      expect(before).toBeUndefined()
+      const res = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(res.attributeStatusInfo).toBeUndefined()
+      expect(res.attributeValue).toBe(Constants.DEFAULT_EV_CONNECTION_TIMEOUT.toString())
+      const after = getConfigurationKey(
+        mockChargingStation,
+        OCPP20RequiredVariableName.EVConnectionTimeOut as unknown as VariableType['name']
+      )
+      expect(after).toBeDefined()
+    })
+
+    await it('Should clear runtime overrides via resetRuntimeOverrides()', () => {
+      manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '123',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ])
+      const beforeReset = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ])[0]
+      expect(beforeReset.attributeValue).toBe('123')
+      manager.resetRuntimeOverrides()
+      const afterReset = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ])[0]
+      expect(afterReset.attributeValue).not.toBe('123')
+      expect(afterReset.attributeValue).toBe('30')
+    })
+
+    await it('Should reject HeartbeatInterval with leading whitespace', () => {
+      const res = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: ' 60',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      expect(res.attributeStatusInfo?.additionalInfo).toContain(
+        'Non-empty digits only string required'
+      )
+    })
+
+    await it('Should reject HeartbeatInterval with trailing whitespace', () => {
+      const res = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '60 ',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      expect(res.attributeStatusInfo?.additionalInfo).toContain(
+        'Non-empty digits only string required'
+      )
+    })
+
+    await it('Should reject HeartbeatInterval with plus sign prefix', () => {
+      const res = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '+10',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      expect(res.attributeStatusInfo?.additionalInfo).toContain(
+        'Non-empty digits only string required'
+      )
+    })
+
+    await it('Should accept HeartbeatInterval with leading zeros', () => {
+      const res = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '007',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      expect(res.attributeStatusInfo).toBeUndefined()
+    })
+
+    await it('Should reject HeartbeatInterval blank string', () => {
+      const res = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      expect(res.attributeStatusInfo?.additionalInfo).toContain(
+        'Non-empty digits only string required'
+      )
+    })
+
+    await it('Should reject HeartbeatInterval with internal space', () => {
+      const res = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '6 0',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      expect(res.attributeStatusInfo?.additionalInfo).toContain(
+        'Non-empty digits only string required'
+      )
+    })
+
+    await it('Should reject ConnectionUrl missing scheme', () => {
+      const res = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: 'example.com/ocpp',
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidURL)
+      expect(res.attributeStatusInfo?.additionalInfo).toContain('Invalid URL format')
+    })
+
+    await it('Should reject ConnectionUrl exceeding max length', () => {
+      const longUrl = 'wss://example.com/' + 'a'.repeat(600)
+      const res = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: longUrl,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      expect(res.attributeStatusInfo?.additionalInfo).toContain(
+        `exceeds maximum length (${CONNECTION_URL_MAX_LENGTH.toString()})`
+      )
+    })
+
+    await it('Should reject HeartbeatInterval exceeding max length', () => {
+      const res = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '1'.repeat(11),
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+      const HEARTBEAT_INTERVAL_MAX_LENGTH =
+        VARIABLE_REGISTRY[
+          `${OCPP20ComponentName.OCPPCommCtrlr}::${OCPP20OptionalVariableName.HeartbeatInterval}`
+        ].maxLength ?? 10
+      expect(res.attributeStatusInfo?.additionalInfo).toContain(
+        `exceeds maximum length (${HEARTBEAT_INTERVAL_MAX_LENGTH.toString()})`
+      )
+    })
+
+    // Effective value size limit tests combining ConfigurationValueSize and ValueSize
+    await it('Should enforce ConfigurationValueSize when ValueSize unset', () => {
+      resetValueSizeLimits(mockChargingStation)
+      setConfigurationValueSize(mockChargingStation, 50)
+      // remove ValueSize to simulate unset
+      deleteConfigurationKey(
+        mockChargingStation,
+        OCPP20RequiredVariableName.ValueSize as unknown as VariableType['name'],
+        { save: false }
+      )
+      const okRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: buildWsExampleUrl(50, 'x'),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(okRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      const tooLongRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: buildWsExampleUrl(51, 'x'),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(tooLongRes.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(tooLongRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+    })
+
+    await it('Should enforce ValueSize when ConfigurationValueSize unset', () => {
+      resetValueSizeLimits(mockChargingStation)
+      setValueSize(mockChargingStation, 40)
+      deleteConfigurationKey(
+        mockChargingStation,
+        OCPP20RequiredVariableName.ConfigurationValueSize as unknown as VariableType['name'],
+        { save: false }
+      )
+      const okRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: buildWsExampleUrl(40, 'y'),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(okRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      const tooLongRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: buildWsExampleUrl(41, 'y'),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(tooLongRes.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(tooLongRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+    })
+
+    await it('Should use smaller of ConfigurationValueSize and ValueSize (ValueSize smaller)', () => {
+      resetValueSizeLimits(mockChargingStation)
+      setConfigurationValueSize(mockChargingStation, 60)
+      setValueSize(mockChargingStation, 55)
+      const okRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: buildWsExampleUrl(55, 'z'),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(okRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      const tooLongRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: buildWsExampleUrl(56, 'z'),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(tooLongRes.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(tooLongRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+    })
+
+    await it('Should use smaller of ConfigurationValueSize and ValueSize (ConfigurationValueSize smaller)', () => {
+      resetValueSizeLimits(mockChargingStation)
+      setConfigurationValueSize(mockChargingStation, 30)
+      setValueSize(mockChargingStation, 100)
+      const okRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: buildWsExampleUrl(30, 'w'),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(okRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      const tooLongRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: buildWsExampleUrl(31, 'w'),
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(tooLongRes.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(tooLongRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.TooLargeElement)
+    })
+
+    await it('Should fallback to default limit when both invalid/non-positive', () => {
+      resetValueSizeLimits(mockChargingStation)
+      // set invalid values
+      setConfigurationValueSize(mockChargingStation, 0)
+      setValueSize(mockChargingStation, -5)
+      const okRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: buildWsExampleUrl(300, 'v'), // below default absolute max length
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(okRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+    })
+  })
+
+  await describe('List validation tests', async () => {
+    const manager = OCPP20VariableManager.getInstance()
+
+    await it('Should accept valid updates to list/sequence list variables', () => {
+      const validUpdates: OCPP20SetVariableDataType[] = [
+        {
+          attributeValue: 'HTTP,HTTPS',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.FileTransferProtocols },
+        },
+        {
+          attributeValue: 'GPS,NTP,RTC', // reorder TimeSource
+          component: { name: OCPP20ComponentName.ClockCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TimeSource },
+        },
+        {
+          attributeValue: 'CablePluggedIn,EnergyTransfer,Authorized',
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxStartPoint },
+        },
+        {
+          attributeValue: 'EVSEIdle,CableUnplugged', // keep same
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxStopPoint },
+        },
+        {
+          attributeValue: 'Energy.Active.Import.Register,Power.Active.Import,Voltage',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxStartedMeasurands },
+        },
+        {
+          attributeValue:
+            'Energy.Active.Import.Register,Current.Import,Energy.Active.Import.Interval',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxEndedMeasurands },
+        },
+        {
+          attributeValue: 'Energy.Active.Import.Register,Current.Import',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedMeasurands },
+        },
+      ]
+      const results = manager.setVariables(mockChargingStation, validUpdates)
+      for (const r of results) {
+        expect(r.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      }
+    })
+
+    await it('Should reject invalid list formats and members', () => {
+      interface ListVar {
+        component: OCPP20ComponentName
+        name: OCPP20RequiredVariableName
+      }
+      const listVariables: ListVar[] = [
+        {
+          component: OCPP20ComponentName.OCPPCommCtrlr,
+          name: OCPP20RequiredVariableName.FileTransferProtocols,
+        },
+        { component: OCPP20ComponentName.ClockCtrlr, name: OCPP20RequiredVariableName.TimeSource },
+        { component: OCPP20ComponentName.TxCtrlr, name: OCPP20RequiredVariableName.TxStartPoint },
+        { component: OCPP20ComponentName.TxCtrlr, name: OCPP20RequiredVariableName.TxStopPoint },
+        {
+          component: OCPP20ComponentName.SampledDataCtrlr,
+          name: OCPP20RequiredVariableName.TxStartedMeasurands,
+        },
+        {
+          component: OCPP20ComponentName.SampledDataCtrlr,
+          name: OCPP20RequiredVariableName.TxEndedMeasurands,
+        },
+        {
+          component: OCPP20ComponentName.SampledDataCtrlr,
+          name: OCPP20RequiredVariableName.TxUpdatedMeasurands,
+        },
+      ]
+      const invalidPatterns = ['', ',HTTP', 'HTTP,', 'HTTP,,FTP', 'HTTP,HTTP']
+      for (const lv of listVariables) {
+        for (const pattern of invalidPatterns) {
+          const res = manager.setVariables(mockChargingStation, [
+            {
+              attributeValue: pattern,
+              component: { name: lv.component },
+              variable: { name: lv.name },
+            },
+          ])[0]
+          expect(res.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+          expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.InvalidValue)
+          if (pattern === '') {
+            expect(res.attributeStatusInfo?.additionalInfo).toContain('List cannot be empty')
+          } else if (pattern.startsWith(',') || pattern.endsWith(',')) {
+            expect(res.attributeStatusInfo?.additionalInfo).toContain('No leading/trailing comma')
+          } else if (pattern.includes(',,')) {
+            expect(res.attributeStatusInfo?.additionalInfo).toContain('Empty list member')
+          } else if (pattern === 'HTTP,HTTP') {
+            expect(res.attributeStatusInfo?.additionalInfo).toContain('Duplicate list member')
+          }
+        }
+      }
+    })
+  })
+
+  await describe('Unsupported MinSet/MaxSet attribute tests', async () => {
+    const manager = OCPP20VariableManager.getInstance()
+    const station = createChargingStation({
+      baseName: 'MMStation',
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+
+    await it('Returns NotSupportedAttributeType for MinSet HeartbeatInterval', () => {
+      const component = { name: OCPP20ComponentName.OCPPCommCtrlr }
+      const variable = { name: OCPP20OptionalVariableName.HeartbeatInterval }
+      const res = manager.getVariables(station, [
+        { attributeType: AttributeEnumType.MinSet, component, variable },
+      ])[0]
+      expect(res.attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+      expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedParam)
+    })
+
+    await it('Returns NotSupportedAttributeType for MaxSet WebSocketPingInterval', () => {
+      const component = { name: OCPP20ComponentName.ChargingStation }
+      const variable = { name: OCPP20OptionalVariableName.WebSocketPingInterval }
+      const res = manager.getVariables(station, [
+        { attributeType: AttributeEnumType.MaxSet, component, variable },
+      ])[0]
+      expect(res.attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+      expect(res.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedParam)
+    })
+  })
+
+  await describe('Get-time value truncation tests', async () => {
+    const manager = OCPP20VariableManager.getInstance()
+
+    await it('Should truncate retrieved value using ValueSize only when ReportingValueSize absent', () => {
+      resetValueSizeLimits(mockChargingStation)
+      // Ensure ReportingValueSize unset
+      deleteConfigurationKey(
+        mockChargingStation,
+        OCPP20RequiredVariableName.ReportingValueSize as unknown as VariableType['name'],
+        { save: false }
+      )
+      // Temporarily set large ValueSize to allow storing long value
+      setValueSize(mockChargingStation, 200)
+      const longUrl = buildWsExampleUrl(180, 'a')
+      const setRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: longUrl,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(setRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      // Now reduce ValueSize to 50 to force truncation at get-time
+      setValueSize(mockChargingStation, 50)
+      const getRes = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(getRes.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(getRes.attributeValue?.length).toBe(50)
+      // First 50 chars should match original long value prefix
+      expect(longUrl.startsWith(getRes.attributeValue ?? '')).toBe(true)
+      resetValueSizeLimits(mockChargingStation)
+    })
+
+    await it('Should apply ValueSize then ReportingValueSize sequential truncation', () => {
+      resetValueSizeLimits(mockChargingStation)
+      // Store long value with large limits
+      setValueSize(mockChargingStation, 300)
+      setReportingValueSize(mockChargingStation, 250) // will be applied second
+      const longUrl = buildWsExampleUrl(260, 'b')
+      const setRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: longUrl,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(setRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      // Reduce ValueSize below ReportingValueSize to 200 so first truncation occurs at 200, then second at 150
+      setValueSize(mockChargingStation, 200)
+      setReportingValueSize(mockChargingStation, 150)
+      const getRes = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(getRes.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(getRes.attributeValue?.length).toBe(150)
+      expect(longUrl.startsWith(getRes.attributeValue ?? '')).toBe(true)
+      resetValueSizeLimits(mockChargingStation)
+      resetReportingValueSize(mockChargingStation)
+    })
+
+    await it('Should enforce absolute max character cap after truncation chain', () => {
+      resetValueSizeLimits(mockChargingStation)
+      resetReportingValueSize(mockChargingStation)
+      // Directly upsert configuration key with > absolute max length value bypassing set-time limit (which rejects > absolute max length)
+      const overLongValue = buildWsExampleUrl(3000, 'c')
+      upsertConfigurationKey(
+        mockChargingStation,
+        OCPP20VendorVariableName.ConnectionUrl as unknown as VariableType['name'],
+        overLongValue
+      )
+      // Set generous ValueSize (1500) and ReportingValueSize (1400) so only absolute cap applies (since both < Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH)
+      setValueSize(mockChargingStation, 1500)
+      setReportingValueSize(mockChargingStation, 1400)
+      const getRes = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(getRes.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(getRes.attributeValue?.length).toBe(1400)
+      expect(overLongValue.startsWith(getRes.attributeValue ?? '')).toBe(true)
+      resetValueSizeLimits(mockChargingStation)
+      resetReportingValueSize(mockChargingStation)
+    })
+
+    await it('Should not exceed variable maxLength even if ValueSize and ReportingValueSize set above it', () => {
+      resetValueSizeLimits(mockChargingStation)
+      resetReportingValueSize(mockChargingStation)
+      // Store exactly variable maxLength value via setVariables (allowed per registry/spec)
+      const connectionUrlMaxLength =
+        VARIABLE_REGISTRY[
+          `${OCPP20ComponentName.ChargingStation}::${OCPP20VendorVariableName.ConnectionUrl}`
+        ].maxLength ?? 512
+      const maxLenValue = buildWsExampleUrl(connectionUrlMaxLength, 'd')
+      const setRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: maxLenValue,
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(setRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      // Set larger limits that would allow a bigger value if not for variable-level maxLength
+      setValueSize(mockChargingStation, 3000)
+      setReportingValueSize(mockChargingStation, 2800)
+      const getRes = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.ChargingStation },
+          variable: { name: OCPP20VendorVariableName.ConnectionUrl },
+        },
+      ])[0]
+      expect(getRes.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(getRes.attributeValue?.length).toBe(connectionUrlMaxLength)
+      expect(getRes.attributeValue).toBe(maxLenValue)
+      resetValueSizeLimits(mockChargingStation)
+      resetReportingValueSize(mockChargingStation)
+    })
+  })
+
+  await describe('Additional persistence and instance-scoped variable tests', async () => {
+    const manager = OCPP20VariableManager.getInstance()
+
+    await it('Should auto-create persistent OrganizationName configuration key during self-check', () => {
+      deleteConfigurationKey(
+        mockChargingStation,
+        OCPP20RequiredVariableName.OrganizationName as unknown as VariableType['name'],
+        { save: false }
+      )
+      const before = getConfigurationKey(
+        mockChargingStation,
+        OCPP20RequiredVariableName.OrganizationName as unknown as VariableType['name']
+      )
+      expect(before).toBeUndefined()
+      const res = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.SecurityCtrlr },
+          variable: { name: OCPP20RequiredVariableName.OrganizationName },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(res.attributeValue).toBe('ChangeMeOrg')
+      const after = getConfigurationKey(
+        mockChargingStation,
+        OCPP20RequiredVariableName.OrganizationName as unknown as VariableType['name']
+      )
+      expect(after).toBeDefined()
+      expect(after?.value).toBe('ChangeMeOrg')
+    })
+
+    await it('Should accept setting OrganizationName but not persist new value (current limitation)', () => {
+      const setRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: 'NewOrgName',
+          component: { name: OCPP20ComponentName.SecurityCtrlr },
+          variable: { name: OCPP20RequiredVariableName.OrganizationName },
+        },
+      ])[0]
+      // Current implementation only marks rebootRequired for ChargingStation component variables
+      expect(setRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+      const getRes = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.SecurityCtrlr },
+          variable: { name: OCPP20RequiredVariableName.OrganizationName },
+        },
+      ])[0]
+      // Value remains the configuration key default due to lack of persistence path for non-ChargingStation components
+      expect(getRes.attributeValue).toBe('ChangeMeOrg')
+    })
+
+    await it('Should preserve OrganizationName value after resetRuntimeOverrides()', () => {
+      manager.resetRuntimeOverrides()
+      const res = manager.getVariables(mockChargingStation, [
+        {
+          component: { name: OCPP20ComponentName.SecurityCtrlr },
+          variable: { name: OCPP20RequiredVariableName.OrganizationName },
+        },
+      ])[0]
+      expect(res.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(res.attributeValue).toBe('ChangeMeOrg')
+    })
+
+    await it('Should create configuration key for instance-scoped MessageAttemptInterval and persist Actual value (Actual-only, no MinSet/MaxSet)', () => {
+      // Ensure no configuration key exists before operations
+      const cfgBefore = getConfigurationKey(
+        mockChargingStation,
+        OCPP20RequiredVariableName.MessageAttemptInterval as unknown as VariableType['name']
+      )
+      expect(cfgBefore).toBeUndefined()
+      const initialGet = manager.getVariables(mockChargingStation, [
+        {
+          component: { instance: 'TransactionEvent', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageAttemptInterval },
+        },
+      ])[0]
+      expect(initialGet.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(initialGet.attributeValue).toBe('5')
+
+      // Negative: MinSet not supported
+      const minSetRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeType: AttributeEnumType.MinSet,
+          attributeValue: '6',
+          component: { instance: 'TransactionEvent', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageAttemptInterval },
+        },
+      ])[0]
+      expect(minSetRes.attributeStatus).toBe(SetVariableStatusEnumType.NotSupportedAttributeType)
+      const getMin = manager.getVariables(mockChargingStation, [
+        {
+          attributeType: AttributeEnumType.MinSet,
+          component: { instance: 'TransactionEvent', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageAttemptInterval },
+        },
+      ])[0]
+      expect(getMin.attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+
+      // Negative: MaxSet not supported
+      const maxSetRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeType: AttributeEnumType.MaxSet,
+          attributeValue: '10',
+          component: { instance: 'TransactionEvent', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageAttemptInterval },
+        },
+      ])[0]
+      expect(maxSetRes.attributeStatus).toBe(SetVariableStatusEnumType.NotSupportedAttributeType)
+      const getMax = manager.getVariables(mockChargingStation, [
+        {
+          attributeType: AttributeEnumType.MaxSet,
+          component: { instance: 'TransactionEvent', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageAttemptInterval },
+        },
+      ])[0]
+      expect(getMax.attributeStatus).toBe(GetVariableStatusEnumType.NotSupportedAttributeType)
+
+      // Attempt Actual value below registry min (min=1) -> reject
+      const belowMinRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '0',
+          component: { instance: 'TransactionEvent', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageAttemptInterval },
+        },
+      ])[0]
+      expect(belowMinRes.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(belowMinRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValuePositiveOnly)
+
+      // Attempt Actual value above registry max (max=3600) -> reject
+      const aboveMaxRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '3601',
+          component: { instance: 'TransactionEvent', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageAttemptInterval },
+        },
+      ])[0]
+      expect(aboveMaxRes.attributeStatus).toBe(SetVariableStatusEnumType.Rejected)
+      expect(aboveMaxRes.attributeStatusInfo?.reasonCode).toBe(ReasonCodeEnumType.ValueTooHigh)
+
+      // Accept Actual value within metadata bounds
+      const withinRes = manager.setVariables(mockChargingStation, [
+        {
+          attributeValue: '7',
+          component: { instance: 'TransactionEvent', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageAttemptInterval },
+        },
+      ])[0]
+      expect(withinRes.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+
+      // Retrieval now returns persisted value '7'
+      const afterSetGet = manager.getVariables(mockChargingStation, [
+        {
+          component: { instance: 'TransactionEvent', name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: OCPP20RequiredVariableName.MessageAttemptInterval },
+        },
+      ])[0]
+      expect(afterSetGet.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+      expect(afterSetGet.attributeValue).toBe('7')
+
+      const cfgAfter = getConfigurationKey(
+        mockChargingStation,
+        OCPP20RequiredVariableName.MessageAttemptInterval as unknown as VariableType['name']
+      )
+      expect(cfgAfter).toBeDefined()
+      expect(cfgAfter?.value).toBe('7')
     })
   })
 })
index 9cbeff5b96bec67b94636ce78897c17fb6b037a5..09df6a30033e3a106e3929f89443815494da913d 100644 (file)
@@ -1,5 +1,3 @@
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-
 import { expect } from '@std/expect'
 import { describe, it } from 'node:test'
 
@@ -22,11 +20,11 @@ await describe('ErrorUtils test suite', async () => {
   const chargingStation = createChargingStation({ baseName: 'CS-TEST' })
 
   await it('Verify handleFileException()', t => {
-    t.mock.method(console, 'warn')
-    t.mock.method(console, 'error')
-    t.mock.method(logger, 'warn')
-    t.mock.method(logger, 'error')
-    const error = new Error()
+    const consoleWarnMock = t.mock.method(console, 'warn')
+    const consoleErrorMock = t.mock.method(console, 'error')
+    const warnMock = t.mock.method(logger, 'warn')
+    const errorMock = t.mock.method(logger, 'error')
+    const error = new Error() as NodeJS.ErrnoException
     error.code = 'ENOENT'
     expect(() => {
       handleFileException('path/to/module.js', FileType.Authorization, error, 'log prefix |', {})
@@ -36,8 +34,8 @@ await describe('ErrorUtils test suite', async () => {
         throwError: false,
       })
     }).not.toThrow()
-    expect(logger.warn.mock.calls.length).toBe(1)
-    expect(logger.error.mock.calls.length).toBe(1)
+    expect(warnMock.mock.calls.length).toBe(1)
+    expect(errorMock.mock.calls.length).toBe(1)
     expect(() => {
       handleFileException('path/to/module.js', FileType.Authorization, error, 'log prefix |', {
         consoleOut: true,
@@ -49,13 +47,13 @@ await describe('ErrorUtils test suite', async () => {
         throwError: false,
       })
     }).not.toThrow()
-    expect(console.warn.mock.calls.length).toBe(1)
-    expect(console.error.mock.calls.length).toBe(1)
+    expect(consoleWarnMock.mock.calls.length).toBe(1)
+    expect(consoleErrorMock.mock.calls.length).toBe(1)
   })
 
   await it('Verify handleSendMessageError()', t => {
-    t.mock.method(logger, 'error')
-    t.mock.method(chargingStation, 'logPrefix')
+    const errorMock = t.mock.method(logger, 'error')
+    const logPrefixMock = t.mock.method(chargingStation, 'logPrefix')
     const error = new Error()
     expect(() => {
       handleSendMessageError(
@@ -74,13 +72,13 @@ await describe('ErrorUtils test suite', async () => {
         { throwError: true }
       )
     }).toThrow(error)
-    expect(chargingStation.logPrefix.mock.calls.length).toBe(2)
-    expect(logger.error.mock.calls.length).toBe(2)
+    expect(logPrefixMock.mock.calls.length).toBe(2)
+    expect(errorMock.mock.calls.length).toBe(2)
   })
 
   await it('Verify handleIncomingRequestError()', t => {
-    t.mock.method(logger, 'error')
-    t.mock.method(chargingStation, 'logPrefix')
+    const errorMock = t.mock.method(logger, 'error')
+    const logPrefixMock = t.mock.method(chargingStation, 'logPrefix')
     const error = new Error()
     expect(() => {
       handleIncomingRequestError(chargingStation, IncomingRequestCommand.CLEAR_CACHE, error)
@@ -98,7 +96,7 @@ await describe('ErrorUtils test suite', async () => {
         errorResponse,
       })
     ).toStrictEqual(errorResponse)
-    expect(chargingStation.logPrefix.mock.calls.length).toBe(3)
-    expect(logger.error.mock.calls.length).toBe(3)
+    expect(logPrefixMock.mock.calls.length).toBe(3)
+    expect(errorMock.mock.calls.length).toBe(3)
   })
 })
index 28b2af79b6b4652882598ce2a14b010a073d2e0c..9d1ce242714f52b8b1fb05717569d446d5f6e6bd 100644 (file)
@@ -16,6 +16,7 @@ import {
   convertToDate,
   convertToFloat,
   convertToInt,
+  convertToIntOrNaN,
   extractTimeSeriesValues,
   formatDurationMilliSeconds,
   formatDurationSeconds,
@@ -414,6 +415,17 @@ await describe('Utils test suite', async () => {
     expect(insertAt('test', 'ing', 2)).toBe('teingst')
   })
 
+  await it('Verify convertToIntOrNaN()', () => {
+    expect(convertToIntOrNaN(undefined)).toBe(0)
+    expect(convertToIntOrNaN(null)).toBe(0)
+    expect(convertToIntOrNaN('0')).toBe(0)
+    expect(convertToIntOrNaN('42')).toBe(42)
+    expect(convertToIntOrNaN('-7')).toBe(-7)
+    expect(convertToIntOrNaN('10.9')).toBe(10)
+    expect(Number.isNaN(convertToIntOrNaN('NaN'))).toBe(true)
+    expect(Number.isNaN(convertToIntOrNaN('abc'))).toBe(true)
+  })
+
   await it('Verify isArraySorted()', () => {
     expect(isArraySorted<number>([], (a, b) => a - b)).toBe(true)
     expect(isArraySorted<number>([1], (a, b) => a - b)).toBe(true)
diff --git a/ui/web/src/composables/Constants.ts b/ui/web/src/composables/Constants.ts
new file mode 100644 (file)
index 0000000..5079f3d
--- /dev/null
@@ -0,0 +1,3 @@
+// Local UI project constants
+
+export const UI_WEBSOCKET_REQUEST_TIMEOUT_MS = 60_000
index faa8e9224ae883a3e61c719e38950cae5d2d57d1..d0a31010e4673aeb8c55acace9c69b58c0c5000e 100644 (file)
@@ -12,6 +12,7 @@ import {
   type UIServerConfigurationSection,
 } from '@/types'
 
+import { UI_WEBSOCKET_REQUEST_TIMEOUT_MS } from './Constants'
 import { randomUUID, validateUUID } from './Utils'
 
 interface ResponseHandler {
@@ -290,7 +291,7 @@ export class UIClient {
         const sendTimeout = setTimeout(() => {
           this.responseHandlers.delete(uuid)
           reject(new Error(`Send request '${procedureName}' message: connection timeout`))
-        }, 60000)
+        }, UI_WEBSOCKET_REQUEST_TIMEOUT_MS)
         try {
           this.ws.send(msg)
           this.responseHandlers.set(uuid, { procedureName, reject, resolve })
index d11b339dbfe77f7482b4b0c144d9d77974fd314c..f02e91ba77bdf70d00030a469d8863e5f41fb4a3 100644 (file)
@@ -5,8 +5,10 @@ import { env } from 'node:process'
 import { fileURLToPath } from 'node:url'
 import serveStatic from 'serve-static'
 
+const UI_DEV_SERVER_PORT = 3030
+
 const isCFEnvironment = env.VCAP_APPLICATION != null
-const PORT = isCFEnvironment ? Number.parseInt(env.PORT) : 3030
+const PORT = isCFEnvironment ? Number.parseInt(env.PORT) : UI_DEV_SERVER_PORT
 const uiPath = join(dirname(fileURLToPath(import.meta.url)), './dist')
 
 const serve = serveStatic(uiPath)