}
private readonly workerEventAdded = (data: ChargingStationData): void => {
- if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data)) {
+ const outcome = this.uiServer.setChargingStationData(data.stationInfo.hashId, data)
+ if (outcome === 'collision') {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.workerEventAdded: Rejected charging station ${
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ data.stationInfo.chargingStationId
+ } (hashId: ${data.stationInfo.hashId}, templateName: ${data.stationInfo.templateName}, templateIndex: ${data.stationInfo.templateIndex.toString()}): a different station identity already maps to this hashId; the added station is orphaned from the UI registry`
+ )
+ return
+ }
+ if (outcome === 'set') {
this.uiServer.scheduleClientNotification()
}
logger.info(
}
private readonly workerEventStarted = (data: ChargingStationData): void => {
- if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data)) {
+ if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data) === 'set') {
this.uiServer.scheduleClientNotification()
}
const templateStatistics = this.templateStatistics.get(data.stationInfo.templateName)
}
private readonly workerEventStopped = (data: ChargingStationData): void => {
- if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data)) {
+ if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data) === 'set') {
this.uiServer.scheduleClientNotification()
}
const templateStatistics = this.templateStatistics.get(data.stationInfo.templateName)
}
private readonly workerEventUpdated = (data: ChargingStationData): void => {
- if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data)) {
+ if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data) === 'set') {
this.uiServer.scheduleClientNotification()
}
}
} from './UIServerSecurity.js'
import { getUsernameAndPasswordFromAuthorizationToken, HttpMethod } from './UIServerUtils.js'
+/**
+ * Outcome of {@link AbstractUIServer.setChargingStationData}:
+ * - `set`: stored (new `hashId`, or a newer/equal-timestamp update of the same
+ * physical station identity, including a legitimate restart re-emit).
+ * - `stale`: an older-timestamp update of the same physical station identity;
+ * dropped.
+ * - `collision`: a different physical station identity — a distinct
+ * `(templateName, templateIndex)` pair — already maps to this deterministic
+ * `hashId`; rejected without overwriting so the registered station is not
+ * silently shadowed (the caller surfaces the rejected twin).
+ */
+export type SetChargingStationDataOutcome = 'collision' | 'set' | 'stale'
+
/**
* Outcome of {@link AbstractUIServer.runRequestPrologue}.
*
*/
public abstract sendResponse (response: ProtocolResponse): void
- public setChargingStationData (hashId: string, data: ChargingStationData): boolean {
+ public setChargingStationData (
+ hashId: string,
+ data: ChargingStationData
+ ): SetChargingStationDataOutcome {
const cachedData = this.chargingStations.get(hashId)
+ if (
+ cachedData != null &&
+ (cachedData.stationInfo.templateName !== data.stationInfo.templateName ||
+ cachedData.stationInfo.templateIndex !== data.stationInfo.templateIndex)
+ ) {
+ return 'collision'
+ }
if (cachedData == null || data.timestamp >= cachedData.timestamp) {
this.chargingStations.set(hashId, data)
- return true
+ return 'set'
}
- return false
+ return 'stale'
}
public setChargingStationTemplates (templates: string[] | undefined): void {
import type {
BroadcastChannelResponse,
BroadcastChannelResponsePayload,
+ ChargingStationData,
UUIDv4,
} from '../../../../src/types/index.js'
channel.onmessage({ data: [uuid, responsePayload] })
}
+/**
+ * Build charging station data with an explicit `templateIndex` under `hashId`,
+ * to exercise identity-collision dedup deterministically.
+ * @param hashId - Deterministic content hash shared by colliding twins.
+ * @param templateIndex - Stable per-instance discriminator within a template.
+ * @param timestamp - Registry merge timestamp in milliseconds.
+ * @param templateName - Owning template name; differs for identity-clone template files.
+ * @returns Charging station data for the described identity.
+ */
+const createStationDataWithIndex = (
+ hashId: string,
+ templateIndex: number,
+ timestamp: number,
+ templateName = 'collision-template'
+): ChargingStationData =>
+ createMockChargingStationData(hashId, {
+ stationInfo: {
+ baseName: 'collision-base',
+ chargePointModel: 'TestModel',
+ chargePointVendor: 'TestVendor',
+ chargingStationId: hashId,
+ hashId,
+ templateIndex,
+ templateName,
+ },
+ timestamp,
+ })
+
await describe('AbstractUIService', async () => {
afterEach(() => {
standardCleanup()
service.stop()
}
})
+
+ await describe('AbstractUIServer identity-collision dedup (issue #2026)', async () => {
+ await it('should reject a twin whose hashId collides with a different templateIndex', () => {
+ const server = new TestableUIWebSocketServer(createMockUIServerConfiguration())
+ const hashId = 'collision-hash'
+ assert.strictEqual(
+ server.setChargingStationData(hashId, createStationDataWithIndex(hashId, 1, 1000)),
+ 'set'
+ )
+ assert.strictEqual(
+ server.setChargingStationData(hashId, createStationDataWithIndex(hashId, 2, 2000)),
+ 'collision'
+ )
+ assert.strictEqual(server.getChargingStationData(hashId)?.stationInfo.templateIndex, 1)
+ assert.strictEqual(server.getChargingStationsCount(), 1)
+ })
+
+ await it('should reject an identity-clone twin sharing hashId and templateIndex but a different templateName', () => {
+ const server = new TestableUIWebSocketServer(createMockUIServerConfiguration())
+ const hashId = 'clone-file-hash'
+ assert.strictEqual(
+ server.setChargingStationData(
+ hashId,
+ createStationDataWithIndex(hashId, 1, 1000, 'template-a')
+ ),
+ 'set'
+ )
+ assert.strictEqual(
+ server.setChargingStationData(
+ hashId,
+ createStationDataWithIndex(hashId, 1, 2000, 'template-b')
+ ),
+ 'collision'
+ )
+ assert.strictEqual(
+ server.getChargingStationData(hashId)?.stationInfo.templateName,
+ 'template-a'
+ )
+ assert.strictEqual(server.getChargingStationsCount(), 1)
+ })
+
+ await it('should still update the registry for a restart re-emit with a newer timestamp', () => {
+ const server = new TestableUIWebSocketServer(createMockUIServerConfiguration())
+ const hashId = 'restart-hash'
+ assert.strictEqual(
+ server.setChargingStationData(hashId, createStationDataWithIndex(hashId, 1, 1000)),
+ 'set'
+ )
+ assert.strictEqual(
+ server.setChargingStationData(hashId, createStationDataWithIndex(hashId, 1, 2000)),
+ 'set'
+ )
+ assert.strictEqual(server.getChargingStationData(hashId)?.timestamp, 2000)
+ })
+
+ await it('should drop a stale same-identity re-emit with an older timestamp', () => {
+ const server = new TestableUIWebSocketServer(createMockUIServerConfiguration())
+ const hashId = 'stale-hash'
+ server.setChargingStationData(hashId, createStationDataWithIndex(hashId, 1, 2000))
+ assert.strictEqual(
+ server.setChargingStationData(hashId, createStationDataWithIndex(hashId, 1, 1000)),
+ 'stale'
+ )
+ assert.strictEqual(server.getChargingStationData(hashId)?.timestamp, 2000)
+ })
+
+ await it('should not report false success for a targeted broadcast after a twin collision', async () => {
+ const server = new TestableUIWebSocketServer(createMockUIServerConfiguration())
+ server.testRegisterProtocolVersionUIService(ProtocolVersion['0.0.1'])
+ const service = server.getUIService(ProtocolVersion['0.0.1'])
+ if (service == null) {
+ assert.fail('Expected UI service to be registered')
+ }
+ const hashId = 'broadcast-collision-hash'
+ server.setChargingStationData(hashId, createStationDataWithIndex(hashId, 1, 1000))
+ server.setChargingStationData(hashId, createStationDataWithIndex(hashId, 2, 2000))
+ try {
+ await registerTransportStopRequestFor(service, TEST_UUID, [hashId])
+ assert.strictEqual(service.getBroadcastChannelOutstandingResponseCount(TEST_UUID), 1)
+ emitWorkerResponse(service, { hashId, status: ResponseStatus.SUCCESS }, TEST_UUID)
+ assert.strictEqual(service.getBroadcastChannelOutstandingResponseCount(TEST_UUID), 0)
+ assert.strictEqual(server.getChargingStationsCount(), 1)
+ assert.strictEqual(server.getChargingStationData(hashId)?.stationInfo.templateIndex, 1)
+ } finally {
+ service.stop()
+ }
+ })
+ })
})