From: Jérôme Benoit Date: Wed, 25 Mar 2026 22:15:51 +0000 (+0100) Subject: feat: add server-side refresh notification over WebSocket X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=a2edfbbcc36e126737531c805ae4b4b58ef67f0f;p=e-mobility-charging-stations-simulator.git feat: add server-side refresh notification over WebSocket 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. --- diff --git a/src/charging-station/Bootstrap.ts b/src/charging-station/Bootstrap.ts index abc37bc0..e232f291 100644 --- a/src/charging-station/Bootstrap.ts +++ b/src/charging-station/Bootstrap.ts @@ -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() + } } } diff --git a/src/charging-station/ui-server/AbstractUIServer.ts b/src/charging-station/ui-server/AbstractUIServer.ts index 3505f65f..bfc8d03a 100644 --- a/src/charging-station/ui-server/AbstractUIServer.ts +++ b/src/charging-station/ui-server/AbstractUIServer.ts @@ -37,6 +37,7 @@ export abstract class AbstractUIServer { private readonly chargingStations: Map private readonly chargingStationTemplates: Set + private clientNotificationDebounceTimer: ReturnType | undefined public constructor (protected readonly uiServerConfiguration: UIServerConfiguration) { this.chargingStations = new Map() @@ -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 { 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)) diff --git a/src/charging-station/ui-server/UIWebSocketServer.ts b/src/charging-station/ui-server/UIWebSocketServer.ts index 332d27ff..ee85a2e3 100644 --- a/src/charging-station/ui-server/UIWebSocketServer.ts +++ b/src/charging-station/ui-server/UIWebSocketServer.ts @@ -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) { diff --git a/src/types/UIProtocol.ts b/src/types/UIProtocol.ts index 90f6725e..29f41cdb 100644 --- a/src/types/UIProtocol.ts +++ b/src/types/UIProtocol.ts @@ -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 = ( diff --git a/src/types/index.ts b/src/types/index.ts index b8e51065..2fb0f64e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 { diff --git a/ui/web/src/composables/UIClient.ts b/ui/web/src/composables/UIClient.ts index eabf49f0..1bb98ec7 100644 --- a/ui/web/src/composables/UIClient.ts +++ b/ui/web/src/composables/UIClient.ts @@ -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 - private ws?: WebSocket private constructor (private uiServerConfiguration: UIServerConfigurationSection) { this.openWS() this.responseHandlers = new Map() + 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 { return this.sendRequest(ProcedureName.OPEN_CONNECTION, { hashIds: [hashId], @@ -282,51 +291,65 @@ export class UIClient { } private responseHandler (messageEvent: MessageEvent): 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 ( diff --git a/ui/web/src/types/UIProtocol.ts b/ui/web/src/types/UIProtocol.ts index 539d436e..a1eb1128 100644 --- a/ui/web/src/types/UIProtocol.ts +++ b/ui/web/src/types/UIProtocol.ts @@ -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 = ( diff --git a/ui/web/src/types/index.ts b/ui/web/src/types/index.ts index e2008a06..a7861c6b 100644 --- a/ui/web/src/types/index.ts +++ b/ui/web/src/types/index.ts @@ -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' diff --git a/ui/web/src/views/ChargingStationsView.vue b/ui/web/src/views/ChargingStationsView.vue index 86e0e996..d6860fdd 100644 --- a/ui/web/src/views/ChargingStationsView.vue +++ b/ui/web/src/views/ChargingStationsView.vue @@ -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: { diff --git a/ui/web/tests/unit/UIClient.test.ts b/ui/web/tests/unit/UIClient.test.ts index 5c80ec40..87797021 100644 --- a/ui/web/tests/unit/UIClient.test.ts +++ b/ui/web/tests/unit/UIClient.test.ts @@ -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) + 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() }) }) diff --git a/ui/web/tests/unit/helpers.ts b/ui/web/tests/unit/helpers.ts index fff69fb3..2e9e4b46 100644 --- a/ui/web/tests/unit/helpers.ts +++ b/ui/web/tests/unit/helpers.ts @@ -20,6 +20,7 @@ export interface MockUIClient { listChargingStations: ReturnType listTemplates: ReturnType lockConnector: ReturnType + onRefresh: ReturnType openConnection: ReturnType registerWSEventListener: ReturnType setConfiguration: ReturnType @@ -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(),