From 723aab3ddb9ca84480d7adc5b726e8eb73503df2 Mon Sep 17 00:00:00 2001 From: Daniel <7558512+DerGenaue@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:10:37 +0200 Subject: [PATCH] fix(charging-station): retain creation options so a reset keeps station identity (#2019) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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) 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) 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) 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) Co-authored-by: Jérôme Benoit --- src/charging-station/ChargingStation.ts | 51 +++- .../ChargingStation-ResetIdentity.test.ts | 247 ++++++++++++++++++ 2 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 tests/charging-station/ChargingStation-ResetIdentity.test.ts diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 6f3d13e2..30fc6d1d 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -213,6 +213,7 @@ export class ChargingStation extends EventEmitter { private configuredSupervisionUrl!: URL private readonly connectors: Map private connectorsConfigurationHash: string + private readonly creationOptions?: ChargingStationOptions private readonly evses: Map 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 index 00000000..b6453a6a --- /dev/null +++ b/tests/charging-station/ChargingStation-ResetIdentity.test.ts @@ -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 => { + 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 + 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 => { + 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 + } + 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 } + 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') + }) +}) -- 2.53.0