]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(charging-station): retain creation options so a reset keeps station identity...
authorDaniel <7558512+DerGenaue@users.noreply.github.com>
Fri, 17 Jul 2026 17:10:37 +0000 (19:10 +0200)
committerGitHub <noreply@github.com>
Fri, 17 Jul 2026 17:10:37 +0000 (19:10 +0200)
* fix(charging-station): retain creation options so a reset keeps station identity

On an OCPP Reset (and on a template-file reload) the station was
re-initialized via initialize() with no arguments, dropping the
creation-time options. The restarted station reverted to the template
defaults, losing its fixed identity (reappearing as CS-BASIC-000N with a
new hashId) and its configured supervision URL; the original station
never came back.

Retain the creation options on the instance and re-pass them in reset()
and in the template-watcher reload. setSupervisionUrl() also updates the
retained snapshot so a runtime supervision-URL change survives a reboot
(but not a full simulator restart, which reconstructs the worker from the
original options). The snapshot is in-memory only: it is never persisted
and never written to the template.

Closes #2017.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
* fix(charging-station): clone the retained creation options

setSupervisionUrl updates the retained options in place so a later reset
re-applies the current supervision URL. Clone them at construction so that
in-place update never mutates the shared worker data passed from the worker
thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
* test(charging-station): cover identity retention across a reset

A non-persistent station keeps its configured identity across a reset only
when the creation options are re-applied; a persistent one restores it from
its saved configuration without them. This locks the behavior and scopes the
options fix to the non-persistent case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
* refactor(charging-station): re-apply retained options on reset only when non-persistent

Address review feedback on the identity-retention fix:
- Rename the retained-options field to creationOptions (clearer; no longer
  shadows the options parameters).
- Re-apply the creation options on reset/template-reload only for a
  non-persistent station, via the reinitializeOptions getter. A persistent
  station restores from its saved config, which stays the source of truth, so
  re-applying them would needlessly override it (and collapse an OCPP-config
  station's supervision URL). The unconditional setSupervisionUrl mirror is kept,
  with a sharpened comment explaining why it must run for both branches.
- Cover the reset -> initialize wiring (both persistence modes) and the
  setSupervisionUrl-across-reset retention with unit tests.

No change to the fixed behavior: a reset still keeps the configured identity.

* refactor(charging-station): factor setSupervisionUrl credential updates into a helper

Deduplicate the supervisionUser/supervisionPassword null-checks that were
applied twice (to stationInfo and to the retained creation options) into a
single applyCredentials() closure. No behavior change.

* test(charging-station): cover OCPP-config supervision URL retention across reset

- Add a regression test for the non-persistent supervisionUrlOcppConfiguration
  path: after setSupervisionUrl(), a real reset() must still dial the new URL
  (the branch the setSupervisionUrl mirror comment reasons about, previously
  untested).
- Prefix all test titles with "should" per tests/TEST_STYLE_GUIDE.md.
- Clear the real SharedLRUCache singleton in afterEach so a cached template
  cannot bleed between tests.
- Support optional template field overrides in the makeTemplate helper.

* refactor(charging-station): rename reinitialization getter and fix its mirror comment

- Rename the reinitializeOptions getter to reinitializationOptions (noun form,
  consistent with the wsConnectionUrl/hasEvses getters).
- Correct the setSupervisionUrl mirror comment: the retained-options mirror is
  load-bearing on a cache-cold reinitialization (template reload), not on a plain
  warm-cache reset() (whose in-place OCPP-key cache mutation already carries the
  URL). No behavior change.

* test(charging-station): add a genuine OCPP-config supervision-URL reload guard

The previous OCPP-config test drove a warm-cache reset() and passed even with the
retained-options mirror deleted (a false guard): the cached template still carried
the mutated OCPP key. Add a cache-cold reload test (clear SharedLRUCache, then
reinitialize) that re-seeds the key from configuredSupervisionUrl and therefore
fails if the mirror is removed. Relabel the warm-cache reset test and correct its
comment to reflect that it exercises the cache-mutation path, not the mirror.

* docs(charging-station): tighten the setSupervisionUrl mirror comment

Condense the retained-options mirror comment (~11 -> 8 lines) while preserving
every load-bearing detail: it runs for both branches, a cache-cold reload
re-seeds the OCPP key from configuredSupervisionUrl, a warm reset() survives via
the cached template mutation, and the mirror is in-memory only.

---------

Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
src/charging-station/ChargingStation.ts
tests/charging-station/ChargingStation-ResetIdentity.test.ts [new file with mode: 0644]

index 6f3d13e26345dc900a2b5e823d5f9a9f4fc95794..30fc6d1de769512387d4ba02572dbc4e814bd3b2 100644 (file)
@@ -213,6 +213,7 @@ export class ChargingStation extends EventEmitter {
   private configuredSupervisionUrl!: URL
   private readonly connectors: Map<number, ConnectorStatus>
   private connectorsConfigurationHash: string
+  private readonly creationOptions?: ChargingStationOptions
   private readonly evses: Map<number, EvseStatus>
   private evsesConfigurationHash: string
   private flushingMessageBuffer: boolean
@@ -227,6 +228,20 @@ export class ChargingStation extends EventEmitter {
   private wsConnectionRetryCount: number
   private wsPingSetInterval?: NodeJS.Timeout
 
+  /**
+   * Creation options to re-apply when re-initializing on a reset or template
+   * reload. Only a non-persistent station needs them: a persistent one restores
+   * its identity and configuration from its saved file, which stays the source
+   * of truth, so re-applying the creation options would needlessly override it.
+   * @returns The retained creation options when the station is non-persistent,
+   *   `undefined` otherwise.
+   */
+  private get reinitializationOptions (): ChargingStationOptions | undefined {
+    return this.stationInfo?.stationInfoPersistentConfiguration === false
+      ? this.creationOptions
+      : undefined
+  }
+
   constructor (index: number, templateFile: string, options?: ChargingStationOptions) {
     super()
     this.started = false
@@ -250,6 +265,10 @@ export class ChargingStation extends EventEmitter {
     this.connectorsConfigurationHash = ''
     this.evsesConfigurationHash = ''
     this.templateFileHash = ''
+    // Kept so a reset or template reload can re-apply the configured identity
+    // and supervision URL. Cloned so the in-place update in setSupervisionUrl
+    // stays off the shared worker data.
+    this.creationOptions = clone(options)
 
     this.on(ChargingStationEvents.added, () => {
       parentPort?.postMessage(buildAddedMessage(this))
@@ -1074,7 +1093,7 @@ export class ChargingStation extends EventEmitter {
     }
     await sleep(this.stationInfo?.resetTime ?? 0)
     OCPPAuthServiceFactory.clearInstance(this)
-    this.initialize()
+    this.initialize(this.reinitializationOptions)
     this.start()
   }
 
@@ -1108,6 +1127,17 @@ export class ChargingStation extends EventEmitter {
     supervisionUser?: string,
     supervisionPassword?: string
   ): void {
+    const applyCredentials = (target: {
+      supervisionPassword?: string
+      supervisionUser?: string
+    }): void => {
+      if (supervisionUser != null) {
+        target.supervisionUser = supervisionUser
+      }
+      if (supervisionPassword != null) {
+        target.supervisionPassword = supervisionPassword
+      }
+    }
     if (
       this.stationInfo?.supervisionUrlOcppConfiguration === true &&
       isNotEmptyString(this.stationInfo.supervisionUrlOcppKey)
@@ -1118,11 +1148,18 @@ export class ChargingStation extends EventEmitter {
       this.configuredSupervisionUrl = this.getConfiguredSupervisionUrl()
     }
     if (this.stationInfo != null) {
-      if (supervisionUser != null) {
-        this.stationInfo.supervisionUser = supervisionUser
-      }
-      if (supervisionPassword != null) {
-        this.stationInfo.supervisionPassword = supervisionPassword
+      applyCredentials(this.stationInfo)
+      // Mirror the update into the retained creation options so a later reset() or
+      // template reload re-applies this URL, not the creation-time one — for BOTH
+      // branches above. On a cache-cold reinitialization (reload) a non-persistent
+      // station's supervisionUrlOcppKey is absent and re-seeded from
+      // configuredSupervisionUrl (i.e. stationInfo.supervisionUrls, written from
+      // these options), so without the mirror the reload would restore the old URL.
+      // (A warm reset() reuses the cached template whose in-place key mutation
+      // already carries it.) In-memory only: a full restart reverts to the originals.
+      if (this.creationOptions != null) {
+        this.creationOptions.supervisionUrls = url
+        applyCredentials(this.creationOptions)
       }
       this.saveStationInfo()
       this.emitChargingStationEvent(ChargingStationEvents.updated)
@@ -1159,7 +1196,7 @@ export class ChargingStation extends EventEmitter {
                     this.idTagsCache.deleteIdTags(idTagsFile)
                   }
                   OCPPAuthServiceFactory.clearInstance(this)
-                  this.initialize()
+                  this.initialize(this.reinitializationOptions)
                   // Restart the ATG
                   const ATGStarted = this.automaticTransactionGenerator?.started
                   if (ATGStarted === true) {
diff --git a/tests/charging-station/ChargingStation-ResetIdentity.test.ts b/tests/charging-station/ChargingStation-ResetIdentity.test.ts
new file mode 100644 (file)
index 0000000..b6453a6
--- /dev/null
@@ -0,0 +1,247 @@
+/**
+ * @file Tests that a charging station keeps its configured identity across a reset.
+ * @description A reset re-initializes the station. A station with a persisted
+ * configuration restores its identity from the saved config file; a
+ * non-persistent one only keeps it when the creation options are re-applied.
+ */
+import assert from 'node:assert/strict'
+import {
+  copyFileSync,
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readdirSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, it } from 'node:test'
+import { setTimeout as sleep } from 'node:timers/promises'
+
+import type { ChargingStationOptions } from '../../src/types/index.js'
+
+import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import { SharedLRUCache } from '../../src/charging-station/SharedLRUCache.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+
+// The identity logic lives in initialize(); the identity tests call it directly
+// to avoid reset()'s stop/sleep/start (which would dial a socket). A separate
+// test drives reset() with those stubbed to guard the reset -> initialize wiring.
+interface StationInternals {
+  creationOptions?: ChargingStationOptions
+  initialize: (options?: ChargingStationOptions) => void
+}
+const internalsOf = (station: ChargingStation): StationInternals =>
+  station as unknown as StationInternals
+
+const identityOf = (station: ChargingStation): string | undefined =>
+  station.stationInfo?.chargingStationId
+
+const tmpRoots: string[] = []
+
+// Fresh template under its own temp station-templates dir, so each test's
+// persisted config lands in an isolated sibling configurations dir. Optional
+// overrides are merged into the template's top-level fields (e.g. to enable the
+// OCPP-config supervision URL mechanism).
+const makeTemplate = (overrides?: Record<string, boolean | number | string>): string => {
+  const root = mkdtempSync(join(tmpdir(), 'cs-reset-identity-'))
+  tmpRoots.push(root)
+  mkdirSync(join(root, 'station-templates'), { recursive: true })
+  const file = join(root, 'station-templates', 'virtual-simple.station-template.json')
+  const source = join(
+    process.cwd(),
+    'src/assets/station-templates/virtual-simple.station-template.json'
+  )
+  if (overrides == null) {
+    copyFileSync(source, file)
+  } else {
+    const template = JSON.parse(readFileSync(source, 'utf8')) as Record<string, unknown>
+    writeFileSync(file, JSON.stringify({ ...template, ...overrides }, null, 2))
+  }
+  return file
+}
+
+// Config writes are asynchronous, so wait until the identity has been persisted.
+// The configurations dir sits beside the station-templates dir (same derivation
+// the station uses to place its config file).
+const waitForPersistedId = async (
+  templateFile: string,
+  chargingStationId: string
+): Promise<boolean> => {
+  const configurationsDir = dirname(templateFile.replace('station-templates', 'configurations'))
+  for (let attempt = 0; attempt < 100; attempt++) {
+    if (existsSync(configurationsDir)) {
+      for (const file of readdirSync(configurationsDir)) {
+        const { stationInfo } = JSON.parse(readFileSync(join(configurationsDir, file), 'utf8')) as {
+          stationInfo?: { chargingStationId?: string }
+        }
+        if (stationInfo?.chargingStationId === chargingStationId) {
+          return true
+        }
+      }
+    }
+    await sleep(20)
+  }
+  return false
+}
+
+await describe('ChargingStation keeps its identity across a reset', async () => {
+  afterEach(() => {
+    standardCleanup()
+    // The real ChargingStation uses the real SharedLRUCache singleton (not the
+    // mock standardCleanup resets); clear it so a cached template cannot bleed
+    // between tests.
+    SharedLRUCache.getInstance().clear()
+    for (const root of tmpRoots.splice(0)) {
+      rmSync(root, { force: true, recursive: true })
+    }
+  })
+
+  await it('should keep identity only when the creation options are re-applied (non-persistent)', () => {
+    const options: ChargingStationOptions = {
+      autoStart: false,
+      baseName: 'TEST-RESET-ID',
+      fixedName: true,
+      persistentConfiguration: false,
+      supervisionUrls: 'ws://localhost:9999/',
+    }
+    const station = new ChargingStation(1, makeTemplate(), options)
+    assert.strictEqual(identityOf(station), 'TEST-RESET-ID')
+
+    // With no options and no saved config to fall back to, the station reverts
+    // to the template's default identity.
+    internalsOf(station).initialize()
+    assert.notStrictEqual(identityOf(station), 'TEST-RESET-ID')
+
+    // Re-applying the retained creation options restores the configured identity.
+    internalsOf(station).initialize(internalsOf(station).creationOptions)
+    assert.strictEqual(identityOf(station), 'TEST-RESET-ID')
+  })
+
+  await it('should keep identity without re-applying the creation options (persistent)', async () => {
+    const templateFile = makeTemplate()
+    const station = new ChargingStation(1, templateFile, {
+      autoStart: false,
+      baseName: 'TEST-PERSIST-ID',
+      fixedName: true,
+      persistentConfiguration: true,
+      supervisionUrls: 'ws://localhost:9999/',
+    })
+    assert.strictEqual(identityOf(station), 'TEST-PERSIST-ID')
+    assert.ok(
+      await waitForPersistedId(templateFile, 'TEST-PERSIST-ID'),
+      'identity was not persisted'
+    )
+
+    // The saved configuration restores the identity on its own, so only a
+    // non-persistent station needs the retained options.
+    internalsOf(station).initialize()
+    assert.strictEqual(identityOf(station), 'TEST-PERSIST-ID')
+  })
+
+  // reset() forwards the creation options to initialize() only for a
+  // non-persistent station; a persistent one restores from its saved config, so
+  // reset() passes nothing. stop/start are stubbed so reset() neither tears down
+  // nor dials a socket, and the reset delay is zeroed.
+  for (const persistentConfiguration of [false, true]) {
+    await it(`should re-apply the creation options to initialize() only when non-persistent (persistent=${persistentConfiguration.toString()})`, async t => {
+      const station = new ChargingStation(1, makeTemplate(), {
+        autoStart: false,
+        baseName: 'TEST-RESET-WIRING',
+        fixedName: true,
+        persistentConfiguration,
+        supervisionUrls: 'ws://localhost:9999/',
+      })
+      if (station.stationInfo != null) {
+        station.stationInfo.resetTime = 0
+      }
+      const internals = station as unknown as StationInternals & {
+        start: () => void
+        stop: () => Promise<void>
+      }
+      t.mock.method(internals, 'stop', () => Promise.resolve())
+      t.mock.method(internals, 'start', () => undefined)
+      const initializeSpy = t.mock.method(internals, 'initialize', () => undefined)
+
+      await station.reset()
+
+      assert.strictEqual(initializeSpy.mock.calls.length, 1)
+      assert.strictEqual(
+        initializeSpy.mock.calls[0].arguments[0],
+        persistentConfiguration ? undefined : internals.creationOptions
+      )
+    })
+  }
+
+  await it('should keep the setSupervisionUrl URL across a reset via the retained options', () => {
+    const station = new ChargingStation(1, makeTemplate(), {
+      autoStart: false,
+      persistentConfiguration: false,
+      supervisionUrls: 'ws://localhost:9999/',
+    })
+    station.setSupervisionUrl('ws://localhost:8888/')
+
+    // The runtime URL is mirrored into the retained options...
+    assert.strictEqual(
+      internalsOf(station).creationOptions?.supervisionUrls,
+      'ws://localhost:8888/'
+    )
+    // ...so re-applying them on reset keeps it instead of the creation-time URL.
+    internalsOf(station).initialize(internalsOf(station).creationOptions)
+    assert.strictEqual(station.stationInfo?.supervisionUrls, 'ws://localhost:8888/')
+  })
+
+  await it('should keep an OCPP-config supervision URL across a warm-cache reset (non-persistent)', async t => {
+    // supervisionUrlOcppConfiguration routes the URL through an OCPP config key
+    // rather than stationInfo.supervisionUrls, and must be a template field to
+    // survive the reset rebuild.
+    const station = new ChargingStation(
+      1,
+      makeTemplate({
+        supervisionUrlOcppConfiguration: true,
+        supervisionUrlOcppKey: 'ConnectionUrl',
+      }),
+      { autoStart: false, persistentConfiguration: false, supervisionUrls: 'ws://localhost:9999/' }
+    )
+    station.setSupervisionUrl('ws://localhost:7777/')
+    if (station.stationInfo != null) {
+      station.stationInfo.resetTime = 0
+    }
+    // Stub stop/start so the real reset() re-initializes without tearing down or
+    // dialing a socket.
+    const internals = station as unknown as { start: () => void; stop: () => Promise<void> }
+    t.mock.method(internals, 'stop', () => Promise.resolve())
+    t.mock.method(internals, 'start', () => undefined)
+
+    await station.reset()
+
+    // A plain reset() reuses the warm template cache, whose in-place OCPP-key
+    // mutation still carries the URL; the retained-options mirror is not exercised
+    // on this path (see the cache-cold reload test below).
+    assert.strictEqual(station.wsConnectionUrl.host, 'localhost:7777')
+  })
+
+  await it('should re-seed an OCPP-config supervision URL from the retained options on template reload (non-persistent)', () => {
+    const station = new ChargingStation(
+      1,
+      makeTemplate({
+        supervisionUrlOcppConfiguration: true,
+        supervisionUrlOcppKey: 'ConnectionUrl',
+      }),
+      { autoStart: false, persistentConfiguration: false, supervisionUrls: 'ws://localhost:9999/' }
+    )
+    station.setSupervisionUrl('ws://localhost:7777/')
+
+    // Invalidate the cached template exactly as the template-reload path does (the
+    // watcher calls deleteChargingStationTemplate before initialize), forcing the
+    // OCPP key absent on reinitialization so it is re-seeded from configuredSupervisionUrl.
+    // This is the only path where the retained-options mirror is load-bearing: a
+    // warm-cache reset passes via the cached mutation and would not guard it.
+    SharedLRUCache.getInstance().clear()
+    internalsOf(station).initialize(internalsOf(station).creationOptions)
+
+    assert.strictEqual(station.wsConnectionUrl.host, 'localhost:7777')
+  })
+})