]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ui-server): allow override of station identity and CSMS credentials in addChargi...
authorDaniel <7558512+DerGenaue@users.noreply.github.com>
Wed, 22 Apr 2026 17:50:28 +0000 (19:50 +0200)
committerGitHub <noreply@github.com>
Wed, 22 Apr 2026 17:50:28 +0000 (19:50 +0200)
* feat(ui-server): allow per-call override of station identity and CSMS credentials

Extend ChargingStationOptions with baseName, fixedName, nameSuffix,
supervisionUser and supervisionPassword so that callers of the
addChargingStations UI procedure can fully customise a station without
editing template files.

Identity derivation (chargingStationId, hashId) moves to the tail of
getStationInfo, after setChargingStationOptions runs, so the merged
stationInfo is the single source of truth for baseName/fixedName/nameSuffix.
getChargingStationId now accepts a narrow StationIdentity pick that both
ChargingStationTemplate and ChargingStationInfo satisfy; getHashId grows
an optional chargingStationIdOverride so the new identity flows through
without altering the hashed payload shape for existing callers.

Default behaviour is unchanged: hash ID regression test pins the
pre-existing hash, and omitted options leave stationInfo untouched.

* docs(ui-server): sync ChargingStationOptions docs with source interface

Bring the README protocol reference, the MCP zod schema and the web UI
ChargingStationOptions type back in sync with src/types/ChargingStationWorker.ts.
Covers the newly added baseName, fixedName, nameSuffix, supervisionUser and
supervisionPassword fields, plus previously missing fields not yet surfaced
in the public docs.

* feat(ui-web): expose baseName, fixedName and CSMS credentials in add-station dialog

Surface the new addChargingStations options on the web UI form: a base
name text field, an "append counter to name" checkbox that controls
fixedName, and a supervision user/password pair alongside the existing
supervision URL. Empty fields are omitted from the payload so the
template defaults apply unchanged.

\ 1 Conflicts:
\ 1 ui/web/src/components/actions/AddChargingStations.vue

* feat(ui-server): allow setSupervisionUrl to update CSMS credentials

Extend the setSupervisionUrl UI procedure so callers can update the
supervision WebSocket's basic auth user and password on an already
running station, not only the URL. All three fields (url,
supervisionUser, supervisionPassword) become optional; the handler now
requires at least one to be set.

ChargingStation.setSupervisionUrl accepts the three optional arguments
and writes the non-URL fields onto stationInfo via saveStationInfo.
Changes take effect on the next WebSocket (re)connect, mirroring how
URL updates already worked. The MCP zod schema and the protocol
reference in the README are updated accordingly.

* feat(ui-web): expose CSMS credentials in set-supervision dialog

Surface the extended setSupervisionUrl procedure on the web UI: user
and password inputs next to the existing URL field, a client-side
guard that requires at least one field set, and a renamed form
header ("Set Supervision") that matches the broader scope. UIClient
passes the new fields through only when populated, so existing
URL-only callers are unaffected.

* fix(ui-web): serve index.html as SPA fallback so deep-linked routes survive reload

The built bundle is served by start.js via serve-static, which 404s on
any path that isn't a physical file — so refreshing a router route
like /add-charging-stations or /set-supervision-url/... produced
"Cannot GET ...". When serve-static gives up, fall back to sending
the bundled index.html with 200 OK so the SPA router can resolve the
route client-side. POST/PUT/etc. still fall through to finalhandler.

* [autofix.ci] apply automated fixes

* test(ui-web): update AddChargingStations tests for new fields

Bump checkbox count to 5 (appendCounter added) and cover baseName,
supervision credential pass-through and fixedName behaviour.

* fix: PR comments

* fix: PR comments

  - server: setSupervisionUrl: url required, supervisionUser/supervisionPassword
    independent (string updates incl. "" to clear, undefined preserves);
    drop block-comment noise; align README and MCP schema descriptions
  - ui: SetSupervisionUrl dialog: always send credentials verbatim — empty
    fields clear stored credentials (frontend can't distinguish null
    vs empty)
  - ui: AddChargingStations dialog: replace inverted appendCounter with
    fixedName mirroring the API field; label "(base name is full
    station name)"; empty fields keep preserving template defaults
  - server: Rename StationIdentity → ChargingStationNameTemplate (and the
    parameter in getChargingStationId) so the type name reflects its
    role as the building blocks of chargingStationId
  - Tests updated to match the new semantics

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
15 files changed:
README.md
src/charging-station/ChargingStation.ts
src/charging-station/Helpers.ts
src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/ui-server/mcp/MCPToolSchemas.ts
src/types/ChargingStationWorker.ts
tests/charging-station/Helpers.test.ts
ui/common/src/types/ChargingStationType.ts
ui/web/src/components/actions/AddChargingStations.vue
ui/web/src/components/actions/SetSupervisionUrl.vue
ui/web/src/composables/UIClient.ts
ui/web/start.js
ui/web/tests/unit/AddChargingStations.test.ts
ui/web/tests/unit/SetSupervisionUrl.test.ts
ui/web/tests/unit/UIClient.test.ts

index fee0ed4582d23aec0eefdb9ad491ca649fbe3e95..afc3c3722f436480b5a921d83c2190e36d2b1754 100644 (file)
--- a/README.md
+++ b/README.md
@@ -1016,13 +1016,18 @@ Set the WebSocket header _Sec-WebSocket-Protocol_ to `ui0.0.1`.
    `template`: string,  
    `numberOfStations`: number,  
    `options?`: {  
-   `supervisionUrls?`: string | string[],  
-   `persistentConfiguration?`: boolean,  
-   `autoStart?`: boolean,  
    `autoRegister?`: boolean,  
+   `autoStart?`: boolean,  
+   `baseName?`: string,  
    `enableStatistics?`: boolean,  
+   `fixedName?`: boolean,  
+   `nameSuffix?`: string,  
    `ocppStrictCompliance?`: boolean,  
-   `stopTransactionsOnStopped?`: boolean  
+   `persistentConfiguration?`: boolean,  
+   `stopTransactionsOnStopped?`: boolean,  
+   `supervisionPassword?`: string,  
+   `supervisionUrls?`: string | string[],  
+   `supervisionUser?`: string  
    }  
   }
 
@@ -1056,8 +1061,11 @@ Set the WebSocket header _Sec-WebSocket-Protocol_ to `ui0.0.1`.
   `ProcedureName`: 'setSupervisionUrl'  
   `PDU`: {  
    `hashIds`: charging station unique identifier strings array (optional, default: all charging stations),  
-   `url`: string  
-  }
+   `url`: string,  
+   `supervisionUser?`: string,  
+   `supervisionPassword?`: string  
+  }  
+  `url` is required. `supervisionUser` and `supervisionPassword` are each optional and independent: a string (including `""`, which clears the field) updates the value; omitting the field preserves the existing value. Changes take effect on the next WebSocket (re)connect.
 
 - Response:  
   `PDU`: {  
index b36e4ba828e5e8ed08e5f47fea68fb4a50fcf089..170f376ded340768c63ab2c1ca5158e051b23a73 100644 (file)
@@ -993,10 +993,16 @@ export class ChargingStation extends EventEmitter {
   }
 
   /**
-   * Updates the supervision server URL in configuration or station info.
+   * Updates the supervision server URL and optionally the CSMS basic auth credentials in configuration or station info.
    * @param url - The new supervision server URL
+   * @param supervisionUser - The new CSMS basic auth user (optional; "" clears, undefined preserves)
+   * @param supervisionPassword - The new CSMS basic auth password (optional; "" clears, undefined preserves)
    */
-  public setSupervisionUrl (url: string): void {
+  public setSupervisionUrl (
+    url: string,
+    supervisionUser?: string,
+    supervisionPassword?: string
+  ): void {
     if (
       this.stationInfo?.supervisionUrlOcppConfiguration === true &&
       isNotEmptyString(this.stationInfo.supervisionUrlOcppKey)
@@ -1007,6 +1013,16 @@ export class ChargingStation extends EventEmitter {
       this.configuredSupervisionUrl = this.getConfiguredSupervisionUrl()
       this.saveStationInfo()
     }
+    if (this.stationInfo != null && (supervisionUser != null || supervisionPassword != null)) {
+      if (supervisionUser != null) {
+        this.stationInfo.supervisionUser = supervisionUser
+      }
+      if (supervisionPassword != null) {
+        this.stationInfo.supervisionPassword = supervisionPassword
+      }
+      this.saveStationInfo()
+      this.emitChargingStationEvent(ChargingStationEvents.updated)
+    }
   }
 
   /** Starts the charging station, initializes connectors, and connects to the central server. */
@@ -1490,7 +1506,8 @@ export class ChargingStation extends EventEmitter {
   }
 
   private getStationInfo (options?: ChargingStationOptions): ChargingStationInfo {
-    const stationInfoFromTemplate = this.getStationInfoFromTemplate()
+    const { stationInfo: stationInfoFromTemplate, stationTemplate } =
+      this.getStationInfoFromTemplate()
     options?.persistentConfiguration != null &&
       (stationInfoFromTemplate.stationInfoPersistentConfiguration = options.persistentConfiguration)
     const stationInfoFromFile = this.getStationInfoFromFile(
@@ -1508,12 +1525,15 @@ export class ChargingStation extends EventEmitter {
     } else {
       stationInfo = stationInfoFromTemplate
       stationInfoFromFile != null &&
-        propagateSerialNumber(this.getTemplateFromFile(), stationInfoFromFile, stationInfo)
+        propagateSerialNumber(stationTemplate, stationInfoFromFile, stationInfo)
     }
-    return setChargingStationOptions(
+    stationInfo = setChargingStationOptions(
       mergeDeepRight(Constants.DEFAULT_STATION_INFO as ChargingStationInfo, stationInfo),
       options
     )
+    stationInfo.chargingStationId = getChargingStationId(this.index, stationInfo)
+    stationInfo.hashId = getHashId(this.index, stationTemplate, stationInfo.chargingStationId)
+    return stationInfo
   }
 
   private getStationInfoFromFile (
@@ -1536,7 +1556,10 @@ export class ChargingStation extends EventEmitter {
     return stationInfo
   }
 
-  private getStationInfoFromTemplate (): ChargingStationInfo {
+  private getStationInfoFromTemplate (): {
+    stationInfo: ChargingStationInfo
+    stationTemplate: ChargingStationTemplate
+  } {
     const stationTemplate = this.getTemplateFromFile()
     if (stationTemplate == null) {
       const errorMsg = `Failed to read charging station template file ${this.templateFile}`
@@ -1553,10 +1576,11 @@ export class ChargingStation extends EventEmitter {
       checkEvsesConfiguration(stationTemplate, this.logPrefix(), this.templateFile)
     }
     const stationInfo = stationTemplateToStationInfo(stationTemplate)
-    stationInfo.hashId = getHashId(this.index, stationTemplate)
+    // hashId and chargingStationId are intentionally not set here. They are derived
+    // at the end of getStationInfo() so that any identity overrides coming from
+    // ChargingStationOptions (baseName, fixedName, nameSuffix) are honoured.
     stationInfo.templateIndex = this.index
     stationInfo.templateName = buildTemplateName(this.templateFile)
-    stationInfo.chargingStationId = getChargingStationId(this.index, stationTemplate)
     createSerialNumber(stationTemplate, stationInfo)
     stationInfo.voltageOut = this.getVoltageOut(stationInfo)
     if (isNotEmptyArray<number>(stationTemplate.power)) {
@@ -1586,7 +1610,7 @@ export class ChargingStation extends EventEmitter {
     if (stationTemplate.resetTime != null) {
       stationInfo.resetTime = secondsToMilliseconds(stationTemplate.resetTime)
     }
-    return stationInfo
+    return { stationInfo, stationTemplate }
   }
 
   private getTemplateFromFile (): ChargingStationTemplate | undefined {
index 57d73f5c230e08d89a95a17bd0c96e5f5d848b88..e75341e56a950b38b8646160f5d9ed8cd999fb6f 100644 (file)
@@ -81,20 +81,25 @@ export const buildTemplateName = (templateFile: string): string => {
   return join(templateFileParsedPath.dir, templateFileParsedPath.name)
 }
 
+export type ChargingStationNameTemplate = Pick<
+  ChargingStationTemplate,
+  'baseName' | 'fixedName' | 'nameSuffix'
+>
+
 export const getChargingStationId = (
   index: number,
-  stationTemplate: ChargingStationTemplate | undefined
+  nameTemplate: ChargingStationNameTemplate | undefined
 ): string => {
-  if (stationTemplate == null) {
+  if (nameTemplate == null) {
     return "Unknown 'chargingStationId'"
   }
   // In case of multiple instances: add instance index to charging station id
   const instanceIndex = env.CF_INSTANCE_INDEX ?? 0
-  const idSuffix = stationTemplate.nameSuffix ?? ''
+  const idSuffix = nameTemplate.nameSuffix ?? ''
   const idStr = `000000000${index.toString()}`
-  return stationTemplate.fixedName === true
-    ? stationTemplate.baseName
-    : `${stationTemplate.baseName}-${instanceIndex.toString()}${idStr.substring(
+  return nameTemplate.fixedName === true
+    ? nameTemplate.baseName
+    : `${nameTemplate.baseName}-${instanceIndex.toString()}${idStr.substring(
         idStr.length - 4
       )}${idSuffix}`
 }
@@ -156,7 +161,11 @@ export const removeExpiredReservations = async (
   }
 }
 
-export const getHashId = (index: number, stationTemplate: ChargingStationTemplate): string => {
+export const getHashId = (
+  index: number,
+  stationTemplate: ChargingStationTemplate,
+  chargingStationIdOverride?: string
+): string => {
   const chargingStationInfo = {
     chargePointModel: stationTemplate.chargePointModel,
     chargePointVendor: stationTemplate.chargePointVendor,
@@ -175,7 +184,9 @@ export const getHashId = (index: number, stationTemplate: ChargingStationTemplat
   }
   return hash(
     Constants.DEFAULT_HASH_ALGORITHM,
-    `${JSON.stringify(chargingStationInfo)}${getChargingStationId(index, stationTemplate)}`,
+    `${JSON.stringify(chargingStationInfo)}${
+      chargingStationIdOverride ?? getChargingStationId(index, stationTemplate)
+    }`,
     'hex'
   )
 }
@@ -428,6 +439,12 @@ export const setChargingStationOptions = (
   if (options?.supervisionUrls != null) {
     stationInfo.supervisionUrls = options.supervisionUrls
   }
+  if (options?.supervisionUser != null) {
+    stationInfo.supervisionUser = options.supervisionUser
+  }
+  if (options?.supervisionPassword != null) {
+    stationInfo.supervisionPassword = options.supervisionPassword
+  }
   if (options?.persistentConfiguration != null) {
     stationInfo.stationInfoPersistentConfiguration = options.persistentConfiguration
     stationInfo.ocppPersistentConfiguration = options.persistentConfiguration
@@ -449,6 +466,15 @@ export const setChargingStationOptions = (
   if (options?.stopTransactionsOnStopped != null) {
     stationInfo.stopTransactionsOnStopped = options.stopTransactionsOnStopped
   }
+  if (options?.baseName != null) {
+    stationInfo.baseName = options.baseName
+  }
+  if (options?.fixedName != null) {
+    stationInfo.fixedName = options.fixedName
+  }
+  if (options?.nameSuffix != null) {
+    stationInfo.nameSuffix = options.nameSuffix
+  }
   return stationInfo
 }
 
index b2533aa1d3b5672b76de76222c7e8d72f45fd855..95b14718a70324a7504a732b0890a693964cc9e8 100644 (file)
@@ -189,7 +189,13 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
               `${this.chargingStation.logPrefix()} ${moduleName}.requestHandler: 'url' field is required`
             )
           }
-          this.chargingStation.setSupervisionUrl(url)
+          const supervisionUser = requestPayload?.supervisionUser
+          const supervisionPassword = requestPayload?.supervisionPassword
+          this.chargingStation.setSupervisionUrl(
+            url,
+            typeof supervisionUser === 'string' ? supervisionUser : undefined,
+            typeof supervisionPassword === 'string' ? supervisionPassword : undefined
+          )
         },
       ],
       [
index 3438b73e303512f91f5c17cfc89922ffc03bffb7..703cabf4023030c55b77824ad8499f8f40428e0a 100644 (file)
@@ -32,7 +32,19 @@ const emptyInputSchema = z.object({})
 const chargingStationOptionsSchema = z.object({
   autoRegister: z.boolean().optional().describe('Set stations as registered at boot notification'),
   autoStart: z.boolean().optional().describe('Enable automatic start of added charging station'),
+  baseName: z
+    .string()
+    .optional()
+    .describe('Override the template base name used to derive the chargingStationId'),
   enableStatistics: z.boolean().optional().describe('Enable charging station statistics'),
+  fixedName: z
+    .boolean()
+    .optional()
+    .describe('Use baseName verbatim as chargingStationId instead of appending index/suffix'),
+  nameSuffix: z
+    .string()
+    .optional()
+    .describe('Suffix appended to the derived chargingStationId (ignored when fixedName is true)'),
   ocppStrictCompliance: z
     .boolean()
     .optional()
@@ -45,10 +57,18 @@ const chargingStationOptionsSchema = z.object({
     .boolean()
     .optional()
     .describe('Enable stop transactions on station stop'),
+  supervisionPassword: z
+    .string()
+    .optional()
+    .describe('CSMS basic auth password used on the supervision WebSocket'),
   supervisionUrls: z
     .union([z.url(), z.array(z.url())])
     .optional()
     .describe('OCPP server supervision URL(s)'),
+  supervisionUser: z
+    .string()
+    .optional()
+    .describe('CSMS basic auth user used on the supervision WebSocket'),
 })
 
 /** Maps ProcedureName to OCPP JSON Schema file base names per version */
@@ -317,9 +337,18 @@ export const mcpToolSchemas = new Map<ProcedureName, MCPToolSchema>([
   [
     ProcedureName.SET_SUPERVISION_URL,
     {
-      description: 'Set the OCPP server supervision URL for one or more charging stations',
+      description:
+        'Set the OCPP server supervision URL for one or more charging stations. Optionally updates the CSMS basic auth credentials (empty string clears a credential, undefined preserves it).',
       inputSchema: z.object({
         hashIds,
+        supervisionPassword: z
+          .string()
+          .optional()
+          .describe('CSMS basic auth password (empty string clears)'),
+        supervisionUser: z
+          .string()
+          .optional()
+          .describe('CSMS basic auth user (empty string clears)'),
         url: z.url().describe('The OCPP server supervision URL to set'),
       }),
     },
index 551c1f70f8cee51bf06eabed72356348fb62ad6c..b1ee7b0477290104da0afe67058f428975b6598d 100644 (file)
@@ -50,11 +50,16 @@ export interface ChargingStationData extends WorkerData {
 export interface ChargingStationOptions extends JsonObject {
   autoRegister?: boolean
   autoStart?: boolean
+  baseName?: string
   enableStatistics?: boolean
+  fixedName?: boolean
+  nameSuffix?: string
   ocppStrictCompliance?: boolean
   persistentConfiguration?: boolean
   stopTransactionsOnStopped?: boolean
+  supervisionPassword?: string
   supervisionUrls?: string | string[]
+  supervisionUser?: string
 }
 
 export interface ChargingStationWorkerData extends WorkerData {
index 8b45004d6f0e16e9c6443e0b1c3f377128fecc43..22f7eefd5fc6da22ce0e1e178a05b763c777e932 100644 (file)
@@ -20,6 +20,7 @@ import {
   hasPendingReservations,
   hasReservationExpired,
   resetConnectorStatus,
+  setChargingStationOptions,
   validateStationInfo,
 } from '../../src/charging-station/Helpers.js'
 import {
@@ -28,6 +29,7 @@ import {
   ChargingProfilePurposeType,
   type ChargingStationConfiguration,
   type ChargingStationInfo,
+  type ChargingStationOptions,
   type ChargingStationTemplate,
   type ConnectorStatus,
   ConnectorStatusEnum,
@@ -78,6 +80,117 @@ await describe('Helpers', async () => {
     )
   })
 
+  await it('should return baseName verbatim when fixedName is true', () => {
+    const template = {
+      baseName: 'DYNAMIC-STATION',
+      fixedName: true,
+    } satisfies Partial<ChargingStationTemplate>
+    assert.strictEqual(
+      getChargingStationId(1, template as ChargingStationTemplate),
+      'DYNAMIC-STATION'
+    )
+  })
+
+  await it('should append nameSuffix to the indexed id when fixedName is false', () => {
+    const template = {
+      baseName: 'CS',
+      fixedName: false,
+      nameSuffix: '-X',
+    } satisfies Partial<ChargingStationTemplate>
+    assert.strictEqual(getChargingStationId(7, template as ChargingStationTemplate), 'CS-00007-X')
+  })
+
+  await it('should derive charging station id from stationInfo identity fields', () => {
+    // stationInfo satisfies the ChargingStationNameTemplate shape since it inherits baseName / fixedName / nameSuffix from the template type.
+    const stationInfo = {
+      baseName: 'INFO-STATION',
+      fixedName: true,
+    } satisfies Partial<ChargingStationInfo>
+    assert.strictEqual(getChargingStationId(1, stationInfo as ChargingStationInfo), 'INFO-STATION')
+  })
+
+  await it('should honour chargingStationId override in getHashId', () => {
+    const hashWithoutOverride = getHashId(1, chargingStationTemplate)
+    const hashWithOverride = getHashId(1, chargingStationTemplate, 'OVERRIDDEN-ID')
+    assert.notStrictEqual(hashWithoutOverride, hashWithOverride)
+    // Passing the default-derived id explicitly must reproduce the default hash.
+    assert.strictEqual(
+      getHashId(1, chargingStationTemplate, getChargingStationId(1, chargingStationTemplate)),
+      hashWithoutOverride
+    )
+  })
+
+  await it('should produce distinct hash ids when identity options differ', () => {
+    const baseId = getChargingStationId(1, chargingStationTemplate)
+    const overriddenId = getChargingStationId(1, {
+      ...chargingStationTemplate,
+      baseName: 'OTHER',
+    })
+    assert.notStrictEqual(
+      getHashId(1, chargingStationTemplate, baseId),
+      getHashId(1, chargingStationTemplate, overriddenId)
+    )
+  })
+
+  await describe('setChargingStationOptions', async () => {
+    const buildStationInfo = (): ChargingStationInfo =>
+      ({
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
+        hashId: 'placeholder',
+        templateIndex: 0,
+        templateName: 'test-template',
+      }) as ChargingStationInfo
+
+    await it('should return stationInfo unchanged when options are undefined', () => {
+      const stationInfo = buildStationInfo()
+      const before = { ...stationInfo }
+      const result = setChargingStationOptions(stationInfo)
+      assert.deepStrictEqual(result, before)
+    })
+
+    await it('should apply baseName override', () => {
+      const options: ChargingStationOptions = { baseName: 'DYNAMIC' }
+      const result = setChargingStationOptions(buildStationInfo(), options)
+      assert.strictEqual(result.baseName, 'DYNAMIC')
+    })
+
+    await it('should apply fixedName override', () => {
+      const options: ChargingStationOptions = { fixedName: true }
+      const result = setChargingStationOptions(buildStationInfo(), options)
+      assert.strictEqual(result.fixedName, true)
+    })
+
+    await it('should apply nameSuffix override', () => {
+      const options: ChargingStationOptions = { nameSuffix: '-SUFFIX' }
+      const result = setChargingStationOptions(buildStationInfo(), options)
+      assert.strictEqual(result.nameSuffix, '-SUFFIX')
+    })
+
+    await it('should apply supervisionUser override', () => {
+      const options: ChargingStationOptions = { supervisionUser: 'alice' }
+      const result = setChargingStationOptions(buildStationInfo(), options)
+      assert.strictEqual(result.supervisionUser, 'alice')
+    })
+
+    await it('should apply supervisionPassword override', () => {
+      const options: ChargingStationOptions = { supervisionPassword: 'secret' }
+      const result = setChargingStationOptions(buildStationInfo(), options)
+      assert.strictEqual(result.supervisionPassword, 'secret')
+    })
+
+    await it('should not overwrite stationInfo fields when option value is undefined', () => {
+      const stationInfo = buildStationInfo()
+      stationInfo.baseName = 'KEEP-ME'
+      stationInfo.supervisionUser = 'original'
+      const result = setChargingStationOptions(stationInfo, {
+        autoStart: true,
+      })
+      assert.strictEqual(result.baseName, 'KEEP-ME')
+      assert.strictEqual(result.supervisionUser, 'original')
+      assert.strictEqual(result.autoStart, true)
+    })
+  })
+
   await it('should throw when stationInfo is missing', () => {
     // Arrange
     // For validation edge cases, we need to manually create invalid states
index c71f81cc4ea7554466935f31a240af2eb07fa31f..f149f1e6730f1feee7a1cf77ab8d5f5f3939ef10 100644 (file)
@@ -257,11 +257,16 @@ export interface ChargingStationOcppConfiguration extends JsonObject {
 export interface ChargingStationOptions extends JsonObject {
   autoRegister?: boolean
   autoStart?: boolean
+  baseName?: string
   enableStatistics?: boolean
+  fixedName?: boolean
+  nameSuffix?: string
   ocppStrictCompliance?: boolean
   persistentConfiguration?: boolean
   stopTransactionsOnStopped?: boolean
+  supervisionPassword?: string
   supervisionUrls?: string | string[]
+  supervisionUser?: string
 }
 
 export interface ConfigurationKey extends OCPPConfigurationKey {
index 1dafe5670788980f5de50a69de5d33beee2b0549..dbd968bc7b296e0d2defa5a619db8812b0c8b4c1 100644 (file)
   >
   <p>Template options overrides:</p>
   <ul class="template-options">
+    <li>
+      Base name:
+      <input
+        id="base-name"
+        v-model.trim="state.baseName"
+        class="base-name"
+        name="base-name"
+        placeholder="<template value>"
+        type="text"
+      >
+      Fixed name (base name is full station name):
+      <input
+        v-model="state.fixedName"
+        false-value="false"
+        true-value="true"
+        type="checkbox"
+      >
+    </li>
     <li>
       Supervision url:
       <input
         type="url"
       >
     </li>
+    <li>
+      Supervision credentials:
+      <input
+        id="supervision-user"
+        v-model.trim="state.supervisionUser"
+        autocomplete="off"
+        class="supervision-user"
+        name="supervision-user"
+        placeholder="<username>"
+        type="text"
+      >
+      <input
+        id="supervision-password"
+        v-model="state.supervisionPassword"
+        class="supervision-password"
+        name="supervision-password"
+        placeholder="<password>"
+        type="password"
+      >
+    </li>
     <li>
       Auto start:
       <input
@@ -106,21 +144,29 @@ import {
 
 const state = ref<{
   autoStart: boolean
+  baseName: string
   enableStatistics: boolean
+  fixedName: boolean
   numberOfStations: number
   ocppStrictCompliance: boolean
   persistentConfiguration: boolean
   renderTemplates: UUIDv4
+  supervisionPassword: string
   supervisionUrl: string
+  supervisionUser: string
   template: string
 }>({
   autoStart: false,
+  baseName: '',
   enableStatistics: false,
+  fixedName: false,
   numberOfStations: 1,
   ocppStrictCompliance: true,
   persistentConfiguration: true,
   renderTemplates: randomUUID(),
+  supervisionPassword: '',
   supervisionUrl: '',
+  supervisionUser: '',
   template: '',
 })
 
@@ -137,11 +183,18 @@ const addChargingStations = (): void => {
   executeAction(
     $uiClient.addChargingStations(state.value.template, state.value.numberOfStations, {
       autoStart: convertToBoolean(state.value.autoStart),
+      baseName: state.value.baseName.length > 0 ? state.value.baseName : undefined,
       enableStatistics: convertToBoolean(state.value.enableStatistics),
+      fixedName:
+        state.value.baseName.length > 0 ? convertToBoolean(state.value.fixedName) : undefined,
       ocppStrictCompliance: convertToBoolean(state.value.ocppStrictCompliance),
       persistentConfiguration: convertToBoolean(state.value.persistentConfiguration),
+      supervisionPassword:
+        state.value.supervisionPassword.length > 0 ? state.value.supervisionPassword : undefined,
       supervisionUrls:
         state.value.supervisionUrl.length > 0 ? state.value.supervisionUrl : undefined,
+      supervisionUser:
+        state.value.supervisionUser.length > 0 ? state.value.supervisionUser : undefined,
     }),
     'Charging stations successfully added',
     'Error at adding charging stations',
@@ -162,8 +215,17 @@ const addChargingStations = (): void => {
   text-align: center;
 }
 
+.supervision-url,
+.base-name,
+.supervision-user,
+.supervision-password {
+  width: 100%;
+  max-width: 40rem;
+  text-align: left;
+}
+
 .template-options {
-  list-style: circle inside;
+  list-style: circle;
   text-align: left;
 }
 </style>
index 133e3fc6c64646d7e9e924e615af161d905c8dfd..e6dbbe165889439c5244ef7c2947f240101045e4 100644 (file)
@@ -3,7 +3,7 @@
     Set Supervision Url
   </h1>
   <h2>{{ chargingStationId }}</h2>
-  <p>Supervision Url:</p>
+  <p>Supervision url:</p>
   <input
     id="supervision-url"
     v-model.trim="state.supervisionUrl"
     placeholder="wss://"
     type="url"
   >
+  <p>Supervision credentials:</p>
+  <input
+    id="supervision-user"
+    v-model.trim="state.supervisionUser"
+    autocomplete="off"
+    class="supervision-user"
+    name="supervision-user"
+    placeholder="<username>"
+    type="text"
+  >
+  <input
+    id="supervision-password"
+    v-model="state.supervisionPassword"
+    class="supervision-password"
+    name="supervision-password"
+    placeholder="<password>"
+    type="password"
+  >
   <br>
   <Button
     id="action-button"
@@ -24,6 +42,7 @@
 <script setup lang="ts">
 import { ref } from 'vue'
 import { useRouter } from 'vue-router'
+import { useToast } from 'vue-toast-notification'
 
 import Button from '@/components/buttons/Button.vue'
 import { resetToggleButtonState, ROUTE_NAMES, useExecuteAction, useUIClient } from '@/composables'
@@ -33,17 +52,33 @@ const props = defineProps<{
   hashId: string
 }>()
 
-const state = ref<{ supervisionUrl: string }>({
+const state = ref<{
+  supervisionPassword: string
+  supervisionUrl: string
+  supervisionUser: string
+}>({
+  supervisionPassword: '',
   supervisionUrl: '',
+  supervisionUser: '',
 })
 
 const $uiClient = useUIClient()
 const $router = useRouter()
+const $toast = useToast()
 const executeAction = useExecuteAction()
 
 const setSupervisionUrl = (): void => {
+  if (state.value.supervisionUrl.length === 0) {
+    $toast.error('Supervision url is required')
+    return
+  }
   executeAction(
-    $uiClient.setSupervisionUrl(props.hashId, state.value.supervisionUrl),
+    $uiClient.setSupervisionUrl(
+      props.hashId,
+      state.value.supervisionUrl,
+      state.value.supervisionUser,
+      state.value.supervisionPassword
+    ),
     'Supervision url successfully set',
     'Error at setting supervision url',
     {
@@ -55,3 +90,13 @@ const setSupervisionUrl = (): void => {
   )
 }
 </script>
+
+<style scoped>
+.supervision-url,
+.supervision-user,
+.supervision-password {
+  width: 100%;
+  max-width: 40rem;
+  text-align: left;
+}
+</style>
index b1f615b5d2c1579ccbfb5654b01804096f920201..8fe631d54e16de227873e77d260112dda241ec71 100644 (file)
@@ -129,10 +129,17 @@ export class UIClient {
     })
   }
 
-  public async setSupervisionUrl (hashId: string, supervisionUrl: string): Promise<ResponsePayload> {
+  public async setSupervisionUrl (
+    hashId: string,
+    supervisionUrl: string,
+    supervisionUser?: string,
+    supervisionPassword?: string
+  ): Promise<ResponsePayload> {
     return this.sendRequest(ProcedureName.SET_SUPERVISION_URL, {
       hashIds: [hashId],
       url: supervisionUrl,
+      ...(supervisionUser != null && { supervisionUser }),
+      ...(supervisionPassword != null && { supervisionPassword }),
     })
   }
 
index f02e91ba77bdf70d00030a469d8863e5f41fb4a3..1666328382ad230a21d4c3799022f3c03c0d8792 100644 (file)
@@ -1,4 +1,5 @@
 import finalhandler from 'finalhandler'
+import { readFileSync } from 'node:fs'
 import { createServer } from 'node:http'
 import { dirname, join } from 'node:path'
 import { env } from 'node:process'
@@ -11,8 +12,18 @@ const isCFEnvironment = env.VCAP_APPLICATION != null
 const PORT = isCFEnvironment ? Number.parseInt(env.PORT) : UI_DEV_SERVER_PORT
 const uiPath = join(dirname(fileURLToPath(import.meta.url)), './dist')
 
+const indexHtml = readFileSync(join(uiPath, 'index.html'))
 const serve = serveStatic(uiPath)
 
-const server = createServer((req, res) => serve(req, res, finalhandler(req, res)))
+const server = createServer((req, res) => {
+  serve(req, res, () => {
+    if (req.method !== 'GET' && req.method !== 'HEAD') {
+      finalhandler(req, res)()
+      return
+    }
+    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
+    res.end(indexHtml)
+  })
+})
 
 server.listen(PORT, () => console.info(`Web UI running at: http://localhost:${PORT}`))
index 656a8947fd0aa007660483b9a1fde07405756682..77ae3602e0fcd169a1c69ead2e2fdaf108c45ce5 100644 (file)
@@ -91,7 +91,7 @@ describe('AddChargingStations', () => {
   it('should render option checkboxes', () => {
     const wrapper = mountComponent()
     const checkboxes = wrapper.findAll('input[type="checkbox"]')
-    expect(checkboxes.length).toBe(4)
+    expect(checkboxes.length).toBe(5)
   })
 
   it('should pass supervision URL option when provided', async () => {
@@ -116,4 +116,36 @@ describe('AddChargingStations', () => {
       expect.objectContaining({ supervisionUrls: undefined })
     )
   })
+
+  it('should pass baseName and credentials when provided', async () => {
+    const wrapper = mountComponent()
+    await wrapper.find('#base-name').setValue('DEV-STATION')
+    await wrapper.find('#supervision-user').setValue('alice')
+    await wrapper.find('#supervision-password').setValue('s3cret')
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(mockClient.addChargingStations).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.anything(),
+      expect.objectContaining({
+        baseName: 'DEV-STATION',
+        supervisionPassword: 's3cret',
+        supervisionUser: 'alice',
+      })
+    )
+  })
+
+  it('should pass fixedName: true when baseName is set and fixedName checkbox is checked', async () => {
+    const wrapper = mountComponent()
+    await wrapper.find('#base-name').setValue('DEV-STATION')
+    const checkboxes = wrapper.findAll('input[type="checkbox"]')
+    await checkboxes[0].setValue(true)
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(mockClient.addChargingStations).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.anything(),
+      expect.objectContaining({ baseName: 'DEV-STATION', fixedName: true })
+    )
+  })
 })
index 48f918b7d21de80b6414d4bfd85fc394e2e5fca6..9c610866ed16ef03268a2af3ca60f22e15e4e028 100644 (file)
@@ -56,25 +56,68 @@ describe('SetSupervisionUrl', () => {
     expect(wrapper.text()).toContain(TEST_STATION_ID)
   })
 
-  it('should render supervision URL input', () => {
+  it('should render supervision URL and credential inputs', () => {
     const wrapper = mountComponent()
     expect(wrapper.find('#supervision-url').exists()).toBe(true)
+    expect(wrapper.find('#supervision-user').exists()).toBe(true)
+    expect(wrapper.find('#supervision-password').exists()).toBe(true)
   })
 
-  it('should call setSupervisionUrl on button click', async () => {
+  it('should send empty credential strings when only url is set', async () => {
     const wrapper = mountComponent()
-    const input = wrapper.find('#supervision-url')
-    await input.setValue('wss://new-server.com:9000')
+    await wrapper.find('#supervision-url').setValue('wss://new-server.com:9000')
     await wrapper.find('button').trigger('click')
     await flushPromises()
     expect(mockClient.setSupervisionUrl).toHaveBeenCalledWith(
       TEST_HASH_ID,
-      'wss://new-server.com:9000'
+      'wss://new-server.com:9000',
+      '',
+      ''
     )
   })
 
+  it('should call setSupervisionUrl with credentials when all fields are set', async () => {
+    const wrapper = mountComponent()
+    await wrapper.find('#supervision-url').setValue('wss://new-server.com:9000')
+    await wrapper.find('#supervision-user').setValue('alice')
+    await wrapper.find('#supervision-password').setValue('s3cret')
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(mockClient.setSupervisionUrl).toHaveBeenCalledWith(
+      TEST_HASH_ID,
+      'wss://new-server.com:9000',
+      'alice',
+      's3cret'
+    )
+  })
+
+  it('should send empty password when only user is typed', async () => {
+    const wrapper = mountComponent()
+    await wrapper.find('#supervision-url').setValue('wss://new-server.com:9000')
+    await wrapper.find('#supervision-user').setValue('alice')
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(mockClient.setSupervisionUrl).toHaveBeenCalledWith(
+      TEST_HASH_ID,
+      'wss://new-server.com:9000',
+      'alice',
+      ''
+    )
+  })
+
+  it('should not call setSupervisionUrl when url is empty', async () => {
+    const wrapper = mountComponent()
+    await wrapper.find('#supervision-user').setValue('alice')
+    await wrapper.find('#supervision-password').setValue('s3cret')
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(mockClient.setSupervisionUrl).not.toHaveBeenCalled()
+    expect(toastMock.error).toHaveBeenCalled()
+  })
+
   it('should navigate to charging-stations after submission', async () => {
     const wrapper = mountComponent()
+    await wrapper.find('#supervision-url').setValue('wss://new-server.com:9000')
     await wrapper.find('button').trigger('click')
     await flushPromises()
     expect(mockRouter.push).toHaveBeenCalledWith({ name: 'charging-stations' })
@@ -83,6 +126,7 @@ describe('SetSupervisionUrl', () => {
   it('should show error toast on failure', async () => {
     const wrapper = mountComponent()
     mockClient.setSupervisionUrl = vi.fn().mockRejectedValue(new Error('Network error'))
+    await wrapper.find('#supervision-url').setValue('wss://new-server.com:9000')
     await wrapper.find('button').trigger('click')
     await flushPromises()
     expect(toastMock.error).toHaveBeenCalled()
index fd104b7af7e3a8e2d936f816d83df6774865e72b..a16db52b2ba5dcacfad1a2de6e7ffb5dcd4daf94 100644 (file)
@@ -349,6 +349,17 @@ describe('UIClient', () => {
       })
     })
 
+    it('should send SET_SUPERVISION_URL with credentials when provided', async () => {
+      const url = 'ws://new-supervision:9001'
+      await client.setSupervisionUrl(TEST_HASH_ID, url, 'alice', 's3cret')
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.SET_SUPERVISION_URL, {
+        hashIds: [TEST_HASH_ID],
+        supervisionPassword: 's3cret',
+        supervisionUser: 'alice',
+        url,
+      })
+    })
+
     it('should send AUTHORIZE with hashIds and idTag', async () => {
       await client.authorize(TEST_HASH_ID, TEST_ID_TAG)
       expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.AUTHORIZE, {