`responsesFailed`: failed responses payload array (optional)
}
+###### Change Configuration
+
+- Request:
+ `ProcedureName`: 'changeConfiguration'
+ `PDU`: {
+ `hashIds`: charging station unique identifier strings array (optional, default: all charging stations),
+ `key`: string,
+ `value`: string
+ }
+ `key` and `value` are both required: `key` is a non-empty OCPP configuration key name and `value` is the new value as a string. Read-only keys are rejected, as are values invalid for the target key (e.g. a non-integer or empty value for a numeric key). Accepted changes apply the OCPP specification side effects (e.g. a `reboot` key returns a reboot-required status).
+
+- Response:
+ `PDU`: {
+ `status`: 'success' | 'failure',
+ `hashIdsSucceeded`: charging station unique identifier strings array,
+ `hashIdsFailed`: charging station unique identifier strings array (optional),
+ `responsesFailed`: failed responses payload array (optional)
+ }
+
###### Performance Statistics
- Request:
type ChargingStationOcppConfiguration,
type ChargingStationOptions,
type ChargingStationTemplate,
+ ConfigurationStatus,
type ConnectorEntry,
type ConnectorStatus,
ConnectorStatusEnum,
this.setIntervalFlushMessageBuffer()
}
+ /**
+ * Applies a configuration change requested by a trusted local caller (e.g. the Web UI),
+ * reusing the OCPP-version-specific spec logic (readonly rejection, value validation,
+ * side-effect restarts, reboot signalling) without emitting an OCPP response to the CSMS.
+ * @param key - Configuration key name.
+ * @param value - New value to apply.
+ * @returns The resulting {@link ConfigurationStatus}.
+ */
+ public changeConfiguration (key: string, value: string): ConfigurationStatus {
+ const status = this.ocppIncomingRequestService.changeConfiguration(this, key, value)
+ if (status === ConfigurationStatus.ACCEPTED || status === ConfigurationStatus.REBOOT_REQUIRED) {
+ this.emitChargingStationEvent(ChargingStationEvents.updated)
+ }
+ return status
+ }
+
/**
* Closes the WebSocket connection to the central server.
* @param options - Close options
type BroadcastChannelRequest,
type BroadcastChannelRequestPayload,
type BroadcastChannelResponsePayload,
+ type ChangeConfigurationResponse,
+ ConfigurationStatus,
type DataTransferResponse,
DataTransferStatus,
GenericStatus,
getErrorMessage,
isAsyncFunction,
isEmpty,
+ isNotEmptyString,
isOCPP20x,
logger,
} from '../../utils/index.js'
type CommandResponse =
| AuthorizeResponse
| BootNotificationResponse
+ | ChangeConfigurationResponse
| DataTransferResponse
| HeartbeatResponse
| OCPP20Get15118EVCertificateResponse
BroadcastChannelProcedureName.BOOT_NOTIFICATION,
r => r.status === RegistrationStatusEnumType.ACCEPTED,
],
+ [
+ BroadcastChannelProcedureName.CHANGE_CONFIGURATION,
+ r =>
+ r.status === ConfigurationStatus.ACCEPTED ||
+ r.status === ConfigurationStatus.REBOOT_REQUIRED,
+ ],
[BroadcastChannelProcedureName.DATA_TRANSFER, r => r.status === DataTransferStatus.ACCEPTED],
[
BroadcastChannelProcedureName.GET_15118_EV_CERTIFICATE,
this.commandHandlers = new Map<BroadcastChannelProcedureName, CommandHandler>([
[BroadcastChannelProcedureName.AUTHORIZE, this.passthrough(RequestCommand.AUTHORIZE)],
[BroadcastChannelProcedureName.BOOT_NOTIFICATION, this.handleBootNotification.bind(this)],
+ [
+ BroadcastChannelProcedureName.CHANGE_CONFIGURATION,
+ (requestPayload?: BroadcastChannelRequestPayload): ChangeConfigurationResponse => {
+ const key = requestPayload?.key
+ if (!isNotEmptyString(key)) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.requestHandler: 'key' field is required`
+ )
+ }
+ const value = requestPayload?.value
+ if (typeof value !== 'string') {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.requestHandler: 'value' field must be a string`
+ )
+ }
+ return { status: this.chargingStation.changeConfiguration(key, value) }
+ },
+ ],
[
BroadcastChannelProcedureName.CLOSE_CONNECTION,
() => {
BroadcastChannelProcedureName.SET_SUPERVISION_URL,
(requestPayload?: BroadcastChannelRequestPayload) => {
const url = requestPayload?.url
- if (typeof url !== 'string' || isEmpty(url)) {
+ if (!isNotEmptyString(url)) {
throw new BaseError(
`${this.chargingStation.logPrefix()} ${moduleName}.requestHandler: 'url' field is required`
)
type ChangeConfigurationResponse,
type ClearCacheResponse,
ConfigurationSection,
+ type ConfigurationStatus,
type ConnectorStatus,
ErrorType,
type GenericResponse,
)
}
+ public changeConfiguration (
+ chargingStation: ChargingStation,
+ key: string,
+ value: string
+ ): ConfigurationStatus {
+ return this.handleRequestChangeConfiguration(chargingStation, { key, value }).status
+ }
+
/**
* @returns Fresh state with all optional {@link OCPP16StationState}
* fields unset. Fields are lazily assigned by the request handlers
OCPP16StandardParametersKey.WebSocketPingInterval,
])
if (integerKeys.has(keyToChange.key as OCPP16StandardParametersKey)) {
- // Number() preserved: rejection check relies on NaN-on-invalid; convertToInt would silently accept (returns 0 for null/undefined, truncates '1.5' → 1) or throw uncaught (for '', 'abc').
+ // convertToInt would truncate '1.5' → 1 and throw on ''/'abc'; Number() instead yields a non-integer
+ // float or NaN that !Number.isInteger rejects, and isNotEmptyString rejects '' (Number('') === 0 would otherwise pass).
const numValue = Number(value)
- if (!Number.isInteger(numValue) || numValue < 0) {
+ if (!isNotEmptyString(value) || !Number.isInteger(numValue) || numValue < 0) {
return OCPP16Constants.OCPP_CONFIGURATION_RESPONSE_REJECTED
}
}
AttributeEnumType,
CertificateSigningUseEnumType,
ChangeAvailabilityStatusEnumType,
+ ConfigurationStatus,
ConnectorEnumType,
type ConnectorStatus,
ConnectorStatusEnum,
InstallCertificateUseEnumType.V2GRootCertificate,
})
+// Collapses the 6 OCPP 2.0.1 SetVariables statuses onto the 4 version-agnostic
+// ConfigurationStatus values used by the local (Web UI) configuration-change seam.
+// UnknownComponent / UnknownVariable are unreachable via changeConfiguration (resolveConfigurationKeyName
+// gates unknown flat keys upstream); Record exhaustiveness is compile-enforced.
+const setVariableStatusToConfigurationStatus: Readonly<
+ Record<SetVariableStatusEnumType, ConfigurationStatus>
+> = Object.freeze({
+ [SetVariableStatusEnumType.Accepted]: ConfigurationStatus.ACCEPTED,
+ [SetVariableStatusEnumType.NotSupportedAttributeType]: ConfigurationStatus.NOT_SUPPORTED,
+ [SetVariableStatusEnumType.RebootRequired]: ConfigurationStatus.REBOOT_REQUIRED,
+ [SetVariableStatusEnumType.Rejected]: ConfigurationStatus.REJECTED,
+ [SetVariableStatusEnumType.UnknownComponent]: ConfigurationStatus.NOT_SUPPORTED,
+ [SetVariableStatusEnumType.UnknownVariable]: ConfigurationStatus.NOT_SUPPORTED,
+})
+
interface StationInfoReportField {
property: 'chargePointModel' | 'chargePointSerialNumber' | 'chargePointVendor' | 'firmwareVersion'
variable: OCPP20DeviceInfoVariableName
)
}
+ /**
+ * Applies a Web UI configuration change: resolves the flat key to its registry tuple and
+ * reuses the SetVariables handler internally, never emitting a SetVariablesRequest to a CSMS.
+ *
+ * The resolved `instance` is placed on the component slot; internal resolution reads
+ * `variable.instance ?? component.instance`, so the round-trip is slot-agnostic. This is only
+ * correct because nothing is emitted on the wire — a future wire-originating path must move a
+ * variableInstance to `variable.instance` for OCPP 2.0.1 conformance.
+ * @param chargingStation - Target charging station.
+ * @param key - Persisted flat configuration key name.
+ * @param value - New value to set.
+ * @returns The resulting configuration status.
+ */
+ public changeConfiguration (
+ chargingStation: ChargingStation,
+ key: string,
+ value: string
+ ): ConfigurationStatus {
+ const resolved = OCPP20VariableManager.getInstance().resolveConfigurationKeyName(key)
+ if (resolved == null) {
+ return ConfigurationStatus.NOT_SUPPORTED
+ }
+ const { component, instance, variable } = resolved
+ const response = this.handleRequestSetVariables(chargingStation, {
+ setVariableData: [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: value,
+ component: { name: component, ...(instance != null && { instance }) },
+ variable: { name: variable },
+ },
+ ],
+ })
+ if (isEmpty(response.setVariableResult)) {
+ return ConfigurationStatus.REJECTED
+ }
+ return setVariableStatusToConfigurationStatus[response.setVariableResult[0].attributeStatus]
+ }
+
/**
* Returns the cert-signing retry manager for the given station,
* lazily creating it on first access.
export class OCPP20VariableManager {
private static instance: null | OCPP20VariableManager = null
+ readonly #configurationKeyNameToVariable = new Map<
+ string,
+ { component: string; instance?: string; variable: string }
+ >(
+ Object.values(VARIABLE_REGISTRY).map(variableMetadata => [
+ computeConfigurationKeyName(variableMetadata),
+ {
+ component: variableMetadata.component,
+ instance: variableMetadata.instance,
+ variable: variableMetadata.variable,
+ },
+ ])
+ )
+
readonly #validComponentNames = new Set<string>(
Object.keys(VARIABLE_REGISTRY).map(k => k.split('::')[0])
)
}
}
+ /**
+ * Resolves a persisted flat configuration key name (as shown in the UI) back to
+ * its registry-defined component/variable/instance tuple, for round-tripping a
+ * generic configuration change through {@link setVariables}.
+ * @param name - Configuration key name (`component.variable[.instance]`).
+ * @returns The resolved tuple, or `undefined` when the name is not registry-backed.
+ */
+ public resolveConfigurationKeyName (
+ name: string
+ ): undefined | { component: string; instance?: string; variable: string } {
+ return this.#configurationKeyNameToVariable.get(name)
+ }
+
public setVariables (
chargingStation: ChargingStation,
setVariableData: OCPP20SetVariableDataType[]
}
const defaultValue = variableMetadata.defaultValue
if (defaultValue != null) {
- addConfigurationKey(chargingStation, configurationKeyName, defaultValue, undefined, {
- overwrite: false,
- })
+ addConfigurationKey(
+ chargingStation,
+ configurationKeyName,
+ defaultValue,
+ {
+ readonly: isReadOnly(variableMetadata),
+ reboot: variableMetadata.rebootRequired === true,
+ },
+ { overwrite: false }
+ )
logger.info(
`${chargingStation.logPrefix()} Added missing configuration key for variable '${configurationKeyName}' with default '${defaultValue}'`
)
chargingStation,
configurationKeyName,
value, // Use the resolved default value
- undefined,
+ {
+ readonly: isReadOnly(variableMetadata),
+ reboot: variableMetadata.rebootRequired === true,
+ },
{
overwrite: false,
}
if (isPersistent(variableMetadata) && !isWriteOnly(variableMetadata)) {
const configKey = getConfigurationKey(chargingStation, configurationKeyName)
if (configKey == null) {
- addConfigurationKey(chargingStation, configurationKeyName, attributeValue, undefined, {
- overwrite: false,
- })
+ addConfigurationKey(
+ chargingStation,
+ configurationKeyName,
+ attributeValue,
+ {
+ readonly: isReadOnly(variableMetadata),
+ reboot: variableMetadata.rebootRequired === true,
+ },
+ { overwrite: false }
+ )
} else if (configKey.value !== attributeValue) {
setConfigurationKeyValue(chargingStation, configurationKeyName, attributeValue)
}
import { type ChargingStation } from '../../charging-station/index.js'
import { OCPPError } from '../../exception/index.js'
import {
+ type ConfigurationStatus,
ErrorType,
type IncomingRequestCommand,
type IncomingRequestHandler,
return OCPPIncomingRequestService.instances.get(this) as T
}
+ /**
+ * Applies a configuration change from a trusted local caller (e.g. the Web UI),
+ * reusing the version-specific incoming-request spec logic (readonly rejection,
+ * value validation, side-effect restarts, reboot signalling) WITHOUT emitting an
+ * OCPP response to the CSMS.
+ * @param chargingStation - Target charging station.
+ * @param key - Configuration key name.
+ * @param value - New value to apply.
+ * @returns The resulting {@link ConfigurationStatus}.
+ */
+ public abstract changeConfiguration (
+ chargingStation: ChargingStation,
+ key: string,
+ value: string
+ ): ConfigurationStatus
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
public async incomingRequestHandler<ReqType extends JsonType, ResType extends JsonType>(
chargingStation: ChargingStation,
inputSchema: ocppInputSchema(ProcedureName.BOOT_NOTIFICATION),
},
],
+ [
+ ProcedureName.CHANGE_CONFIGURATION,
+ {
+ description:
+ 'Change the value of an OCPP configuration key for one or more charging stations, applying the OCPP spec side effects (read-only keys are rejected)',
+ inputSchema: z.object({
+ hashIds,
+ key: z.string().min(1).describe('The OCPP configuration key to change'),
+ value: z.string().describe('The new value to set for the configuration key'),
+ }),
+ },
+ ],
[
ProcedureName.CLOSE_CONNECTION,
{
>([
[ProcedureName.AUTHORIZE, BroadcastChannelProcedureName.AUTHORIZE],
[ProcedureName.BOOT_NOTIFICATION, BroadcastChannelProcedureName.BOOT_NOTIFICATION],
+ [ProcedureName.CHANGE_CONFIGURATION, BroadcastChannelProcedureName.CHANGE_CONFIGURATION],
[ProcedureName.CLOSE_CONNECTION, BroadcastChannelProcedureName.CLOSE_CONNECTION],
[ProcedureName.DATA_TRANSFER, BroadcastChannelProcedureName.DATA_TRANSFER],
[
ADD_CHARGING_STATIONS = 'addChargingStations',
AUTHORIZE = 'authorize',
BOOT_NOTIFICATION = 'bootNotification',
+ CHANGE_CONFIGURATION = 'changeConfiguration',
CLOSE_CONNECTION = 'closeConnection',
DATA_TRANSFER = 'dataTransfer',
DELETE_CHARGING_STATIONS = 'deleteChargingStations',
export enum BroadcastChannelProcedureName {
AUTHORIZE = 'authorize',
BOOT_NOTIFICATION = 'bootNotification',
+ CHANGE_CONFIGURATION = 'changeConfiguration',
CLOSE_CONNECTION = 'closeConnection',
DATA_TRANSFER = 'dataTransfer',
DELETE_CHARGING_STATIONS = 'deleteChargingStations',
--- /dev/null
+/**
+ * @file Tests for ChargingStation.changeConfiguration emit gating
+ * @description The public changeConfiguration delegate must emit
+ * ChargingStationEvents.updated (which drives the Web UI read-view refresh)
+ * only when the underlying change was applied (Accepted / RebootRequired),
+ * and must never emit for a rejected or unsupported change.
+ */
+
+import assert from 'node:assert/strict'
+import { describe, it, mock } from 'node:test'
+
+import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import { ChargingStationEvents, ConfigurationStatus } from '../../src/types/index.js'
+
+interface ChangeConfigurationContext {
+ emitChargingStationEvent: (event: ChargingStationEvents) => void
+ ocppIncomingRequestService: {
+ changeConfiguration: (station: unknown, key: string, value: string) => ConfigurationStatus
+ }
+}
+
+/**
+ * Invokes the real ChargingStation.changeConfiguration against a minimal `this`
+ * so the emit-gating branch is exercised without constructing a full station.
+ * @param status - The status the incoming-request service returns
+ * @returns The emit spy and the returned status
+ */
+const runChangeConfiguration = (status: ConfigurationStatus) => {
+ const emitSpy = mock.fn()
+ const context: ChangeConfigurationContext = {
+ emitChargingStationEvent: emitSpy,
+ ocppIncomingRequestService: {
+ changeConfiguration: () => status,
+ },
+ }
+ const returned = ChargingStation.prototype.changeConfiguration.call(
+ context as unknown as ChargingStation,
+ 'MeterValueSampleInterval',
+ '30'
+ )
+ return { emitSpy, returned }
+}
+
+await describe('ChargingStation.changeConfiguration emit gating', async () => {
+ await it('should emit updated and return the status when the change is Accepted', () => {
+ const { emitSpy, returned } = runChangeConfiguration(ConfigurationStatus.ACCEPTED)
+ assert.strictEqual(returned, ConfigurationStatus.ACCEPTED)
+ assert.strictEqual(emitSpy.mock.callCount(), 1)
+ assert.strictEqual(emitSpy.mock.calls[0].arguments[0], ChargingStationEvents.updated)
+ })
+
+ await it('should emit updated when the change is RebootRequired (value was applied)', () => {
+ const { emitSpy, returned } = runChangeConfiguration(ConfigurationStatus.REBOOT_REQUIRED)
+ assert.strictEqual(returned, ConfigurationStatus.REBOOT_REQUIRED)
+ assert.strictEqual(emitSpy.mock.callCount(), 1)
+ assert.strictEqual(emitSpy.mock.calls[0].arguments[0], ChargingStationEvents.updated)
+ })
+
+ await it('should not emit when the change is Rejected', () => {
+ const { emitSpy, returned } = runChangeConfiguration(ConfigurationStatus.REJECTED)
+ assert.strictEqual(returned, ConfigurationStatus.REJECTED)
+ assert.strictEqual(emitSpy.mock.callCount(), 0)
+ })
+
+ await it('should not emit when the change is NotSupported', () => {
+ const { emitSpy, returned } = runChangeConfiguration(ConfigurationStatus.NOT_SUPPORTED)
+ assert.strictEqual(returned, ConfigurationStatus.NOT_SUPPORTED)
+ assert.strictEqual(emitSpy.mock.callCount(), 0)
+ })
+})
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
-import { afterEach, describe, it } from 'node:test'
+import { afterEach, describe, it, mock } from 'node:test'
import { ChargingStationWorkerBroadcastChannel } from '../../../src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.js'
import { AbstractUIService } from '../../../src/charging-station/ui-server/ui-services/AbstractUIService.js'
+import { BaseError } from '../../../src/exception/index.js'
import {
BroadcastChannelProcedureName,
type BroadcastChannelRequestPayload,
+ ConfigurationStatus,
GenericStatus,
GetCertificateStatusEnumType,
Iso15118EVCertificateStatusEnumType,
})
// ==========================================================================
- // Group 4: commandHandler dispatch pipeline — verify full dispatch (8 tests)
+ // Group 3: CHANGE_CONFIGURATION — status collapse + worker handler (9 tests)
+ // The status-collapse tests guard the acceptedStatusCommands entry: without it,
+ // even ACCEPTED would fall through to the FAILURE default.
+ // ==========================================================================
+
+ await describe('commandResponseToResponseStatus CHANGE_CONFIGURATION', async () => {
+ const cases: { expected: ResponseStatus; status: ConfigurationStatus }[] = [
+ { expected: ResponseStatus.SUCCESS, status: ConfigurationStatus.ACCEPTED },
+ { expected: ResponseStatus.SUCCESS, status: ConfigurationStatus.REBOOT_REQUIRED },
+ { expected: ResponseStatus.FAILURE, status: ConfigurationStatus.REJECTED },
+ { expected: ResponseStatus.FAILURE, status: ConfigurationStatus.NOT_SUPPORTED },
+ ]
+ for (const { expected, status } of cases) {
+ await it(`should map ${status} to ${expected}`, () => {
+ const { station } = createMockChargingStation({
+ connectorsCount: 1,
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+
+ assert.strictEqual(
+ testable.commandResponseToResponseStatus(
+ BroadcastChannelProcedureName.CHANGE_CONFIGURATION,
+ { status }
+ ),
+ expected
+ )
+ })
+ }
+ })
+
+ // CHANGE_CONFIGURATION command handler: payload validation + delegation
+
+ await describe('CHANGE_CONFIGURATION handler', async () => {
+ const changePayload = (
+ overrides: Partial<BroadcastChannelRequestPayload> = {}
+ ): BroadcastChannelRequestPayload => ({ key: 'HeartbeatInterval', value: '60', ...overrides })
+
+ const setup = () => {
+ const { station } = createMockChargingStation({
+ connectorsCount: 1,
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const changeConfigurationMock = mock.fn(() => ConfigurationStatus.ACCEPTED)
+ station.changeConfiguration = changeConfigurationMock
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.CHANGE_CONFIGURATION
+ )
+ assert.ok(handler != null)
+ return { changeConfigurationMock, handler }
+ }
+
+ await it('should delegate a valid payload to changeConfiguration and return its status', () => {
+ const { changeConfigurationMock, handler } = setup()
+ const response = handler(changePayload())
+ assert.deepStrictEqual(response, { status: ConfigurationStatus.ACCEPTED })
+ assert.strictEqual(changeConfigurationMock.mock.callCount(), 1)
+ assert.deepStrictEqual(changeConfigurationMock.mock.calls[0].arguments, [
+ 'HeartbeatInterval',
+ '60',
+ ])
+ })
+
+ await it('should accept an empty-string value', () => {
+ const { changeConfigurationMock, handler } = setup()
+ const response = handler(changePayload({ value: '' }))
+ assert.deepStrictEqual(response, { status: ConfigurationStatus.ACCEPTED })
+ assert.deepStrictEqual(changeConfigurationMock.mock.calls[0].arguments, [
+ 'HeartbeatInterval',
+ '',
+ ])
+ })
+
+ await it('should throw a BaseError when key is missing', () => {
+ const { changeConfigurationMock, handler } = setup()
+ assert.throws(() => handler(changePayload({ key: undefined })), BaseError)
+ assert.strictEqual(changeConfigurationMock.mock.callCount(), 0)
+ })
+
+ await it('should throw a BaseError when key is an empty string', () => {
+ const { handler } = setup()
+ assert.throws(() => handler(changePayload({ key: '' })), BaseError)
+ })
+
+ await it('should throw a BaseError when value is not a string', () => {
+ const { changeConfigurationMock, handler } = setup()
+ assert.throws(() => handler(changePayload({ value: 42 as unknown as string })), BaseError)
+ assert.strictEqual(changeConfigurationMock.mock.callCount(), 0)
+ })
+ })
+
+ // ==========================================================================
+ // Group 4: commandHandler dispatch pipeline — verify full dispatch (9 tests)
// ==========================================================================
await describe('commandHandler OCPP 2.0.1 dispatch pipeline', async () => {
GetConfigurationRequest,
} from '../../../../src/types/index.js'
+import { getConfigurationKey } from '../../../../src/charging-station/ConfigurationKeyUtils.js'
import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
import {
OCPP16ConfigurationStatus,
assert.deepStrictEqual(restartedConnectorIds, [1, 3])
})
+ // ---------------------------------------------------------------------------
+ // changeConfiguration (local Web UI seam over §5.4)
+ // ---------------------------------------------------------------------------
+
+ await it('should apply and persist a mutable key change via the changeConfiguration seam', () => {
+ // Arrange
+ const { incomingRequestService, station } = testContext
+ upsertConfigurationKey(station, OCPP16StandardParametersKey.MeterValueSampleInterval, '60')
+
+ // Act
+ const status = incomingRequestService.changeConfiguration(
+ station,
+ OCPP16StandardParametersKey.MeterValueSampleInterval,
+ '30'
+ )
+
+ // Assert — status returned AND the new value persisted to the configuration
+ assert.strictEqual(status, OCPP16ConfigurationStatus.ACCEPTED)
+ assert.strictEqual(
+ getConfigurationKey(station, OCPP16StandardParametersKey.MeterValueSampleInterval)?.value,
+ '30'
+ )
+ })
+
+ await it('should reject a readonly key via the seam without mutating its value', () => {
+ // Arrange
+ const { incomingRequestService, station } = testContext
+ upsertConfigurationKey(station, OCPP16StandardParametersKey.HeartbeatInterval, '60', true)
+
+ // Act
+ const status = incomingRequestService.changeConfiguration(
+ station,
+ OCPP16StandardParametersKey.HeartbeatInterval,
+ '30'
+ )
+
+ // Assert — rejected AND value unchanged
+ assert.strictEqual(status, OCPP16ConfigurationStatus.REJECTED)
+ assert.strictEqual(
+ getConfigurationKey(station, OCPP16StandardParametersKey.HeartbeatInterval)?.value,
+ '60'
+ )
+ })
+
+ await it('should reject a non-integer value for an integer key via the seam', () => {
+ // Arrange
+ const { incomingRequestService, station } = testContext
+ upsertConfigurationKey(station, OCPP16StandardParametersKey.ConnectionTimeOut, '60')
+
+ // Act
+ const status = incomingRequestService.changeConfiguration(
+ station,
+ OCPP16StandardParametersKey.ConnectionTimeOut,
+ 'not-a-number'
+ )
+
+ // Assert
+ assert.strictEqual(status, OCPP16ConfigurationStatus.REJECTED)
+ assert.strictEqual(
+ getConfigurationKey(station, OCPP16StandardParametersKey.ConnectionTimeOut)?.value,
+ '60'
+ )
+ })
+
+ await it('should reject an empty value for an integer key via the seam', () => {
+ // Arrange
+ const { incomingRequestService, station } = testContext
+ upsertConfigurationKey(station, OCPP16StandardParametersKey.ConnectionTimeOut, '60')
+
+ // Act — Number('') === 0 must not be accepted as a valid integer
+ const status = incomingRequestService.changeConfiguration(
+ station,
+ OCPP16StandardParametersKey.ConnectionTimeOut,
+ ''
+ )
+
+ // Assert — rejected AND value unchanged
+ assert.strictEqual(status, OCPP16ConfigurationStatus.REJECTED)
+ assert.strictEqual(
+ getConfigurationKey(station, OCPP16StandardParametersKey.ConnectionTimeOut)?.value,
+ '60'
+ )
+ })
+
+ await it('should return RebootRequired via the seam for a reboot key', () => {
+ // Arrange
+ const { incomingRequestService, station } = testContext
+ upsertConfigurationKey(station, 'RebootKey', 'oldValue')
+ const configKey = station.ocppConfiguration?.configurationKey?.find(k => k.key === 'RebootKey')
+ if (configKey != null) {
+ configKey.reboot = true
+ }
+
+ // Act
+ const status = incomingRequestService.changeConfiguration(station, 'RebootKey', 'newValue')
+
+ // Assert
+ assert.strictEqual(status, OCPP16ConfigurationStatus.REBOOT_REQUIRED)
+ })
+
+ await it('should return NotSupported via the seam for an unknown key', () => {
+ // Arrange
+ const { incomingRequestService, station } = testContext
+
+ // Act
+ const status = incomingRequestService.changeConfiguration(station, 'NonExistentKey', 'anyValue')
+
+ // Assert
+ assert.strictEqual(status, OCPP16ConfigurationStatus.NOT_SUPPORTED)
+ })
+
+ await it('should not send an OCPP response to the CSMS when applying a change via the seam', () => {
+ // Arrange
+ const { incomingRequestService, station } = testContext
+ upsertConfigurationKey(station, OCPP16StandardParametersKey.MeterValueSampleInterval, '60')
+ const sendResponseSpy = mock.method(station.ocppRequestService, 'sendResponse', async () =>
+ Promise.resolve()
+ )
+
+ // Act
+ incomingRequestService.changeConfiguration(
+ station,
+ OCPP16StandardParametersKey.MeterValueSampleInterval,
+ '30'
+ )
+
+ // Assert — the seam reuses the handler's logic but must NOT emit a CSMS CALLRESULT
+ assert.strictEqual(sendResponseSpy.mock.callCount(), 0)
+ })
+
// ---------------------------------------------------------------------------
// GetConfiguration (§5.8)
// ---------------------------------------------------------------------------
--- /dev/null
+/**
+ * @file Tests for OCPP20IncomingRequestService changeConfiguration (local Web UI seam)
+ * @description Unit tests for the generic configuration-change seam that resolves a flat
+ * configuration key name and routes it through the OCPP 2.0.1 SetVariables spec logic.
+ */
+
+import { millisecondsToSeconds } from 'date-fns'
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import {
+ buildConfigKey,
+ type ChargingStation,
+ getConfigurationKey,
+} from '../../../../src/charging-station/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
+import {
+ ConfigurationStatus,
+ OCPP20ComponentName,
+ OCPP20OptionalVariableName,
+ OCPP20RequiredVariableName,
+ OCPPVersion,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../helpers/StationHelpers.js'
+
+await describe('OCPP20IncomingRequestService — changeConfiguration seam', async () => {
+ let station: ChargingStation
+ let incomingRequestService: OCPP20IncomingRequestService
+
+ beforeEach(() => {
+ const mock = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 3,
+ evseConfiguration: { evsesCount: 3 },
+ stationInfo: {
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station = mock.station
+ incomingRequestService = new OCPP20IncomingRequestService()
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+ })
+
+ await it('should resolve a persisted composite key name back to its component/variable tuple', () => {
+ const name = buildConfigKey(
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20OptionalVariableName.HeartbeatInterval
+ )
+
+ const resolved = OCPP20VariableManager.getInstance().resolveConfigurationKeyName(name)
+
+ assert.deepStrictEqual(resolved, {
+ component: OCPP20ComponentName.OCPPCommCtrlr,
+ instance: undefined,
+ variable: OCPP20OptionalVariableName.HeartbeatInterval,
+ })
+ })
+
+ await it('should return undefined when resolving a non-registry key name', () => {
+ assert.strictEqual(
+ OCPP20VariableManager.getInstance().resolveConfigurationKeyName('Not.A.RegistryKey'),
+ undefined
+ )
+ })
+
+ await it('should accept a writable key routed through SetVariables', () => {
+ const name = buildConfigKey(
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20OptionalVariableName.HeartbeatInterval
+ )
+
+ const status = incomingRequestService.changeConfiguration(
+ station,
+ name,
+ (millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL_MS) + 1).toString()
+ )
+
+ assert.strictEqual(status, ConfigurationStatus.ACCEPTED)
+ })
+
+ await it('should reject a read-only registry variable', () => {
+ const name = buildConfigKey(OCPP20ComponentName.ChargingStation, 'Available')
+
+ const status = incomingRequestService.changeConfiguration(station, name, 'false')
+
+ assert.strictEqual(status, ConfigurationStatus.REJECTED)
+ })
+
+ await it('should return RebootRequired for a reboot-required registry variable', () => {
+ const name = buildConfigKey(
+ OCPP20ComponentName.SecurityCtrlr,
+ OCPP20RequiredVariableName.OrganizationName
+ )
+
+ const status = incomingRequestService.changeConfiguration(station, name, 'Acme Corporation')
+
+ assert.strictEqual(status, ConfigurationStatus.REBOOT_REQUIRED)
+ })
+
+ await it('should resolve, accept and persist an instance-scoped registry variable', () => {
+ // TariffCostCtrlr.Enabled has instance 'Cost' (ReadWrite/Persistent): the flat key
+ // must round-trip through the component-scoped instance and persist the new value.
+ const name = buildConfigKey(
+ OCPP20ComponentName.TariffCostCtrlr,
+ OCPP20RequiredVariableName.Enabled,
+ 'Cost'
+ )
+
+ const status = incomingRequestService.changeConfiguration(station, name, 'true')
+
+ assert.strictEqual(status, ConfigurationStatus.ACCEPTED)
+ assert.strictEqual(getConfigurationKey(station, name)?.value, 'true')
+ })
+
+ await it('should return NotSupported for a key that does not resolve to a registry variable', () => {
+ const status = incomingRequestService.changeConfiguration(station, 'Not.A.RegistryKey', '42')
+
+ assert.strictEqual(status, ConfigurationStatus.NOT_SUPPORTED)
+ })
+})
ADD_CHARGING_STATIONS = 'addChargingStations',
AUTHORIZE = 'authorize',
BOOT_NOTIFICATION = 'bootNotification',
+ CHANGE_CONFIGURATION = 'changeConfiguration',
CLOSE_CONNECTION = 'closeConnection',
DATA_TRANSFER = 'dataTransfer',
DELETE_CHARGING_STATIONS = 'deleteChargingStations',

1. The top bar lets you switch between UI servers, start/stop the simulator, add charging stations, and select themes and skins.
-2. Each charging station is a card with status indicators, connector details, and actions: start, stop, open/close connection, start/stop transaction, show details, and more.
+2. Each charging station is a card with status indicators, connector details, and actions: start, stop, open/close connection, start/stop transaction, show details, change configuration, and more.
## Table of contents
export const ROUTE_NAMES = {
ADD_CHARGING_STATIONS: 'add-charging-stations',
+ CHANGE_CONFIGURATION: 'change-configuration',
CHARGING_STATIONS: 'charging-stations',
NOT_FOUND: 'not-found',
SET_SUPERVISION_URL: 'set-supervision-url',
})
}
+ public async changeConfiguration (
+ hashId: string,
+ key: string,
+ value: string
+ ): Promise<ResponsePayload> {
+ return this.sendRequest(ProcedureName.CHANGE_CONFIGURATION, {
+ hashIds: [hashId],
+ key,
+ value,
+ })
+ }
+
public async closeConnection (hashId: string): Promise<ResponsePayload> {
return this.sendRequest(ProcedureName.CLOSE_CONNECTION, {
hashIds: [hashId],
name: ROUTE_NAMES.ADD_CHARGING_STATIONS,
path: '/add-charging-stations',
},
+ {
+ beforeEnter: skinGuard,
+ components: {
+ action: () => import('@/skins/classic/components/actions/ChangeConfiguration.vue'),
+ },
+ meta: { skinOnly: 'classic' },
+ name: ROUTE_NAMES.CHANGE_CONFIGURATION,
+ path: '/change-configuration/:hashId/:chargingStationId',
+ props: { action: true },
+ },
{
beforeEnter: skinGuard,
components: {
--- /dev/null
+import { type ConfigurationKey } from 'ui-common'
+import { type DeepReadonly, reactive, readonly, type Ref, watch } from 'vue'
+import { useToast } from 'vue-toast-notification'
+
+import { useUIClient } from '@/core/index.js'
+
+/**
+ * Returns per-key draft state and submission logic for changing OCPP configuration keys,
+ * shared by both skins so the draft seeding, mutation flow, toast messaging and pending
+ * tracking stay single-sourced. Read-only keys are never submitted (the backend also rejects
+ * them). A successful change on a `reboot` key surfaces a reboot-required notice, mirroring
+ * the OCPP spec semantics.
+ * @param hashId - The charging station hash identifier
+ * @param visibleConfigurationKeys - The reactive set of configuration keys the form operates on
+ * @returns The per-key draft values, the keys with an in-flight change, and a per-key save function
+ */
+export function useChangeConfigurationForm (
+ hashId: string,
+ visibleConfigurationKeys: Readonly<Ref<ConfigurationKey[]>>
+): {
+ draftValues: Record<string, string>
+ pending: DeepReadonly<Set<string>>
+ save: (configurationKey: ConfigurationKey) => Promise<boolean>
+ } {
+ const $uiClient = useUIClient()
+ const $toast = useToast()
+
+ const pending = reactive(new Set<string>())
+ const draftValues = reactive<Record<string, string>>({})
+
+ // Seed a draft entry for each new key without clobbering in-flight user input.
+ watch(
+ visibleConfigurationKeys,
+ configurationKeys => {
+ for (const configurationKey of configurationKeys) {
+ if (!(configurationKey.key in draftValues)) {
+ draftValues[configurationKey.key] = configurationKey.value ?? ''
+ }
+ }
+ },
+ { immediate: true }
+ )
+
+ /**
+ * Submits the current draft value for a configuration key. Read-only keys are ignored,
+ * and a concurrent save for the same key short-circuits.
+ * @param configurationKey - The configuration key being changed
+ * @returns Whether the change was accepted
+ */
+ async function save (configurationKey: ConfigurationKey): Promise<boolean> {
+ if (configurationKey.readonly || pending.has(configurationKey.key)) {
+ return false
+ }
+ pending.add(configurationKey.key)
+ try {
+ await $uiClient.changeConfiguration(
+ hashId,
+ configurationKey.key,
+ draftValues[configurationKey.key] ?? ''
+ )
+ // The reboot notice is driven by the key's `reboot` metadata flag, not the runtime
+ // ConfigurationStatus: the worker broadcast-channel response collapses ACCEPTED and
+ // REBOOT_REQUIRED into a single boolean success, so the actual status is not observable here.
+ $toast.success(
+ configurationKey.reboot === true
+ ? `Configuration key '${configurationKey.key}' set, reboot required to take effect`
+ : `Configuration key '${configurationKey.key}' successfully set`
+ )
+ return true
+ } catch (error: unknown) {
+ $toast.error(`Error at setting configuration key '${configurationKey.key}'`)
+ console.error(`Error at setting configuration key '${configurationKey.key}':`, error)
+ return false
+ } finally {
+ pending.delete(configurationKey.key)
+ }
+ }
+
+ return {
+ draftValues,
+ pending: readonly(pending),
+ save,
+ }
+}
-import { type ChargingStationData } from 'ui-common'
+import { type ChargingStationData, type ConfigurationKey } from 'ui-common'
import { computed, type ComputedRef } from 'vue'
import { useChargingStations } from '@/core/index.js'
buildStationDetailSections,
type ConfigurationRow,
type DetailSection,
+ getVisibleConfigurationKeys,
} from '@/shared/utils/index.js'
export interface StationDetailsView {
configurationRows: ComputedRef<ConfigurationRow[]>
sections: ComputedRef<DetailSection[]>
station: ComputedRef<ChargingStationData | undefined>
+ visibleConfigurationKeys: ComputedRef<ConfigurationKey[]>
}
/**
- * Resolves a charging station from the store by hash id and derives its read-only
- * "Show details" view model (detail sections + OCPP configuration rows). Shared by both
- * skins so the reactive lookup and view-model wiring stay single-sourced. The view stays
- * reactive to store updates and degrades to `undefined`/empty when the station is removed.
+ * Resolves a charging station from the store by hash id and derives its "Show details"
+ * view model: detail sections, the OCPP configuration rows for display, and the visible
+ * configuration keys the change-configuration form operates on. Shared by both skins so the
+ * reactive lookup and view-model wiring stay single-sourced. The view stays reactive to
+ * store updates and degrades to `undefined`/empty when the station is removed.
* @param hashId - The charging station hash identifier
- * @returns The resolved station and its derived detail sections and configuration rows
+ * @returns The resolved station with its detail sections, configuration rows and visible keys
*/
export function useStationDetails (hashId: string): StationDetailsView {
const $chargingStations = useChargingStations()
station.value != null ? buildConfigurationRows(station.value) : []
)
+ const visibleConfigurationKeys = computed(() =>
+ station.value != null ? getVisibleConfigurationKeys(station.value) : []
+ )
+
return {
configurationRows,
sections,
station,
+ visibleConfigurationKeys,
}
}
export {
buildConfigurationRows,
buildStationDetailSections,
+ formatBoolean,
getVisibleConfigurationKeys,
} from './stationDetails.js'
export type { StatusVariant } from './stationStatus.js'
* @param value - The raw boolean flag
* @returns "Yes" or "No"
*/
-const formatBoolean = (value: boolean | undefined): string => (value === true ? 'Yes' : 'No')
+export const formatBoolean = (value: boolean | undefined): string => (value === true ? 'Yes' : 'No')
/**
* Formats a date-like station field for display.
--- /dev/null
+<template>
+ <h1 class="classic-action-header">
+ Change Configuration
+ </h1>
+ <h2>{{ chargingStationId }}</h2>
+ <template v-if="station == null">
+ <p class="change-configuration__empty">
+ Charging station not found
+ </p>
+ <Button
+ id="action-button"
+ @click="close()"
+ >
+ Back to Charging Stations
+ </Button>
+ </template>
+ <template v-else>
+ <table class="data-table data-table--bordered change-configuration__table">
+ <caption class="data-table__caption">
+ OCPP Parameters
+ </caption>
+ <thead class="data-table__head">
+ <tr>
+ <th scope="col">
+ Key
+ </th>
+ <th scope="col">
+ Value
+ </th>
+ <th scope="col">
+ Readonly
+ </th>
+ <th scope="col">
+ Reboot
+ </th>
+ <th scope="col">
+ Action
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-if="visibleConfigurationKeys.length === 0">
+ <td colspan="5">
+ No OCPP parameters reported
+ </td>
+ </tr>
+ <tr
+ v-for="configurationKey in visibleConfigurationKeys"
+ :key="configurationKey.key"
+ >
+ <th scope="row">
+ {{ configurationKey.key }}
+ </th>
+ <td>
+ <input
+ v-model="draftValues[configurationKey.key]"
+ :aria-label="`Value for ${configurationKey.key}`"
+ class="change-configuration__input"
+ :disabled="configurationKey.readonly || pending.has(configurationKey.key)"
+ :name="`configuration-value-${configurationKey.key}`"
+ type="text"
+ >
+ </td>
+ <td>{{ formatBoolean(configurationKey.readonly) }}</td>
+ <td>{{ formatBoolean(configurationKey.reboot) }}</td>
+ <td>
+ <Button
+ :aria-busy="pending.has(configurationKey.key) || undefined"
+ :aria-label="`Save ${configurationKey.key}`"
+ :disabled="configurationKey.readonly || pending.has(configurationKey.key)"
+ @click="save(configurationKey)"
+ >
+ Save
+ </Button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <Button
+ id="action-button"
+ @click="close()"
+ >
+ Back to Charging Stations
+ </Button>
+ </template>
+</template>
+
+<script setup lang="ts">
+import { useRouter } from 'vue-router'
+
+import { resetToggleButtonState, ROUTE_NAMES } from '@/core/index.js'
+import { useChangeConfigurationForm } from '@/shared/composables/useChangeConfigurationForm.js'
+import { useStationDetails } from '@/shared/composables/useStationDetails.js'
+import { formatBoolean } from '@/shared/utils/index.js'
+
+import Button from '../buttons/ClassicButton.vue'
+
+const props = defineProps<{
+ chargingStationId: string
+ hashId: string
+}>()
+
+const $router = useRouter()
+
+const { station, visibleConfigurationKeys } = useStationDetails(props.hashId)
+const { draftValues, pending, save } = useChangeConfigurationForm(
+ props.hashId,
+ visibleConfigurationKeys
+)
+
+const close = (): void => {
+ resetToggleButtonState(`${props.hashId}-change-configuration`, true)
+ $router.push({ name: ROUTE_NAMES.CHARGING_STATIONS }).catch(() => undefined)
+}
+</script>
+
+<style scoped>
+/* Bound the width: the shared action container is `min-width: max-content`,
+ * so uncapped this table would grow it to fill the main area. */
+.change-configuration__table {
+ width: 40rem;
+ margin-bottom: var(--spacing-lg);
+}
+
+.change-configuration__table :is(th, td) {
+ text-align: left;
+ vertical-align: top;
+ overflow-wrap: anywhere;
+}
+
+.change-configuration__table th[scope='row'] {
+ font-weight: bold;
+ background-color: var(--color-bg-header);
+ border-right: solid 0.25px var(--color-border);
+}
+
+.change-configuration__input {
+ width: 100%;
+}
+
+.change-configuration__empty {
+ text-align: center;
+}
+</style>
>
Show Details
</ToggleButton>
+ <ToggleButton
+ :id="`${chargingStation.stationInfo.hashId}-change-configuration`"
+ :off="
+ () => {
+ $router.push({ name: ROUTE_NAMES.CHARGING_STATIONS }).catch(() => undefined)
+ }
+ "
+ :on="
+ () => {
+ $router
+ .push({
+ name: ROUTE_NAMES.CHANGE_CONFIGURATION,
+ params: {
+ hashId: chargingStation.stationInfo.hashId,
+ chargingStationId: chargingStation.stationInfo.chargingStationId,
+ },
+ })
+ .catch(() => undefined)
+ }
+ "
+ :shared="true"
+ @clicked="$emit('need-refresh')"
+ >
+ Change Configuration
+ </ToggleButton>
<Button @click="deleteChargingStation()">
Delete Charging Station
</Button>
:key="station.stationInfo.hashId"
:charging-station="station"
@open-authorize="openAuthorizeDialog"
+ @open-change-config="openChangeConfigDialog"
@open-details="openDetailsDialog"
@open-set-url="openSetUrlDialog"
@open-start-tx="openStartTxDialog"
:charging-station-id="showDetailsDialog.chargingStationId"
@close="showDetailsDialog = null"
/>
+ <ChangeConfigurationDialog
+ v-if="showChangeConfigDialog"
+ :hash-id="showChangeConfigDialog.hashId"
+ :charging-station-id="showChangeConfigDialog.chargingStationId"
+ @close="showChangeConfigDialog = null"
+ />
</main>
</template>
() => import('./components/dialogs/AddStationsDialog.vue')
)
const AuthorizeDialog = defineAsyncDialog(() => import('./components/dialogs/AuthorizeDialog.vue'))
+const ChangeConfigurationDialog = defineAsyncDialog(
+ () => import('./components/dialogs/ChangeConfigurationDialog.vue')
+)
const SetSupervisionUrlDialog = defineAsyncDialog(
() => import('./components/dialogs/SetSupervisionUrlDialog.vue')
)
chargingStationId: string
hashId: string
}>(null)
+const showChangeConfigDialog = ref<null | {
+ chargingStationId: string
+ hashId: string
+}>(null)
const confirmStopSimulator = (): void => {
stopSimulator()
const openAuthorizeDialog = (data: typeof showAuthorizeDialog.value): void => {
showAuthorizeDialog.value = data
}
+const openChangeConfigDialog = (data: typeof showChangeConfigDialog.value): void => {
+ showChangeConfigDialog.value = data
+}
const openDetailsDialog = (data: typeof showDetailsDialog.value): void => {
showDetailsDialog.value = data
}
>
Details
</ActionButton>
+ <ActionButton
+ variant="ghost"
+ @click="emitOpenChangeConfig"
+ >
+ Configuration
+ </ActionButton>
</div>
<ActionButton
variant="danger"
const emit = defineEmits<{
'open-authorize': [data: { chargingStationId: string; hashId: string; ocppVersion?: OCPPVersion }]
+ 'open-change-config': [data: { chargingStationId: string; hashId: string }]
'open-details': [data: { chargingStationId: string; hashId: string }]
'open-set-url': [data: { chargingStationId: string; hashId: string }]
'open-start-tx': [
})
}
+const emitOpenChangeConfig = (): void => {
+ emit('open-change-config', {
+ chargingStationId: props.chargingStation.stationInfo.chargingStationId,
+ hashId: props.chargingStation.stationInfo.hashId,
+ })
+}
+
const handleDeleteStation = (): void => {
const hashId = props.chargingStation.stationInfo.hashId
deleteStation(hashId, () => {
--- /dev/null
+<template>
+ <Modal
+ :title="`Change configuration — ${chargingStationId}`"
+ @close="close"
+ >
+ <p
+ v-if="station == null"
+ class="change-configuration__empty"
+ >
+ Charging station not found
+ </p>
+ <p
+ v-else-if="visibleConfigurationKeys.length === 0"
+ class="change-configuration__empty"
+ >
+ No OCPP parameters reported
+ </p>
+ <table
+ v-else
+ class="change-configuration__table"
+ >
+ <caption class="change-configuration__caption">
+ OCPP Parameters
+ </caption>
+ <thead>
+ <tr>
+ <th scope="col">
+ Key
+ </th>
+ <th scope="col">
+ Value
+ </th>
+ <th scope="col">
+ Readonly
+ </th>
+ <th scope="col">
+ Reboot
+ </th>
+ <th scope="col">
+ Action
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr
+ v-for="configurationKey in visibleConfigurationKeys"
+ :key="configurationKey.key"
+ >
+ <th scope="row">
+ {{ configurationKey.key }}
+ </th>
+ <td>
+ <input
+ v-model="draftValues[configurationKey.key]"
+ :aria-label="`Value for ${configurationKey.key}`"
+ class="change-configuration__input"
+ :disabled="configurationKey.readonly || pending.has(configurationKey.key)"
+ type="text"
+ >
+ </td>
+ <td>{{ formatBoolean(configurationKey.readonly) }}</td>
+ <td>{{ formatBoolean(configurationKey.reboot) }}</td>
+ <td>
+ <ActionButton
+ :aria-label="`Save ${configurationKey.key}`"
+ :disabled="configurationKey.readonly"
+ :pending="pending.has(configurationKey.key)"
+ variant="primary"
+ @click="save(configurationKey)"
+ >
+ Save
+ </ActionButton>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <template #footer>
+ <ActionButton
+ variant="ghost"
+ @click="close"
+ >
+ Close
+ </ActionButton>
+ </template>
+ </Modal>
+</template>
+
+<script setup lang="ts">
+import { useChangeConfigurationForm } from '@/shared/composables/useChangeConfigurationForm.js'
+import { useStationDetails } from '@/shared/composables/useStationDetails.js'
+import { formatBoolean } from '@/shared/utils/index.js'
+
+import ActionButton from '../ActionButton.vue'
+import Modal from '../ModernModal.vue'
+
+const props = defineProps<{
+ chargingStationId: string
+ hashId: string
+}>()
+
+const emit = defineEmits<{ close: [] }>()
+
+const { station, visibleConfigurationKeys } = useStationDetails(props.hashId)
+const { draftValues, pending, save } = useChangeConfigurationForm(
+ props.hashId,
+ visibleConfigurationKeys
+)
+
+const close = (): void => {
+ emit('close')
+}
+</script>
+
+<style scoped>
+.change-configuration__table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8125rem;
+}
+
+.change-configuration__caption {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--color-text-muted);
+ text-align: left;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding-bottom: var(--skin-space-2);
+}
+
+.change-configuration__table th,
+.change-configuration__table td {
+ padding: var(--skin-space-1) var(--skin-space-2);
+ text-align: left;
+}
+
+.change-configuration__table td,
+.change-configuration__table th[scope='row'] {
+ word-break: break-word;
+}
+
+.change-configuration__table thead th {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ white-space: nowrap;
+ border-bottom: 1px solid var(--skin-border);
+}
+
+.change-configuration__table th[scope='row'] {
+ font-weight: 600;
+ color: var(--color-text-strong);
+}
+
+.change-configuration__table tbody tr {
+ border-bottom: 1px solid var(--skin-border);
+}
+
+.change-configuration__input {
+ width: 100%;
+}
+
+.change-configuration__empty {
+ margin: 0;
+ color: var(--color-text-muted);
+}
+</style>
import {
type ChargingStationData,
type ChargingStationInfo,
+ type ConfigurationKey,
type ConnectorStatus,
DEFAULT_HOST,
DEFAULT_PORT,
}
}
+/**
+ * Creates a ChargingStationData fixture carrying the given OCPP configuration keys.
+ * @param configurationKey - OCPP configuration keys to seed
+ * @returns ChargingStationData fixture with the given configuration keys
+ */
+export function createStationWithConfigurationKeys (
+ configurationKey: ConfigurationKey[]
+): ChargingStationData {
+ return createChargingStationData({ ocppConfiguration: { configurationKey } })
+}
+
/**
* Creates a UIServerConfigurationSection fixture with sensible defaults.
* @param overrides - Optional partial overrides for the fixture
export interface MockUIClient {
addChargingStations: ReturnType<typeof vi.fn>
authorize: ReturnType<typeof vi.fn>
+ changeConfiguration: ReturnType<typeof vi.fn>
closeConnection: ReturnType<typeof vi.fn>
deleteChargingStation: ReturnType<typeof vi.fn>
isConnected: ReturnType<typeof vi.fn>
return {
addChargingStations: vi.fn().mockResolvedValue(successResponse),
authorize: vi.fn().mockResolvedValue(successResponse),
+ changeConfiguration: vi.fn().mockResolvedValue(successResponse),
closeConnection: vi.fn().mockResolvedValue(successResponse),
deleteChargingStation: vi.fn().mockResolvedValue(successResponse),
isConnected: vi.fn().mockReturnValue(false),
getVisibleConfigurationKeys,
} from '@/shared/utils/index.js'
-import { createChargingStationData, createStationInfo } from '../../constants.js'
+import {
+ createChargingStationData,
+ createStationInfo,
+ createStationWithConfigurationKeys,
+} from '../../constants.js'
/**
* Reads a formatted entry value from built sections.
describe('getVisibleConfigurationKeys', () => {
it('should exclude keys explicitly marked not visible', () => {
const keys = getVisibleConfigurationKeys(
- createChargingStationData({
- ocppConfiguration: {
- configurationKey: [
- { key: 'Visible', readonly: false, value: 'a' },
- { key: 'Shown', readonly: true, value: 'b', visible: true },
- { key: 'Hidden', readonly: false, value: 'c', visible: false },
- ],
- },
- })
+ createStationWithConfigurationKeys([
+ { key: 'Visible', readonly: false, value: 'a' },
+ { key: 'Shown', readonly: true, value: 'b', visible: true },
+ { key: 'Hidden', readonly: false, value: 'c', visible: false },
+ ])
)
expect(keys.map(key => key.key)).toEqual(['Visible', 'Shown'])
})
describe('buildConfigurationRows', () => {
it('should format readonly, reboot and missing value for display', () => {
const rows = buildConfigurationRows(
- createChargingStationData({
- ocppConfiguration: {
- configurationKey: [{ key: 'HeartbeatInterval', readonly: true, reboot: true }],
- },
- })
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: true, reboot: true },
+ ])
)
expect(rows).toEqual([
{
it('should format non-readonly, non-reboot keys with their value', () => {
const rows = buildConfigurationRows(
- createChargingStationData({
- ocppConfiguration: {
- configurationKey: [{ key: 'MeterValueSampleInterval', readonly: false, value: '60' }],
- },
- })
+ createStationWithConfigurationKeys([
+ { key: 'MeterValueSampleInterval', readonly: false, value: '60' },
+ ])
)
expect(rows).toEqual([
{ key: 'MeterValueSampleInterval', readonly: 'No', reboot: 'No', value: '60' },
it('should render an empty-string value as the empty placeholder', () => {
const rows = buildConfigurationRows(
- createChargingStationData({
- ocppConfiguration: {
- configurationKey: [{ key: 'BlankValue', readonly: false, value: '' }],
- },
- })
+ createStationWithConfigurationKeys([{ key: 'BlankValue', readonly: false, value: '' }])
)
expect(rows[0].value).toBe(EMPTY_VALUE_PLACEHOLDER)
})
it('should exclude keys marked not visible', () => {
const rows = buildConfigurationRows(
- createChargingStationData({
- ocppConfiguration: {
- configurationKey: [
- { key: 'Shown', readonly: false, value: 'a' },
- { key: 'Hidden', readonly: false, value: 'b', visible: false },
- ],
- },
- })
+ createStationWithConfigurationKeys([
+ { key: 'Shown', readonly: false, value: 'a' },
+ { key: 'Hidden', readonly: false, value: 'b', visible: false },
+ ])
)
expect(rows.map(row => row.key)).toEqual(['Shown'])
})
--- /dev/null
+/**
+ * @file Tests for useChangeConfigurationForm composable
+ * @description Tests for the shared per-key OCPP configuration change composable.
+ */
+import type { ConfigurationKey } from 'ui-common'
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { nextTick, ref } from 'vue'
+
+import { toastMock } from '../../../setup.js'
+
+const mockChangeConfiguration = vi.fn().mockResolvedValue({ status: 'success' })
+
+vi.mock('@/core/index.js', () => ({
+ useUIClient: () => ({
+ changeConfiguration: mockChangeConfiguration,
+ }),
+}))
+import { useChangeConfigurationForm } from '@/shared/composables/useChangeConfigurationForm.js'
+
+const writableKey: ConfigurationKey = {
+ key: 'MeterValueSampleInterval',
+ readonly: false,
+ value: '60',
+}
+const readonlyKey: ConfigurationKey = {
+ key: 'SupportedFeatureProfiles',
+ readonly: true,
+ value: 'Core',
+}
+const rebootKey: ConfigurationKey = {
+ key: 'AuthorizationKey',
+ readonly: false,
+ reboot: true,
+ value: 'abc',
+}
+
+describe('useChangeConfigurationForm', () => {
+ afterEach(() => {
+ vi.clearAllMocks()
+ mockChangeConfiguration.mockResolvedValue({ status: 'success' })
+ })
+
+ it('should seed draft values from the visible keys', () => {
+ const keys = ref<ConfigurationKey[]>([writableKey, rebootKey])
+ const { draftValues } = useChangeConfigurationForm('hash1', keys)
+ expect(draftValues).toEqual({ AuthorizationKey: 'abc', MeterValueSampleInterval: '60' })
+ })
+
+ it('should submit a writable key draft and toast success', async () => {
+ const keys = ref<ConfigurationKey[]>([writableKey])
+ const { draftValues, save } = useChangeConfigurationForm('hash1', keys)
+ draftValues.MeterValueSampleInterval = '30'
+ const result = await save(writableKey)
+ expect(result).toBe(true)
+ expect(mockChangeConfiguration).toHaveBeenCalledWith('hash1', 'MeterValueSampleInterval', '30')
+ expect(toastMock.success).toHaveBeenCalledWith(
+ "Configuration key 'MeterValueSampleInterval' successfully set"
+ )
+ })
+
+ it('should surface a reboot-required notice for a reboot key', async () => {
+ const keys = ref<ConfigurationKey[]>([rebootKey])
+ const { save } = useChangeConfigurationForm('hash1', keys)
+ await save(rebootKey)
+ expect(toastMock.success).toHaveBeenCalledWith(
+ "Configuration key 'AuthorizationKey' set, reboot required to take effect"
+ )
+ })
+
+ it('should never submit a readonly key', async () => {
+ const keys = ref<ConfigurationKey[]>([readonlyKey])
+ const { save } = useChangeConfigurationForm('hash1', keys)
+ const result = await save(readonlyKey)
+ expect(result).toBe(false)
+ expect(mockChangeConfiguration).not.toHaveBeenCalled()
+ })
+
+ it('should return false and toast error when the change is rejected', async () => {
+ mockChangeConfiguration.mockRejectedValueOnce(new Error('rejected'))
+ const keys = ref<ConfigurationKey[]>([writableKey])
+ const { save } = useChangeConfigurationForm('hash1', keys)
+ const result = await save(writableKey)
+ expect(result).toBe(false)
+ expect(toastMock.error).toHaveBeenCalledWith(
+ "Error at setting configuration key 'MeterValueSampleInterval'"
+ )
+ })
+
+ it('should short-circuit a concurrent save for the same key', async () => {
+ let resolveChange: ((value: { status: string }) => void) | undefined
+ mockChangeConfiguration.mockReturnValueOnce(
+ new Promise<{ status: string }>(resolve => {
+ resolveChange = resolve
+ })
+ )
+ const keys = ref<ConfigurationKey[]>([writableKey])
+ const { pending, save } = useChangeConfigurationForm('hash1', keys)
+
+ const first = save(writableKey)
+ expect(pending.has('MeterValueSampleInterval')).toBe(true)
+ const second = await save(writableKey)
+ expect(second).toBe(false)
+ expect(mockChangeConfiguration).toHaveBeenCalledTimes(1)
+
+ resolveChange?.({ status: 'success' })
+ await first
+ expect(pending.has('MeterValueSampleInterval')).toBe(false)
+ })
+
+ it('should preserve user draft input when new keys arrive', async () => {
+ const keys = ref<ConfigurationKey[]>([writableKey])
+ const { draftValues } = useChangeConfigurationForm('hash1', keys)
+ draftValues.MeterValueSampleInterval = 'edited'
+ keys.value = [writableKey, rebootKey]
+ await nextTick()
+ expect(draftValues.MeterValueSampleInterval).toBe('edited')
+ expect(draftValues.AuthorizationKey).toBe('abc')
+ })
+})
useStationDetails,
} from '@/shared/composables/useStationDetails.js'
-import { createChargingStationData, TEST_HASH_ID } from '../../constants.js'
+import {
+ createChargingStationData,
+ createStationWithConfigurationKeys,
+ TEST_HASH_ID,
+} from '../../constants.js'
/**
* Mounts a throwaway component that runs useStationDetails with the provided store.
describe('useStationDetails', () => {
it('should resolve the station and derive its sections and configuration rows', () => {
const stations = ref([
- createChargingStationData({
- ocppConfiguration: {
- configurationKey: [{ key: 'HeartbeatInterval', readonly: false, value: '30' }],
- },
- }),
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ]),
])
const { configurationRows, sections, station } = runComposable(stations, TEST_HASH_ID)
expect(station.value?.stationInfo.hashId).toBe(TEST_HASH_ID)
])
})
+ it('should expose the visible raw configuration keys', () => {
+ const stations = ref([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ { key: 'SecretKey', readonly: true, value: 'x', visible: false },
+ ]),
+ ])
+ const { visibleConfigurationKeys } = runComposable(stations, TEST_HASH_ID)
+ expect(visibleConfigurationKeys.value).toEqual([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ])
+ })
+
it('should return an undefined station and empty derived data for an unknown hashId', () => {
const stations = ref([createChargingStationData()])
const { configurationRows, sections, station } = runComposable(stations, 'unknown-hash')
import { flushPromises, mount } from '@vue/test-utils'
import { OCPPVersion } from 'ui-common'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { ref, shallowRef } from 'vue'
+import { nextTick, ref, shallowRef } from 'vue'
import {
chargingStationsKey,
uiClientKey,
} from '@/core/index.js'
import AddChargingStations from '@/skins/classic/components/actions/AddChargingStations.vue'
+import ChangeConfiguration from '@/skins/classic/components/actions/ChangeConfiguration.vue'
import SetSupervisionUrl from '@/skins/classic/components/actions/SetSupervisionUrl.vue'
import ShowDetails from '@/skins/classic/components/actions/ShowDetails.vue'
import StartTransaction from '@/skins/classic/components/actions/StartTransaction.vue'
import {
createChargingStationData,
createStationInfo,
+ createStationWithConfigurationKeys,
createUIServerConfig,
TEST_HASH_ID,
TEST_STATION_ID,
})
it('should render the empty message when no OCPP parameters are reported', () => {
- const wrapper = mountShowDetails([
- createChargingStationData({ ocppConfiguration: { configurationKey: [] } }),
- ])
+ const wrapper = mountShowDetails([createStationWithConfigurationKeys([])])
expect(wrapper.text()).toContain('No OCPP parameters reported')
})
it('should format OCPP readonly, reboot and missing value cells', () => {
const wrapper = mountShowDetails([
- createChargingStationData({
- ocppConfiguration: {
- configurationKey: [{ key: 'RebootKey', readonly: true, reboot: true }],
- },
- }),
+ createStationWithConfigurationKeys([{ key: 'RebootKey', readonly: true, reboot: true }]),
])
const tables = wrapper.findAll('table.data-table')
const ocppTable = tables[tables.length - 1]
expect(mockPush).toHaveBeenCalledWith({ name: 'charging-stations' })
})
})
+
+ describe('ChangeConfiguration', () => {
+ beforeEach(() => {
+ mockClient = createMockUIClient()
+ mockPush.mockClear()
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ vi.restoreAllMocks()
+ })
+
+ /**
+ * Mounts ChangeConfiguration with a provided store.
+ * @param stations - Charging stations to seed the store with
+ * @returns Mounted ChangeConfiguration wrapper
+ */
+ function mountChange (stations = [createChargingStationData()]) {
+ return mount(ChangeConfiguration, {
+ global: {
+ provide: {
+ [chargingStationsKey as symbol]: shallowRef(stations),
+ [uiClientKey as symbol]: mockClient,
+ },
+ stubs: { Button: ButtonStub },
+ },
+ props: {
+ chargingStationId: TEST_STATION_ID,
+ hashId: TEST_HASH_ID,
+ },
+ })
+ }
+
+ it('should render the heading and station id', () => {
+ const wrapper = mountChange()
+ expect(wrapper.find('h1').text()).toBe('Change Configuration')
+ expect(wrapper.find('h2').text()).toBe(TEST_STATION_ID)
+ })
+
+ it('should render a not-found panel and navigate away when the station is absent', async () => {
+ const wrapper = mountChange([])
+ expect(wrapper.text()).toContain('Charging station not found')
+ await wrapper.findComponent(ButtonStub).trigger('click')
+ await flushPromises()
+ expect(mockPush).toHaveBeenCalledWith({ name: 'charging-stations' })
+ })
+
+ it('should render the empty message when no OCPP parameters are reported', () => {
+ const wrapper = mountChange([createStationWithConfigurationKeys([])])
+ expect(wrapper.text()).toContain('No OCPP parameters reported')
+ })
+
+ it('should exclude keys explicitly marked not visible', () => {
+ const wrapper = mountChange([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ { key: 'HiddenKey', readonly: false, value: 'x', visible: false },
+ ]),
+ ])
+ expect(wrapper.find('input[name="configuration-value-HeartbeatInterval"]').exists()).toBe(
+ true
+ )
+ expect(wrapper.find('input[name="configuration-value-HiddenKey"]').exists()).toBe(false)
+ })
+
+ it('should prefill inputs and disable read-only keys', () => {
+ const wrapper = mountChange([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ { key: 'SecretKey', readonly: true, value: 'x' },
+ ]),
+ ])
+ const editable = wrapper.find<HTMLInputElement>(
+ 'input[name="configuration-value-HeartbeatInterval"]'
+ )
+ expect(editable.element.value).toBe('30')
+ expect(editable.attributes('disabled')).toBeUndefined()
+ expect(
+ wrapper.find('input[name="configuration-value-SecretKey"]').attributes('disabled')
+ ).toBeDefined()
+ })
+
+ it('should call changeConfiguration and toast success on save', async () => {
+ const wrapper = mountChange([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ]),
+ ])
+ await wrapper.find('input[name="configuration-value-HeartbeatInterval"]').setValue('45')
+ await wrapper.findAllComponents(ButtonStub)[0].trigger('click')
+ await flushPromises()
+ expect(mockClient.changeConfiguration).toHaveBeenCalledWith(
+ TEST_HASH_ID,
+ 'HeartbeatInterval',
+ '45'
+ )
+ expect(toastMock.success).toHaveBeenCalledWith(
+ "Configuration key 'HeartbeatInterval' successfully set"
+ )
+ })
+
+ it('should surface a reboot-required notice for reboot keys', async () => {
+ const wrapper = mountChange([
+ createStationWithConfigurationKeys([
+ { key: 'RebootKey', readonly: false, reboot: true, value: '1' },
+ ]),
+ ])
+ await wrapper.findAllComponents(ButtonStub)[0].trigger('click')
+ await flushPromises()
+ expect(toastMock.success).toHaveBeenCalledWith(
+ "Configuration key 'RebootKey' set, reboot required to take effect"
+ )
+ })
+
+ it('should not submit read-only keys', async () => {
+ const wrapper = mountChange([
+ createStationWithConfigurationKeys([{ key: 'SecretKey', readonly: true, value: 'x' }]),
+ ])
+ await wrapper.findAllComponents(ButtonStub)[0].trigger('click')
+ await flushPromises()
+ expect(mockClient.changeConfiguration).not.toHaveBeenCalled()
+ })
+
+ it('should toast an error when the backend rejects the change', async () => {
+ mockClient.changeConfiguration = vi.fn().mockRejectedValue(new Error('boom'))
+ const wrapper = mountChange([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ]),
+ ])
+ await wrapper.findAllComponents(ButtonStub)[0].trigger('click')
+ await flushPromises()
+ expect(toastMock.error).toHaveBeenCalledWith(
+ "Error at setting configuration key 'HeartbeatInterval'"
+ )
+ })
+
+ it('should render Yes/No cells for the readonly and reboot flags', () => {
+ const wrapper = mountChange([
+ createStationWithConfigurationKeys([
+ { key: 'ReadonlyKey', readonly: true, reboot: false, value: '1' },
+ { key: 'RebootKey', readonly: false, reboot: true, value: '2' },
+ ]),
+ ])
+ const readonlyCells = wrapper.findAll('tbody tr')[0].findAll('td')
+ expect(readonlyCells[1].text()).toBe('Yes')
+ expect(readonlyCells[2].text()).toBe('No')
+ const rebootCells = wrapper.findAll('tbody tr')[1].findAll('td')
+ expect(rebootCells[1].text()).toBe('No')
+ expect(rebootCells[2].text()).toBe('Yes')
+ })
+
+ it('should name the OCPP parameters table via its caption', () => {
+ const wrapper = mountChange([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ]),
+ ])
+ expect(wrapper.find('.change-configuration__table caption').text()).toBe('OCPP Parameters')
+ })
+
+ it('should mark the Save button busy while a change is in flight', async () => {
+ // Promise.withResolvers is unavailable under this project's TS lib (@vue/tsconfig overrides
+ // node24's es2024 lib -> TS2550), so a captured-resolver Promise is used instead.
+ let resolveChange: () => void = () => undefined
+ mockClient.changeConfiguration = vi.fn().mockReturnValue(
+ new Promise<void>(resolve => {
+ resolveChange = resolve
+ })
+ )
+ const wrapper = mountChange([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ]),
+ ])
+ const save = wrapper.findAllComponents(ButtonStub)[0]
+ await save.trigger('click')
+ await nextTick()
+ expect(save.attributes('aria-busy')).toBe('true')
+ resolveChange()
+ await flushPromises()
+ expect(save.attributes('aria-busy')).toBeUndefined()
+ })
+ })
})
ServerFailureError,
} from 'ui-common'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { defineComponent, ref } from 'vue'
+import { defineComponent, nextTick, ref } from 'vue'
import {
chargingStationsKey,
import AddStationsDialog from '@/skins/modern/components/dialogs/AddStationsDialog.vue'
import AuthorizeDialog from '@/skins/modern/components/dialogs/AuthorizeDialog.vue'
+import ChangeConfigurationDialog from '@/skins/modern/components/dialogs/ChangeConfigurationDialog.vue'
import SetConnectorStatusDialog from '@/skins/modern/components/dialogs/SetConnectorStatusDialog.vue'
import SetSupervisionUrlDialog from '@/skins/modern/components/dialogs/SetSupervisionUrlDialog.vue'
import ShowDetailsDialog from '@/skins/modern/components/dialogs/ShowDetailsDialog.vue'
import {
createChargingStationData,
createStationInfo,
+ createStationWithConfigurationKeys,
TEST_HASH_ID,
TEST_STATION_ID,
} from '../../constants.js'
it('should render OCPP parameter rows from the configuration keys', () => {
const wrapper = mountDialog([
- createChargingStationData({
- ocppConfiguration: {
- configurationKey: [{ key: 'HeartbeatInterval', readonly: false, value: '30' }],
- },
- }),
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ]),
])
expect(wrapper.text()).toContain('HeartbeatInterval')
expect(wrapper.text()).toContain('30')
it('should format OCPP readonly, reboot and missing value cells', () => {
const wrapper = mountDialog([
- createChargingStationData({
- ocppConfiguration: {
- configurationKey: [{ key: 'RebootKey', readonly: true, reboot: true }],
- },
- }),
+ createStationWithConfigurationKeys([{ key: 'RebootKey', readonly: true, reboot: true }]),
])
const row = wrapper
.findAll('.station-details__table tbody tr')
})
it('should render the empty message when no OCPP parameters are reported', () => {
- const wrapper = mountDialog([
- createChargingStationData({ ocppConfiguration: { configurationKey: [] } }),
- ])
+ const wrapper = mountDialog([createStationWithConfigurationKeys([])])
expect(wrapper.text()).toContain('No OCPP parameters reported')
})
it('should give the OCPP parameters table an accessible name', () => {
const wrapper = mountDialog([
- createChargingStationData({
- ocppConfiguration: {
- configurationKey: [{ key: 'HeartbeatInterval', readonly: false, value: '30' }],
- },
- }),
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ]),
])
const labelledBy = wrapper.find('.station-details__table').attributes('aria-labelledby') ?? ''
expect(labelledBy).not.toBe('')
expect(wrapper.emitted('close')).toHaveLength(1)
})
})
+
+ describe('ChangeConfigurationDialog', () => {
+ /**
+ * @param stations - Charging station data to provide to the dialog
+ * @returns Mounted wrapper for ChangeConfigurationDialog
+ */
+ function mountDialog (stations = [createChargingStationData()]) {
+ return mount(ChangeConfigurationDialog, {
+ global: {
+ provide: {
+ [chargingStationsKey as symbol]: ref(stations),
+ [uiClientKey as symbol]: mockClient,
+ },
+ },
+ props: { chargingStationId: TEST_STATION_ID, hashId: TEST_HASH_ID },
+ })
+ }
+
+ it('should render the title with the station id', () => {
+ const wrapper = mountDialog()
+ expect(wrapper.text()).toContain(`Change configuration — ${TEST_STATION_ID}`)
+ })
+
+ it('should render a not-found message when the station is absent', () => {
+ const wrapper = mountDialog([])
+ expect(wrapper.text()).toContain('Charging station not found')
+ })
+
+ it('should render the empty message when no OCPP parameters are reported', () => {
+ const wrapper = mountDialog([createStationWithConfigurationKeys([])])
+ expect(wrapper.text()).toContain('No OCPP parameters reported')
+ })
+
+ it('should prefill inputs and disable read-only keys', () => {
+ const wrapper = mountDialog([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ { key: 'SecretKey', readonly: true, value: 'x' },
+ ]),
+ ])
+ const editable = wrapper.find<HTMLInputElement>('[aria-label="Value for HeartbeatInterval"]')
+ expect(editable.element.value).toBe('30')
+ expect(editable.attributes('disabled')).toBeUndefined()
+ expect(
+ wrapper.find('[aria-label="Value for SecretKey"]').attributes('disabled')
+ ).toBeDefined()
+ expect(wrapper.find('[aria-label="Save SecretKey"]').attributes('disabled')).toBeDefined()
+ })
+
+ it('should call changeConfiguration and toast success on save', async () => {
+ const wrapper = mountDialog([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ]),
+ ])
+ await wrapper.find('[aria-label="Value for HeartbeatInterval"]').setValue('45')
+ await wrapper.find('[aria-label="Save HeartbeatInterval"]').trigger('click')
+ await flushPromises()
+ expect(mockClient.changeConfiguration).toHaveBeenCalledWith(
+ TEST_HASH_ID,
+ 'HeartbeatInterval',
+ '45'
+ )
+ expect(toastMock.success).toHaveBeenCalledWith(
+ "Configuration key 'HeartbeatInterval' successfully set"
+ )
+ })
+
+ it('should render Yes/No cells for the readonly and reboot flags', () => {
+ const wrapper = mountDialog([
+ createStationWithConfigurationKeys([
+ { key: 'ReadonlyKey', readonly: true, reboot: false, value: '1' },
+ { key: 'RebootKey', readonly: false, reboot: true, value: '2' },
+ ]),
+ ])
+ const readonlyCells = wrapper.findAll('tbody tr')[0].findAll('td')
+ expect(readonlyCells[1].text()).toBe('Yes')
+ expect(readonlyCells[2].text()).toBe('No')
+ const rebootCells = wrapper.findAll('tbody tr')[1].findAll('td')
+ expect(rebootCells[1].text()).toBe('No')
+ expect(rebootCells[2].text()).toBe('Yes')
+ })
+
+ it('should name the OCPP parameters table via its caption', () => {
+ const wrapper = mountDialog([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ]),
+ ])
+ expect(wrapper.find('.change-configuration__table caption').text()).toBe('OCPP Parameters')
+ })
+
+ it('should mark the Save button busy while a change is in flight', async () => {
+ // Promise.withResolvers is unavailable under this project's TS lib (@vue/tsconfig overrides
+ // node24's es2024 lib -> TS2550), so a captured-resolver Promise is used instead.
+ let resolveChange: () => void = () => undefined
+ mockClient.changeConfiguration = vi.fn().mockReturnValue(
+ new Promise<void>(resolve => {
+ resolveChange = resolve
+ })
+ )
+ const wrapper = mountDialog([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ]),
+ ])
+ const save = wrapper.find('[aria-label="Save HeartbeatInterval"]')
+ await save.trigger('click')
+ await nextTick()
+ expect(save.attributes('aria-busy')).toBe('true')
+ resolveChange()
+ await flushPromises()
+ expect(save.attributes('aria-busy')).toBeUndefined()
+ })
+
+ it('should surface a reboot-required notice for reboot keys', async () => {
+ const wrapper = mountDialog([
+ createStationWithConfigurationKeys([
+ { key: 'RebootKey', readonly: false, reboot: true, value: '1' },
+ ]),
+ ])
+ await wrapper.find('[aria-label="Save RebootKey"]').trigger('click')
+ await flushPromises()
+ expect(toastMock.success).toHaveBeenCalledWith(
+ "Configuration key 'RebootKey' set, reboot required to take effect"
+ )
+ })
+
+ it('should not submit read-only keys', async () => {
+ const wrapper = mountDialog([
+ createStationWithConfigurationKeys([{ key: 'SecretKey', readonly: true, value: 'x' }]),
+ ])
+ await wrapper.find('[aria-label="Save SecretKey"]').trigger('click')
+ await flushPromises()
+ expect(mockClient.changeConfiguration).not.toHaveBeenCalled()
+ })
+
+ it('should toast an error when the backend rejects the change', async () => {
+ mockClient.changeConfiguration = vi.fn().mockRejectedValue(new Error('boom'))
+ const wrapper = mountDialog([
+ createStationWithConfigurationKeys([
+ { key: 'HeartbeatInterval', readonly: false, value: '30' },
+ ]),
+ ])
+ await wrapper.find('[aria-label="Save HeartbeatInterval"]').trigger('click')
+ await flushPromises()
+ expect(toastMock.error).toHaveBeenCalledWith(
+ "Error at setting configuration key 'HeartbeatInterval'"
+ )
+ })
+
+ it('should emit close when the Close button is clicked', async () => {
+ const wrapper = mountDialog()
+ await wrapper.findAll('.stub-modal__foot button')[0].trigger('click')
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+ })
})