From aa432cb88d85abf304b662691df416e1c288ef82 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 13 Aug 2026 21:37:03 +0200 Subject: [PATCH] feat(webui): allow editing charging station configuration (#2077) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(webui): allow editing charging station configuration Add a generic, data-driven editor for a charging station's current OCPP configurationKey values in the Web UI (both classic and modern skins), closing #1828. A new CHANGE_CONFIGURATION UI protocol verb clones the SET_SUPERVISION_URL chain end-to-end (UIClient -> ProcedureName/BroadcastChannelProcedureName -> AbstractUIService mapping -> worker handler). To stay OCPP spec-faithful, the change is applied through a new version-agnostic ChargingStation.changeConfiguration seam that reuses the existing OCPP 1.6 ChangeConfiguration and OCPP 2.0.1 SetVariables handler logic (readonly rejection, integer/bounds validation, heartbeat/WS-ping restarts, reboot signalling) without emitting a CSMS response, then emits ChargingStationEvents.updated so the UI refreshes. OCPP 2.0.1 configurationKey entries are now seeded with their registry mutability/rebootRequired flags, so readonly keys are correctly disabled in the UI and reboot keys are flagged. Refs: #1828 * docs(webui): mention edit configuration action * test(webui): cover changeConfiguration emit gating, spec-fidelity and OCPP 2.0.1 edge cases Address PR review coverage gaps: - assert ChargingStation.changeConfiguration emits ChargingStationEvents.updated only on Accepted/RebootRequired (drives the read-view refresh) - assert the OCPP 1.6 seam applies changes without emitting a CSMS response - add OCPP 2.0.1 seam read-only rejection and reboot-required cases * refactor(webui): address review findings for change-configuration - OCPP 2.0.1 seam: set the resolved `instance` only on the SetVariables `component` (drop the redundant `variable.instance`); the registry models the instance as component-scoped and internal resolution reads `variable.instance ?? component.instance`, so this is behavior-neutral. Add an instance-scoped non-regression test (TariffCostCtrlr.Enabled.Cost). - useStationDetails: docstring no longer claims "read-only" now that it exposes editableConfigurationKeys. - ui/web README: "edit configuration" -> "change configuration" to match the UI/code terminology. - ModernLayout: order the ChangeConfigurationDialog async declaration alphabetically and put :hash-id before :charging-station-id, matching the sibling dialogs. * refactor(webui): hoist change-configuration draft state into the shared composable Address the second-round review findings: - M1 (DRY): move the per-key draft values, seeding watch and save() from both skin components into useChangeConfigurationForm (now takes the editable keys ref and exposes { draftValues, pending, save }), mirroring useSetUrlForm which owns its form state. The classic action and modern dialog become pure UI. - M2: add direct unit tests for the worker CHANGE_CONFIGURATION handler (delegation, empty-value accepted, missing/empty/non-string key or value rejected with BaseError). - M3: align residual "edit"/"editing" wording with the "change configuration" terminology in docstrings (kept editableConfigurationKeys as a property name). * refactor(webui): validate required broadcast-channel string fields with isNotEmptyString Replace the raw `typeof x !== 'string' || isEmpty(x)` guards in the CHANGE_CONFIGURATION and SET_SUPERVISION_URL worker handlers with the `!isNotEmptyString(x)` type guard (the repo-wide idiom, ~78 usages), which also narrows the field to a non-empty string. Both handlers are switched together so the file keeps a single convention. `isEmpty` remains used for the empty-response status checks; the `value` field stays `typeof value !== 'string'` since an empty value is a legitimate configuration value. * refactor(webui): tidy change-configuration naming, reverse-map and MCP schema Third-round review nits: - M1: rename `editableConfigurationKeys` -> `visibleConfigurationKeys` (the collection includes read-only keys; the name now matches its source util getVisibleConfigurationKeys). Propagated across useStationDetails, useChangeConfigurationForm, both skin components and the tests. - M2: build OCPP20VariableManager's flat-key reverse map as a declarative private instance field (like #validComponentNames) instead of a mutable module-level for-loop; the dedup guard was inert (composite key names are unique). - T1: inline the single-caller private `submit` into `save`. - T2: enforce a non-empty `key` at the MCP option layer (z.string().min(1)); `value` stays unconstrained (empty is a valid configuration value). * test(webui): cover change-configuration components Add component tests for the classic `ChangeConfiguration.vue` action and the modern `ChangeConfigurationDialog.vue`, which were shipped without component tests and dropped ui/web coverage below the CI thresholds (the failing `Build dashboard / Node 24.x / ubuntu-latest` cell runs `pnpm test:coverage`). Tests exercise the observable contracts already verified in-browser: not-found panel, empty-state, non-visible key exclusion, read-only input/button disabling, editable save invoking `changeConfiguration`, reboot-required notice, read-only save guard and backend-rejection error toast. Adds the missing `changeConfiguration` method to the shared `MockUIClient`. Refs: #1828 * fix(ocpp): reject empty value for integer 1.6 configuration keys Address the initial-review findings on PR #2077: - T1 (fix): the OCPP 1.6 ChangeConfiguration handler accepted an empty/blank value for integer keys (Number('') === 0 passed the non-negative-integer check) and persisted the raw empty string. Reject it explicitly via isNotEmptyString, matching the spec (values not conforming to the expected integer format are Rejected). Covers both the UI seam and the OCPP wire path. Add a discriminating test (empty value -> REJECTED, value preserved). - T2 (fix): correct the misleading worker error message "'value' field is required" -> "'value' field must be a string" (an empty string is a legitimate value; only a non-string is rejected). - T3 (test): rename the composable test description "editable keys" -> "visible keys" to match the renamed visibleConfigurationKeys parameter. - M1 (docs): document, on OCPP20 changeConfiguration, that the resolved instance is carried on component.instance and is internal-only (never emitted to a CSMS), so the component- vs variable-instance distinction is immaterial here. - M2 (docs): add the missing 'changeConfiguration' ProcedureName block to the README UI protocol reference. Kept as-is with rationale: the version-agnostic seam reuses the 1.6-named ChangeConfigurationResponse type (established CommandResponse union convention, type-safe alias); the modern dialog table style follows the skin's per-component scoped convention. Cleartext config values in the edit UI are pre-existing (already exposed via LIST_CHARGING_STATIONS / MCP) and out of scope. Refs: #1828 * docs: clarify changeConfiguration value validation in the README protocol reference Address review finding TR1: the changeConfiguration block claimed "an empty string is allowed", which is only true at the message-envelope level. A value invalid for the target key (e.g. a non-integer or empty value for a numeric 1.6 key) is rejected. Reword the value clause to state that invalid values are rejected and keep it version-agnostic (the verb serves both OCPP 1.6 and 2.0.1). No code change. Refs: #1828 * test(webui): factor duplicated configuration-keys station fixture into a shared factory The `stationWithKeys` helper was duplicated verbatim in the classic and modern skin test files. Mirroring the test-factorization convention consolidated in main (PR #2078: shared test helpers, no per-file scaffolding duplication), extract it into the canonical fixture home `tests/unit/constants.ts` as `createStationWithConfigurationKeys`, and migrate both skin test suites to it. Removes the now-unused local helpers and `ConfigurationKey` imports. No behavior change (ui/web 592/592). Refs: #1828 * test(webui): migrate remaining inline config-key fixtures to the shared factory Address review finding TR1: complete the DRY migration started with createStationWithConfigurationKeys. Replace the 13 remaining inline `createChargingStationData({ ocppConfiguration: { configurationKey: … } })` sole-override fixtures across the stationDetails, useStationDetails, ShowDetails and ShowDetailsDialog test blocks with the shared factory. The empty- ocppConfiguration case (stationDetails.test.ts, tests the absent-key fallback) and all sites carrying additional overrides are intentionally left inline. No behavior change (ui/web 592/592). Refs: #1828 * docs(ocpp): tighten change-configuration rationale comments - OCPP16 integer-key guard: consolidate the two lines commenting the same guard into one coherent, accurate rationale — why Number() over convertToInt (truncates '1.5' → 1, throws on ''/'abc') and why the explicit isNotEmptyString check. Number() yields a non-integer float ('1.5' → 1.5) or NaN ('abc'), both caught by !Number.isInteger; isNotEmptyString rejects '' (Number('') === 0 would otherwise pass). - OCPP20 changeConfiguration JSDoc: remove redundancy with the delegate's JSDoc and compress the instance-placement caveat, keeping every non-derivable fact. Comment-only change; no behavior change. Refs: #1828 * refactor(webui): single-source config-key formatting and align a11y state - Export the shared formatBoolean helper and reuse it for the Readonly/Reboot cells in both change-configuration skins (drop the 4 inline Yes/No literals). - Classic Save button: expose aria-busy while a change is in flight, matching the modern ActionButton (no visual spinner: classic has no such token). - Drop the redundant aria-labelledby (and its useId) on the modern table whose already names it, and the partial aria-disabled on the inputs whose native disabled is authoritative. - Add tests: Readonly/Reboot cell rendering and in-flight aria-busy in both skins, plus modern parity for reboot-notice, read-only-not-submitted and error-toast (mirroring the classic suite). Refs: #1828 * docs: clarify metadata-driven reboot notice and safe 2.0.1 status collapse - useChangeConfigurationForm: note that the reboot notice derives from the key metadata, not the runtime status, because the worker response collapses ACCEPTED|REBOOT_REQUIRED into a boolean success. - OCPP20 status mapping: note that UnknownComponent/UnknownVariable are unreachable via changeConfiguration (resolveConfigurationKeyName gates unknown keys) and that Record exhaustiveness is compile-enforced. Refs: #1828 * [autofix.ci] apply automated fixes * test(webui): harden change-configuration table tests - Yes/No cell test: use column-asymmetric keys (readonly-only vs reboot-only) so an accidental swap of the Readonly/Reboot columns fails the assertion. - Add a guard that the OCPP parameters table is named via its in both skins (the modern table dropped its redundant aria-labelledby). Refs: #1828 * refactor(ocpp): use isEmpty for the SetVariables result check Replace `response.setVariableResult.length === 0` with `isEmpty(...)` in the 2.0.1 changeConfiguration seam, matching the repo convention (isEmpty is already imported and used for arrays in this file). Refs: #1828 * test(webui): fit CHANGE_CONFIGURATION worker tests into the group structure The two CHANGE_CONFIGURATION describes (status collapse + handler) were wedged between Group 1 and Group 2 without a group banner. Relocate them after Group 2 under a numbered "Group 3" banner (restoring ascending group order and filling the pre-existing gap), and correct the stale Group 4 count (8 -> 9 tests). Refs: #1828 --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- README.md | 19 ++ src/charging-station/ChargingStation.ts | 17 ++ .../ChargingStationWorkerBroadcastChannel.ts | 30 ++- .../ocpp/1.6/OCPP16IncomingRequestService.ts | 14 +- .../ocpp/2.0/OCPP20IncomingRequestService.ts | 55 +++++ .../ocpp/2.0/OCPP20VariableManager.ts | 58 ++++- .../ocpp/OCPPIncomingRequestService.ts | 17 ++ .../ui-server/mcp/MCPToolSchemas.ts | 12 ++ .../ui-services/AbstractUIService.ts | 1 + src/types/UIProtocol.ts | 1 + src/types/WorkerBroadcastChannel.ts | 1 + ...hargingStation-ChangeConfiguration.test.ts | 70 +++++++ ...rgingStationWorkerBroadcastChannel.test.ts | 102 ++++++++- ...comingRequestService-Configuration.test.ts | 131 ++++++++++++ ...RequestService-ChangeConfiguration.test.ts | 130 ++++++++++++ ui/common/src/types/UIProtocol.ts | 1 + ui/web/README.md | 2 +- ui/web/src/core/Constants.ts | 1 + ui/web/src/core/UIClient.ts | 12 ++ ui/web/src/router/index.ts | 10 + .../composables/useChangeConfigurationForm.ts | 84 ++++++++ .../shared/composables/useStationDetails.ts | 20 +- ui/web/src/shared/utils/index.ts | 1 + ui/web/src/shared/utils/stationDetails.ts | 2 +- .../actions/ChangeConfiguration.vue | 144 +++++++++++++ .../components/charging-stations/CSData.vue | 25 +++ ui/web/src/skins/modern/ModernLayout.vue | 17 ++ .../skins/modern/components/StationCard.vue | 14 ++ .../dialogs/ChangeConfigurationDialog.vue | 169 +++++++++++++++ ui/web/tests/unit/constants.ts | 12 ++ ui/web/tests/unit/helpers.ts | 2 + .../shared/composables/stationDetails.test.ts | 54 ++--- .../useChangeConfigurationForm.test.ts | 120 +++++++++++ .../composables/useStationDetails.test.ts | 27 ++- .../tests/unit/skins/classic/Actions.test.ts | 198 +++++++++++++++++- .../tests/unit/skins/modern/Dialogs.test.ts | 187 +++++++++++++++-- 36 files changed, 1673 insertions(+), 87 deletions(-) create mode 100644 tests/charging-station/ChargingStation-ChangeConfiguration.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeConfiguration.test.ts create mode 100644 ui/web/src/shared/composables/useChangeConfigurationForm.ts create mode 100644 ui/web/src/skins/classic/components/actions/ChangeConfiguration.vue create mode 100644 ui/web/src/skins/modern/components/dialogs/ChangeConfigurationDialog.vue create mode 100644 ui/web/tests/unit/shared/composables/useChangeConfigurationForm.test.ts diff --git a/README.md b/README.md index 9392e645..fa39cdb4 100644 --- a/README.md +++ b/README.md @@ -1139,6 +1139,25 @@ Set the WebSocket header _Sec-WebSocket-Protocol_ to `ui0.0.1`. `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: diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 4290c7da..16065711 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -25,6 +25,7 @@ import { type ChargingStationOcppConfiguration, type ChargingStationOptions, type ChargingStationTemplate, + ConfigurationStatus, type ConnectorEntry, type ConnectorStatus, ConnectorStatusEnum, @@ -386,6 +387,22 @@ export class ChargingStation extends EventEmitter { 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 diff --git a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts index 45f8977a..025dd2cc 100644 --- a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts +++ b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts @@ -12,6 +12,8 @@ import { type BroadcastChannelRequest, type BroadcastChannelRequestPayload, type BroadcastChannelResponsePayload, + type ChangeConfigurationResponse, + ConfigurationStatus, type DataTransferResponse, DataTransferStatus, GenericStatus, @@ -46,6 +48,7 @@ import { getErrorMessage, isAsyncFunction, isEmpty, + isNotEmptyString, isOCPP20x, logger, } from '../../utils/index.js' @@ -68,6 +71,7 @@ type CommandHandler = ( type CommandResponse = | AuthorizeResponse | BootNotificationResponse + | ChangeConfigurationResponse | DataTransferResponse | HeartbeatResponse | OCPP20Get15118EVCertificateResponse @@ -86,6 +90,12 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne 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, @@ -127,6 +137,24 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne this.commandHandlers = new Map([ [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, () => { @@ -193,7 +221,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne 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` ) diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index 153c9573..b7cae5da 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -33,6 +33,7 @@ import { type ChangeConfigurationResponse, type ClearCacheResponse, ConfigurationSection, + type ConfigurationStatus, type ConnectorStatus, ErrorType, type GenericResponse, @@ -727,6 +728,14 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService +> = 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 @@ -698,6 +714,45 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService( + Object.values(VARIABLE_REGISTRY).map(variableMetadata => [ + computeConfigurationKeyName(variableMetadata), + { + component: variableMetadata.component, + instance: variableMetadata.instance, + variable: variableMetadata.variable, + }, + ]) + ) + readonly #validComponentNames = new Set( Object.keys(VARIABLE_REGISTRY).map(k => k.split('::')[0]) ) @@ -125,6 +139,19 @@ export class OCPP20VariableManager { } } + /** + * 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[] @@ -284,9 +311,16 @@ export class OCPP20VariableManager { } 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}'` ) @@ -638,7 +672,10 @@ export class OCPP20VariableManager { chargingStation, configurationKeyName, value, // Use the resolved default value - undefined, + { + readonly: isReadOnly(variableMetadata), + reboot: variableMetadata.rebootRequired === true, + }, { overwrite: false, } @@ -1001,9 +1038,16 @@ export class OCPP20VariableManager { 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) } diff --git a/src/charging-station/ocpp/OCPPIncomingRequestService.ts b/src/charging-station/ocpp/OCPPIncomingRequestService.ts index 55a5319a..a7606ddb 100644 --- a/src/charging-station/ocpp/OCPPIncomingRequestService.ts +++ b/src/charging-station/ocpp/OCPPIncomingRequestService.ts @@ -5,6 +5,7 @@ import { EventEmitter } from 'node:events' import { type ChargingStation } from '../../charging-station/index.js' import { OCPPError } from '../../exception/index.js' import { + type ConfigurationStatus, ErrorType, type IncomingRequestCommand, type IncomingRequestHandler, @@ -107,6 +108,22 @@ export abstract class OCPPIncomingRequestService< 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( chargingStation: ChargingStation, diff --git a/src/charging-station/ui-server/mcp/MCPToolSchemas.ts b/src/charging-station/ui-server/mcp/MCPToolSchemas.ts index 660320d0..be14ab5a 100644 --- a/src/charging-station/ui-server/mcp/MCPToolSchemas.ts +++ b/src/charging-station/ui-server/mcp/MCPToolSchemas.ts @@ -181,6 +181,18 @@ export const mcpToolSchemas = new Map([ 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, { diff --git a/src/charging-station/ui-server/ui-services/AbstractUIService.ts b/src/charging-station/ui-server/ui-services/AbstractUIService.ts index 7d14cad9..5c6c2a7f 100644 --- a/src/charging-station/ui-server/ui-services/AbstractUIService.ts +++ b/src/charging-station/ui-server/ui-services/AbstractUIService.ts @@ -83,6 +83,7 @@ export abstract class AbstractUIService { >([ [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], [ diff --git a/src/types/UIProtocol.ts b/src/types/UIProtocol.ts index a08b833d..98cf0c99 100644 --- a/src/types/UIProtocol.ts +++ b/src/types/UIProtocol.ts @@ -17,6 +17,7 @@ export enum ProcedureName { ADD_CHARGING_STATIONS = 'addChargingStations', AUTHORIZE = 'authorize', BOOT_NOTIFICATION = 'bootNotification', + CHANGE_CONFIGURATION = 'changeConfiguration', CLOSE_CONNECTION = 'closeConnection', DATA_TRANSFER = 'dataTransfer', DELETE_CHARGING_STATIONS = 'deleteChargingStations', diff --git a/src/types/WorkerBroadcastChannel.ts b/src/types/WorkerBroadcastChannel.ts index 008b617d..2be46905 100644 --- a/src/types/WorkerBroadcastChannel.ts +++ b/src/types/WorkerBroadcastChannel.ts @@ -4,6 +4,7 @@ import type { UUIDv4 } from './UUID.js' export enum BroadcastChannelProcedureName { AUTHORIZE = 'authorize', BOOT_NOTIFICATION = 'bootNotification', + CHANGE_CONFIGURATION = 'changeConfiguration', CLOSE_CONNECTION = 'closeConnection', DATA_TRANSFER = 'dataTransfer', DELETE_CHARGING_STATIONS = 'deleteChargingStations', diff --git a/tests/charging-station/ChargingStation-ChangeConfiguration.test.ts b/tests/charging-station/ChargingStation-ChangeConfiguration.test.ts new file mode 100644 index 00000000..5f8c6cd3 --- /dev/null +++ b/tests/charging-station/ChargingStation-ChangeConfiguration.test.ts @@ -0,0 +1,70 @@ +/** + * @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) + }) +}) diff --git a/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts b/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts index fec1711d..124b1fd0 100644 --- a/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts +++ b/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts @@ -7,13 +7,15 @@ 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, @@ -514,7 +516,103 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => { }) // ========================================================================== - // 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 => ({ 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 () => { diff --git a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts index f8a07059..ef962416 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts @@ -13,6 +13,7 @@ import type { 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, @@ -177,6 +178,136 @@ await describe('OCPP16IncomingRequestService — Configuration', async () => { 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) // --------------------------------------------------------------------------- diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeConfiguration.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeConfiguration.test.ts new file mode 100644 index 00000000..0831fe92 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeConfiguration.test.ts @@ -0,0 +1,130 @@ +/** + * @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) + }) +}) diff --git a/ui/common/src/types/UIProtocol.ts b/ui/common/src/types/UIProtocol.ts index d1434242..6a287e65 100644 --- a/ui/common/src/types/UIProtocol.ts +++ b/ui/common/src/types/UIProtocol.ts @@ -14,6 +14,7 @@ export enum ProcedureName { ADD_CHARGING_STATIONS = 'addChargingStations', AUTHORIZE = 'authorize', BOOT_NOTIFICATION = 'bootNotification', + CHANGE_CONFIGURATION = 'changeConfiguration', CLOSE_CONNECTION = 'closeConnection', DATA_TRANSFER = 'dataTransfer', DELETE_CHARGING_STATIONS = 'deleteChargingStations', diff --git a/ui/web/README.md b/ui/web/README.md index 0864a249..5c1e1fc3 100644 --- a/ui/web/README.md +++ b/ui/web/README.md @@ -9,7 +9,7 @@ Vue.js dashboard for monitoring and controlling the e-mobility charging stations ![Web UI](./src/assets/screenshot.png) 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 diff --git a/ui/web/src/core/Constants.ts b/ui/web/src/core/Constants.ts index 6be8f005..b59dc3f2 100644 --- a/ui/web/src/core/Constants.ts +++ b/ui/web/src/core/Constants.ts @@ -17,6 +17,7 @@ export const WH_PER_KWH = 1000 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', diff --git a/ui/web/src/core/UIClient.ts b/ui/web/src/core/UIClient.ts index eb0ee40e..ef4bfc23 100644 --- a/ui/web/src/core/UIClient.ts +++ b/ui/web/src/core/UIClient.ts @@ -74,6 +74,18 @@ export class UIClient { }) } + public async changeConfiguration ( + hashId: string, + key: string, + value: string + ): Promise { + return this.sendRequest(ProcedureName.CHANGE_CONFIGURATION, { + hashIds: [hashId], + key, + value, + }) + } + public async closeConnection (hashId: string): Promise { return this.sendRequest(ProcedureName.CLOSE_CONNECTION, { hashIds: [hashId], diff --git a/ui/web/src/router/index.ts b/ui/web/src/router/index.ts index fadadfc6..7d1fc0c2 100644 --- a/ui/web/src/router/index.ts +++ b/ui/web/src/router/index.ts @@ -60,6 +60,16 @@ export const router = createRouter({ 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: { diff --git a/ui/web/src/shared/composables/useChangeConfigurationForm.ts b/ui/web/src/shared/composables/useChangeConfigurationForm.ts new file mode 100644 index 00000000..55440928 --- /dev/null +++ b/ui/web/src/shared/composables/useChangeConfigurationForm.ts @@ -0,0 +1,84 @@ +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> +): { + draftValues: Record + pending: DeepReadonly> + save: (configurationKey: ConfigurationKey) => Promise + } { + const $uiClient = useUIClient() + const $toast = useToast() + + const pending = reactive(new Set()) + const draftValues = reactive>({}) + + // 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 { + 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, + } +} diff --git a/ui/web/src/shared/composables/useStationDetails.ts b/ui/web/src/shared/composables/useStationDetails.ts index d1a3b7f4..7afffad1 100644 --- a/ui/web/src/shared/composables/useStationDetails.ts +++ b/ui/web/src/shared/composables/useStationDetails.ts @@ -1,4 +1,4 @@ -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' @@ -7,21 +7,24 @@ import { buildStationDetailSections, type ConfigurationRow, type DetailSection, + getVisibleConfigurationKeys, } from '@/shared/utils/index.js' export interface StationDetailsView { configurationRows: ComputedRef sections: ComputedRef station: ComputedRef + visibleConfigurationKeys: ComputedRef } /** - * 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() @@ -38,9 +41,14 @@ export function useStationDetails (hashId: string): StationDetailsView { station.value != null ? buildConfigurationRows(station.value) : [] ) + const visibleConfigurationKeys = computed(() => + station.value != null ? getVisibleConfigurationKeys(station.value) : [] + ) + return { configurationRows, sections, station, + visibleConfigurationKeys, } } diff --git a/ui/web/src/shared/utils/index.ts b/ui/web/src/shared/utils/index.ts index 97220381..1eeca4c1 100644 --- a/ui/web/src/shared/utils/index.ts +++ b/ui/web/src/shared/utils/index.ts @@ -5,6 +5,7 @@ export type { ConfigurationRow, DetailEntry, DetailSection } from './stationDeta export { buildConfigurationRows, buildStationDetailSections, + formatBoolean, getVisibleConfigurationKeys, } from './stationDetails.js' export type { StatusVariant } from './stationStatus.js' diff --git a/ui/web/src/shared/utils/stationDetails.ts b/ui/web/src/shared/utils/stationDetails.ts index 97f183b3..15afd523 100644 --- a/ui/web/src/shared/utils/stationDetails.ts +++ b/ui/web/src/shared/utils/stationDetails.ts @@ -38,7 +38,7 @@ export interface DetailSection { * @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. diff --git a/ui/web/src/skins/classic/components/actions/ChangeConfiguration.vue b/ui/web/src/skins/classic/components/actions/ChangeConfiguration.vue new file mode 100644 index 00000000..4d1dc8dd --- /dev/null +++ b/ui/web/src/skins/classic/components/actions/ChangeConfiguration.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/ui/web/src/skins/classic/components/charging-stations/CSData.vue b/ui/web/src/skins/classic/components/charging-stations/CSData.vue index 5a473f3b..13377f46 100644 --- a/ui/web/src/skins/classic/components/charging-stations/CSData.vue +++ b/ui/web/src/skins/classic/components/charging-stations/CSData.vue @@ -95,6 +95,31 @@ > Show Details + + Change Configuration + diff --git a/ui/web/src/skins/modern/ModernLayout.vue b/ui/web/src/skins/modern/ModernLayout.vue index 56873599..685efaea 100644 --- a/ui/web/src/skins/modern/ModernLayout.vue +++ b/ui/web/src/skins/modern/ModernLayout.vue @@ -32,6 +32,7 @@ :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" @@ -78,6 +79,12 @@ :charging-station-id="showDetailsDialog.chargingStationId" @close="showDetailsDialog = null" /> + @@ -128,6 +135,9 @@ const AddStationsDialog = defineAsyncDialog( () => 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') ) @@ -179,6 +189,10 @@ const showDetailsDialog = ref(null) +const showChangeConfigDialog = ref(null) const confirmStopSimulator = (): void => { stopSimulator() @@ -196,6 +210,9 @@ const toggleSimulator = (): void => { 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 } diff --git a/ui/web/src/skins/modern/components/StationCard.vue b/ui/web/src/skins/modern/components/StationCard.vue index 75df4266..9882cceb 100644 --- a/ui/web/src/skins/modern/components/StationCard.vue +++ b/ui/web/src/skins/modern/components/StationCard.vue @@ -142,6 +142,12 @@ > Details + + Configuration + { }) } +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, () => { diff --git a/ui/web/src/skins/modern/components/dialogs/ChangeConfigurationDialog.vue b/ui/web/src/skins/modern/components/dialogs/ChangeConfigurationDialog.vue new file mode 100644 index 00000000..95fdf888 --- /dev/null +++ b/ui/web/src/skins/modern/components/dialogs/ChangeConfigurationDialog.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/ui/web/tests/unit/constants.ts b/ui/web/tests/unit/constants.ts index ab8868e1..a7c292a0 100644 --- a/ui/web/tests/unit/constants.ts +++ b/ui/web/tests/unit/constants.ts @@ -6,6 +6,7 @@ import { type ChargingStationData, type ChargingStationInfo, + type ConfigurationKey, type ConnectorStatus, DEFAULT_HOST, DEFAULT_PORT, @@ -101,6 +102,17 @@ export function createStationInfo (overrides?: Partial): Ch } } +/** + * 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 diff --git a/ui/web/tests/unit/helpers.ts b/ui/web/tests/unit/helpers.ts index f51e23b8..9d256aed 100644 --- a/ui/web/tests/unit/helpers.ts +++ b/ui/web/tests/unit/helpers.ts @@ -11,6 +11,7 @@ import { type App, createApp } from 'vue' export interface MockUIClient { addChargingStations: ReturnType authorize: ReturnType + changeConfiguration: ReturnType closeConnection: ReturnType deleteChargingStation: ReturnType isConnected: ReturnType @@ -140,6 +141,7 @@ export function createMockUIClient (): MockUIClient { 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), diff --git a/ui/web/tests/unit/shared/composables/stationDetails.test.ts b/ui/web/tests/unit/shared/composables/stationDetails.test.ts index 08783740..70d3cee6 100644 --- a/ui/web/tests/unit/shared/composables/stationDetails.test.ts +++ b/ui/web/tests/unit/shared/composables/stationDetails.test.ts @@ -13,7 +13,11 @@ import { 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. @@ -121,15 +125,11 @@ describe('stationDetails', () => { 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']) }) @@ -144,11 +144,9 @@ describe('stationDetails', () => { 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([ { @@ -162,11 +160,9 @@ describe('stationDetails', () => { 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' }, @@ -175,25 +171,17 @@ describe('stationDetails', () => { 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']) }) diff --git a/ui/web/tests/unit/shared/composables/useChangeConfigurationForm.test.ts b/ui/web/tests/unit/shared/composables/useChangeConfigurationForm.test.ts new file mode 100644 index 00000000..048ec03d --- /dev/null +++ b/ui/web/tests/unit/shared/composables/useChangeConfigurationForm.test.ts @@ -0,0 +1,120 @@ +/** + * @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([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([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([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([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([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([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([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') + }) +}) diff --git a/ui/web/tests/unit/shared/composables/useStationDetails.test.ts b/ui/web/tests/unit/shared/composables/useStationDetails.test.ts index 5924695c..e6db89b7 100644 --- a/ui/web/tests/unit/shared/composables/useStationDetails.test.ts +++ b/ui/web/tests/unit/shared/composables/useStationDetails.test.ts @@ -14,7 +14,11 @@ import { 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. @@ -39,11 +43,9 @@ function runComposable (stations: Ref, hashId: string): S 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) @@ -53,6 +55,19 @@ describe('useStationDetails', () => { ]) }) + 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') diff --git a/ui/web/tests/unit/skins/classic/Actions.test.ts b/ui/web/tests/unit/skins/classic/Actions.test.ts index 9b86caed..c9062226 100644 --- a/ui/web/tests/unit/skins/classic/Actions.test.ts +++ b/ui/web/tests/unit/skins/classic/Actions.test.ts @@ -5,7 +5,7 @@ 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, @@ -16,6 +16,7 @@ import { 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' @@ -24,6 +25,7 @@ import { toastMock } from '../../../setup.js' import { createChargingStationData, createStationInfo, + createStationWithConfigurationKeys, createUIServerConfig, TEST_HASH_ID, TEST_STATION_ID, @@ -462,9 +464,7 @@ describe('Actions', () => { }) 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') }) @@ -480,11 +480,7 @@ describe('Actions', () => { 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] @@ -509,4 +505,188 @@ describe('Actions', () => { 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( + '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(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() + }) + }) }) diff --git a/ui/web/tests/unit/skins/modern/Dialogs.test.ts b/ui/web/tests/unit/skins/modern/Dialogs.test.ts index 3b8c4b37..cacaefc9 100644 --- a/ui/web/tests/unit/skins/modern/Dialogs.test.ts +++ b/ui/web/tests/unit/skins/modern/Dialogs.test.ts @@ -13,7 +13,7 @@ import { 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, @@ -39,6 +39,7 @@ vi.mock('@/skins/modern/components/ModernModal.vue', () => ({ 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' @@ -48,6 +49,7 @@ import { toastMock } from '../../../setup.js' import { createChargingStationData, createStationInfo, + createStationWithConfigurationKeys, TEST_HASH_ID, TEST_STATION_ID, } from '../../constants.js' @@ -580,11 +582,9 @@ describe('Dialogs', () => { 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') @@ -592,11 +592,7 @@ describe('Dialogs', () => { 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') @@ -610,9 +606,7 @@ describe('Dialogs', () => { }) 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') }) @@ -628,11 +622,9 @@ describe('Dialogs', () => { 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('') @@ -661,4 +653,161 @@ describe('Dialogs', () => { 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('[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(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) + }) + }) }) -- 2.53.0