From c3d964087d7d0b5db5fed6a7c40c1f0b2a5f8b36 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 29 Oct 2025 22:34:04 +0100 Subject: [PATCH] =?utf8?q?feat(ocpp20):=20add=20SetVariables=20handling=20?= =?utf8?q?with=20runtime=20+=20persistent=20var=E2=80=A6=20(#1576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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 * test(ocpp20): consolidate setVariables tests into main VariableManager suite * refactor: variable namespace alignment Signed-off-by: Jérôme Benoit * 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 * 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 * refactor: ocpp2 spec alignment Signed-off-by: Jérôme Benoit * fix: handle variables in a case incensitive way Signed-off-by: Jérôme Benoit * 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 * 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 * 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 * refactor: align variable namespace Signed-off-by: Jérôme Benoit * refactor: remove unneeded public wrappers for UTs only purpose Signed-off-by: Jérôme Benoit * refactor: cleanup code comments Signed-off-by: Jérôme Benoit * 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 * test: remove incorrect tests Signed-off-by: Jérôme Benoit * 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 * refactor: use existing enum Signed-off-by: Jérôme Benoit * test: add configuration utils tests Signed-off-by: Jérôme Benoit * docs: removed outdated markdown Signed-off-by: Jérôme Benoit * refactor: factor out common logic Signed-off-by: Jérôme Benoit * refactor: use builtin helpers Signed-off-by: Jérôme Benoit * refactor: remove unneeded comment Signed-off-by: Jérôme Benoit --------- Signed-off-by: Jérôme Benoit Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .serena/project.yml | 2 +- AGENTS.md | 2 +- README.md | 2 +- eslint.config.js | 22 + opencode.jsonc | 8 + src/charging-station/Bootstrap.ts | 7 +- src/charging-station/ChargingStation.ts | 18 +- src/charging-station/ConfigurationKeyUtils.ts | 11 + .../ocpp/1.6/OCPP16IncomingRequestService.ts | 4 + .../ocpp/2.0/OCPP20IncomingRequestService.ts | 366 ++++- .../ocpp/2.0/OCPP20ServiceUtils.ts | 76 + .../ocpp/2.0/OCPP20VariableManager.ts | 1040 +++++++++--- .../ocpp/2.0/OCPP20VariableRegistry.ts | 1251 ++++++++++++++ .../ocpp/OCPPIncomingRequestService.ts | 2 + src/types/ChargingStationOcppConfiguration.ts | 3 +- src/types/ChargingStationWorker.ts | 1 + src/types/index.ts | 7 + src/types/ocpp/2.0/Requests.ts | 1 + src/types/ocpp/2.0/Variables.ts | 13 +- src/utils/Configuration.ts | 2 +- src/utils/Constants.ts | 4 + src/utils/MessageChannelUtils.ts | 14 +- src/utils/Utils.ts | 41 +- src/utils/index.ts | 1 + src/worker/WorkerFactory.ts | 5 +- tests/ChargingStationFactory.ts | 16 +- .../ConfigurationKeyUtils.test.ts | 297 ++++ tests/charging-station/Helpers.test.ts | 30 +- ...0IncomingRequestService-ClearCache.test.ts | 2 + ...comingRequestService-GetBaseReport.test.ts | 155 +- ...ncomingRequestService-GetVariables.test.ts | 427 ++++- ...OCPP20IncomingRequestService-Reset.test.ts | 45 +- ...ncomingRequestService-SetVariables.test.ts | 731 +++++++++ ...P20RequestService-BootNotification.test.ts | 5 + .../OCPP20RequestService-HeartBeat.test.ts | 10 +- .../OCPP20RequestService-NotifyReport.test.ts | 30 +- ...0RequestService-StatusNotification.test.ts | 7 + .../ocpp/2.0/OCPP20TestUtils.ts | 137 ++ .../ocpp/2.0/OCPP20VariableManager.test.ts | 1433 +++++++++++++++-- tests/utils/ErrorUtils.test.ts | 36 +- tests/utils/Utils.test.ts | 12 + ui/web/src/composables/Constants.ts | 3 + ui/web/src/composables/UIClient.ts | 3 +- ui/web/start.js | 4 +- 44 files changed, 5686 insertions(+), 600 deletions(-) create mode 100644 opencode.jsonc create mode 100644 src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts create mode 100644 tests/charging-station/ConfigurationKeyUtils.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetVariables.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts create mode 100644 ui/web/src/composables/Constants.ts diff --git a/.serena/project.yml b/.serena/project.yml index 38546696..b5cefbde 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -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' diff --git a/AGENTS.md b/AGENTS.md index 5fbc6643..31159654 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,4 +20,4 @@ Keep this managed block so 'openspec update' can refresh the instructions. -Open`@/.github/copilot-instructions.md`, read it and strictly follow the instructions. +Open `@/.github/copilot-instructions.md`, read it and strictly follow the instructions. diff --git a/README.md b/README.md index 201fe6bf..b1e3ae86 100644 --- 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 diff --git a/eslint.config.js b/eslint.config.js index e6f4d01b..6af2482e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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 index 00000000..65b42326 --- /dev/null +++ b/opencode.jsonc @@ -0,0 +1,8 @@ +{ + "$schema": "https://opencode.ai/config.json", + "formatter": { + "prettier": { + "disabled": true, + }, + }, +} diff --git a/src/charging-station/Bootstrap.ts b/src/charging-station/Bootstrap.ts index 0629ad85..3e10d092 100644 --- a/src/charging-station/Bootstrap.ts +++ b/src/charging-station/Bootstrap.ts @@ -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 private readonly uiServer: AbstractUIServer private uiServerStarted: boolean - private readonly version: string = version + private readonly version: string = packageJson.version private workerImplementation?: WorkerAbstract 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 diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 5f8003eb..38bb259d 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -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( + Constants.DEFAULT_STATION_INFO as ChargingStationInfo, + stationInfo + ), options ) } @@ -2192,7 +2196,10 @@ export class ChargingStation extends EventEmitter { } else { delete configurationData.configurationKey } - configurationData = mergeDeepRight( + configurationData = mergeDeepRight< + ChargingStationConfiguration, + Partial + >( 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 { diff --git a/src/charging-station/ConfigurationKeyUtils.ts b/src/charging-station/ConfigurationKeyUtils.ts index d40591a1..709914b8 100644 --- a/src/charging-station/ConfigurationKeyUtils.ts +++ b/src/charging-station/ConfigurationKeyUtils.ts @@ -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] diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index 5e7a4a6b..8ee67e2b 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -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 diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index 342432d5..a1269b1d 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -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 + 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() this.incomingRequestHandlers = new Map([ - [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( + '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( 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 { 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 ( diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index aa947ae5..424d6758 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -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( relativePath: string, moduleName?: string, diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts b/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts index 7f923b2d..3c4883d5 100644 --- a/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts @@ -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() + private readonly invalidVariables = new Set() // composite key (lower case) + private readonly maxSetOverrides = new Map() // composite key (lower case) + private readonly minSetOverrides = new Map() // composite key (lower case) + private readonly runtimeOverrides = new Map() // 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([ + 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 index 00000000..d0dec097 --- /dev/null +++ b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts @@ -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}[.]::${variable}` (case sensitive) + * - Built with buildRegistryKey(). + * 2. Case-insensitive composite key (lookup convenience): `${component}[.].${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 = { + // 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 = Object.values( + VARIABLE_REGISTRY +).reduce>((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() + 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 } +} diff --git a/src/charging-station/ocpp/OCPPIncomingRequestService.ts b/src/charging-station/ocpp/OCPPIncomingRequestService.ts index 15981f7e..77633584 100644 --- a/src/charging-station/ocpp/OCPPIncomingRequestService.ts +++ b/src/charging-station/ocpp/OCPPIncomingRequestService.ts @@ -57,6 +57,8 @@ export abstract class OCPPIncomingRequestService extends EventEmitter { commandPayload: ReqType ): Promise + 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!)!)) { diff --git a/src/types/ChargingStationOcppConfiguration.ts b/src/types/ChargingStationOcppConfiguration.ts index cdd1d3ff..d919c260 100644 --- a/src/types/ChargingStationOcppConfiguration.ts +++ b/src/types/ChargingStationOcppConfiguration.ts @@ -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[] } diff --git a/src/types/ChargingStationWorker.ts b/src/types/ChargingStationWorker.ts index 3bea940f..80d1a13f 100644 --- a/src/types/ChargingStationWorker.ts +++ b/src/types/ChargingStationWorker.ts @@ -52,6 +52,7 @@ export interface ChargingStationWorkerData extends WorkerData { export interface ChargingStationWorkerMessage { data: T event: ChargingStationWorkerMessageEvents + uuid?: string } export type ChargingStationWorkerMessageData = ChargingStationData | Statistics diff --git a/src/types/index.ts b/src/types/index.ts index d0f80321..a5343dfd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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' diff --git a/src/types/ocpp/2.0/Requests.ts b/src/types/ocpp/2.0/Requests.ts index 166d78bb..463bdb22 100644 --- a/src/types/ocpp/2.0/Requests.ts +++ b/src/types/ocpp/2.0/Requests.ts @@ -18,6 +18,7 @@ export enum OCPP20IncomingRequestCommand { REQUEST_START_TRANSACTION = 'RequestStartTransaction', REQUEST_STOP_TRANSACTION = 'RequestStopTransaction', RESET = 'Reset', + SET_VARIABLES = 'SetVariables', } export enum OCPP20RequestCommand { diff --git a/src/types/ocpp/2.0/Variables.ts b/src/types/ocpp/2.0/Variables.ts index db9cc82c..0d94001c 100644 --- a/src/types/ocpp/2.0/Variables.ts +++ b/src/types/ocpp/2.0/Variables.ts @@ -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', diff --git a/src/utils/Configuration.ts b/src/utils/Configuration.ts index 2fe28900..ea164099 100644 --- a/src/utils/Configuration.ts +++ b/src/utils/Configuration.ts @@ -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, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion Configuration.getConfigurationData()!.uiServer! diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 4bcb0ed7..91072789 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -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 diff --git a/src/utils/MessageChannelUtils.ts b/src/utils/MessageChannelUtils.ts index 69abece3..41c380c7 100644 --- a/src/utils/MessageChannelUtils.ts +++ b/src/utils/MessageChannelUtils.ts @@ -64,12 +64,14 @@ export const buildUpdatedMessage = ( export const buildPerformanceStatisticsMessage = ( statistics: Statistics ): ChargingStationWorkerMessage => { - 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, diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 3e0f59cc..fc20974b 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -51,11 +51,11 @@ export const once = 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, property) } const type = (value: unknown): string => { @@ -100,27 +100,26 @@ const isObject = (value: unknown): value is object => { return type(value) === 'Object' } -export const mergeDeepRight = >( - target: T, - source: Partial -): T => { - const output = { ...target } +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +export const mergeDeepRight = (target: T, source: S): T => { + const output: Record = { ...(target as Record) } 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).forEach(key => { + const sourceValue = (source as Record)[key] + const targetValue = (target as Record)[key] + if (isObject(sourceValue) && isObject(targetValue)) { + output[key] = mergeDeepRight( + targetValue as Record, + sourceValue as Record + ) } 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 diff --git a/src/utils/index.ts b/src/utils/index.ts index c9baaf34..f248b5ce 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -32,6 +32,7 @@ export { convertToDate, convertToFloat, convertToInt, + convertToIntOrNaN, exponentialDelay, extractTimeSeriesValues, formatDurationMilliSeconds, diff --git a/src/worker/WorkerFactory.ts b/src/worker/WorkerFactory.ts index 788fa300..c5f92ec1 100644 --- a/src/worker/WorkerFactory.ts +++ b/src/worker/WorkerFactory.ts @@ -23,7 +23,10 @@ export class WorkerFactory { if (!isMainThread) { throw new Error('Cannot get a worker implementation outside the main thread') } - workerOptions = mergeDeepRight(DEFAULT_WORKER_OPTIONS, workerOptions ?? {}) + workerOptions = mergeDeepRight( + DEFAULT_WORKER_OPTIONS, + (workerOptions ?? {}) as WorkerOptions + ) switch (workerProcessType) { case WorkerProcessType.dynamicPool: return new WorkerDynamicPool(workerScript, workerOptions) diff --git a/tests/ChargingStationFactory.ts b/tests/ChargingStationFactory.ts index 3264f5dc..9ab785cb 100644 --- a/tests/ChargingStationFactory.ts +++ b/tests/ChargingStationFactory.ts @@ -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 index 00000000..c7104fd4 --- /dev/null +++ b/tests/charging-station/ConfigurationKeyUtils.test.ts @@ -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() + }) + }) +}) diff --git a/tests/charging-station/Helpers.test.ts b/tests/charging-station/Helpers.test.ts index 4300d5a6..43f8b529 100644 --- a/tests/charging-station/Helpers.test.ts +++ b/tests/charging-station/Helpers.test.ts @@ -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() }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts index 4ad80e18..0dfc4e76 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts @@ -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 diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts index d9f6ef89..2e05f3b2 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts @@ -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) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts index 15cca681..3ae17add 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts @@ -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) }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts index 17caf1d7..b195c087 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts @@ -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 index 00000000..c0a24ec1 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetVariables.test.ts @@ -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) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-BootNotification.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-BootNotification.test.ts index 9957b939..fa6b179f 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20RequestService-BootNotification.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-BootNotification.test.ts @@ -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: { diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-HeartBeat.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-HeartBeat.test.ts index 40057132..c6c2981c 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20RequestService-HeartBeat.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-HeartBeat.test.ts @@ -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) diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts index e6973481..2d8b6305 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts @@ -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', () => { diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts index 2b1c497b..a29f2e67 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-StatusNotification.test.ts @@ -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 index 00000000..4c849185 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts @@ -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 +} diff --git a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts index 78804450..8a1fe78a 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts @@ -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') }) }) }) diff --git a/tests/utils/ErrorUtils.test.ts b/tests/utils/ErrorUtils.test.ts index 9cbeff5b..09df6a30 100644 --- a/tests/utils/ErrorUtils.test.ts +++ b/tests/utils/ErrorUtils.test.ts @@ -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) }) }) diff --git a/tests/utils/Utils.test.ts b/tests/utils/Utils.test.ts index 28b2af79..9d1ce242 100644 --- a/tests/utils/Utils.test.ts +++ b/tests/utils/Utils.test.ts @@ -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([], (a, b) => a - b)).toBe(true) expect(isArraySorted([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 index 00000000..5079f3d8 --- /dev/null +++ b/ui/web/src/composables/Constants.ts @@ -0,0 +1,3 @@ +// Local UI project constants + +export const UI_WEBSOCKET_REQUEST_TIMEOUT_MS = 60_000 diff --git a/ui/web/src/composables/UIClient.ts b/ui/web/src/composables/UIClient.ts index faa8e922..d0a31010 100644 --- a/ui/web/src/composables/UIClient.ts +++ b/ui/web/src/composables/UIClient.ts @@ -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 }) diff --git a/ui/web/start.js b/ui/web/start.js index d11b339d..f02e91ba 100644 --- a/ui/web/start.js +++ b/ui/web/start.js @@ -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) -- 2.43.0