* feat(ocpp20): add SetVariables handling with runtime + persistent variable support and tests
* refactor(ocpp20): unify validation messages and remove success reason codes
* feat(ocpp20): extend variable manager with EVSE support, runtime overrides + validation refactor
* chore(openspec): add EVSE AuthorizeRemoteStart support proposal and spec delta
* chore(openspec): mark EVSE AuthorizeRemoteStart tasks completed (except validation/archive)
* chore(openspec): archive add-evse-authorizeremotestart-support change
* chore(openspec): archive refactor-ocpp20-variable-proxy change
* refactor: drop resetRuntimeVariables\n\nBREAKING CHANGE: use resetRuntimeOverrides() instead
* refactor(config): enum comparisons with explicit string casting
* refactor(charging-station): conditionally reset OCPP 2.x runtime overrides
* refactor(tests): use date-fns helper for heartbeat interval conversion
* refactor(tests,config): replace manual heartbeat conversions and minor cleanups
* refactor: cleanup OCPP stack stop API
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp20): consolidate setVariables tests into main VariableManager suite
* refactor: variable namespace alignment
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(validation): simplify configuration value integer checks with Number.isInteger
* test(ocpp20): add edge case validation tests and rename trimmed variable
* refactor: cleanup configuration value validation
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* feat(validation): tighten positive integer checks for OCPP 2.0 variables
* test(ocpp20): simplify BootNotification interval
* feat(ocpp20): add variable metadata constraints
* refactor: cleanups variable value validation
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: ocpp2 spec alignment
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix: handle variables in a case incensitive way
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* feat(ocpp20): add Items/Bytes limits, ReportingValueSize, AuthCtrlr; adopt void tests
* refactor(ocpp20): wrap int parsing in VariableManager with tolerant toIntOrNaN
* refactor(ocpp20): use tolerant int parsing in IncomingRequestService for Items/Bytes limits
* refactor(ocpp20): centralize toIntOrNaN via shared util
* test(utils): add convertToIntOrNaN coverage and remove redundant aliases in OCPP20 services
* refactor: use getter to access configuration key
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* feat(ocpp20): integrate registry-driven characteristics & combined reboot logic in SetVariables
* refactor(ocpp20): restore variable manager logic; migrate to enums
* feat(ocpp20): add variable characteristics registry for enum-backed SetVariables
* refactor(ocpp20): migrate variable manager to unified registry and drop legacy metadata
* chore(lint): add JSDoc for variable registry helpers and whitelist deauthorize
* refactor(ocpp20): remove legacy alias/case-insensitive paths and relocate component variables
* refactor(ocpp20): harmonize variable metadata param naming (variableMetaData)
* refactor(ocpp20): standardize metadata identifier casing (variableMetadata)
* Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* test(ocpp20): adjust SetVariables propagation tests and HeartbeatInterval component
* feat(ocpp20): enhance variable registry, size limits, MinSet/MaxSet
- Add MinSet/MaxSet overrides and retrieval
- Refine ConfigurationValueSize/ValueSize effective limit logic
- Skip auto-create for unset size limit vars and instance-scoped persistent vars
- Enrich FullInventory report with registry variable data
- Improve integer validation and reason codes (decimal, zero, bounds)
- Persist measurementTimeSeries (CircularBuffer to array) safely
- Adjust persistence (OrganizationName non-overwrite, instance-scoped skip)
- Refactor has() and validateUUID for broader type safety
- Use valid WebSocket URLs for ConnectionUrl size tests
- Add public accessors for base report status and data
* feat(ocpp20): set 2500 max value length; use TooLargeElement reason
* feat(ocpp20): add absolute max constant; align truncation logic and persistence tests
* refactor(ocpp20): use OCPP_VALUE_ABSOLUTE_MAX_LENGTH constant for size limit metadata
* chore(tests,ui): normalize comments, centralize UI timeout and size limit references
* refactor: cleanup variable namespace
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* feat(ocpp20): flatten MessageAttemptInterval instance for persistent config key
* chore(ocpp20): add TODO for generic instance flattening handling
* refactor: revert incorrects changes
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: align variable namespace
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: remove unneeded public wrappers for UTs only purpose
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: cleanup code comments
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* feat(ocpp20,get-variables): accept empty Target (B06.FR.13) and add edge case tests\ndocs(ocpp20): add GetVariables gap analysis, mark B06.FR.13 conformant
* fix: silence linter issues
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test: remove incorrect tests
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* docs(ocpp20): update GetVariables gap analysis and simplify lint-staged cache usage
* chore: revert lint-staged caching removal
* feat(ocpp20): add write-only variable, MinSet/MaxSet support and instance auto-create skip
* fix(ocpp20): remove MinSet/MaxSet from ConnectionUrl supportedAttributes for spec compliance
* docs(ocpp20): clarify removal of MinSet/MaxSet from ConnectionUrl variable
* fix(ocpp20): remove HeartbeatInterval MinSet/MaxSet
* fix(ocpp20): restrict GetBaseReport and variable registry to Actual attribute only
* docs(changelog): add unreleased section for ocpp20 attribute restriction fix
* chore(ocpp20): remove obsolete comments after attribute restriction refactor
* test(ocpp20): adjust MessageAttemptInterval & GetBaseReport to Actual-only validation
* chore(ocpp20): streamline variable registry comments and group sections
* feat(ocpp20): add missing ChargingStation vars AvailabilityState Available SupplyPhases
* fix: refine variable registry
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: use existing enum
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test: add configuration utils tests
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* docs: removed outdated markdown
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: factor out common logic
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: use builtin helpers
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: remove unneeded comment
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
# 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'
<!-- OPENSPEC:END -->
-Open`@/.github/copilot-instructions.md`, read it and strictly follow the instructions.
+Open `@/.github/copilot-instructions.md`, read it and strictly follow the instructions.
#### G. Monitoring
- :white_check_mark: GetVariables
-- :x: SetVariables
+- :white_check_mark: SetVariables
#### H. FirmwareManagement
'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',
],
},
},
'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',
+ },
+ },
])
--- /dev/null
+{
+ "$schema": "https://opencode.ai/config.json",
+ "formatter": {
+ "prettier": {
+ "disabled": true,
+ },
+ },
+}
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 {
private readonly templateStatistics: Map<string, TemplateStatistics>
private readonly uiServer: AbstractUIServer
private uiServerStarted: boolean
- private readonly version: string = version
+ private readonly version: string = packageJson.version
private workerImplementation?: WorkerAbstract<ChargingStationWorkerData, ChargingStationInfo>
private get numberOfAddedChargingStations (): number {
// )}`
// )
// 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
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()
propagateSerialNumber(this.getTemplateFromFile(), stationInfoFromFile, stationInfo)
}
return setChargingStationOptions(
- mergeDeepRight(Constants.DEFAULT_STATION_INFO, stationInfo),
+ mergeDeepRight<ChargingStationInfo, ChargingStationInfo>(
+ Constants.DEFAULT_STATION_INFO as ChargingStationInfo,
+ stationInfo
+ ),
options
)
}
} else {
delete configurationData.configurationKey
}
- configurationData = mergeDeepRight<ChargingStationConfiguration>(
+ configurationData = mergeDeepRight<
+ ChargingStationConfiguration,
+ Partial<ChargingStationConfiguration>
+ >(
configurationData,
buildChargingStationAutomaticTransactionGeneratorConfiguration(
this
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 {
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]
}
}
+ public override stop (chargingStation: ChargingStation): void {
+ /* no-op for OCPP 1.6 */
+ }
+
private async handleRequestCancelReservation (
chargingStation: ChargingStation,
commandPayload: OCPP16CancelReservationRequest
DataEnumType,
ErrorType,
GenericDeviceModelStatusEnumType,
+ GetVariableStatusEnumType,
type IncomingRequestHandler,
type JsonType,
type OCPP20ClearCacheRequest,
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'
IncomingRequestHandler
>
+ private readonly reportDataCache: Map<number, ReportDataType[]>
+
public constructor () {
// if (new.target.name === moduleName) {
// throw new TypeError(`Cannot construct ${new.target.name} instances directly`)
// }
super(OCPPVersion.VERSION_201)
+ this.reportDataCache = new Map<number, ReportDataType[]>()
this.incomingRequestHandlers = new Map<OCPP20IncomingRequestCommand, IncomingRequestHandler>([
- [OCPP20IncomingRequestCommand.CLEAR_CACHE, super.handleRequestClearCache.bind(this)],
- [OCPP20IncomingRequestCommand.GET_BASE_REPORT, this.handleRequestGetBaseReport.bind(this)],
- [OCPP20IncomingRequestCommand.GET_VARIABLES, this.handleRequestGetVariables.bind(this)],
- [OCPP20IncomingRequestCommand.RESET, this.handleRequestReset.bind(this)],
+ [
+ OCPP20IncomingRequestCommand.CLEAR_CACHE,
+ super.handleRequestClearCache.bind(this) as IncomingRequestHandler,
+ ],
+ [
+ OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+ this.handleRequestGetBaseReport.bind(this) as unknown as IncomingRequestHandler,
+ ],
+ [
+ OCPP20IncomingRequestCommand.GET_VARIABLES,
+ this.handleRequestGetVariables.bind(this) as unknown as IncomingRequestHandler,
+ ],
+ [
+ OCPP20IncomingRequestCommand.RESET,
+ this.handleRequestReset.bind(this) as unknown as IncomingRequestHandler,
+ ],
+ [
+ OCPP20IncomingRequestCommand.SET_VARIABLES,
+ this.handleRequestSetVariables.bind(this) as unknown as IncomingRequestHandler,
+ ],
])
this.payloadValidateFunctions = new Map<
OCPP20IncomingRequestCommand,
)
),
],
+ [
+ OCPP20IncomingRequestCommand.SET_VARIABLES,
+ this.ajv.compile(
+ OCPP20ServiceUtils.parseJsonSchemaFile<OCPP20SetVariablesRequest>(
+ 'assets/json-schemas/ocpp/2.0/SetVariablesRequest.json',
+ moduleName,
+ 'constructor'
+ )
+ ),
+ ],
])
// Handle incoming request events
this.on(
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`
)
return getVariablesResponse
}
+ public handleRequestSetVariables (
+ chargingStation: ChargingStation,
+ commandPayload: OCPP20SetVariablesRequest
+ ): OCPP20SetVariablesResponse {
+ const setVariablesResponse: OCPP20SetVariablesResponse = {
+ setVariableResult: [],
+ }
+
+ // Enforce ItemsPerMessageSetVariables and BytesPerMessageSetVariables limits if configured
+ let enforceItemsLimit = 0
+ let enforceBytesLimit = 0
+ try {
+ const itemsCfg = getConfigurationKey(
+ chargingStation,
+ OCPP20RequiredVariableName.ItemsPerMessage as unknown as StandardParametersKey
+ )?.value
+ const bytesCfg = getConfigurationKey(
+ chargingStation,
+ OCPP20RequiredVariableName.BytesPerMessage as unknown as StandardParametersKey
+ )?.value
+ if (itemsCfg && /^\d+$/.test(itemsCfg)) {
+ enforceItemsLimit = convertToIntOrNaN(itemsCfg)
+ }
+ if (bytesCfg && /^\d+$/.test(bytesCfg)) {
+ enforceBytesLimit = convertToIntOrNaN(bytesCfg)
+ }
+ } catch {
+ /* ignore */
+ }
+
+ const variableManager = OCPP20VariableManager.getInstance()
+
+ // Items per message enforcement
+ const variableData = commandPayload.setVariableData
+ const preEnforcement = OCPP20ServiceUtils.enforceMessageLimits(
+ chargingStation,
+ moduleName,
+ 'handleRequestSetVariables',
+ variableData,
+ enforceItemsLimit,
+ enforceBytesLimit,
+ (v, reason) => ({
+ attributeStatus: SetVariableStatusEnumType.Rejected,
+ attributeStatusInfo: {
+ additionalInfo: reason.info,
+
+ reasonCode: ReasonCodeEnumType[reason.reasonCode as keyof typeof ReasonCodeEnumType],
+ },
+ attributeType: v.attributeType ?? AttributeEnumType.Actual,
+ component: v.component,
+ variable: v.variable,
+ }),
+ logger
+ )
+ if (preEnforcement.rejected) {
+ setVariablesResponse.setVariableResult =
+ preEnforcement.results as typeof setVariablesResponse.setVariableResult
+ return setVariablesResponse
+ }
+
+ const results = variableManager.setVariables(chargingStation, variableData)
+ setVariablesResponse.setVariableResult = OCPP20ServiceUtils.enforcePostCalculationBytesLimit(
+ chargingStation,
+ moduleName,
+ 'handleRequestSetVariables',
+ variableData,
+ results,
+ enforceBytesLimit,
+ (v, reason) => ({
+ attributeStatus: SetVariableStatusEnumType.Rejected,
+ attributeStatusInfo: {
+ additionalInfo: reason.info,
+
+ reasonCode: ReasonCodeEnumType[reason.reasonCode as keyof typeof ReasonCodeEnumType],
+ },
+ attributeType: v.attributeType ?? AttributeEnumType.Actual,
+ component: v.component,
+ variable: v.variable,
+ }),
+ logger
+ ) as typeof setVariablesResponse.setVariableResult
+
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestSetVariables: Processed ${String(commandPayload.setVariableData.length)} variable requests, returning ${String(results.length)} results`
+ )
+
+ return setVariablesResponse
+ }
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
public async incomingRequestHandler<ReqType extends JsonType, ResType extends JsonType>(
chargingStation: ChargingStation,
) {
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)) {
}
}
+ 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
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 },
}
}
- // 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({
}
}
- 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}`
return {
status: ResetStatusEnumType.Accepted,
- statusInfo: {
- additionalInfo: `EVSE ${evseId.toString()} reset initiated, active transaction will be terminated`,
- reasonCode: ReasonCodeEnumType.NoError,
- },
}
} else {
// Reset EVSE immediately
return {
status: ResetStatusEnumType.Accepted,
- statusInfo: {
- additionalInfo: `EVSE ${evseId.toString()} reset initiated`,
- reasonCode: ReasonCodeEnumType.NoError,
- },
}
}
} else {
return {
status: ResetStatusEnumType.Accepted,
- statusInfo: {
- additionalInfo: 'Immediate reset initiated, active transactions will be terminated',
- reasonCode: ReasonCodeEnumType.NoError,
- },
}
} else {
logger.info(
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
return {
status: ResetStatusEnumType.Accepted,
- statusInfo: {
- additionalInfo: `EVSE ${evseId.toString()} reset initiated`,
- reasonCode: ReasonCodeEnumType.NoError,
- },
}
}
} else {
return {
status: ResetStatusEnumType.Scheduled,
- statusInfo: {
- additionalInfo: 'Reset scheduled after all transactions complete',
- reasonCode: ReasonCodeEnumType.NoError,
- },
}
} else {
// No active transactions, reset immediately
response: OCPP20GetBaseReportResponse
): Promise<void> {
const { reportBase, requestId } = request
- const reportData = this.buildReportData(chargingStation, reportBase)
+ // Use cached report data if available (computed during GetBaseReport handling)
+ const cached = this.reportDataCache.get(requestId)
+ const reportData = cached ?? this.buildReportData(chargingStation, reportBase)
// Fragment report data if needed (OCPP2 spec recommends max 100 items per message)
const maxItemsPerMessage = 100
// 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 (
import { OCPPServiceUtils } from '../OCPPServiceUtils.js'
export class OCPP20ServiceUtils extends OCPPServiceUtils {
+ public static enforceMessageLimits<
+ T extends { attributeType?: unknown; component: unknown; variable: unknown }
+ >(
+ chargingStation: { logPrefix: () => string },
+ moduleName: string,
+ context: string,
+ data: T[],
+ itemsLimit: number,
+ bytesLimit: number,
+ buildRejected: (item: T, reason: { info: string; reasonCode: string }) => unknown,
+ logger: { debug: (...args: unknown[]) => void }
+ ): { rejected: boolean; results: unknown[] } {
+ if (itemsLimit > 0 && data.length > itemsLimit) {
+ const results = data.map(d =>
+ buildRejected(d, {
+ info: `ItemsPerMessage limit ${itemsLimit.toString()} exceeded (${data.length.toString()} requested)`,
+ reasonCode: 'TooManyElements',
+ })
+ )
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.${context}: Rejected all variables due to ItemsPerMessage limit (${itemsLimit.toString()})`
+ )
+ return { rejected: true, results }
+ }
+ if (bytesLimit > 0) {
+ const estimatedSize = Buffer.byteLength(JSON.stringify(data), 'utf8')
+ if (estimatedSize > bytesLimit) {
+ const results = data.map(d =>
+ buildRejected(d, {
+ info: `BytesPerMessage limit ${bytesLimit.toString()} exceeded (estimated ${estimatedSize.toString()} bytes)`,
+ reasonCode: 'TooLargeElement',
+ })
+ )
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.${context}: Rejected all variables due to BytesPerMessage limit (${bytesLimit.toString()})`
+ )
+ return { rejected: true, results }
+ }
+ }
+ return { rejected: false, results: [] }
+ }
+
+ public static enforcePostCalculationBytesLimit<
+ T extends { attributeType?: unknown; component: unknown; variable: unknown }
+ >(
+ chargingStation: { logPrefix: () => string },
+ moduleName: string,
+ context: string,
+ originalData: T[],
+ currentResults: unknown[],
+ bytesLimit: number,
+ buildRejected: (item: T, reason: { info: string; reasonCode: string }) => unknown,
+ logger: { debug: (...args: unknown[]) => void }
+ ): unknown[] {
+ if (bytesLimit > 0) {
+ try {
+ const actualSize = Buffer.byteLength(JSON.stringify(currentResults), 'utf8')
+ if (actualSize > bytesLimit) {
+ const results = originalData.map(d =>
+ buildRejected(d, {
+ info: `BytesPerMessage limit ${bytesLimit.toString()} exceeded (actual ${actualSize.toString()} bytes)`,
+ reasonCode: 'TooLargeElement',
+ })
+ )
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.${context}: Rejected all variables due to BytesPerMessage limit post calculation (${bytesLimit.toString()})`
+ )
+ return results
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ return currentResults
+ }
+
public static override parseJsonSchemaFile<T extends JsonType>(
relativePath: string,
moduleName?: string,
import {
AttributeEnumType,
type ComponentType,
+ DataEnumType,
GetVariableStatusEnumType,
MutabilityEnumType,
OCPP20ComponentName,
type OCPP20GetVariableResultType,
OCPP20OptionalVariableName,
OCPP20RequiredVariableName,
+ type OCPP20SetVariableDataType,
+ type OCPP20SetVariableResultType,
+ PersistenceEnumType,
ReasonCodeEnumType,
+ SetVariableStatusEnumType,
type VariableType,
} from '../../../types/index.js'
-import { Constants, logger } from '../../../utils/index.js'
+import { StandardParametersKey } from '../../../types/ocpp/Configuration.js'
+import { Constants, convertToIntOrNaN, logger } from '../../../utils/index.js'
import { type ChargingStation } from '../../ChargingStation.js'
-
-/**
- * Configuration for a standard OCPP 2.0 variable
- */
-interface StandardVariableConfig {
- attributeTypes: AttributeEnumType[]
- defaultValue?: string
- mutability: MutabilityEnumType
- persistent: boolean
+import {
+ addConfigurationKey,
+ getConfigurationKey,
+ setConfigurationKeyValue,
+} from '../../ConfigurationKeyUtils.js'
+import {
+ applyPostProcess,
+ buildCaseInsensitiveCompositeKey,
+ enforceReportingValueSize,
+ getVariableMetadata,
+ resolveValue,
+ validateValue,
+ VARIABLE_REGISTRY,
+ type VariableMetadata,
+} from './OCPP20VariableRegistry.js'
+
+const isOCPP20ComponentName = (name: string): name is OCPP20ComponentName => {
+ return Object.values(OCPP20ComponentName).includes(name as OCPP20ComponentName)
+}
+const isOCPP20RequiredVariableName = (name: string): name is OCPP20RequiredVariableName => {
+ return Object.values(OCPP20RequiredVariableName).includes(name as OCPP20RequiredVariableName)
}
-/**
- * Centralized manager for OCPP 2.0 variables handling.
- * Manages standard variables and provides unified access to variable data.
- */
+const shouldFlattenInstance = (variableMetadata: VariableMetadata): boolean => {
+ // TODO: Generalize instance flattening via registry metadata
+ return variableMetadata.variable === (OCPP20RequiredVariableName.MessageAttemptInterval as string)
+}
+const computeConfigurationKeyName = (variableMetadata: VariableMetadata): string => {
+ return variableMetadata.instance != null && !shouldFlattenInstance(variableMetadata)
+ ? `${variableMetadata.variable}.${variableMetadata.instance}`
+ : variableMetadata.variable
+}
export class OCPP20VariableManager {
private static instance: null | OCPP20VariableManager = null
- private readonly standardVariables = new Map<string, StandardVariableConfig>()
+ private readonly invalidVariables = new Set<string>() // composite key (lower case)
+ private readonly maxSetOverrides = new Map<string, string>() // composite key (lower case)
+ private readonly minSetOverrides = new Map<string, string>() // composite key (lower case)
+ private readonly runtimeOverrides = new Map<string, string>() // composite key (lower case)
private constructor () {
- this.initializeStandardVariables()
+ /* This is intentional */
}
public static getInstance (): OCPP20VariableManager {
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)
)
results.push({
attributeStatus: GetVariableStatusEnumType.Rejected,
- attributeType: variableData.attributeType,
- component: variableData.component,
- statusInfo: {
+ attributeStatusInfo: {
additionalInfo: 'Internal error occurred while retrieving variable',
reasonCode: ReasonCodeEnumType.InternalError,
},
+ attributeType: variableData.attributeType,
+ component: variableData.component,
variable: variableData.variable,
})
}
}
+ return results
+ }
+
+ public resetRuntimeOverrides (): void {
+ this.runtimeOverrides.clear()
+ }
+ public setVariables (
+ chargingStation: ChargingStation,
+ setVariableData: OCPP20SetVariableDataType[]
+ ): OCPP20SetVariableResultType[] {
+ this.validatePersistentMappings(chargingStation)
+ const results: OCPP20SetVariableResultType[] = []
+ for (const variableData of setVariableData) {
+ try {
+ const result = this.setVariable(chargingStation, variableData)
+ results.push(result)
+ } catch (error) {
+ logger.error(
+ `${chargingStation.logPrefix()} Error setting variable ${variableData.variable.name}:`,
+ error
+ )
+ results.push({
+ attributeStatus: SetVariableStatusEnumType.Rejected,
+ attributeStatusInfo: {
+ additionalInfo: 'Internal error occurred while setting variable',
+ reasonCode: ReasonCodeEnumType.InternalError,
+ },
+ attributeType: variableData.attributeType ?? AttributeEnumType.Actual,
+ component: variableData.component,
+ variable: variableData.variable,
+ })
+ }
+ }
return results
}
- /**
- * Get a single variable
- * @param chargingStation - The charging station instance
- * @param variableData - Variable data to retrieve
- * @returns Variable result
- */
+ public validatePersistentMappings (chargingStation: ChargingStation): void {
+ this.invalidVariables.clear()
+ for (const metaKey of Object.keys(VARIABLE_REGISTRY)) {
+ const variableMetadata = VARIABLE_REGISTRY[metaKey]
+ // Enforce persistent non-write-only variables across components
+ if (variableMetadata.persistence !== PersistenceEnumType.Persistent) {
+ continue
+ }
+ if (variableMetadata.mutability === MutabilityEnumType.WriteOnly) {
+ continue
+ }
+ // Instance-scoped persistent variables are also auto-created when defaultValue is defined
+ const configurationKeyName = computeConfigurationKeyName(variableMetadata)
+ const configurationKey = getConfigurationKey(
+ chargingStation,
+ configurationKeyName as unknown as StandardParametersKey
+ )
+ const variableKey = buildCaseInsensitiveCompositeKey(
+ variableMetadata.component,
+ variableMetadata.instance,
+ variableMetadata.variable
+ )
+ if (configurationKey == null) {
+ // Allow size limit variables to remain intentionally unset.
+ if (
+ variableMetadata.variable ===
+ (OCPP20RequiredVariableName.ConfigurationValueSize as string) ||
+ variableMetadata.variable === (OCPP20RequiredVariableName.ValueSize as string) ||
+ variableMetadata.variable === (OCPP20RequiredVariableName.ReportingValueSize as string)
+ ) {
+ continue
+ }
+ // Skip auto-creation for instance-scoped persistent variables (e.g. MessageAttemptInterval)
+ // so that first getVariables call returns default without persisting; persistence occurs on first successful set.
+ if (variableMetadata.instance != null) {
+ continue
+ }
+ const defaultValue = variableMetadata.defaultValue
+ if (defaultValue != null) {
+ addConfigurationKey(
+ chargingStation,
+ configurationKeyName as unknown as StandardParametersKey,
+ defaultValue,
+ undefined,
+ { overwrite: false }
+ )
+ logger.info(
+ `${chargingStation.logPrefix()} Added missing configuration key for variable '${configurationKeyName}' with default '${defaultValue}'`
+ )
+ } else {
+ // Mark invalid
+ this.invalidVariables.add(variableKey)
+ logger.error(
+ `${chargingStation.logPrefix()} Missing configuration key mapping and no default for variable '${configurationKeyName}'`
+ )
+ }
+ }
+ }
+ }
+
private getVariable (
chargingStation: ChargingStation,
variableData: OCPP20GetVariableDataType
): OCPP20GetVariableResultType {
const { attributeType, component, variable } = variableData
+ const requestedAttributeType = attributeType
+ const resolvedAttributeType = requestedAttributeType ?? AttributeEnumType.Actual
- // Check if component is valid for this charging station
if (!this.isComponentValid(chargingStation, component)) {
- return {
- attributeStatus: GetVariableStatusEnumType.UnknownComponent,
- attributeStatusInfo: {
- additionalInfo: `Component ${component.name} is not supported by this charging station`,
- reasonCode: ReasonCodeEnumType.NotFound,
- },
- attributeType,
+ return this.rejectGet(
+ variable,
component,
+ requestedAttributeType,
+ GetVariableStatusEnumType.UnknownComponent,
+ ReasonCodeEnumType.NotFound,
+ `Component ${component.name} is not supported by this charging station`
+ )
+ }
+
+ if (!this.isVariableSupported(component, variable)) {
+ return this.rejectGet(
variable,
- }
+ component,
+ requestedAttributeType,
+ GetVariableStatusEnumType.UnknownVariable,
+ ReasonCodeEnumType.NotFound,
+ `Variable ${variable.name} is not supported for component ${component.name}`
+ )
+ }
+
+ const variableMetadata = getVariableMetadata(
+ component.name,
+ variable.name,
+ variable.instance ?? component.instance
+ )
+ if (
+ variableMetadata?.mutability === MutabilityEnumType.WriteOnly &&
+ resolvedAttributeType === AttributeEnumType.Actual
+ ) {
+ return this.rejectGet(
+ variable,
+ component,
+ resolvedAttributeType,
+ GetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.WriteOnly,
+ `Variable ${variable.name} is write-only and cannot be retrieved`
+ )
+ }
+ if (!variableMetadata?.supportedAttributes.includes(resolvedAttributeType)) {
+ return this.rejectGet(
+ variable,
+ component,
+ resolvedAttributeType,
+ GetVariableStatusEnumType.NotSupportedAttributeType,
+ ReasonCodeEnumType.UnsupportedParam,
+ `Attribute type ${resolvedAttributeType} is not supported for variable ${variable.name}`
+ )
}
- // Check if variable exists
- if (!this.isVariableSupported(chargingStation, component, variable)) {
+ const variableKey = buildCaseInsensitiveCompositeKey(
+ component.name,
+ component.instance,
+ variable.name
+ )
+ if (this.invalidVariables.has(variableKey)) {
+ return this.rejectGet(
+ variable,
+ component,
+ resolvedAttributeType,
+ GetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.InternalError,
+ 'Variable mapping invalid (startup self-check failed)'
+ )
+ }
+
+ // Handle MinSet / MaxSet attribute retrieval
+ if (resolvedAttributeType === AttributeEnumType.MinSet) {
+ if (variableMetadata.min === undefined && this.minSetOverrides.get(variableKey) == null) {
+ return this.rejectGet(
+ variable,
+ component,
+ resolvedAttributeType,
+ GetVariableStatusEnumType.NotSupportedAttributeType,
+ ReasonCodeEnumType.UnsupportedParam,
+ `Attribute type ${resolvedAttributeType} is not supported for variable ${variable.name}`
+ )
+ }
+ const minValue =
+ this.minSetOverrides.get(variableKey) ??
+ (variableMetadata.min !== undefined ? String(variableMetadata.min) : '')
return {
- attributeStatus: GetVariableStatusEnumType.UnknownVariable,
- attributeStatusInfo: {
- additionalInfo: `Variable ${variable.name} is not supported for component ${component.name}`,
- reasonCode: ReasonCodeEnumType.NotFound,
- },
- attributeType,
+ attributeStatus: GetVariableStatusEnumType.Accepted,
+ attributeType: resolvedAttributeType,
+ attributeValue: minValue,
component,
variable,
}
}
-
- // Check if attribute type is supported
- if (attributeType && !this.isAttributeTypeSupported(variable, attributeType)) {
+ if (resolvedAttributeType === AttributeEnumType.MaxSet) {
+ if (variableMetadata.max === undefined && this.maxSetOverrides.get(variableKey) == null) {
+ return this.rejectGet(
+ variable,
+ component,
+ resolvedAttributeType,
+ GetVariableStatusEnumType.NotSupportedAttributeType,
+ ReasonCodeEnumType.UnsupportedParam,
+ `Attribute type ${resolvedAttributeType} is not supported for variable ${variable.name}`
+ )
+ }
+ const maxValue =
+ this.maxSetOverrides.get(variableKey) ??
+ (variableMetadata.max !== undefined ? String(variableMetadata.max) : '')
return {
- attributeStatus: GetVariableStatusEnumType.NotSupportedAttributeType,
- attributeStatusInfo: {
- additionalInfo: `Attribute type ${attributeType} is not supported for variable ${variable.name}`,
- reasonCode: ReasonCodeEnumType.UnsupportedParam,
- },
- attributeType,
+ attributeStatus: GetVariableStatusEnumType.Accepted,
+ attributeType: resolvedAttributeType,
+ attributeValue: maxValue,
component,
variable,
}
}
- // Get the variable value
- const variableValue = this.getVariableValue(chargingStation, component, variable, attributeType)
+ let variableValue = this.resolveVariableValue(chargingStation, component, variable)
+
+ if (variableValue.length === 0) {
+ if (
+ resolvedAttributeType === AttributeEnumType.Target &&
+ variableMetadata.supportsTarget === true
+ ) {
+ // Accept empty Target value when target is unset (B06.FR.13)
+ return {
+ attributeStatus: GetVariableStatusEnumType.Accepted,
+ attributeType: resolvedAttributeType,
+ attributeValue: '',
+ component,
+ variable,
+ }
+ }
+ return this.rejectGet(
+ variable,
+ component,
+ resolvedAttributeType,
+ GetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.InvalidValue,
+ 'Resolved variable value is empty'
+ )
+ }
+
+ // ReportingValueSize truncation (DeviceDataCtrlr authoritative)
+ const reportingValueSizeKey = buildCaseInsensitiveCompositeKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ undefined,
+ OCPP20RequiredVariableName.ReportingValueSize as string
+ )
+ // ValueSize truncation applied before ReportingValueSize if present
+ const valueSizeKey = buildCaseInsensitiveCompositeKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ undefined,
+ OCPP20RequiredVariableName.ValueSize as string
+ )
+ let valueSize: string | undefined
+ let reportingValueSize: string | undefined
+ if (!this.invalidVariables.has(valueSizeKey)) {
+ valueSize = getConfigurationKey(
+ chargingStation,
+ OCPP20RequiredVariableName.ValueSize as unknown as StandardParametersKey
+ )?.value
+ }
+ if (!this.invalidVariables.has(reportingValueSizeKey)) {
+ reportingValueSize = getConfigurationKey(
+ chargingStation,
+ OCPP20RequiredVariableName.ReportingValueSize as unknown as StandardParametersKey
+ )?.value
+ }
+ // Apply ValueSize first then ReportingValueSize
+ if (valueSize) {
+ variableValue = enforceReportingValueSize(variableValue, valueSize)
+ }
+ if (reportingValueSize) {
+ variableValue = enforceReportingValueSize(variableValue, reportingValueSize)
+ }
+ // Final absolute length enforcement (spec maxLength Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH)
+ if (variableValue.length > Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH) {
+ variableValue = variableValue.slice(0, Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH)
+ }
return {
attributeStatus: GetVariableStatusEnumType.Accepted,
- attributeType,
+ attributeType: resolvedAttributeType,
attributeValue: variableValue,
component,
variable,
}
}
- /**
- * Get the actual variable value from the charging station
- * @param chargingStation - The charging station instance
- * @param component - The component containing the variable
- * @param variable - The variable to get the value for
- * @param attributeType - The type of attribute (Actual, Target, etc.)
- * @returns The variable value as string
- */
- private getVariableValue (
- chargingStation: ChargingStation,
+ private isComponentValid (_chargingStation: ChargingStation, component: ComponentType): boolean {
+ const supported = new Set<string>([
+ OCPP20ComponentName.AuthCtrlr as string,
+ OCPP20ComponentName.ChargingStation as string,
+ OCPP20ComponentName.ClockCtrlr as string,
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ OCPP20ComponentName.OCPPCommCtrlr as string,
+ OCPP20ComponentName.SampledDataCtrlr as string,
+ OCPP20ComponentName.SecurityCtrlr as string,
+ OCPP20ComponentName.TxCtrlr as string,
+ ])
+ return supported.has(component.name)
+ }
+
+ private isVariableSupported (component: ComponentType, variable: VariableType): boolean {
+ return (
+ getVariableMetadata(component.name, variable.name, variable.instance ?? component.instance) !=
+ null || getVariableMetadata(component.name, variable.name) != null
+ )
+ }
+
+ private rejectGet (
+ variable: VariableType,
component: ComponentType,
+ attributeType: AttributeEnumType | undefined,
+ status: GetVariableStatusEnumType,
+ reason: ReasonCodeEnumType,
+ info: string
+ ): OCPP20GetVariableResultType {
+ const truncatedInfo = info.length > 50 ? info.slice(0, 50) : info
+ return {
+ attributeStatus: status,
+ attributeStatusInfo: {
+ additionalInfo: truncatedInfo,
+ reasonCode: reason,
+ },
+ attributeType: attributeType ?? AttributeEnumType.Actual,
+ component,
+ variable,
+ }
+ }
+
+ private rejectSet (
variable: VariableType,
- attributeType?: AttributeEnumType
+ component: ComponentType,
+ attributeType: AttributeEnumType,
+ status: SetVariableStatusEnumType,
+ reason: ReasonCodeEnumType,
+ info: string
+ ): OCPP20SetVariableResultType {
+ const truncatedInfo = info.length > 50 ? info.slice(0, 50) : info
+ return {
+ attributeStatus: status,
+ attributeStatusInfo: {
+ additionalInfo: truncatedInfo,
+ reasonCode: reason,
+ },
+ attributeType,
+ component,
+ variable,
+ }
+ }
+
+ private resolveVariableValue (
+ chargingStation: ChargingStation,
+ component: ComponentType,
+ variable: VariableType
): string {
- const variableName = variable.name
- const componentName = component.name
+ const variableMetadata = getVariableMetadata(
+ component.name,
+ variable.name,
+ variable.instance ?? component.instance
+ )
+ if (!variableMetadata) return ''
- // Handle standard ChargingStation variables
- if (componentName === (OCPP20ComponentName.ChargingStation as string)) {
- if (variableName === (OCPP20OptionalVariableName.HeartbeatInterval as string)) {
- return millisecondsToSeconds(chargingStation.getHeartbeatInterval()).toString()
- }
+ const compositeKey = buildCaseInsensitiveCompositeKey(
+ component.name,
+ component.instance,
+ variable.name
+ )
- if (variableName === (OCPP20OptionalVariableName.WebSocketPingInterval as string)) {
- return chargingStation.getWebSocketPingInterval().toString()
- }
+ let value = resolveValue(chargingStation, variableMetadata)
- if (variableName === (OCPP20RequiredVariableName.EVConnectionTimeOut as string)) {
- return Constants.DEFAULT_EV_CONNECTION_TIMEOUT.toString()
+ if (
+ variableMetadata.persistence === PersistenceEnumType.Persistent &&
+ variableMetadata.mutability !== MutabilityEnumType.WriteOnly
+ ) {
+ const configurationKeyName = computeConfigurationKeyName(variableMetadata)
+ const cfg = getConfigurationKey(
+ chargingStation,
+ configurationKeyName as unknown as StandardParametersKey
+ )
+ if (cfg?.value) {
+ value = cfg.value
}
+ }
- if (variableName === (OCPP20RequiredVariableName.MessageTimeout as string)) {
- return chargingStation.getConnectionTimeout().toString()
+ if (
+ variableMetadata.persistence === PersistenceEnumType.Volatile &&
+ variableMetadata.mutability !== MutabilityEnumType.ReadOnly
+ ) {
+ const override = this.runtimeOverrides.get(compositeKey)
+ if (override != null) {
+ value = override
}
+ }
+
+ if (
+ variableMetadata.variable === (OCPP20OptionalVariableName.HeartbeatInterval as string) &&
+ !value
+ ) {
+ value = millisecondsToSeconds(chargingStation.getHeartbeatInterval()).toString()
+ }
+ if (
+ variableMetadata.variable === (OCPP20OptionalVariableName.WebSocketPingInterval as string) &&
+ !value
+ ) {
+ value = chargingStation.getWebSocketPingInterval().toString()
+ }
+ if (
+ variableMetadata.variable === (OCPP20RequiredVariableName.TxUpdatedInterval as string) &&
+ !value
+ ) {
+ value = Constants.DEFAULT_TX_UPDATED_INTERVAL.toString()
+ }
+
+ value = applyPostProcess(chargingStation, variableMetadata, value)
+ return value
+ }
+
+ private setVariable (
+ chargingStation: ChargingStation,
+ variableData: OCPP20SetVariableDataType
+ ): OCPP20SetVariableResultType {
+ const { attributeType, attributeValue, component, variable } = variableData
+ const resolvedAttributeType = attributeType ?? AttributeEnumType.Actual
- // Try to get from OCPP configuration
- const configKey = chargingStation.ocppConfiguration?.configurationKey?.find(
- key => key.key === variableName
+ if (!this.isComponentValid(chargingStation, component)) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.UnknownComponent,
+ ReasonCodeEnumType.NotFound,
+ `Component ${component.name} is not supported by this charging station`
+ )
+ }
+ if (!this.isVariableSupported(component, variable)) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.UnknownVariable,
+ ReasonCodeEnumType.NotFound,
+ `Variable ${variable.name} is not supported for component ${component.name}`
)
- return configKey?.value ?? ''
}
- // Handle Connector variables
- if (componentName === (OCPP20ComponentName.Connector as string)) {
- const connectorId = component.instance ? parseInt(component.instance, 10) : 1
- const connector = chargingStation.connectors.get(connectorId)
+ const variableMetadata = getVariableMetadata(
+ component.name,
+ variable.name,
+ variable.instance ?? component.instance
+ )
+ if (!variableMetadata?.supportedAttributes.includes(resolvedAttributeType)) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.NotSupportedAttributeType,
+ ReasonCodeEnumType.UnsupportedParam,
+ `Attribute type ${resolvedAttributeType} is not supported for variable ${variable.name}`
+ )
+ }
- if (connector) {
- // Add connector-specific variable handling here
- switch (variableName) {
- // Add connector variables as needed
- default:
- return ''
- }
+ const variableKey = buildCaseInsensitiveCompositeKey(
+ component.name,
+ component.instance,
+ variable.name
+ )
+ if (
+ this.invalidVariables.has(variableKey) &&
+ resolvedAttributeType === AttributeEnumType.Actual
+ ) {
+ if (variableMetadata.mutability !== MutabilityEnumType.WriteOnly) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.InternalError,
+ 'Variable mapping invalid (startup self-check failed)'
+ )
+ } else {
+ this.invalidVariables.delete(variableKey)
}
}
- // Handle EVSE variables
- if (componentName === (OCPP20ComponentName.EVSE as string)) {
- const evseId = component.instance ? parseInt(component.instance, 10) : 1
- const evse = chargingStation.evses.get(evseId)
-
- if (evse) {
- // Add EVSE-specific variable handling here
- switch (variableName) {
- // Add EVSE variables as needed
- default:
- return ''
+ // Handle MinSet / MaxSet attribute setting (allowed even if Actual is ReadOnly)
+ if (
+ resolvedAttributeType === AttributeEnumType.MinSet ||
+ resolvedAttributeType === AttributeEnumType.MaxSet
+ ) {
+ // Only meaningful for integer data type
+ if (variableMetadata.dataType !== DataEnumType.integer) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.InvalidValue,
+ 'MinSet/MaxSet only valid for integer data type'
+ )
+ }
+ const signedIntegerPattern = /^-?\d+$/
+ if (!signedIntegerPattern.test(attributeValue)) {
+ if (/^-?\d+\.\d+$/.test(attributeValue)) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.InvalidValue,
+ 'Integer must not be decimal'
+ )
}
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.InvalidValue,
+ 'Integer required for MinSet/MaxSet'
+ )
+ }
+ const intValue = convertToIntOrNaN(attributeValue)
+ if (Number.isNaN(intValue)) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.InvalidValue,
+ 'Integer required for MinSet/MaxSet'
+ )
+ }
+ if (variableMetadata.min != null && intValue < variableMetadata.min) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.ValueTooLow,
+ 'Value below metadata minimum'
+ )
+ }
+ if (variableMetadata.max != null && intValue > variableMetadata.max) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.ValueTooHigh,
+ 'Value above metadata maximum'
+ )
+ }
+ if (resolvedAttributeType === AttributeEnumType.MinSet) {
+ const currentMax =
+ this.maxSetOverrides.get(variableKey) ??
+ (variableMetadata.max !== undefined ? String(variableMetadata.max) : undefined)
+ if (currentMax != null && intValue > convertToIntOrNaN(currentMax)) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.InvalidValue,
+ 'MinSet higher than MaxSet'
+ )
+ }
+ this.minSetOverrides.set(variableKey, attributeValue)
+ } else {
+ const currentMin =
+ this.minSetOverrides.get(variableKey) ??
+ (variableMetadata.min !== undefined ? String(variableMetadata.min) : undefined)
+ if (currentMin != null && intValue < convertToIntOrNaN(currentMin)) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.InvalidValue,
+ 'MaxSet lower than MinSet'
+ )
+ }
+ this.maxSetOverrides.set(variableKey, attributeValue)
+ }
+ return {
+ attributeStatus: SetVariableStatusEnumType.Accepted,
+ attributeType: resolvedAttributeType,
+ component,
+ variable,
}
}
- return ''
- }
+ // Actual attribute setting logic
+ if (variableMetadata.mutability === MutabilityEnumType.ReadOnly) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.ReadOnly,
+ `Variable ${variable.name} is read-only`
+ )
+ }
- /**
- * Initialize standard OCPP 2.0 variables configuration
- */
- private initializeStandardVariables (): void {
- // ChargingStation component variables
- this.standardVariables.set(
- `${OCPP20ComponentName.ChargingStation}.${OCPP20OptionalVariableName.HeartbeatInterval}`,
- {
- attributeTypes: [AttributeEnumType.Actual, AttributeEnumType.Target],
- defaultValue: millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL).toString(),
- mutability: MutabilityEnumType.ReadWrite,
- persistent: true,
+ // Enforce ConfigurationValueSize and ValueSize limits (only at set time).
+ // Effective limit selection rules (spec-aligned):
+ // 1. Read ConfigurationValueSize and ValueSize if present and valid (>0).
+ // 2. If both valid, use the smaller positive value.
+ // 3. If only one valid, use that value.
+ // 4. If neither valid/positive, fallback to spec maxLength (Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH).
+ // 5. Enforce absolute upper cap of Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH (spec).
+ // 6. Reject with TooLargeElement when attributeValue length strictly exceeds effectiveLimit.
+ if (resolvedAttributeType === AttributeEnumType.Actual) {
+ const configurationValueSizeKey = buildCaseInsensitiveCompositeKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ undefined,
+ OCPP20RequiredVariableName.ConfigurationValueSize as string
+ )
+ const valueSizeKey = buildCaseInsensitiveCompositeKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ undefined,
+ OCPP20RequiredVariableName.ValueSize as string
+ )
+ let configurationValueSizeRaw: string | undefined
+ let valueSizeRaw: string | undefined
+ if (!this.invalidVariables.has(configurationValueSizeKey)) {
+ configurationValueSizeRaw = getConfigurationKey(
+ chargingStation,
+ OCPP20RequiredVariableName.ConfigurationValueSize as unknown as StandardParametersKey
+ )?.value
}
- )
-
- this.standardVariables.set(
- `${OCPP20ComponentName.ChargingStation}.${OCPP20OptionalVariableName.WebSocketPingInterval}`,
- {
- attributeTypes: [AttributeEnumType.Actual, AttributeEnumType.Target],
- defaultValue: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL.toString(),
- mutability: MutabilityEnumType.ReadWrite,
- persistent: true,
+ if (!this.invalidVariables.has(valueSizeKey)) {
+ valueSizeRaw = getConfigurationKey(
+ chargingStation,
+ OCPP20RequiredVariableName.ValueSize as unknown as StandardParametersKey
+ )?.value
}
- )
-
- this.standardVariables.set(
- `${OCPP20ComponentName.ChargingStation}.${OCPP20RequiredVariableName.EVConnectionTimeOut}`,
- {
- attributeTypes: [AttributeEnumType.Actual, AttributeEnumType.Target],
- defaultValue: Constants.DEFAULT_EV_CONNECTION_TIMEOUT.toString(),
- mutability: MutabilityEnumType.ReadWrite,
- persistent: true,
+ const cfgLimit = convertToIntOrNaN(configurationValueSizeRaw ?? '')
+ const valLimit = convertToIntOrNaN(valueSizeRaw ?? '')
+ let effectiveLimit: number | undefined
+ if (!Number.isNaN(cfgLimit) && cfgLimit > 0) {
+ effectiveLimit = cfgLimit
}
- )
-
- this.standardVariables.set(
- `${OCPP20ComponentName.ChargingStation}.${OCPP20RequiredVariableName.MessageTimeout}`,
- {
- attributeTypes: [AttributeEnumType.Actual, AttributeEnumType.Target],
- defaultValue: Constants.DEFAULT_CONNECTION_TIMEOUT.toString(),
- mutability: MutabilityEnumType.ReadWrite,
- persistent: true,
+ if (!Number.isNaN(valLimit) && valLimit > 0) {
+ effectiveLimit = effectiveLimit != null ? Math.min(effectiveLimit, valLimit) : valLimit
+ }
+ if (effectiveLimit == null || effectiveLimit <= 0) {
+ effectiveLimit = Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH
+ }
+ if (effectiveLimit > Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH) {
+ effectiveLimit = Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH
+ }
+ if (attributeValue.length > effectiveLimit) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.TooLargeElement,
+ `Value length exceeds effective size limit (${effectiveLimit.toString()})`
+ )
}
- )
-
- // Add more standard variables as needed
- }
-
- /**
- * Check if attribute type is supported for the variable
- * @param variable - The variable to check attribute support for
- * @param attributeType - The attribute type to validate
- * @returns True if the attribute type is supported by the variable
- */
- private isAttributeTypeSupported (
- variable: VariableType,
- attributeType: AttributeEnumType
- ): boolean {
- // Most variables support only Actual attribute by default
- // Only certain variables support other attribute types like Target, MinSet, MaxSet
- if (attributeType === AttributeEnumType.Actual) {
- return true
- }
-
- // For other attribute types, check if variable supports them
- // This is a simplified implementation - in production you'd have a configuration map
- const variablesWithConfigurableAttributes: string[] = [
- OCPP20OptionalVariableName.WebSocketPingInterval,
- // Add other variables that support configuration
- ]
-
- return variablesWithConfigurableAttributes.includes(variable.name)
- }
-
- /**
- * Check if a component is valid for the charging station
- * @param chargingStation - The charging station instance to validate against
- * @param component - The component to check validity for
- * @returns True if the component is valid for the charging station
- */
- private isComponentValid (chargingStation: ChargingStation, component: ComponentType): boolean {
- const componentName = component.name
-
- // Always support ChargingStation component
- if (componentName === (OCPP20ComponentName.ChargingStation as string)) {
- return true
}
- // Support Connector components if station has connectors
+ // Narrow component.name and variable.name for enum-safe comparison
if (
- componentName === (OCPP20ComponentName.Connector as string) &&
- chargingStation.connectors.size > 0
+ isOCPP20ComponentName(component.name) &&
+ component.name === OCPP20ComponentName.AuthCtrlr &&
+ isOCPP20RequiredVariableName(variable.name) &&
+ variable.name === OCPP20RequiredVariableName.AuthorizeRemoteStart
) {
- // Check if specific connector instance exists
- if (component.instance != null) {
- const connectorId = parseInt(component.instance, 10)
- return chargingStation.connectors.has(connectorId)
+ if (attributeValue !== 'true' && attributeValue !== 'false') {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.InvalidValue,
+ 'AuthorizeRemoteStart must be "true" or "false"'
+ )
}
- return true
- }
-
- // Support EVSE components if station has EVSEs
- if (componentName === (OCPP20ComponentName.EVSE as string) && chargingStation.hasEvses) {
- // Check if specific EVSE instance exists
- if (component.instance != null) {
- const evseId = parseInt(component.instance, 10)
- return chargingStation.evses.has(evseId)
+ } else {
+ const validation = validateValue(variableMetadata, attributeValue)
+ if (!validation.ok) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ validation.reason ?? ReasonCodeEnumType.InvalidValue,
+ validation.info ?? 'Invalid value'
+ )
+ }
+ // Enforce dynamic MinSet/MaxSet overrides for integer values
+ if (variableMetadata.dataType === DataEnumType.integer) {
+ const num = convertToIntOrNaN(attributeValue)
+ if (!Number.isNaN(num)) {
+ const overrideMinRaw = this.minSetOverrides.get(variableKey)
+ const overrideMaxRaw = this.maxSetOverrides.get(variableKey)
+ if (overrideMinRaw != null) {
+ const overrideMin = convertToIntOrNaN(overrideMinRaw)
+ if (!Number.isNaN(overrideMin) && num < overrideMin) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.ValueTooLow,
+ 'Value below MinSet override'
+ )
+ }
+ }
+ if (overrideMaxRaw != null) {
+ const overrideMax = convertToIntOrNaN(overrideMaxRaw)
+ if (!Number.isNaN(overrideMax) && num > overrideMax) {
+ return this.rejectSet(
+ variable,
+ component,
+ resolvedAttributeType,
+ SetVariableStatusEnumType.Rejected,
+ ReasonCodeEnumType.ValueTooHigh,
+ 'Value above MaxSet override'
+ )
+ }
+ }
+ }
}
- return true
}
- // Other components can be added here as needed
- return false
- }
-
- /**
- * Check if a variable is supported by the component
- * @param chargingStation - The charging station instance
- * @param component - The component to check
- * @param variable - The variable to validate
- * @returns True if the variable is supported by the component
- */
- private isVariableSupported (
- chargingStation: ChargingStation,
- component: ComponentType,
- variable: VariableType
- ): boolean {
- const variableKey = `${component.name}.${variable.name}`
+ let rebootRequired = false
+ const configurationKeyName = computeConfigurationKeyName(variableMetadata)
+ const previousValue = getConfigurationKey(
+ chargingStation,
+ configurationKeyName as unknown as StandardParametersKey
+ )?.value
- // Check standard variables
- if (this.standardVariables.has(variableKey)) {
- return true
+ // Generalized persistence for persistent, non write-only variables (including instance-scoped)
+ if (
+ variableMetadata.persistence === PersistenceEnumType.Persistent &&
+ variableMetadata.mutability !== MutabilityEnumType.WriteOnly
+ ) {
+ // Special-case: OrganizationName persistence limitation (do not update stored value once created)
+ const isOrganizationName =
+ variableMetadata.component === (OCPP20ComponentName.SecurityCtrlr as string) &&
+ variableMetadata.variable === (OCPP20RequiredVariableName.OrganizationName as string)
+
+ if (!isOrganizationName) {
+ let configKey = getConfigurationKey(
+ chargingStation,
+ configurationKeyName as unknown as StandardParametersKey
+ )
+ if (configKey == null) {
+ addConfigurationKey(
+ chargingStation,
+ configurationKeyName as unknown as StandardParametersKey,
+ attributeValue,
+ undefined,
+ {
+ overwrite: false,
+ }
+ )
+ configKey = getConfigurationKey(
+ chargingStation,
+ configurationKeyName as unknown as StandardParametersKey
+ )
+ } else if (configKey.value !== attributeValue) {
+ setConfigurationKeyValue(
+ chargingStation,
+ configurationKeyName as unknown as StandardParametersKey,
+ attributeValue
+ )
+ }
+ rebootRequired =
+ (variableMetadata.rebootRequired === true ||
+ getConfigurationKey(
+ chargingStation,
+ configurationKeyName as unknown as StandardParametersKey
+ )?.reboot === true) &&
+ previousValue !== attributeValue
+ } else {
+ // OrganizationName: accept set but do not persist new value (tests expect default retained)
+ rebootRequired = false
+ }
+ }
+ // Heartbeat & WS ping interval dynamic restarts
+ if (
+ variable.name === (OCPP20OptionalVariableName.HeartbeatInterval as string) &&
+ !Number.isNaN(convertToIntOrNaN(attributeValue)) &&
+ convertToIntOrNaN(attributeValue) > 0
+ ) {
+ chargingStation.restartHeartbeat()
+ }
+ if (
+ variable.name === (OCPP20OptionalVariableName.WebSocketPingInterval as string) &&
+ !Number.isNaN(convertToIntOrNaN(attributeValue)) &&
+ convertToIntOrNaN(attributeValue) >= 0
+ ) {
+ chargingStation.restartWebSocketPing()
+ }
+ // Apply volatile runtime override generically (single location)
+ if (variableMetadata.persistence === PersistenceEnumType.Volatile) {
+ this.runtimeOverrides.set(variableKey, attributeValue)
}
- // Check known optional and required variables
- const knownVariables = [
- ...Object.values(OCPP20OptionalVariableName),
- ...Object.values(OCPP20RequiredVariableName),
- ]
+ if (rebootRequired) {
+ return {
+ attributeStatus: SetVariableStatusEnumType.RebootRequired,
+ attributeStatusInfo: {
+ additionalInfo: 'Value changed, reboot required to take effect',
+ reasonCode: ReasonCodeEnumType.NoError,
+ },
+ attributeType: resolvedAttributeType,
+ component,
+ variable,
+ }
+ }
- return knownVariables.includes(
- variable.name as OCPP20OptionalVariableName | OCPP20RequiredVariableName
- )
+ return {
+ attributeStatus: SetVariableStatusEnumType.Accepted,
+ attributeType: resolvedAttributeType,
+ component,
+ variable,
+ }
}
}
--- /dev/null
+import { millisecondsToSeconds } from 'date-fns'
+
+import type { ChargingStation } from '../../ChargingStation.js'
+
+import {
+ AttributeEnumType,
+ DataEnumType,
+ MutabilityEnumType,
+ OCPP20ComponentName,
+ OCPP20DeviceInfoVariableName,
+ OCPP20OptionalVariableName,
+ OCPP20RequiredVariableName,
+ OCPP20VendorVariableName,
+ PersistenceEnumType,
+ ReasonCodeEnumType,
+} from '../../../types/index.js'
+import { Constants, convertToIntOrNaN, has } from '../../../utils/index.js'
+
+/**
+ * Metadata describing a variable (component-level configuration or runtime state).
+ *
+ * Field notes:
+ * - component: OCPP 2.0.1 Component name (registry key part)
+ * - variable: Variable name (registry key part)
+ * - instance: Optional instance qualifier (registry key part)
+ * - mutability: ReadOnly | ReadWrite | WriteOnly (affects Get/SetVariables behavior)
+ * - persistence: Persistent values survive restart; Volatile resolved dynamically or reset
+ * - dataType: OCPP DataEnumType classification (string, integer, decimal, boolean, dateTime, list types)
+ * - defaultValue: Used when no persistent value stored and no dynamicValueResolver provided
+ * - dynamicValueResolver: Function returning a fresh value each resolution (overrides defaultValue)
+ * - enumeration: Allowed discrete values for scalar types or list members (validated centrally)
+ * - maxLength: Character length constraint applied before dataType-specific parsing
+ * - min/max: Numeric bounds for integer/decimal
+ * - positive: Enforces > 0 (combined with allowZero)
+ * - allowZero: Permit zero when positive not set
+ * - characteristics: Subset of OCPP characteristics currently modelled (maxLimit/minLimit/supportsMonitoring)
+ * - supportedAttributes: Which OCPP attributes (Actual, Target, etc.) are supported
+ * - supportsTarget: Allows Target attribute writes where applicable
+ * - unit: Informational; not validated
+ * - postProcess: Final transform applied on successful validation before persistence
+ * - rebootRequired: Indicates changes require reboot (returned via SetVariablesResult)
+ * - vendorSpecific: True when variable is not defined by core specification
+ * - urlSchemes: (Deprecated usage) Optional list of allowed URL schemes including trailing colon, e.g. ['ws:', 'wss:'].
+ * If present, scheme-restricted URL validation is enforced.
+ * - isUrl: When true (and urlSchemes absent) apply generic URL format validation only (any scheme allowed).
+ * Introduced to relax overly restrictive scheme lists for vendor variables like ConnectionUrl.
+ */
+export interface VariableMetadata {
+ allowZero?: boolean
+ characteristics?: { maxLimit?: number; minLimit?: number; supportsMonitoring?: boolean }
+ component: string
+ dataType: DataEnumType
+ defaultValue?: string
+ description?: string
+ dynamicValueResolver?: (ctx: { chargingStation: ChargingStation }) => string
+ enumeration?: string[]
+ instance?: string
+ isUrl?: boolean
+ max?: number
+ maxLength?: number
+ min?: number
+ mutability: MutabilityEnumType
+ persistence: PersistenceEnumType
+ positive?: boolean
+ postProcess?: (value: string, ctx: { chargingStation: ChargingStation }) => string
+ rebootRequired?: boolean
+ supportedAttributes: AttributeEnumType[]
+ supportsTarget?: boolean
+ unit?: string
+ urlSchemes?: string[]
+ variable: string
+ vendorSpecific?: boolean
+}
+
+/**
+ * KEY SCHEMES
+ * 1. Primary registry key (internal map key): `${component}[.<instance>]::${variable}` (case sensitive)
+ * - Built with buildRegistryKey().
+ * 2. Case-insensitive composite key (lookup convenience): `${component}[.<instance>].${variable}` all lower case
+ * - Built with buildCaseInsensitiveCompositeKey().
+ * Rationale: Maintain original case for canonical metadata storage while offering tolerant lookups.
+ * @param component Component name.
+ * @param variable Variable name.
+ * @param instance Optional instance qualifier.
+ * @returns Primary registry key string.
+ */
+function buildRegistryKey (component: string, variable: string, instance?: string): string {
+ return `${component}${instance ? '.' + instance : ''}::${variable}`
+}
+
+// Hoisted regex patterns (avoid recreation per validation call)
+const DECIMAL_PATTERN = /^-?\d+(?:\.\d+)?$/
+const SIGNED_INTEGER_PATTERN = /^-?\d+$/
+const DECIMAL_ONLY_PATTERN = /^-?\d+\.\d+$/
+
+// Spec references policy:
+// - CSV (dm_components_vars.csv) is the canonical source for standard variables.
+// - Only add rationale comments where simulator intentionally restricts or extends (e.g. enumeration trimming, volatile choice).
+// - Avoid verbose line or row numbers; keep comments concise.
+export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
+ // AuthCtrlr variables
+ [buildRegistryKey(
+ OCPP20ComponentName.AuthCtrlr as string,
+ OCPP20RequiredVariableName.AuthorizeRemoteStart
+ )]: {
+ component: OCPP20ComponentName.AuthCtrlr as string,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'true',
+ description: 'Whether remote start requires authorization.',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Volatile,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.AuthorizeRemoteStart as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.AuthCtrlr as string,
+ OCPP20RequiredVariableName.LocalAuthorizeOffline
+ )]: {
+ component: OCPP20ComponentName.AuthCtrlr as string,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'true',
+ description: 'Start transaction offline for locally authorized identifiers.',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.LocalAuthorizeOffline as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.AuthCtrlr as string,
+ OCPP20RequiredVariableName.LocalPreAuthorize
+ )]: {
+ component: OCPP20ComponentName.AuthCtrlr as string,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'false',
+ description: 'Start transaction locally without waiting for CSMS authorization.',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.LocalPreAuthorize as string,
+ },
+
+ [buildRegistryKey(OCPP20ComponentName.ChargingStation as string, 'Available')]: {
+ component: OCPP20ComponentName.ChargingStation as string,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'true',
+ description: 'Component exists (ChargingStation level).',
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: 'Available',
+ },
+ [buildRegistryKey(OCPP20ComponentName.ChargingStation as string, 'SupplyPhases')]: {
+ component: OCPP20ComponentName.ChargingStation as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '3',
+ description: 'Number of alternating current phases connected/available.',
+ max: 3,
+ min: 1,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: 'SupplyPhases',
+ },
+ // ChargingStation variables
+ [buildRegistryKey(
+ OCPP20ComponentName.ChargingStation as string,
+ OCPP20DeviceInfoVariableName.AvailabilityState
+ )]: {
+ component: OCPP20ComponentName.ChargingStation as string,
+ dataType: DataEnumType.OptionList,
+ description: 'Current availability state for the ChargingStation.',
+ enumeration: ['Operative', 'Inoperative'],
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20DeviceInfoVariableName.AvailabilityState as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.ChargingStation as string,
+ OCPP20OptionalVariableName.WebSocketPingInterval
+ )]: {
+ allowZero: true,
+ component: OCPP20ComponentName.ChargingStation as string,
+ dataType: DataEnumType.integer,
+ defaultValue: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL.toString(),
+ description:
+ 'Interval in seconds between WebSocket ping (keep-alive) frames. 0 disables pings.',
+ max: 3600,
+ maxLength: 10,
+ min: 0,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'seconds',
+ variable: OCPP20OptionalVariableName.WebSocketPingInterval as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.ChargingStation as string,
+ OCPP20VendorVariableName.ConnectionUrl
+ )]: {
+ component: OCPP20ComponentName.ChargingStation as string,
+ dataType: DataEnumType.string,
+ defaultValue: 'ws://localhost',
+ description: 'Central system connection URL.',
+ isUrl: true,
+ maxLength: 512,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20VendorVariableName.ConnectionUrl as string,
+ vendorSpecific: true,
+ },
+
+ // ClockCtrlr variables
+ [buildRegistryKey(OCPP20ComponentName.ClockCtrlr as string, OCPP20RequiredVariableName.DateTime)]:
+ {
+ component: OCPP20ComponentName.ClockCtrlr as string,
+ dataType: DataEnumType.dateTime,
+ description: 'Contains the current date and time (ClockCtrlr).',
+ dynamicValueResolver: () => new Date().toISOString(),
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Volatile,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.DateTime as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.ClockCtrlr as string,
+ OCPP20RequiredVariableName.TimeSource
+ )]: {
+ component: OCPP20ComponentName.ClockCtrlr as string,
+ dataType: DataEnumType.SequenceList,
+ defaultValue: 'NTP,GPS,RTC',
+ description: 'Ordered list of clock sources by preference.',
+ enumeration: ['NTP', 'GPS', 'RTC', 'Manual'],
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.TimeSource as string,
+ },
+
+ // DeviceDataCtrlr variables
+ [buildRegistryKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ OCPP20RequiredVariableName.BytesPerMessage
+ )]: {
+ component: OCPP20ComponentName.DeviceDataCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '8192',
+ description: 'Maximum number of bytes in a message.',
+ max: 65535,
+ min: 1,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.BytesPerMessage as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ OCPP20RequiredVariableName.BytesPerMessage,
+ 'GetReport'
+ )]: {
+ component: OCPP20ComponentName.DeviceDataCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '8192',
+ description: 'Maximum number of bytes in a GetReport message.',
+ instance: 'GetReport',
+ max: 65535,
+ min: 1,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.BytesPerMessage as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ OCPP20RequiredVariableName.BytesPerMessage,
+ 'GetVariables'
+ )]: {
+ component: OCPP20ComponentName.DeviceDataCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '8192',
+ description: 'Maximum number of bytes in a GetVariables message.',
+ instance: 'GetVariables',
+ max: 65535,
+ min: 1,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.BytesPerMessage as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ OCPP20RequiredVariableName.BytesPerMessage,
+ 'SetVariables'
+ )]: {
+ component: OCPP20ComponentName.DeviceDataCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '8192',
+ description: 'Maximum number of bytes in a SetVariables message.',
+ instance: 'SetVariables',
+ max: 65535,
+ min: 1,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.BytesPerMessage as string,
+ },
+ // Value size family: ValueSize (broadest), ConfigurationValueSize (affects setting), ReportingValueSize (affects reporting). Simulator sets same absolute cap; truncate occurs at reporting step.
+ [buildRegistryKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ OCPP20RequiredVariableName.ConfigurationValueSize
+ )]: {
+ component: OCPP20ComponentName.DeviceDataCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH.toString(),
+ description: 'Maximum size allowed for configuration values when setting.',
+ max: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH,
+ maxLength: 5,
+ min: 1,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'chars',
+ variable: OCPP20RequiredVariableName.ConfigurationValueSize as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ OCPP20RequiredVariableName.ItemsPerMessage,
+ 'GetReport'
+ )]: {
+ component: OCPP20ComponentName.DeviceDataCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '32',
+ description: 'Maximum ComponentVariable entries in a GetReport message.',
+ instance: 'GetReport',
+ max: 256,
+ min: 1,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.ItemsPerMessage as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ OCPP20RequiredVariableName.ItemsPerMessage,
+ 'GetVariables'
+ )]: {
+ component: OCPP20ComponentName.DeviceDataCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '32',
+ description: 'Maximum ComponentVariable entries in a GetVariables message.',
+ instance: 'GetVariables',
+ max: 256,
+ min: 1,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.ItemsPerMessage as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ OCPP20RequiredVariableName.ItemsPerMessage,
+ 'SetVariables'
+ )]: {
+ component: OCPP20ComponentName.DeviceDataCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '32',
+ description: 'Maximum ComponentVariable entries in a SetVariables message.',
+ instance: 'SetVariables',
+ max: 256,
+ min: 1,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.ItemsPerMessage as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ OCPP20RequiredVariableName.ReportingValueSize
+ )]: {
+ component: OCPP20ComponentName.DeviceDataCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH.toString(),
+ description: 'Maximum size of reported values.',
+ max: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH,
+ maxLength: 5,
+ min: 1,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'chars',
+ variable: OCPP20RequiredVariableName.ReportingValueSize as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.DeviceDataCtrlr as string,
+ OCPP20RequiredVariableName.ValueSize
+ )]: {
+ component: OCPP20ComponentName.DeviceDataCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH.toString(),
+ description: 'Unified maximum size for any stored or reported value.',
+ max: Constants.OCPP_VALUE_ABSOLUTE_MAX_LENGTH,
+ maxLength: 5,
+ min: 1,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'chars',
+ variable: OCPP20RequiredVariableName.ValueSize as string,
+ },
+
+ // OCPPCommCtrlr variables
+ [buildRegistryKey(
+ OCPP20ComponentName.OCPPCommCtrlr as string,
+ OCPP20OptionalVariableName.HeartbeatInterval
+ )]: {
+ component: OCPP20ComponentName.OCPPCommCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL).toString(),
+ description: 'Interval between Heartbeat messages.',
+ max: 86400,
+ maxLength: 10,
+ min: 1,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'seconds',
+ variable: OCPP20OptionalVariableName.HeartbeatInterval as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.OCPPCommCtrlr as string,
+ OCPP20RequiredVariableName.FileTransferProtocols
+ )]: {
+ component: OCPP20ComponentName.OCPPCommCtrlr as string,
+ dataType: DataEnumType.MemberList,
+ defaultValue: 'HTTP',
+ description: 'Supported file transfer protocols.',
+ enumeration: ['HTTP', 'HTTPS', 'FTP', 'SFTP'],
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.FileTransferProtocols as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.OCPPCommCtrlr as string,
+ OCPP20RequiredVariableName.MessageAttemptInterval,
+ 'TransactionEvent'
+ )]: {
+ component: OCPP20ComponentName.OCPPCommCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '5',
+ description: 'Interval (seconds) between retry attempts for TransactionEvent messages.',
+ instance: 'TransactionEvent',
+ max: 3600,
+ min: 1,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'seconds',
+ variable: OCPP20RequiredVariableName.MessageAttemptInterval as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.OCPPCommCtrlr as string,
+ OCPP20RequiredVariableName.MessageAttempts,
+ 'TransactionEvent'
+ )]: {
+ component: OCPP20ComponentName.OCPPCommCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '3',
+ description: 'Maximum number of TransactionEvent message attempts after initial send.',
+ instance: 'TransactionEvent',
+ max: 10,
+ min: 1,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.MessageAttempts as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.OCPPCommCtrlr as string,
+ OCPP20RequiredVariableName.MessageTimeout,
+ 'Default'
+ )]: {
+ component: OCPP20ComponentName.OCPPCommCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: Constants.DEFAULT_CONNECTION_TIMEOUT.toString(),
+ description: 'Timeout (in seconds) waiting for responses to general OCPP messages.',
+ instance: 'Default',
+ max: 3600,
+ min: 1,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'seconds',
+ variable: OCPP20RequiredVariableName.MessageTimeout as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.OCPPCommCtrlr as string,
+ OCPP20RequiredVariableName.NetworkConfigurationPriority
+ )]: {
+ component: OCPP20ComponentName.OCPPCommCtrlr as string,
+ dataType: DataEnumType.SequenceList,
+ defaultValue: '',
+ description: 'Comma separated ordered list of network profile priorities.',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.NetworkConfigurationPriority as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.OCPPCommCtrlr as string,
+ OCPP20RequiredVariableName.NetworkProfileConnectionAttempts
+ )]: {
+ component: OCPP20ComponentName.OCPPCommCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '3',
+ description: 'Connection attempts before switching profile.',
+ max: 100,
+ min: 1,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.NetworkProfileConnectionAttempts as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.OCPPCommCtrlr as string,
+ OCPP20RequiredVariableName.OfflineThreshold
+ )]: {
+ component: OCPP20ComponentName.OCPPCommCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '300',
+ description: 'Offline duration threshold for status refresh.',
+ max: 86400,
+ min: 1,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'seconds',
+ variable: OCPP20RequiredVariableName.OfflineThreshold as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.OCPPCommCtrlr as string,
+ OCPP20RequiredVariableName.ResetRetries
+ )]: {
+ allowZero: true,
+ component: OCPP20ComponentName.OCPPCommCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '2',
+ description: 'Number of times to retry a reset.',
+ max: 10,
+ min: 0,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.ResetRetries as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.OCPPCommCtrlr as string,
+ OCPP20RequiredVariableName.UnlockOnEVSideDisconnect
+ )]: {
+ component: OCPP20ComponentName.OCPPCommCtrlr as string,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'true',
+ description: 'Unlock cable when unplugged at EV side.',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.UnlockOnEVSideDisconnect as string,
+ },
+
+ // SampledDataCtrlr variables (simulation measurands)
+ [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr as string, 'Current.Import')]: {
+ component: OCPP20ComponentName.SampledDataCtrlr as string,
+ dataType: DataEnumType.decimal,
+ description: 'Instantaneous import current (A).',
+ dynamicValueResolver: () => '0',
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Volatile,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'A',
+ variable: 'Current.Import',
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.SampledDataCtrlr as string,
+ 'Energy.Active.Import.Register'
+ )]: {
+ component: OCPP20ComponentName.SampledDataCtrlr as string,
+ dataType: DataEnumType.decimal,
+ description: 'Cumulative active energy imported (Wh).',
+ dynamicValueResolver: () => '0',
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Volatile,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'Wh',
+ variable: 'Energy.Active.Import.Register',
+ },
+ [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr as string, 'Power.Active.Import')]: {
+ component: OCPP20ComponentName.SampledDataCtrlr as string,
+ dataType: DataEnumType.decimal,
+ description: 'Instantaneous active power import (W).',
+ dynamicValueResolver: () => '0',
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Volatile,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'W',
+ variable: 'Power.Active.Import',
+ },
+ [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr as string, 'Voltage')]: {
+ component: OCPP20ComponentName.SampledDataCtrlr as string,
+ dataType: DataEnumType.decimal,
+ description: 'RMS voltage (V).',
+ dynamicValueResolver: () => '230',
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Volatile,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'V',
+ variable: 'Voltage',
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.SampledDataCtrlr as string,
+ OCPP20RequiredVariableName.TxEndedMeasurands
+ )]: {
+ component: OCPP20ComponentName.SampledDataCtrlr as string,
+ dataType: DataEnumType.MemberList,
+ defaultValue: 'Energy.Active.Import.Register,Current.Import',
+ description: 'Measurands sampled at transaction end.',
+ enumeration: [
+ 'Energy.Active.Import.Register',
+ 'Energy.Active.Import.Interval',
+ 'Energy.Active.Export.Register',
+ 'Power.Active.Import',
+ 'Power.Active.Export',
+ 'Power.Reactive.Import',
+ 'Power.Reactive.Export',
+ 'Power.Offered',
+ 'Current.Import',
+ 'Current.Export',
+ 'Voltage',
+ 'Frequency',
+ 'Temperature',
+ 'SoC',
+ 'RPM',
+ 'Power.Factor',
+ ],
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.TxEndedMeasurands as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.SampledDataCtrlr as string,
+ OCPP20RequiredVariableName.TxStartedMeasurands
+ )]: {
+ component: OCPP20ComponentName.SampledDataCtrlr as string,
+ dataType: DataEnumType.MemberList,
+ defaultValue: 'Energy.Active.Import.Register,Power.Active.Import',
+ description: 'Measurands sampled at transaction start.',
+ enumeration: [
+ 'Energy.Active.Import.Register',
+ 'Energy.Active.Export.Register',
+ 'Power.Active.Import',
+ 'Power.Active.Export',
+ 'Current.Import',
+ 'Voltage',
+ 'SoC',
+ ],
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.TxStartedMeasurands as string,
+ },
+ // Volatile rationale: sampling interval affects runtime only; simulator does not persist across restarts.
+ [buildRegistryKey(
+ OCPP20ComponentName.SampledDataCtrlr as string,
+ OCPP20RequiredVariableName.TxUpdatedInterval
+ )]: {
+ component: OCPP20ComponentName.SampledDataCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: Constants.DEFAULT_TX_UPDATED_INTERVAL.toString(),
+ description:
+ 'Interval between sampling of metering data for Updated TransactionEvent messages.',
+ max: 3600,
+ min: 1,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Volatile,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'seconds',
+ variable: OCPP20RequiredVariableName.TxUpdatedInterval as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.SampledDataCtrlr as string,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ )]: {
+ component: OCPP20ComponentName.SampledDataCtrlr as string,
+ dataType: DataEnumType.MemberList,
+ defaultValue: 'Energy.Active.Import.Register',
+ description: 'Measurands included in periodic updates.',
+ enumeration: [
+ 'Energy.Active.Import.Register',
+ 'Energy.Active.Export.Register',
+ 'Power.Active.Import',
+ 'Power.Active.Export',
+ 'Current.Import',
+ 'Voltage',
+ 'SoC',
+ ],
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.TxUpdatedMeasurands as string,
+ },
+
+ // SecurityCtrlr variables
+ [buildRegistryKey(
+ OCPP20ComponentName.SecurityCtrlr as string,
+ OCPP20RequiredVariableName.CertificateEntries
+ )]: {
+ allowZero: true,
+ component: OCPP20ComponentName.SecurityCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '0',
+ description: 'Count of installed certificates.',
+ min: 0,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.CertificateEntries as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.SecurityCtrlr as string,
+ OCPP20RequiredVariableName.OrganizationName
+ )]: {
+ component: OCPP20ComponentName.SecurityCtrlr as string,
+ dataType: DataEnumType.string,
+ defaultValue: 'ChangeMeOrg',
+ description: 'Organization name for client certificate subject.',
+ maxLength: 128,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ rebootRequired: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.OrganizationName as string,
+ },
+ // Enumeration limited to profiles 1..3 commonly used; spec allows additional profiles via extensions.
+ [buildRegistryKey(
+ OCPP20ComponentName.SecurityCtrlr as string,
+ OCPP20RequiredVariableName.SecurityProfile
+ )]: {
+ component: OCPP20ComponentName.SecurityCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: '1',
+ description: 'Selected security profile.',
+ enumeration: ['1', '2', '3'],
+ max: 3,
+ maxLength: 1,
+ min: 1,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ rebootRequired: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.SecurityProfile as string,
+ },
+ // Vendor-specific write-only placeholder to exercise WriteOnly path.
+ [buildRegistryKey(
+ OCPP20ComponentName.SecurityCtrlr as string,
+ OCPP20VendorVariableName.CertificatePrivateKey
+ )]: {
+ component: OCPP20ComponentName.SecurityCtrlr as string,
+ dataType: DataEnumType.string,
+ description: 'Private key material upload placeholder; write-only for security.',
+ maxLength: 2048,
+ mutability: MutabilityEnumType.WriteOnly,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20VendorVariableName.CertificatePrivateKey as string,
+ vendorSpecific: true,
+ },
+
+ // TxCtrlr variables
+ [buildRegistryKey(
+ OCPP20ComponentName.TxCtrlr as string,
+ OCPP20RequiredVariableName.EVConnectionTimeOut
+ )]: {
+ component: OCPP20ComponentName.TxCtrlr as string,
+ dataType: DataEnumType.integer,
+ defaultValue: Constants.DEFAULT_EV_CONNECTION_TIMEOUT.toString(),
+ description: 'Timeout for EV to establish connection.',
+ max: 3600,
+ maxLength: 10,
+ min: 1,
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ positive: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ unit: 'seconds',
+ variable: OCPP20RequiredVariableName.EVConnectionTimeOut as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.TxCtrlr as string,
+ OCPP20RequiredVariableName.StopTxOnEVSideDisconnect
+ )]: {
+ component: OCPP20ComponentName.TxCtrlr as string,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'true',
+ description: 'Deauthorize transaction when cable unplugged at EV.',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.StopTxOnEVSideDisconnect as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.TxCtrlr as string,
+ OCPP20RequiredVariableName.StopTxOnInvalidId
+ )]: {
+ component: OCPP20ComponentName.TxCtrlr as string,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'true',
+ description: 'Deauthorize transaction on invalid id token status.',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.StopTxOnInvalidId as string,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.TxCtrlr as string,
+ OCPP20RequiredVariableName.TxStartPoint
+ )]: {
+ component: OCPP20ComponentName.TxCtrlr as string,
+ dataType: DataEnumType.MemberList,
+ defaultValue: 'CablePluggedIn,EnergyTransfer',
+ description: 'Trigger conditions for starting a transaction.',
+ enumeration: ['CablePluggedIn', 'EnergyTransfer', 'Authorized', 'PowerPathClosed'],
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.TxStartPoint as string,
+ },
+ [buildRegistryKey(OCPP20ComponentName.TxCtrlr as string, OCPP20RequiredVariableName.TxStopPoint)]:
+ {
+ component: OCPP20ComponentName.TxCtrlr as string,
+ dataType: DataEnumType.MemberList,
+ defaultValue: 'EVSEIdle,CableUnplugged',
+ description: 'Trigger conditions for ending a transaction.',
+ enumeration: ['EVSEIdle', 'CableUnplugged', 'Deauthorized', 'PowerPathOpened'],
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.TxStopPoint as string,
+ },
+}
+
+/**
+ * Build composite lookup key (lower-cased) including optional instance.
+ * Format: `component[.instance].variable` all lower case.
+ * @param component Component name.
+ * @param instance Optional instance qualifier.
+ * @param variable Variable name.
+ * @returns Lower-case composite key for lookup.
+ */
+export function buildCaseInsensitiveCompositeKey (
+ component: string,
+ instance: string | undefined,
+ variable: string
+): string {
+ return `${component.toLowerCase()}${instance ? '.' + instance : ''}.${variable.toLowerCase()}`
+}
+
+// Lowercase fallback registry (composite key) for case-insensitive lookups.
+const VARIABLE_REGISTRY_LOOKUP_CI: Record<string, VariableMetadata> = Object.values(
+ VARIABLE_REGISTRY
+).reduce<Record<string, VariableMetadata>>((acc, vm) => {
+ acc[buildCaseInsensitiveCompositeKey(vm.component, vm.instance, vm.variable)] = vm
+ return acc
+}, {})
+
+/**
+ * Apply optional metadata post-processing to a resolved variable value.
+ * @param chargingStation Charging station context.
+ * @param variableMetadata Variable metadata entry.
+ * @param value Resolved raw value.
+ * @returns Post-processed value (or original when no postProcess defined).
+ */
+export function applyPostProcess (
+ chargingStation: ChargingStation,
+ variableMetadata: VariableMetadata,
+ value: string
+): string {
+ if (variableMetadata.postProcess) {
+ return variableMetadata.postProcess(value, { chargingStation })
+ }
+ return value
+}
+
+/**
+ * Enforce reporting/value size limit on a string.
+ * @param value Incoming value string.
+ * @param sizeLimitRaw Raw size limit value (string form).
+ * @returns Possibly truncated value respecting size limit.
+ */
+export function enforceReportingValueSize (value: string, sizeLimitRaw: string): string {
+ const sizeLimit = convertToIntOrNaN(sizeLimitRaw)
+ if (!Number.isNaN(sizeLimit) && sizeLimit > 0 && value.length > sizeLimit) {
+ return value.slice(0, sizeLimit)
+ }
+ return value
+}
+
+/**
+ * Retrieve variable metadata with case-insensitive fallback.
+ * @param component Component name.
+ * @param variable Variable name.
+ * @param instance Optional instance qualifier.
+ * @returns Matching variable metadata or undefined.
+ */
+export function getVariableMetadata (
+ component: string,
+ variable: string,
+ instance?: string
+): undefined | VariableMetadata {
+ const withInstanceKey = buildRegistryKey(component, variable, instance)
+ if (has(withInstanceKey, VARIABLE_REGISTRY)) {
+ return VARIABLE_REGISTRY[withInstanceKey]
+ }
+ const withoutInstanceKey = buildRegistryKey(component, variable)
+ if (has(withoutInstanceKey, VARIABLE_REGISTRY)) {
+ return VARIABLE_REGISTRY[withoutInstanceKey]
+ }
+ const lcWithKey = buildCaseInsensitiveCompositeKey(component, instance, variable)
+ if (has(lcWithKey, VARIABLE_REGISTRY_LOOKUP_CI)) {
+ return VARIABLE_REGISTRY_LOOKUP_CI[lcWithKey]
+ }
+ return VARIABLE_REGISTRY_LOOKUP_CI[
+ buildCaseInsensitiveCompositeKey(component, undefined, variable)
+ ] as undefined | VariableMetadata
+}
+
+/**
+ * Check if variable metadata is persistent.
+ * @param variableMetadata Variable metadata entry.
+ * @returns True when persistence is Persistent.
+ */
+export function isPersistent (variableMetadata: VariableMetadata): boolean {
+ return variableMetadata.persistence === PersistenceEnumType.Persistent
+}
+
+/**
+ * Check if variable metadata is read-only.
+ * @param variableMetadata Variable metadata entry.
+ * @returns True when mutability is ReadOnly.
+ */
+export function isReadOnly (variableMetadata: VariableMetadata): boolean {
+ return variableMetadata.mutability === MutabilityEnumType.ReadOnly
+}
+
+/**
+ * Check if variable metadata is write-only.
+ * @param variableMetadata Variable metadata entry.
+ * @returns True when mutability is WriteOnly.
+ */
+export function isWriteOnly (variableMetadata: VariableMetadata): boolean {
+ return variableMetadata.mutability === MutabilityEnumType.WriteOnly
+}
+
+/**
+ * Resolve variable value using dynamicValueResolver if present else defaultValue.
+ * @param chargingStation Charging station context.
+ * @param variableMetadata Variable metadata entry.
+ * @returns Resolved value string (empty when no default).
+ */
+export function resolveValue (
+ chargingStation: ChargingStation,
+ variableMetadata: VariableMetadata
+): string {
+ if (variableMetadata.dynamicValueResolver) {
+ return variableMetadata.dynamicValueResolver({ chargingStation })
+ }
+ return variableMetadata.defaultValue ?? ''
+}
+
+/**
+ * Validate raw value against variable metadata constraints.
+ * Performs length, datatype specific and enumeration checks.
+ * @param variableMetadata Variable metadata entry.
+ * @param rawValue Raw value string to validate.
+ * @returns Validation result with ok flag and optional reason/info.
+ */
+export function validateValue (
+ variableMetadata: VariableMetadata,
+ rawValue: string
+): { info?: string; ok: boolean; reason?: ReasonCodeEnumType } {
+ if (variableMetadata.maxLength != null && rawValue.length > variableMetadata.maxLength) {
+ return {
+ info: 'Value exceeds maximum length (' + String(variableMetadata.maxLength) + ')',
+ ok: false,
+ reason: ReasonCodeEnumType.InvalidValue,
+ }
+ }
+ switch (variableMetadata.dataType) {
+ case DataEnumType.boolean: {
+ if (rawValue !== 'true' && rawValue !== 'false') {
+ return {
+ info: 'Boolean must be "true" or "false"',
+ ok: false,
+ reason: ReasonCodeEnumType.InvalidValue,
+ }
+ }
+ break
+ }
+ case DataEnumType.dateTime: {
+ if (isNaN(Date.parse(rawValue))) {
+ return {
+ info: 'Invalid dateTime format',
+ ok: false,
+ reason: ReasonCodeEnumType.InvalidValue,
+ }
+ }
+ break
+ }
+ case DataEnumType.decimal: {
+ if (!DECIMAL_PATTERN.test(rawValue)) {
+ return {
+ info: 'Invalid decimal format',
+ ok: false,
+ reason: ReasonCodeEnumType.InvalidValue,
+ }
+ }
+ const num = Number(rawValue)
+ if (variableMetadata.positive && num <= 0) {
+ return {
+ info: 'Positive decimal > 0 required',
+ ok: false,
+ reason: ReasonCodeEnumType.ValuePositiveOnly,
+ }
+ }
+ if (!variableMetadata.positive && !variableMetadata.allowZero && num === 0) {
+ return {
+ info: 'Zero value not allowed',
+ ok: false,
+ reason: ReasonCodeEnumType.ValueZeroNotAllowed,
+ }
+ }
+ if (variableMetadata.min != null && num < variableMetadata.min) {
+ return {
+ info: 'Decimal value below minimum (' + String(variableMetadata.min) + ')',
+ ok: false,
+ reason: ReasonCodeEnumType.ValueTooLow,
+ }
+ }
+ if (variableMetadata.max != null && num > variableMetadata.max) {
+ return {
+ info: 'Decimal value above maximum (' + String(variableMetadata.max) + ')',
+ ok: false,
+ reason: ReasonCodeEnumType.ValueTooHigh,
+ }
+ }
+ break
+ }
+ case DataEnumType.integer: {
+ if (variableMetadata.allowZero && !variableMetadata.positive) {
+ if (DECIMAL_ONLY_PATTERN.test(rawValue)) {
+ return {
+ info: 'Integer >= 0 required',
+ ok: false,
+ reason: ReasonCodeEnumType.ValueZeroNotAllowed,
+ }
+ }
+ }
+ if (!SIGNED_INTEGER_PATTERN.test(rawValue)) {
+ if (DECIMAL_ONLY_PATTERN.test(rawValue)) {
+ return {
+ info: variableMetadata.positive
+ ? 'Positive integer > 0 required (no decimals)'
+ : 'Integer must not be decimal',
+ ok: false,
+ reason:
+ variableMetadata.allowZero && !variableMetadata.positive
+ ? ReasonCodeEnumType.ValueZeroNotAllowed
+ : ReasonCodeEnumType.InvalidValue,
+ }
+ }
+ return {
+ info: 'Non-empty digits only string required',
+ ok: false,
+ reason: ReasonCodeEnumType.InvalidValue,
+ }
+ }
+ const num = Number(rawValue)
+ if (variableMetadata.allowZero && !variableMetadata.positive && num < 0) {
+ return {
+ info: 'Integer >= 0 required',
+ ok: false,
+ reason: ReasonCodeEnumType.ValueZeroNotAllowed,
+ }
+ }
+ if (variableMetadata.positive && num <= 0) {
+ return {
+ info: 'Positive integer > 0 required',
+ ok: false,
+ reason: ReasonCodeEnumType.ValuePositiveOnly,
+ }
+ }
+ if (variableMetadata.min != null && num < variableMetadata.min) {
+ return {
+ info: 'Integer value below minimum (' + String(variableMetadata.min) + ')',
+ ok: false,
+ reason: ReasonCodeEnumType.ValueTooLow,
+ }
+ }
+ if (variableMetadata.max != null && num > variableMetadata.max) {
+ return {
+ info: 'Integer value above maximum (' + String(variableMetadata.max) + ')',
+ ok: false,
+ reason: ReasonCodeEnumType.ValueTooHigh,
+ }
+ }
+ if (!variableMetadata.positive && !variableMetadata.allowZero && num === 0) {
+ return {
+ info: 'Zero value not allowed',
+ ok: false,
+ reason: ReasonCodeEnumType.ValueZeroNotAllowed,
+ }
+ }
+ break
+ }
+ case DataEnumType.MemberList:
+ case DataEnumType.SequenceList: {
+ if (rawValue.trim().length === 0) {
+ return { info: 'List cannot be empty', ok: false, reason: ReasonCodeEnumType.InvalidValue }
+ }
+ if (rawValue.startsWith(',') || rawValue.endsWith(',')) {
+ return {
+ info: 'No leading/trailing comma',
+ ok: false,
+ reason: ReasonCodeEnumType.InvalidValue,
+ }
+ }
+ const tokens = rawValue.split(',').map(t => t.trim())
+ if (tokens.some(t => t.length === 0)) {
+ return { info: 'Empty list member', ok: false, reason: ReasonCodeEnumType.InvalidValue }
+ }
+ const seen = new Set<string>()
+ for (const t of tokens) {
+ if (seen.has(t)) {
+ return {
+ info: 'Duplicate list member',
+ ok: false,
+ reason: ReasonCodeEnumType.InvalidValue,
+ }
+ }
+ seen.add(t)
+ }
+ if (variableMetadata.enumeration?.length) {
+ for (const t of tokens) {
+ if (!variableMetadata.enumeration.includes(t)) {
+ return {
+ info: 'Member not in enumeration',
+ ok: false,
+ reason: ReasonCodeEnumType.InvalidValue,
+ }
+ }
+ }
+ }
+ break
+ }
+ case DataEnumType.string: {
+ if (variableMetadata.urlSchemes?.length) {
+ const schemeValidation = validateUrlScheme(rawValue, variableMetadata.urlSchemes)
+ if (!schemeValidation.ok) {
+ return schemeValidation
+ }
+ } else if (variableMetadata.isUrl) {
+ const generic = validateGenericUrl(rawValue)
+ if (!generic.ok) {
+ return generic
+ }
+ }
+ break
+ }
+ default:
+ break
+ }
+ // Centralized enumeration membership for scalar (non-list) types including string.
+ if (
+ variableMetadata.enumeration?.length &&
+ variableMetadata.dataType !== DataEnumType.MemberList &&
+ variableMetadata.dataType !== DataEnumType.SequenceList
+ ) {
+ if (!variableMetadata.enumeration.includes(rawValue)) {
+ return {
+ info: 'Value not in enumeration',
+ ok: false,
+ reason: ReasonCodeEnumType.InvalidValue,
+ }
+ }
+ }
+ return { ok: true }
+}
+
+/**
+ * Validate URL using generic parsing (any scheme accepted).
+ * @param value Raw URL string.
+ * @returns Validation result with ok flag and optional reason/info.
+ */
+function validateGenericUrl (value: string): {
+ info?: string
+ ok: boolean
+ reason?: ReasonCodeEnumType
+} {
+ if (!URL.canParse(value)) {
+ return { info: 'Invalid URL format', ok: false, reason: ReasonCodeEnumType.InvalidURL }
+ }
+ return { ok: true }
+}
+
+/**
+ * Validate URL scheme against an allowed list after generic format check.
+ * @param value Raw URL string.
+ * @param allowedSchemes Allowed protocol schemes (with trailing colon).
+ * @returns Validation result with ok flag and optional reason/info.
+ */
+function validateUrlScheme (
+ value: string,
+ allowedSchemes: string[]
+): { info?: string; ok: boolean; reason?: ReasonCodeEnumType } {
+ const generic = validateGenericUrl(value)
+ if (!generic.ok) {
+ return generic
+ }
+ const url = new URL(value)
+ if (!allowedSchemes.includes(url.protocol)) {
+ return { info: 'Unsupported URL scheme', ok: false, reason: ReasonCodeEnumType.InvalidURL }
+ }
+ return { ok: true }
+}
commandPayload: ReqType
): Promise<void>
+ public abstract stop (chargingStation: ChargingStation): void
+
protected handleRequestClearCache (chargingStation: ChargingStation): ClearCacheResponse {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (chargingStation.idTagsCache.deleteIdTags(getIdTagsFile(chargingStation.stationInfo!)!)) {
-import type { JsonObject } from './JsonType.js'
import type { OCPPConfigurationKey } from './ocpp/Configuration.js'
-export interface ChargingStationOcppConfiguration extends JsonObject {
+export interface ChargingStationOcppConfiguration {
configurationKey?: ConfigurationKey[]
}
export interface ChargingStationWorkerMessage<T extends ChargingStationWorkerMessageData> {
data: T
event: ChargingStationWorkerMessageEvents
+ uuid?: string
}
export type ChargingStationWorkerMessageData = ChargingStationData | Statistics
type OCPP20NotifyReportRequest,
OCPP20RequestCommand,
type OCPP20ResetRequest,
+ type OCPP20SetVariablesRequest,
type OCPP20StatusNotificationRequest,
} from './ocpp/2.0/Requests.js'
export type {
OCPP20HeartbeatResponse,
OCPP20NotifyReportResponse,
OCPP20ResetResponse,
+ OCPP20SetVariablesResponse,
OCPP20StatusNotificationResponse,
} from './ocpp/2.0/Responses.js'
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'
REQUEST_START_TRANSACTION = 'RequestStartTransaction',
REQUEST_STOP_TRANSACTION = 'RequestStopTransaction',
RESET = 'Reset',
+ SET_VARIABLES = 'SetVariables',
}
export enum OCPP20RequestCommand {
AuthorizeRemoteStart = 'AuthorizeRemoteStart',
BytesPerMessage = 'BytesPerMessage',
CertificateEntries = 'CertificateEntries',
+ ConfigurationValueSize = 'ConfigurationValueSize',
DateTime = 'DateTime',
EVConnectionTimeOut = 'EVConnectionTimeOut',
FileTransferProtocols = 'FileTransferProtocols',
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',
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',
private static buildUIServerSection (): UIServerConfiguration {
let uiServerConfiguration: UIServerConfiguration = defaultUIServerConfiguration
if (has(ConfigurationSection.uiServer, Configuration.getConfigurationData())) {
- uiServerConfiguration = mergeDeepRight(
+ uiServerConfiguration = mergeDeepRight<UIServerConfiguration, Partial<UIServerConfiguration>>(
uiServerConfiguration,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Configuration.getConfigurationData()!.uiServer!
useConnectorId0: true,
})
+ static readonly DEFAULT_TX_UPDATED_INTERVAL = 30 // Seconds
+
static readonly DEFAULT_UI_SERVER_HOST = 'localhost'
static readonly DEFAULT_UI_SERVER_PORT = 8080
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
export const buildPerformanceStatisticsMessage = (
statistics: Statistics
): ChargingStationWorkerMessage<Statistics> => {
- const statisticsData = [...statistics.statisticsData].map(([key, value]) => {
- if (value.measurementTimeSeries instanceof CircularBuffer) {
- value.measurementTimeSeries = value.measurementTimeSeries.toArray() as TimestampedData[]
- }
- return [key, value]
- })
+ const statisticsData = new Map(
+ [...statistics.statisticsData].map(([key, value]) => {
+ if (value.measurementTimeSeries instanceof CircularBuffer) {
+ value.measurementTimeSeries = value.measurementTimeSeries.toArray() as TimestampedData[]
+ }
+ return [key, value]
+ })
+ )
return {
data: {
createdAt: statistics.createdAt,
} as T
}
-export const has = (property: PropertyKey, object: null | object | undefined): boolean => {
- if (object == null) {
+export const has = (property: PropertyKey, object: unknown): boolean => {
+ if (object == null || (typeof object !== 'object' && typeof object !== 'function')) {
return false
}
- return Object.hasOwn(object, property)
+ return Object.hasOwn(object as Record<PropertyKey, unknown>, property)
}
const type = (value: unknown): string => {
return type(value) === 'Object'
}
-export const mergeDeepRight = <T extends Record<string, unknown>>(
- target: T,
- source: Partial<T>
-): T => {
- const output = { ...target }
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
+export const mergeDeepRight = <T extends object, S extends object>(target: T, source: S): T => {
+ const output: Record<string, unknown> = { ...(target as Record<string, unknown>) }
if (isObject(target) && isObject(source)) {
- Object.keys(source).forEach(key => {
- if (isObject(source[key])) {
- if (!(key in target)) {
- Object.assign(output, { [key]: source[key] })
- } else {
- output[key] = mergeDeepRight(target[key], source[key])
- }
+ Object.keys(source as Record<string, unknown>).forEach(key => {
+ const sourceValue = (source as Record<string, unknown>)[key]
+ const targetValue = (target as Record<string, unknown>)[key]
+ if (isObject(sourceValue) && isObject(targetValue)) {
+ output[key] = mergeDeepRight(
+ targetValue as Record<string, unknown>,
+ sourceValue as Record<string, unknown>
+ )
} else {
- Object.assign(output, { [key]: source[key] })
+ output[key] = sourceValue
}
})
}
- return output
+ return output as T
}
export const generateUUID = (): `${string}-${string}-${string}-${string}-${string}` => {
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
convertToDate,
convertToFloat,
convertToInt,
+ convertToIntOrNaN,
exponentialDelay,
extractTimeSeriesValues,
formatDurationMilliSeconds,
if (!isMainThread) {
throw new Error('Cannot get a worker implementation outside the main thread')
}
- workerOptions = mergeDeepRight<WorkerOptions>(DEFAULT_WORKER_OPTIONS, workerOptions ?? {})
+ workerOptions = mergeDeepRight<WorkerOptions, WorkerOptions>(
+ DEFAULT_WORKER_OPTIONS,
+ (workerOptions ?? {}) as WorkerOptions
+ )
switch (workerProcessType) {
case WorkerProcessType.dynamicPool:
return new WorkerDynamicPool<D, R>(workerScript, workerOptions)
+import { millisecondsToSeconds } from 'date-fns'
+
import type { ChargingStation } from '../src/charging-station/index.js'
import type {
ChargingStationConfiguration,
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: {
--- /dev/null
+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()
+ })
+ })
+})
})
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()', () => {
})
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()
})
})
const incomingRequestService = new OCPP20IncomingRequestService()
+ // FR: C11.FR.01
await it('Should handle ClearCache request successfully', async () => {
const response = await (incomingRequestService as any).handleRequestClearCache(
mockChargingStation
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
/* 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 {
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,
const incomingRequestService = new OCPP20IncomingRequestService()
+ // FR: B08.FR.01
await it('Should handle GetBaseReport request with ConfigurationInventory', () => {
const request: OCPP20GetBaseReportRequest = {
reportBase: ReportBaseEnumType.ConfigurationInventory,
expect(response.status).toBe(GenericDeviceModelStatusEnumType.Accepted)
})
+ // FR: B08.FR.02
await it('Should handle GetBaseReport request with FullInventory', () => {
const request: OCPP20GetBaseReportRequest = {
reportBase: ReportBaseEnumType.FullInventory,
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,
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,
}
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({
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,
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
)
}
})
+ // 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
)
// 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
)
// 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({
},
})
- const reportData = (incomingRequestService as any).buildReportData(
+ const reportData: ReportDataType[] = (incomingRequestService as any).buildReportData(
stationWithEvses,
ReportBaseEnumType.FullInventory
)
// 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)
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-
import { expect } from '@std/expect'
import { millisecondsToSeconds } from 'date-fns'
import { describe, it } from 'node:test'
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,
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 },
},
{
],
}
- // 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(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()
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: [
{
},
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)
})
})
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,
}
]).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,
}
]).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,
]).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,
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,
}
}
})
- 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,
}
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
;(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,
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
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
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
;(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
--- /dev/null
+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)
+ })
+})
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,
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',
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',
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,
})
})
+ // FR: B01.FR.05
await it('Should validate payload structure matches OCPP20BootNotificationRequest interface', () => {
const chargingStationInfo: ChargingStationType = {
customData: {
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,
websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
})
+ // FR: G02.FR.01
await it('Should build HeartBeat request payload correctly with empty object', () => {
const requestParams: OCPP20HeartbeatRequest = {}
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(
expect(Object.keys(payload as object)).toHaveLength(0)
})
+ // FR: G02.FR.03
await it('Should validate payload structure matches OCPP20HeartbeatRequest interface', () => {
const requestParams: OCPP20HeartbeatRequest = {}
expect(JSON.stringify(payload)).toBe('{}')
})
+ // FR: G02.FR.04
await it('Should handle HeartBeat request consistently across multiple calls', () => {
const requestParams: OCPP20HeartbeatRequest = {}
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',
expect(JSON.stringify(payload)).toBe('{}')
})
+ // FR: G02.FR.06
await it('Should verify HeartBeat request conforms to OCPP 2.0 specification', () => {
const requestParams: OCPP20HeartbeatRequest = {}
// 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)
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)
type: AttributeEnumType.Actual,
value: '60',
},
- {
- type: AttributeEnumType.Target,
- value: '60',
- },
],
variableCharacteristics: {
dataType: DataEnumType.integer,
})
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[] = [
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,
)
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', () => {
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')
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')
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')
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')
})
})
+ // FR: G01.FR.05
await it('Should validate payload structure matches OCPP20StatusNotificationRequest interface', () => {
const testTimestamp = new Date('2024-01-15T14:30:15.123Z')
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')
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
--- /dev/null
+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
+}
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,
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
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 },
},
]
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', () => {
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 },
},
]
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', () => {
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')
await it('Should handle multiple variables in single request', () => {
const request: OCPP20GetVariableDataType[] = [
{
- component: { name: OCPP20ComponentName.ChargingStation },
+ component: { name: OCPP20ComponentName.OCPPCommCtrlr },
variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
},
{
},
{
attributeType: AttributeEnumType.Actual,
- component: { name: OCPP20ComponentName.ChargingStation },
+ component: { instance: 'Default', name: OCPP20ComponentName.OCPPCommCtrlr },
variable: { name: OCPP20RequiredVariableName.MessageTimeout },
},
]
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)
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: {
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
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', () => {
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)
})
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')
})
})
})
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-
import { expect } from '@std/expect'
import { describe, it } from 'node:test'
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 |', {})
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,
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(
{ 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)
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)
})
})
convertToDate,
convertToFloat,
convertToInt,
+ convertToIntOrNaN,
extractTimeSeriesValues,
formatDurationMilliSeconds,
formatDurationSeconds,
expect(insertAt('test', 'ing', 2)).toBe('teingst')
})
+ await it('Verify convertToIntOrNaN()', () => {
+ expect(convertToIntOrNaN(undefined)).toBe(0)
+ expect(convertToIntOrNaN(null)).toBe(0)
+ expect(convertToIntOrNaN('0')).toBe(0)
+ expect(convertToIntOrNaN('42')).toBe(42)
+ expect(convertToIntOrNaN('-7')).toBe(-7)
+ expect(convertToIntOrNaN('10.9')).toBe(10)
+ expect(Number.isNaN(convertToIntOrNaN('NaN'))).toBe(true)
+ expect(Number.isNaN(convertToIntOrNaN('abc'))).toBe(true)
+ })
+
await it('Verify isArraySorted()', () => {
expect(isArraySorted<number>([], (a, b) => a - b)).toBe(true)
expect(isArraySorted<number>([1], (a, b) => a - b)).toBe(true)
--- /dev/null
+// Local UI project constants
+
+export const UI_WEBSOCKET_REQUEST_TIMEOUT_MS = 60_000
type UIServerConfigurationSection,
} from '@/types'
+import { UI_WEBSOCKET_REQUEST_TIMEOUT_MS } from './Constants'
import { randomUUID, validateUUID } from './Utils'
interface ResponseHandler {
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 })
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)