]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat: add server-side refresh notification over WebSocket
authorJérôme Benoit <jerome.benoit@sap.com>
Wed, 25 Mar 2026 22:15:51 +0000 (23:15 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Wed, 25 Mar 2026 22:15:51 +0000 (23:15 +0100)
Bootstrap calls scheduleClientNotification() after each cache mutation.
A 500ms debounce collapses rapid updates into a single broadcast of
ProtocolNotification [ServerNotification.REFRESH] to all connected WS
clients. setChargingStationData/deleteChargingStationData return boolean
so callers only notify on actual change. Frontend responseHandler uses
isServerNotification/isProtocolResponse guards to dispatch messages.

src/charging-station/Bootstrap.ts
src/charging-station/ui-server/AbstractUIServer.ts
src/charging-station/ui-server/UIWebSocketServer.ts
src/types/UIProtocol.ts
src/types/index.ts
ui/web/src/composables/UIClient.ts
ui/web/src/types/UIProtocol.ts
ui/web/src/types/index.ts
ui/web/src/views/ChargingStationsView.vue
ui/web/tests/unit/UIClient.test.ts
ui/web/tests/unit/helpers.ts

index abc37bc0d6e0d9e2bc88f89fdf6b7f765898992a..e232f2915565dd2c2d425476f4a729b3dda3d441 100644 (file)
@@ -594,7 +594,9 @@ export class Bootstrap extends EventEmitter {
   }
 
   private readonly workerEventAdded = (data: ChargingStationData): void => {
-    this.uiServer.setChargingStationData(data.stationInfo.hashId, data)
+    if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data)) {
+      this.uiServer.scheduleClientNotification()
+    }
     logger.info(
       `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
         // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
@@ -604,7 +606,9 @@ export class Bootstrap extends EventEmitter {
   }
 
   private readonly workerEventDeleted = (data: ChargingStationData): void => {
-    this.uiServer.deleteChargingStationData(data.stationInfo.hashId)
+    if (this.uiServer.deleteChargingStationData(data.stationInfo.hashId)) {
+      this.uiServer.scheduleClientNotification()
+    }
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const templateStatistics = this.templateStatistics.get(data.stationInfo.templateName)!
     --templateStatistics.added
@@ -635,7 +639,9 @@ export class Bootstrap extends EventEmitter {
   }
 
   private readonly workerEventStarted = (data: ChargingStationData): void => {
-    this.uiServer.setChargingStationData(data.stationInfo.hashId, data)
+    if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data)) {
+      this.uiServer.scheduleClientNotification()
+    }
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     ++this.templateStatistics.get(data.stationInfo.templateName)!.started
     logger.info(
@@ -647,7 +653,9 @@ export class Bootstrap extends EventEmitter {
   }
 
   private readonly workerEventStopped = (data: ChargingStationData): void => {
-    this.uiServer.setChargingStationData(data.stationInfo.hashId, data)
+    if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data)) {
+      this.uiServer.scheduleClientNotification()
+    }
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     --this.templateStatistics.get(data.stationInfo.templateName)!.started
     logger.info(
@@ -659,6 +667,8 @@ export class Bootstrap extends EventEmitter {
   }
 
   private readonly workerEventUpdated = (data: ChargingStationData): void => {
-    this.uiServer.setChargingStationData(data.stationInfo.hashId, data)
+    if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data)) {
+      this.uiServer.scheduleClientNotification()
+    }
   }
 }
index 3505f65f600e92cda38621f11dc69878c65d68b5..bfc8d03ae216d4511d0dc821866047d3bf580258 100644 (file)
@@ -37,6 +37,7 @@ export abstract class AbstractUIServer {
 
   private readonly chargingStations: Map<string, ChargingStationData>
   private readonly chargingStationTemplates: Set<string>
+  private clientNotificationDebounceTimer: ReturnType<typeof setTimeout> | undefined
 
   public constructor (protected readonly uiServerConfiguration: UIServerConfiguration) {
     this.chargingStations = new Map<string, ChargingStationData>()
@@ -117,6 +118,16 @@ export abstract class AbstractUIServer {
     return logPrefix(logMsg)
   }
 
+  public scheduleClientNotification (): void {
+    if (this.clientNotificationDebounceTimer != null) {
+      clearTimeout(this.clientNotificationDebounceTimer)
+    }
+    this.clientNotificationDebounceTimer = setTimeout(() => {
+      this.notifyClients()
+      this.clientNotificationDebounceTimer = undefined
+    }, 500)
+  }
+
   public async sendInternalRequest (request: ProtocolRequest): Promise<ProtocolResponse> {
     const protocolVersion = ProtocolVersion['0.0.1']
     this.registerProtocolVersionUIService(protocolVersion)
@@ -129,11 +140,13 @@ export abstract class AbstractUIServer {
 
   public abstract sendResponse (response: ProtocolResponse): void
 
-  public setChargingStationData (hashId: string, data: ChargingStationData): void {
+  public setChargingStationData (hashId: string, data: ChargingStationData): boolean {
     const cachedData = this.chargingStations.get(hashId)
     if (cachedData == null || data.timestamp >= cachedData.timestamp) {
       this.chargingStations.set(hashId, data)
+      return true
     }
+    return false
   }
 
   public setChargingStationTemplates (templates: string[] | undefined): void {
@@ -148,6 +161,7 @@ export abstract class AbstractUIServer {
   public abstract start (): void
 
   public stop (): void {
+    clearTimeout(this.clientNotificationDebounceTimer)
     this.stopHttpServer()
     for (const uiService of this.uiServices.values()) {
       uiService.stop()
@@ -171,6 +185,10 @@ export abstract class AbstractUIServer {
     next(ok ? undefined : new BaseError('Unauthorized'))
   }
 
+  protected notifyClients (): void {
+    // No-op by default — subclasses with push capability override this
+  }
+
   protected registerProtocolVersionUIService (version: ProtocolVersion): void {
     if (!this.uiServices.has(version)) {
       this.uiServices.set(version, UIServiceFactory.getUIServiceImplementation(version, this))
index 332d27ff6b6a661a3c3771788d4bbd94859e1773..ee85a2e3484b823632727192463bf1981cdfbeca 100644 (file)
@@ -6,8 +6,10 @@ import { type RawData, WebSocket, WebSocketServer } from 'ws'
 
 import {
   MapStringifyFormat,
+  type ProtocolNotification,
   type ProtocolRequest,
   type ProtocolResponse,
+  ServerNotification,
   type UIServerConfiguration,
   WebSocketCloseEventStatusCode,
 } from '../../types/index.js'
@@ -198,6 +200,11 @@ export class UIWebSocketServer extends AbstractUIServer {
     this.startHttpServer()
   }
 
+  protected override notifyClients (): void {
+    const notification: ProtocolNotification = [ServerNotification.REFRESH]
+    this.broadcastToClients(JSON.stringify(notification))
+  }
+
   private broadcastToClients (message: string): void {
     for (const client of this.webSocketServer.clients) {
       if (client.readyState === WebSocket.OPEN) {
index 90f6725ee47f11dc34a559fafcb62a64656da740..29f41cdb7d3d32a04d499c29f154c59c69bdfb3f 100644 (file)
@@ -64,6 +64,12 @@ export enum ResponseStatus {
   SUCCESS = 'success',
 }
 
+export enum ServerNotification {
+  REFRESH = 'refresh',
+}
+
+export type ProtocolNotification = [ServerNotification]
+
 export type ProtocolRequest = [UUIDv4, ProcedureName, RequestPayload]
 
 export type ProtocolRequestHandler = (
index b8e51065603bffe53f432a59521f1a6e105757e2..2fb0f64e63b232e56f189a4d7193ffa631381394 100644 (file)
@@ -430,6 +430,7 @@ export {
   AuthenticationType,
   ProcedureName,
   Protocol,
+  type ProtocolNotification,
   type ProtocolRequest,
   type ProtocolRequestHandler,
   type ProtocolResponse,
@@ -437,6 +438,7 @@ export {
   type RequestPayload,
   type ResponsePayload,
   ResponseStatus,
+  ServerNotification,
 } from './UIProtocol.js'
 export type { UUIDv4 } from './UUID.js'
 export {
index eabf49f0e1a7b2a709e58534463a66bdfe86d904..1bb98ec7d4856c99df7297e2a5042bb04c557890 100644 (file)
@@ -13,6 +13,7 @@ import {
   type RequestPayload,
   type ResponsePayload,
   ResponseStatus,
+  ServerNotification,
   type UIServerConfigurationSection,
   type UUIDv4,
 } from '@/types'
@@ -28,13 +29,14 @@ interface ResponseHandler {
 
 export class UIClient {
   private static instance: null | UIClient = null
+  private readonly refreshListeners: Set<() => void>
   private responseHandlers: Map<UUIDv4, ResponseHandler>
-
   private ws?: WebSocket
 
   private constructor (private uiServerConfiguration: UIServerConfigurationSection) {
     this.openWS()
     this.responseHandlers = new Map<UUIDv4, ResponseHandler>()
+    this.refreshListeners = new Set()
   }
 
   public static getInstance (uiServerConfiguration?: UIServerConfigurationSection): UIClient {
@@ -97,6 +99,13 @@ export class UIClient {
     })
   }
 
+  public onRefresh (listener: () => void): () => void {
+    this.refreshListeners.add(listener)
+    return () => {
+      this.refreshListeners.delete(listener)
+    }
+  }
+
   public async openConnection (hashId: string): Promise<ResponsePayload> {
     return this.sendRequest(ProcedureName.OPEN_CONNECTION, {
       hashIds: [hashId],
@@ -282,51 +291,65 @@ export class UIClient {
   }
 
   private responseHandler (messageEvent: MessageEvent<string>): void {
-    let response: ProtocolResponse
+    let message: unknown
     try {
-      response = JSON.parse(messageEvent.data) as ProtocolResponse
+      message = JSON.parse(messageEvent.data)
     } catch (error) {
       useToast().error('Invalid response JSON format')
       console.error('Invalid response JSON format', error)
       return
     }
 
-    if (!Array.isArray(response)) {
+    if (!Array.isArray(message)) {
       useToast().error('Response not an array')
-      console.error('Response not an array:', response)
+      console.error('Response not an array:', message)
       return
     }
 
-    const [uuid, responsePayload] = response
+    const isServerNotification =
+      message.length === 1 &&
+      Object.values(ServerNotification).includes(message[0] as ServerNotification)
 
-    if (!validateUUID(uuid)) {
-      useToast().error('Response UUID field is invalid')
-      console.error('Response UUID field is invalid:', response)
+    if (isServerNotification) {
+      if (message[0] === ServerNotification.REFRESH) {
+        for (const listener of this.refreshListeners) {
+          listener()
+        }
+      }
       return
     }
 
-    if (this.responseHandlers.has(uuid)) {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      const { procedureName, reject, resolve } = this.responseHandlers.get(uuid)!
-      switch (responsePayload.status) {
-        case ResponseStatus.FAILURE:
-          reject(responsePayload)
-          break
-        case ResponseStatus.SUCCESS:
-          resolve(responsePayload)
-          break
-        default:
-          reject(
-            new Error(
-              // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-              `Response status for procedure '${procedureName}' not supported: '${responsePayload.status}'`
+    const isProtocolResponse = message.length === 2 && validateUUID(message[0] as string)
+
+    if (isProtocolResponse) {
+      const [uuid, responsePayload] = message as ProtocolResponse
+      if (this.responseHandlers.has(uuid)) {
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        const { procedureName, reject, resolve } = this.responseHandlers.get(uuid)!
+        switch (responsePayload.status) {
+          case ResponseStatus.FAILURE:
+            reject(responsePayload)
+            break
+          case ResponseStatus.SUCCESS:
+            resolve(responsePayload)
+            break
+          default:
+            reject(
+              new Error(
+                // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+                `Response status for procedure '${procedureName}' not supported: '${responsePayload.status}'`
+              )
             )
-          )
+        }
+        this.responseHandlers.delete(uuid)
+      } else {
+        throw new Error(`Not a response to a request: ${JSON.stringify(message, undefined, 2)}`)
       }
-      this.responseHandlers.delete(uuid)
-    } else {
-      throw new Error(`Not a response to a request: ${JSON.stringify(response, undefined, 2)}`)
+      return
     }
+
+    useToast().error('Unknown message format')
+    console.error('Unknown message format:', message)
   }
 
   private async sendRequest (
index 539d436ecbd83c24883545da7ad47d8b734743e6..a1eb1128c2019b2795478ff66751740baf2bd887 100644 (file)
@@ -53,6 +53,12 @@ export enum ResponseStatus {
   SUCCESS = 'success',
 }
 
+export enum ServerNotification {
+  REFRESH = 'refresh',
+}
+
+export type ProtocolNotification = [ServerNotification]
+
 export type ProtocolRequest = [UUIDv4, ProcedureName, RequestPayload]
 
 export type ProtocolRequestHandler = (
index e2008a0674e370de27ad075725d34ce5eaed01ac..a7861c6b2fabb15cd1ccc3d667e9de79b794c97e 100644 (file)
@@ -24,11 +24,13 @@ export {
   AuthenticationType,
   ProcedureName,
   Protocol,
+  type ProtocolNotification,
   type ProtocolResponse,
   ProtocolVersion,
   type RequestPayload,
   type ResponsePayload,
   ResponseStatus,
+  ServerNotification,
   type SimulatorState,
 } from './UIProtocol'
 export type { UUIDv4 } from './UUID'
index 86e0e996420de126588a9ad1c9056b131e623a15..d6860fdd2766f11393699c5895a94fc2789ff24d 100644 (file)
@@ -284,12 +284,18 @@ const unregisterWSEventListeners = () => {
   uiClient.unregisterWSEventListener('close', clearChargingStations)
 }
 
+let unsubscribeRefresh: (() => void) | undefined
+
 onMounted(() => {
   registerWSEventListeners()
+  unsubscribeRefresh = uiClient.onRefresh(() => {
+    getChargingStations()
+  })
 })
 
 onUnmounted(() => {
   unregisterWSEventListeners()
+  unsubscribeRefresh?.()
 })
 
 const uiServerConfigurations: {
index 5c80ec4096f091ae8106875f45a07808100a4b27..8779702182b67d1164b012de4d2f681a664a24a7 100644 (file)
@@ -12,6 +12,7 @@ import {
   OCPPVersion,
   ProcedureName,
   ResponseStatus,
+  ServerNotification,
 } from '@/types'
 
 import { toastMock } from '../setup'
@@ -210,6 +211,7 @@ describe('UIClient', () => {
 
       ws.onmessage?.({ data: 'not json' } as MessageEvent<string>)
 
+      expect(toastMock.error).toHaveBeenCalledWith('Invalid response JSON format')
       expect(consoleSpy).toHaveBeenCalledWith(
         'Invalid response JSON format',
         expect.any(SyntaxError)
@@ -224,6 +226,7 @@ describe('UIClient', () => {
 
       ws.simulateMessage({ notAnArray: true })
 
+      expect(toastMock.error).toHaveBeenCalledWith('Response not an array')
       expect(consoleSpy).toHaveBeenCalledWith(
         'Response not an array:',
         expect.objectContaining({ notAnArray: true })
@@ -241,12 +244,40 @@ describe('UIClient', () => {
       }).toThrow('Not a response to a request')
     })
 
-    it('should handle response with invalid UUID', () => {
+    it('should show error toast on response with invalid UUID', () => {
+      const consoleSpy = vi.spyOn(console, 'error')
       const client = UIClient.getInstance(createUIServerConfig())
       // @ts-expect-error — accessing private property for testing
       const ws = client.ws as MockWebSocket
       ws.simulateMessage(['not-a-valid-uuid', { status: ResponseStatus.SUCCESS }])
-      // Should not throw — just logs error via toast
+      expect(toastMock.error).toHaveBeenCalledWith('Unknown message format')
+      expect(consoleSpy).toHaveBeenCalledWith(
+        'Unknown message format:',
+        expect.arrayContaining(['not-a-valid-uuid'])
+      )
+    })
+  })
+
+  describe('server notifications', () => {
+    it('should invoke refresh listeners on server notification', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      const listener = vi.fn()
+      client.onRefresh(listener)
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      ws.simulateMessage([ServerNotification.REFRESH])
+      expect(listener).toHaveBeenCalledOnce()
+    })
+
+    it('should not invoke refresh listeners after unsubscribe', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      const listener = vi.fn()
+      const unsubscribe = client.onRefresh(listener)
+      unsubscribe()
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      ws.simulateMessage([ServerNotification.REFRESH])
+      expect(listener).not.toHaveBeenCalled()
     })
   })
 
index fff69fb3142597f24e8a7cd0f6b2bf6caa0e3e05..2e9e4b46bdf82522b4b712c0575e1cb55ed46db7 100644 (file)
@@ -20,6 +20,7 @@ export interface MockUIClient {
   listChargingStations: ReturnType<typeof vi.fn>
   listTemplates: ReturnType<typeof vi.fn>
   lockConnector: ReturnType<typeof vi.fn>
+  onRefresh: ReturnType<typeof vi.fn>
   openConnection: ReturnType<typeof vi.fn>
   registerWSEventListener: ReturnType<typeof vi.fn>
   setConfiguration: ReturnType<typeof vi.fn>
@@ -127,6 +128,8 @@ export function createMockUIClient (): MockUIClient {
     listChargingStations: vi.fn().mockResolvedValue({ ...successResponse, chargingStations: [] }),
     listTemplates: vi.fn().mockResolvedValue({ ...successResponse, templates: [] }),
     lockConnector: vi.fn().mockResolvedValue(successResponse),
+    // eslint-disable-next-line @typescript-eslint/no-empty-function
+    onRefresh: vi.fn().mockReturnValue(() => {}),
     openConnection: vi.fn().mockResolvedValue(successResponse),
     registerWSEventListener: vi.fn(),
     setConfiguration: vi.fn(),