]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(webui): allow editing charging station configuration (#2077)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 13 Aug 2026 19:37:03 +0000 (21:37 +0200)
committerGitHub <noreply@github.com>
Thu, 13 Aug 2026 19:37:03 +0000 (21:37 +0200)
* 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
  <caption> 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 <caption> 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>
36 files changed:
README.md
src/charging-station/ChargingStation.ts
src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20VariableManager.ts
src/charging-station/ocpp/OCPPIncomingRequestService.ts
src/charging-station/ui-server/mcp/MCPToolSchemas.ts
src/charging-station/ui-server/ui-services/AbstractUIService.ts
src/types/UIProtocol.ts
src/types/WorkerBroadcastChannel.ts
tests/charging-station/ChargingStation-ChangeConfiguration.test.ts [new file with mode: 0644]
tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeConfiguration.test.ts [new file with mode: 0644]
ui/common/src/types/UIProtocol.ts
ui/web/README.md
ui/web/src/core/Constants.ts
ui/web/src/core/UIClient.ts
ui/web/src/router/index.ts
ui/web/src/shared/composables/useChangeConfigurationForm.ts [new file with mode: 0644]
ui/web/src/shared/composables/useStationDetails.ts
ui/web/src/shared/utils/index.ts
ui/web/src/shared/utils/stationDetails.ts
ui/web/src/skins/classic/components/actions/ChangeConfiguration.vue [new file with mode: 0644]
ui/web/src/skins/classic/components/charging-stations/CSData.vue
ui/web/src/skins/modern/ModernLayout.vue
ui/web/src/skins/modern/components/StationCard.vue
ui/web/src/skins/modern/components/dialogs/ChangeConfigurationDialog.vue [new file with mode: 0644]
ui/web/tests/unit/constants.ts
ui/web/tests/unit/helpers.ts
ui/web/tests/unit/shared/composables/stationDetails.test.ts
ui/web/tests/unit/shared/composables/useChangeConfigurationForm.test.ts [new file with mode: 0644]
ui/web/tests/unit/shared/composables/useStationDetails.test.ts
ui/web/tests/unit/skins/classic/Actions.test.ts
ui/web/tests/unit/skins/modern/Dialogs.test.ts

index 9392e645fada1d134973a5050e2ca6190a9f73a8..fa39cdb4b587d8d5bbafc51629841653bf6ff1a3 100644 (file)
--- 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:  
index 4290c7da35166caf58f0299d5833e002e8c53b5a..16065711df546977a3dba11a72a3c7bddf9e54ba 100644 (file)
@@ -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
index 45f8977acb1e4121abfd4c6539e7d11d68e238bc..025dd2cc308529fd11f4170ea847560e7c854f0c 100644 (file)
@@ -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, 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,
         () => {
@@ -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`
             )
index 153c9573639f4a6b69f27f482ad437892cecf915..b7cae5da960a6b6ef4725c2ea17533db2b176557 100644 (file)
@@ -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<OCP
     )
   }
 
+  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
@@ -964,9 +973,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
         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
         }
       }
index a363d40787ef8cec07ea8ea95c565d069d5228e7..c1829e2c5cdffdda0155928ede0476d27d3dddad 100644 (file)
@@ -12,6 +12,7 @@ import {
   AttributeEnumType,
   CertificateSigningUseEnumType,
   ChangeAvailabilityStatusEnumType,
+  ConfigurationStatus,
   ConnectorEnumType,
   type ConnectorStatus,
   ConnectorStatusEnum,
@@ -213,6 +214,21 @@ const getCertificateIdUseToInstallCertificateUse: Readonly<
     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
@@ -698,6 +714,45 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     )
   }
 
+  /**
+   * 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.
index ccfb695f3604bd2241af8c8d1425f02bd2bb14fc..709844961549127355ec9a9605b1c165816a5948 100644 (file)
@@ -54,6 +54,20 @@ const computeConfigurationKeyName = (variableMetadata: VariableMetadata): string
 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])
   )
@@ -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)
       }
index 55a5319ac9be9fcf496183f9d818b422213df789..a7606ddbe183ca550174855b76e236e52f0ae595 100644 (file)
@@ -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<ReqType extends JsonType, ResType extends JsonType>(
     chargingStation: ChargingStation,
index 660320d06e4163fb8cfa7f2958ed6a76823ddc6f..be14ab5a0f7efd0b9ca552de0480df5d7c004c14 100644 (file)
@@ -181,6 +181,18 @@ export const mcpToolSchemas = new Map<ProcedureName, MCPToolSchema>([
       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,
     {
index 7d14cad94ec45405ac70cecb3ab1efa419fb8bd0..5c6c2a7fc9c85fba9706ebfc49ea1ed4da994e76 100644 (file)
@@ -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],
     [
index a08b833d4138c99d2251b099fa0d383ccef494cf..98cf0c996f8a2e28a01cfbd12dcad405d3528bb0 100644 (file)
@@ -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',
index 008b617d4fd9a96f051e919116d9ffd8e4d813ce..2be46905b65f9519a42454ec3e62e1fc3051de78 100644 (file)
@@ -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 (file)
index 0000000..5f8c6cd
--- /dev/null
@@ -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)
+  })
+})
index fec1711d8a1f45782facef4d416354de856c4a84..124b1fd098d1b17d0ff94702c91e915ea9dc91de 100644 (file)
@@ -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> = {}
+    ): 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 () => {
index f8a070597be6425a0171fbd7bf0223b7128db82b..ef962416a26cad71abb459a6e65e87ac15dbdb0b 100644 (file)
@@ -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 (file)
index 0000000..0831fe9
--- /dev/null
@@ -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)
+  })
+})
index d1434242ec336bf3d5f9a7f2b69229eaffe7a157..6a287e655f604193ed56ea79df420abb10c5b96d 100644 (file)
@@ -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',
index 0864a24958f323165c1a9ce5adfff27027da8e2a..5c1e1fc34d593e04caa7149d2dd3191a1de31bb0 100644 (file)
@@ -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
 
index 6be8f0053fc87468ec98c25e29d65d2116fd7f36..b59dc3f2c7c0ff64c23e945ea72698999e3e3f69 100644 (file)
@@ -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',
index eb0ee40ead735d420344a9797abb046871dc5f71..ef4bfc2310babd3c87a4c2159b7466f23e68fba7 100644 (file)
@@ -74,6 +74,18 @@ export class UIClient {
     })
   }
 
+  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],
index fadadfc64589ec6559a6a9646c0e2cb7670cea71..7d1fc0c2bc1f245814152b5f11f187bac9b367b1 100644 (file)
@@ -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 (file)
index 0000000..5544092
--- /dev/null
@@ -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<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,
+  }
+}
index d1a3b7f465e62c7182b43b6adfcfd9c972445c00..7afffad173f9b6d53bb6977637b4dc40a321fbc1 100644 (file)
@@ -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<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()
@@ -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,
   }
 }
index 9722038176f8fd13d2fe216d99aa987835da1989..1eeca4c117339fda4d9b49b6756816d6e587ddb3 100644 (file)
@@ -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'
index 97f183b3d60a99fcb5753f795c0dfcf4e853fc4a..15afd5236b93421ea27e49933f335a17ca7ad4ca 100644 (file)
@@ -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 (file)
index 0000000..4d1dc8d
--- /dev/null
@@ -0,0 +1,144 @@
+<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>
index 5a473f3b6ac03a585ad9dee406e7679b2fb6e32f..13377f46bcd5f659bea042d5f5cdeccccedd2181 100644 (file)
       >
         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>
index 568735993bdf85e063338b4515cede4b83aaa34c..685efaea46c4470f617ae8e8a66867ea31e1b0a0 100644 (file)
@@ -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"
       :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>
 
@@ -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 | {
   chargingStationId: string
   hashId: string
 }>(null)
+const showChangeConfigDialog = ref<null | {
+  chargingStationId: string
+  hashId: string
+}>(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
 }
index 75df4266bc5faa634734fb3d85b60c6376ab8298..9882ccebf5a2c38d99c38321ab7c368b20ab7d27 100644 (file)
         >
           Details
         </ActionButton>
+        <ActionButton
+          variant="ghost"
+          @click="emitOpenChangeConfig"
+        >
+          Configuration
+        </ActionButton>
       </div>
       <ActionButton
         variant="danger"
@@ -193,6 +199,7 @@ const props = defineProps<{
 
 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': [
@@ -269,6 +276,13 @@ const emitOpenDetails = (): void => {
   })
 }
 
+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 (file)
index 0000000..95fdf88
--- /dev/null
@@ -0,0 +1,169 @@
+<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>
index ab8868e18e0dcb8525e23ed29887e215f83c10d3..a7c292a05b7d4750b7ec8a4b28fd4bfa7c112512 100644 (file)
@@ -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<ChargingStationInfo>): 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
index f51e23b88424649919d89bb5dcf624e8a5483079..9d256aed95aab14a49b4c1bbe61444438030c0d1 100644 (file)
@@ -11,6 +11,7 @@ import { type App, createApp } from 'vue'
 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>
@@ -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),
index 087837406a8c9804b9fe10c5319a203f57cdb422..70d3cee6d6e76e8ed46b4e197e10b0eaeaec257d 100644 (file)
@@ -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 (file)
index 0000000..048ec03
--- /dev/null
@@ -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<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')
+  })
+})
index 5924695c552d8f556f62ddfc99d4f660214d2e68..e6db89b771ba212c276eb80f1884dd70ff2a1324 100644 (file)
@@ -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<ChargingStationData[]>, 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')
index 9b86caedd18fd24f0192e51681dc599ffe6c9106..c90622263cf7de5946f73387dfa98dd32b0d4982 100644 (file)
@@ -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<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()
+    })
+  })
 })
index 3b8c4b374c8b2cc5670cb9f5821f516c75110786..cacaefc9e90c3709f6e41a731e812ff52d191962 100644 (file)
@@ -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<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)
+    })
+  })
 })