ChargingStationWorkerMessageEvents.performanceStatistics,
this.workerEventPerformanceStatistics
)
- // eslint-disable-next-line @typescript-eslint/unbound-method
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- isAsyncFunction inspects the method's constructor tag only; no `this` binding is required
if (isAsyncFunction(this.workerImplementation?.start)) {
await this.workerImplementation.start()
} else {
}
private readonly workerEventPerformanceStatistics = (data: Statistics): void => {
- // eslint-disable-next-line @typescript-eslint/unbound-method
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- isAsyncFunction inspects the method's constructor tag only; no `this` binding is required
if (isAsyncFunction(this.storage?.storePerformanceStatistics)) {
;(
this.storage.storePerformanceStatistics as (
if (stationInfoPersistentConfiguration) {
stationInfo = this.getConfigurationFromFile()?.stationInfo
if (stationInfo != null) {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- pruning the deprecated `infoHash` field from the persisted-configuration snapshot before use
delete stationInfo.infoHash
delete (stationInfo as ChargingStationTemplate).numberOfConnectors
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
error
)
}
- // eslint-disable-next-line promise/no-promise-in-callback
+ // eslint-disable-next-line promise/no-promise-in-callback -- exponential-backoff sleep inside a WebSocket callback; failures on the outer send are surfaced separately
sleep(
computeExponentialBackOffDelay({
baseDelayMs: Constants.DEFAULT_EXPONENTIAL_BACKOFF_BASE_DELAY_MS,
if (key != null) {
template[key] = template[deprecatedKey]
}
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- deleting the deprecated key that was just remapped to its canonical destination
delete template[deprecatedKey]
}
}
import type { ChargingStationTemplate } from '../types/index.js'
import { BaseError } from '../exception/index.js'
-import { assertIsJsonObject, clone, isEmpty, isNotEmptyString, logger } from '../utils/index.js'
+import {
+ assertIsJsonObject,
+ clone,
+ type FieldError,
+ formatFieldErrorsSummary,
+ isEmpty,
+ isNotEmptyString,
+ logger,
+ mapZodIssuesToFieldErrors,
+} from '../utils/index.js'
import { getMaxConfiguredNumberOfConnectors } from './Helpers.js'
import { applyMigration, coerceVersion, CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js'
import { TemplateSchema } from './TemplateSchema.js'
* Includes structured field errors and migration context for diagnostics.
*/
export class TemplateValidationError extends BaseError {
- public readonly fieldErrors: { message: string; path: string }[]
+ public readonly fieldErrors: FieldError[]
public readonly filePath: string
public readonly migratedFrom?: number
public override readonly name = 'TemplateValidationError' as const
public constructor (zodError: ZodError, context: { filePath: string; migratedFrom?: number }) {
- const fieldErrors = zodError.issues.map(issue => ({
- message: issue.message,
- path: issue.path.join('.'),
- }))
- const fieldSummary = fieldErrors
- .map(e => ` - ${e.path !== '' ? e.path : '(root)'}: ${e.message}`)
- .join('\n')
+ const fieldErrors = mapZodIssuesToFieldErrors(zodError)
+ const fieldSummary = formatFieldErrorsSummary(fieldErrors)
const migrationNote =
context.migratedFrom != null
? ` (migrated from v${context.migratedFrom.toString()} → v${CURRENT_SCHEMA_VERSION.toString()})`
type CommandHandler = (
requestPayload?: BroadcastChannelRequestPayload
- // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- broadcast-channel command handlers may legitimately return no payload
) => CommandResponse | Promise<CommandResponse | void> | void
type CommandResponse =
private async commandHandler (
command: BroadcastChannelProcedureName,
requestPayload: BroadcastChannelRequestPayload
- // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- broadcast-channel command handlers may resolve with no payload
): Promise<CommandResponse | void> {
if (this.commandHandlers.has(command)) {
this.cleanRequestPayload(command, requestPayload)
return (
commandHandler as (
requestPayload?: BroadcastChannelRequestPayload
- // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- narrow cast onto the void-returning branch of the CommandHandler union
) => CommandResponse | void
)(requestPayload)
}
return
}
let responsePayload: BroadcastChannelResponsePayload | undefined
- // eslint-disable-next-line promise/catch-or-return
+ // eslint-disable-next-line promise/catch-or-return -- errors are handled inside .then via the response payload; the returned promise is intentionally discarded
this.commandHandler(command, requestPayload)
.then(commandResponse => {
if (commandResponse == null || isEmpty(commandResponse)) {
response = OCPP16Constants.OCPP_RESERVATION_RESPONSE_OCCUPIED
break
}
- // eslint-disable-next-line no-fallthrough
+ // eslint-disable-next-line no-fallthrough -- intentional fall-through when the connector-scoped reservability check passes: continue to the connector-agnostic check
default:
if (!chargingStation.isConnectorReservable(reservationId, idTag)) {
response = OCPP16Constants.OCPP_RESERVATION_RESPONSE_OCCUPIED
CertificateSigningUseEnumType.ChargingStationCertificate,
''
)
- // getCertificatePath returns basePath/ChargingStationCertificate/.pem
- // We need the directory: basePath/ChargingStationCertificate
+ // `getCertificatePath` returns `basePath/ChargingStationCertificate/.pem`;
+ // `dirPath` is its parent directory `basePath/ChargingStationCertificate`.
const dirPath = resolve(certFilePath, '..')
if (!(await this.pathExists(dirPath))) {
static readonly LOG_UPLOAD_STEP_DELAY_MS = 1000
- static readonly MAX_SECURITY_EVENT_SEND_ATTEMPTS = 3
+ /** OCPP 2.0.1 §2.1.20: `ConfigurationValueSize` `maxLimit`. Cap on `SetVariableDataType.attributeValue` `string[0..1000]`. */
+ static readonly MAX_CONFIGURATION_VALUE_SIZE = 1000
+
+ /** OCPP 2.0.1 §2.1.21: `ReportingValueSize` `maxLimit`. Cap on `GetVariableResult.attributeValue` `string[0..2500]`. */
+ static readonly MAX_REPORTING_VALUE_SIZE = 2500
- static readonly MAX_VARIABLE_VALUE_LENGTH = 2500
+ static readonly MAX_SECURITY_EVENT_SEND_ATTEMPTS = 3
static readonly OCPP_SEND_LOCAL_LIST_RESPONSE_ACCEPTED: OCPP20SendLocalListResponse =
Object.freeze({
variableValue = enforceReportingValueSize(variableValue, reportingValueSize)
}
- // Final absolute length enforcement (spec maxLength OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH)
- if (variableValue.length > OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH) {
- variableValue = variableValue.slice(0, OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH)
+ // Reporting absolute cap (OCPP 2.0.1 §2.1.21 ReportingValueSize maxLimit = 2500)
+ if (variableValue.length > OCPP20Constants.MAX_REPORTING_VALUE_SIZE) {
+ variableValue = variableValue.slice(0, OCPP20Constants.MAX_REPORTING_VALUE_SIZE)
}
return {
attributeStatus: GetVariableStatusEnumType.Accepted,
// 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 (OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH).
- // 5. Enforce absolute upper cap of OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH (spec).
+ // 4. If neither valid/positive, fallback to `OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE` (OCPP 2.0.1 §2.1.20 maxLimit = 1000).
+ // 5. Enforce absolute upper cap of `OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE` (SetVariableDataType.attributeValue = string[0..1000]).
// 6. Reject with TooLargeElement when attributeValue length strictly exceeds effectiveLimit.
if (resolvedAttributeType === AttributeEnumType.Actual) {
const configurationValueSizeKey = buildCaseInsensitiveCompositeKey(
effectiveLimit = effectiveLimit != null ? Math.min(effectiveLimit, valLimit) : valLimit
}
if (effectiveLimit == null || effectiveLimit <= 0) {
- effectiveLimit = OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH
+ effectiveLimit = OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE
}
- if (effectiveLimit > OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH) {
- effectiveLimit = OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH
+ if (effectiveLimit > OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE) {
+ effectiveLimit = OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE
}
if (attributeValue.length > effectiveLimit) {
return this.rejectSet(
},
// DeviceDataCtrlr Component
- // Value size family: ValueSize (broadest), ConfigurationValueSize (affects setting), ReportingValueSize (affects reporting). Simulator sets same absolute cap; truncate occurs at reporting step.
+ // Value size family per OCPP 2.0.1 §2.1.20-21: ConfigurationValueSize (SetVariable input cap, 1000), ReportingValueSize (GetVariable output cap, 2500). ValueSize is OCPP 2.1 (broadest umbrella, capped at reporting size).
[buildRegistryKey(
OCPP20ComponentName.DeviceDataCtrlr,
OCPP20OptionalVariableName.ConfigurationValueSize
)]: {
component: OCPP20ComponentName.DeviceDataCtrlr,
dataType: DataEnumType.integer,
- defaultValue: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString(),
+ defaultValue: OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE.toString(),
description: 'Maximum size allowed for configuration values when setting.',
- max: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH,
+ max: OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE,
maxLength: 5,
min: 1,
mutability: MutabilityEnumType.ReadOnly,
)]: {
component: OCPP20ComponentName.DeviceDataCtrlr,
dataType: DataEnumType.integer,
- defaultValue: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString(),
+ defaultValue: OCPP20Constants.MAX_REPORTING_VALUE_SIZE.toString(),
description: 'Maximum size of reported values.',
- max: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH,
+ max: OCPP20Constants.MAX_REPORTING_VALUE_SIZE,
maxLength: 5,
min: 1,
mutability: MutabilityEnumType.ReadOnly,
[buildRegistryKey(OCPP20ComponentName.DeviceDataCtrlr, OCPP20OptionalVariableName.ValueSize)]: {
component: OCPP20ComponentName.DeviceDataCtrlr,
dataType: DataEnumType.integer,
- defaultValue: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString(),
+ defaultValue: OCPP20Constants.MAX_REPORTING_VALUE_SIZE.toString(),
description: 'Maximum size for any stored or reported value.',
- max: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH,
+ max: OCPP20Constants.MAX_REPORTING_VALUE_SIZE,
maxLength: 5,
min: 1,
mutability: MutabilityEnumType.ReadOnly,
(chargingStation.inPendingState() &&
(params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
) {
- // eslint-disable-next-line @typescript-eslint/no-this-alias
+ // eslint-disable-next-line @typescript-eslint/no-this-alias -- stable outer-this reference captured for nested Promise executor and its response-handler closures
const self = this
// Send a message through wsConnection
return await new Promise<ResponseType>((resolve, reject: (reason?: unknown) => void) => {
}
} else if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- iterating an unknown-typed JSON array to normalize Date entries in place
const item = value[i]
if (isDate(item)) {
try {
import { CertificateAuthStrategy } from '../strategies/CertificateAuthStrategy.js'
import { LocalAuthStrategy } from '../strategies/LocalAuthStrategy.js'
import { RemoteAuthStrategy } from '../strategies/RemoteAuthStrategy.js'
-import { AuthConfigValidator } from '../utils/ConfigValidator.js'
+import { AuthConfigValidator } from '../utils/AuthConfigValidator.js'
/**
* Factory for creating authentication components with proper dependency injection
type Identifier,
IdentifierType,
} from '../types/AuthTypes.js'
-import { AuthConfigValidator } from '../utils/ConfigValidator.js'
+import { AuthConfigValidator } from '../utils/AuthConfigValidator.js'
const moduleName = 'OCPPAuthServiceImpl'
return result
}
- // Should not reach here due to canHandle check, but handle gracefully
+ // Unreachable when the `canHandle` contract holds; defensive fallback
+ // for unsupported OCPP versions.
return this.createFailureResult(
AuthorizationStatus.INVALID,
`Certificate authentication not supported for OCPP ${this.adapter.ocppVersion}`,
return false
}
- // Certificate authentication must be enabled
+ // Certificate authentication is enabled per configuration.
const certAuthEnabled = config.certificateAuthEnabled
- // Must have certificate data in the identifier
+ // The identifier carries certificate data.
const hasCertificateData = this.hasCertificateData(request.identifier)
return certAuthEnabled && hasCertificateData && this.isInitialized
}
export type ConfigurationData = z.infer<typeof ConfigurationSchema>
-export type ElementsPerWorkerType = NonNullable<WorkerConfiguration['elementsPerWorker']>
export type LogConfiguration = z.infer<typeof LogConfigurationSchema>
export type StationTemplateUrl = z.infer<typeof StationTemplateUrlSchema>
export type StorageConfiguration = z.infer<typeof StorageConfigurationSchema>
ApplicationProtocolVersion,
type ConfigurationData,
ConfigurationSection,
- type ElementsPerWorkerType,
type LogConfiguration,
type StationTemplateUrl,
type StorageConfiguration,
RPC_FRAMEWORK_ERROR = 'RpcFrameworkError',
// During the processing of Action a security issue occurred preventing receiver from completing the Action successfully
SECURITY_ERROR = 'SecurityError',
- // eslint-disable-next-line @cspell/spellchecker
+ // eslint-disable-next-line @cspell/spellchecker -- placeholder example value in the spec quote is not a dictionary word
// Payload for Action is syntactically correct but at least one of the fields violates data type constraints (e.g. "somestring" = 12)
TYPE_CONSTRAINT_VIOLATION = 'TypeConstraintViolation',
}
startDelay: DEFAULT_WORKER_START_DELAY_MS,
}
-export const DEFAULT_PERSIST_STATE = true as const
+const DEFAULT_PERSIST_STATE = true as const
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class Configuration {
import { isDeepStrictEqual } from 'node:util'
+import type { FieldError } from './FieldError.js'
+
// Direct path: the `exception/index.js` barrel re-exports OCPPError, causing a TDZ cycle.
import { BaseError } from '../exception/BaseError.js'
import { clone } from './Utils.js'
*/
export const CURRENT_CONFIGURATION_SCHEMA_VERSION = 1
-export interface FieldError {
- message: string
- path: string
-}
-
export type MigrationFn = (
config: Record<string, unknown>,
filePath: string
* `UIServerFactory` so empty placeholders cannot ship under `enabled: false`
* and become a Basic-Auth bypass on the next boot with `enabled: true`.
*/
-export const UIServerAuthenticationSchema = z
+const UIServerAuthenticationSchema = z
.object({
enabled: z.boolean(),
password: z.string().min(1).optional(),
trustedProxies: [],
} as const
-export const UIServerAccessPolicySchema = z
+const UIServerAccessPolicySchema = z
.object({
allowedHosts: z
.array(
import chalk from 'chalk'
import type { ConfigurationData } from '../types/index.js'
-import type { FieldError } from './ConfigurationMigrations.js'
+import type { FieldError } from './FieldError.js'
import { BaseError } from '../exception/index.js'
import {
} from './ConfigurationMigrations.js'
import { ConfigurationSchema } from './ConfigurationSchema.js'
import { configurationLogPrefix } from './ConfigurationUtils.js'
+import { formatFieldErrorsSummary, mapZodIssuesToFieldErrors } from './FieldError.js'
import { assertIsJsonObject, clone, isEmpty, isNotEmptyArray } from './Utils.js'
const moduleName = 'ConfigurationValidation'
fieldErrors: FieldError[],
context: { filePath: string; migratedFrom?: number; phase: ValidationPhase }
) {
- const fieldSummary = fieldErrors
- .map(e => ` - ${e.path !== '' ? e.path : '(root)'}: ${e.message}`)
- .join('\n')
+ const fieldSummary = formatFieldErrorsSummary(fieldErrors)
const migrationNote =
context.migratedFrom != null
? ` (migrated from v${context.migratedFrom.toString()} → v${CURRENT_CONFIGURATION_SCHEMA_VERSION.toString()})`
zodError: ZodError,
context: { filePath: string; migratedFrom?: number }
): ConfigurationValidationError {
- const fieldErrors: FieldError[] = zodError.issues.map(issue => ({
- message: issue.message,
- path: issue.path.join('.'),
- }))
+ const fieldErrors: FieldError[] = mapZodIssuesToFieldErrors(zodError)
return new ConfigurationValidationError(fieldErrors, { ...context, phase: 'schema' })
}
}
--- /dev/null
+import type { ZodError } from 'zod'
+
+export interface FieldError {
+ message: string
+ path: string
+}
+
+export const mapZodIssuesToFieldErrors = (zodError: ZodError): FieldError[] =>
+ zodError.issues.map(issue => ({
+ message: issue.message,
+ path: issue.path.join('.'),
+ }))
+
+export const formatFieldErrorsSummary = (fieldErrors: readonly FieldError[]): string =>
+ fieldErrors.map(e => ` - ${e.path !== '' ? e.path : '(root)'}: ${e.message}`).join('\n')
let tmpInvocationCounter = 0
-export interface AtomicWriteOptions {
+interface AtomicWriteOptions {
/**
* Character encoding when `data` is a string. Defaults to `'utf8'`.
*/
buildEvseEntries,
buildEvsesStatus,
} from './ChargingStationConfigurationUtils.js'
-export { Configuration, DEFAULT_PERSIST_STATE } from './Configuration.js'
+export { Configuration } from './Configuration.js'
export {
CURRENT_CONFIGURATION_SCHEMA_VERSION,
DEPRECATED_KEY_REMAPPINGS,
StationTemplateUrlSchema,
StorageConfigurationSchema,
UI_SERVER_ACCESS_POLICY_DEFAULTS,
- UIServerAccessPolicySchema,
- UIServerAuthenticationSchema,
UIServerConfigurationSchema,
UIServerMetricsConfigurationSchema,
WorkerConfigurationSchema,
handleUnhandledRejection,
} from './ErrorUtils.js'
export {
- atomicWriteFile,
- atomicWriteFileSync,
- type AtomicWriteOptions,
- watchJsonFile,
-} from './FileUtils.js'
+ type FieldError,
+ formatFieldErrorsSummary,
+ mapZodIssuesToFieldErrors,
+} from './FieldError.js'
+export { atomicWriteFile, atomicWriteFileSync, watchJsonFile } from './FileUtils.js'
export {
isHostLiteralWithoutPort,
isLoopback,
this.addWorkerSetElement()
}
worker.unref()
- // eslint-disable-next-line promise/no-promise-in-callback
+ // eslint-disable-next-line promise/no-promise-in-callback -- fire-and-forget worker termination inside an 'error' listener; failures are surfaced via safeEmit
worker.terminate().catch((error: unknown) => {
this.safeEmit(WorkerSetEvents.error, error)
})
} from '../../src/charging-station/index.js'
import { logger } from '../../src/utils/index.js'
import { mockLoggerWarnDebug, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { TEST_SUPERVISION_URL } from '../utils/TestNetworkConstants.js'
import { buildLegacyTemplate } from './helpers/TemplateFixtures.js'
await describe('TemplateMigrations', async () => {
authorizationFile: 'tags.json',
mustAuthorizeAtRemoteStart: true,
payloadSchemaValidation: false,
- supervisionUrl: 'ws://localhost:8080',
+ supervisionUrl: TEST_SUPERVISION_URL,
})
const result = applyMigration(0, template)
assert.strictEqual(result.$schemaVersion, CURRENT_SCHEMA_VERSION)
- assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080')
+ assert.strictEqual(result.supervisionUrls, TEST_SUPERVISION_URL)
assert.strictEqual(result.idTagsFile, 'tags.json')
assert.strictEqual(result.remoteAuthorization, true)
assert.strictEqual(result.ocppStrictCompliance, false)
})
for (const [deprecated, replacement, value] of [
- ['supervisionUrl', 'supervisionUrls', 'ws://localhost:8080'],
+ ['supervisionUrl', 'supervisionUrls', TEST_SUPERVISION_URL],
['authorizationFile', 'idTagsFile', 'tags.json'],
['payloadSchemaValidation', 'ocppStrictCompliance', false],
['mustAuthorizeAtRemoteStart', 'remoteAuthorization', true],
import { CURRENT_SCHEMA_VERSION } from '../../src/charging-station/index.js'
import { TemplateSchema } from '../../src/charging-station/index.js'
import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { TEST_SUPERVISION_URL } from '../utils/TestNetworkConstants.js'
import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js'
import { buildMinimalTemplate } from './helpers/TemplateFixtures.js'
await describe('deprecated keys rejection', async () => {
for (const [legacyKey, legacyValue] of [
- ['supervisionUrl', 'ws://localhost:8080'],
+ ['supervisionUrl', TEST_SUPERVISION_URL],
['authorizationFile', 'tags.json'],
['mustAuthorizeAtRemoteStart', true],
['payloadSchemaValidation', false],
import { BaseError } from '../../src/exception/index.js'
import { logger } from '../../src/utils/index.js'
import { mockLoggerWarnDebug, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { TEST_SUPERVISION_URL } from '../utils/TestNetworkConstants.js'
import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js'
import { buildLegacyTemplate, buildMinimalTemplate } from './helpers/TemplateFixtures.js'
mockLoggerWarnDebug(t, logger)
const parsed = buildLegacyTemplate({
Connectors: { 0: {}, 1: {} },
- supervisionUrl: 'ws://localhost:8080',
+ supervisionUrl: TEST_SUPERVISION_URL,
})
const before = structuredClone(parsed)
const parsed = buildLegacyTemplate({
$schemaVersion: 0,
Connectors: { 0: {}, 1: {} },
- supervisionUrl: 'ws://localhost:8080',
+ supervisionUrl: TEST_SUPERVISION_URL,
})
const result = validateTemplate(parsed, 'test.json')
- assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080')
+ assert.strictEqual(result.supervisionUrls, TEST_SUPERVISION_URL)
})
await it('should auto-migrate template missing $schemaVersion (legacy v0 default)', t => {
Connectors: { 0: {}, 1: {} },
mustAuthorizeAtRemoteStart: true,
payloadSchemaValidation: false,
- supervisionUrl: 'ws://localhost:8080',
+ supervisionUrl: TEST_SUPERVISION_URL,
})
const result = validateTemplate(parsed, 'legacy.json')
- assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080')
+ assert.strictEqual(result.supervisionUrls, TEST_SUPERVISION_URL)
assert.strictEqual(result.idTagsFile, 'tags.json')
assert.strictEqual(result.remoteAuthorization, true)
assert.strictEqual(result.ocppStrictCompliance, false)
}
for (const [legacyKey, legacyValue] of [
- ['supervisionUrl', 'ws://localhost:8080'],
+ ['supervisionUrl', TEST_SUPERVISION_URL],
['mustAuthorizeAtRemoteStart', true],
['payloadSchemaValidation', false],
] as const) {
// ReportingValueSize truncation test
await it('should truncate long SequenceList/MemberList values per ReportingValueSize', () => {
- // Ensure ReportingValueSize is at a small value (default is OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH). We will override configuration key if absent.
+ // Ensure ReportingValueSize is at a small value (default is OCPP20Constants.MAX_REPORTING_VALUE_SIZE). We will override configuration key if absent.
const reportingSizeKey = buildConfigKey(
OCPP20ComponentName.DeviceDataCtrlr,
StandardParametersKey.ReportingValueSize
OCPP20ComponentName.DeviceDataCtrlr,
OCPP20OptionalVariableName.ReportingValueSize
),
- OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString()
+ OCPP20Constants.MAX_REPORTING_VALUE_SIZE.toString()
)
}
OCPP20ComponentName.DeviceDataCtrlr,
OCPP20OptionalVariableName.ConfigurationValueSize
),
- OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString()
+ OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE.toString()
)
upsertConfigurationKey(
chargingStation,
buildConfigKey(OCPP20ComponentName.DeviceDataCtrlr, OCPP20OptionalVariableName.ValueSize),
- OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString()
+ OCPP20Constants.MAX_REPORTING_VALUE_SIZE.toString()
)
}
buildConfigKey(OCPP20ComponentName.ChargingStation, OCPP20VendorVariableName.ConnectionUrl),
overLongValue
)
- // Set generous ValueSize (1500) and ReportingValueSize (1400) so only absolute cap applies (since both < OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH)
+ // Set generous ValueSize (1500) and ReportingValueSize (1400) so only absolute cap applies (since both < OCPP20Constants.MAX_REPORTING_VALUE_SIZE)
setValueSize(station, 1500)
setReportingValueSize(station, 1400)
const getRes = manager.getVariables(station, [
AuthenticationError,
AuthorizationStatus,
} from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
-import { AuthConfigValidator } from '../../../../../src/charging-station/ocpp/auth/utils/ConfigValidator.js'
+import { AuthConfigValidator } from '../../../../../src/charging-station/ocpp/auth/utils/AuthConfigValidator.js'
import { standardCleanup } from '../../../../helpers/TestLifecycleHelpers.js'
await describe('AuthConfigValidator', async () => {
import { PerformanceRecord } from '../../../src/types/orm/entities/PerformanceRecord.js'
import { Constants, logger } from '../../../src/utils/index.js'
import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import { TEST_SUPERVISION_URL } from '../../utils/TestNetworkConstants.js'
import { buildTestStatistics } from './StorageTestHelpers.js'
const TEST_LOG_PREFIX = '[MikroOrmStorage Test]'
const record = await verifyOrm.em.fork().findOne(PerformanceRecord, { id: 'station-1' })
assert.notStrictEqual(record, undefined)
assert.strictEqual(record?.name, 'cs-station-1')
- assert.strictEqual(record.uri, 'ws://localhost:8080')
+ assert.strictEqual(record.uri, TEST_SUPERVISION_URL)
} finally {
await verifyOrm.close()
}
buildFullConfiguration,
buildMinimalConfiguration,
} from './helpers/ConfigurationFixtures.js'
+import { TEST_SUPERVISION_URL, TEST_SUPERVISION_URL_ALT_PORT } from './TestNetworkConstants.js'
await describe('ConfigurationSchema', async () => {
afterEach(() => {
['logRotate', true],
['logStatisticsInterval', 60],
['stationTemplateURLs', [{ file: 'a.json', numberOfStations: 1 }]],
- ['supervisionURLs', 'ws://localhost:8080'],
+ ['supervisionURLs', TEST_SUPERVISION_URL],
['uiWebSocketServer', {}],
['useWorkerPool', false],
['workerPoolMaxSize', 16],
await describe('mixed-type fields', async () => {
await it('should accept supervisionUrls as string', () => {
const result = ConfigurationSchema.safeParse(
- buildMinimalConfiguration({ supervisionUrls: 'ws://localhost:8080' })
+ buildMinimalConfiguration({ supervisionUrls: TEST_SUPERVISION_URL })
)
assert.ok(result.success)
})
await it('should accept supervisionUrls as string array', () => {
const result = ConfigurationSchema.safeParse(
buildMinimalConfiguration({
- supervisionUrls: ['ws://localhost:8080', 'ws://localhost:8081'],
+ supervisionUrls: [TEST_SUPERVISION_URL, TEST_SUPERVISION_URL_ALT_PORT],
})
)
assert.ok(result.success)
buildMinimalConfiguration,
buildV1WithDeprecatedKey,
} from './helpers/ConfigurationFixtures.js'
+import { TEST_SUPERVISION_URL } from './TestNetworkConstants.js'
/** Expected error message for a v1 config missing `stationTemplateUrls`. */
const EXPECTED_SNAPSHOT =
logRotate: true,
logStatisticsInterval: 60,
stationTemplateURLs: [{ file: 'a.json', numberOfStations: 1 }],
- supervisionURLs: 'ws://localhost:8080',
+ supervisionURLs: TEST_SUPERVISION_URL,
uiWebSocketServer: {},
useWorkerPool: false,
'worker.elementStartDelay': 100,
/**
* @file Shared network-related test constants derived from production defaults.
- * @description Single source of truth for the `ws://host:port` test URL,
+ * @description Single source of truth for the `ws://host:port` test URLs,
* built from `Constants.DEFAULT_UI_SERVER_HOST` /
* `Constants.DEFAULT_UI_SERVER_PORT` so a change to the production defaults
- * propagates automatically to the tests.
+ * propagates automatically to the tests. Two variants are exposed: the
+ * primary URL at the default port, and an alternate at the next port for
+ * tests that need two distinct URL fixtures (e.g. `supervisionUrls` arrays).
*/
import { Constants } from '../../src/utils/index.js'
-export const TEST_SUPERVISION_URL = `ws://${Constants.DEFAULT_UI_SERVER_HOST}:${Constants.DEFAULT_UI_SERVER_PORT.toString()}`
+const HOST = Constants.DEFAULT_UI_SERVER_HOST
+const PORT = Constants.DEFAULT_UI_SERVER_PORT
+
+export const TEST_SUPERVISION_URL = `ws://${HOST}:${PORT.toString()}`
+export const TEST_SUPERVISION_URL_ALT_PORT = `ws://${HOST}:${(PORT + 1).toString()}`
import process from 'node:process'
import {
+ extractErrorMessage,
type OCPPVersion,
ProcedureName,
type RequestPayload,
silent: true,
})
} catch (error: unknown) {
- const msg = error instanceof Error ? error.message : String(error)
- throw new Error(`Failed to fetch charging station list: ${msg}`)
+ throw new Error(`Failed to fetch charging station list: ${extractErrorMessage(error)}`)
}
if (response.status !== ResponseStatus.SUCCESS || !Array.isArray(response.chargingStations)) {
import { homedir } from 'node:os'
import { resolve } from 'node:path'
import process from 'node:process'
+import { extractErrorMessage } from 'ui-common'
declare const __EMBEDDED_SKILL__: string
mkdirSync(dir, { recursive: true })
writeFileSync(filepath, __EMBEDDED_SKILL__, 'utf8')
} catch (error: unknown) {
- const msg = error instanceof Error ? error.message : String(error)
- process.stderr.write(`Failed to install skill: ${msg}\n`)
+ process.stderr.write(`Failed to install skill: ${extractErrorMessage(error)}\n`)
process.exitCode = 1
return
}
-import { type SKIN_IDS } from 'ui-common'
+import { type SKIN_IDS, type THEME_IDS } from 'ui-common'
// Local UI project constants
export const ASYNC_COMPONENT_DELAY_MS = 200
export const DEFAULT_SKIN: (typeof SKIN_IDS)[number] = 'modern'
+export const DEFAULT_THEME: (typeof THEME_IDS)[number] = 'tokyo-night-storm'
export const ASYNC_COMPONENT_TIMEOUT_MS = 10_000
export const EMPTY_VALUE_PLACEHOLDER = 'Ø'
export const MAX_SKIN_ERROR_RELOADS = 2
export const MAX_STATIONS_PER_ADD = 100
export const SKIN_ERROR_RELOAD_COUNT_KEY = 'skin-error-reload-count'
+export const SKIN_STORAGE_KEY = 'ecs-ui-skin'
+export const THEME_STORAGE_KEY = 'ecs-ui-theme'
export const WH_PER_KWH = 1000
export const ROUTE_NAMES = {
let aborted = false
const config = this.uiServerConfiguration
const eventTarget = this.wsEventTarget
+ const uiServerAddress = `${config.host}:${config.port.toString()}`
const factory: WebSocketFactory = (url, protocols) => {
const adapter = createBrowserWsAdapter(
adapter.onerror = event => {
if (aborted) return
handler?.(event)
- useToast().error(
- `Error in WebSocket to UI server '${config.host}:${config.port.toString()}'`
- )
- console.error(
- `Error in WebSocket to UI server '${config.host}:${config.port.toString()}'`,
- event
- )
+ useToast().error(`Error in WebSocket to UI server '${uiServerAddress}'`)
+ console.error(`Error in WebSocket to UI server '${uiServerAddress}'`, event)
eventTarget.dispatchEvent(new Event('error'))
}
},
adapter.onopen = () => {
if (aborted) return
handler?.()
- useToast().success(
- `WebSocket to UI server '${config.host}:${config.port.toString()}' successfully opened`
- )
+ useToast().success(`WebSocket to UI server '${uiServerAddress}' successfully opened`)
eventTarget.dispatchEvent(new Event('open'))
}
},
ASYNC_COMPONENT_DELAY_MS,
ASYNC_COMPONENT_TIMEOUT_MS,
DEFAULT_SKIN,
+ DEFAULT_THEME,
EMPTY_VALUE_PLACEHOLDER,
LEGACY_UI_SERVER_CONFIG_KEY,
MAX_SKIN_ERROR_RELOADS,
ROUTE_NAMES,
SHARED_TOGGLE_BUTTON_KEY_PREFIX,
SKIN_ERROR_RELOAD_COUNT_KEY,
+ SKIN_STORAGE_KEY,
+ THEME_STORAGE_KEY,
TOGGLE_BUTTON_KEY_PREFIX,
UI_SERVER_CONFIGURATION_INDEX_KEY,
WH_PER_KWH,
chargingStationsKey,
configurationKey,
DEFAULT_SKIN,
+ DEFAULT_THEME,
getFromLocalStorage,
isDev,
LEGACY_UI_SERVER_CONFIG_KEY,
setToLocalStorage,
+ SKIN_STORAGE_KEY,
templatesKey,
+ THEME_STORAGE_KEY,
UI_SERVER_CONFIGURATION_INDEX_KEY,
UIClient,
uiClientKey,
} from '@/core/index.js'
import { router } from '@/router/index.js'
-import { SKIN_STORAGE_KEY, useSkin } from '@/shared/composables/useSkin.js'
-import { DEFAULT_THEME, THEME_STORAGE_KEY, useTheme } from '@/shared/composables/useTheme.js'
+import { useSkin } from '@/shared/composables/useSkin.js'
+import { useTheme } from '@/shared/composables/useTheme.js'
import 'vue-toast-notification/dist/theme-bootstrap.css'
if (getFromLocalStorage<string>(SKIN_STORAGE_KEY, '') === '' && config.skin != null) {
setToLocalStorage<string>(SKIN_STORAGE_KEY, config.skin)
}
- const initialSkin = getFromLocalStorage<string>(SKIN_STORAGE_KEY, config.skin ?? 'classic')
+ const initialSkin = getFromLocalStorage<string>(SKIN_STORAGE_KEY, config.skin ?? DEFAULT_SKIN)
const switched = await switchSkin(initialSkin)
if (!switched && initialSkin !== DEFAULT_SKIN) {
console.warn(`[useSkin] Failed to load skin '${initialSkin}', falling back to default`)
MAX_SKIN_ERROR_RELOADS,
setToLocalStorage,
SKIN_ERROR_RELOAD_COUNT_KEY,
+ SKIN_STORAGE_KEY,
} from '@/core/index.js'
-import { SKIN_STORAGE_KEY } from '@/shared/composables/useSkin.js'
// Intentional: registry.ts is pure metadata (ids, labels, loaders) — no behavioral coupling.
import { skins } from '@/skins/registry.js'
const { action, errorMsg, onSuccess, successMsg } = options
if (pending[key]) return
pending[key] = true as T[keyof T]
- // eslint-disable-next-line no-void
+ // eslint-disable-next-line no-void -- fire-and-forget IIFE; the returned promise is intentionally discarded (rejections are handled inside the try/catch)
void (async () => {
try {
await action()
-import { type SKIN_IDS } from 'ui-common'
+import { extractErrorMessage, type SKIN_IDS } from 'ui-common'
import { readonly, ref, type Ref } from 'vue'
import {
getFromLocalStorage,
setToLocalStorage,
SKIN_ERROR_RELOAD_COUNT_KEY,
+ SKIN_STORAGE_KEY,
} from '@/core/index.js'
import { validateTokenContract } from '@/shared/tokens/contract.js'
// Intentional: registry.ts is pure metadata (ids, labels, loaders) — no behavioral coupling.
import { type SkinDefinition, skins } from '@/skins/registry.js'
-export const SKIN_STORAGE_KEY = 'ecs-ui-skin'
-
export type SkinName = (typeof SKIN_IDS)[number]
/**
}
return true
} catch (error: unknown) {
- const message = error instanceof Error ? error.message : String(error)
+ const message = extractErrorMessage(error)
console.warn(`[useSkin] Failed to load CSS for skin '${skinId}':`, message)
lastError.value = message
return false
import { THEME_IDS } from 'ui-common'
import { readonly, ref, type Ref } from 'vue'
-import { getFromLocalStorage, setToLocalStorage } from '@/core/index.js'
+import {
+ DEFAULT_THEME,
+ getFromLocalStorage,
+ setToLocalStorage,
+ THEME_STORAGE_KEY,
+} from '@/core/index.js'
import { validateTokenContract } from '@/shared/tokens/contract.js'
export const AVAILABLE_THEMES = THEME_IDS
-export const DEFAULT_THEME: ThemeName = 'tokyo-night-storm'
-export const THEME_STORAGE_KEY = 'ecs-ui-theme'
export type ThemeName = (typeof THEME_IDS)[number]
document.documentElement.classList.add('theme-switching')
document.documentElement.setAttribute('data-theme', themeName)
// Force reflow so the theme CSS is resolved before reading color-scheme.
- // eslint-disable-next-line no-void
+ // eslint-disable-next-line no-void -- reading `offsetHeight` forces a layout reflow so the theme CSS is resolved before `color-scheme` is read; `void` discards the value
void document.documentElement.offsetHeight
document.documentElement.setAttribute('data-color-scheme', resolveColorScheme())
document.documentElement.classList.remove('theme-switching')